Files
msd-core/tests/bug-3621-cherry-pick-test-fixtures.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

218 lines
9.0 KiB
JavaScript

/**
* Regression test for bug #3621
*
* The release-sdk hotfix cherry-pick loop's `fix:`/`chore:` prefix filter
* and the shipped-paths classifier together excluded a test-fixture update
* that was required to align CI with a cherry-picked production fix.
*
* Concrete failure shape (v1.42.3 hotfix, run 25949422676):
* - Commit 36534059 fix(3562) was picked. It changed the installer to
* materialize gsd-named SKILL.md files under the codex skills dir.
* - Commit 08848df8 (docs(3562) prefix) was POLICY_SKIPPED by the prefix
* filter. It bundled the matching test-fixture correction (removing an
* "if (runtime === 'codex') return new Set()" short-circuit in
* tests/install-minimal-all-runtimes.test.cjs).
* - Result: hotfix branch has new production behavior + stale test
* assertion → 3 tests fail.
*
* Two-part fix:
* 1. release-sdk.yml prefix filter now includes test: commits.
* 2. scripts/diff-touches-shipped-paths.cjs treats tests/-rooted paths
* and sdk/src vitest specs (.test.ts / .spec.ts and friends) as
* CI-gating-equivalent so a test: commit that touches only those
* paths passes the shipped-paths gate.
*
* Without both halves the bundling failure mode persists.
*/
'use strict';
// allow-test-rule: source-text-is-the-product
// release-sdk.yml IS the product for hotfix automation; this test reads
// the workflow's prefix-filter regex line directly because the regex IS
// the behavior contract — there is no runtime that consumes it.
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');
const { isCiGating } = require('../scripts/diff-touches-shipped-paths.cjs');
function workflowText() {
return fs.readFileSync(WORKFLOW_PATH, 'utf8');
}
function runClassifier(stdin, cwd) {
return spawnSync(process.execPath, [CLASSIFIER_PATH], {
cwd,
input: stdin,
encoding: 'utf8',
});
}
function writeMinimalPkg(tmp, files) {
fs.writeFileSync(
path.join(tmp, 'package.json'),
JSON.stringify({ name: 'test-pkg', version: '0.0.0', files }, null, 2),
);
}
describe('bug-3621: release-sdk hotfix accepts `test:` commits', () => {
test('candidate-loop regex includes test: prefix alongside fix:/chore:', () => {
const text = workflowText();
// The regex must accept all three prefixes — fix, chore, test — with
// optional (scope) and optional ! for breaking. Asserted by extracting
// the literal regex line.
const regexLine = text.split('\n').find((l) => l.includes("grep -qE '^(") && l.includes(')(\\([^)]+\\))?!?: '));
assert.ok(regexLine, 'release-sdk.yml must contain the candidate-prefix regex line');
assert.ok(
/\^\(fix\|chore\|test\)/.test(regexLine),
`prefix regex must include fix|chore|test (got: ${regexLine.trim()})`,
);
});
test('regex does NOT silently drop fix: or chore: while adding test:', () => {
const text = workflowText();
const regexLine = text.split('\n').find((l) => l.includes("grep -qE '^("));
assert.ok(regexLine.includes('fix|chore|test'), 'must keep fix and chore as before');
assert.ok(!regexLine.includes('feat|'), 'must NOT silently add feat: — features are not hotfix-eligible');
assert.ok(!regexLine.includes('docs|'), 'must NOT silently add docs: — docs commits remain POLICY_SKIPPED');
});
});
describe('bug-3621: shipped-paths classifier treats test paths as CI-gating', () => {
test('isCiGating recognizes tests/** as CI-gating', () => {
assert.equal(isCiGating('tests/bug-foo.test.cjs'), true);
assert.equal(isCiGating('tests/helpers.cjs'), true);
assert.equal(isCiGating('tests/fixtures/adversarial/roadmap/duplicate-keys.md'), true);
});
test('isCiGating recognizes sdk/src vitest specs as CI-gating', () => {
assert.equal(isCiGating('sdk/src/query/init.test.ts'), true);
assert.equal(isCiGating('sdk/src/query/init.spec.ts'), true);
assert.equal(isCiGating('sdk/src/golden/golden.integration.test.ts'), true);
});
test('isCiGating rejects non-test sdk/src paths (those ship via sdk/dist)', () => {
assert.equal(isCiGating('sdk/src/query/init.ts'), false);
assert.equal(isCiGating('sdk/src/config.ts'), false);
});
test('isCiGating rejects paths that merely contain "test" in their name', () => {
// Guard against /test/ false positives.
assert.equal(isCiGating('docs/test-strategy.md'), false);
assert.equal(isCiGating('bin/install-test-helper.js'), false);
// .test.md is not a recognized spec extension here.
assert.equal(isCiGating('docs/install.test.md'), false);
});
test('classifier exits 0 for a test-only diff (the v1.42.3 case)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3621-classifier-'));
try {
writeMinimalPkg(tmp, ['bin', 'sdk/dist']);
const result = runClassifier('tests/install-minimal-all-runtimes.test.cjs\n', tmp);
assert.equal(
result.status,
0,
`tests/** path must classify as shipped-equivalent (status=${result.status}, stderr=${result.stderr})`,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('classifier exits 0 for a mixed test+docs diff (the v1.42.3 commit shape)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3621-classifier-mixed-'));
try {
writeMinimalPkg(tmp, ['bin', 'sdk/dist']);
const stdin = [
'README.md',
'docs/CONFIGURATION.md',
'docs/USER-GUIDE.md',
'tests/install-minimal-all-runtimes.test.cjs',
'tests/installer-migration-install-integration.test.cjs',
].join('\n') + '\n';
const result = runClassifier(stdin, tmp);
assert.equal(
result.status,
0,
`mixed docs+test diff must classify as shipped-equivalent because at least one path is CI-gating (status=${result.status})`,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('classifier still exits 1 for a pure docs-only diff (no test paths)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3621-classifier-docs-only-'));
try {
writeMinimalPkg(tmp, ['bin', 'sdk/dist']);
const stdin = 'README.md\ndocs/CONFIGURATION.md\n';
const result = runClassifier(stdin, tmp);
assert.equal(
result.status,
1,
`pure docs-only diff must remain NOT_SHIPPED — they aren't CI-gating either (status=${result.status})`,
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('classifier still exits 0 for a normal shipped-path diff (pre-fix behavior preserved)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3621-classifier-shipped-'));
try {
writeMinimalPkg(tmp, ['bin', 'sdk/dist']);
const result = runClassifier('bin/install.js\n', tmp);
assert.equal(result.status, 0, 'shipped-path classification must not regress');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('classifier still exits 1 for CI-only workflow diffs (.github/** is neither shipped nor CI-gating)', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3621-classifier-ci-'));
try {
writeMinimalPkg(tmp, ['bin', 'sdk/dist']);
const result = runClassifier('.github/workflows/release-sdk.yml\n', tmp);
assert.equal(
result.status,
1,
'workflow-only changes must remain NOT_SHIPPED — they\'d otherwise fail the push step (default GITHUB_TOKEN lacks workflow scope, #2980)',
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
test('push-blocking guard wins: workflow + test bundle classifies as NOT_SHIPPED (preserves #2980)', () => {
// The canonical #2980 case is a `fix(release-sdk):` commit touching
// both the workflow file and its regression test. Under #3621 the test
// path alone would otherwise satisfy the CI-gating check; the
// .github/workflows/* push-blocker must still skip the whole commit.
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-3621-classifier-pushblock-'));
try {
writeMinimalPkg(tmp, ['bin', 'sdk/dist']);
const stdin = [
'.github/workflows/release-sdk.yml',
'tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs',
'CHANGELOG.md',
].join('\n') + '\n';
const result = runClassifier(stdin, tmp);
assert.equal(
result.status,
1,
'#2980 preservation: bundle with .github/workflows/* must skip regardless of test paths in the same commit',
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});