Files
msd-core/tests/release-finalize-syncs-next-version.test.cjs
Tom Boucher 6b196ef638 fix(#2423): finalize job syncs next package.json after final release (#2437)
* fix(release): finalize job calls sync-next-version.cjs to bump next after final release (#2423)

The release pipeline's 'finalize' job shipped X.Y.0 to npm 'latest' and
merged to main, but never bumped 'next' to match. 'scripts/sync-next-version.cjs'
exists exactly for this — its docstring promises to run 'for every release
type (rc / hotfix / final)' — but it was wired only into the 'rc' job
(release.yml:479), not 'finalize'. As a result, after 1.7.0 shipped on
2026-07-15, 'next' stayed at 1.7.0-rc.6 and every npm script banner on
'next' (and feature branches cut from it) reported the stale rc version.

Regression of #1104 — closed incomplete (covered rc only, not final).

This patch:
  - adds a 'Sync next branch to the published release' step to the
    'finalize' job, mirroring the rc job's pattern at line 479 (uses
    VERSION from inputs.version rather than PRE_VERSION from steps.prerelease,
    since finalize does not run the prerelease step);
  - gates it on !inputs.dry_run + continue-on-error:true (matches rc);
  - adds tests/release-finalize-syncs-next-version.test.cjs — a structural
    YAML assertion that fails against the pre-fix workflow and passes after.
    Failing-first demonstrated during development; the test parses job blocks
    by indentation rather than grep so it stays valid as the file grows.

Root-cause diagnosis: scripts/sync-next-version.cjs:14 docstring admits
'used by release.yml's rc job, which has no next-targeting PR of its own'.
release.yml:506-685 (finalize job) had no sync-next-version step before
this patch. Every prior rc.N release has a matching 'chore: sync next
package version to 1.7.0-rc.N' commit; there is no such commit for 1.7.0.

* chore(release): sync next package version to 1.7.0 (#2423)

Replays the canonical 'chore: sync next package version to <v>' commit
that the release pipeline's rc job auto-produces via scripts/sync-next-version.cjs,
for the 1.7.0 final release that shipped on 2026-07-15 (commit dd4c90f82
'chore: finalize v1.7.0' on main). Without this, 'next' (and every feature
branch cut from it) carried 1.7.0-rc.6 indefinitely and reported it in
every npm script banner (e.g. 'lint:ci').

Bumps 43 synchronized manifests via the npm 'version' lifecycle hook
(scripts/sync-manifest-versions.cjs --stage + scripts/gen-capability-registry.cjs
--write), matching the file set of commit 27f69cc48 ('chore: sync next
package version to 1.7.0-rc.6') and commit dd4c90f82 ('chore: finalize
v1.7.0').

This is the immediate Layer-1 repair for #2423. Layer-2 (prevent recurrence)
is the workflow patch in the previous commit; Layer-3 (regression test)
ships with it. Future X.Y.0 final releases will produce this commit
automatically once the workflow fix lands.

* test(release): tighten #2423 dry-run gate assertion to the sync step

Code review of fix/2423 found that test #3 ('gates sync-next-version on
!inputs.dry_run') asserted too loosely: it scanned the entire finalize
block for any '!inputs.dry_run' line, so it would still pass if the
gate were stripped from the sync-next-version step specifically — the
exact regression the test name promises to catch. The finalize job has
multiple steps with their own !inputs.dry_run gates (e.g. Verify
publish), so the loose version masked the very bug it claimed to detect.

Tighten by extracting the specific YAML step block containing
'scripts/sync-next-version.cjs' and asserting the gate appears within
THAT step's lines, not anywhere in the job. Verified the tightened test:

  - PASSES against the post-fix workflow (sync step has its own gate)
  - FAILS when the sync step's gate is stripped (even when other steps
    in finalize retain their own !inputs.dry_run gates) — the exact
    regression that previously slipped through

Adds extractStepBlockContaining(jobBlock, marker) helper alongside the
existing extractJobBlock(text, jobName). Reuses the same indentation-
based parsing, so it stays valid as the file grows.

* chore(changeset): backfill pr:2437 in .changeset/sturdy-ibex-jump.md

CLAUDE.md changeset convention: 'Use placeholder pr:0 during initial commit.
Backfill immediately after gh api POST /pulls returns the real number.'

PR #2437 created from branch fix/2423-release-finalize-sync-next-version.

* fix(#2423): add see #2423 to allow-test-rule exemption per ADR-456

CI lint-allow-test-rule-refs failed on PR #2437: ADR-456 requires new
allow-test-rule exemptions added after the ADR's acceptance to include a
tracking issue number in the comment, in the form
  // allow-test-rule: <reason> (see #NNN)
The exemption added in commit 976c8b0a2 lacked this ref. Fixed.

Verified locally:
  node scripts/lint-allow-test-rule-refs.cjs
    → ok lint-allow-test-rule-refs: 173 grandfathered exemption(s) tracked, no novel untracked offenders
2026-07-19 14:33:19 -04:00

115 lines
5.2 KiB
JavaScript

// allow-test-rule: source-text-is-the-product (see #2423)
// .github/workflows/release.yml is the deployed CI contract; asserting that
// the finalize job wires in scripts/sync-next-version.cjs is only expressible
// against the workflow text. See regression #2423 — the finalize job shipped
// 1.7.0 to npm latest without bumping next, which sat stale at 1.7.0-rc.6 and
// leaked into every npm script banner (e.g. `lint:ci`).
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const RELEASE_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'release.yml');
/**
* Extract a top-level job block from a GitHub Actions workflow YAML file.
*
* Jobs sit at column 2 (`^ <job>:`). Returns the lines from the job header
* through the line before the next top-level key (column 0) or the next job.
* @param {string} text - workflow file text
* @param {string} jobName - job key to extract
* @returns {string[]} lines belonging to the job (including its header)
*/
function extractJobBlock(text, jobName) {
const lines = text.split(/\r?\n/);
const startIdx = lines.findIndex((l) => new RegExp(`^\\s{2}${jobName}:\\s*$`).test(l));
assert.ok(startIdx >= 0, `job '${jobName}' not found in release.yml`);
let endIdx = lines.length;
for (let i = startIdx + 1; i < lines.length; i++) {
// Next top-level key (column 0, non-blank, non-comment) ends the job block.
if (/^\S/.test(lines[i])) {
endIdx = i;
break;
}
}
return lines.slice(startIdx, endIdx);
}
/**
* Extract the YAML step block (within a job) that contains `marker` text.
*
* Steps begin at `^ - name:` (6-space indent for a job at column 2).
* Returns the lines from the step's `- name:` line through the line before
* the next `- name:` at the same indent (or end of the job block).
*
* Used to assert invariants about a SPECIFIC step rather than the whole job,
* so a test like "the sync step is gated on !inputs.dry_run" can't pass by
* accident because some OTHER step in the same job happens to have that gate.
* @param {string[]} jobBlock - lines of the containing job (from extractJobBlock)
* @param {string} marker - substring identifying the target step
* @returns {string[]} lines of the matching step (including its `- name:` header)
*/
function extractStepBlockContaining(jobBlock, marker) {
const stepStarts = [];
for (let i = 0; i < jobBlock.length; i++) {
if (/^\s{6}- name:/.test(jobBlock[i])) {
stepStarts.push(i);
}
}
for (let s = 0; s < stepStarts.length; s++) {
const start = stepStarts[s];
const end = s + 1 < stepStarts.length ? stepStarts[s + 1] : jobBlock.length;
const stepLines = jobBlock.slice(start, end);
if (stepLines.some((l) => l.includes(marker))) {
return stepLines;
}
}
return [];
}
describe('release-finalize-syncs-next-version (regression #2423)', () => {
const text = fs.readFileSync(RELEASE_WORKFLOW, 'utf8');
test('the rc job still wires sync-next-version.cjs (control — must not regress)', () => {
const rcBlock = extractJobBlock(text, 'rc');
const hits = rcBlock.filter((l) => l.includes('scripts/sync-next-version.cjs'));
assert.notStrictEqual(hits.length, 0,
'rc job must call scripts/sync-next-version.cjs (lost during refactor?)');
});
test('the finalize job calls scripts/sync-next-version.cjs after publishing', () => {
const finalizeBlock = extractJobBlock(text, 'finalize');
const stepLines = finalizeBlock.filter((l) => l.includes('scripts/sync-next-version.cjs'));
assert.notStrictEqual(stepLines.length, 0,
[
'finalize job must call scripts/sync-next-version.cjs to bump next after',
'a final release (regression #2423). Without it, next drifts to whatever',
'rc.N version the release branch started from, and every npm script banner',
'on next (and feature branches cut from it) reports the stale rc version.',
].join(' '));
});
test('the finalize job gates sync-next-version on !inputs.dry_run', () => {
// A dry-run finalize must not open a sync PR. We assert this against the
// SPECIFIC step that runs sync-next-version.cjs (not the whole finalize
// block) so the test cannot pass by accident if some OTHER step in the
// finalize job happens to have a !inputs.dry_run gate. Regression of the
// loose-invariant version of this test, caught during #2423 code review.
const finalizeBlock = extractJobBlock(text, 'finalize');
const syncStep = extractStepBlockContaining(finalizeBlock, 'scripts/sync-next-version.cjs');
assert.notStrictEqual(syncStep.length, 0,
'cannot locate the sync-next-version step within the finalize job — has the step shape changed?');
const stepHasGate = syncStep.some((l) => l.includes('!inputs.dry_run'));
assert.ok(stepHasGate,
[
'the sync-next-version step itself must be gated on !inputs.dry_run',
'(regression of the loose-invariant test that passed when ANY step in',
'finalize had the gate). Without this guard, a dry-run finalize would',
'open a real chore: sync next package version PR against next.',
].join(' '));
});
});