Files
msd-core/tests/sync-next-version.test.cjs
Tom Boucher 7f1d49935c 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>
2026-06-12 13:29:45 -04:00

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',
);
});