Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 110 additions & 14 deletions .github/workflows/pr-require-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3049,9 +3049,9 @@ jobs:
runs-on: ubuntu-24.04
# 9 s p95 and 42 s max in full mode, about 10 s contract-only. The cap is
# the worst case of the pinned composites' waits: two 60 s rate-limit waits
# (`rate-limit-wait-seconds` on check-contract and aggregate-results), 15 s
# (`rate-limit-wait-seconds` on check-contract and aggregate-results), 45 s
# of own-gate retries, 7 s of status-write retries and the 90 s
# `rerun-wait-seconds` wait, about four minutes.
# `rerun-wait-seconds` wait, about four and a half minutes.
timeout-minutes: 5
permissions:
# `yield-to-full-run` lists this workflow's runs on the head SHA, and
Expand Down Expand Up @@ -3190,43 +3190,139 @@ jobs:
msg="draft: lanes not run; the ready_for_review run lints and tests this SHA"
echo "::notice::$msg"
echo "$msg" >>"$GITHUB_STEP_SUMMARY"
# A QUEUED PULL REQUEST IS HELD BY ITS LABEL TOO. A merge-group payload
# A PULL REQUEST CARRYING THE HUMAN-GATED LABEL IS HELD. A review lane or
# an escalation puts the label on so a person decides; removing it is the
# approval, and the `unlabeled` event re-runs this job. The label's name
# is the base commit's .work-item-tracker.json role_labels.human-gated
# (default `needs-human`), resolved as pr-merge.yml resolves it, and read
# at the base so the pull request cannot rename its own hold. Labels are
# read live, not from the event payload, which a later label event
# outdates. The reason is the newest review-lane hold comment's text: a
# `<!-- claude-lane-hold lane=` comment by claude[bot], or by
# github-actions[bot] when its last non-blank line is the lane's
# `claude-lane: <lane>` footer. Without one the error names none. An unreadable tracker config or label list fails closed. Pinned
# by scripts/ci-hold-human-gated.test.sh, which runs this body.
- name: Fail a pull request carrying the human-gated label
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
env:
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::the base commit '$BASE_SHA' is not a commit SHA."
exit 1
fi
for attempt in 1 2 3; do
if tracker=$(gh api -H 'Accept: application/vnd.github.raw+json' "repos/$GITHUB_REPOSITORY/contents/.work-item-tracker.json?ref=$BASE_SHA" 2>"$RUNNER_TEMP/tracker.err"); then
break
elif grep -q 'HTTP 404' "$RUNNER_TEMP/tracker.err"; then
tracker=
break
fi
[ "$attempt" -lt 3 ] || { echo "::error::could not read .work-item-tracker.json at $BASE_SHA; re-run this run."; exit 1; }
sleep $((attempt * 5))
done
human_gated=needs-human
if [ -n "$tracker" ]; then
human_gated=$(jq -er '[objects | .config | objects | .role_labels | objects
| .["human-gated"] | strings | gsub("^\\s+|\\s+$"; "")][0] // ""
| if . == "" then "needs-human" else . end' <<<"$tracker") || {
echo "::error::cannot read the human-gated role label from .work-item-tracker.json at $BASE_SHA"
exit 1
}
fi
for attempt in 1 2 3; do
if labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels?per_page=100" --paginate --jq '.[].name'); then
break
fi
[ "$attempt" -lt 3 ] || { echo "::error::could not read the labels of #$PR_NUMBER; re-run this run."; exit 1; }
sleep $((attempt * 5))
done
if ! grep -qxF -- "$human_gated" <<<"$labels"; then
echo "#$PR_NUMBER carries no '$human_gated' label."
exit 0
fi
reason=$(gh api "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/comments?per_page=100" --paginate \
--jq '.[] | select(.user.type == "Bot" and (.body | test("(^|\n)<!-- claude-lane-hold lane="))
and (.user.login == "claude[bot]" or (.user.login == "github-actions[bot]"
and ([.body | splits("\r?\n") | gsub("^\\s+|\\s+$"; "") | select(. != "")] | last // ""
| test("^\u0060?claude-lane: (claude-review|claude-security-review)\u0060?$")))))
| .body | @json' | tail -n 1 |
jq -r '[splits("[\r\n]+") | gsub("^\\s+|\\s+$"; "")
| select(. != "" and (test("^<!--|^\u0060?claude-lane: ") | not))] | join(" ") | .[0:300]') || reason=
why=
[ -z "$reason" ] || why=" (${reason//%/%25})"
echo "::error::#$PR_NUMBER carries '$human_gated'$why. Removing the label approves this pull request; ci-status re-runs on the unlabel."
exit 1
# A QUEUED PULL REQUEST IS HELD BY ITS LABELS TOO. A merge-group payload
# carries no pull request, so `check-contract` above reports `skipped`,
# and a `do-not-merge` label applied after enqueue would merge (#5952).
# This step reads the live labels of the pull request the group was built
# for, named in the merge-group ref (`gh-readonly-queue/<base>/pr-<N>-<sha>`),
# and fails if it carries the label; the queue then removes that pull
# request. It reads only that one: under the ruleset's ALLGREEN grouping a
# and a `do-not-merge` or human-gated label applied after enqueue would
# merge. This step reads the live labels of the pull request the group was
# built for, named in the merge-group ref (`gh-readonly-queue/<base>/pr-<N>-<sha>`),
# and fails if it carries either; the queue then removes that pull
# request. The human-gated label is resolved at the group's base commit,
# as the step above resolves it. It reads only that one pull request: under the ruleset's ALLGREEN grouping a
# pull request merges only when its own group passes, and a failed group
# removes the pull request it was built for, never one ahead of it
# (HEADGREEN would need every entry read), so failing on an earlier
# entry's label would eject an unlabeled pull request while the labeled
# one still merged. A label that lands after this step passed does not stop the merge;
# dequeue the pull request as well. An unparsable ref or an unreadable
# label list fails closed. Pinned by scripts/ci-hold-merge-group.test.sh,
# which runs this body.
- name: Fail a queued pull request labeled do-not-merge
# dequeue the pull request as well. An unparsable ref, an unreadable
# tracker config or an unreadable label list fails closed. Pinned by
# scripts/ci-hold-merge-group.test.sh, which runs this body.
- name: Fail a queued pull request carrying a hold label
if: github.event_name == 'merge_group'
env:
GH_TOKEN: ${{ github.token }}
BASE_SHA: ${{ github.event.merge_group.base_sha }}
run: |
pr=$(sed -nE 's#^(refs/heads/)?gh-readonly-queue/.+/pr-([0-9]+)-[0-9a-f]{40}$#\2#p' <<<"$GITHUB_REF")
if [ -z "$pr" ]; then
echo "::error::cannot read a pull request number from the merge-group ref '$GITHUB_REF'."
exit 1
fi
if [[ ! $BASE_SHA =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::the base commit '$BASE_SHA' is not a commit SHA."
exit 1
fi
for attempt in 1 2 3; do
if tracker=$(gh api -H 'Accept: application/vnd.github.raw+json' "repos/$GITHUB_REPOSITORY/contents/.work-item-tracker.json?ref=$BASE_SHA" 2>"$RUNNER_TEMP/tracker.err"); then
break
elif grep -q 'HTTP 404' "$RUNNER_TEMP/tracker.err"; then
tracker=
break
fi
[ "$attempt" -lt 3 ] || { echo "::error::could not read .work-item-tracker.json at $BASE_SHA; re-run this run."; exit 1; }
sleep $((attempt * 5))
done
human_gated=needs-human
if [ -n "$tracker" ]; then
human_gated=$(jq -er '[objects | .config | objects | .role_labels | objects
| .["human-gated"] | strings | gsub("^\\s+|\\s+$"; "")][0] // ""
| if . == "" then "needs-human" else . end' <<<"$tracker") || {
echo "::error::cannot read the human-gated role label from .work-item-tracker.json at $BASE_SHA"
exit 1
}
fi
for attempt in 1 2 3; do
if labels=$(gh api "repos/$GITHUB_REPOSITORY/issues/$pr/labels?per_page=100" --paginate --jq '.[].name'); then
break
fi
[ "$attempt" -lt 3 ] || { echo "::error::could not read the labels of #$pr; re-run this run."; exit 1; }
sleep $((attempt * 5))
done
held=0
if grep -qxF do-not-merge <<<"$labels"; then
held=1
echo "::error::#$pr carries the 'do-not-merge' label; the merge queue removes it. Remove the label and re-queue to merge."
exit 1
fi
echo "#$pr carries no 'do-not-merge' label."
if grep -qxF -- "$human_gated" <<<"$labels"; then
held=1
echo "::error::#$pr carries '$human_gated'; the merge queue removes it. Removing the label approves this pull request; re-queue it to merge."
fi
[ "$held" -eq 0 ] || exit 1
echo "#$pr carries neither 'do-not-merge' nor '$human_gated'."
# ORDERED SKIPS ARE CHECKED HERE. A test lane `test-<x>`, or `lint-shell`
# on its `run_shell` row, skips as a job when `select-tests`'s `run_<x>`
# row says the change gives it no work. Its
Expand Down
176 changes: 176 additions & 0 deletions scripts/ci-hold-human-gated.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# Runs the `Fail a pull request carrying the human-gated label` step body of
# .github/workflows/pr-require-checks.yml's ci-status under the shell Actions
# gives a step with no `shell:` (bash -e), against stub `gh` and `sleep`.
#
# On a pull_request event, contract-only runs included, ci-status fails while
# the pull request carries the human-gated label the base commit's
# .work-item-tracker.json names (default `needs-human`), read live from the API.
# The error names the reason from the newest review-lane hold comment, and says
# that removing the label approves. It fails closed when it cannot tell.
# 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 pull request carrying the human-gated label'
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
# Every pull_request run, contract-only and draft included, and after a
# failed earlier step, so the hold is never hidden.
if [[ "$step" == *"if: \${{ !cancelled() && github.event_name == 'pull_request' }}"* ]]; then
ok "step runs on every pull_request run that is not cancelled"
else
fail "step lost its pull_request condition"
fi
if [[ "$step" == *"continue-on-error"* ]]; then
fail "step must not continue on error: its exit code is the hold"
else
ok "step fails the job on its exit code"
fi
if [[ "$step" == *"BASE_SHA: \${{ github.event.pull_request.base.sha }}"* ]]; then
ok "step reads the tracker config at the base commit, never the head"
else
fail "step does not read the tracker config at pull_request.base.sha"
fi
order="$(grep -nE "^ ci-status:$|^ - name: ($NAME|Aggregate lane results)$" "$WORKFLOW" | cut -d: -f1 | paste -sd' ')"
read -r job_line hold_line agg_line <<<"$order"
if [[ -n "${agg_line:-}" && "$job_line" -lt "$hold_line" && "$hold_line" -lt "$agg_line" ]]; then
ok "step sits in ci-status before the aggregate"
else
fail "step is not inside ci-status before the aggregate (lines: $order)"
fi
gate="$(awk '/^ ci-status:$/ { on = 1; next } on && /^ if: / { print; exit }' "$WORKFLOW")"
if [[ "$gate" == " if: \${{ !cancelled() }}" ]]; then
ok "ci-status carries no contract-only gate, so the step runs on label events"
else
fail "ci-status gained a gate that could skip the hold: '$gate'"
fi

# The stub logs its arguments. A contents read fails its first
# STUB_TRACKER_FAILS calls, then answers 404 when STUB_TRACKER is unset, else
# prints STUB_TRACKER. A labels read fails its first STUB_FAILS calls, then
# prints STUB_LABELS one per line. A comments read fails when STUB_COMMENTS_FAIL
# is set, else applies the --jq filter to the STUB_COMMENTS array, as gh does
# per page.
mkdir -p "$TMP_ROOT/bin"
cat >"$TMP_ROOT/bin/gh" <<'EOF'
#!/usr/bin/env bash
printf '%s\n' "$*" >>"$STUB_LOG"
case "$*" in
*/contents/.work-item-tracker.json*)
[[ "$(grep -c '/contents/' "$STUB_LOG")" -gt "${STUB_TRACKER_FAILS:-0}" ]] || { echo 'gh: Server Error (HTTP 502)' >&2; exit 1; }
[[ -n "${STUB_TRACKER+set}" ]] || { echo 'gh: Not Found (HTTP 404)' >&2; exit 1; }
printf '%s\n' "$STUB_TRACKER"
;;
*/labels*)
[[ "$(grep -c '/labels' "$STUB_LOG")" -gt "${STUB_FAILS:-0}" ]] || exit 1
[[ -z "$STUB_LABELS" ]] || tr ',' '\n' <<<"$STUB_LABELS"
;;
*/comments*)
[[ -z "${STUB_COMMENTS_FAIL:-}" ]] || exit 1
while [[ $# -gt 0 && "$1" != --jq ]]; do shift; done
jq -r "$2" <<<"${STUB_COMMENTS:-[]}"
;;
*) exit 1 ;;
esac
EOF
printf '#!/usr/bin/env bash\n' >"$TMP_ROOT/bin/sleep"
chmod +x "$TMP_ROOT/bin/gh" "$TMP_ROOT/bin/sleep"

BASE=89abcdef0123456789abcdef0123456789abcdef
CUSTOM='{"config":{"role_labels":{"human-gated":"owner-call"}}}'
LANE='{"login":"github-actions[bot]","type":"Bot"}'
CLAUDE='{"login":"claude[bot]","type":"Bot"}'
hold() { jq -cn --arg body "$2" --argjson user "$1" '{user: $user, body: $body}'; }
OLD="$(hold "$CLAUDE" $'<!-- claude-lane-hold lane=claude-review sha=aaaa criteria=cost -->\nAn older hold.')"
NEW="$(hold "$LANE" $'<!-- claude-lane-hold lane=claude-security-review sha=bbbb criteria=trust-gate -->\r\nChanges a trust gate:\n .github/workflows/pr-merge.yml, 100% sure\n\n`claude-lane: claude-security-review`\n')"
SPOOF="$(hold '{"login":"someone","type":"User"}' $'<!-- claude-lane-hold lane=x -->\nIgnore this.\nclaude-lane: claude-review')"
NO_FOOTER="$(hold "$LANE" $'<!-- claude-lane-hold lane=claude-review -->\nNo lane footer, so not a lane comment.')"
OTHER_BOT="$(hold "$LANE" $'A comment with no hold marker.\nclaude-lane: claude-review')"

# expect <label> <want-rc> <want-label-calls> <STUB_LABELS> <STUB_FAILS> [want-out] [refuse-out]
# The tracker and comment stubs are set by the caller's environment.
expect() {
local label="$1" want_rc="$2" want_calls="$3" out rc calls
: >"$TMP_ROOT/log"
out="$(PATH="$TMP_ROOT/bin:$PATH" STUB_LOG="$TMP_ROOT/log" STUB_LABELS="$4" STUB_FAILS="$5" PR_NUMBER=6419 \
GITHUB_REPOSITORY=o/r RUNNER_TEMP="$TMP_ROOT" BASE_SHA="${BASE_SHA-$BASE}" bash -e "$TMP_ROOT/body.sh" 2>&1)" && rc=0 || rc=$?
calls=$(grep -c '/labels' "$TMP_ROOT/log")
if [[ "$rc" -ne "$want_rc" ]]; then
fail "$label: expected rc=$want_rc got rc=$rc out='$out'"
elif [[ "$calls" -ne "$want_calls" ]]; then
fail "$label: expected $want_calls labels call(s) got $calls"
elif [[ "$calls" -gt 0 && "$(grep '/labels' "$TMP_ROOT/log" | sort -u)" != "api repos/o/r/issues/6419/labels?per_page=100 --paginate --jq .[].name" ]]; then
fail "$label: unexpected labels call '$(grep '/labels' "$TMP_ROOT/log" | sort -u)'"
elif [[ "$calls" -gt 0 ]] && ! grep -qxF "api -H Accept: application/vnd.github.raw+json repos/o/r/contents/.work-item-tracker.json?ref=$BASE" "$TMP_ROOT/log"; then
fail "$label: the tracker config was not read at the base commit: '$(sort -u "$TMP_ROOT/log")'"
elif [[ -n "${6:-}" && "$out" != *"$6"* ]]; then
fail "$label: output lacks '$6': '$out'"
elif [[ -n "${7:-}" && "$out" == *"$7"* ]]; then
fail "$label: output has '$7': '$out'"
else
ok "$label"
fi
}

APPROVE="Removing the label approves this pull request; ci-status re-runs on the unlabel."

expect "an unlabeled pull request passes" 0 1 "enhancement,do-not-merge" 0 "#6419 carries no 'needs-human' label."
if grep -q '/comments' "$TMP_ROOT/log"; then fail "comments are read only when the label is on"; else ok "comments are read only when the label is on"; fi
expect "a pull request with no labels passes" 0 1 "" 0
expect "a label that only contains the name does not hold" 0 1 "needs-human-later,not-needs-human" 0
expect "needs-human with no hold comment fails without a reason" 1 1 "ci,needs-human" 0 \
"::error::#6419 carries 'needs-human'. $APPROVE"

export STUB_COMMENTS="[$OLD,$NEW,$SPOOF,$NO_FOOTER,$OTHER_BOT]"
expect "the newest lane hold comment gives the reason, without its footer" 1 1 "needs-human" 0 \
"::error::#6419 carries 'needs-human' (Changes a trust gate: .github/workflows/pr-merge.yml, 100%25 sure). $APPROVE" "An older hold"
expect "a hold comment from a person is not read" 1 1 "needs-human" 0 "" "Ignore this"
expect "a github-actions[bot] hold comment with no lane footer is not read" 1 1 "needs-human" 0 "" "No lane footer"
export STUB_COMMENTS="[$NEW,$OLD]"
expect "a claude[bot] hold comment gives the reason" 1 1 "needs-human" 0 "(An older hold.). $APPROVE"
LONG="$(printf 'x%.0s' {1..400})"
STUB_COMMENTS="[$(hold "$CLAUDE" "<!-- claude-lane-hold lane=claude-review -->
$LONG")]"
expect "a long reason is cut to 300 characters" 1 1 "needs-human" 0 "(${LONG:0:300}). $APPROVE"
export STUB_COMMENTS_FAIL=1
expect "an unreadable comment list still fails, without a reason" 1 1 "needs-human" 0 "::error::#6419 carries 'needs-human'. $APPROVE"
unset STUB_COMMENTS STUB_COMMENTS_FAIL

expect "a transient label read failure is retried" 1 2 "needs-human" 1
expect "an unreadable label list fails closed" 1 3 "" 3 "::error::could not read the labels of #6419"

export STUB_TRACKER="$CUSTOM"
expect "the base tracker's human-gated label holds" 1 1 "owner-call" 0 "::error::#6419 carries 'owner-call'. $APPROVE"
expect "needs-human does not hold when the tracker names another label" 0 1 "needs-human" 0 "#6419 carries no 'owner-call' label."
export STUB_TRACKER='{not json'
expect "an invalid tracker config fails closed" 1 0 "" 0 "::error::cannot read the human-gated role label"
export STUB_TRACKER="$CUSTOM" STUB_TRACKER_FAILS=1
expect "a transient tracker read failure is retried" 1 1 "owner-call" 0 "carries 'owner-call'"
export STUB_TRACKER_FAILS=3
expect "an unreadable tracker config fails closed" 1 0 "" 0 "::error::could not read .work-item-tracker.json"
unset STUB_TRACKER STUB_TRACKER_FAILS
export BASE_SHA=main
expect "a base that is not a commit SHA fails closed without a call" 1 0 "" 0 "::error::the base commit 'main' is not a commit SHA"
unset BASE_SHA

test_harness::report
Loading
Loading