Files
msd-core/tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs
Tom Boucher 7424271aa0 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
2026-05-01 16:59:49 -04:00

143 lines
7.2 KiB
JavaScript

/**
* Regression test for bug #2964
*
* The release-sdk hotfix workflow's auto_cherry_pick loop aborted the entire
* run if any commit between the base tag and origin/main had an empty diff
* against its parent (e.g. a squash-merge whose contents were already merged
* via an earlier PR). `git cherry-pick -x` exits non-zero on empty commits
* with "The previous cherry-pick is now empty", and the workflow's loop
* (`if ! git cherry-pick -x "$SHA"; then ... exit 1`) treated any non-zero
* as a hard conflict — bricking every hotfix the moment a no-op commit
* landed on main.
*
* Fix: pass `--allow-empty --keep-redundant-commits` so empty picks are
* preserved on the hotfix branch (with `-x` provenance, matching main 1:1)
* and picks whose diff resolves to empty after applying to the new base
* also pass cleanly. Real conflicts still surface — the flags only change
* the empty-commit exit code.
*
* This test asserts both:
* 1. Static — the workflow YAML carries the flags on the cherry-pick call
* inside the auto_cherry_pick loop. If a future edit drops them, this
* regresses immediately.
* 2. Behavioral — `git cherry-pick -x --allow-empty --keep-redundant-commits`
* against a real empty commit in a throwaway repo exits 0 (proves the
* flags semantically do what we claim), while plain `git cherry-pick -x`
* exits non-zero against the same commit (proves the bug exists without
* the flags).
*/
'use strict';
// allow-test-rule: source-text-is-the-product
// The release-sdk.yml workflow IS the product for hotfix automation —
// GitHub Actions executes the YAML's shell verbatim. Testing the text
// content tests the deployed contract: if the flags are absent, the
// empty-commit guarantee is absent.
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 WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'release-sdk.yml');
function git(cwd, args, env = {}) {
// Force-disable signing inline so a developer's global gpgsign / sshsign
// config can't fail commits in this throwaway repo. Don't rely on env
// because gpg.format/user.signingkey live in gitconfig, not env vars.
const signingOff = ['-c', 'commit.gpgsign=false', '-c', 'tag.gpgsign=false', '-c', 'gpg.format=openpgp', '-c', 'user.signingkey='];
return spawnSync('git', [...signingOff, ...args], {
cwd,
encoding: 'utf8',
env: { ...process.env, ...env, GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 't@t' },
});
}
describe('bug-2964: release-sdk hotfix cherry-pick survives empty commits', () => {
test('release-sdk.yml passes --allow-empty --keep-redundant-commits in the auto_cherry_pick loop', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
// Find the auto_cherry_pick block by anchoring on a line unique to it,
// then assert the cherry-pick invocation inside that block carries both
// flags. We deliberately scope to the loop — a stray `git cherry-pick`
// elsewhere in the file (none today) would not satisfy this contract.
const loopAnchor = yaml.indexOf('CANDIDATES=$(git cherry HEAD origin/main');
assert.ok(
loopAnchor !== -1,
'release-sdk.yml must contain the auto_cherry_pick loop that derives candidates via `git cherry HEAD origin/main` (#2964)'
);
// The cherry-pick call lives within the auto_cherry_pick loop. Bound
// the slice generously after the anchor so future pre-skip guards /
// classification scaffolding (e.g. the merge-commit pre-skip added
// on PR #2970, the workflow-file pre-skip added on PR for #2980)
// don't push the call out of range, but still tight enough to avoid
// matching unrelated cherry-pick refs elsewhere in the workflow file.
// Allow arbitrary git options between `git` and `cherry-pick` (e.g.
// `git -c merge.conflictStyle=merge cherry-pick ...` added for #2966)
// so this test doesn't false-fail on legitimate option additions.
const window = yaml.slice(loopAnchor, loopAnchor + 6000);
const pickMatch = /git\b[^\n]*?cherry-pick[^\n]*"\$SHA"/.exec(window);
assert.ok(
pickMatch,
'auto_cherry_pick loop must invoke `git ... cherry-pick ... "$SHA"` (#2964)'
);
const pickLine = pickMatch[0];
assert.ok(
pickLine.includes('--allow-empty'),
`auto_cherry_pick must pass --allow-empty so empty no-op commits on main do not abort the hotfix (#2964). Found: ${pickLine}`
);
assert.ok(
pickLine.includes('--keep-redundant-commits'),
`auto_cherry_pick must pass --keep-redundant-commits so commits whose diff resolves to empty after rebasing onto the base tag do not abort the hotfix (#2964). Found: ${pickLine}`
);
});
test('git cherry-pick with --allow-empty --keep-redundant-commits succeeds on an empty commit; without them it fails', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'bug-2964-'));
try {
// Build a synthetic repo with one real commit on main and one truly
// empty commit on top — same shape as the real upstream artifact
// (b328f326 on origin/main has tree == its parent's tree).
assert.equal(git(tmp, ['init', '-q', '-b', 'main']).status, 0, 'git init');
fs.writeFileSync(path.join(tmp, 'README.md'), 'base\n');
assert.equal(git(tmp, ['add', 'README.md']).status, 0, 'git add');
assert.equal(git(tmp, ['commit', '-q', '-m', 'base']).status, 0, 'base commit');
assert.equal(git(tmp, ['tag', 'v0.0.0']).status, 0, 'tag base');
// Make a genuinely empty commit on main.
assert.equal(git(tmp, ['commit', '--allow-empty', '-q', '-m', 'fix: noop on main']).status, 0, 'empty commit');
const empty = git(tmp, ['rev-parse', 'HEAD']).stdout.trim();
assert.ok(empty.length === 40, `expected sha, got ${empty}`);
// Reset to the base tag (simulates the hotfix branch starting from v0.0.0).
assert.equal(git(tmp, ['checkout', '-q', '-b', 'hotfix/0.0.1', 'v0.0.0']).status, 0, 'checkout hotfix');
// Without the flags: cherry-pick of an empty commit fails.
const without = git(tmp, ['cherry-pick', '-x', empty]);
assert.notEqual(
without.status,
0,
'plain `git cherry-pick -x` MUST fail on an empty commit — if this passes, git semantics changed and the bug premise is gone (#2964)'
);
// Reset cherry-pick state for the next run.
git(tmp, ['cherry-pick', '--abort']);
// git may have already auto-resolved to a clean state; ensure we're back to v0.0.0.
git(tmp, ['reset', '--hard', 'v0.0.0']);
// With the flags (matching what the workflow now uses): success.
const withFlags = git(tmp, ['cherry-pick', '-x', '--allow-empty', '--keep-redundant-commits', empty]);
assert.equal(
withFlags.status,
0,
`git cherry-pick -x --allow-empty --keep-redundant-commits MUST succeed on an empty commit (#2964). stderr: ${withFlags.stderr}`
);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
});