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..a431d940 --- /dev/null +++ b/scripts/push-release-commit.sh @@ -0,0 +1,90 @@ +#!/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 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) +# PUSH_ATTEMPTS how many pushes to make before giving up (default: 5) +# +set -euo pipefail + +BRANCH="${BRANCH:-main}" +ATTEMPTS="${PUSH_ATTEMPTS:-5}" + +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" + + # 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::The commit at HEAD adds or modifies no files. Packages may already be on npm; reconcile $BRANCH by hand." >&2 + exit 1 + fi + + 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 + + # 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" + 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 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 6969c11a..3cc7cff9 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,194 @@ 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'); + // -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); + 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'); + + 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, 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 }, + 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. + 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('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(); + + // 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. + 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' + ); + }); +}