From 699eda4bb8f60864aeafc6603d202ca8adf37be5 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Mon, 24 Aug 2026 13:55:53 +0200 Subject: [PATCH 1/3] fix(ci): make the release push survive a branch that moved mid-run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On 2026-08-24 two publish dispatches landed 38s apart. The concurrency group serialized them correctly, but `workflow_dispatch` pins `github.sha` at dispatch time, so the queued run checked out a commit from before the run ahead of it — bumped from a stale baseline, published all fifteen packages to npm, then failed at `Tag + push` with a non-fast-forward. npm ended up a version ahead of main, and fifteen 4.1.49 tags were left pointing at a commit no branch contained. Three fixes, one per link in that chain: - Check out `github.ref_name` instead of the pinned dispatch SHA, so a queued run bumps from the branch tip as it exists when it actually runs. - Push through scripts/push-release-commit.sh, which rebuilds the release commit on the current tip and retries instead of failing. By this point the packages are on npm, so this run's version strings are the truth and win; every other file comes from the newer tip. Files the tip also changed are named in a warning rather than silently overwritten. - Create tags only after the commit is on the branch, so a rejected push can no longer strand them. publish-persona.yml has the same single-release-commit shape and gets the same treatment. publish-internal-personas.yml commits once per persona inside its publish loop — a different shape this script does not model, so it is left alone. Tests run the real script against throwaway git repos: the incident replay (a concurrent release commit plus a PR merged mid-run), the unmoved-branch path, and the already-current no-op. All three fail against the previous `git push origin HEAD --follow-tags`. Co-Authored-By: Claude Opus 5 --- .github/workflows/publish-persona.yml | 25 ++++- .github/workflows/publish.yml | 49 ++++++++- scripts/push-release-commit.sh | 81 ++++++++++++++ scripts/release-workflows.test.mjs | 152 +++++++++++++++++++++++++- 4 files changed, 298 insertions(+), 9 deletions(-) create mode 100755 scripts/push-release-commit.sh diff --git a/.github/workflows/publish-persona.yml b/.github/workflows/publish-persona.yml index d6f2e7d8..a544b1e5 100644 --- a/.github/workflows/publish-persona.yml +++ b/.github/workflows/publish-persona.yml @@ -65,6 +65,10 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + # `workflow_dispatch` pins `github.sha` at dispatch time; a queued run + # would otherwise bump and changelog from a stale commit. See the same + # note in publish.yml. + ref: ${{ github.ref_name }} - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -177,11 +181,26 @@ jobs: echo "==> Publishing $TARBALL $COMMON_FLAGS" npm publish "$TARBALL" $COMMON_FLAGS - - name: Tag + push + # Same shape as publish.yml: the package is already on npm by now, so the + # push reconciles onto whatever landed on the branch mid-run rather than + # failing, and the tag is created only once the commit is on the branch. + - name: Push release commit + if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} + env: + BRANCH: ${{ github.ref_name }} + run: scripts/push-release-commit.sh + + - name: Tag + push tags if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} run: | - git tag -a "personas-core-v${{ steps.bump.outputs.version }}" -m "${{ steps.package.outputs.npm_name }}@${{ steps.bump.outputs.version }}" - git push origin HEAD --follow-tags + set -euo pipefail + TAG="personas-core-v${{ steps.bump.outputs.version }}" + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + echo "::warning::tag $TAG already exists - leaving it as is" + exit 0 + fi + git tag -a "$TAG" -m "${{ steps.package.outputs.npm_name }}@${{ steps.bump.outputs.version }}" + git push origin "refs/tags/$TAG" - name: Summary run: | diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index ae8b70f8..94ed7a9d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -59,6 +59,13 @@ jobs: uses: actions/checkout@v6 with: fetch-depth: 0 + # `workflow_dispatch` pins `github.sha` at dispatch time, but the + # concurrency group above makes a second dispatch *queue* — so by the + # time it runs, that SHA can be several commits stale. Building, + # bumping and changelogging from it produced 2026-08-24's split brain + # (run 32713237250 bumped from a pre-release commit and then could not + # push). Take the branch tip at run start instead. + ref: ${{ github.ref_name }} - name: Setup pnpm uses: pnpm/action-setup@v5 @@ -625,18 +632,50 @@ jobs: fi done - # Annotated tags (-a) so `git push --follow-tags` actually pushes them; - # lightweight tags are skipped by --follow-tags. - - name: Tag + push + # This step runs *after* the packages are already on npm, so failing + # here is the worst outcome available: the registry moves ahead of git + # and the next run bumps from a version main has never seen. A plain + # `git push` is rejected by anything that landed on the branch mid-run — + # a merged PR, or another publish run's release commit (2026-08-24). + # + # So don't fail: rebuild the release commit on the current tip and retry. + # This run's version strings are what was actually published, so they win; + # every other file comes from the newer branch tip. + - name: Push release commit + if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} + env: + BRANCH: ${{ github.ref_name }} + run: scripts/push-release-commit.sh + + # Tags are created only once the release commit is on the branch, so a + # rejected push can never strand them on an unreachable commit — which is + # exactly what run 32713237250 did with fifteen 4.1.49 tags. Annotated + # (-a) because the create-release job checks the canonical tag out. + - name: Tag + push tags if: ${{ github.event.inputs.dry_run != 'true' && (github.event.inputs.version != 'none' || github.event.inputs.custom_version != '') }} run: | + set -euo pipefail + CREATED="" for entry in ${{ steps.bump.outputs.versions }}; do pkg="${entry%%:*}" version="${entry##*:}" + TAG="$pkg-v$version" NPM_NAME=$(node -p "require('./packages/$pkg/package.json').name") - git tag -a "$pkg-v$version" -m "$NPM_NAME@$version" + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + # Left behind by an earlier run that published this version but + # failed before its commit landed. Repointing a published tag is + # not this workflow's call, so leave it and say so. + echo "::warning::tag $TAG already exists - leaving it as is" + continue + fi + git tag -a "$TAG" -m "$NPM_NAME@$version" + CREATED="$CREATED refs/tags/$TAG" done - git push origin HEAD --follow-tags + if [ -n "$CREATED" ]; then + set -f + git push origin $CREATED + set +f + fi - name: Summary run: | diff --git a/scripts/push-release-commit.sh b/scripts/push-release-commit.sh new file mode 100755 index 00000000..2e25b02d --- /dev/null +++ b/scripts/push-release-commit.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# +# Push the release commit at HEAD to $BRANCH, rebuilding it on the branch tip +# if the push is rejected. +# +# Publish workflows run this *after* the packages are already on npm, so a +# rejected push is the worst failure available: the registry moves ahead of git +# and the next run bumps from a version the branch has never seen. That is what +# happened on 2026-08-24 (run 32713237250), when a queued second publish run +# built from a stale dispatch SHA and lost the push race with the run ahead of +# it. Anything landing on the branch mid-run does this — a merged PR, another +# release commit — so reconcile instead of failing. +# +# HEAD must be a single release commit. Callers that create several commits per +# run need a different shape and should not use this script. +# +# Env: +# BRANCH branch to push to (default: main) +# RELEASE_RE regex matching the files the release commit owns +# +set -euo pipefail + +BRANCH="${BRANCH:-main}" + +# The files a release commit owns. Matched with a regex over the full tree +# listing rather than a git pathspec: `git ls-tree` does not glob, and quietly +# returns a short list instead of erroring — which would rebuild the release +# commit with no version bumps in it. +RELEASE_RE="${RELEASE_RE:-^(packages/[^/]+/(package\.json|CHANGELOG\.md)|CHANGELOG\.md)\$}" + +for attempt in 1 2 3 4 5; do + if git push origin "HEAD:refs/heads/$BRANCH"; then + echo "Pushed the release commit to $BRANCH on attempt $attempt." + exit 0 + fi + + echo "::warning::push to $BRANCH was rejected (attempt $attempt) - rebuilding the release commit on the current tip" + REL=$(git rev-parse HEAD) + MSG=$(git log -1 --format=%B "$REL") + git fetch origin "$BRANCH" + + # Re-apply only files present in BOTH trees: `git checkout` with a + # path that is missing from either side aborts under `set -e`, and + # this is the one code path that must not die. Intersecting also + # avoids resurrecting a file the newer tip deleted. + FILES=$(comm -12 \ + <(git ls-tree -r --name-only "$REL" | grep -E "$RELEASE_RE" | sort) \ + <(git ls-tree -r --name-only "origin/$BRANCH" | grep -E "$RELEASE_RE" | sort)) + if [ -z "$FILES" ]; then + echo "::error title=Release commit not pushed::None of this run's release files exist on $BRANCH. Packages are on npm; reconcile $BRANCH by hand." >&2 + exit 1 + fi + + # $FILES is deliberately unquoted below so it splits into one + # argument per path; every path is a literal package.json or + # CHANGELOG.md, so there is nothing to split on but newlines. + # noglob keeps the shell from expanding them as patterns. + set -f + + # Our copy of a release file overwrites the tip's. That is right for + # a concurrent release commit and wrong for a hand-edited changelog, + # so say which files it applies to instead of losing them silently. + git diff --name-only "$REL^" "origin/$BRANCH" -- $FILES | + while read -r changed; do + echo "::warning::$changed also changed on $BRANCH during this run - this run's copy wins" + done + + git reset --hard "origin/$BRANCH" + git checkout "$REL" -- $FILES + git add -- $FILES + set +f + if git diff --cached --quiet; then + echo "$BRANCH already carries this run's release files; nothing to push." + exit 0 + fi + git commit -m "$MSG" + sleep $((attempt * 5)) +done + +echo "::error title=Release commit not pushed::Packages are on npm but $BRANCH could not be updated after 5 attempts. Reconcile the workspace versions on $BRANCH by hand." >&2 +exit 1 diff --git a/scripts/release-workflows.test.mjs b/scripts/release-workflows.test.mjs index 6969c11a..05f12554 100644 --- a/scripts/release-workflows.test.mjs +++ b/scripts/release-workflows.test.mjs @@ -1,9 +1,13 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import test from 'node:test'; const publishWorkflow = readFileSync('.github/workflows/publish.yml', 'utf8'); const verifyWorkflow = readFileSync('.github/workflows/verify-publish.yml', 'utf8'); +const personaWorkflow = readFileSync('.github/workflows/publish-persona.yml', 'utf8'); function publishTargetDirectories(workflow) { const match = workflow.match(/echo "packages=([^"]+)"/); @@ -72,3 +76,149 @@ test('scoped CLI verification checks only the supported thin-entry contract', () assert.match(step, /assert\.equal\(typeof mod\.main, 'function'\)/); assert.doesNotMatch(step, /CLI_VERSION|cli-impl/); }); + +/** + * Publish workflows push their release commit *after* the packages are on npm, + * so a rejected push leaves the registry ahead of git — which is what happened + * on 2026-08-24 (run 32713237250). These tests run the real shared script + * against throwaway git repos that replay that shape. + */ +const pushScript = 'scripts/push-release-commit.sh'; + +const GIT_ENV = { + ...process.env, + GIT_AUTHOR_NAME: 'release-test', + GIT_AUTHOR_EMAIL: 'release-test@example.com', + GIT_COMMITTER_NAME: 'release-test', + GIT_COMMITTER_EMAIL: 'release-test@example.com', +}; + +function git(cwd, ...args) { + return execFileSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV }); +} + +function writeVersions(dir, version) { + for (const pkg of ['cli', 'deploy']) { + mkdirSync(join(dir, 'packages', pkg), { recursive: true }); + writeFileSync(join(dir, 'packages', pkg, 'package.json'), `{"version":"${version}"}\n`); + writeFileSync(join(dir, 'packages', pkg, 'CHANGELOG.md'), `## ${version}\n`); + } + writeFileSync(join(dir, 'CHANGELOG.md'), `## ${version}\n`); +} + +/** A bare origin at 4.1.47, plus a clone whose HEAD bumps it to 4.1.49. */ +function stageRelease() { + const root = mkdtempSync(join(tmpdir(), 'publish-push-')); + const origin = join(root, 'origin.git'); + git(root, 'init', '-q', '--bare', origin); + + const seed = join(root, 'seed'); + git(root, 'clone', '-q', origin, seed); + writeVersions(seed, '4.1.47'); + writeFileSync(join(seed, 'packages', 'cli', 'source.ts'), 'export const x = 1;\n'); + git(seed, 'add', '-A'); + git(seed, 'commit', '-qm', 'base 4.1.47'); + git(seed, 'push', '-q', 'origin', 'HEAD:refs/heads/main'); + + const run = join(root, 'run'); + git(root, 'clone', '-q', origin, run); + writeVersions(run, '4.1.49'); + git(run, 'add', '-A'); + git(run, 'commit', '-qm', 'chore(release): @scope/cli@4.1.49 @scope/deploy@4.1.49'); + + return { root, seed, run }; +} + +function runPushStep(cwd) { + // /bin/bash, not the PATH bash: the runner's is 5.x but macOS ships 3.2, + // so this also pins the script to portable syntax. + return execFileSync('/bin/bash', [resolve(pushScript)], { + cwd, + encoding: 'utf8', + env: { ...GIT_ENV, BRANCH: 'main' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); +} + +function versionOnMain(seed, pkg) { + git(seed, 'fetch', '-q', 'origin'); + return JSON.parse(git(seed, 'show', `origin/main:packages/${pkg}/package.json`)).version; +} + +test('release commit pushes unchanged when the branch has not moved', () => { + const { seed, run } = stageRelease(); + const output = runPushStep(run); + + assert.match(output, /on attempt 1/); + assert.equal(versionOnMain(seed, 'cli'), '4.1.49'); +}); + +test('release commit is rebuilt on the tip when the branch moved mid-run', () => { + const { seed, run } = stageRelease(); + + // Another publish run's release commit, then a PR merging mid-run. + git(seed, 'pull', '-q'); + writeVersions(seed, '4.1.48'); + git(seed, 'commit', '-qam', 'chore(release): @scope/cli@4.1.48 @scope/deploy@4.1.48'); + writeFileSync(join(seed, 'packages', 'cli', 'source.ts'), 'export const x = 2;\n'); + git(seed, 'commit', '-qam', 'feat: merged mid-run'); + git(seed, 'push', '-q', 'origin', 'HEAD:refs/heads/main'); + + const output = runPushStep(run); + assert.match(output, /on attempt 2/); + + // Every bumped package lands — not just the ones a short file list caught. + assert.equal(versionOnMain(seed, 'cli'), '4.1.49'); + assert.equal(versionOnMain(seed, 'deploy'), '4.1.49'); + + // The mid-run merge survives, and the overwritten release files are named. + assert.equal(git(seed, 'show', 'origin/main:packages/cli/source.ts'), 'export const x = 2;\n'); + assert.match(output, /packages\/cli\/package\.json also changed on main/); + + const history = git(seed, 'log', '--format=%s', 'origin/main'); + assert.match(history, /@scope\/cli@4\.1\.49/); + assert.match(history, /@scope\/cli@4\.1\.48/); + assert.match(history, /feat: merged mid-run/); +}); + +test('release commit is a no-op when the branch already carries its files', () => { + const { seed, run } = stageRelease(); + + // Same release files, different commit — a re-dispatched run that got there + // first. The differing message keeps the SHAs apart; identical content and + // timestamps would otherwise produce the same commit and fast-forward. + git(seed, 'pull', '-q'); + writeVersions(seed, '4.1.49'); + git(seed, 'commit', '-qam', 'chore(release): 4.1.49 from an earlier dispatch'); + git(seed, 'push', '-q', 'origin', 'HEAD:refs/heads/main'); + + const output = runPushStep(run); + assert.match(output, /nothing to push/); + assert.equal(versionOnMain(seed, 'cli'), '4.1.49'); +}); + +for (const [name, workflow] of [ + ['publish.yml', publishWorkflow], + ['publish-persona.yml', personaWorkflow], +]) { + test(`${name} pushes the release commit before tagging it`, () => { + const lines = workflow.split('\n'); + const push = lines.findIndex((line) => line.trim() === '- name: Push release commit'); + const tag = lines.findIndex((line) => line.trim() === '- name: Tag + push tags'); + assert.notEqual(push, -1, 'must reconcile its push'); + assert.notEqual(tag, -1, 'must tag in its own step'); + assert.ok(push < tag, 'tagging before the push can strand tags on an unreachable commit'); + assert.ok( + workflow.includes(`run: ${pushScript}`), + 'must use the shared reconciling push script' + ); + }); + + test(`${name} checks out the branch tip, not the dispatch SHA`, () => { + assert.match( + workflow, + /fetch-depth: 0\n(\s+#.*\n)*\s+ref: \$\{\{ github\.ref_name \}\}/, + 'a queued run built from the pinned dispatch SHA bumps from a stale base' + ); + }); +} From 00ed039f104048e5353a9cb3467d93fb4d297440 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Mon, 24 Aug 2026 14:07:25 +0200 Subject: [PATCH 2/3] test(ci): stop assuming the runner's default branch is main CI runs with init.defaultBranch=master, so the test's clone tracked a ref the harness never pushed and `git pull` failed. Create the bare repo with -b main and drop the pull, which was a no-op sync. Verified by re-running the suite with GIT_CONFIG init.defaultBranch=master. Co-Authored-By: Claude Opus 5 --- scripts/release-workflows.test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/release-workflows.test.mjs b/scripts/release-workflows.test.mjs index 05f12554..b19fdd02 100644 --- a/scripts/release-workflows.test.mjs +++ b/scripts/release-workflows.test.mjs @@ -110,7 +110,10 @@ function writeVersions(dir, version) { function stageRelease() { const root = mkdtempSync(join(tmpdir(), 'publish-push-')); const origin = join(root, 'origin.git'); - git(root, 'init', '-q', '--bare', origin); + // -b main: the clones take their branch from the bare repo's HEAD, and a + // runner whose init.defaultBranch is `master` would otherwise track a ref + // these tests never push. + git(root, 'init', '-q', '--bare', '-b', 'main', origin); const seed = join(root, 'seed'); git(root, 'clone', '-q', origin, seed); @@ -157,7 +160,6 @@ test('release commit is rebuilt on the tip when the branch moved mid-run', () => const { seed, run } = stageRelease(); // Another publish run's release commit, then a PR merging mid-run. - git(seed, 'pull', '-q'); writeVersions(seed, '4.1.48'); git(seed, 'commit', '-qam', 'chore(release): @scope/cli@4.1.48 @scope/deploy@4.1.48'); writeFileSync(join(seed, 'packages', 'cli', 'source.ts'), 'export const x = 2;\n'); @@ -187,7 +189,6 @@ test('release commit is a no-op when the branch already carries its files', () = // Same release files, different commit — a re-dispatched run that got there // first. The differing message keeps the SHAs apart; identical content and // timestamps would otherwise produce the same commit and fast-forward. - git(seed, 'pull', '-q'); writeVersions(seed, '4.1.49'); git(seed, 'commit', '-qam', 'chore(release): 4.1.49 from an earlier dispatch'); git(seed, 'push', '-q', 'origin', 'HEAD:refs/heads/main'); From cdf8061827a7a269b84a2beee09764e7e7153a5a Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Mon, 24 Aug 2026 14:18:05 +0200 Subject: [PATCH 3/3] fix(ci): rebuild only the files the release commit changed (PR feedback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings, both real: - codex/Devin/CodeRabbit: selecting files by pattern over the release commit's tree also selects files the release never touched. A persona publish retrying while a lockstep release lands would have reverted all fifteen lockstep versions to its own stale base — pushing git *behind* npm, the exact failure this script exists to prevent. Take the file list from the release commit's own diff instead, which also drops the RELEASE_RE knob entirely. - CodeRabbit: the loop rebuilt the release commit on its final attempt and then exited without ever pushing it. Stop rebuilding once the attempt budget is spent, so every rebuild gets a push. Also guards a parentless HEAD and a release commit that adds nothing, and warns rather than silently skipping a path the release commit deleted. Fixtures for both: a concurrent bump of a package this release does not own must survive the rebuild, and a spent budget must fail loudly with the branch and the local release commit both untouched. Each fails against the implementation it targets. Co-Authored-By: Claude Opus 5 --- scripts/push-release-commit.sh | 63 +++++++++++++++++------------- scripts/release-workflows.test.mjs | 48 ++++++++++++++++++++++- 2 files changed, 82 insertions(+), 29 deletions(-) diff --git a/scripts/push-release-commit.sh b/scripts/push-release-commit.sh index 2e25b02d..a431d940 100755 --- a/scripts/push-release-commit.sh +++ b/scripts/push-release-commit.sh @@ -11,55 +11,63 @@ # it. Anything landing on the branch mid-run does this — a merged PR, another # release commit — so reconcile instead of failing. # -# HEAD must be a single release commit. Callers that create several commits per -# run need a different shape and should not use this script. +# HEAD must be a single release commit with a parent. Callers that create +# several commits per run need a different shape and should not use this script. # # Env: -# BRANCH branch to push to (default: main) -# RELEASE_RE regex matching the files the release commit owns +# BRANCH branch to push to (default: main) +# PUSH_ATTEMPTS how many pushes to make before giving up (default: 5) # set -euo pipefail BRANCH="${BRANCH:-main}" +ATTEMPTS="${PUSH_ATTEMPTS:-5}" -# The files a release commit owns. Matched with a regex over the full tree -# listing rather than a git pathspec: `git ls-tree` does not glob, and quietly -# returns a short list instead of erroring — which would rebuild the release -# commit with no version bumps in it. -RELEASE_RE="${RELEASE_RE:-^(packages/[^/]+/(package\.json|CHANGELOG\.md)|CHANGELOG\.md)\$}" - -for attempt in 1 2 3 4 5; do +for attempt in $(seq 1 "$ATTEMPTS"); do if git push origin "HEAD:refs/heads/$BRANCH"; then echo "Pushed the release commit to $BRANCH on attempt $attempt." exit 0 fi + # Every rebuild must get a push of its own, so stop rebuilding once the last + # attempt has been spent rather than leaving a commit that never gets tried. + if [ "$attempt" -eq "$ATTEMPTS" ]; then + break + fi + echo "::warning::push to $BRANCH was rejected (attempt $attempt) - rebuilding the release commit on the current tip" REL=$(git rev-parse HEAD) + if ! git rev-parse -q --verify "$REL^" >/dev/null; then + echo "::error title=Release commit not pushed::HEAD has no parent, so there is no release commit to rebuild. Reconcile $BRANCH by hand." >&2 + exit 1 + fi MSG=$(git log -1 --format=%B "$REL") git fetch origin "$BRANCH" - # Re-apply only files present in BOTH trees: `git checkout` with a - # path that is missing from either side aborts under `set -e`, and - # this is the one code path that must not die. Intersecting also - # avoids resurrecting a file the newer tip deleted. - FILES=$(comm -12 \ - <(git ls-tree -r --name-only "$REL" | grep -E "$RELEASE_RE" | sort) \ - <(git ls-tree -r --name-only "origin/$BRANCH" | grep -E "$RELEASE_RE" | sort)) + # Exactly the files this release commit changed, taken from its own diff. + # A pattern over the tree would also pick up files the release never touched + # and revert them — another package's version bumped by whatever landed on + # the branch mid-run, say. `--diff-filter=d` drops paths the commit deleted, + # which cannot be checked out of it. + FILES=$(git diff --name-only --diff-filter=d "$REL^" "$REL") if [ -z "$FILES" ]; then - echo "::error title=Release commit not pushed::None of this run's release files exist on $BRANCH. Packages are on npm; reconcile $BRANCH by hand." >&2 + echo "::error title=Release commit not pushed::The commit at HEAD adds or modifies no files. Packages may already be on npm; reconcile $BRANCH by hand." >&2 exit 1 fi - # $FILES is deliberately unquoted below so it splits into one - # argument per path; every path is a literal package.json or - # CHANGELOG.md, so there is nothing to split on but newlines. - # noglob keeps the shell from expanding them as patterns. + git diff --name-only --diff-filter=D "$REL^" "$REL" | + while read -r removed; do + echo "::warning::$removed was deleted by the release commit; the rebuild does not re-apply that deletion" + done + + # $FILES is deliberately unquoted below so it splits into one argument per + # path. Release files are package.json / CHANGELOG.md paths with no spaces; + # noglob keeps the shell from expanding any of them as a pattern. set -f - # Our copy of a release file overwrites the tip's. That is right for - # a concurrent release commit and wrong for a hand-edited changelog, - # so say which files it applies to instead of losing them silently. + # This run's copy of a file it owns overwrites the tip's. That is right for a + # concurrent release commit and wrong for a hand-edited changelog, so name the + # overlap instead of losing it silently. git diff --name-only "$REL^" "origin/$BRANCH" -- $FILES | while read -r changed; do echo "::warning::$changed also changed on $BRANCH during this run - this run's copy wins" @@ -69,6 +77,7 @@ for attempt in 1 2 3 4 5; do git checkout "$REL" -- $FILES git add -- $FILES set +f + if git diff --cached --quiet; then echo "$BRANCH already carries this run's release files; nothing to push." exit 0 @@ -77,5 +86,5 @@ for attempt in 1 2 3 4 5; do sleep $((attempt * 5)) done -echo "::error title=Release commit not pushed::Packages are on npm but $BRANCH could not be updated after 5 attempts. Reconcile the workspace versions on $BRANCH by hand." >&2 +echo "::error title=Release commit not pushed::Packages are on npm but $BRANCH could not be updated in $ATTEMPTS attempts. Reconcile the workspace versions on $BRANCH by hand." >&2 exit 1 diff --git a/scripts/release-workflows.test.mjs b/scripts/release-workflows.test.mjs index b19fdd02..3cc7cff9 100644 --- a/scripts/release-workflows.test.mjs +++ b/scripts/release-workflows.test.mjs @@ -119,6 +119,10 @@ function stageRelease() { git(root, 'clone', '-q', origin, seed); writeVersions(seed, '4.1.47'); writeFileSync(join(seed, 'packages', 'cli', 'source.ts'), 'export const x = 1;\n'); + // A package this release does not bump, present in both trees — the shape + // that separates "files this commit changed" from "files matching a pattern". + mkdirSync(join(seed, 'packages', 'other'), { recursive: true }); + writeFileSync(join(seed, 'packages', 'other', 'package.json'), '{"version":"4.1.47"}\n'); git(seed, 'add', '-A'); git(seed, 'commit', '-qm', 'base 4.1.47'); git(seed, 'push', '-q', 'origin', 'HEAD:refs/heads/main'); @@ -132,13 +136,13 @@ function stageRelease() { return { root, seed, run }; } -function runPushStep(cwd) { +function runPushStep(cwd, env = {}) { // /bin/bash, not the PATH bash: the runner's is 5.x but macOS ships 3.2, // so this also pins the script to portable syntax. return execFileSync('/bin/bash', [resolve(pushScript)], { cwd, encoding: 'utf8', - env: { ...GIT_ENV, BRANCH: 'main' }, + env: { ...GIT_ENV, BRANCH: 'main', ...env }, stdio: ['ignore', 'pipe', 'pipe'], }); } @@ -183,6 +187,46 @@ test('release commit is rebuilt on the tip when the branch moved mid-run', () => assert.match(history, /feat: merged mid-run/); }); +test('rebuilding leaves files the release commit never touched alone', () => { + const { seed, run } = stageRelease(); + + // A concurrent release bumps a package that this run's release commit left + // untouched — e.g. the lockstep workflow landing while a persona run retries. + writeFileSync(join(seed, 'packages', 'other', 'package.json'), '{"version":"9.9.9"}\n'); + git(seed, 'commit', '-qam', 'chore(release): @scope/other@9.9.9'); + git(seed, 'push', '-q', 'origin', 'HEAD:refs/heads/main'); + + runPushStep(run); + + // A pattern over the tree would have reverted this to the release commit's + // base; only the files the release commit actually changed may move. + assert.equal(versionOnMain(seed, 'other'), '9.9.9'); + assert.equal(versionOnMain(seed, 'cli'), '4.1.49'); +}); + +test('exhausted attempts fail loudly instead of stranding a rebuilt commit', () => { + const { seed, run } = stageRelease(); + + writeVersions(seed, '4.1.48'); + git(seed, 'commit', '-qam', 'chore(release): @scope/cli@4.1.48 @scope/deploy@4.1.48'); + git(seed, 'push', '-q', 'origin', 'HEAD:refs/heads/main'); + + const tipBefore = git(seed, 'rev-parse', 'origin/main').trim(); + const releaseBefore = git(run, 'rev-parse', 'HEAD').trim(); + assert.throws( + () => runPushStep(run, { PUSH_ATTEMPTS: '1' }), + /Release commit not pushed/, + 'a spent attempt budget must surface, not exit clean' + ); + + git(seed, 'fetch', '-q', 'origin'); + assert.equal(git(seed, 'rev-parse', 'origin/main').trim(), tipBefore, 'branch must be untouched'); + // No rebuild on the last attempt: rebuilding one that can never be pushed + // burns the release commit and leaves the checkout disagreeing with the + // "reconcile by hand" the error message asks for. + assert.equal(git(run, 'rev-parse', 'HEAD').trim(), releaseBefore); +}); + test('release commit is a no-op when the branch already carries its files', () => { const { seed, run } = stageRelease();