* 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>
249 lines
11 KiB
JavaScript
249 lines
11 KiB
JavaScript
'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',
|
|
);
|
|
});
|