From 5f72007146086d1dfef79cd32bea9a7b373456df Mon Sep 17 00:00:00 2001 From: Paul Newling Date: Wed, 24 Jun 2026 10:43:21 -0700 Subject: [PATCH 1/3] fix: make release commit-push to main concurrency-safe and idempotent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release job's "Commit changed files to main" step ran `changeset version` then a bare `git push`, with no serialization and no retry. When `main` advanced between checkout and push — a concurrent run producing the identical "Auto-generated changes" commit, another merge, or a queued run — the push failed with "cannot lock ref 'refs/heads/main': is at X but expected Y", the step exited 1, and the release was left half-done: versions bumped on main but npm publish + tag (gated on the follow-up run) never happened. Re-running could not recover, since a re-run checks out the frozen run head, now behind main. - Add per-ref `concurrency` (cancel-in-progress: false) to serialize releases so two runs cannot race the push. - Replace the bare push with a rebase-and-retry loop that treats an identical commit already on main as success (idempotent). Applied to both node-simple-pnpm.yaml and node-matrix-pnpm.yaml. --- .github/workflows/node-matrix-pnpm.yaml | 35 ++++++++++++++++++++++++- .github/workflows/node-simple-pnpm.yaml | 35 ++++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/.github/workflows/node-matrix-pnpm.yaml b/.github/workflows/node-matrix-pnpm.yaml index 62ed186d..eb6613e1 100644 --- a/.github/workflows/node-matrix-pnpm.yaml +++ b/.github/workflows/node-matrix-pnpm.yaml @@ -778,6 +778,13 @@ jobs: build-publish: name: unified (build publish) + # Serialize releases per ref so two runs cannot concurrently run + # `changeset version` and race the push of "Auto-generated changes" to main. + # cancel-in-progress must stay false: a release in flight must finish (publish/tag), + # never be cancelled. Same-ref runs queue instead. + concurrency: + group: release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false runs-on: ubuntu-latest needs: - check-changesets @@ -945,7 +952,33 @@ jobs: git checkout main git add . git commit -m "Auto-generated changes" - git push + + # Resilient, idempotent push: `main` can advance between checkout and push + # (another merge, a queued run, or a concurrent twin producing the + # byte-identical "Auto-generated changes" commit). A bare `git push` then + # fails ("cannot lock ref ... is at X but expected Y"), the step exits 1, + # and the release is left half-done (version bumped on main, never published). + pushed=false + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + echo "Pushed auto-generated changes on attempt ${attempt}." + pushed=true + break + fi + echo "Push rejected on attempt ${attempt}; syncing with origin/main." + git fetch origin main + if git diff --quiet FETCH_HEAD -- .; then + echo "origin/main already contains these changes; nothing to push." + pushed=true + break + fi + git rebase FETCH_HEAD || { git rebase --abort || true; echo "Rebase onto origin/main failed."; exit 1; } + done + + if [ "${pushed}" != "true" ]; then + echo "Failed to push auto-generated changes after retries." >&2 + exit 1 + fi - name: Publish npm package id: publish-to-private diff --git a/.github/workflows/node-simple-pnpm.yaml b/.github/workflows/node-simple-pnpm.yaml index 455171c9..023aa188 100644 --- a/.github/workflows/node-simple-pnpm.yaml +++ b/.github/workflows/node-simple-pnpm.yaml @@ -753,6 +753,13 @@ jobs: build-test-publish: name: unified (build test publish) + # Serialize releases per ref so two runs cannot concurrently run + # `changeset version` and race the push of "Auto-generated changes" to main. + # cancel-in-progress must stay false: a release in flight must finish (publish/tag), + # never be cancelled. Same-ref runs queue instead. + concurrency: + group: release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false runs-on: ${{ inputs.gha-runner-label }} needs: - preflight-require-latest @@ -992,7 +999,33 @@ jobs: git checkout main git add . git commit -m "Auto-generated changes" - git push + + # Resilient, idempotent push: `main` can advance between checkout and push + # (another merge, a queued run, or a concurrent twin producing the + # byte-identical "Auto-generated changes" commit). A bare `git push` then + # fails ("cannot lock ref ... is at X but expected Y"), the step exits 1, + # and the release is left half-done (version bumped on main, never published). + pushed=false + for attempt in 1 2 3 4 5; do + if git push origin HEAD:main; then + echo "Pushed auto-generated changes on attempt ${attempt}." + pushed=true + break + fi + echo "Push rejected on attempt ${attempt}; syncing with origin/main." + git fetch origin main + if git diff --quiet FETCH_HEAD -- .; then + echo "origin/main already contains these changes; nothing to push." + pushed=true + break + fi + git rebase FETCH_HEAD || { git rebase --abort || true; echo "Rebase onto origin/main failed."; exit 1; } + done + + if [ "${pushed}" != "true" ]; then + echo "Failed to push auto-generated changes after retries." >&2 + exit 1 + fi - name: Publish npm package id: publish-to-private From 6ff3a036f404297098167f55f1dc1a735cad349d Mon Sep 17 00:00:00 2001 From: Paul Newling Date: Wed, 24 Jun 2026 10:47:57 -0700 Subject: [PATCH 2/3] docs: tighten release commit-push comments (clear-writing pass) --- .github/workflows/node-matrix-pnpm.yaml | 19 ++++++++++--------- .github/workflows/node-simple-pnpm.yaml | 19 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/.github/workflows/node-matrix-pnpm.yaml b/.github/workflows/node-matrix-pnpm.yaml index eb6613e1..69ac598b 100644 --- a/.github/workflows/node-matrix-pnpm.yaml +++ b/.github/workflows/node-matrix-pnpm.yaml @@ -778,10 +778,10 @@ jobs: build-publish: name: unified (build publish) - # Serialize releases per ref so two runs cannot concurrently run - # `changeset version` and race the push of "Auto-generated changes" to main. - # cancel-in-progress must stay false: a release in flight must finish (publish/tag), - # never be cancelled. Same-ref runs queue instead. + # Serialize releases per ref: run only one at a time. Otherwise two runs race + # `changeset version` and the push of "Auto-generated changes" to main. + # Keep cancel-in-progress false so an in-flight release finishes (publish + tag); + # same-ref runs queue instead. concurrency: group: release-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -953,11 +953,12 @@ jobs: git add . git commit -m "Auto-generated changes" - # Resilient, idempotent push: `main` can advance between checkout and push - # (another merge, a queued run, or a concurrent twin producing the - # byte-identical "Auto-generated changes" commit). A bare `git push` then - # fails ("cannot lock ref ... is at X but expected Y"), the step exits 1, - # and the release is left half-done (version bumped on main, never published). + # Resilient, idempotent push. `main` can advance between checkout and push: + # another merge, a queued run, or a concurrent twin pushing the byte-identical + # "Auto-generated changes" commit. A bare `git push` then fails + # ("cannot lock ref ... is at X but expected Y") and exits 1, stranding the + # release half-done — main bumped, never published. Rebase, retry, and treat + # "already on main" as success. pushed=false for attempt in 1 2 3 4 5; do if git push origin HEAD:main; then diff --git a/.github/workflows/node-simple-pnpm.yaml b/.github/workflows/node-simple-pnpm.yaml index 023aa188..7dc48e54 100644 --- a/.github/workflows/node-simple-pnpm.yaml +++ b/.github/workflows/node-simple-pnpm.yaml @@ -753,10 +753,10 @@ jobs: build-test-publish: name: unified (build test publish) - # Serialize releases per ref so two runs cannot concurrently run - # `changeset version` and race the push of "Auto-generated changes" to main. - # cancel-in-progress must stay false: a release in flight must finish (publish/tag), - # never be cancelled. Same-ref runs queue instead. + # Serialize releases per ref: run only one at a time. Otherwise two runs race + # `changeset version` and the push of "Auto-generated changes" to main. + # Keep cancel-in-progress false so an in-flight release finishes (publish + tag); + # same-ref runs queue instead. concurrency: group: release-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -1000,11 +1000,12 @@ jobs: git add . git commit -m "Auto-generated changes" - # Resilient, idempotent push: `main` can advance between checkout and push - # (another merge, a queued run, or a concurrent twin producing the - # byte-identical "Auto-generated changes" commit). A bare `git push` then - # fails ("cannot lock ref ... is at X but expected Y"), the step exits 1, - # and the release is left half-done (version bumped on main, never published). + # Resilient, idempotent push. `main` can advance between checkout and push: + # another merge, a queued run, or a concurrent twin pushing the byte-identical + # "Auto-generated changes" commit. A bare `git push` then fails + # ("cannot lock ref ... is at X but expected Y") and exits 1, stranding the + # release half-done — main bumped, never published. Rebase, retry, and treat + # "already on main" as success. pushed=false for attempt in 1 2 3 4 5; do if git push origin HEAD:main; then From eb6ef1e8f730fe38c07346881f720e05460d7066 Mon Sep 17 00:00:00 2001 From: Paul Newling Date: Wed, 24 Jun 2026 17:23:06 -0700 Subject: [PATCH 3/3] docs: clarify release concurrency semantics in step comments Correct the concurrency comment: with queue:single (default) a pending publish run is superseded, not queued, when a newer same-ref run arrives. Document why this is safe (two-pass release self-heals; only an intermediate version/tag is skipped under burst merges) and note queue:max as the per-bump-tag escape hatch. Reconcile the push-loop comment with the concurrency block. --- .github/workflows/node-matrix-pnpm.yaml | 20 ++++++++++++-------- .github/workflows/node-simple-pnpm.yaml | 20 ++++++++++++-------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/.github/workflows/node-matrix-pnpm.yaml b/.github/workflows/node-matrix-pnpm.yaml index 69ac598b..ab01c3e1 100644 --- a/.github/workflows/node-matrix-pnpm.yaml +++ b/.github/workflows/node-matrix-pnpm.yaml @@ -778,10 +778,14 @@ jobs: build-publish: name: unified (build publish) - # Serialize releases per ref: run only one at a time. Otherwise two runs race - # `changeset version` and the push of "Auto-generated changes" to main. - # Keep cancel-in-progress false so an in-flight release finishes (publish + tag); - # same-ref runs queue instead. + # Serialize the release job per ref so two runs never race `changeset version` and + # the push of "Auto-generated changes" to main. + # cancel-in-progress: false lets an in-flight release finish (publish + tag) instead of + # being cut mid-publish. GitHub keeps only one run pending per group (queue: single, the + # default), so a burst of merges can supersede a queued publish run. That is safe: the + # release is two-pass and self-heals — the final bump always triggers a publish run that + # ships the latest versions; only an intermediate version/tag can be skipped. Set + # queue: max to keep every bump's own version and tag. concurrency: group: release-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -953,11 +957,11 @@ jobs: git add . git commit -m "Auto-generated changes" - # Resilient, idempotent push. `main` can advance between checkout and push: - # another merge, a queued run, or a concurrent twin pushing the byte-identical - # "Auto-generated changes" commit. A bare `git push` then fails + # Resilient, idempotent push. The concurrency block above stops a concurrent + # twin, but main can still advance between checkout and push — a human or another + # workflow pushes, or a retry re-enters. A bare `git push` then fails # ("cannot lock ref ... is at X but expected Y") and exits 1, stranding the - # release half-done — main bumped, never published. Rebase, retry, and treat + # release half-done: main bumped, never published. Rebase, retry, and treat # "already on main" as success. pushed=false for attempt in 1 2 3 4 5; do diff --git a/.github/workflows/node-simple-pnpm.yaml b/.github/workflows/node-simple-pnpm.yaml index 7dc48e54..b625ae4e 100644 --- a/.github/workflows/node-simple-pnpm.yaml +++ b/.github/workflows/node-simple-pnpm.yaml @@ -753,10 +753,14 @@ jobs: build-test-publish: name: unified (build test publish) - # Serialize releases per ref: run only one at a time. Otherwise two runs race - # `changeset version` and the push of "Auto-generated changes" to main. - # Keep cancel-in-progress false so an in-flight release finishes (publish + tag); - # same-ref runs queue instead. + # Serialize the release job per ref so two runs never race `changeset version` and + # the push of "Auto-generated changes" to main. + # cancel-in-progress: false lets an in-flight release finish (publish + tag) instead of + # being cut mid-publish. GitHub keeps only one run pending per group (queue: single, the + # default), so a burst of merges can supersede a queued publish run. That is safe: the + # release is two-pass and self-heals — the final bump always triggers a publish run that + # ships the latest versions; only an intermediate version/tag can be skipped. Set + # queue: max to keep every bump's own version and tag. concurrency: group: release-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -1000,11 +1004,11 @@ jobs: git add . git commit -m "Auto-generated changes" - # Resilient, idempotent push. `main` can advance between checkout and push: - # another merge, a queued run, or a concurrent twin pushing the byte-identical - # "Auto-generated changes" commit. A bare `git push` then fails + # Resilient, idempotent push. The concurrency block above stops a concurrent + # twin, but main can still advance between checkout and push — a human or another + # workflow pushes, or a retry re-enters. A bare `git push` then fails # ("cannot lock ref ... is at X but expected Y") and exits 1, stranding the - # release half-done — main bumped, never published. Rebase, retry, and treat + # release half-done: main bumped, never published. Rebase, retry, and treat # "already on main" as success. pushed=false for attempt in 1 2 3 4 5; do