ci(#1104): keep next package.json in sync with the last published release (#1109)

* ci(#1104): sync next package.json version to the last published release

next rested on a -dev stream per ADR-660 (1.3.1-dev.0) — a never-published
placeholder that leaked to source/dev installs. Make every release type write
its exact published version back to next:

- finalize/hotfix (push main): auto-backmerge sets next's version to main's
  released version, folded into the existing back-merge PR (+ pinned setup-node).
- rc (no main push): the rc job opens + admin-merges a sync PR after publish.

Shared, fail-closed scripts/sync-next-version.cjs stamps package.json + the
runtime manifests via the npm version hook and refuses any non-release version.
Amends ADR-660 (supersedes the -dev stream decision).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* ci(#1104): harden next-version sync against post-publish failure modes

Review hardening (Codex + code-review gates) on the #1104 sync helper and
its workflow callers:

- release.yml rc Sync step: continue-on-error so a post-publish sync hiccup
  cannot fail an already-published release (npm immutability would block re-run).
- auto-backmerge.yml inline sync: set -euo pipefail + validate VERSION before
  any shell use (closes a ${VERSION}-in-commit-message injection vector); git
  add -u instead of -A.
- sync-next-version.cjs: reuse an existing open PR instead of failing gh pr
  create on rc re-runs; regex-parse the PR number and fail loud; discriminate
  the git diff --cached --quiet exit code (only status 1 == has-diff, else
  rethrow); git add -u to avoid sweeping runner artifacts into next; tolerate
  already-merged on admin merge.
- tests: +2 (existing-PR reuse, non-diff rethrow); 14/14 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-12 13:29:45 -04:00
committed by GitHub
parent c70842b6df
commit 7f1d49935c
5 changed files with 433 additions and 0 deletions

View File

@@ -24,6 +24,9 @@ permissions:
contents: write
pull-requests: write
env:
NODE_VERSION: 24
jobs:
backmerge:
# Phase-1 gate: leave false until `next` exists. Flip to `true` in Phase 2.
@@ -36,6 +39,10 @@ jobs:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ env.NODE_VERSION }}
- name: Verify next branch exists
id: check
run: |
@@ -99,6 +106,20 @@ jobs:
echo "reused=false" >> "$GITHUB_OUTPUT"
fi
- name: Sync next's version to main's released version
if: steps.check.outputs.next_exists == 'true'
run: |
set -euo pipefail
VERSION=$(git show origin/main:package.json | node -pe "JSON.parse(require('fs').readFileSync(0,'utf8')).version")
case "$VERSION" in
[0-9]*.[0-9]*.[0-9]*) : ;;
*) echo "refusing to sync next: unexpected version '$VERSION' from origin/main" >&2; exit 1 ;;
esac
node scripts/sync-next-version.cjs "$VERSION" --in-place
git add -u
git diff --cached --quiet || git commit -m "chore: sync next package version to ${VERSION}"
git push origin HEAD
- name: Open or update PR
if: steps.check.outputs.next_exists == 'true'
id: openpr

View File

@@ -463,6 +463,14 @@ jobs:
PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }}
run: node scripts/verify-npm-publish.cjs --package @opengsd/gsd-core --version "$PRE_VERSION" --dist-tag next
- name: Sync next branch to the published pre-release
if: ${{ !inputs.dry_run }}
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GSD_BOT_PR_TOKEN || secrets.GITHUB_TOKEN }}
PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }}
run: node scripts/sync-next-version.cjs "$PRE_VERSION"
- name: Summary
env:
PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }}

View File

@@ -151,3 +151,26 @@ right; only the *movable placeholder* mechanic was wrong.
*(Recommend: keep the rc tags — harmless, immutable, and they anchor the GitHub prerelease.)*
3. `-dev` floor increment: next-patch (`A.B.(C+1)-dev.0`, the precedence-safe default above) or
next-minor (`A.(B+1).0-dev.0`)? *(Recommend: next-patch floor.)*
## Amendment (2026-06-12, #1104): `next` tracks the last published release
**Supersedes** the §2 / "Resolved by maintainer" choice to rest `next` on a `-dev` stream.
The `-dev` floor (e.g. `1.3.1-dev.0`) was never published to npm, yet it became the
source-of-truth version on the default branch and leaked to the real world via source/dev
installs that report `package.json`'s version — a version no release ever bore. To eliminate
phantom versions, `next` now **rests at the last published release** and is synced
automatically by the release pipeline for **every** release type:
- **finalize / hotfix** (these push `main`): the existing `main → next` back-merge
(`.github/workflows/auto-backmerge.yml`) sets `next`'s version to `main`'s released version,
folded into the same back-merge PR.
- **rc** (publishes a pre-release to the `release/<version>` branch + `@next`; does **not** push
`main`): the `rc` job in `.github/workflows/release.yml` opens and admin-merges a
`chore: sync next package version` PR after a confirmed publish.
Both paths share `scripts/sync-next-version.cjs`, which sets `package.json` and stamps the
runtime manifests (`plugin.json`, `gemini-extension.json`) via the `version` lifecycle hook,
and **refuses any non-release version string** (fail-closed — a `-dev`/placeholder can never be
written to `next` again). Open question 3 (the `-dev` floor increment) is therefore moot: there
is no `-dev` floor.

View File

@@ -0,0 +1,133 @@
'use strict';
/**
* Sync the `next` branch's package.json version to a published release version.
*
* The release pipeline bumps the version only on the release/X.Y.Z branch at
* publish time, so `next` drifts and can carry a never-published placeholder
* (e.g. 1.3.1-dev.0) that leaks to source/dev installs reporting package.json
* version. This keeps `next` equal to the last published release for every
* release type (rc / hotfix / final). See issue #1104.
*
* Modes:
* default — open + admin-merge a `chore: sync next version` PR (used by
* release.yml's rc job, which has no next-targeting PR of its
* own). Idempotent: a no-op when `next` is already at the target.
* --in-place — set the version (+ manifests, via the npm `version` hook) in
* the current working tree only; the caller commits/pushes (used
* by auto-backmerge.yml, which folds it into its existing
* main->next PR).
*
* Subprocesses are bounded (repo convention) and the exec seam is injectable so
* the orchestration is unit-testable without git/gh/npm.
*/
const { execFileSync } = require('child_process');
const RELEASE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+(-(rc|beta)\.[0-9]+)?$/;
/** True iff `v` is a publishable release version: X.Y.Z optionally -rc.N / -beta.N. Rejects -dev and anything else. */
function isReleaseVersion(v) {
return typeof v === 'string' && RELEASE_VERSION.test(v);
}
/** Read the `version` field out of a package.json text blob. Throws on a missing/non-string version. */
function versionFromPackageJson(text) {
const parsed = JSON.parse(text);
if (typeof parsed.version !== 'string') throw new Error('package.json has no string version');
return parsed.version;
}
function defaultRun(cmd, args, opts = {}) {
return execFileSync(cmd, args, {
encoding: 'utf8',
timeout: opts.timeout ?? 30000,
stdio: opts.stdio ?? ['ignore', 'pipe', 'inherit'],
});
}
/** Set package.json version (and manifests, via the npm `version` lifecycle hook) in the current tree. */
function applyVersion(version, { run = defaultRun } = {}) {
if (!isReleaseVersion(version)) {
throw new Error(`refusing to sync next to invalid/non-release version '${version}'`);
}
run('npm', ['version', version, '--no-git-tag-version', '--allow-same-version'], { timeout: 60000 });
}
/** Full PR-based sync to the `next` branch. Returns 'noop' | 'synced'. */
function syncViaPr(version, { run = defaultRun } = {}) {
if (!isReleaseVersion(version)) {
throw new Error(`refusing to sync next to invalid/non-release version '${version}'`);
}
run('git', ['fetch', 'origin', 'next'], { timeout: 30000 });
const current = versionFromPackageJson(run('git', ['show', 'origin/next:package.json'], { timeout: 15000 }));
if (current === version) {
process.stdout.write(`next already at ${version} — nothing to sync\n`);
return 'noop';
}
const branch = `chore/sync-next-version-${version}`;
run('git', ['checkout', '-B', branch, 'origin/next'], { timeout: 15000 });
applyVersion(version, { run });
run('git', ['add', '-u'], { timeout: 15000 });
// `git diff --cached --quiet` exits 0 when there is NO staged diff (execFileSync
// returns), exits 1 when there IS one (execFileSync throws). Proceed only on a diff.
let hasDiff = false;
try {
run('git', ['diff', '--cached', '--quiet'], { timeout: 15000 });
} catch (err) {
// exit 1 == there is a staged diff (expected); anything else is a real failure.
if (err && err.status === 1) hasDiff = true;
else throw err;
}
if (!hasDiff) {
process.stdout.write('no changes to commit — nothing to sync\n');
return 'noop';
}
run('git', ['commit', '-m', `chore: sync next package version to ${version}`], { timeout: 15000 });
run('git', ['push', '--force-with-lease', 'origin', branch], { timeout: 60000 });
let prUrl = run('gh', ['pr', 'list', '--head', branch, '--base', 'next', '--state', 'open',
'--json', 'url', '--jq', '.[0].url // ""'], { timeout: 30000 }).trim();
if (!prUrl) {
prUrl = run('gh', ['pr', 'create', '--base', 'next', '--head', branch,
'--title', `chore: sync next package version to ${version}`,
'--body', `Automated: keep \`next\`'s package.json at the last published release (\`${version}\`) so the default branch never carries a never-published version. Generated by the release pipeline (#1104).`,
], { timeout: 60000 }).trim();
}
const m = prUrl.match(/\/pull\/(\d+)\b/);
if (!m) throw new Error(`could not parse PR number from gh output: ${prUrl}`);
const prNum = m[1];
try {
run('gh', ['pr', 'edit', prNum, '--add-label', 'automation', '--add-label', 'no-changelog'], { timeout: 30000 });
} catch {
/* labels are best-effort; admin-merge below still lands the PR */
}
try {
run('gh', ['pr', 'merge', '--admin', '--merge', prNum], { timeout: 60000 });
} catch (err) {
// tolerate "already merged"; rethrow anything else
const msg = String((err && (err.stderr || err.message)) || '');
if (!/already merged|not mergeable|Merged/i.test(msg)) throw err;
}
process.stdout.write(`synced next -> ${version} via PR #${prNum}\n`);
return 'synced';
}
function main(argv = process.argv.slice(2), deps = {}) {
const inPlace = argv.includes('--in-place');
const version = argv.find((a) => !a.startsWith('--'));
if (!version) throw new Error('usage: sync-next-version.cjs <version> [--in-place]');
if (inPlace) {
applyVersion(version, deps);
return;
}
syncViaPr(version, deps);
}
if (require.main === module) {
try {
main();
} catch (e) {
process.stderr.write(`error: ${e.message}\n`);
process.exitCode = 1;
}
}
module.exports = { isReleaseVersion, versionFromPackageJson, applyVersion, syncViaPr, main };

View File

@@ -0,0 +1,248 @@
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { isReleaseVersion, versionFromPackageJson, applyVersion, syncViaPr, main } = require('../scripts/sync-next-version.cjs');
// ---------------------------------------------------------------------------
// Run-stub factory
// ---------------------------------------------------------------------------
/**
* Creates a run stub for injection into applyVersion/syncViaPr/main.
*
* `responses` is an array of matchers:
* { cmd, args0, returns } — if cmd matches and (args0 is set) args[0] matches → return `returns`
* { cmd, args0, throws } — same match but throws the value
*
* Unmatched calls return '' by default.
*/
function makeRun(responses = []) {
const calls = [];
function run(cmd, args) {
calls.push({ cmd, args: [...args] });
for (const r of responses) {
const cmdMatch = !r.cmd || r.cmd === cmd;
const args0Match = r.args0 === undefined || r.args0 === args[0];
const args1Match = r.args1 === undefined || r.args1 === args[1];
if (cmdMatch && args0Match && args1Match) {
if ('throws' in r) throw r.throws;
return r.returns ?? '';
}
}
return '';
}
run.calls = calls;
return run;
}
// ---------------------------------------------------------------------------
// A. isReleaseVersion
// ---------------------------------------------------------------------------
test('isReleaseVersion — true for valid release versions', () => {
for (const v of ['1.5.0', '1.5.0-rc.2', '1.5.0-beta.1', '10.20.30', '0.0.1', '1.5.0-rc.10']) {
assert.equal(isReleaseVersion(v), true, `expected true for '${v}'`);
}
});
test('isReleaseVersion — false for invalid/non-release versions', () => {
for (const v of ['1.3.1-dev.0', '1.5.0-dev.0', '1.5', 'v1.5.0', '1.5.0-rc', '1.5.0-rc.x', '1.5.0-alpha.1', '', ' 1.5.0', null, undefined, 123]) {
assert.equal(isReleaseVersion(v), false, `expected false for ${JSON.stringify(v)}`);
}
});
// ---------------------------------------------------------------------------
// B. versionFromPackageJson
// ---------------------------------------------------------------------------
test('versionFromPackageJson — returns version for valid JSON', () => {
assert.equal(versionFromPackageJson('{"version":"1.5.0-rc.2"}'), '1.5.0-rc.2');
});
test('versionFromPackageJson — throws for missing version field', () => {
assert.throws(() => versionFromPackageJson('{}'), /no string version/);
});
test('versionFromPackageJson — throws for invalid JSON', () => {
assert.throws(() => versionFromPackageJson('not json'));
});
// ---------------------------------------------------------------------------
// C. applyVersion
// ---------------------------------------------------------------------------
test('applyVersion — throws for invalid version without calling run', () => {
const run = makeRun();
assert.throws(
() => applyVersion('1.3.1-dev.0', { run }),
/refusing to sync next to invalid\/non-release version/,
);
assert.equal(run.calls.length, 0, 'run should not have been called');
});
test('applyVersion — calls npm version once with correct args for valid version', () => {
const run = makeRun([{ cmd: 'npm', returns: '' }]);
applyVersion('1.5.0', { run });
assert.equal(run.calls.length, 1);
const [call] = run.calls;
assert.equal(call.cmd, 'npm');
assert.deepEqual(call.args, ['version', '1.5.0', '--no-git-tag-version', '--allow-same-version']);
});
// ---------------------------------------------------------------------------
// D. syncViaPr — idempotent (version already matches)
// ---------------------------------------------------------------------------
test('syncViaPr — noop when next is already at the target version', () => {
const run = makeRun([
// git fetch — just returns
{ cmd: 'git', args0: 'fetch', returns: '' },
// git show — returns the SAME version as target
{ cmd: 'git', args0: 'show', returns: '{"version":"1.5.0-rc.2"}' },
]);
const result = syncViaPr('1.5.0-rc.2', { run });
assert.equal(result, 'noop');
// Must NOT have called checkout, commit, or gh
const cmdArgs = run.calls.map((c) => `${c.cmd} ${c.args[0]}`);
assert.ok(!cmdArgs.some((s) => s.includes('checkout')), 'should not checkout');
assert.ok(!cmdArgs.some((s) => s.includes('commit')), 'should not commit');
assert.ok(!cmdArgs.some((s) => s === 'gh pr'), 'should not call gh');
assert.ok(!run.calls.some((c) => c.cmd === 'gh'), 'should not call gh at all');
});
// ---------------------------------------------------------------------------
// E. syncViaPr — happy path (version differs, diff present)
// ---------------------------------------------------------------------------
test('syncViaPr — happy path: syncs and returns "synced"', () => {
const run = makeRun([
{ cmd: 'git', args0: 'fetch', returns: '' },
{ cmd: 'git', args0: 'show', returns: '{"version":"1.4.0"}' },
{ cmd: 'git', args0: 'checkout', returns: '' },
{ cmd: 'npm', returns: '' },
{ cmd: 'git', args0: 'add', returns: '' },
// diff --cached --quiet throws with status 1 → there IS a staged diff
{ cmd: 'git', args0: 'diff', throws: Object.assign(new Error('diff'), { status: 1 }) },
{ cmd: 'git', args0: 'commit', returns: '' },
{ cmd: 'git', args0: 'push', returns: '' },
// gh pr list returns '' → no existing PR, so create path runs
{ cmd: 'gh', args0: 'pr', args1: 'list', returns: '' },
{ cmd: 'gh', args0: 'pr', args1: 'create', returns: 'https://github.com/o/r/pull/777\n' },
{ cmd: 'gh', args0: 'pr', args1: 'edit', returns: '' },
{ cmd: 'gh', args0: 'pr', args1: 'merge', returns: '' },
]);
const result = syncViaPr('1.5.0-rc.2', { run });
assert.equal(result, 'synced');
// Verify ordered calls: checkout -B, npm version, git commit, git push, gh pr create, gh pr merge --admin
const calls = run.calls;
const checkoutIdx = calls.findIndex((c) => c.cmd === 'git' && c.args[0] === 'checkout');
assert.ok(checkoutIdx >= 0, 'should have checkout');
assert.equal(calls[checkoutIdx].args[1], '-B');
const npmIdx = calls.findIndex((c) => c.cmd === 'npm');
assert.ok(npmIdx > checkoutIdx, 'npm version after checkout');
const commitIdx = calls.findIndex((c) => c.cmd === 'git' && c.args[0] === 'commit');
assert.ok(commitIdx > npmIdx, 'commit after npm version');
const pushIdx = calls.findIndex((c) => c.cmd === 'git' && c.args[0] === 'push');
assert.ok(pushIdx > commitIdx, 'push after commit');
const prCreateIdx = calls.findIndex((c) => c.cmd === 'gh' && c.args[0] === 'pr' && c.args[1] === 'create');
assert.ok(prCreateIdx > pushIdx, 'gh pr create after push');
const prMergeIdx = calls.findIndex((c) => c.cmd === 'gh' && c.args[0] === 'pr' && c.args[1] === 'merge');
assert.ok(prMergeIdx > prCreateIdx, 'gh pr merge after gh pr create');
assert.ok(calls[prMergeIdx].args.includes('--admin'), 'merge uses --admin');
// Assert branch name
const checkoutCall = calls[checkoutIdx];
const branchArg = checkoutCall.args[2]; // git checkout -B <branch> origin/next
assert.ok(branchArg.includes('chore/sync-next-version-1.5.0-rc.2'), `branch should contain 'chore/sync-next-version-1.5.0-rc.2', got '${branchArg}'`);
});
// ---------------------------------------------------------------------------
// F. syncViaPr — rejects invalid version before any run call
// ---------------------------------------------------------------------------
test('syncViaPr — throws for invalid version before calling run', () => {
const run = makeRun();
assert.throws(
() => syncViaPr('1.3.1-dev.0', { run }),
/refusing to sync next to invalid\/non-release version/,
);
assert.equal(run.calls.length, 0, 'run must not be called');
});
// ---------------------------------------------------------------------------
// G. main — --in-place routes to applyVersion; no PR/fetch/checkout
// ---------------------------------------------------------------------------
test('main --in-place calls npm version and does not fetch/checkout/gh', () => {
const run = makeRun([{ cmd: 'npm', returns: '' }]);
main(['1.5.0', '--in-place'], { run });
assert.ok(run.calls.some((c) => c.cmd === 'npm'), 'should call npm');
assert.ok(!run.calls.some((c) => c.cmd === 'git' && c.args[0] === 'fetch'), 'should not fetch');
assert.ok(!run.calls.some((c) => c.cmd === 'git' && c.args[0] === 'checkout'), 'should not checkout');
assert.ok(!run.calls.some((c) => c.cmd === 'gh'), 'should not call gh');
});
test('main (no --in-place) with matching version → noop via syncViaPr', () => {
const run = makeRun([
{ cmd: 'git', args0: 'fetch', returns: '' },
{ cmd: 'git', args0: 'show', returns: '{"version":"1.5.0"}' },
]);
// Should not throw; syncViaPr returns 'noop'
main(['1.5.0'], { run });
assert.ok(!run.calls.some((c) => c.cmd === 'gh'), 'should not call gh on noop');
});
// ---------------------------------------------------------------------------
// H. syncViaPr — reuses existing open PR
// ---------------------------------------------------------------------------
test('syncViaPr — reuses an existing open PR instead of creating', () => {
const run = makeRun([
{ cmd: 'git', args0: 'fetch', returns: '' },
{ cmd: 'git', args0: 'show', returns: '{"version":"1.4.0"}' },
{ cmd: 'git', args0: 'checkout', returns: '' },
{ cmd: 'npm', returns: '' },
{ cmd: 'git', args0: 'add', returns: '' },
{ cmd: 'git', args0: 'diff', throws: Object.assign(new Error('diff'), { status: 1 }) },
{ cmd: 'git', args0: 'commit', returns: '' },
{ cmd: 'git', args0: 'push', returns: '' },
// gh pr list returns an existing PR URL
{ cmd: 'gh', args0: 'pr', args1: 'list', returns: 'https://github.com/o/r/pull/555\n' },
{ cmd: 'gh', args0: 'pr', args1: 'edit', returns: '' },
{ cmd: 'gh', args0: 'pr', args1: 'merge', returns: '' },
]);
const result = syncViaPr('1.5.0-rc.2', { run });
assert.equal(result, 'synced');
// Must NOT have called gh pr create
assert.ok(
!run.calls.some((c) => c.cmd === 'gh' && c.args[0] === 'pr' && c.args[1] === 'create'),
'should not call gh pr create when existing PR found',
);
// Admin merge must target PR 555
const mergeCall = run.calls.find((c) => c.cmd === 'gh' && c.args[0] === 'pr' && c.args[1] === 'merge');
assert.ok(mergeCall, 'should call gh pr merge');
assert.ok(mergeCall.args.includes('555'), 'merge should target PR 555');
});
// ---------------------------------------------------------------------------
// I. syncViaPr — rethrows when git diff fails for a non-diff reason
// ---------------------------------------------------------------------------
test('syncViaPr — rethrows when git diff fails for a non-diff reason', () => {
const run = makeRun([
{ cmd: 'git', args0: 'fetch', returns: '' },
{ cmd: 'git', args0: 'show', returns: '{"version":"1.4.0"}' },
{ cmd: 'git', args0: 'checkout', returns: '' },
{ cmd: 'npm', returns: '' },
{ cmd: 'git', args0: 'add', returns: '' },
// git diff throws with status 128 → real error, not a diff signal
{ cmd: 'git', args0: 'diff', throws: Object.assign(new Error('fatal: not a git repo'), { status: 128 }) },
]);
assert.throws(
() => syncViaPr('1.5.0-rc.2', { run }),
/fatal: not a git repo/,
'should rethrow the real error',
);
});