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): commit0fb992d("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.0fb992dwas 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:
164
.github/workflows/release-sdk.yml
vendored
164
.github/workflows/release-sdk.yml
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
295
tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs
Normal file
295
tests/bug-2968-cherry-pick-skip-on-any-conflict.test.cjs
Normal 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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user