diff --git a/.agents/skills/afk/SKILL.md b/.agents/skills/afk/SKILL.md index 93789278c85..a634360e791 100644 --- a/.agents/skills/afk/SKILL.md +++ b/.agents/skills/afk/SKILL.md @@ -91,7 +91,7 @@ afk changes how the captain is informed and what happens at a captain-owned deci A PR ready for merge keeps the merge authority from `AGENTS.md` section 7, and a needs-decision finding keeps the `ask-user-authority` policy; anything requiring the captain still waits for the captain's explicit word. While the away-posture record exists, any pull request green at its live head may merge under away authority; which one the captain's words meant is the away session's reading, and a merge the words do not call for holds for the return. Away authority never releases a captain hold, and it expires when the away record is archived. -`--allow-red` remains attended-only and is refused while the record exists. +`--allow-red` and `--allow-missing` remain attended-only and are refused while the record exists. A merge under away authority must be synchronous; `fm-pr-merge.sh` refuses auto-merge and any GitHub queue state that cannot prove an immediate merge while the record exists. The same gates bind whichever actor performs the action: on Pi the parked main's standing authority relocates to the supervision branch, which meets exactly these rules, and the spend cap recorded at entry is enforced by `fm-spawn.sh` for both actors while the record exists. The captain's away words are their explicit instruction given before leaving, recorded verbatim and acted on by the away session's judgment at the moment an event makes them relevant; the words cover nothing they do not say, are never applied by analogy, and die at archive. diff --git a/AGENTS.md b/AGENTS.md index 1757b3b0623..e5318fa77df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -366,7 +366,7 @@ The path's worker, automated gates, and captain approval remain authoritative: Delivery mode and `yolo` are orthogonal. `yolo` governs merge authority only: with it off, the captain approves every PR merge and every local-only landing; with it on, firstmate merges green, in-scope work itself. -Never merge a red PR under either setting unless a current explicit captain instruction names the single GitHub check waived through `fm-pr-merge.sh --allow-red`; that attended-only waiver still requires every other check green. +Never merge a red PR, or one with a required check that has not reported, under either setting unless a current explicit captain instruction names the GitHub check to waive; `bin/fm-pr-merge.sh`'s header owns the attended-only waiver mechanics and remaining guards. Destructive, irreversible, and security-sensitive merges still escalate. Without a current explicit captain instruction that states the concrete merge, the green default stands, and standing `yolo` cannot authorize a red merge; section 1 owns when such an instruction overrides a Firstmate-written standing rule within its exact scope. Load `ask-user-authority` before deciding any ask-user finding; the implementation worker never answers its own finding. diff --git a/bin/fm-branch-prompt.sh b/bin/fm-branch-prompt.sh index accbb024cbd..66c12a53324 100755 --- a/bin/fm-branch-prompt.sh +++ b/bin/fm-branch-prompt.sh @@ -110,7 +110,7 @@ Away (the record exists): the wake message ends with a `POSTURE: AWAY` tail carr The record is the captain's away words, recorded verbatim: the explicit instruction the captain gave before leaving, and the whole mandate. No script parses them; you read them at the tail of every wake, decide by your own judgment whether the event in front of you is the moment they name, and act on them only through the guarded scripts under MAIN's standing authority - never more than MAIN could do attended - which enforce what a script can check without reading words: - `bin/fm-pr-merge.sh`: a merge the words call for proceeds when the pull request is green at its live head, synchronously, under the record lock; which pull request the words meant is your reading, and any green merge is mechanically permitted while the record exists. - A red pull request is never merged while away, whatever the words say, and `--allow-red` is refused under the record: a merge the words want past a red check holds for the return. + A red pull request, or one with a required check that has not reported, is never merged while away, whatever the words say, and `--allow-red` and `--allow-missing` are refused under the record: a merge the words want past a red or unreported check holds for the return. - `bin/fm-spawn.sh`: work the words explicitly call for is dispatched within the record's spend cap, from a queued backlog item - one already queued, or one you file yourself for exactly that step under the `backlog` lease, writing its brief intent from the captain's words and a backlog note citing them; filing the item the captain asked for is not inventing work, and anything the words do not call for is. - `bin/fm-send.sh` and `bin/fm-control.sh`: a run the words say to abort or a worker the words say to steer is steered, as in any posture. - `bin/fm-send.sh --resolve-key`: a decision the words pre-answer is answered with the captain's own answer, and every other decision only as the ask-user-authority policy at the end of this prompt lets firstmate decide; a finding it says to escalate is reported with verdict captain and left for the return. diff --git a/bin/fm-pr-merge.sh b/bin/fm-pr-merge.sh index ad945f2bcd1..daf10654a4c 100755 --- a/bin/fm-pr-merge.sh +++ b/bin/fm-pr-merge.sh @@ -12,18 +12,41 @@ # --squash, --merge, --rebase, or --method after the optional -- separator. # A GitHub merge is refused unless every pre-merge condition holds, each read # live at merge time rather than taken from recorded metadata: the pull request -# is open, not a draft, mergeable, free of conflicts, and every unwaived check +# is open, not a draft, mergeable, free of conflicts, every unwaived check # is green at the exact current head commit, where github_checks_not_green below -# owns what makes a check green and judges each one by its current run. -# Every failing condition is reported, not -# just the first. The verified head is then passed to gh as +# owns what makes a check green and judges each one by its current run, and +# every unwaived check the forge requires for the base branch has reported at +# that head. A required check that never reported is absent from the checks +# list rather than red, so github_read_required_contexts below reads the +# required set from classic branch protection and active rulesets. Check-run +# requirements retain their producer app binding: a same-named check run from another app cannot +# satisfy them, and a duplicate name-only entry cannot weaken that binding. +# Unbound requirements match by name. A bound requirement reported as a check +# run also needs a matching producer in the check-runs read at the verified +# head, while one reported as a commit status matches by name, because the +# status carries no app id to compare. Status-creator app binding is not verified +# here, so an attended --attended-override -- --admin merge can bypass that +# protection without a missing-check waiver when a same-named status reported. +# An unreadable producer read still refuses. +# Successfully read requirements remain checked even if another +# source fails, so known missing checks and all read errors are reported together. +# github_branch_rules_unavailable_on_plan owns the narrow plan-unavailable +# exception; every other unreadable required source refuses. +# Every failing condition is reported, not just the first. +# The verified head is then passed to gh as # --match-head-commit, so a push that lands between that read and the merge # fails the merge instead of landing commits nothing verified. Reading that # state needs gh and jq, and either one absent stops the merge before any # state is recorded. An attended --allow-red may be passed once, # with the name as a separate argument; it waives only checks with that exact -# name, still requires every other check green, and still binds the head. It is -# refused while the away-posture record exists, and it never +# name, still requires every other check green, and still binds the head. Its +# twin, an attended --allow-missing , follows the same rules for one +# required check that has not reported: it waives only that exact name, still +# requires every other required check to have reported and every check to be +# green unless separately waived by --allow-red. It matches the required +# context name even for an app-bound requirement, and never waives an unreadable +# required source or producer read. Both are +# refused while the away-posture record exists, and neither # applies on GitLab, where a merge already requires the head pipeline to have # succeeded. After gh returns success, GitHub's live state is read back and # accepted only when the pull request is merged or in the merge queue. gh's @@ -102,7 +125,7 @@ # explicit captain instruction and never skips the live green check, the # away-record read, or a captain hold. # -# Usage: fm-pr-merge.sh [--attended-override] [--allow-red ] [-- ] +# Usage: fm-pr-merge.sh [--attended-override] [--allow-red ] [--allow-missing ] [-- ] # # On GitLab, this script confirms the MR is actually merged before reporting it; # an auto-merge-queued or unconfirmed request leaves the poll armed and records @@ -162,6 +185,7 @@ fi shift 2 ATTENDED_OVERRIDE=false ALLOW_RED=() +ALLOW_MISSING=() while [ "$#" -gt 0 ]; do case "$1" in --attended-override) @@ -182,6 +206,16 @@ while [ "$#" -gt 0 ]; do echo "error: --allow-red requires a separate check name argument" >&2 exit 2 ;; + --allow-missing) + [ -n "${2:-}" ] || { echo "error: --allow-missing requires a check name" >&2; exit 2; } + [ "${#ALLOW_MISSING[@]}" -eq 0 ] || { echo "error: --allow-missing may be specified only once" >&2; exit 2; } + ALLOW_MISSING+=("$2") + shift 2 + ;; + --allow-missing=*) + echo "error: --allow-missing requires a separate check name argument" >&2 + exit 2 + ;; --) shift; break ;; *) break ;; esac @@ -190,6 +224,10 @@ if [ "${#ALLOW_RED[@]}" -gt 0 ] && [ "$PROVIDER" = gitlab ]; then echo "error: --allow-red does not apply to GitLab, where a merge already requires the head pipeline to have succeeded" >&2 exit 2 fi +if [ "${#ALLOW_MISSING[@]}" -gt 0 ] && [ "$PROVIDER" = gitlab ]; then + echo "error: --allow-missing does not apply to GitLab, where a merge already requires the head pipeline to have succeeded" >&2 + exit 2 +fi caller_has_merge_method() { local arg @@ -582,10 +620,94 @@ github_checks_not_green() { ' 2>/dev/null || return 1 } -# Pre-merge conditions for a GitHub pull request, read from one live view. +FM_PR_GITHUB_REQUIRED= +FM_PR_GITHUB_REQUIRED_ERROR= +github_read_required_contexts() { + local base=$1 branch_path branch_json rules_json classic='' ruleset='' api_err api_err_text + FM_PR_GITHUB_REQUIRED='[]' + FM_PR_GITHUB_REQUIRED_ERROR= + branch_path=$(github_urlencode_path_segment "$base") + + if ! branch_json=$(gh api "repos/$PR_OWNER/$PR_REPO/branches/$branch_path" 2>/dev/null) \ + || [ -z "$branch_json" ] \ + || ! classic=$(printf '%s' "$branch_json" | jq -c ' + if type != "object" or (.protected | type) != "boolean" then + error("branch payload is unreadable") + elif .protected == false then + empty + elif (.protection.required_status_checks | type) != "object" then + error("branch protection summary is unreadable") + else + .protection.required_status_checks + | ((.checks // []) | if type == "array" then .[] else error("invalid checks") end + | {context, app_id}), + ((.contexts // []) | if type == "array" then .[] else error("invalid contexts") end + | {context: ., app_id: null}) + | if (.context | type) == "string" and (.context | length) > 0 + and (.app_id == null or (.app_id | type) == "number") + then . else error("invalid required check") end + | if .app_id == -1 then .app_id = null else . end + end' 2>/dev/null); then + classic='' + FM_PR_GITHUB_REQUIRED_ERROR="the branch protection summary for base branch $base could not be read" + fi + + if ! api_err=$(mktemp "${TMPDIR:-/tmp}/fm-pr-merge-required-rules.XXXXXX"); then + FM_PR_GITHUB_REQUIRED_ERROR="${FM_PR_GITHUB_REQUIRED_ERROR:+$FM_PR_GITHUB_REQUIRED_ERROR +}the branch rules for base branch $base could not be read" + else + if ! rules_json=$(gh api --paginate "repos/$PR_OWNER/$PR_REPO/rules/branches/$branch_path" 2>"$api_err"); then + api_err_text=$(cat "$api_err" 2>/dev/null) + if ! github_branch_rules_unavailable_on_plan "$api_err_text"; then + FM_PR_GITHUB_REQUIRED_ERROR="${FM_PR_GITHUB_REQUIRED_ERROR:+$FM_PR_GITHUB_REQUIRED_ERROR +}the branch rules for base branch $base could not be read" + fi + elif [ -z "$rules_json" ] || ! ruleset=$(printf '%s' "$rules_json" | jq -c ' + if type != "array" then error("rules payload is unreadable") else .[] end + | select(type != "object" or .type == "required_status_checks") + | if type == "object" and (.parameters.required_status_checks | type) == "array" + then .parameters.required_status_checks[] else error("invalid required check rule") end + | if type == "object" and (.context | type) == "string" and (.context | length) > 0 + and (.integration_id == null or (.integration_id | type) == "number") + then {context, app_id: .integration_id} else error("invalid required check rule") end + | if .app_id == -1 then .app_id = null else . end' 2>/dev/null); then + ruleset='' + FM_PR_GITHUB_REQUIRED_ERROR="${FM_PR_GITHUB_REQUIRED_ERROR:+$FM_PR_GITHUB_REQUIRED_ERROR +}the branch rules for base branch $base could not be read" + fi + rm -f "$api_err" + fi + + FM_PR_GITHUB_REQUIRED=$(printf '%s\n%s\n' "$classic" "$ruleset" | jq -sc ' + unique_by([.context, .app_id]) | group_by(.context) + | map(if any(.[]; .app_id != null) then map(select(.app_id != null)) else . end) | add // []') + [ -z "$FM_PR_GITHUB_REQUIRED_ERROR" ] +} + +github_required_checks_missing() { + local json=$1 required=$2 producers=$3 + printf '%s' "$json" | jq -r --argjson required "$required" --argjson producers "$producers" ' + if (.statusCheckRollup | type) != "array" then error("no check rollup") else . end + | .statusCheckRollup as $reported + | $required + | map(. as $requirement + | select(any($reported[]; + if $requirement.app_id == null then + (if .__typename == "CheckRun" then .name else .context end) == $requirement.context + elif .__typename == "CheckRun" then + .name == $requirement.context + and any($producers[]; .name == $requirement.context and .app.id == $requirement.app_id) + else + .context == $requirement.context + end) | not) + | .context) | unique[] + ' 2>/dev/null || return 1 +} + +# Pre-merge conditions from a live PR view, base requirements, and head producers. # Sets FM_PR_MERGE_HEAD to the verified head on success. github_verify_mergeable() { - local json fields line red name covered + local json fields line red name covered missing unreported producers runs local total=0 named=0 refusals='' local state='' draft='' mergeable='' merge_state='' live_head='' base='' @@ -671,13 +793,51 @@ FIELDS $red EOF + unreported='' + if ! github_read_required_contexts "$base"; then + while IFS= read -r line; do + refusals="$refusals - $line, so a required check that has not reported cannot be ruled out +" + done </dev/null; then + if ! runs=$(gh api --paginate "repos/$PR_OWNER/$PR_REPO/commits/$live_head/check-runs" 2>/dev/null) \ + || [ -z "$runs" ] \ + || ! producers=$(printf '%s' "$runs" | jq -sc --arg head "$live_head" ' + [ .[] | if (.check_runs | type) == "array" then .check_runs[] else error("invalid check runs") end + | if (.name | type) == "string" and (.app.id | type) == "number" and .head_sha == $head + then . else error("invalid check producer") end ]' 2>/dev/null); then + producers='[]' + refusals="$refusals - required check producers at head $live_head could not be read +" + fi + fi + if ! missing=$(github_required_checks_missing "$json" "$FM_PR_GITHUB_REQUIRED" "$producers"); then + refusals="$refusals - the GitHub pull request check rollup could not be read +" + else + while IFS= read -r name; do + [ -n "$name" ] || continue + [ "${#ALLOW_MISSING[@]}" -gt 0 ] && [ "${ALLOW_MISSING[0]}" = "$name" ] && continue + refusals="$refusals - required check '$name' has not reported at head $live_head +" + unreported="${unreported:+$unreported, }$name" + done <&2 printf '%s' "$refusals" >&2 [ -z "$uncovered" ] || printf 'error: these checks are not green: %s\n' "$uncovered" >&2 + [ -z "$unreported" ] || printf 'error: these required checks have not reported: %s\n' "$unreported" >&2 return 1 fi - printf 'verified: %s is open and mergeable, with every required check green at head %s\n' \ + printf 'verified: %s is open and mergeable, with every unwaived required check reported and every unwaived check green at head %s\n' \ "$URL" "$live_head" >&2 FM_PR_MERGE_HEAD=$live_head FM_PR_GITHUB_BASE=$base @@ -798,6 +958,20 @@ github_urlencode_path_segment() { printf '%s' "$encoded" } +# Whether a failed branch-rules read (the gh stderr given) is GitHub's +# plan-gated 403 ("Upgrade to GitHub Pro or make this repository public"), +# which means the repository's plan cannot expose branch rules at all, on +# GitHub or GitHub Enterprise Server - not that this script failed to read +# them, and not that the token lacks a permission. Such a repository has no +# active ruleset rule of any kind. Any other failure (auth, rate limit, +# network, a 404, an unrelated 403) is not this and stays unreadable. +github_branch_rules_unavailable_on_plan() { + case "$1" in + *"Upgrade to GitHub Pro or make this repository public"*) return 0 ;; + esac + return 1 +} + # Read the effective merge-queue method for the observed base branch. The four # situations the refusal has to keep apart - no queue rule, a rules response # that could not be read, several rules that disagree, and a rule whose method @@ -822,18 +996,12 @@ github_read_queue_method() { 2>"$api_err"); then api_err_text=$(cat "$api_err" 2>/dev/null) rm -f "$api_err" - # A plan-gated 403 on this endpoint ("Upgrade to GitHub Pro or make this - # repository public") means the repository's plan cannot expose branch - # rules at all, on GitHub or GitHub Enterprise Server - not that this - # script failed to read them. A repository that cannot have branch rules - # cannot have a merge_queue rule either, so that specific 403 resolves to - # no queue rather than the generic unreadable status. Any other failure - # (auth, rate limit, network, a 404, an unrelated 403) stays unreadable. - case "$api_err_text" in - *"Upgrade to GitHub Pro or make this repository public"*) - FM_PR_GITHUB_QUEUE_STATUS=none - ;; - esac + # A repository that cannot have branch rules cannot have a merge_queue + # rule either, so that specific refusal resolves to no queue rather than + # the generic unreadable status. + if github_branch_rules_unavailable_on_plan "$api_err_text"; then + FM_PR_GITHUB_QUEUE_STATUS=none + fi return 0 fi rm -f "$api_err" @@ -950,6 +1118,10 @@ require_current_away_authority() { echo "error: --allow-red is attended-only; while the away-posture record exists the green check is absolute" >&2 return 2 fi + if [ "$FM_PR_AWAY_POSTURE" = true ] && [ "${#ALLOW_MISSING[@]}" -gt 0 ]; then + echo "error: --allow-missing is attended-only; while the away-posture record exists every required check must report" >&2 + return 2 + fi } persist_accepted_merge_authority() { diff --git a/docs/architecture.md b/docs/architecture.md index 375d210844e..20e4359bedf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -376,10 +376,10 @@ Where a no-mistakes pipeline stores evidence in the repo, it publishes that PR-v This repo uses that setting, and its own `.no-mistakes/` directory remains local state that stays gitignored and is rejected by CI if tracked; [`configuration.md`](configuration.md) owns the setting. PR-based task merges go through `bin/fm-pr-merge.sh`, which records `pr=` and any available `pr_head=` through `bin/fm-pr-check.sh` before calling the forge CLI. The helper requires a full canonical URL and rejects malformed URLs or repo override flags before recording merge state. -A `https://github.com///pull/` URL requires `gh` and `jq`, is merged only after one live read confirms the pull request is open, not a draft, mergeable, conflict-free, and every unwaived check is green at the current head, then `gh pr merge` binds that verified head with `--match-head-commit`. +A `https://github.com///pull/` URL requires `gh` and `jq`, is merged only after live reads confirm the pull request is open, not a draft, mergeable, conflict-free, every unwaived check is green at the current head, and every unwaived check the base branch requires has reported at that head, then `gh pr merge` binds that verified head with `--match-head-commit`. +A required check that never reported is absent from the checks list rather than red; [`bin/fm-pr-merge.sh`](../bin/fm-pr-merge.sh)'s header owns required-context sources, producer identity, partial-read refusals, and attended check waivers. A check run is green when its current run is green, because GitHub leaves a cancelled run in the rollup beside the passing re-run it triggered when the base branch advanced; `bin/fm-pr-merge.sh`'s `github_checks_not_green` owns the rule, which uses `startedAt` to clear only an older completed check run that a passing run with the same name provably replaced, while unfinished check runs and non-green status contexts stay red. `--auto`, `--admin`, and branch-deletion flags are refused unless `--attended-override` is passed for an explicit captain instruction; that override never skips the live green check, the away-record read, or a captain hold. -An attended `--allow-red ` may appear once, waives only GitHub checks with that exact name, and is refused while the away-posture record exists. Because away merge authority is read from that record and then acted on by the forge, the authority read and synchronous forge command share the record's cross-subsystem lock, closing the common live-owner TOCTOU. A lock that cannot be taken refuses the merge. While the record exists, GitHub auto-merge and any base whose rules cannot prove the absence of a merge queue are refused before submission, and GitLab auto-merge flags or scheduled state are refused while an immediate merge is forced with a final `--auto-merge=false`; a branch-rules read that fails only because the repository's plan does not expose branch rules at all (GitHub's plan-upgrade 403) proves the absence of a merge queue on its own and does not refuse, while every other failure to read that state still does. diff --git a/docs/pi-supervision-branch.md b/docs/pi-supervision-branch.md index 9c85e1c1cdf..3c784076f25 100644 --- a/docs/pi-supervision-branch.md +++ b/docs/pi-supervision-branch.md @@ -590,7 +590,7 @@ Each relocated script keeps its own gate, enforcing exactly what a script can ch | Script | Gate while away | | --- | --- | -| `bin/fm-pr-merge.sh` | Merges any pull request green at its live head, synchronously, under the record lock, and refuses `--allow-red` while away, so the green gate is absolute in this posture; which pull request the words meant is the branch's reading. | +| `bin/fm-pr-merge.sh` | Merges any pull request green at its live head, synchronously, under the record lock, and refuses `--allow-red` and `--allow-missing` while away, so the green gate is absolute in this posture; which pull request the words meant is the branch's reading. | | `bin/fm-spawn.sh` | Dispatches only queued work whose blockers cleared - already queued, or filed by the branch because the words explicitly call for it; refuses a fresh ordinary spawn for either actor once the home holds as many ordinary task records as the record's spend cap (relaunches and secondmates exempt). | | `bin/fm-send.sh --resolve-key` | Answers a decision the words pre-answer, or one `ask-user-authority`'s judgment (carried verbatim in the branch prompt) lets firstmate decide. | | `bin/fm-merge-local.sh` | Never relocated. | @@ -643,7 +643,7 @@ At that moment the branch reports any refusal instead of concluding there is "no `tests/fm-afk-return.test.sh` covers the ordered cleanup-due section, its durable merge-marker requirement, and exclusion of a done task without durable merge evidence. -`tests/fm-pr-merge.test.sh` covers the branch actor merging a green task under the record, being refused on a red check or `--allow-red` under it, and being refused at the partition while attended. +`tests/fm-pr-merge.test.sh` covers the branch actor merging a green task under the record, being refused on a red check, an unreported required check, or `--allow-red`/`--allow-missing` under it, and being refused at the partition while attended. `tests/fm-send-resolve-key.test.sh` covers the decision-answer partition: diff --git a/tests/fm-captain-hold-lifecycle.test.sh b/tests/fm-captain-hold-lifecycle.test.sh index 86a40b667a8..5ecd51fe9ba 100755 --- a/tests/fm-captain-hold-lifecycle.test.sh +++ b/tests/fm-captain-hold-lifecycle.test.sh @@ -115,6 +115,13 @@ case "${1:-} ${2:-}" in "api graphql") printf '%s\n' 'state=MERGED' 'merged=true' 'queued=false' 'base=main' ;; + "api --paginate") + case " $* " in + *merge_queue*) ;; + *) printf '%s\n' '[]' ;; + esac + ;; + "api repos/"*) printf '%s\n' '{"name":"main","protected":false}' ;; esac SH cat > "$home/fakebin/gh-axi" <<'SH' diff --git a/tests/fm-pr-check-security.test.sh b/tests/fm-pr-check-security.test.sh index 57aa1f04ff6..6611da4f49f 100755 --- a/tests/fm-pr-check-security.test.sh +++ b/tests/fm-pr-check-security.test.sh @@ -179,6 +179,14 @@ case " $* " in *" api repos/"*"/commits/"*"/statuses?per_page=100 "*) printf '%s\n' '[[]]' ;; + *" api --paginate repos/"*"/rules/branches/"*merge_queue*) + ;; + *" api --paginate repos/"*"/rules/branches/"*) + printf '%s\n' '[]' + ;; + *" api repos/"*"/branches/"*) + printf '%s\n' '{"name":"main","protected":false}' + ;; *" api repos/"*"/pulls/"*) printf '%s\n' "{\"state\":\"open\",\"user\":{\"login\":\"author\"},\"head\":{\"sha\":\"${FM_TEST_GH_HEAD:-0123456789abcdef0123456789abcdef01234567}\"},\"draft\":false,\"mergeable\":true,\"merged_at\":null}" ;; diff --git a/tests/fm-pr-merge.test.sh b/tests/fm-pr-merge.test.sh index c4c0549f05c..677fd76223d 100755 --- a/tests/fm-pr-merge.test.sh +++ b/tests/fm-pr-merge.test.sh @@ -53,6 +53,9 @@ make_case() { 'queued=false' \ 'base=main' > "$case_dir/github-outcome" : > "$case_dir/github-rules" + # The base branch the forge reports by default: unprotected, with no ruleset + # rule, so nothing is required unless a case says otherwise. + write_github_required "$case_dir" : > "$case_dir/gh.log" # The worktree is a git copy whose HEAD is on a remote-tracking ref, as a # pushed ship task's is, so fm-pr-check.sh's named-head gate accepts it when @@ -60,6 +63,32 @@ make_case() { printf '%s\n' "$case_dir" } +# The base branch's required checks as GitHub reports them: the classic branch +# protection summary on the branch, and the active ruleset rules for it. Each +# name is given as classic: or ruleset:; with no names the +# branch is unprotected and has no rules. Args: case_dir [kind:name]... +write_github_required() { + local case_dir=$1 spec contexts='' checks='' rules='' protected=false + shift + for spec in "$@"; do + case "$spec" in + classic:*) + protected=true + contexts="${contexts:+$contexts,}\"${spec#classic:}\"" + checks="${checks:+$checks,}{\"context\":\"${spec#classic:}\",\"app_id\":null}" + ;; + ruleset:*) + rules="${rules:+$rules,}{\"type\":\"required_status_checks\",\"parameters\":{\"required_status_checks\":[{\"context\":\"${spec#ruleset:}\"}]}}" + ;; + *) fail "write_github_required: unknown spec '$spec'" ;; + esac + done + printf '{"name":"main","protected":%s,"protection":{"enabled":%s,"required_status_checks":{"enforcement_level":"%s","contexts":[%s],"checks":[%s]}}}\n' \ + "$protected" "$protected" "$([ "$protected" = true ] && echo non_admins || echo off)" "$contexts" "$checks" \ + > "$case_dir/github-branch.json" + printf '[{"type":"deletion"}%s]\n' "${rules:+,$rules}" > "$case_dir/github-required-rules.json" +} + # Live GitHub JSON for the pre-merge verify, plus gh-axi for the # post-merge fallback view. Merge itself is `gh pr merge --match-head-commit`. # Args: case_dir head_sha @@ -200,6 +229,35 @@ case "${1:-} ${2:-}" in exit 0 ;; api\ *) + # The required-check reads: the branch itself, and its rules read without + # the merge-queue filter the queue reader below applies. + case " $* " in + *" repos/"*"/commits/"*"/check-runs"*) + case "$*" in + *"/commits/$(cat "$FM_TEST_GH_HEAD")/check-runs"*) ;; + *) exit 1 ;; + esac + cat "$FM_TEST_GH_RUNS" + exit $? + ;; + *" repos/"*"/rules/branches/"*merge_queue*) ;; + *" repos/"*"/rules/branches/"*) + if [ -f "${FM_TEST_GH_REQUIRED_RULES_FAIL:-}" ]; then + cat "$FM_TEST_GH_REQUIRED_RULES_FAIL" >&2 + exit 1 + fi + cat "$FM_TEST_GH_REQUIRED_RULES" + exit 0 + ;; + *" repos/"*"/branches/"*) + if [ -f "${FM_TEST_GH_BRANCH_FAIL:-}" ]; then + cat "$FM_TEST_GH_BRANCH_FAIL" >&2 + exit 1 + fi + cat "$FM_TEST_GH_BRANCH" + exit 0 + ;; + esac if [ -f "${FM_TEST_GH_RULES_FAIL_BODY:-}" ]; then cat "$FM_TEST_GH_RULES_FAIL_BODY" >&2 exit 1 @@ -391,11 +449,16 @@ run_pr_merge() { FM_TEST_GH_RULES="$case_dir/github-rules" \ FM_TEST_GH_VIEW_JSON="$case_dir/github-view.json" \ FM_TEST_GH_HEAD="$case_dir/github-head" \ + FM_TEST_GH_RUNS="$case_dir/github-runs.json" \ FM_TEST_GH_MERGE_RC_FILE="$case_dir/github-merge-rc" \ FM_TEST_GH_MERGE_OUTPUT="$(cat "$case_dir/github-merge-output" 2>/dev/null || true)" \ FM_TEST_GH_GRAPHQL_FAIL="$case_dir/github-graphql-fail" \ FM_TEST_GH_RULES_FAIL="$case_dir/github-rules-fail" \ FM_TEST_GH_RULES_FAIL_BODY="$case_dir/github-rules-fail-body" \ + FM_TEST_GH_BRANCH="$case_dir/github-branch.json" \ + FM_TEST_GH_BRANCH_FAIL="$case_dir/github-branch-fail" \ + FM_TEST_GH_REQUIRED_RULES="$case_dir/github-required-rules.json" \ + FM_TEST_GH_REQUIRED_RULES_FAIL="$case_dir/github-required-rules-fail" \ FM_TEST_META_AT_MERGE="$case_dir/meta-at-merge" \ FM_TEST_AWAY_RECORD_AFTER_VIEW="$case_dir/away-record-after-view" \ FM_TEST_ROOT="$ROOT" \ @@ -3213,6 +3276,395 @@ test_allow_red_refused_on_gitlab() { pass "fm-pr-merge refuses --allow-red on GitLab" } +# A required check that never reported has no entry in the rollup at all, so +# it can only be found missing by reading the forge's own required set. Each +# case drives the GitHub path through the public entrypoint with a faked forge. +# Args: case_dir pr_number [merge args]...; sets RC. +run_required_case() { + local case_dir=$1 number=$2 + shift 2 + set +e + run_pr_merge "$case_dir" task-x1 "https://github.com/example/repo/pull/$number" "$@" \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + RC=$? + set -e +} + +test_required_producer_identity() { + local case_dir head kind variant expected app + head=a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1 + for kind in classic ruleset; do + for variant in wrong correct unreadable malformed stale waived; do + case_dir=$(make_case "required-producer-$kind-$variant") + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" "$kind:ci" + if [ "$kind" = classic ]; then + jq '.protection.required_status_checks.checks[0].app_id = 15368' \ + "$case_dir/github-branch.json" > "$case_dir/updated.json" + mv "$case_dir/updated.json" "$case_dir/github-branch.json" + else + jq '.[1].parameters.required_status_checks[0].integration_id = 15368' \ + "$case_dir/github-required-rules.json" > "$case_dir/updated.json" + mv "$case_dir/updated.json" "$case_dir/github-required-rules.json" + fi + app=42 + [ "$variant" != correct ] || app=15368 + printf '{"check_runs":[{"name":"ci","app":{"id":%s},"head_sha":"%s"}]}\n' \ + "$app" "$head" > "$case_dir/github-runs.json" + case "$variant" in + unreadable) rm "$case_dir/github-runs.json" ;; + malformed) printf '{}' > "$case_dir/github-runs.json" ;; + stale) printf '{"check_runs":[{"name":"ci","app":{"id":15368},"head_sha":"bbbb"}]}' > "$case_dir/github-runs.json" ;; + esac + expected=1 + if [ "$variant" = waived ]; then + run_required_case "$case_dir" 110 --attended-override --allow-missing ci -- --admin + expected=0 + else + run_required_case "$case_dir" 110 --attended-override -- --admin + [ "$variant" != correct ] || expected=0 + fi + expect_code "$expected" "$RC" "producer-$kind-$variant: $(cat "$case_dir/stderr")" + if [ "$expected" = 1 ]; then + assert_grep "required check 'ci' has not reported" "$case_dir/stderr" "producer absence not reported" + assert_no_grep 'pr merge' "$case_dir/gh.log" "wrong producer reached merge" + else + assert_grep 'pr merge' "$case_dir/gh.log" "accepted producer did not merge" + fi + case "$variant" in + unreadable|malformed|stale) + assert_grep 'required check producers at head' "$case_dir/stderr" "producer read error not reported" ;; + esac + done + done + pass "fm-pr-merge enforces required producer identity and named waivers" +} + +# A commit status carries no app id to compare, so an app-bound required context +# that arrives as a green status matches by name, while the same context left +# unreported still refuses. +test_app_bound_required_status_context_matches_by_name() { + local case_dir head kind variant + head=a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7a7 + for kind in classic ruleset; do + for variant in reported absent; do + case_dir=$(make_case "required-app-status-$kind-$variant") + add_gh_mocks "$case_dir" "$head" + if [ "$variant" = reported ]; then + write_github_rollup_json "$case_dir" "$head" \ + "$(check_run ci COMPLETED SUCCESS)" \ + "$(status_context 'license/cla' SUCCESS)" + fi + write_github_required "$case_dir" "$kind:license/cla" + if [ "$kind" = classic ]; then + jq '.protection.required_status_checks.checks[0].app_id = 865473' \ + "$case_dir/github-branch.json" > "$case_dir/updated.json" + mv "$case_dir/updated.json" "$case_dir/github-branch.json" + else + jq '.[1].parameters.required_status_checks[0].integration_id = 865473' \ + "$case_dir/github-required-rules.json" > "$case_dir/updated.json" + mv "$case_dir/updated.json" "$case_dir/github-required-rules.json" + fi + printf '{"check_runs":[{"name":"ci","app":{"id":42},"head_sha":"%s"}]}\n' \ + "$head" > "$case_dir/github-runs.json" + run_required_case "$case_dir" 111 + if [ "$variant" = reported ]; then + expect_code 0 "$RC" "app-status-$kind-reported: a green app-bound status must merge: $(cat "$case_dir/stderr")" + assert_logged_gh_merge "$case_dir" 111 example/repo --squash + else + expect_code 1 "$RC" "app-status-$kind-absent: an unreported app-bound status must refuse" + assert_grep "required check 'license/cla' has not reported" "$case_dir/stderr" \ + "app-status-$kind-absent: the unreported status was not named" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "app-status-$kind-absent: gh pr merge ran with the status unreported" + fi + done + done + pass "fm-pr-merge matches an app-bound required commit status by name" +} + +test_required_partial_reads_report_all_failures() { + local case_dir head variant + head=a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1 + for variant in branch rules both; do + case_dir=$(make_case "required-partial-$variant") + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" classic:validate ruleset:lint + case "$variant" in + branch|both) printf 'read failed' > "$case_dir/github-branch-fail" ;; + esac + case "$variant" in + rules|both) printf 'read failed' > "$case_dir/github-required-rules-fail" ;; + esac + run_required_case "$case_dir" 111 + expect_code 1 "$RC" "partial-$variant must refuse" + case "$variant" in + branch|both) assert_grep 'branch protection summary for base branch main could not be read' "$case_dir/stderr" "lost branch error" ;; + esac + case "$variant" in + rules|both) assert_grep 'branch rules for base branch main could not be read' "$case_dir/stderr" "lost rules error" ;; + esac + case "$variant" in + branch) assert_grep "required check 'lint' has not reported" "$case_dir/stderr" "lost rules requirement" ;; + rules) assert_grep "required check 'validate' has not reported" "$case_dir/stderr" "lost classic requirement" ;; + esac + assert_no_grep 'pr merge' "$case_dir/gh.log" "partial read reached merge" + done + pass "fm-pr-merge reports known missing checks and all independent read errors" +} + +test_required_check_that_never_reported_refuses() { + local case_dir head kind + head=a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1 + for kind in classic ruleset; do + case_dir=$(make_case "github-required-absent-$kind") + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" "$kind:ci" "$kind:validate" + run_required_case "$case_dir" 90 + expect_code 1 "$RC" "required-absent-$kind: an unreported required check must refuse" + assert_grep "required check 'validate' has not reported at head $head" "$case_dir/stderr" \ + "required-absent-$kind: the unreported required check was not named" + assert_grep 'these required checks have not reported: validate' "$case_dir/stderr" \ + "required-absent-$kind: the summary did not name the unreported check" + assert_no_grep "required check 'ci'" "$case_dir/stderr" \ + "required-absent-$kind: a reported green required check was called missing" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "required-absent-$kind: gh pr merge ran with a required check unreported" + assert_no_grep 'verified: ' "$case_dir/stderr" \ + "required-absent-$kind: the refusal still claimed a verified head" + done + pass "fm-pr-merge refuses when a required check from branch protection or a ruleset never reported" +} + +test_required_checks_reported_and_green_merge() { + local case_dir head + head=a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2a2 + case_dir=$(make_case github-required-present) + add_gh_mocks "$case_dir" "$head" + write_github_rollup_json "$case_dir" "$head" \ + "$(check_run ci COMPLETED SUCCESS)" \ + "$(status_context 'license/cla' SUCCESS)" + write_github_required "$case_dir" classic:ci ruleset:license/cla ruleset:ci + run_required_case "$case_dir" 91 + expect_code 0 "$RC" "required-present: every required check reported and green must merge: $(cat "$case_dir/stderr")" + assert_grep 'api repos/example/repo/branches/main' "$case_dir/gh.log" \ + "required-present: the branch protection summary was not read" + assert_grep 'api --paginate repos/example/repo/rules/branches/main' "$case_dir/gh.log" \ + "required-present: the branch rules were not read" + assert_grep "every unwaived required check reported and every unwaived check green at head $head" \ + "$case_dir/stderr" "required-present: the verified line did not state the required checks reported" + assert_logged_gh_merge "$case_dir" 91 example/repo --squash + pass "fm-pr-merge merges when every required check reported and is green" +} + +test_red_and_unreported_checks_are_reported_together() { + local case_dir head + head=a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3a3 + case_dir=$(make_case github-red-and-unreported) + add_gh_mocks "$case_dir" "$head" + write_github_rollup_json "$case_dir" "$head" \ + "$(check_run lint COMPLETED FAILURE)" + sed 's/"isDraft":false/"isDraft":true/' "$case_dir/github-view.json" > "$case_dir/view.tmp" + mv "$case_dir/view.tmp" "$case_dir/github-view.json" + write_github_required "$case_dir" classic:lint ruleset:validate + run_required_case "$case_dir" 92 + expect_code 1 "$RC" "red-and-unreported: must refuse" + assert_grep 'the pull request is a draft' "$case_dir/stderr" \ + "red-and-unreported: the draft condition was dropped" + assert_grep "check 'lint' is not green" "$case_dir/stderr" \ + "red-and-unreported: the red check was dropped" + assert_grep "required check 'validate' has not reported" "$case_dir/stderr" \ + "red-and-unreported: the unreported required check was dropped" + assert_grep 'these checks are not green: lint' "$case_dir/stderr" \ + "red-and-unreported: the red summary was dropped" + assert_grep 'these required checks have not reported: validate' "$case_dir/stderr" \ + "red-and-unreported: the unreported summary was dropped" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "red-and-unreported: gh pr merge ran" + pass "fm-pr-merge reports a red check and an unreported required check together with every other failure" +} + +test_unreadable_required_set_refuses() { + local case_dir head label + head=a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4a4 + for label in branch-read-fails branch-shape rules-read-fails rules-forbidden rules-shape; do + case_dir=$(make_case "github-required-unreadable-$label") + add_gh_mocks "$case_dir" "$head" + case "$label" in + branch-read-fails) + printf 'gh: Not Found (HTTP 404)\n' > "$case_dir/github-branch-fail" + ;; + branch-shape) + printf '{"name":"main","protected":true}\n' > "$case_dir/github-branch.json" + ;; + rules-read-fails) + printf 'gh: Not Found (HTTP 404)\n' > "$case_dir/github-required-rules-fail" + ;; + rules-forbidden) + printf 'gh: Resource not accessible by personal access token (HTTP 403)\n' \ + > "$case_dir/github-required-rules-fail" + ;; + rules-shape) + printf '[{"type":"required_status_checks","parameters":{}}]\n' \ + > "$case_dir/github-required-rules.json" + ;; + esac + # A waiver names one check, so it can never stand in for a required set + # that could not be read. + run_required_case "$case_dir" 93 --allow-missing validate + expect_code 1 "$RC" "required-unreadable-$label: an unreadable required set must refuse" + case "$label" in + branch-*) + assert_grep 'the branch protection summary for base branch main could not be read, so a required check that has not reported cannot be ruled out' \ + "$case_dir/stderr" "required-unreadable-$label: the refusal did not name the unreadable source" + ;; + rules-*) + assert_grep 'the branch rules for base branch main could not be read, so a required check that has not reported cannot be ruled out' \ + "$case_dir/stderr" "required-unreadable-$label: the refusal did not name the unreadable source" + ;; + esac + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "required-unreadable-$label: gh pr merge ran on an unreadable required set" + done + + # GitHub's plan-gated refusal means the repository cannot have branch rules + # at all, which is a readable answer, so the classic set alone decides. + case_dir=$(make_case github-required-plan-gated) + add_gh_mocks "$case_dir" "$head" + printf 'gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)\n' \ + > "$case_dir/github-required-rules-fail" + run_required_case "$case_dir" 94 + expect_code 0 "$RC" "required-plan-gated: a plan without branch rules must not read as unreadable: $(cat "$case_dir/stderr")" + assert_logged_gh_merge "$case_dir" 94 example/repo --squash + + case_dir=$(make_case github-required-plan-gated-classic-absent) + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" classic:validate + printf 'gh: Upgrade to GitHub Pro or make this repository public to enable this feature. (HTTP 403)\n' \ + > "$case_dir/github-required-rules-fail" + run_required_case "$case_dir" 95 + expect_code 1 "$RC" "required-plan-gated-classic-absent: a classic required check must still be enforced" + assert_grep "required check 'validate' has not reported" "$case_dir/stderr" \ + "required-plan-gated-classic-absent: the unreported classic check was not named" + pass "fm-pr-merge refuses when the required checks cannot be read, and tells a plan without rules apart" +} + +test_allow_missing_waives_only_the_named_unreported_check() { + local case_dir head + head=a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5 + + case_dir=$(make_case github-allow-missing-named) + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" classic:ci ruleset:validate + run_required_case "$case_dir" 96 --allow-missing validate + expect_code 0 "$RC" "allow-missing-named: the named waiver should merge: $(cat "$case_dir/stderr")" + assert_logged_gh_merge "$case_dir" 96 example/repo --squash + + case_dir=$(make_case github-allow-missing-other-missing) + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" ruleset:validate ruleset:e2e + run_required_case "$case_dir" 97 --allow-missing validate + expect_code 1 "$RC" "allow-missing-other-missing: another unreported check must still refuse" + assert_grep "required check 'e2e' has not reported" "$case_dir/stderr" \ + "allow-missing-other-missing: the other unreported check was not named" + assert_no_grep "required check 'validate'" "$case_dir/stderr" \ + "allow-missing-other-missing: the waived check was still reported" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "allow-missing-other-missing: gh pr merge ran with an unwaived unreported check" + + case_dir=$(make_case github-allow-missing-other-red) + add_gh_mocks "$case_dir" "$head" + write_github_red_json "$case_dir" "$head" lint + write_github_required "$case_dir" ruleset:validate + run_required_case "$case_dir" 98 --allow-missing validate + expect_code 1 "$RC" "allow-missing-other-red: a red check must still refuse" + assert_grep "check 'lint' is not green" "$case_dir/stderr" \ + "allow-missing-other-red: the red check was not named" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "allow-missing-other-red: gh pr merge ran with a red check" + + # The waiver covers absence only: a required check that did report red is + # not missing, and waiving it takes --allow-red. + case_dir=$(make_case github-allow-missing-names-red) + add_gh_mocks "$case_dir" "$head" + write_github_red_json "$case_dir" "$head" lint + write_github_required "$case_dir" classic:lint + run_required_case "$case_dir" 99 --allow-missing lint + expect_code 1 "$RC" "allow-missing-names-red: a reported red check must not be waived as missing" + assert_grep "check 'lint' is not green" "$case_dir/stderr" \ + "allow-missing-names-red: the red check was not named" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "allow-missing-names-red: gh pr merge ran with a red required check" + pass "fm-pr-merge --allow-missing waives only its named unreported check" +} + +test_allow_missing_follows_the_allow_red_rules() { + local case_dir head + head=a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6a6 + + case_dir=$(make_case github-allow-missing-equals) + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" ruleset:validate + run_required_case "$case_dir" 100 --allow-missing=validate + expect_code 2 "$RC" "allow-missing-equals: the equals form must be refused" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "allow-missing-equals: gh pr merge ran for the equals form" + + case_dir=$(make_case github-allow-missing-duplicate) + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" ruleset:validate ruleset:e2e + run_required_case "$case_dir" 101 --allow-missing validate --allow-missing e2e + expect_code 2 "$RC" "allow-missing-duplicate: a second waiver must be refused" + assert_grep '--allow-missing may be specified only once' "$case_dir/stderr" \ + "allow-missing-duplicate: the refusal did not say single use" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "allow-missing-duplicate: gh pr merge ran for two waivers" + + case_dir=$(make_case github-allow-missing-away) + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" ruleset:validate + write_away_record "$case_dir" --words 'merge task-x1 when green' + run_required_case "$case_dir" 102 --allow-missing validate + expect_code 2 "$RC" "allow-missing-away: the waiver must be refused while away" + assert_grep '--allow-missing is attended-only' "$case_dir/stderr" \ + "allow-missing-away: the refusal did not name attended-only" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "allow-missing-away: gh pr merge ran despite an away waiver" + + case_dir=$(make_case github-allow-missing-away-after-view) + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" ruleset:validate + write_away_record "$case_dir" --words 'merge task-x1 when green' + mv "$case_dir/state/.afk-contract" "$case_dir/away-record-after-view" + run_required_case "$case_dir" 102 --allow-missing validate + expect_code 2 "$RC" "allow-missing-away-after-view: late away publication must refuse the waiver" + assert_grep '--allow-missing is attended-only' "$case_dir/stderr" \ + "allow-missing-away-after-view: the late refusal did not name attended-only" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "allow-missing-away-after-view: gh pr merge ran after late away publication" + + case_dir=$(make_case github-unreported-away) + add_gh_mocks "$case_dir" "$head" + write_github_required "$case_dir" ruleset:validate + write_away_record "$case_dir" --words 'merge task-x1 when green' + run_required_case "$case_dir" 103 + expect_code 1 "$RC" "unreported-away: the away record must not waive an unreported check" + assert_no_grep 'pr merge' "$case_dir/gh.log" \ + "unreported-away: gh pr merge ran with an unreported check while away" + + case_dir=$(make_gitlab_case gitlab-allow-missing) + set +e + run_pr_merge "$case_dir" task-x1 "$MR_URL" --allow-missing validate \ + > "$case_dir/stdout" 2> "$case_dir/stderr" + RC=$? + set -e + expect_code 2 "$RC" "gitlab-allow-missing: the waiver must not apply on GitLab" + assert_grep '--allow-missing does not apply to GitLab' "$case_dir/stderr" \ + "gitlab-allow-missing: the refusal did not name GitLab" + [ ! -s "$case_dir/glab.log" ] || fail "gitlab-allow-missing: glab ran despite the waiver" + pass "fm-pr-merge --allow-missing is single use, attended-only, and GitHub-only like --allow-red" +} + test_gitlab_head_override_args_refuse_before_recording test_secondmate_merge_reports_upward_once test_secondmate_merge_reports_on_the_local_route @@ -3256,3 +3708,13 @@ test_away_record_cannot_change_between_the_authority_read_and_the_merge test_a_record_made_unreadable_before_the_merge_refuses_it test_merge_refuses_when_the_away_record_cannot_be_locked test_allow_red_refused_on_gitlab +test_required_check_that_never_reported_refuses +test_required_checks_reported_and_green_merge +test_red_and_unreported_checks_are_reported_together +test_unreadable_required_set_refuses +test_allow_missing_waives_only_the_named_unreported_check +test_allow_missing_follows_the_allow_red_rules + +test_required_producer_identity +test_app_bound_required_status_context_matches_by_name +test_required_partial_reads_report_all_failures