@@ -64,7 +64,7 @@ echo "[auto-merge] sweeping open PRs against ${BASE_BRANCH} in ${REPO}"
6464# batching this script exists to prevent.
6565base_sha=$( gh api " repos/${REPO} /commits/${BASE_BRANCH} " --jq ' .sha' )
6666base_ci=$( gh run list --repo " $REPO " --workflow " $CI_WORKFLOW " --branch " $BASE_BRANCH " --limit 1 \
67- --json status,conclusion,headSha --jq ' .[0] // empty' )
67+ --json databaseId, status,conclusion,headSha --jq ' .[0] // empty' )
6868
6969if [ -z " $base_ci " ]; then
7070 echo " [auto-merge] no CI history for ${BASE_BRANCH} — proceeding"
8181 echo " [auto-merge] ${BASE_BRANCH} CI is still running — deferring to the next sweep"
8282 exit 0
8383 fi
84+ # A red base must not become a trap for the PR that repairs it.
85+ #
86+ # "Never merge onto red" is right for an unrelated change: it stops a broken
87+ # base quietly collecting more of them and getting harder to diagnose. But
88+ # when the PR *is* the repair, the same rule deadlocks the repo — the fix
89+ # cannot travel the path its own redness blocks, and only a human can move
90+ # it. Seen in maonakamoto/aoz-housing on 2026-08-07: E2E red on the base, the
91+ # fix sitting green in a PR, every sweep refusing politely.
92+ #
93+ # So identify WHICH jobs are red and let a PR through only if its own checks
94+ # pass every one of them. Not a weakening: a PR's checks run on the MERGE
95+ # result (refs/pull/N/merge), so green-on-those-jobs is direct evidence the
96+ # post-merge base is better than the pre-merge base. Still refused: a PR that
97+ # does not cover the failing jobs, one that covers only some of them, and a
98+ # base failure whose jobs cannot be identified at all.
99+ base_red_jobs=" "
84100 if [ " $base_conclusion " != " success" ]; then
85- echo " [auto-merge] ${BASE_BRANCH} CI is ${base_conclusion} — refusing to merge onto a broken base" >&2
86- exit 0
101+ base_run_id=$( printf ' %s' " ${base_ci} " | jq -r ' .databaseId' )
102+ base_red_jobs=$( gh run view " ${base_run_id} " --repo " $REPO " --json jobs \
103+ --jq ' [.jobs[] | select(.conclusion == "failure") | .name] | .[]' 2> /dev/null || true)
104+ if [ -z " ${base_red_jobs} " ]; then
105+ echo " [auto-merge] ${BASE_BRANCH} CI is ${base_conclusion} and no failing job could be identified — refusing to merge onto a broken base" >&2
106+ exit 0
107+ fi
108+ echo " [auto-merge] ${BASE_BRANCH} CI is ${base_conclusion} — failing: $( printf ' %s' " ${base_red_jobs} " | tr ' \n' ' ' ) " >&2
109+ echo " [auto-merge] only a PR that is green on those exact jobs may merge (its checks run on the merge result)"
87110 fi
88111fi
89112
@@ -222,6 +245,29 @@ for number in $(printf '%s' "$prs_json" | jq -r 'sort_by(.number) | .[].number')
222245 break
223246 fi
224247
248+ # Red base: this PR merges only if it proves every failing job green.
249+ if [ -n " ${base_red_jobs} " ]; then
250+ pr_green=$( printf ' %s' " $pr " | jq -r '
251+ [ .statusCheckRollup[]?
252+ | select(((.conclusion // .state // "") | test("^(SUCCESS|NEUTRAL|SKIPPED)$")))
253+ | (.name // .context) ] | .[]' )
254+ uncovered=" "
255+ while IFS= read -r job; do
256+ [ -z " $job " ] && continue
257+ printf ' %s\n' " $pr_green " | grep -Fxq " $job " || uncovered=" ${uncovered}${job} ; "
258+ done << INNER_EOF
259+ ${base_red_jobs}
260+ INNER_EOF
261+ if [ -n " $uncovered " ]; then
262+ echo " [auto-merge] #${number} skip: ${BASE_BRANCH} is red on [${uncovered% ; } ] and this PR does not prove those green — ${title} "
263+ continue
264+ fi
265+ echo " [auto-merge] #${number} is green on every job ${BASE_BRANCH} fails — merging it to repair the base: ${title} " >&2
266+ if [ -n " ${GITHUB_STEP_SUMMARY:- } " ]; then
267+ echo " - 🔧 #${number} merged onto a red \` ${BASE_BRANCH} \` because it passes every failing job — ${title} " >> " $GITHUB_STEP_SUMMARY "
268+ fi
269+ fi
270+
225271 echo " [auto-merge] #${number} green and ready — merging: ${title} "
226272 if gh pr merge " $number " --repo " $REPO " --squash --delete-branch; then
227273 merged_any=1
0 commit comments