diff --git a/.github/workflows/release-sdk.yml b/.github/workflows/release-sdk.yml index 7178cf4e0..9dfdb957b 100644 --- a/.github/workflows/release-sdk.yml +++ b/.github/workflows/release-sdk.yml @@ -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 ` 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 diff --git a/tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs b/tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs index f3ae8e773..8eda40d1c 100644 --- a/tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs +++ b/tests/bug-2964-release-sdk-empty-cherry-pick.test.cjs @@ -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, diff --git a/tests/bug-2966-cherry-pick-context-missing.test.cjs b/tests/bug-2966-cherry-pick-context-missing.test.cjs index edf7d8370..df0021ca4 100644 --- a/tests/bug-2966-cherry-pick-context-missing.test.cjs +++ b/tests/bug-2966-cherry-pick-context-missing.test.cjs @@ -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'); diff --git a/tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs b/tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs new file mode 100644 index 000000000..aa55db8fa --- /dev/null +++ b/tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs @@ -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 ` which the loop + // can't choose automatically. Without it, `git cherry-pick ` + // 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 ` 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)' + ); + }); +});