Files
msd-core/tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs
Tom Boucher 0efe4f0612 fix(3621): release-sdk hotfix cherry-picks test-fixture commits (#3623)
* fix(3621): cherry-pick test-fixture commits in hotfix runs

The release-sdk hotfix loop excluded test-fixture updates that align CI
with a cherry-picked production fix, leaving the hotfix branch with new
production behavior and stale test assertions. Broke v1.42.3 CI (run
25949422676) when fix(3562) was cherry-picked but its bundled test
correction in docs(3562) commit 08848df8 was POLICY_SKIPPED by the
prefix filter.

Two-part fix:

1. release-sdk.yml prefix regex now accepts test: alongside fix:/chore:.
   feat:, docs:, refactor: still POLICY_SKIPPED as before.

2. scripts/diff-touches-shipped-paths.cjs treats tests/-rooted paths and
   sdk/src vitest specs as CI-gating-equivalent. A test: commit touching
   only those paths now passes the shipped-paths gate.

The #2980 push-blocking guard is preserved as a separate first-priority
check: any commit touching .github/workflows/<file> still skips
regardless of test paths in the same bundle, because the default
GITHUB_TOKEN lacks the workflow scope and the push step would fail.

New regression coverage in tests/bug-3621-cherry-pick-test-fixtures.test.cjs:
- workflow prefix regex includes test:
- isCiGating accepts tests/ and sdk/src vitest specs, rejects
  non-spec sdk/src paths and incidental "test" name occurrences
- classifier exits 0 for test-only, mixed test+docs, and the original
  shipped paths
- classifier exits 1 for pure docs-only and workflow-only diffs
- new explicit assertion that #2980 push-blocking wins over #3621:
  workflow + test + changelog bundle still skips

Adjusted one pre-existing bug-2980 test fixture to use a non-
push-blocking non-shipped path (planning/notes.md) instead of
.github/workflows/release-sdk.yml. The original assertion was
documenting "mixed diff includes shipped path → include" but its
fixture happened to also trigger the push-blocking guard now made
explicit by this PR.

Fixes #3621

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(release-sdk): align hotfix summary labels with test matcher

* fix(3621): align operator-facing strings with the fix/chore/test matcher

The candidate-loop regex was updated to accept test: but several
human-facing strings in the same job still read fix/chore. Update every
description/comment/summary line for consistency so operators reading
the run summary or workflow_dispatch inputs see the same set of accepted
prefixes the matcher actually applies.

Also corrected the NON_SHIPPED_SKIPPED summary text — it claimed test
changes belong on main, not in a hotfix. That assumption is what #3621
fixes; tests under tests/ and sdk/src vitest specs are now CI-gating
candidates and may be picked. The summary now scopes the never-pick
guidance to CI / docs / planning paths only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:43:21 -04:00

309 lines
14 KiB
JavaScript

/**
* Regression test for bug #2980
*
* The release-sdk hotfix cherry-pick loop's `fix:`/`chore:` filter is
* too broad: it picks anything with that conventional-commit type
* regardless of whether the diff can affect the published npm package.
* That caused two compounding problems:
*
* 1. CI-only fixes (release-sdk.yml, hotfix tooling) were cherry-picked
* into hotfix branches even though they cannot change what ships.
* 2. The subset of those CI-only fixes touching `.github/workflows/*`
* caused the prepare job's `git push` to be rejected by GitHub —
* the default GITHUB_TOKEN lacks the `workflow` scope:
*
* ! [remote rejected] hotfix/X.YY.Z -> hotfix/X.YY.Z
* (refusing to allow a GitHub App to create or update workflow
* ... without `workflows` permission)
*
* v1.39.1 hit this on PR #2977 (run 25232010071): #2977 cherry-
* picked cleanly because earlier workflow-file fixes had been
* skipped on conflict, then the push exploded.
*
* Fix (root cause): pre-pick guard that checks whether the candidate
* commit's diff intersects the npm tarball's shipped paths (entries in
* `package.json` `files` plus `package.json` itself). Non-shipping
* commits are skipped with an informational summary entry; the
* workflow-file rejection is now a non-issue because workflow files
* are not in `files`.
*
* The shipped-paths classifier lives in
* `scripts/diff-touches-shipped-paths.cjs` rather than inline in the
* workflow YAML so its rules are unit-testable.
*
* This test covers two layers:
* - Static workflow assertions (the loop calls the script before
* attempting the pick, the result drives a NON_SHIPPED_SKIPPED
* bucket, and the run summary surfaces it).
* - Behavioral assertions on the classifier script itself (matches
* `npm pack` semantics for `files` entries).
*/
'use strict';
// allow-test-rule: source-text-is-the-product
// release-sdk.yml IS the product for hotfix automation; the static
// assertions extract the "Prepare hotfix branch" run block via
// indentation-aware YAML parsing rather than raw-text grep across the
// whole document.
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const REPO_ROOT = path.join(__dirname, '..');
const WORKFLOW_PATH = path.join(REPO_ROOT, '.github', 'workflows', 'release-sdk.yml');
const CLASSIFIER_PATH = path.join(REPO_ROOT, 'scripts', 'diff-touches-shipped-paths.cjs');
function extractStepRun(workflowText, stepName) {
const lines = workflowText.split('\n');
for (let i = 0; i < lines.length; i++) {
const m = lines[i].match(/^(\s*)- name:\s*(.+?)\s*$/);
if (!m || m[2] !== stepName) continue;
const stepIndent = m[1].length;
let j = i + 1;
while (j < lines.length) {
const peek = lines[j];
if (/^\s*- /.test(peek)) {
const peekIndent = peek.match(/^(\s*)/)[1].length;
if (peekIndent <= stepIndent) break;
}
const runMatch = peek.match(/^(\s*)run:\s*\|(?:[+-])?\s*$/);
if (runMatch) {
const blockIndent = runMatch[1].length + 2;
const body = [];
for (let k = j + 1; k < lines.length; k++) {
const bodyLine = lines[k];
if (bodyLine.length === 0) {
body.push('');
continue;
}
const lead = bodyLine.match(/^(\s*)/)[1].length;
if (lead < blockIndent && bodyLine.trim() !== '') break;
body.push(bodyLine.slice(blockIndent));
}
return body.join('\n');
}
j++;
}
throw new Error(`step "${stepName}" found but no run: | block before step end`);
}
throw new Error(`step "${stepName}" not found in workflow`);
}
/**
* Slice the lines from the merge-commit pre-skip guard up to (but not
* including) the cherry-pick attempt. Any new pre-pick guard MUST live
* in this region to fire before the pick.
*/
function extractPrePickRegion(script) {
const lines = script.split('\n');
const startIdx = lines.findIndex(l => /merge commit — manual -m parent selection required/.test(l));
if (startIdx === -1) throw new Error('merge-commit pre-skip guard not found — sentinel for pre-pick region');
const endIdx = lines.findIndex((l, i) => i > startIdx && /git[^\n]*cherry-pick[^\n]*"\$SHA"/.test(l));
if (endIdx === -1) throw new Error('cherry-pick attempt not found after merge-commit guard');
return lines.slice(startIdx, endIdx).join('\n');
}
describe('bug-2980: release-sdk hotfix only picks commits that touch shipped paths', () => {
test('pre-pick guard runs the shipped-paths classifier before attempting the pick', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const prePick = extractPrePickRegion(script);
// Must call the classifier script. Inline grep on `.github/workflows/`
// would only catch the workflow-file subset of the bug — the broader
// root cause is "any non-shipping commit in a hotfix is meaningless"
// and the classifier encodes the precise `files`-whitelist rule.
assert.match(
prePick,
/git diff-tree --no-commit-id --name-only -r "\$SHA"/,
'pre-pick region must extract the candidate SHA\'s file list with `git diff-tree` so the classifier has accurate input (#2980)'
);
// After #2983 the classifier is invoked via the staged $CLASSIFIER
// variable (not the in-tree path), to survive the working-tree swap
// performed by `git checkout -b "$BRANCH" "$BASE_TAG"`. Either form
// proves the classifier participates; the bug-2983 test enforces
// the staged-path form specifically.
assert.match(
prePick,
/node "\$CLASSIFIER"/,
'pre-pick region must invoke `node "$CLASSIFIER"` (the staged classifier) — the in-tree path is unsafe after the base-tag checkout (#2980, #2983)'
);
// Skip-on-exit-1 dispatch: pre-#2983 used `if ! ... ; then skip`,
// but that conflated classifier errors (exit 2+) with the
// legitimate "not shipped" signal. Post-#2983 the dispatch is
// explicit `case "$CLASSIFIER_RC" in 1) skip ;; *) error ;; esac`.
// This test accepts the modern form; bug-2983 enforces it.
assert.match(
prePick,
/case "\$CLASSIFIER_RC" in[\s\S]+?1\)[\s\S]+?continue/,
'pre-pick region must skip on exit 1 via case-dispatch on $CLASSIFIER_RC — the post-#2983 shape that distinguishes "not shipped" from classifier errors (#2980, #2983)'
);
});
test('non-shipped skips land in NON_SHIPPED_SKIPPED, distinct from CONFLICT_SKIPPED and POLICY_SKIPPED', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const prePick = extractPrePickRegion(script);
assert.match(
prePick,
/^\s*NON_SHIPPED_SKIPPED="\$\{NON_SHIPPED_SKIPPED\}/m,
'non-shipped skip must append to NON_SHIPPED_SKIPPED — distinct from CONFLICT_SKIPPED (manual-review queue) and POLICY_SKIPPED (feat/refactor exclusions) (#2980)'
);
// The bucket must be initialized at the top of the loop alongside
// the other two — so a future `set -u` doesn't silently break it.
assert.match(
script,
/^\s*NON_SHIPPED_SKIPPED=""\s*$/m,
'NON_SHIPPED_SKIPPED must be initialized to empty alongside POLICY_SKIPPED and CONFLICT_SKIPPED (#2980)'
);
});
test('non-shipped skip emits no ::warning:: — the change cannot affect the package', () => {
// A non-shipped commit is by definition incapable of changing what
// ships, so the skip needs no operator alert. The summary bucket is
// informational; a yellow warning would imply remediation is
// possible, which would mislead operators.
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const prePick = extractPrePickRegion(script);
assert.doesNotMatch(
prePick,
/::warning::/,
'non-shipped skip must NOT emit a ::warning:: — the commit cannot change what ships, so a warning would falsely imply remediation is needed (#2980)'
);
});
test('run summary surfaces NON_SHIPPED_SKIPPED in its own section, framed as informational', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
assert.match(
script,
/if \[ -n "\$NON_SHIPPED_SKIPPED" \]/,
'run summary must conditionally render the NON_SHIPPED_SKIPPED bucket so empty hotfixes don\'t print an empty section (#2980)'
);
// The header must NOT use "manual review" framing — that's the
// CONFLICT_SKIPPED queue. Non-shipped skips need no manual action.
assert.doesNotMatch(
script,
/Skipped — touches no shipped paths[^\n]*manual review/,
'NON_SHIPPED_SKIPPED summary header must NOT imply manual review — non-shipping commits need no remediation (#2980)'
);
assert.match(
script,
/Skipped — touches no shipped paths[^\n]*informational/,
'NON_SHIPPED_SKIPPED summary header must signal "informational" so operators don\'t mistake it for the manual-review queue (#2980)'
);
});
});
describe('bug-2980: scripts/diff-touches-shipped-paths.cjs classifier semantics', () => {
function runClassifier(stdin, cwd) {
return spawnSync('node', [CLASSIFIER_PATH], {
cwd,
input: stdin,
encoding: 'utf8',
});
}
function makeFixtureRepo(filesArray) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2980-'));
fs.writeFileSync(
path.join(tmp, 'package.json'),
JSON.stringify({ name: 'fixture', version: '0.0.0', files: filesArray }, null, 2)
);
return tmp;
}
test('directory entry in `files` matches paths under that directory but not sibling prefixes', () => {
const tmp = makeFixtureRepo(['bin', 'sdk/dist']);
try {
// bin/foo.js is shipped (under bin/).
assert.equal(runClassifier('bin/foo.js\n', tmp).status, 0, 'bin/foo.js must be shipped');
// bin alone (the directory entry itself) is shipped.
assert.equal(runClassifier('bin\n', tmp).status, 0, 'bin (exact match) must be shipped');
// binaries/foo.js must NOT match bin (prefix-without-slash bug).
assert.equal(runClassifier('binaries/foo.js\n', tmp).status, 1, 'binaries/foo.js must NOT match bin/ — prefix without slash boundary is a classic bug');
// sdk/dist/cli.js is shipped.
assert.equal(runClassifier('sdk/dist/cli.js\n', tmp).status, 0, 'sdk/dist/cli.js must be shipped');
// sdk/src/cli.ts is NOT shipped (only sdk/dist is in `files`).
assert.equal(runClassifier('sdk/src/cli.ts\n', tmp).status, 1, 'sdk/src/cli.ts must NOT be shipped when only sdk/dist is whitelisted');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('package.json is always shipped even when not in `files`', () => {
// `npm pack` always includes package.json regardless of `files`. The
// classifier must mirror that, so a version-bump-only commit isn't
// wrongly skipped.
const tmp = makeFixtureRepo([]);
try {
assert.equal(runClassifier('package.json\n', tmp).status, 0, 'package.json must be classified as shipped — `npm pack` always includes it');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('package-lock.json is NOT shipped unless explicitly in `files`', () => {
// `npm pack` does NOT include package-lock.json by default. A
// lockfile-only commit can't change the published package's runtime
// behavior (consumers resolve their own lockfile from `dependencies`).
const tmp = makeFixtureRepo(['bin']);
try {
assert.equal(runClassifier('package-lock.json\n', tmp).status, 1, 'package-lock.json must NOT be classified as shipped when absent from `files` — `npm pack` excludes it by default');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('mixed diff is shipped if ANY path is shipped', () => {
// A commit that touches both a shipped file and an unrelated non-
// shipped, non-push-blocking file must be classified as shipped — the
// non-shipped paths are along for the ride, but the commit can still
// affect what ships. Note: this fixture deliberately avoids
// `.github/workflows/*`, which is push-blocking (#3621) and forces a
// skip regardless of other shipped paths in the same bundle.
const tmp = makeFixtureRepo(['bin']);
try {
const stdin = 'CHANGELOG.md\nbin/foo.js\nplanning/notes.md\n';
assert.equal(runClassifier(stdin, tmp).status, 0, 'mixed diff with at least one shipped path must classify as shipped');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('purely CI/test/docs commit is NOT shipped (the actual #2980 case)', () => {
// The classic case: a fix(release-sdk): commit that touches only
// .github/workflows/release-sdk.yml and a regression test under
// tests/. Pre-#2980 the loop picked it; the cherry-pick succeeded;
// the push then failed because of the workflow-file scope rule.
// Post-#2980 the loop skips it pre-pick — the push problem and the
// "meaningless pick" problem dissolve together.
const tmp = makeFixtureRepo(['bin', 'commands', 'sdk/dist']);
try {
const stdin = '.github/workflows/release-sdk.yml\ntests/bug-2980-shipped-paths.test.cjs\nCHANGELOG.md\n';
assert.equal(runClassifier(stdin, tmp).status, 1, 'CI-only commit (workflow + test + changelog) must classify as NOT shipped — the canonical #2980 case');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('empty stdin classifies as not-shipped (defensive — empty diff means no candidate paths)', () => {
const tmp = makeFixtureRepo(['bin']);
try {
assert.equal(runClassifier('', tmp).status, 1, 'empty stdin must classify as not-shipped — no paths can\'t intersect any whitelist');
assert.equal(runClassifier('\n\n\n', tmp).status, 1, 'whitespace-only stdin must classify as not-shipped');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});