Skip to content
Open
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ Before deciding any ask-user finding, load `ask-user-authority`; the implementat
Never merge a red PR.
Without a current explicit captain instruction that states the concrete merge, that 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.
Use `bin/fm-pr-merge.sh` for every task PR merge so merge metadata is recorded, and use `bin/fm-merge-local.sh` for approved local-only landing; never call a lower-level merge command around their guards.
`bin/fm-pr-merge.sh` reads the forge's own check verdict, including the merge queue's separate verdict on the combined commit, and refuses the merge when it is failing or unreadable; its `--allow-failing-checks` override is a red merge, so it needs the same current explicit captain instruction that any red merge needs.
After an autonomous merge, give the captain a one-line full-URL or local-main outcome.

### Validate
Expand Down
276 changes: 276 additions & 0 deletions bin/fm-pr-checks-lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,276 @@
#!/usr/bin/env bash
# fm-pr-checks-lib.sh - read the forge's own verdict on a GitHub pull request's
# checks, so bin/fm-pr-merge.sh can refuse a red merge instead of trusting
# silence. Sourced, never executed.
#
# Two independent sources are read, because either can be red on its own:
#
# 1. the pull request head's status check rollup, and
# 2. the newest merge-queue attempt for that pull request. A merge queue runs
# the checks AGAIN on a combined commit published as a temporary
# "gh-readonly-queue/<base>/pr-<number>-<base sha>" branch, so its verdict
# is a different fact from the branch's own and can be red while every
# branch check is green.
#
# Source 2 is not hypothetical. On 2026-08-10 the queue run for nguzen/aln pull
# request 182 failed at 17:54 UTC, the pull request was merged at 17:59 UTC, and
# the defect in that combined commit then blocked every production deploy for
# two days. A branch-only read would not have refused that merge.
#
# `gh pr checks` is deliberately unused: it exits non-zero both when a check has
# failed and when one is still running, so its exit status cannot classify a
# result (verified 2026-08-12) and only its human-facing lines carry the answer.
# The JSON reads below are classified by value instead.
#
# Every read failure - a missing tool, a failed call, a bound hit, output that
# does not parse, or a payload for a different pull request - is reported as
# "unreadable", never as a pass. Treating the absence of a red signal as green
# is the exact failure that produced the incident above, so the caller must
# refuse on "unreadable" as it refuses on "failing".
#
# The caller must have validated owner, repository and number through
# bin/fm-pr-lib.sh before calling in, because those values are passed to `gh`.
#
# GitHub only, deliberately. bin/fm-pr-lib.sh also parses GitLab merge requests
# for the watcher, but bin/fm-pr-merge.sh refuses a GitLab URL before it ever
# reaches this library, so a forge whose check state cannot be read here is
# refused by name at the merge entrypoint rather than mis-classified in here.
#
# fm_pr_checks_read <owner> <repo> <number>
# Sets FM_PR_CHECKS_STATE to one of:
# failing at least one check the forge reports as failed
# pending nothing failed, but something has not finished
# green every reported check succeeded (or was neutral/skipped)
# none the forge reports no checks at all for this pull request
# unreadable the state could not be established (see _REASON)
# FM_PR_CHECKS_FAILING and FM_PR_CHECKS_PENDING carry one human-readable
# line per check, FM_PR_CHECKS_REASON explains an unreadable state, and
# FM_PR_CHECKS_QUEUE_REF names the merge-queue attempt that was read.
# Always returns 0: the classification is the result, not the exit status.

set -u

# Every FM_PR_CHECKS_* value below is this library's OUTPUT: it is read by the
# sourcing caller (bin/fm-pr-merge.sh), never inside this file.
# shellcheck disable=SC2034
FM_PR_CHECKS_STATE=
FM_PR_CHECKS_FAILING=
FM_PR_CHECKS_PENDING=
FM_PR_CHECKS_REASON=
FM_PR_CHECKS_QUEUE_REF=

FM_PR_CHECKS_LIB_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
# shellcheck source=bin/fm-timeout-lib.sh
. "$FM_PR_CHECKS_LIB_DIR/fm-timeout-lib.sh"

# Bound every forge read so a hung CLI cannot stall a merge indefinitely. A
# non-positive bound is not a bound (see bin/fm-timeout-lib.sh), so reject it.
fm_pr_checks_timeout() {
local seconds=${FM_PR_CHECKS_TIMEOUT:-45}
case "$seconds" in
''|*[!0-9]*) seconds=45 ;;
esac
[ "$seconds" -gt 0 ] || seconds=45
printf '%s\n' "$seconds"
}

fm_pr_checks_gh() {
fm_run_timed "$(fm_pr_checks_timeout)" \
env GH_PROMPT_DISABLED=1 GH_NO_UPDATE_NOTIFIER=1 gh "$@"
}

# A CheckRun carries status+conclusion, a StatusContext carries only state, and
# both shapes appear side by side in one rollup. CANCELLED and STALE on the
# branch itself are treated as failures rather than noise: the branch is not
# green and nothing else re-runs them.
# The jq program is deliberately literal: $want is a jq argument, not a shell one.
# shellcheck disable=SC2016
FM_PR_CHECKS_ROLLUP_JQ='
def norm: (. // "") | tostring | ascii_upcase;
def verdict:
(.conclusion | norm) as $c
| ((.status // .state) | norm) as $s
| if ($c == "FAILURE" or $c == "ERROR" or $c == "TIMED_OUT" or $c == "CANCELLED"
or $c == "ACTION_REQUIRED" or $c == "STARTUP_FAILURE" or $c == "STALE"
or $s == "FAILURE" or $s == "ERROR") then "fail"
elif ($c == "SUCCESS" or $c == "NEUTRAL" or $c == "SKIPPED" or $s == "SUCCESS") then "ok"
else "pending" end;
def check_name: (.name // .context // "check") | tostring;
def check_detail: (.conclusion // .status // .state // "unknown") | tostring;
if (has("number") | not) or (has("baseRefName") | not) or (has("statusCheckRollup") | not) then "invalid"
elif ((.number | tostring) != ($want | tostring)) then "identity"
else
("base\t" + ((.baseRefName // "") | tostring)),
("total\t" + ((.statusCheckRollup // []) | length | tostring)),
((.statusCheckRollup // [])[] | (verdict + "\t" + check_name + "\t" + check_detail))
end
'

# Merge-queue runs are Actions runs on the queue branch. Only the NEWEST attempt
# for this pull request is judged, so a superseded red attempt cannot keep
# refusing a pull request that has since been re-queued green; within that
# attempt the newest run per workflow wins, so a re-run replaces its own result.
# cancelled/stale queue attempts are inconclusive rather than failures: the
# queue discards and re-runs them as PRs ahead of this one land.
# $prefix is a jq argument, not a shell one.
# shellcheck disable=SC2016
FM_PR_CHECKS_QUEUE_JQ='
def norm: (. // "") | tostring | ascii_upcase;
def verdict:
(.conclusion | norm) as $c
| (.status | norm) as $s
| if ($c == "FAILURE" or $c == "TIMED_OUT" or $c == "ACTION_REQUIRED"
or $c == "STARTUP_FAILURE") then "fail"
elif ($c == "SUCCESS" or $c == "NEUTRAL" or $c == "SKIPPED") then "ok"
else "pending" end;
if (has("workflow_runs") | not) then "invalid" else
[ (.workflow_runs // [])[]
| select(((.head_branch // "") | tostring) | startswith($prefix)) ]
| (sort_by((.created_at // "") | tostring) | reverse) as $runs
| if ($runs | length) == 0 then "queue_none"
else (($runs[0].head_branch) | tostring) as $ref
| ("queue_ref\t" + $ref),
( [ $runs[] | select((((.head_branch // "") | tostring)) == $ref) ]
| group_by((.name // "") | tostring)
| map(sort_by([((.created_at // "") | tostring), ((.run_attempt // 0) | tonumber? // 0)]) | last)
| .[]
| (verdict + "\t" + ((.name // "run") | tostring) + "\t"
+ ((.conclusion // .status // "unknown") | tostring)) )
end
end
'

fm_pr_checks_unreadable() {
FM_PR_CHECKS_STATE=unreadable
FM_PR_CHECKS_REASON=$1
return 0
}

fm_pr_checks_read() {
local owner=$1 repo=$2 number=$3
local head_json runs_json parsed prefix
local kind field_a field_b base='' total=0 queue_seen=0 fail_count=0 pend_count=0

# Reset the caller-visible outputs, which this file only ever writes.
# shellcheck disable=SC2034
FM_PR_CHECKS_STATE=
FM_PR_CHECKS_FAILING=
FM_PR_CHECKS_PENDING=
# shellcheck disable=SC2034
FM_PR_CHECKS_REASON=
# shellcheck disable=SC2034
FM_PR_CHECKS_QUEUE_REF=

command -v gh >/dev/null 2>&1 \
|| { fm_pr_checks_unreadable 'gh not found, so the forge check state cannot be read'; return 0; }
command -v jq >/dev/null 2>&1 \
|| { fm_pr_checks_unreadable 'jq not found, so the forge check state cannot be classified'; return 0; }

head_json=$(fm_pr_checks_gh pr view "$number" --repo "$owner/$repo" \
--json number,baseRefName,statusCheckRollup 2>/dev/null) \
|| { fm_pr_checks_unreadable "gh pr view failed for $owner/$repo#$number"; return 0; }
[ -n "$head_json" ] \
|| { fm_pr_checks_unreadable "gh pr view returned no data for $owner/$repo#$number"; return 0; }

parsed=$(printf '%s' "$head_json" \
| jq -r --arg want "$number" "$FM_PR_CHECKS_ROLLUP_JQ" 2>/dev/null) \
|| { fm_pr_checks_unreadable "the pull request check payload for $owner/$repo#$number did not parse"; return 0; }
case $parsed in
identity|identity$'\n'*)
fm_pr_checks_unreadable "the check payload did not describe $owner/$repo#$number"
return 0
;;
invalid|invalid$'\n'*)
fm_pr_checks_unreadable "the check payload for $owner/$repo#$number was missing the fields that were asked for"
return 0
;;
esac

while IFS=$'\t' read -r kind field_a field_b; do
case $kind in
base) base=$field_a ;;
total)
case $field_a in
''|*[!0-9]*)
fm_pr_checks_unreadable "the check count of $owner/$repo#$number was not a number"
return 0
;;
esac
total=$field_a
;;
fail)
fail_count=$((fail_count + 1))
FM_PR_CHECKS_FAILING="${FM_PR_CHECKS_FAILING}branch check: $field_a ($field_b)"$'\n'
;;
pending)
pend_count=$((pend_count + 1))
FM_PR_CHECKS_PENDING="${FM_PR_CHECKS_PENDING}branch check: $field_a ($field_b)"$'\n'
;;
ok|'') ;;
*)
fm_pr_checks_unreadable "the pull request check payload for $owner/$repo#$number was not understood"
return 0
;;
esac
done <<EOF
$parsed
EOF

[ -n "$base" ] \
|| { fm_pr_checks_unreadable "the base branch of $owner/$repo#$number could not be read"; return 0; }

# The merge-queue read is a required source, not an optional enrichment: a
# failure here means the queue verdict is unknown, which must refuse.
prefix="gh-readonly-queue/$base/pr-$number-"
runs_json=$(fm_pr_checks_gh api \
"repos/$owner/$repo/actions/runs?event=merge_group&per_page=100" 2>/dev/null) \
|| { fm_pr_checks_unreadable "the merge-queue check runs of $owner/$repo could not be listed"; return 0; }
[ -n "$runs_json" ] \
|| { fm_pr_checks_unreadable "the merge-queue check runs of $owner/$repo returned no data"; return 0; }

parsed=$(printf '%s' "$runs_json" \
| jq -r --arg prefix "$prefix" "$FM_PR_CHECKS_QUEUE_JQ" 2>/dev/null) \
|| { fm_pr_checks_unreadable "the merge-queue check payload of $owner/$repo did not parse"; return 0; }

while IFS=$'\t' read -r kind field_a field_b; do
case $kind in
queue_none|'') ;;
invalid)
fm_pr_checks_unreadable "the merge-queue payload of $owner/$repo did not list any workflow runs field"
return 0
;;
queue_ref)
# shellcheck disable=SC2034 # Read by the sourcing caller.
FM_PR_CHECKS_QUEUE_REF=$field_a
queue_seen=1
;;
fail)
fail_count=$((fail_count + 1))
FM_PR_CHECKS_FAILING="${FM_PR_CHECKS_FAILING}merge-queue check: $field_a ($field_b)"$'\n'
;;
pending)
pend_count=$((pend_count + 1))
FM_PR_CHECKS_PENDING="${FM_PR_CHECKS_PENDING}merge-queue check: $field_a ($field_b)"$'\n'
;;
ok) ;;
*)
fm_pr_checks_unreadable "the merge-queue check payload of $owner/$repo was not understood"
return 0
;;
esac
done <<EOF
$parsed
EOF

if [ "$fail_count" -gt 0 ]; then
FM_PR_CHECKS_STATE=failing
elif [ "$pend_count" -gt 0 ]; then
FM_PR_CHECKS_STATE=pending
elif [ "$total" -eq 0 ] && [ "$queue_seen" -eq 0 ]; then
FM_PR_CHECKS_STATE=none
else
# shellcheck disable=SC2034 # Read by the sourcing caller.
FM_PR_CHECKS_STATE=green
fi
return 0
}
54 changes: 53 additions & 1 deletion bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@
# Merge method defaults to --squash when the caller passes none of --squash,
# --merge, --rebase, or --method after the optional -- separator. Extra args
# must not include --repo or -R because the repository comes only from the URL.
# Usage: fm-pr-merge.sh <task-id> <pr-url> [-- <extra gh-axi pr merge args>]
#
# Before merging, the forge's own check verdict is read through
# bin/fm-pr-checks-lib.sh and a failing or unreadable verdict REFUSES the merge.
# That guard exists because "never merge a red PR" previously lived only in
# AGENTS.md: on 2026-08-10 pull request 182 of nguzen/aln was merged five minutes
# after its merge-queue run failed, and the resulting commit blocked production
# deploys for two days. Pass --allow-failing-checks to merge anyway when the
# failure is known to be infrastructural rather than in the change.
# Usage: fm-pr-merge.sh <task-id> <pr-url> [--allow-failing-checks]
# [-- <extra gh-axi pr merge args>]
set -eu

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
Expand All @@ -17,6 +26,8 @@ STATE="${FM_STATE_OVERRIDE:-$FM_HOME/state}"

# shellcheck source=bin/fm-pr-lib.sh
. "$SCRIPT_DIR/fm-pr-lib.sh"
# shellcheck source=bin/fm-pr-checks-lib.sh
. "$SCRIPT_DIR/fm-pr-checks-lib.sh"

if [ "$#" -lt 2 ]; then
echo "error: invalid PR merge request" >&2
Expand All @@ -37,6 +48,20 @@ PR_OWNER=$FM_PR_OWNER
PR_REPO=$FM_PR_REPO
PR_NUMBER=$FM_PR_NUMBER
shift 2

# Own flags are consumed before the optional -- separator, so they are never
# forwarded to gh-axi. An unknown flag here is a usage error rather than a
# silently ignored intent.
ALLOW_FAILING_CHECKS=0
while [ "$#" -gt 0 ] && [ "${1:-}" != "--" ]; do
case $1 in
--allow-failing-checks) ALLOW_FAILING_CHECKS=1; shift ;;
*)
echo "error: unknown merge flag $1 (own flags: --allow-failing-checks; pass gh-axi flags after --)" >&2
exit 2
;;
esac
done
[ "${1:-}" = "--" ] && shift

caller_has_merge_method() {
Expand Down Expand Up @@ -70,6 +95,33 @@ if [ ! -f "$META" ] || [ -L "$META" ]; then
exit 1
fi

# The check gate runs before any state is recorded or any poll is armed, so a
# refused merge leaves nothing behind, exactly like the earlier refusals above.
if [ "$ALLOW_FAILING_CHECKS" -eq 1 ]; then
echo "warning: merging $URL without reading the forge's check verdict (--allow-failing-checks)" >&2
else
fm_pr_checks_read "$PR_OWNER" "$PR_REPO" "$PR_NUMBER"
case $FM_PR_CHECKS_STATE in
failing)
echo "error: refusing to merge $URL: the forge reports failing checks" >&2
printf '%s' "$FM_PR_CHECKS_FAILING" | sed 's/^/ /' >&2
[ -z "$FM_PR_CHECKS_QUEUE_REF" ] \
|| echo " (merge-queue attempt read: $FM_PR_CHECKS_QUEUE_REF)" >&2
echo "hint: land a green head, or pass --allow-failing-checks when the failure is infrastructural" >&2
exit 1
;;
unreadable)
echo "error: refusing to merge $URL: the forge's check state is unreadable ($FM_PR_CHECKS_REASON)" >&2
echo "hint: an unreadable verdict is never a pass; fix the read, or pass --allow-failing-checks deliberately" >&2
exit 1
;;
pending)
echo "note: merging $URL with checks still running:" >&2
printf '%s' "$FM_PR_CHECKS_PENDING" | sed 's/^/ /' >&2
;;
esac
fi

"$SCRIPT_DIR/fm-pr-check.sh" "$ID" "$URL"
grep -qxF "pr=$URL" "$META" || {
echo "error: PR metadata recording failed" >&2
Expand Down
3 changes: 3 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,9 @@ For target project repos shipped through their own no-mistakes pipeline, commits
The firstmate repo itself is the exception: its `.no-mistakes/` directory is local state, stays gitignored, and is rejected by CI if tracked.
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 `gh-axi pr merge`.
The helper requires a full `https://github.com/<owner>/<repo>/pull/<n>` URL, invokes `gh-axi pr merge <n> --repo <owner>/<repo>`, defaults to `--squash`, preserves explicit merge-method flags, and rejects malformed URLs or repo override flags before recording merge state; a well-formed GitLab merge request URL (see [docs/gitlab-merge-watch.md](gitlab-merge-watch.md)) is refused too, explicitly, rather than sent to the wrong forge.
Before recording anything it reads the forge's check verdict through [`bin/fm-pr-checks-lib.sh`](../bin/fm-pr-checks-lib.sh) and refuses a failing or unreadable verdict, so "never merge a red PR" is enforced by the one merge path instead of by session attention alone.
That read has two sources because either can be red on its own: the pull request head's status check rollup, and the newest merge-queue attempt, whose checks run again on a combined commit and therefore judge something the branch never did.
An unreadable verdict refuses exactly like a failing one, and `--allow-failing-checks` is the single deliberate override.
Teardown is fail-closed for ship worktrees: dirty worktrees refuse, and committed work must be landed before the worktree is returned.
[`bin/fm-teardown.sh`](../bin/fm-teardown.sh)'s header owns the landed-work proofs, PR-discovery fallback, and stale-lock recovery procedure.

Expand Down
3 changes: 2 additions & 1 deletion docs/scripts.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,8 @@ The shared no-mistakes gate refusal for fleet lifecycle entrypoints is summarize
| `fm-pr-poll.sh` | Provide the byte-static watcher program for validated PR/MR-poll sidecars |
| `fm-pr-check-migrate.sh` | Quarantine older task polls without execution and rebuild only canonical polls |
| `fm-pr-check.sh` | Record validated `pr=` and `pr_head=` values, then atomically arm a static merge poll |
| `fm-pr-merge.sh` | Record PR metadata, then merge a task's canonical full GitHub URL |
| `fm-pr-merge.sh` | Refuse a failing or unreadable forge check verdict, record PR metadata, then merge a task's canonical full GitHub URL |
| `fm-pr-checks-lib.sh` | Classify a GitHub PR's check verdict from its head rollup and its newest merge-queue attempt |
| `fm-promote.sh` | Promote a scout task in place to a protected ship task with an explicit delivery mode |
| `fm-teardown.sh` | Fail-closed teardown: return landed ship worktrees, require completed scout deliverables, retire secondmate homes |
| `fm-harness.sh` | Detect the running harness and resolve crew or secondmate harness, model, and effort |
Expand Down
Loading
Loading