Skip to content
87 changes: 85 additions & 2 deletions .github/workflows/reverify-base.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,44 @@
# either publishes a verdict or FAILS; none of them can go green having checked
# nothing.
#
# THE CAPTAIN'S APPROVALS reach this check the only way they can: as a signed
# line in the PR body, verified in a job of its own. A branch may deliberately
# supersede behaviour the base asserts, and bin/fm-pr-merge.sh honours that from
# a private record - which a GitHub runner cannot read, so this check would
# otherwise report the same findings and stay red with nothing the branch could
# push to fix it. bin/fm-supersession-verify.sh decides what the captain signed;
# bin/fm-reverify-base.sh decides what that excuses. A finding no approval
# covers still blocks.
#
# WHY THE VERIFICATION IS THE FIRST STEP, RUN FROM THE BASE'S OWN CHECKOUT. This
# job runs the branch's own scripts by construction - that is what
# re-verification means - so the secret must never be readable by them. Two
# properties keep it out of their reach, and both are asserted by
# tests/fm-reverify-base.test.sh:
# - the verifier is the BASE's copy, checked out separately, never the PR's,
# exactly as .github/workflows/ci.yml's waiver job does. A PR cannot replace
# the script that decides whether its own findings may be excused.
# - it runs BEFORE any step that executes the branch's own scripts, and a
# step's `env:` reaches only that step. Nothing PR-authored has run when the
# secret is in the environment, so nothing has been able to put itself on
# PATH or in the way of it.
# Neither closes the broader path where a PR edits this workflow file; that is
# inherent to pull_request events and is mitigated by branch protection.
#
# WHY IT IS A STEP AND NOT A SECOND JOB. A second job would have to hand its
# verdict over through `needs:`, and a required job that `needs:` another is
# SKIPPED when that one fails - a required check that never reports leaves
# branch protection waiting on it forever. The condition that repairs that
# (`if: !cancelled()`) is a job-level condition, and this job deliberately has
# none: every condition here is on a step, so the job always runs and always
# reports. Keeping the verification in this job removes the hazard rather than
# patching it.
#
# NEITHER SCRIPT'S WORK IS RE-SPELLED HERE. bin/fm-assert-tests-kept.sh is the
# single owner of which of the base's test files still need running,
# bin/fm-reverify-base.sh renders its verdict, and bin/fm-reverify-premise.sh
# establishes the premise the skip rests on. This file wires them together and
# bin/fm-reverify-base.sh renders its verdict, bin/fm-reverify-premise.sh
# establishes the premise the skip rests on, and bin/fm-supersession-verify.sh
# establishes what the captain approved. This file wires them together and
# decides nothing.
name: Base re-verification

Expand All @@ -43,6 +77,11 @@ permissions:
# The premise is this head's own behaviour-suite result, read from its check
# runs. Nothing here writes.
checks: read
# The captain's attestation is read from the PR body at job time, never from
# the event payload: an approval always arrives as an edit to an open PR, and
# editing a body triggers no workflow, so the body a re-run replays predates
# it (bin/fm-supersession-verify.sh owns that reasoning).
pull-requests: read

jobs:
reverify-base:
Expand All @@ -65,6 +104,45 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0

# The BASE's copy of the attestation verifier and its libraries, in a
# directory of its own. A pull_request build otherwise runs PR-authored
# code, which would let a PR replace the script that decides whether its
# own findings may be excused - while it holds the secret.
- name: Check out the base's own copy of the attestation verifier
uses: actions/checkout@v6
with:
ref: ${{ github.event.pull_request.base.sha }}
path: .fm-base

# FIRST, before any step that runs the branch's own scripts: see the
# header. A PR with no attestation - almost every PR - passes through this
# silently.
- name: Verify the captain's supersession attestation
id: supersession
env:
FM_SUPERSESSION_SECRET: ${{ secrets.FM_SUPERSESSION_SECRET }}
FM_SUPERSESSION_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
FM_SUPERSESSION_REPO: ${{ github.repository }}
FM_SUPERSESSION_PR: ${{ github.event.pull_request.number }}
GH_TOKEN: ${{ github.token }}
run: |
set -eu
# A base that predates this check carries no verifier at all, so this
# reports nothing-approved rather than exiting 127 and failing the
# job. That is the fail-closed verdict, not a workaround: a base with
# no verifier can honour no approval, so "everything blocks" is
# correct. It self-heals, because every base from this change onwards
# has the verifier.
if [ ! -x .fm-base/bin/fm-supersession-verify.sh ]; then
echo "::warning::the base branch carries no supersession verifier, so no captain approval can be honoured here; every base-assertion finding blocks"
echo "superseded=false" >> "$GITHUB_OUTPUT"
else
.fm-base/bin/fm-supersession-verify.sh
fi
# Removed once read, so the base's tree is never in the worktree the
# re-verification below measures.
rm -rf .fm-base

- name: Establish the premise the cheap path rests on
id: premise
env:
Expand Down Expand Up @@ -127,6 +205,11 @@ jobs:
env:
BASE_REF: ${{ github.event.pull_request.base.ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
# Empty unless the job above verified the captain's signature over
# this exact head. Through env rather than interpolated into the
# command, matching ci.yml's rule for every value that comes off the
# event payload.
FM_SUPERSESSION_ENTRIES: ${{ steps.supersession.outputs.entries }}
run: |
set -eu
bin/fm-reverify-base.sh \
Expand Down
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,15 +72,15 @@ config/cmux-socket-password optional cmux control-socket password; LOCAL, gitig
config/wedge-alarm optional away-mode wedge-alarm active-alert directives; LOCAL, gitignored; absent means auto (macOS Notification Center when available); see docs/wedge-alarm.md
config/statusline-base optional override of the operator's own status-line command, composed above bin/fm-statusline.sh's fleet-control line; LOCAL, gitignored; absent means the harness's own user-level status line is composed instead, so no home needs this file; inherited by secondmate homes and forwarded to task worktrees as FM_STATUSLINE_BASE (docs/configuration.md "Status-line composition")
config/x-mode.env generated X-mode watcher cadence; LOCAL, gitignored; source before arming watcher when present
config/ci-waiver-secret optional captain-held MASTER signing key, used by the CI testing waiver and by the per-task monitoring exemption; LOCAL, gitignored, mode 600; created by bin/fm-ci-waiver.sh init, never printed, published, given to a worker, or inherited by secondmate homes (docs/configuration.md "The CI testing waiver secret")
config/ci-waiver-secret optional captain-held MASTER signing key, used by the CI testing waiver, by the per-task monitoring exemption, and by the supersession attestation; LOCAL, gitignored, mode 600; created by bin/fm-ci-waiver.sh init, never printed, published, given to a worker, or inherited by secondmate homes (docs/configuration.md "The CI testing waiver secret")
data/ personal fleet records; LOCAL, gitignored as a whole
backlog.md task queue, dependencies, history
captain.md this home's domain-local captain preferences and working style; LOCAL, gitignored, canonical even if harness memory mirrors it, and updated with inspect-then-update
captain-shared.md main-authoritative shared captain preferences propagated read-only to secondmate homes; LOCAL, gitignored, owned by secondmate-provisioning
learnings.md fleet-local operational facts and gotchas; LOCAL, gitignored; dated, evidence-backed, curated, and updated with inspect-then-update - rewrite and prune rather than append forever, the same contract as captain.md; created lazily, absent until this home has a learning to store
projects.md thin fleet navigation registry; firstmate-private, parsed by fm-project-mode.sh (section 6)
secondmates.md secondmate routing table; firstmate-private, maintained by fm-home-seed.sh (section 6)
supersessions/<project>.md captain-approved test-assertion supersessions consumed by bin/fm-pr-merge.sh's test-keep gate (entry format in its header); LOCAL, gitignored; created lazily by captain approval only, never auto-generated, absent means no approvals
supersessions/<project>.md captain-approved test-assertion supersessions consumed by bin/fm-pr-merge.sh's test-keep gate (entry format in its header) and carried to CI, which cannot read this record, by bin/fm-supersession-attest.sh's signed attestation; LOCAL, gitignored; created lazily by captain approval only, never auto-generated, absent means no approvals
exec-gate/<project> per-project marker making the test-keep gate's unexecuted findings block instead of warn (contract in bin/fm-pr-merge.sh's header); LOCAL, gitignored; created lazily by the captain only, never auto-generated, absent means warn-only
no-pr-ci/<project> per-project marker confirming the project intentionally runs no PR CI, letting a zero-check PR pass the checks-green merge gate (contract in bin/fm-pr-merge.sh's header, which also owns the per-task signed-ci_skip route to the same gate); LOCAL, gitignored; created lazily by the captain only, never auto-generated, absent means a zero-check PR is refused unless that task's own CI skip is signed
<id>/brief.md per-task crewmate brief, or per-secondmate charter brief when kind=secondmate
Expand Down Expand Up @@ -296,6 +296,7 @@ Both refuse a landing whose commit messages or PR description carry AI attributi
A base assertion the gate could not execute at all against the branch is reported as unexecuted, and it refuses only for a project the captain has enabled with a `data/exec-gate/<project>` marker; otherwise it is informational.
A base assertion the gate could not compare because the base's own test named it differently across two runs is reported as unstable and always refuses; that is a defect in the base's test, so the fix is an ordinary test-fix task naming the assertion with a constant string, never a captain decision.
On that refusal, rebase damage is the worker's to fix, while a deliberate supersession of the base's asserted behavior is a product decision that is never the worker's or firstmate's to make - relay it to the captain, and only a captain-approved entry in `data/supersessions/<project>.md` (entry format in `bin/fm-pr-merge.sh`'s header) lets that merge proceed.
An approved entry clears the merge gate but not the PR's own re-verification check, which cannot read that private record, so run `bin/fm-supersession-attest.sh attest <id>` to publish the captain-signed attestation that carries the same approvals into CI and re-run that check.
`bin/fm-pr-merge.sh` also enforces "never merge a red PR" in code: it refuses when any PR check is failing, still pending, or unreadable, and treats a PR reporting no checks at all as unverified rather than green unless a captain's decision says that absence was deliberate - either a captain-created `data/no-pr-ci/<project>` marker or a signed `ci_skip` on that task, never a bare flag line and never a local skip - with that script's header owning the contract.
The single exception is the no-mistakes attestation check, which a PR that legitimately never used the pipeline can never pass: its failure alone is excused when the task carries a signed testing skip or the project ships `direct-PR`, every other check still has to be green, and that script's header owns the contract.
After an autonomous merge, give the captain a one-line full-URL or local-main outcome.
Expand Down
35 changes: 35 additions & 0 deletions bin/fm-ci-waiver-lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,41 @@ fm_ci_waiver_valid_repo() {
return 0
}

# fm_ci_waiver_task_repo_slug <meta>: the owner/repo the task's own checkout
# pushes to, or nothing (exit 1) when that cannot be determined. Only a GitHub
# remote is resolved, because GitHub Actions is where anything signed here is
# ever verified; anything else is deliberately reported as unknown rather than
# parsed into a guess.
#
# It lives here rather than in either signer because BOTH of them refuse to sign
# for a repository the task does not belong to, for the same reason
# (bin/fm-ci-waiver.sh's sign_waiver states it), and a refusal that reads one way
# in one signer and another way in the other is a refusal an operator cannot
# reason about.
fm_ci_waiver_task_repo_slug() {
local meta=$1 dir url slug
dir=$(grep -m1 '^worktree=' "$meta" | cut -d= -f2- || true)
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
dir=$(grep -m1 '^project=' "$meta" | cut -d= -f2- || true)
fi
[ -n "$dir" ] && [ -d "$dir" ] || return 1
command -v git >/dev/null 2>&1 || return 1
url=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1
case "$url" in
*github.com[:/]*) : ;;
*) return 1 ;;
esac
slug=${url%.git}
slug=${slug##*github.com}
slug=${slug#:}
slug=${slug#/}
case "$slug" in
*/*/*|*/) return 1 ;;
*/*) printf '%s\n' "$slug" | tr '[:upper:]' '[:lower:]' ;;
*) return 1 ;;
esac
}

# fm_ci_waiver_secret_readable <path>: 0 iff <path> is a usable secret file - a
# non-empty regular file that is not a symlink pointing somewhere else.
#
Expand Down
30 changes: 1 addition & 29 deletions bin/fm-ci-waiver.sh
Original file line number Diff line number Diff line change
Expand Up @@ -120,34 +120,6 @@ read_secret_or_die() {
fi
}

# task_repo_slug <meta>: the owner/repo the task's own checkout pushes to, or
# nothing (exit 1) when that cannot be determined. Only a GitHub remote is
# resolved, because GitHub Actions is where a waiver is ever verified; anything
# else is deliberately reported as unknown rather than parsed into a guess.
task_repo_slug() {
local meta=$1 dir url slug
dir=$(grep -m1 '^worktree=' "$meta" | cut -d= -f2- || true)
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
dir=$(grep -m1 '^project=' "$meta" | cut -d= -f2- || true)
fi
[ -n "$dir" ] && [ -d "$dir" ] || return 1
command -v git >/dev/null 2>&1 || return 1
url=$(git -C "$dir" remote get-url origin 2>/dev/null) || return 1
case "$url" in
*github.com[:/]*) : ;;
*) return 1 ;;
esac
slug=${url%.git}
slug=${slug##*github.com}
slug=${slug#:}
slug=${slug#/}
case "$slug" in
*/*/*|*/) return 1 ;;
*/*) printf '%s\n' "$slug" | tr '[:upper:]' '[:lower:]' ;;
*) return 1 ;;
esac
}

# sign_waiver <task-id> <sha> <owner/repo>: validate, check the dispatch
# authorization, and print the one publishable line. THE authority check lives
# here, and both `sign` and `waive` go through this single function, so no
Expand Down Expand Up @@ -179,7 +151,7 @@ sign_waiver() {
# cannot be resolved at all is reported and allowed, because that is exactly
# what this script did before the check existed: it closes the mismatch
# wherever the information exists and breaks no dispatch where it does not.
TASK_REPO=$(task_repo_slug "$META") || TASK_REPO=
TASK_REPO=$(fm_ci_waiver_task_repo_slug "$META") || TASK_REPO=
REPO_LOWER=$(printf '%s' "$REPO" | tr '[:upper:]' '[:lower:]')
if [ -n "$TASK_REPO" ] && [ "$TASK_REPO" != "$REPO_LOWER" ]; then
echo "error: task $ID's own checkout pushes to $TASK_REPO, not $REPO; refusing to sign a waiver for a repository this task does not belong to" >&2
Expand Down
7 changes: 7 additions & 0 deletions bin/fm-pr-merge.sh
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@
# - Only the captain approves an entry. There is no mechanical guarantee of
# this: nothing physically prevents another writer, and the required fields
# exist so a fabricated entry is visible rather than silent.
# - CI cannot read this record, and must not: it is private by design. An
# approval reaches the required `Base assertions re-verified` check as a
# captain-signed attestation carrying each entry's matching half and nothing
# else, so a finding is excused there exactly when it is excused here.
# bin/fm-supersession-attest.sh is how one is issued and
# bin/fm-supersession-attest-lib.sh owns its wire; neither changes anything
# in this grammar or in this gate.
#
# Unexecuted findings and per-project enablement: check 2 reports
# `unexecuted: <file>::<name>` for a base assertion it could not execute at all
Expand Down
Loading
Loading