* fix(#2983): classifier exit-code discipline, base-tag staging, drop vestigial merge-back Three issues surfaced by CodeRabbit's post-merge review of #2981 plus a production failure on the v1.39.1 release run. (1) Overloaded classifier exit code scripts/diff-touches-shipped-paths.cjs reused exit 1 for both the legitimate "no shipped paths" result and Node's default exit on uncaught throw, so any classifier failure (corrupt package.json, EPERM, etc.) was indistinguishable from a normal skip — the workflow's `if ! ... ; then skip` idiom would silently drop the commit. Distinct exit codes now: 0 shipped — at least one path is in the npm `files` whitelist 1 not shipped — CI / test / docs / planning only 2 classifier error — workflow MUST fail-fast uncaughtException + unhandledRejection + try/catch around fs/JSON parsing all route to exit 2 with stderr context. (2) Classifier missing at the base tag (CRITICAL) `Prepare hotfix branch` runs `git checkout -b "$BRANCH" "$BASE_TAG"` BEFORE the cherry-pick loop, replacing the working tree with the base tag's contents. Base tags predating #2980 (notably v1.39.0, the most likely next hotfix base) don't have scripts/diff-touches-shipped-paths.cjs at all — `node <missing>` exits non-zero — `if !` skips every commit — empty hotfix branch published. Strictly worse than the original #2980 push-rejection, which at least failed loudly. Stage the classifier from the dispatched ref's working tree into $RUNNER_TEMP at the top of the run script (before any working-tree- mutating git command). The cherry-pick loop now references $CLASSIFIER (staged) instead of the in-tree path. Sanity guards: refuse to start if scripts/diff-touches-shipped-paths.cjs is missing in the dispatched ref, refuse to proceed if cp didn't materialize $CLASSIFIER. The cherry-pick loop captures node's exit via ${PIPESTATUS[1]} and dispatches via explicit case: 0 proceed with cherry-pick 1 skip into NON_SHIPPED_SKIPPED * emit ::error:: + exit "$CLASSIFIER_RC" (3) Drop the merge-back PR step Auto-cherry-pick only picks commits already on main (`git cherry HEAD origin/main` outputs the unmerged ones; we filter fix:/chore: from main). By construction every code commit on the hotfix branch is already on main. The only hotfix-branch-only commit is `chore: bump version to X.Y.Z for hotfix`, which either no-ops against main or rewinds main's in-progress version. The merge-back PR was vestigial. It also failed in production on run 25232968975 with `GitHub Actions is not permitted to create or approve pull requests (createPullRequest)` — org policy blocks PR creation from the workflow's GH_TOKEN. Even without that block, the PR would have nothing useful to merge. Step removed. The `pull-requests: write` permission granted solely for the merge-back step has been dropped from the release job (least-privilege). Regression coverage tests/bug-2983-classifier-exit-codes-and-base-tag-staging.test.cjs adds 12 assertions across two describe blocks: - 5 classifier behavioral: exit 0/1 preserved, exit 2 on missing package.json, exit 2 on malformed JSON, exit-code constants exported. - 7 workflow contract: classifier staged before checkout, target is $RUNNER_TEMP, missing-source guard, missing-staged guard, PIPESTATUS-based dispatch, error branch fails workflow, loop uses staged path (not in-tree). tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs updated where it asserted the pre-#2983 `if ! ... ; then` shape: now accepts the post-#2983 case-dispatch form. The test still proves the classifier participates; bug-2983 enforces the specific shape. Run summary references for the curious reviewer: - Run 25232010071 — original #2980 trigger (workflow-file push rejection) - Run 25232968975 — failed merge-back step that prompted the "is this even useful?" question that drove the removal Closes #2983 * fix(#2983): address CodeRabbit findings on PR #2984 Two findings, both real, both fixed. (1) [Critical] PIPESTATUS capture clobbered by `|| true` Pre-fix shape: git diff-tree ... | node "$CLASSIFIER" || true CLASSIFIER_RC="${PIPESTATUS[1]}" When the classifier exits 1 ("not shipped" — common case) or 2 (error), `|| true` triggers the right-hand side. `true` is a one-command "pipeline" that overwrites PIPESTATUS to (0). ${PIPESTATUS[1]} on the next line is therefore unset (or stale under set -u). The case dispatch then matched the empty string — falling into `*)` and failing the workflow on every non-shipped commit, OR matching `0)` after some shells default-init unset to 0 and silently picking commits that don't ship. Local repro confirms the issue: $ bash -c 'set -euo pipefail; false | sh -c "exit 7" || true; \ echo "PIPESTATUS: ${PIPESTATUS[*]}"; \ echo "[1]: ${PIPESTATUS[1]:-<unset>}"' PIPESTATUS: 0 [1]: <unset> Fix: bracket the pipeline in `set +e`/`set -e`, snapshot PIPESTATUS into a local array on the very next line, then dispatch on the snapshot: set +e git diff-tree ... | node "$CLASSIFIER" PIPE_RC=("${PIPESTATUS[@]}") set -e DIFFTREE_RC="${PIPE_RC[0]}" CLASSIFIER_RC="${PIPE_RC[1]}" The snapshot must happen on the first line after the pipeline; any intervening simple command resets PIPESTATUS. The array form is invariant against that. Bonus from the new shape: $DIFFTREE_RC is now also captured. git diff-tree is unlikely to fail on a known-good $SHA, but if it does, we no longer feed partial/empty input to the classifier and call it "not shipped." A non-zero DIFFTREE_RC emits ::error::git diff-tree failed and exits. (2) [Minor] Stale "Merge-back PR opened against main" summary line The hotfix run summary still printed: echo "- Merge-back PR opened against main" But the merge-back step itself was removed in the previous commit on this branch. Operators reading the summary would expect a PR that doesn't exist. Replaced with explicit non-action text: echo "- No merge-back PR (auto-picked commits are already on main)" Test coverage bug-2983 test file gains 3 assertions: - PIPE_RC array-snapshot pattern is required (regex matches the exact `PIPE_RC=("${PIPESTATUS[@]}")` form). - The `pipeline || true; ${PIPESTATUS[1]}` antipattern is explicitly forbidden via assert.doesNotMatch. - DIFFTREE_RC is captured from PIPE_RC[0] and a non-zero value triggers ::error::git diff-tree failed. - Run summary forbids `Merge-back PR opened against main` and requires the new non-action sentence. bug-2964 test's loop-anchor window bumped 6 KB → 8 KB to accommodate the additional pre-pick scaffolding (the test's own comment had already anticipated this kind of growth, citing prior precedents from #2970 and #2980). Mark CodeRabbit comments resolved post-commit. Refs CR finding ids 3175253571, 3175253578 on PR #2984.
306 lines
14 KiB
JavaScript
306 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 a non-shipped file
|
|
// must be classified as shipped — the non-shipped paths are along
|
|
// for the ride, but the commit can still affect what ships.
|
|
const tmp = makeFixtureRepo(['bin']);
|
|
try {
|
|
const stdin = '.github/workflows/release-sdk.yml\nbin/foo.js\ntests/bar.test.cjs\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 });
|
|
}
|
|
});
|
|
});
|