From f8aabca271626257c82d2f38ea2523ce67efb32d Mon Sep 17 00:00:00 2001 From: Tym Rabchuk Date: Tue, 19 May 2026 11:44:49 -0400 Subject: [PATCH] fix(release): publish release before pushing bump commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current release workflow pushes the `bump to vX.Y.Z` commit to main *before* the matching GitHub release is published. During the ~80s build window the release is still a draft, so asset URLs return 404 to unauthenticated downloads — the `bin/devkit` wrapper's first-run engine download then fails and CC caches the MCP startup failure until restart. Reorder so the bump commit lands only after binaries are uploaded and the release is un-drafted. The release tag now points to the PR-merge commit (matters only for auto-bumps — manual bumps already have the new plugin.json in the merge commit). Cleanup-on-failure intentionally excludes bump failures: the release is already published by then, so artifacts must stay; the next merge's auto-bump walks forward from the latest tag and self-recovers if a single bump push fails. --- .github/workflows/release.yml | 107 ++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 31 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b43824d..94eb4c7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,23 +8,39 @@ on: permissions: contents: write +# Ordering invariant: the version-bump commit lands on main only AFTER the +# matching release is published and all binaries are uploaded. This closes +# the ~80s race window in which CC would sync plugin.json at the new version +# while the GitHub release was still a draft (asset URLs return 404 for +# unauthenticated downloads, so the wrapper's first-run download fails and +# CC caches the MCP startup failure until restart). + jobs: # =========================================================================== - # Step 1: Determine version and commit bump + # Step 1: Determine the target version (no push yet) # =========================================================================== version: if: github.event.pull_request.merged == true runs-on: ubuntu-latest outputs: version: ${{ steps.version.outputs.version }} - commit_sha: ${{ steps.bump.outputs.commit_sha }} + bumped: ${{ steps.version.outputs.bumped }} + merge_sha: ${{ steps.resolve.outputs.merge_sha }} steps: - uses: actions/checkout@v4 with: - ssh-key: ${{ secrets.VERSION_BUMP_KEY }} fetch-depth: 0 + - name: Resolve PR merge commit + id: resolve + run: | + SHA="${{ github.event.pull_request.merge_commit_sha }}" + if [ -z "$SHA" ]; then + SHA=$(git rev-parse HEAD) + fi + echo "merge_sha=$SHA" >> "$GITHUB_OUTPUT" + - name: Determine version id: version run: | @@ -69,29 +85,14 @@ jobs: echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT" fi - - name: Sync version files - run: | - VERSION="${{ steps.version.outputs.version }}" - jq --arg v "$VERSION" '.version = $v' .claude-plugin/plugin.json > tmp.json && mv tmp.json .claude-plugin/plugin.json - - - name: Commit version bump - id: bump - run: | - VERSION="${{ steps.version.outputs.version }}" - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add .claude-plugin/plugin.json - if git diff --cached --quiet; then - echo "Version files already at $VERSION — no commit needed" - else - git commit -m "bump to v${VERSION}" - git push - fi - echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" - # =========================================================================== - # Step 2: Create draft release (tag + release atomically) + # Step 2: Create draft release, tagging the PR-merge commit # =========================================================================== + # The tag points to the merge SHA so the tag exists before any bump-commit + # push. For auto-bumps this means the tagged tree has plugin.json one + # version behind the tag name (the bump lands in Step 5); for manual bumps + # the merge commit already contains the new plugin.json so the tagged tree + # matches the tag. create-release: needs: version runs-on: ubuntu-latest @@ -101,7 +102,7 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 - ref: main + ref: ${{ needs.version.outputs.merge_sha }} - name: Generate release notes id: notes @@ -129,17 +130,18 @@ jobs: RELEASE_NOTES: ${{ steps.notes.outputs.notes }} run: | TAG="v${{ needs.version.outputs.version }}" - COMMIT="${{ needs.version.outputs.commit_sha }}" + MERGE_SHA="${{ needs.version.outputs.merge_sha }}" if git ls-remote --tags origin "refs/tags/$TAG" | grep -q "$TAG"; then echo "Tag $TAG already exists — skipping release" echo "release_created=false" >> "$GITHUB_OUTPUT" exit 0 fi - # Tag the version-bumped commit, not the stale checkout - git tag "$TAG" "$COMMIT" + # Tag the PR-merge commit so the tag is in place before any + # plugin.json bump-commit lands on main. + git tag "$TAG" "$MERGE_SHA" git push origin "$TAG" # Create release; clean up tag on failure - if ! gh release create "$TAG" --title "$TAG" --draft --notes "$RELEASE_NOTES"; then + if ! gh release create "$TAG" --title "$TAG" --target "$MERGE_SHA" --draft --notes "$RELEASE_NOTES"; then echo "::warning::Release creation failed — cleaning up orphaned tag" git push origin --delete "$TAG" || true echo "release_created=false" >> "$GITHUB_OUTPUT" @@ -163,6 +165,8 @@ jobs: arch: [amd64, arm64] steps: - uses: actions/checkout@v4 + with: + ref: ${{ needs.version.outputs.merge_sha }} - uses: actions/setup-go@v5 with: @@ -186,7 +190,7 @@ jobs: working-directory: src # =========================================================================== - # Step 4: Generate checksums and publish release + # Step 4: Generate checksums and publish (un-draft) the release # =========================================================================== publish: needs: [version, create-release, binaries] @@ -210,11 +214,52 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: gh release edit "v${{ needs.version.outputs.version }}" --draft=false + # =========================================================================== + # Step 5: Push the version-bump commit (auto-bump only) + # =========================================================================== + # Runs last so plugin.json on main only advertises the new version after + # the release is fully published. Skipped for manual bumps where the + # merge commit already carries the bumped plugin.json. + bump: + needs: [version, create-release, binaries, publish] + if: >- + needs.create-release.outputs.release_created == 'true' && + needs.version.outputs.bumped == 'auto' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + ssh-key: ${{ secrets.VERSION_BUMP_KEY }} + fetch-depth: 0 + ref: main + + - name: Sync plugin.json + run: | + VERSION="${{ needs.version.outputs.version }}" + jq --arg v "$VERSION" '.version = $v' .claude-plugin/plugin.json > tmp.json && mv tmp.json .claude-plugin/plugin.json + + - name: Commit and push version bump + run: | + VERSION="${{ needs.version.outputs.version }}" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .claude-plugin/plugin.json + if git diff --cached --quiet; then + echo "Version files already at $VERSION — no commit needed" + else + git commit -m "bump to v${VERSION}" + git push + fi + # =========================================================================== # Cleanup on failure # =========================================================================== + # Note: bump failures do NOT trigger cleanup — by that point the release + # is already published, so the artifacts should stay. A failed bump leaves + # plugin.json on main one version behind; the next merge's auto-bump logic + # walks forward from the latest tag, so recovery is automatic on next merge. cleanup-on-failure: - needs: [version, create-release, binaries, publish] + needs: [version, create-release, binaries, publish, bump] if: >- always() && needs.create-release.outputs.release_created == 'true' &&