* 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 (commitdd4c90f82'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 commit27f69cc48('chore: sync next package version to 1.7.0-rc.6') and commitdd4c90f82('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
115 lines
5.2 KiB
JavaScript
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(' '));
|
|
});
|
|
});
|