#!/usr/bin/env node /** * Used by the release-sdk hotfix cherry-pick loop to decide whether a * candidate commit can possibly change what ships in the npm package. * * Reads a newline-separated list of paths from stdin (typically the * output of `git diff-tree --no-commit-id --name-only -r `) and * exits with one of three codes so the workflow can distinguish a * legitimate "skip this commit" signal from a classifier failure. * * "Shipped" = the union of: * - package.json (always included by `npm pack`, regardless of `files`) * - every entry in package.json `files`, treated as either an exact * file match or a directory prefix (matching `npm pack` semantics). * - CI-gating test paths: `tests/` — these don't ship in the * tarball, but they gate the hotfix-branch test job. A test fixture * update that aligns with a cherry-picked production fix MUST be * pickable or CI fails on the hotfix run. * #3621 — root cause of the v1.42.3 hotfix red CI. * * `package-lock.json` is intentionally NOT considered shipped — `npm pack` * excludes it from the tarball unless it's explicitly in `files`, and at * the time of writing this repo's `files` whitelist does not include it. * * Exit codes (the workflow MUST treat these distinctly — bug #2983): * 0 at least one path is shipped → cherry-pick is meaningful * 1 no shipped paths → CI / test / docs / planning * only; hotfix loop skips * 2 classifier error → bad/missing package.json, * I/O failure, or any * uncaught exception. The * workflow MUST fail-fast on * this code rather than * treating it as a skip. * * Why distinct codes: Node's default exit code for uncaught throws is 1, * which would otherwise be indistinguishable from the legitimate "no * shipped paths" result. CodeRabbit on PR #2981 / bug #2983. */ 'use strict'; const fs = require('node:fs'); const path = require('node:path'); const { ExitError, runMain } = require('./lib/cli-exit.cjs'); const EXIT_SHIPPED = 0; const EXIT_NOT_SHIPPED = 1; const EXIT_ERROR = 2; function loadShipPrefixes(pkgPath) { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); const files = Array.isArray(pkg.files) ? pkg.files : []; return ['package.json', ...files]; } // #3621: paths that gate hotfix-branch CI even though they don't appear // in the npm tarball. When a cherry-picked production fix changes behavior // that an existing test on the v1.42.2 base asserts against, the matching // test fixture from `main` must also be cherry-picked or CI fails on the // hotfix run (exactly what happened on v1.42.3 — production fix(3562) was // picked, the bundled test-fixture correction in commit 08848df8 was not). // Combined with the `test:` prefix being added to the candidate-loop regex // in hotfix.yml, this lets `test(####):` fixture-alignment commits be // cherry-picked alongside their production counterparts. function isCiGating(diffPath) { return diffPath.startsWith('tests/'); } function isShipped(diffPath, shipPrefixes) { // Normalize Windows-style separators just in case (git always emits // forward slashes, but a developer running this locally on a different // tool's output shouldn't get a false negative). const p = diffPath.replace(/\\/g, '/'); return shipPrefixes.some((s) => p === s || p.startsWith(s + '/')); // allow-handrolled-containment: shipped-path filtering for a CI check, not a safety decision } // #2980: commits that touch `.github/workflows/*` cannot be cherry-picked // onto a hotfix branch because the default GITHUB_TOKEN lacks the // `workflow` permission and the push step fails. Detect them upfront so a // `test:`-eligible commit bundling a workflow edit still gets skipped. function isPushBlocking(diffPath) { return diffPath.replace(/\\/g, '/').startsWith('.github/workflows/'); } function readStdin() { return new Promise((resolve, reject) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk) => { data += chunk; }); process.stdin.on('end', () => resolve(data)); process.stdin.on('error', reject); }); } async function main() { try { let shipPrefixes; try { const pkgPath = path.resolve(process.cwd(), 'package.json'); shipPrefixes = loadShipPrefixes(pkgPath); } catch (err) { process.stderr.write(`diff-touches-shipped-paths: failed to read package.json from ${process.cwd()}\n`); if (err && err.stack) process.stderr.write(`${err.stack}\n`); throw new ExitError(EXIT_ERROR); } let buf; try { buf = await readStdin(); } catch (err) { process.stderr.write(`diff-touches-shipped-paths: stdin read error\n`); if (err && err.stack) process.stderr.write(`${err.stack}\n`); throw new ExitError(EXIT_ERROR); } const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean); // #2980 still wins over #3621: any commit touching .github/workflows/* // is unpickable regardless of other content because the push step // fails on workflow scope rejection. Check this first. if (paths.some(isPushBlocking)) { return EXIT_NOT_SHIPPED; } if (paths.some((p) => isShipped(p, shipPrefixes))) { return EXIT_SHIPPED; } // #3621: a commit whose only relevant paths are CI-gating tests is // still pickable — it can change whether the hotfix CI passes even // though it doesn't change what the npm tarball ships. if (paths.some(isCiGating)) { return EXIT_SHIPPED; } return EXIT_NOT_SHIPPED; } catch (e) { // Re-throw ExitError unchanged; map any unexpected error to EXIT_ERROR=2 // (Node's default uncaught-exception code is 1, which is indistinguishable // from the legitimate EXIT_NOT_SHIPPED result — bug #2983). if (e instanceof ExitError) throw e; process.stderr.write(`diff-touches-shipped-paths: classification failed\n`); if (e && e.stack) process.stderr.write(`${e.stack}\n`); throw new ExitError(EXIT_ERROR); } } if (require.main === module) { runMain(main); } module.exports = { loadShipPrefixes, isShipped, isCiGating, isPushBlocking, EXIT_SHIPPED, EXIT_NOT_SHIPPED, EXIT_ERROR };