From 2aec46146bd9f0350684533ee7ea3b13ae36a7cf Mon Sep 17 00:00:00 2001 From: Cato <41178744+catomean@users.noreply.github.com> Date: Fri, 11 Sep 2026 03:04:30 +0200 Subject: [PATCH] ci(publish): make publishing idempotent so a second run is green, not E403 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One merge starts Publish twice — the PAT-made push and the auto-merge sweep's workflow_dispatch re-arm, seconds apart. Both ask the registry before either publishes, both build, and the loser gets E403 "cannot publish over previously published versions" (bip-kit 0.2.6, 0.2.7 on 2026-09-11). A red run that means nothing is wrong teaches everyone to ignore the workflow. - concurrency group "publish", never cancelled: the second run waits, then sees the version on the registry and skips - registry check writes a green step summary ("already published — nothing to do") and gates install/verify/publish - npm publish failure is re-checked against the registry: if the version is there, that is the goal state, not an error OIDC trusted publishing, verify-before-publish, tag checks and the re-arm list are unchanged. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01WqKqMnHQHSmkGFfc5t7Rxn --- .github/workflows/publish.yml | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d688b7e..21d42b3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -22,6 +22,18 @@ on: - cron: '43 * * * *' workflow_dispatch: +# One merge starts this workflow TWICE: once from the push (the sweep merges +# with a PAT, whose pushes do trigger workflows) and once from the sweep's +# workflow_dispatch re-arm, seconds later. Two runs asking the registry the +# same question at the same moment both hear "not published", both build, and +# the one that publishes second gets E403 — a red run that means nothing is +# wrong (bip-kit 0.2.6 and 0.2.7, 2026-09-11). Serialise them instead: the +# second run waits, then finds the version on the registry and skips. Never +# cancel — a cancelled publish is a half-shipped release. +concurrency: + group: publish + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest @@ -47,10 +59,12 @@ jobs: run: | name=$(node -p "require('./package.json').name") version=$(node -p "require('./package.json').version") + echo "name=$name" >> "$GITHUB_OUTPUT" echo "version=$version" >> "$GITHUB_OUTPUT" if npm view "$name@$version" version >/dev/null 2>&1; then echo "→ $name@$version is already published; nothing to do." echo "publish=false" >> "$GITHUB_OUTPUT" + echo "✅ \`$name@$version\` is already published — nothing to do." >> "$GITHUB_STEP_SUMMARY" else echo "→ $name@$version is not on the registry; releasing it." echo "publish=true" >> "$GITHUB_OUTPUT" @@ -66,8 +80,23 @@ jobs: # Auth is trusted publishing (OIDC): npm accepts this job's identity token # because the package's Trusted Publisher is pinned to exactly this repo # and workflow. No npm token exists anywhere, so none can leak or expire. - - if: steps.check.outputs.publish == 'true' - run: npm publish + - name: Publish to npm (OIDC, tokenless) + if: steps.check.outputs.publish == 'true' + env: + NAME: ${{ steps.check.outputs.name }} + VERSION: ${{ steps.check.outputs.version }} + run: | + if npm publish; then + echo "🚀 Published \`$NAME@$VERSION\`." >> "$GITHUB_STEP_SUMMARY" + elif npm view "$NAME@$VERSION" version >/dev/null 2>&1; then + # The check above and this publish are not atomic. If another run + # (a schedule tick, a hand dispatch) slipped between them, the + # version is on the registry — which is the goal state, not an error. + echo "→ $NAME@$VERSION was published by a concurrent run; nothing to do." + echo "✅ \`$NAME@$VERSION\` was published by a concurrent run — nothing to do." >> "$GITHUB_STEP_SUMMARY" + else + exit 1 + fi - name: Tag the released commit if: steps.check.outputs.publish == 'true'