fix(release-sdk): skip all cherry-pick conflicts in hotfix loop (full automation) (#2970)

* fix(release-sdk): skip all cherry-pick conflicts in hotfix loop

Full-automation policy: any conflict the cherry-pick can't auto-resolve
— context-missing (#2966) or real merge conflict — is now skipped, not
aborted. The hotfix run completes with whatever applies cleanly; the
SKIPPED list in the run summary becomes the operator's post-hoc review
queue.

Surfaced in run 25227493387 (1.39.1 dry-run): commit 0fb992d
("fix(git): add git.base_branch config") produced real conflicts in
config.cjs / ship.md / complete-milestone.md / tests/config.test.cjs.
v1.39.0 was tagged on the feat/hermes-runtime-2841 branch (#2920),
which restructured those files. 0fb992d was authored against the
pre-restructure shape, so cherry-pick can't auto-resolve.

Pre-#2968 behavior: the workflow distinguished context-missing (skip)
from real (abort + push partial + exit 1). Real conflicts blocked every
hotfix from a base tag whose lineage diverged from main — exactly the
v1.39.x situation. The user has called explicitly for full automation:
"this needs to be fully automated, no one is going to sit there and
tag fixes."

Behavior change:
  - Both classification branches now `git cherry-pick --skip` and
    append to SKIPPED with a reason category:
      * "context absent at base" — empty-HEAD markers (#2966)
      * "merge conflict — manual review" — non-empty HEAD (#2968)
  - Removed: `git cherry-pick --abort`, partial-state push,
    "Cherry-pick conflict" GITHUB_STEP_SUMMARY block, `exit 1`.
  - Operator's manual recovery path via `auto_cherry_pick=false`
    remains intact.

Trade-off (acknowledged in #2968): a critical fix can be silently
dropped if no one reviews the SKIPPED list. The release job's
install-smoke + full test suite still runs and would catch any
test-covered regression. Fixes that aren't test-covered could ship
missing — accepted cost of full automation per the issue.

Tests:
  - tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs (new) —
    extracts the cherry-pick failure block via bash if/fi nesting walk
    (no raw-text grep) and asserts the abort path is removed, --skip
    is unconditional, and "merge conflict" + "context absent at base"
    annotations both exist.
  - tests/bug-2966-cherry-pick-context-missing.test.cjs (renamed
    describe + first test name) — assertions still valid since the
    classifier survives for skip-reason annotation.
  - tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs — unchanged
    and still green.

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

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* fix(release-sdk): split cherry-pick conflict skips from policy skips

CodeRabbit flagged on PR #2970 that conflict skips and policy skips
share the SKIPPED bucket. The run summary heading
"Skipped (feat/refactor/etc — not auto-included)" buries manual-review
conflicts (which the operator must triage) under the same list as
intentional policy exclusions (commits that don't match fix/chore by
design and need no action). Operators reviewing the summary can't
distinguish the two without reading every entry.

Split into two variables:
  - POLICY_SKIPPED — feat/refactor/docs/etc filtered out by the
    fix/chore regex (informational, no action needed)
  - CONFLICT_SKIPPED — fix/chore commits whose cherry-pick failed and
    were skipped per the full-automation policy (#2968) (manual
    review queue)

Run summary now emits two sections with distinct headings:
  - "Skipped — cherry-pick conflict (manual review)"
  - "Not auto-included (feat/refactor/docs/etc)"

The new bug-2968 test asserts both buckets are populated correctly:
  - failure path appends to CONFLICT_SKIPPED, not SKIPPED
  - both bucket variables are echoed in the summary
  - both section headings are present

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

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* fix(release-sdk): handle merge commits and guard cherry-pick --skip

CodeRabbit flagged a real major issue on PR #2970: merge commits with
fix:/chore: titles fail BEFORE entering cherry-pick state because they
need `-m <parent>` to specify the diff base. Without it, the cherry-pick
errors out and CHERRY_PICK_HEAD is never created. The unconditional
`git cherry-pick --skip` call that follows then fails too (no in-progress
cherry-pick to skip), bricking the loop — defeating the full-automation
policy this PR set out to deliver.

Two guards added:

1. Pre-skip merge commits before invoking cherry-pick. The loop checks
   parent count via `git rev-list --parents -n 1 "$SHA"`; if > 1, the
   commit goes straight to CONFLICT_SKIPPED with reason "merge commit —
   manual -m parent selection required". Operator decides which parent
   to keep when reviewing the run summary.

2. Guard `git cherry-pick --skip` with a CHERRY_PICK_HEAD existence
   check. Catches any other failure mode where the cherry-pick aborts
   before entering conflict state (unreadable commit, ref problems,
   etc.) so the loop still continues cleanly.

Also bumped the bug-2964 test's regex slice window from 2000 to 4000
chars so the merge-commit pre-skip block doesn't push the cherry-pick
line out of the test's match range.

Tests added in tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs:
  - merge-commit detection: workflow must call
    `git rev-list --parents -n 1 "$SHA"` before cherry-pick and annotate
    skips with the distinct "manual -m parent selection required"
    reason.
  - guard: failure block must check CHERRY_PICK_HEAD before --skip.

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

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

* fix(release-sdk): guard awk classifier against degenerate unmerged paths

CodeRabbit raised two issues on PR #2970:

1. Major (workflow): the `awk` classifier runs under `set -euo pipefail`.
   If a CONFLICTED path is missing/unreadable, awk exits non-zero and
   terminates the entire step — bricking the loop on a degenerate file.
   Also, an unmerged path with no `<<<<<<< ` markers (path-level conflict
   or anomalous git state) was misclassified as "context absent at base"
   (the auto-skip path), letting potentially-real conflicts skip silently.

   Fix: before invoking awk, check `[ ! -r "$CONFLICTED" ]` and
   `grep -q '^<<<<<<< ' "$CONFLICTED"`. Either failure marks
   ALL_EMPTY_HEAD=false → REASON falls through to "merge conflict —
   manual review", landing the pick in the operator review queue.
   Also added `2>/dev/null || echo "real"` on the awk call so a
   transient awk failure can't slip into the auto-skip bucket.

2. Nitpick (tests): regex assertions on `failureBlock` could match
   commented lines (e.g. comment text mentioning "CONFLICT_SKIPPED"
   or "git cherry-pick --skip" satisfied the assertions without the
   real command being present).

   Fix: anchor with `^\s*...` + `m` flag so only executable shell lines
   count.

Plus a new test asserting all three workflow guards
(`[ ! -r "$CONFLICTED" ]`, `grep -q '^<<<<<<< '`, `awk ... || echo
"real"`) are present in the failure block.

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

https://claude.ai/code/session_01LApueb9PVs2uSBhsLprVzG

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Tom Boucher
2026-05-01 15:15:20 -04:00
committed by GitHub
parent 0d25ef0c47
commit b8d9bd69b2
4 changed files with 397 additions and 76 deletions

View File

@@ -161,11 +161,31 @@ jobs:
ORDERED=$(git log --reverse --format='%H' "${BASE_TAG}..origin/main" \
| grep -F -f <(echo "$CANDIDATES") || true)
INCLUDED=""
SKIPPED=""
# POLICY_SKIPPED — commits intentionally not picked because they
# don't match the fix/chore filter (feat/refactor/docs/etc).
# CONFLICT_SKIPPED — fix/chore commits whose cherry-pick failed
# and were skipped per the full-automation policy (#2968).
# Operators reviewing the run summary need these distinct so
# the manual-review queue isn't buried in the policy bucket.
POLICY_SKIPPED=""
CONFLICT_SKIPPED=""
while IFS= read -r SHA; do
[ -z "$SHA" ] && continue
SUBJECT=$(git log -1 --format='%s' "$SHA")
if echo "$SUBJECT" | grep -qE '^(fix|chore)(\([^)]+\))?!?: '; then
# Merge commits with fix:/chore: titles can't be cherry-picked
# without `-m <parent>` and we can't pick the parent
# automatically. They fail BEFORE entering cherry-pick state
# (no CHERRY_PICK_HEAD), so an unconditional `--skip` would
# then fail and brick the loop. Skip them upfront with a
# distinct reason. Bug #2968 / CodeRabbit on PR #2970.
PARENT_COUNT=$(git rev-list --parents -n 1 "$SHA" | awk '{print NF - 1}')
if [ "$PARENT_COUNT" -gt 1 ]; then
REASON="merge commit — manual -m parent selection required"
echo "↷ skipping $SHA — $REASON"
CONFLICT_SKIPPED="${CONFLICT_SKIPPED}- \`${SHA}\` ${SUBJECT} ($REASON)"$'\n'
continue
fi
echo "→ cherry-picking $SHA $SUBJECT"
# Pin merge.conflictStyle=merge on the cherry-pick so the
# awk classifier below sees deterministic marker shapes —
@@ -173,79 +193,76 @@ jobs:
# the HEAD section and cause context-missing conflicts to
# misclassify as real. Bug #2966.
if ! git -c merge.conflictStyle=merge cherry-pick -x --allow-empty --keep-redundant-commits "$SHA"; then
# Distinguish patch-context-missing (the picked commit
# modifies code that doesn't exist at the base — typically
# because the surrounding infrastructure was added in a
# feat/refactor commit excluded by this filter) from a real
# content conflict (operator must resolve). Signal: a
# context-missing conflict produces marker blocks where the
# `<<<<<<< HEAD ... =======` HEAD section is empty for every
# block in every conflicted file. A real conflict has
# non-blank content in the HEAD section. Marker style is
# pinned on the cherry-pick command above. Bug #2966.
# Full automation policy (bug #2968): any conflict the
# cherry-pick can't auto-resolve is skipped, not aborted.
# The hotfix run completes with whatever applies cleanly;
# the CONFLICT_SKIPPED list below becomes the operator's
# review queue (see "Cherry-pick summary" in the run
# summary).
#
# Classify the conflict for the skip reason (operator-
# facing diagnostic — doesn't change control flow):
# - context absent at base: HEAD section in every
# conflict marker is empty (the picked commit modifies
# code that doesn't exist at the base). Bug #2966.
# - merge conflict: HEAD section has content (both base
# and patch want different content for the same
# region). Typical when the base tag was cut from a
# branch that has diverged from main. Bug #2968.
UNMERGED=$(git diff --name-only --diff-filter=U)
CONTEXT_MISSING_ONLY=true
if [ -z "$UNMERGED" ]; then
CONTEXT_MISSING_ONLY=false
fi
while IFS= read -r CONFLICTED; do
[ -z "$CONFLICTED" ] && continue
REAL=$(awk '
/^<<<<<<< / { in_head=1; head=""; next }
/^=======$/ && in_head { in_head=0; next }
/^>>>>>>> / {
if (head ~ /[^[:space:]]/) { print "real"; exit }
head=""
next
}
in_head { head = head $0 "\n" }
' "$CONFLICTED")
if [ "$REAL" = "real" ]; then
CONTEXT_MISSING_ONLY=false
break
fi
done <<< "$UNMERGED"
if [ "$CONTEXT_MISSING_ONLY" = "true" ]; then
echo "↷ skipping $SHA — patch context absent at $BASE_TAG"
git cherry-pick --skip
SKIPPED="${SKIPPED}- \`${SHA}\` ${SUBJECT} (context absent at base)"$'\n'
continue
fi
git cherry-pick --abort || true
# On real runs: push the partial-pick state so the operator
# can fetch + resolve $SHA + push + re-run with auto_cherry_pick=false.
if [ "$DRY_RUN" != "true" ]; then
git push --force-with-lease origin "$BRANCH" || git push origin "$BRANCH" || true
fi
{
echo "## Cherry-pick conflict"
echo ""
echo "Failed at: \`${SHA}\` — \`${SUBJECT}\`"
echo ""
if [ "$DRY_RUN" = "true" ]; then
echo "**Dry run:** branch was not pushed, so the picks below were discarded with the runner."
if [ -n "$INCLUDED" ]; then
echo ""
echo "Already-applied picks (lost — must be re-applied before resolving \`${SHA}\`):"
echo ""
echo "$INCLUDED"
REASON="merge conflict — manual review"
if [ -n "$UNMERGED" ]; then
ALL_EMPTY_HEAD=true
while IFS= read -r CONFLICTED; do
[ -z "$CONFLICTED" ] && continue
# Guard the classifier against degenerate cases that
# would otherwise skew toward "context absent" (the
# auto-skip path) when they're actually unsafe to skip:
# - file missing or unreadable: don't pretend the
# conflict is benign; treat as real.
# - file listed as unmerged but no conflict markers
# present: anomalous git state; treat as real so
# the pick goes to the manual-review queue.
# CodeRabbit on PR #2970.
if [ ! -r "$CONFLICTED" ] || ! grep -q '^<<<<<<< ' "$CONFLICTED" 2>/dev/null; then
ALL_EMPTY_HEAD=false
break
fi
echo ""
echo "**To resolve:** re-run a real hotfix with \`auto_cherry_pick=true\` to materialize the partial branch on origin, then resolve \`${SHA}\` manually. Re-running with \`auto_cherry_pick=false\` would recreate the branch from \`${BASE_TAG}\` and lose every pick listed above."
else
echo "Branch \`${BRANCH}\` was pushed with picks applied up to (but not including) the conflicting commit."
echo ""
echo "**To resolve:** \`git fetch origin && git checkout ${BRANCH} && git cherry-pick -x ${SHA}\`, fix the conflict, push, then re-run with \`auto_cherry_pick=false\`."
REAL=$(awk '
/^<<<<<<< / { in_head=1; head=""; next }
/^=======$/ && in_head { in_head=0; next }
/^>>>>>>> / {
if (head ~ /[^[:space:]]/) { print "real"; exit }
head=""
next
}
in_head { head = head $0 "\n" }
' "$CONFLICTED" 2>/dev/null || echo "real")
if [ "$REAL" = "real" ]; then
ALL_EMPTY_HEAD=false
break
fi
done <<< "$UNMERGED"
if [ "$ALL_EMPTY_HEAD" = "true" ]; then
REASON="context absent at base"
fi
} >> "$GITHUB_STEP_SUMMARY"
echo "::error::Cherry-pick of $SHA failed. See run summary."
exit 1
fi
echo "↷ skipping $SHA — $REASON"
# Guard `--skip`: cherry-pick can fail before entering the
# conflict state (e.g. unreadable commit, empty-without-
# --allow-empty edge cases the flag misses). Calling
# `--skip` outside an in-progress cherry-pick exits non-
# zero and would brick the loop. CodeRabbit on PR #2970.
if git rev-parse -q --verify CHERRY_PICK_HEAD >/dev/null 2>&1; then
git cherry-pick --skip
fi
CONFLICT_SKIPPED="${CONFLICT_SKIPPED}- \`${SHA}\` ${SUBJECT} ($REASON)"$'\n'
continue
fi
INCLUDED="${INCLUDED}- \`${SHA}\` ${SUBJECT}"$'\n'
else
SKIPPED="${SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n'
POLICY_SKIPPED="${POLICY_SKIPPED}- \`${SHA}\` ${SUBJECT}"$'\n'
fi
done <<< "$ORDERED"
{
@@ -260,10 +277,15 @@ jobs:
else
echo "_No fix/chore commits to include._"
fi
if [ -n "$SKIPPED" ]; then
echo "### Skipped (feat/refactor/etc — not auto-included)"
if [ -n "$CONFLICT_SKIPPED" ]; then
echo "### Skipped — cherry-pick conflict (manual review)"
echo ""
echo "$SKIPPED"
echo "$CONFLICT_SKIPPED"
fi
if [ -n "$POLICY_SKIPPED" ]; then
echo "### Not auto-included (feat/refactor/docs/etc)"
echo ""
echo "$POLICY_SKIPPED"
fi
} >> "$GITHUB_STEP_SUMMARY"
fi

View File

@@ -70,12 +70,16 @@ describe('bug-2964: release-sdk hotfix cherry-pick survives empty commits', () =
'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.
// The cherry-pick call lives within the auto_cherry_pick loop. Bound
// the slice to ~4 KB after the anchor — generous enough that future
// pre-skip guards / classification scaffolding (e.g. the merge-commit
// pre-skip added on PR #2970) 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 + 2000);
const window = yaml.slice(loopAnchor, loopAnchor + 4000);
const pickMatch = /git\b[^\n]*?cherry-pick[^\n]*"\$SHA"/.exec(window);
assert.ok(
pickMatch,

View File

@@ -114,8 +114,8 @@ function git(cwd, args) {
});
}
describe('bug-2966: release-sdk hotfix cherry-pick distinguishes context-missing from real conflicts', () => {
test('Prepare hotfix branch step skips on context-missing conflicts and aborts on real ones', () => {
describe('bug-2966: release-sdk hotfix cherry-pick classifies context-missing vs real conflicts for skip-reason annotation', () => {
test('Prepare hotfix branch step classifies and annotates context-missing conflicts', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');

View File

@@ -0,0 +1,295 @@
/**
* Regression test for bug #2968
*
* Full-automation policy: any cherry-pick conflict in the release-sdk
* hotfix loop — context-missing OR real merge conflict — must be
* skipped, logged to the SKIPPED list with a classified reason, and
* the loop continues. The hotfix run completes with whatever applies
* cleanly; the SKIPPED list is the operator's post-hoc review queue.
*
* Pre-#2968 behavior: real conflicts (HEAD section non-empty)
* triggered the abort/push-partial/error path, blocking every hotfix
* run whose base tag had diverged from main. v1.39.1 hit this on
* commit 0fb992d (run 25227493387) because v1.39.0 was tagged on the
* `feat/hermes-runtime-2841` branch, which had restructured files that
* pre-hermes fixes still patched against the old structure.
*
* This test asserts the workflow:
* 1. No longer carries the abort-on-real-conflict control flow
* (no `git cherry-pick --abort` followed by `exit 1` for picks
* that have unmerged paths).
* 2. Calls `git cherry-pick --skip` unconditionally on any
* cherry-pick failure inside the auto_cherry_pick loop.
* 3. Annotates the SKIPPED list with `merge conflict` for real
* conflicts (so operators can find them in the run summary).
* 4. Still records `context absent at base` for the empty-HEAD case
* — the classifier's diagnostic value is preserved even though
* the control flow no longer branches on it.
*/
'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 path = require('node:path');
const WORKFLOW_PATH = path.join(__dirname, '..', '.github', 'workflows', 'release-sdk.yml');
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`);
}
/**
* Extract just the body of the `if ! git ... cherry-pick ... ; then ... fi`
* conditional inside the auto_cherry_pick loop, so assertions can target
* the failure path without matching unrelated cherry-pick references
* (e.g. the operator-recovery hint in `$GITHUB_STEP_SUMMARY` echoes).
*
* Walks bash `if`/`fi` nesting to find the matching `fi` for the failure
* branch — naïve string matching wouldn't survive nested conditionals.
*/
function extractCherryPickFailureBlock(script) {
const lines = script.split('\n');
const startIdx = lines.findIndex(l => /if ! git[^\n]*cherry-pick[^\n]*"\$SHA"; then/.test(l));
if (startIdx === -1) throw new Error('cherry-pick failure conditional not found in auto_cherry_pick loop');
let depth = 1;
for (let i = startIdx + 1; i < lines.length; i++) {
if (/^\s*if[\s(]/.test(lines[i]) || /;\s*then\s*$/.test(lines[i])) depth++;
if (/^\s*fi\s*$/.test(lines[i])) {
depth--;
if (depth === 0) return lines.slice(startIdx + 1, i).join('\n');
}
}
throw new Error('matching `fi` for cherry-pick failure conditional not found');
}
describe('bug-2968: release-sdk hotfix cherry-pick skips all conflicts (full automation)', () => {
test('cherry-pick failure path no longer carries abort-on-real-conflict control flow', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const failureBlock = extractCherryPickFailureBlock(script);
// The failure block must NOT call `git cherry-pick --abort` — that was
// the pre-#2968 behavior on real conflicts. Skip-on-any-conflict means
// we never abort; we always --skip.
assert.doesNotMatch(
failureBlock,
/git cherry-pick --abort/,
'auto_cherry_pick failure path must not call `git cherry-pick --abort` — full-automation policy is to skip all conflicts (#2968)'
);
// The failure block must NOT exit 1 — that bricked every hotfix on
// a divergent base tag. The workflow continues past conflicts now.
assert.doesNotMatch(
failureBlock,
/exit 1/,
'auto_cherry_pick failure path must not `exit 1` on cherry-pick conflicts — full-automation policy is to log and continue (#2968)'
);
// The failure block must NOT push --force-with-lease — that was the
// recovery-state push for operator-resolvable conflicts. With
// skip-on-any-conflict there's no partial-pick state to preserve.
assert.doesNotMatch(
failureBlock,
/git push --force-with-lease/,
'auto_cherry_pick failure path must not push partial state — full-automation policy is to skip and continue, no recovery state needed (#2968)'
);
});
test('cherry-pick failure path always calls `git cherry-pick --skip` and appends to CONFLICT_SKIPPED', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const failureBlock = extractCherryPickFailureBlock(script);
// All assertions on `failureBlock` are line-anchored (`^\s*...`, `m`
// flag) so a comment that mentions a command — e.g. "Calling `--skip`
// outside an in-progress cherry-pick exits non-zero" — can't satisfy
// the assertion. Only executable shell lines count. CodeRabbit on
// PR #2970.
assert.match(
failureBlock,
/^\s*git cherry-pick --skip\b/m,
'auto_cherry_pick failure path must call `git cherry-pick --skip` to clear cherry-pick state and continue the loop (#2968)'
);
// Conflict skips MUST go into a dedicated bucket — operators reviewing
// the run summary need to find manual-review items without scanning
// through policy-excluded feat/refactor/etc commits. Bug #2968.
assert.match(
failureBlock,
/^\s*CONFLICT_SKIPPED="\$\{CONFLICT_SKIPPED\}/m,
'auto_cherry_pick failure path must append to CONFLICT_SKIPPED (a separate bucket from POLICY_SKIPPED) so operators can find manual-review items in the run summary (#2968)'
);
assert.doesNotMatch(
failureBlock,
/^\s*SKIPPED="\$\{SKIPPED\}/m,
'auto_cherry_pick failure path must NOT append to the legacy SKIPPED bucket — that buries manual-review conflicts under "feat/refactor/etc — not auto-included" (#2968)'
);
assert.match(
failureBlock,
/^\s*continue\s*$/m,
'auto_cherry_pick failure path must `continue` the loop after skipping — full-automation policy is best-effort cherry-pick (#2968)'
);
});
test('merge commits are pre-skipped before cherry-pick is attempted', () => {
// Cherry-picking a merge commit requires `-m <parent>` which the loop
// can't choose automatically. Without it, `git cherry-pick <merge-sha>`
// fails BEFORE entering cherry-pick state — no CHERRY_PICK_HEAD — so
// the unconditional `--skip` would also fail and brick the loop.
// The loop must detect parent count > 1 and skip with a distinct
// reason BEFORE invoking cherry-pick. CodeRabbit on PR #2970.
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
assert.match(
script,
/git rev-list --parents -n 1 "\$SHA"/,
'auto_cherry_pick must inspect parent count before invoking cherry-pick — merge commits need `-m <parent>` and we can\'t pick the parent automatically (#2968)'
);
assert.match(
script,
/merge commit — manual -m parent selection required/,
'auto_cherry_pick must annotate merge-commit skips with a distinct reason so operators understand why the pick wasn\'t attempted (#2968)'
);
});
test('classifier guards against unreadable / markerless unmerged paths', () => {
// A degenerate unmerged file (missing, unreadable, or no conflict
// markers) must NOT be misclassified as "context absent at base" — the
// auto-skip path. Treat as real so the operator can investigate.
// Also: `awk` runs under `set -e`; a non-zero exit on a missing file
// would terminate the step. CodeRabbit on PR #2970.
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const failureBlock = extractCherryPickFailureBlock(script);
// Readability check before invoking the marker classifier.
assert.match(
failureBlock,
/\[\s*!\s*-r\s+"\$CONFLICTED"\s*\]/,
'auto_cherry_pick must check `[ ! -r "$CONFLICTED" ]` before running the awk classifier so an unreadable unmerged path does not terminate the step under `set -e` (#2968)'
);
// Marker-presence check before invoking the marker classifier — a file
// listed as unmerged but with no `<<<<<<< ` header is anomalous.
assert.match(
failureBlock,
/grep -q '\^<<<<<<< '\s+"\$CONFLICTED"/,
'auto_cherry_pick must verify `<<<<<<< ` markers exist in the file before running the awk classifier so a markerless unmerged file is not misclassified as context-missing (#2968)'
);
// The awk invocation must tolerate non-zero exits (e.g. via 2>/dev/null
// and `|| echo "real"`) so a transient awk failure can't slip the file
// into the auto-skip bucket.
assert.match(
failureBlock,
/awk[\s\S]+?\|\|\s*echo\s+"real"/,
'awk classifier must default to "real" on non-zero exit so transient awk failures do not auto-skip a real conflict (#2968)'
);
});
test('git cherry-pick --skip is guarded by CHERRY_PICK_HEAD existence', () => {
// If cherry-pick fails for a reason that doesn't enter conflict state
// (e.g. unreadable commit, ref problem), CHERRY_PICK_HEAD doesn't exist
// and `git cherry-pick --skip` exits non-zero — bricking the loop.
// The skip call must be guarded. CodeRabbit on PR #2970.
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const failureBlock = extractCherryPickFailureBlock(script);
assert.match(
failureBlock,
/git rev-parse[^\n]*CHERRY_PICK_HEAD/,
'auto_cherry_pick must check CHERRY_PICK_HEAD exists before calling `git cherry-pick --skip` — calling --skip outside an in-progress cherry-pick fails (#2968)'
);
});
test('run summary uses distinct sections for conflict skips vs policy skips', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
// The summary must surface both buckets with distinct headings so
// operators can act on the right one. Conflict skips are the review
// queue; policy skips are informational.
assert.match(
script,
/Skipped — cherry-pick conflict \(manual review\)/,
'run summary must show conflict skips under a "manual review" heading distinct from policy skips (#2968)'
);
assert.match(
script,
/Not auto-included \(feat\/refactor\/docs\/etc\)/,
'run summary must show policy skips under a heading that names the excluded categories — they are not failures (#2968)'
);
// Both buckets must be referenced when emitting the summary so a
// future edit can't silently drop one section.
assert.match(
script,
/\$CONFLICT_SKIPPED/,
'run summary must echo $CONFLICT_SKIPPED so the manual-review queue actually appears (#2968)'
);
assert.match(
script,
/\$POLICY_SKIPPED/,
'run summary must echo $POLICY_SKIPPED so policy-excluded commits remain visible to operators (#2968)'
);
});
test('skip reason annotates real merge conflicts distinctly from context-missing', () => {
const yaml = fs.readFileSync(WORKFLOW_PATH, 'utf8');
const script = extractStepRun(yaml, 'Prepare hotfix branch');
const failureBlock = extractCherryPickFailureBlock(script);
// Operators must be able to find real conflicts in the run summary —
// the "merge conflict" string is the discriminator.
assert.match(
failureBlock,
/merge conflict/i,
'auto_cherry_pick must annotate real-conflict skips with "merge conflict" so operators can find them in the run summary (#2968)'
);
// The empty-HEAD/context-missing classification (#2966) is preserved
// — its diagnostic value (operator can tell the conflict was "fix
// patched code that doesn't exist here" vs "fix patched code we
// restructured") survives the policy change.
assert.match(
failureBlock,
/context absent at base/,
'auto_cherry_pick must still annotate context-missing skips distinctly from real merge conflicts so operators can distinguish the diagnostic (#2966 + #2968)'
);
});
});