From 06240c73b3b2a4fbb00409ff41d87fd5bc33932f Mon Sep 17 00:00:00 2001 From: Igor Lins e Silva <4753812+igorls@users.noreply.github.com> Date: Tue, 14 Apr 2026 11:32:44 -0300 Subject: [PATCH] ci: add version guard to catch tag/manifest drift Fails a tag push if `vX.Y.Z` does not match `mempalace/version.py` (the single source of truth per CLAUDE.md), and fails PRs that touch any version file without keeping all five in sync (pyproject.toml, version.py, .claude-plugin/marketplace.json, .claude-plugin/plugin.json, .codex-plugin/plugin.json). Prevents the class of bug described in #874, where v3.1.0/v3.2.0/v3.3.0 tags all landed pointing at commits that still carried manifest version 3.0.14, blocking `/plugin update` for end users. Refs #874 --- .github/workflows/version-guard.yml | 85 +++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 .github/workflows/version-guard.yml diff --git a/.github/workflows/version-guard.yml b/.github/workflows/version-guard.yml new file mode 100644 index 0000000..ea9e730 --- /dev/null +++ b/.github/workflows/version-guard.yml @@ -0,0 +1,85 @@ +name: Version Guard + +on: + push: + tags: ['v*'] + pull_request: + paths: + - 'pyproject.toml' + - 'mempalace/version.py' + - '.claude-plugin/marketplace.json' + - '.claude-plugin/plugin.json' + - '.codex-plugin/plugin.json' + - '.github/workflows/version-guard.yml' + +jobs: + check-versions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Extract versions from all sources + id: versions + run: | + set -euo pipefail + py_version=$(grep -E '^__version__' mempalace/version.py | cut -d'"' -f2) + pyproject_version=$(grep -E '^version' pyproject.toml | head -1 | cut -d'"' -f2) + marketplace_version=$(jq -r '.plugins[0].version' .claude-plugin/marketplace.json) + plugin_version=$(jq -r '.version' .claude-plugin/plugin.json) + codex_version=$(jq -r '.version' .codex-plugin/plugin.json) + + echo "py_version=$py_version" >> "$GITHUB_OUTPUT" + echo "pyproject_version=$pyproject_version" >> "$GITHUB_OUTPUT" + echo "marketplace_version=$marketplace_version" >> "$GITHUB_OUTPUT" + echo "plugin_version=$plugin_version" >> "$GITHUB_OUTPUT" + echo "codex_version=$codex_version" >> "$GITHUB_OUTPUT" + + { + echo "## Detected versions" + echo "" + echo "| Source | Version |" + echo "| --- | --- |" + echo "| mempalace/version.py | \`$py_version\` |" + echo "| pyproject.toml | \`$pyproject_version\` |" + echo "| .claude-plugin/marketplace.json | \`$marketplace_version\` |" + echo "| .claude-plugin/plugin.json | \`$plugin_version\` |" + echo "| .codex-plugin/plugin.json | \`$codex_version\` |" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Verify all sources agree + env: + PY: ${{ steps.versions.outputs.py_version }} + PYPROJECT: ${{ steps.versions.outputs.pyproject_version }} + MARKETPLACE: ${{ steps.versions.outputs.marketplace_version }} + PLUGIN: ${{ steps.versions.outputs.plugin_version }} + CODEX: ${{ steps.versions.outputs.codex_version }} + run: | + set -euo pipefail + fail=0 + check() { + local name="$1" value="$2" expected="$3" + if [[ "$value" != "$expected" ]]; then + echo "::error file=$name::version mismatch — expected $expected, got $value" + fail=1 + fi + } + # All five must agree with each other (use version.py as the reference, per CLAUDE.md) + check "pyproject.toml" "$PYPROJECT" "$PY" + check ".claude-plugin/marketplace.json" "$MARKETPLACE" "$PY" + check ".claude-plugin/plugin.json" "$PLUGIN" "$PY" + check ".codex-plugin/plugin.json" "$CODEX" "$PY" + exit $fail + + - name: Verify tag matches manifest (tag pushes only) + if: startsWith(github.ref, 'refs/tags/v') + env: + PY: ${{ steps.versions.outputs.py_version }} + run: | + set -euo pipefail + tag_version="${GITHUB_REF_NAME#v}" + if [[ "$tag_version" != "$PY" ]]; then + echo "::error::tag $GITHUB_REF_NAME does not match manifest version $PY" + echo "Bump mempalace/version.py, pyproject.toml, and all plugin manifests before tagging." + exit 1 + fi + echo "Tag $GITHUB_REF_NAME matches manifest version $PY"