fix(#2980): hotfix cherry-pick only picks commits that change what ships (#2981)

* fix(#2980): pre-skip workflow-file cherry-picks in release-sdk hotfix loop

The default GITHUB_TOKEN issued to the release-sdk run lacks the
`workflow` scope, so the prepare job's `git push origin "$BRANCH"` is
rejected by GitHub when any cherry-picked commit modifies a file under
`.github/workflows/`:

  ! [remote rejected] hotfix/X.YY.Z -> hotfix/X.YY.Z
    (refusing to allow a GitHub App to create or update workflow ...
     without `workflows` permission)

Pre-#2980 behavior: the auto_cherry_pick loop happily picked
workflow-file commits, then the trailing push exploded with no clear
signal which commit was the culprit. v1.39.1 hit this on PR #2977
(run 25232010071) — earlier release-sdk fixes (#2965, #2967, #2970)
had been skipped on conflict so their workflow-file changes never
reached the push step, masking the bug; #2977 was the first
workflow-file commit to apply cleanly and the push immediately
exploded.

Fix: pre-pick guard in the cherry-pick loop. Inspect each candidate
commit's file list via `git diff-tree --no-commit-id --name-only -r`
BEFORE attempting the pick. If any path matches `^\.github/workflows/`,
skip the commit, emit a `::warning::` annotation naming the dropped
commit, and append to a new `WORKFLOW_SKIPPED` bucket. The run summary
surfaces this bucket in its own section, distinct from `CONFLICT_SKIPPED`
(real merge conflicts) and `POLICY_SKIPPED` (feat/refactor exclusions),
so operators reviewing the run never confuse the remediation paths.

The loud-warning piece is non-negotiable: silent drops were explicitly
rejected as a failure mode during the option-1/2/3 tradeoff discussion.
If a workflow-file fix genuinely needs to ship in a hotfix, the
operator applies it manually on the hotfix branch using a token with
`workflow` scope, or lands it on main and re-cuts the release.

Regression covered by tests/bug-2980-skip-workflow-file-cherrypicks.test.cjs
(5 assertions: pre-pick guard exists, uses `git diff-tree`, emits
`::warning::`, lands in dedicated bucket, surfaces in summary).

The bug-2964 test's 4 KB window after the cherry-pick-loop anchor was
nudged to 6 KB to accommodate the new pre-pick scaffolding — the test's
own comment had already anticipated this kind of growth (citing #2970's
merge-commit pre-skip as prior precedent).

Closes #2980

* refactor(#2980): replace workflow-file pre-skip with shipped-paths filter

The previous commit on this branch caught only the .github/workflows/*
subset of the bug, treating the symptom (push rejection on workflow-file
changes) rather than the root cause (the fix:/chore: filter is too broad
— it picks any commit with that conventional-commit type even when the
diff cannot affect the published npm package).

CI-only fixes (release-sdk.yml itself, hotfix tooling, test-only
commits) shouldn't flow through hotfix runs at all — they cannot change
what `npm install get-shit-done-cc@X.YY.Z` produces. The
.github/workflows/* push rejection is just the loudest of these
"shouldn't have been picked" cases; tests/, docs/, .planning/ commits
get picked silently with the same lack of effect on consumers.

Replace the workflow-file pre-skip with a shipped-paths filter:

- New scripts/diff-touches-shipped-paths.cjs reads package.json `files`,
  plus package.json itself (always-shipped per `npm pack` semantics),
  and exits 0 iff any input path is in the shipped set. Lockfile is
  not shipped (npm pack excludes it unless explicitly in `files`).
- Workflow loop now pipes `git diff-tree --no-commit-id --name-only -r`
  through the classifier; on exit 1 the commit is skipped and
  appended to a new NON_SHIPPED_SKIPPED bucket (replaces
  WORKFLOW_SKIPPED).
- Run summary surfaces NON_SHIPPED_SKIPPED as informational — no
  ::warning:: annotation. A non-shipping commit cannot affect the
  package, so a yellow alert would imply remediation is possible
  and would mislead operators.

The classifier in a separate .cjs file (rather than inline bash
heredoc) is so its rules — directory-prefix vs exact-match,
package.json-always-shipped, lockfile-not-shipped — are unit-testable
in tests/bug-2980-hotfix-only-picks-shipping-changes.test.cjs (11 new
assertions: 4 static workflow + 6 classifier behavioral + 1 mixed-
diff edge case).

Why this dissolves the original push-rejection bug: workflow files
aren't in `files`, so workflow-only commits are skipped pre-pick.
The push step never sees them.

If a workflow-file fix genuinely needs to ship in a hotfix release
(extremely rare — the hotfix workflow is read from main's ref, not
the hotfix branch's), the operator applies it manually using a token
with `workflow` scope. The pre-skip puts that requirement in the run
summary explicitly.

Closes #2980
This commit is contained in:
Tom Boucher
2026-05-01 16:59:49 -04:00
committed by GitHub
parent 7a416b10d4
commit 7424271aa0
5 changed files with 412 additions and 7 deletions

View File

@@ -0,0 +1,65 @@
#!/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 <SHA>`) and
* exits 0 if any path is part of the npm tarball's shipped contents,
* 1 otherwise.
*
* "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).
*
* `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:
* 0 at least one path is shipped → cherry-pick is meaningful
* 1 no shipped paths → CI / test / docs / planning-only;
* hotfix loop skips the commit
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
function loadShipPrefixes(pkgPath) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const files = Array.isArray(pkg.files) ? pkg.files : [];
return ['package.json', ...files];
}
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 + '/'));
}
function main() {
const pkgPath = path.resolve(process.cwd(), 'package.json');
const shipPrefixes = loadShipPrefixes(pkgPath);
let buf = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', (chunk) => {
buf += chunk;
});
process.stdin.on('end', () => {
const paths = buf.split('\n').map((s) => s.trim()).filter(Boolean);
const hit = paths.some((p) => isShipped(p, shipPrefixes));
process.exit(hit ? 0 : 1);
});
}
if (require.main === module) {
main();
}
module.exports = { loadShipPrefixes, isShipped };