Files
msd-core/tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs
Tom Boucher 0d25ef0c47 fix(release-sdk): skip cherry-picks whose target context is absent at base (#2967)
* fix(release-sdk): skip cherry-picks whose target context is absent at base

When auto_cherry_pick processed a fix:/chore: commit whose patch modified
code that didn't exist at the hotfix base tag — typically because the
surrounding infrastructure was added later in a feat/refactor commit
excluded by the filter — `git cherry-pick` failed with a conflict that
no operator could meaningfully resolve, and the loop bricked the run.

Discovered re-running the 1.39.1 dry-run after #2965 merged: cherry-pick
of `a3467792` (the #2965 merge itself) failed because the auto_cherry_pick
block it modifies was added in #2956 ("Add automated cherry-pick + SDK-
bundle parity to hotfix flow") — an Add/feat commit, so the fix/chore
filter excludes it. v1.39.0 has no such block, so the patch had no
anchor.

The conflict is unmistakably distinguishable from a real content conflict:
git emits marker blocks where every `<<<<<<< HEAD ... =======` HEAD
section is empty (no anchor lines to reconcile against), while real
conflicts have content on both sides.

After cherry-pick fails:
  1. List unmerged paths via `git diff --diff-filter=U`.
  2. For each, scan conflict markers with awk. If every HEAD section is
     blank/whitespace-only across every block, classify as
     context-missing.
  3. Context-missing → `git cherry-pick --skip` and append to SKIPPED
     list with reason "(context absent at base)".
  4. Otherwise fall through to the existing abort/push-partial/error
     path that surfaces the conflict for operator resolution.

Real conflicts still surface with the same workflow as before.

Tests in tests/bug-2966-cherry-pick-context-missing.test.cjs cover:
  - Static — extracts the "Prepare hotfix branch" run block via
    indentation-aware YAML parsing (no raw-text grep) and asserts the
    classification predicate, --skip call, and skipped-reason annotation
    are present.
  - Behavioral — synthetic repo reproducing the real shape of the
    failure, asserts cherry-pick exits non-zero and produces the
    empty-HEAD marker shape.
  - Predicate — pulls the awk script out of the deployed workflow and
    feeds it sample conflict shapes (empty-HEAD, real, mixed,
    whitespace-only); asserts each is classified as the workflow will
    behave.

Local: `node --test tests/bug-2966-...test.cjs` → 3/3 pass.
Local: `npm run lint:tests` → 0 violations.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* fix(release-sdk): pin merge.conflictStyle=merge on hotfix cherry-pick

CodeRabbit flagged on #2967 that the awk classifier introduced for #2966
assumes default conflict-marker style (plain `<<<<<<< HEAD ... ======= ...
>>>>>>>`). If a runner has merge.conflictStyle=diff3 or zdiff3 set
(globally, repo-config, or via git defaults shift), the marker emits an
extra `||||||| ancestor` section between HEAD and =======. The awk's
`in_head` mode would accumulate that ancestor content into the HEAD
buffer, and a context-missing conflict would misclassify as real —
sending the workflow into the abort path on a pick that should be
silently skipped.

Pass `-c merge.conflictStyle=merge` on the cherry-pick command itself
(scoped to that one git invocation; doesn't leak to other commands).
This guarantees marker shape regardless of the runner's git config.

Updated the existing static assertion in
tests/bug-2966-cherry-pick-context-missing.test.cjs to require the pin —
a future edit dropping it fails the test.

Local: `node --test tests/bug-2966-...test.cjs` → 3/3 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* test(#2964): allow git options between `git` and `cherry-pick`

The previous commit on this branch (d6530190) added
`git -c merge.conflictStyle=merge cherry-pick ...` to release-sdk.yml.
The bug-2964 static test's regex `/git cherry-pick[^\n]*"\$SHA"/`
required `cherry-pick` to be the literal next token after `git`, so it
no longer matched the line and CI failed on Node 22 / Node 24 / macOS.

Loosen to `/git\b[^\n]*?cherry-pick[^\n]*"\$SHA"/` so any options
between `git` and `cherry-pick` (e.g. `-c key=value`) are tolerated.
The flag assertions on the matched line still verify --allow-empty and
--keep-redundant-commits are present, which is what bug-2964 actually
guards.

Local: `node --test tests/bug-2964-...test.cjs tests/bug-2966-...test.cjs`
→ 5/5 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* test(#2966): pin merge.conflictStyle in test git wrapper, assert awk status

CodeRabbit raised two issues on PR #2967:

1. The synthetic-repo cherry-pick reproducer asserted `<<<<<<< HEAD ...`
   blocks have empty HEAD sections, but the cherry-pick itself didn't
   pin `merge.conflictStyle`. A developer or CI runner with global
   diff3/zdiff3 config would inject `||||||| ancestor` lines into the
   HEAD scan and the test would fail for environment reasons rather
   than the bug premise. Pin the style on the test's `git()` wrapper
   so every git operation in the test is deterministic regardless of
   user config.

2. `classify()` ran awk and consumed `r.stdout.trim()` without checking
   `r.status` or `r.error`. A failed awk invocation (missing binary,
   syntax error, signal) returns empty stdout, which would falsely
   classify as "context-missing" and the test would silently pass on
   broken predicates. Add `assert.ok(!r.error, ...)` and
   `assert.equal(r.status, 0, ...)` before reading stdout.

Local: `node --test tests/bug-2966-...test.cjs tests/bug-2964-...test.cjs`
→ 5/5 pass.

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 14:35:18 -04:00

139 lines
6.9 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 ~30 lines of the anchor. Limit the
// window to avoid matching unrelated cherry-pick references elsewhere.
// 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 + 2000);
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 });
}
});
});