Files
msd-core/scripts/sync-next-version.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

134 lines
5.7 KiB
JavaScript

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