From e50365b139549b16ab816ce18cc8d03c5d11abb6 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:19:06 -0400 Subject: [PATCH 1/6] fix(ci): judge the pr-unblock hold by the base's unblockStops ci-status's pr-unblock step applied its own jq and awk copy of the rule merge-holds.mjs states as unblockStops. It now hands the head's statuses to the base commit's unblockStops, so the rule is stated once. A missing base config or a base module that cannot judge the list fails closed. Refs: #10747 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pr-require-checks.yml | 39 ++++++++++++++++------- scripts/ci-hold-pr-unblock-status.test.sh | 23 ++++++++++--- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index 1e0215e870..cb333a46ee 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -5064,16 +5064,18 @@ jobs: # `pr-unblock/` commit status on the head (pr-unblock.yml # report-stop-status), and only ci-status is required, so this step # fails while the newest status of any `pr-unblock/*` context on the - # head is `failure` or `error`, naming each. Newest per context, by - # github-actions[bot] only, as report-escalation.sh `clear` reads it: - # its later `success` on the same head supersedes the failure, and - # another account's status neither holds nor clears. On a pull request + # head is `failure` or `error`, naming each, by the base commit's + # merge-holds.mjs unblockStops, the rule enable-auto-merge judges a head + # by: newest per context, by github-actions[bot] only, as + # report-escalation.sh `clear` reads it, so its later `success` on the + # same head supersedes the failure, and another account's status neither + # holds nor clears. On a pull request # event the head is the event's; in the merge queue it is the head of # the pull request named in the group's ref, read live (the ref's SHA is # the group's base). A status event re-runs nothing, so a stop posted # after this step passed holds at the merge queue's run. An unparsable - # ref, a head that is not a commit SHA, or an unreadable pull request or - # status list fails closed. + # ref, a head that is not a commit SHA, an unreadable pull request or + # status list, or a base module that cannot judge them fails closed. # Pinned by scripts/ci-hold-pr-unblock-status.test.sh, which runs this body. - name: Fail a head a pr-unblock lane stopped id: pr_unblock_status @@ -5081,8 +5083,14 @@ jobs: env: GH_TOKEN: ${{ github.token }} HEAD_SHA: ${{ github.event.pull_request.head.sha }} + BASE_CONFIG: ${{ steps.base_config.outcome == 'success' && format('{0}/base-config', github.workspace) || '' }} run: | . "$RUNNER_TEMP/gh-read.sh" + module=${BASE_CONFIG:-}/.github/scripts/pr-merge/merge-holds.mjs + if [ -z "${BASE_CONFIG:-}" ] || [ ! -s "$module" ]; then + echo "::error::cannot read merge-holds.mjs at the base commit; re-run this run." + exit 1 + fi read3() { local attempt for attempt in 1 2 3; do @@ -5105,11 +5113,20 @@ jobs: echo "::error::the head '$head' is not a commit SHA." exit 1 fi - # Newest first across pages, so the first row of a context is its newest. - rows=$(read3 "the statuses of $head" "repos/$GITHUB_REPOSITORY/commits/$head/statuses?per_page=100" --paginate \ - --jq '.[] | select((.context | startswith("pr-unblock/")) and .creator.login == "github-actions[bot]") - | [.context, .state, (.description // "" | gsub("[\t\r\n]"; " "))] | @tsv') || exit 1 - failing=$(awk -F'\t' '!seen[$1]++ && ($2 == "failure" || $2 == "error")' <<<"$rows") + # Newest first across pages, as unblockStops reads them. + rows=$(read3 "the statuses of $head" "repos/$GITHUB_REPOSITORY/commits/$head/statuses?per_page=100" --paginate --jq '.[]') || exit 1 + jq -s . <<<"$rows" >"$RUNNER_TEMP/pr-unblock-statuses.json" || { echo "::error::the statuses of $head do not parse."; exit 1; } + # shellcheck disable=SC2016 # JavaScript, not shell + stops=$(node --input-type=module -e 'import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + const { unblockStops } = await import(pathToFileURL(process.argv[2]).href); + console.log(JSON.stringify(unblockStops(JSON.parse(readFileSync(process.argv[1], "utf8")))));' \ + "$RUNNER_TEMP/pr-unblock-statuses.json" "$module") && + jq -e 'type == "array"' <<<"$stops" >/dev/null && + failing=$(jq -r '.[] | [.context, .state, .description] | @tsv' <<<"$stops") || { + echo "::error::merge-holds.mjs at the base commit could not judge the statuses of $head; re-run this run." + exit 1 + } if [ -z "$failing" ]; then echo "No pr-unblock/* status fails on $head." exit 0 diff --git a/scripts/ci-hold-pr-unblock-status.test.sh b/scripts/ci-hold-pr-unblock-status.test.sh index 34a1085b77..44c8db2753 100755 --- a/scripts/ci-hold-pr-unblock-status.test.sh +++ b/scripts/ci-hold-pr-unblock-status.test.sh @@ -6,8 +6,9 @@ # ci-status fails while the newest `pr-unblock/*` status on the head, by # github-actions[bot], is a failure or error, and names each such context. A # later success on the same head supersedes a failure. In the merge queue the -# head is the queued pull request's, read live. It fails closed when it cannot -# tell. +# head is the queued pull request's, read live. The rule is the base commit's +# merge-holds.mjs unblockStops, here the repository's own copy. It fails closed +# when it cannot tell, a base module that cannot judge included. # test-scope: .github/workflows/pr-require-checks.yml set -uo pipefail @@ -85,6 +86,8 @@ cat >"$TMP_ROOT/bin/sleep" <<'EOF' printf '%s\n' "$*" >>"$STUB_SLEEPS" EOF chmod +x "$TMP_ROOT/bin/gh" "$TMP_ROOT/bin/sleep" +mkdir -p "$TMP_ROOT/base/.github/scripts/pr-merge" +cp "$ROOT/.github/scripts/pr-merge/merge-holds.mjs" "$TMP_ROOT/base/.github/scripts/pr-merge/" HEAD=0123456789abcdef0123456789abcdef01234567 QUEUED=fedcba9876543210fedcba9876543210fedcba98 @@ -103,7 +106,7 @@ expect() { ci_gh_read::reset "$TMP_ROOT" 1792800600 out="$(PATH="$TMP_ROOT/bin:$PATH" STUB_LOG="$TMP_ROOT/log" STUB_SLEEPS="$TMP_ROOT/sleeps" STUB_STATUSES="$3" \ GITHUB_REPOSITORY=o/r GITHUB_EVENT_NAME="${EVENT:-pull_request}" GITHUB_REF="${REF:-refs/pull/7/merge}" \ - HEAD_SHA="${HEAD_SHA-$HEAD}" RUNNER_TEMP="$TMP_ROOT" bash -e "$TMP_ROOT/body.sh" 2>&1)" && rc=0 || rc=$? + HEAD_SHA="${HEAD_SHA-$HEAD}" BASE_CONFIG="${BASE_CONFIG-$TMP_ROOT/base}" RUNNER_TEMP="$TMP_ROOT" bash -e "$TMP_ROOT/body.sh" 2>&1)" && rc=0 || rc=$? if [[ "$rc" -ne "$want_rc" ]]; then fail "$label: expected rc=$want_rc got rc=$rc out='$out'" elif [[ -n "${4:-}" && "$out" != *"$4"* ]]; then @@ -131,7 +134,7 @@ expect "each failing context is named" 1 "[$(status pr-unblock/fix-ci failure),$ "'pr-unblock/conflict' is error" "'pr-unblock/address-feedback'" out_both="$(grep -c "::error::'pr-unblock/" <<<"$(PATH="$TMP_ROOT/bin:$PATH" STUB_LOG="$TMP_ROOT/log" STUB_SLEEPS="$TMP_ROOT/sleeps" \ STUB_STATUSES="[$(status pr-unblock/fix-ci failure),$(status pr-unblock/conflict error)]" GITHUB_REPOSITORY=o/r \ - GITHUB_EVENT_NAME=pull_request HEAD_SHA="$HEAD" RUNNER_TEMP="$TMP_ROOT" bash -e "$TMP_ROOT/body.sh" 2>&1)")" + GITHUB_EVENT_NAME=pull_request HEAD_SHA="$HEAD" BASE_CONFIG="$TMP_ROOT/base" RUNNER_TEMP="$TMP_ROOT" bash -e "$TMP_ROOT/body.sh" 2>&1)")" if [[ "$out_both" -eq 2 ]]; then ok "two failing contexts give two errors"; else fail "two failing contexts gave $out_both errors"; fi expect "a newer success supersedes an older failure" 0 "[$(status pr-unblock/fix-ci success),$(status pr-unblock/fix-ci failure)]" expect "a newer failure over an older success fails" 1 "[$(status pr-unblock/fix-ci failure),$(status pr-unblock/fix-ci success)]" "'pr-unblock/fix-ci' is failure" @@ -142,6 +145,18 @@ expect "a description's percent sign is escaped" 1 "[$(status pr-unblock/fix-ci export STUB_STATUS_FAILS=1 expect "an unreadable status list fails closed" 1 "[]" "::error::could not read the statuses of $HEAD; re-run this run." unset STUB_STATUS_FAILS +expect "a description's tab and newline become spaces" 1 "[$(status pr-unblock/fix-ci failure "$LANE" $'one\ttwo\nthree')]" "on $HEAD: one two three. The lane" +export BASE_CONFIG= +expect "no base config fails closed" 1 "[]" "::error::cannot read merge-holds.mjs at the base commit; re-run this run." +if [[ -s "$TMP_ROOT/log" ]]; then fail "no base config still read: '$(cat "$TMP_ROOT/log")'"; else ok "no base config makes no call"; fi +mkdir -p "$TMP_ROOT/old/.github/scripts/pr-merge" +echo 'export const refsIssue = () => null;' >"$TMP_ROOT/old/.github/scripts/pr-merge/merge-holds.mjs" +export BASE_CONFIG="$TMP_ROOT/old" +expect "a base module without unblockStops fails closed" 1 "[$(status pr-unblock/fix-ci success)]" \ + "::error::merge-holds.mjs at the base commit could not judge the statuses of $HEAD; re-run this run." +echo 'export const unblockStops = () => ({});' >"$TMP_ROOT/old/.github/scripts/pr-merge/merge-holds.mjs" +expect "a base module that answers no list fails closed" 1 "[]" "could not judge the statuses of $HEAD" +unset BASE_CONFIG export HEAD_SHA=main expect "a pull request head that is not a commit SHA fails closed" 1 "[]" "::error::the head 'main' is not a commit SHA." if [[ -s "$TMP_ROOT/log" ]]; then fail "a bad head was read: '$(cat "$TMP_ROOT/log")'"; else ok "a bad head makes no call"; fi From c9e37cc117b25ec0d5a10f55560057f7a19b3002 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:26:53 -0400 Subject: [PATCH 2/6] perf(ci): fail a held merge-queue pull request before the lanes run A merge group for a pull request carrying a hold label, a head a pr-unblock lane stopped, or a lanes App pull request under the kill switch ran every lane leg before ci-status failed it. select-tests, which every leg needs, now judges those holds first in merge_group runs by the base commit's merge-holds.mjs queueHolds, so the group fails in seconds and the legs skip. ci-status keeps judging each hold from its own reads. A read the early step cannot make, or a base module without queueHolds, leaves the hold to ci-status. The step reads the pull request and its head's statuses through the api-budget wrapper and ends with its api-calls notice; select-tests gains statuses: read for it. The pr-unblock step's base-module call is written as one if, which shellcheck reads cleanly. Refs: #10747 Co-Authored-By: Claude Opus 5.5 --- .github/scripts/pr-merge/merge-holds.mjs | 60 +++++++ .github/scripts/pr-merge/merge-holds.test.mjs | 67 +++++++ .github/workflows/pr-require-checks.yml | 99 +++++++++- scripts/ci-early-queue-holds.test.sh | 170 ++++++++++++++++++ 4 files changed, 390 insertions(+), 6 deletions(-) create mode 100755 scripts/ci-early-queue-holds.test.sh diff --git a/.github/scripts/pr-merge/merge-holds.mjs b/.github/scripts/pr-merge/merge-holds.mjs index 1c5e33ea4a..4029c8dead 100755 --- a/.github/scripts/pr-merge/merge-holds.mjs +++ b/.github/scripts/pr-merge/merge-holds.mjs @@ -1396,6 +1396,66 @@ export async function readStatuses(github, repo, head) { throw new Error("more than 1000 statuses on the head"); } +// The tracker config's human-gated role label (.work-item-tracker.json +// config.role_labels["human-gated"], trimmed), `needs-human` when it names +// none, as ci-status reads it. +export function humanGatedLabel(tracker) { + const label = tracker?.config?.role_labels?.["human-gated"]; + return typeof label === "string" && label.trim() ? label.trim() : "needs-human"; +} + +// The hold labels on pull request `number`: `do-not-merge`, the tracker's +// human-gated label and `pr-verify-failed`, one reason each. `labels` are +// names or label objects; a list that is not an array throws. +export function labelHolds(labels, tracker, number) { + if (!Array.isArray(labels)) { + throw new Error("the labels are not a list"); + } + const names = new Set(labels.map((label) => (typeof label === "string" ? label : label?.name))); + const gated = humanGatedLabel(tracker); + return [ + names.has("do-not-merge") && `#${number} carries the 'do-not-merge' label; the merge queue removes it. Remove the label and re-queue to merge.`, + names.has(gated) && `#${number} carries '${gated}'; the merge queue removes it. Removing the label approves this pull request; re-queue it to merge.`, + names.has("pr-verify-failed") && + `#${number} carries 'pr-verify-failed': pr-verify failed its head, and a pass at a new commit removes the label. The merge queue removes it; re-queue it once the label is off.`, + ].filter(Boolean); +} + +// The lanes kill switch on a lanes App pull request: while CLAUDE_LANES_DISABLED +// or CLAUDE_LANES_REPO_DISABLED is anything but the exact value `false`, a pull +// request whose author is the Bot of numeric id `appId` holds. Any other +// author, or an `appId` that is not a numeric id, holds nothing here. +export function switchHold(pull, { appId, disabled, repoDisabled } = {}) { + const id = Number(appId); + if (!/^[1-9][0-9]*$/.test(String(appId)) || pull?.user?.type !== "Bot" || pull?.user?.id !== id) { + return null; + } + if (disabled === "false" && repoDisabled === "false") { + return null; + } + return `#${pull.number}: the lanes kill switch is on (CLAUDE_LANES_DISABLED or CLAUDE_LANES_REPO_DISABLED is not 'false'), so no lanes App pull request merges; it merges once both read 'false' and ci-status is re-run, which the lost-runs reconciler does when this is the run's only failure.`; +} + +// THE HOLDS THAT COST NO LANE WORK, for a queued pull request: its hold labels, +// its head's pr-unblock stops and the lanes kill switch, from the pull request +// as the pulls API returns it, its head's statuses newest first, the tracker +// config (or null) and the kill switch's { appId, disabled, repoDisabled }. +// select-tests runs this before the lanes; ci-status keeps judging each hold. +export function queueHolds({ pull, statuses, tracker = null, lanes = {} } = {}) { + const head = pull?.head?.sha; + if (!Number.isInteger(pull?.number) || typeof head !== "string") { + throw new Error("the pull request does not read"); + } + return [ + ...labelHolds(pull.labels, tracker, pull.number), + ...unblockStops(statuses).map( + ({ context, state, description }) => + `'${context}' is ${state} on ${head}: ${description}. The lane that stopped clears it by running again on this head, or a new head carries none.`, + ), + switchHold(pull, lanes), + ].filter(Boolean); +} + // Whether a person wrote the body of pull request `number` (personWrote); a // read that fails counts as a person. async function bodyByPerson(github, repo, number) { diff --git a/.github/scripts/pr-merge/merge-holds.test.mjs b/.github/scripts/pr-merge/merge-holds.test.mjs index 7aae9f5a75..a8eee4efc2 100644 --- a/.github/scripts/pr-merge/merge-holds.test.mjs +++ b/.github/scripts/pr-merge/merge-holds.test.mjs @@ -1455,3 +1455,70 @@ test("Q10: a compare not read whole, not ahead, or with a dismissal the push did } assert.equal(carriesQ10(pushed(["docs/a.md"]), null), false, "unread hold paths"); }); + +// The queue holds select-tests checks before the lanes (#10747). Expected +// reasons are ci-status's own error texts for the same holds: its hold-label +// step's three label errors, its pr-unblock step's error and its lanes App +// step's kill-switch error, which reconcile.test.mjs keys on. +const QUEUED = "fedcba9876543210fedcba9876543210fedcba98"; +const queuedPull = (over = {}) => ({ number: 7, head: { sha: QUEUED }, labels: [], user: { id: 42, type: "User" }, ...over }); +const ON = { appId: "99", disabled: "false", repoDisabled: "false" }; + +test("queueHolds: a pull request with no hold label, stop or switch passes", () => { + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull(), statuses: [], tracker: null, lanes: ON }), []); +}); + +test("queueHolds: each hold label holds with ci-status's text", () => { + for (const [label, want] of [ + ["do-not-merge", "#7 carries the 'do-not-merge' label; the merge queue removes it. Remove the label and re-queue to merge."], + ["needs-human", "#7 carries 'needs-human'; the merge queue removes it. Removing the label approves this pull request; re-queue it to merge."], + ["pr-verify-failed", "#7 carries 'pr-verify-failed': pr-verify failed its head, and a pass at a new commit removes the label. The merge queue removes it; re-queue it once the label is off."], + ]) { + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull({ labels: [{ name: "bug" }, { name: label }] }), statuses: [] }), [want], label); + } + assert.equal(holdsModule.queueHolds({ pull: queuedPull({ labels: [{ name: "do-not-merge" }, { name: "needs-human" }] }), statuses: [] }).length, 2); +}); + +test("queueHolds: the human-gated label is the tracker's, trimmed, else needs-human", () => { + const tracker = { config: { role_labels: { "human-gated": " hitl " } } }; + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull({ labels: [{ name: "needs-human" }] }), statuses: [], tracker }), []); + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull({ labels: [{ name: "hitl" }] }), statuses: [], tracker }), [ + "#7 carries 'hitl'; the merge queue removes it. Removing the label approves this pull request; re-queue it to merge.", + ]); + for (const blank of [{ config: { role_labels: { "human-gated": " " } } }, { config: {} }, [], "x", null]) { + assert.equal(holdsModule.humanGatedLabel(blank), "needs-human", JSON.stringify(blank)); + } +}); + +test("queueHolds: a pr-unblock stop on the head holds; a newer success or another account's status does not", () => { + const lane = { login: "github-actions[bot]" }; + const failure = { context: "pr-unblock/fix-ci", state: "failure", creator: lane, description: "The lane stopped" }; + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull(), statuses: [failure] }), [ + `'pr-unblock/fix-ci' is failure on ${QUEUED}: The lane stopped. The lane that stopped clears it by running again on this head, or a new head carries none.`, + ]); + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull(), statuses: [{ ...failure, state: "success" }, failure] }), []); + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull(), statuses: [{ ...failure, creator: { login: "someone" } }] }), []); +}); + +test("queueHolds: the kill switch holds only the lanes App's pull request, until both read false", () => { + const app = { user: { id: 99, type: "Bot" } }; + const text = + "#7: the lanes kill switch is on (CLAUDE_LANES_DISABLED or CLAUDE_LANES_REPO_DISABLED is not 'false'), so no lanes App pull request merges; it merges once both read 'false' and ci-status is re-run, which the lost-runs reconciler does when this is the run's only failure."; + for (const [name, lanes] of [ + ["on", { ...ON, disabled: "true" }], + ["repo on", { ...ON, repoDisabled: "1" }], + ["unset", { appId: "99" }], + ]) { + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull(app), statuses: [], lanes }), [text], name); + } + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull(app), statuses: [], lanes: ON }), []); + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull(), statuses: [], lanes: { appId: "99", disabled: "true" } }), [], "a person's"); + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull({ user: { id: 99, type: "User" } }), statuses: [], lanes: { appId: "99" } }), [], "a User with the id"); + assert.deepEqual(holdsModule.queueHolds({ pull: queuedPull(app), statuses: [], lanes: { appId: "", disabled: "true" } }), [], "no app id"); +}); + +test("queueHolds: a pull request, label list or status list that does not read throws", () => { + assert.throws(() => holdsModule.queueHolds({ pull: { number: 7, head: {} }, statuses: [] }), /does not read/); + assert.throws(() => holdsModule.queueHolds({ pull: queuedPull({ labels: null }), statuses: [] }), /labels are not a list/); + assert.throws(() => holdsModule.queueHolds({ pull: queuedPull(), statuses: {} }), /not a list/); +}); diff --git a/.github/workflows/pr-require-checks.yml b/.github/workflows/pr-require-checks.yml index cb333a46ee..2033cb4bab 100644 --- a/.github/workflows/pr-require-checks.yml +++ b/.github/workflows/pr-require-checks.yml @@ -283,10 +283,14 @@ jobs: timeout-minutes: 3 # The detect-changes action lists the pull request's files through the # API. Nothing else in this workflow needs that read, so it is granted here - # and nowhere else. No write scope: this job runs pull request code. + # and nowhere else. The queue-hold step reads the queued pull request and + # its head's commit statuses ("Commit statuses" read, per + # https://docs.github.com/en/rest/authentication/permissions-required-for-github-apps). + # No write scope: this job runs pull request code. permissions: contents: read pull-requests: read + statuses: read outputs: run_full: ${{ steps.detect.outputs.docs_only != 'true' }} run_tests: ${{ steps.detect.outputs.docs_only != 'true' && github.event.pull_request.draft != true }} @@ -320,6 +324,89 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # A QUEUED PULL REQUEST A HOLD STOPS FAILS HERE, BEFORE THE LANES. In the + # merge queue the holds that cost no lane work, its hold labels, a + # pr-unblock stop on its head and the lanes kill switch, are judged by + # the base commit's merge-holds.mjs queueHolds: a held group fails this + # job in seconds, and every lane, which needs it, skips. ci-status still + # judges each hold itself, from its own reads, so the aggregate fails on + # the hold and on this job, and a skipped lane stays skipped there (this + # job did not succeed). Nothing here feeds a ci-status hold: this job runs + # pull request code. Only a hold read fails it: a read that fails, a ref + # it cannot parse, or a base module without queueHolds leaves the hold to + # ci-status, which retries and fails closed. Two reads, the pull request + # and its head's statuses (one per 100), through the api-budget gh + # wrapper, whose api-calls notice the step ends with. The base's + # module, tracker config, wrapper and Node pin come from one shallow + # fetch of the group's base commit. Pinned by + # scripts/ci-early-queue-holds.test.sh, which runs this body. + - name: Read the base commit's queue-hold rules + id: queue_rules + if: github.event_name == 'merge_group' + continue-on-error: true + env: + BASE_SHA: ${{ github.event.merge_group.base_sha }} + run: | + [[ $BASE_SHA =~ ^[0-9a-f]{40}$ ]] || { echo "::warning::the group's base '$BASE_SHA' is not a commit SHA; ci-status judges the holds."; exit 1; } + git fetch --no-tags --depth=1 origin "$BASE_SHA" + dir="$RUNNER_TEMP/queue-rules" + mkdir -p "$dir" + git show "$BASE_SHA:.github/scripts/pr-merge/merge-holds.mjs" >"$dir/merge-holds.mjs" + git show "$BASE_SHA:.github/actions/log-api-budget/api-budget.bash" >"$dir/api-budget.bash" + git show "$BASE_SHA:.work-item-tracker.json" >"$dir/tracker.json" 2>/dev/null || rm -f "$dir/tracker.json" + echo "node=$(git show "$BASE_SHA:.node-version")" >>"$GITHUB_OUTPUT" + - name: Set up Node for the queue holds + if: github.event_name == 'merge_group' && steps.queue_rules.outcome == 'success' + continue-on-error: true + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: ${{ steps.queue_rules.outputs.node }} + package-manager-cache: false + - name: Fail a queued pull request a hold stops, before the lanes + if: github.event_name == 'merge_group' + env: + GH_TOKEN: ${{ github.token }} + RULES: ${{ steps.queue_rules.outcome == 'success' && format('{0}/queue-rules', runner.temp) || '' }} + LANES_APP_SENDER_ID: ${{ vars.AUTOMATION_LANES_APP_SENDER_ID }} + LANES_DISABLED: ${{ vars.CLAUDE_LANES_DISABLED }} + LANES_REPO_DISABLED: ${{ vars.CLAUDE_LANES_REPO_DISABLED }} + run: | + if [ -z "$RULES" ] || [ ! -s "$RULES/merge-holds.mjs" ]; then + echo "::notice::the base commit's hold rules did not read; ci-status judges the holds." + exit 0 + fi + # shellcheck source=/dev/null + if . "$RULES/api-budget.bash"; then + api_budget_wrap_gh github-token + trap 'api_budget_emit github-token; api_budget_emit_calls github-token' EXIT + fi + leave() { echo "::warning::$1; ci-status judges the holds."; exit 0; } + pr=$(sed -nE 's#^(refs/heads/)?gh-readonly-queue/.+/pr-([0-9]+)-[0-9a-f]{40}$#\2#p' <<<"$GITHUB_REF") + [ -n "$pr" ] || leave "cannot read a pull request number from the merge-group ref '$GITHUB_REF'" + gh api "repos/$GITHUB_REPOSITORY/pulls/$pr" >"$RUNNER_TEMP/queued-pull.json" || leave "could not read #$pr" + head=$(jq -r '.head.sha // ""' "$RUNNER_TEMP/queued-pull.json") || head= + [[ $head =~ ^[0-9a-f]{40}$ ]] || leave "the head of #$pr is not a commit SHA" + rows=$(gh api "repos/$GITHUB_REPOSITORY/commits/$head/statuses?per_page=100" --paginate --jq '.[]') || leave "could not read the statuses of $head" + tracker=null + [ ! -s "$RULES/tracker.json" ] || tracker=$(cat "$RULES/tracker.json") + jq -n --slurpfile pull "$RUNNER_TEMP/queued-pull.json" --argjson tracker "$tracker" --slurpfile statuses <(printf '%s\n' "$rows") \ + --arg appId "${LANES_APP_SENDER_ID:-}" --arg disabled "${LANES_DISABLED:-}" --arg repoDisabled "${LANES_REPO_DISABLED:-}" \ + '{pull: $pull[0], statuses: $statuses, tracker: $tracker, lanes: {appId: $appId, disabled: $disabled, repoDisabled: $repoDisabled}}' \ + >"$RUNNER_TEMP/queue-holds.json" || leave "the reads of #$pr do not parse" + # shellcheck disable=SC2016 # JavaScript, not shell + reasons=$(node --input-type=module -e 'import { readFileSync } from "node:fs"; + import { pathToFileURL } from "node:url"; + const { queueHolds } = await import(pathToFileURL(process.argv[2]).href); + for (const reason of queueHolds(JSON.parse(readFileSync(process.argv[1], "utf8")))) console.log(reason.replaceAll(/[\r\n]/g, " "));' \ + "$RUNNER_TEMP/queue-holds.json" "$RULES/merge-holds.mjs") || leave "the base commit's merge-holds.mjs could not judge #$pr" + if [ -z "$reasons" ]; then + echo "#$pr: no hold label, pr-unblock stop or kill switch holds it before the lanes." + exit 0 + fi + while IFS= read -r reason; do + echo "::error::${reason//%/%25} The lanes do not run for this group." + done <<<"$reasons" + exit 1 - name: Fetch base uses: ./.github/actions/download-full-history # zizmor: ignore[self-repository] # THE DIFF BASE, RESOLVED ONCE. A pull request diffs against its base @@ -5117,16 +5204,16 @@ jobs: rows=$(read3 "the statuses of $head" "repos/$GITHUB_REPOSITORY/commits/$head/statuses?per_page=100" --paginate --jq '.[]') || exit 1 jq -s . <<<"$rows" >"$RUNNER_TEMP/pr-unblock-statuses.json" || { echo "::error::the statuses of $head do not parse."; exit 1; } # shellcheck disable=SC2016 # JavaScript, not shell - stops=$(node --input-type=module -e 'import { readFileSync } from "node:fs"; + if ! stops=$(node --input-type=module -e 'import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; const { unblockStops } = await import(pathToFileURL(process.argv[2]).href); console.log(JSON.stringify(unblockStops(JSON.parse(readFileSync(process.argv[1], "utf8")))));' \ - "$RUNNER_TEMP/pr-unblock-statuses.json" "$module") && - jq -e 'type == "array"' <<<"$stops" >/dev/null && - failing=$(jq -r '.[] | [.context, .state, .description] | @tsv' <<<"$stops") || { + "$RUNNER_TEMP/pr-unblock-statuses.json" "$module") || + ! jq -e 'type == "array"' <<<"$stops" >/dev/null || + ! failing=$(jq -r '.[] | [.context, .state, .description] | @tsv' <<<"$stops"); then echo "::error::merge-holds.mjs at the base commit could not judge the statuses of $head; re-run this run." exit 1 - } + fi if [ -z "$failing" ]; then echo "No pr-unblock/* status fails on $head." exit 0 diff --git a/scripts/ci-early-queue-holds.test.sh b/scripts/ci-early-queue-holds.test.sh new file mode 100755 index 0000000000..775a6913ee --- /dev/null +++ b/scripts/ci-early-queue-holds.test.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Runs the `Fail a queued pull request a hold stops, before the lanes` step +# body of .github/workflows/pr-require-checks.yml's select-tests under the +# shell Actions gives a step with no `shell:` (bash -e), against a stub `gh`, +# with the repository's own merge-holds.mjs and api-budget.bash as the base +# commit's. +# +# In the merge queue a pull request carrying a hold label (do-not-merge, the +# tracker's human-gated label, pr-verify-failed), a head a pr-unblock lane +# stopped, or a lanes App pull request while the kill switch is on fails +# select-tests, which every lane needs, so no lane runs (#10747). ci-status +# still judges each hold. A read that fails, an unparsable ref or a base module +# without queueHolds leaves the hold to ci-status and passes. The step ends +# with the api-calls notice: two requests, the pull and one statuses page. +# test-scope: .github/workflows/pr-require-checks.yml +set -uo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +WORKFLOW="$ROOT/.github/workflows/pr-require-checks.yml" + +# shellcheck source=lib/test-harness.sh +. "$ROOT/scripts/lib/test-harness.sh" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +NAME='Fail a queued pull request a hold stops, before the lanes' +step="$(awk -v name=" - name: $NAME" '$0 == name { on = 1; print; next } + on && /^ - / { exit } + on { print }' "$WORKFLOW")" +awk '/^ run: \|$/ { on = 1; next } + on && /^ / { print substr($0, 11); next } + on && /^[[:blank:]]*$/ { print ""; next } + on { exit }' <<<"$step" >"$TMP_ROOT/body.sh" + +if [[ ! -s "$TMP_ROOT/body.sh" ]]; then + fail "no '$NAME' step with a run: | body in $WORKFLOW" + test_harness::report + exit 1 +fi +if [[ "$step" == *"if: github.event_name == 'merge_group'"* && "$step" != *"continue-on-error"* ]]; then + ok "step runs only in the merge queue and fails the job on its exit code" +else + fail "step lost its merge_group condition or continues on error" +fi +order="$(grep -nE "^ (select-tests|lint-repo):$|^ - name: ($NAME|Fetch base)$" "$WORKFLOW" | cut -d: -f1 | head -4 | paste -sd' ')" +read -r job_line hold_line fetch_line next_job <<<"$order" +if [[ -n "${next_job:-}" && "$job_line" -lt "$hold_line" && "$hold_line" -lt "$fetch_line" && "$fetch_line" -lt "$next_job" ]]; then + ok "step sits in select-tests before the history fetch" +else + fail "step is not in select-tests before the history fetch (lines: $order)" +fi +if grep -qE "^ - (select-tests|mark-lanes)$" <<<"$(awk '/^ ci-status:$/ { on = 1 } on && /^ if:/ { exit } on' "$WORKFLOW")"; then + ok "ci-status still needs select-tests, so a held group's aggregate fails" +else + fail "ci-status no longer needs select-tests" +fi + +RULES="$TMP_ROOT/rules" +mkdir -p "$RULES" +cp "$ROOT/.github/scripts/pr-merge/merge-holds.mjs" "$ROOT/.github/actions/log-api-budget/api-budget.bash" "$RULES/" + +# The gh stub logs its arguments and, under GH_DEBUG, the request line the +# api-budget wrapper counts. A pulls read answers STUB_PULL, after a header +# block when the wrapper added -i. A statuses read applies the step's --jq to +# STUB_STATUSES (newest first). STUB_FAIL=pulls|statuses fails that read. +mkdir -p "$TMP_ROOT/bin" +cat >"$TMP_ROOT/bin/gh" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >>"$STUB_LOG" +[[ -z "${GH_DEBUG:-}" ]] || echo "* Request to https://api.github.com/x" >&2 +case "$*" in +*/pulls/*) + [[ "${STUB_FAIL:-}" != pulls ]] || exit 1 + [[ "$2" != -i ]] || printf 'HTTP/2.0 200 OK\r\nX-Ratelimit-Remaining: 4000\r\nX-Ratelimit-Resource: core\r\n\r\n' + printf '%s\n' "$STUB_PULL" + ;; +*/statuses*) + [[ "${STUB_FAIL:-}" != statuses ]] || exit 1 + while [[ $# -gt 0 && "$1" != --jq ]]; do shift; done + jq -r "$2" <<<"${STUB_STATUSES:-[]}" + ;; +*) exit 1 ;; +esac +EOF +chmod +x "$TMP_ROOT/bin/gh" + +HEAD=fedcba9876543210fedcba9876543210fedcba98 +GROUP_BASE=89abcdef0123456789abcdef0123456789abcdef +REF="refs/heads/gh-readonly-queue/main/pr-7-$GROUP_BASE" +LANE='{"login":"github-actions[bot]"}' +# pull [labels-json] [user-json] +PERSON='{"id":5,"type":"User"}' +pull() { jq -cn --arg h "$HEAD" --argjson l "${1:-[]}" --argjson u "${2:-$PERSON}" '{number: 7, head: {sha: $h}, labels: $l, user: $u}'; } +status() { jq -cn --arg c "$1" --arg s "$2" --argjson u "$LANE" '{context: $c, state: $s, creator: $u, description: "The lane stopped"}'; } + +# expect