PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 10s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 12s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Failing after 6s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Skipped
Bounded correction round on PR #134 after a PASS-WITH-FINDINGS review whose
Finding 4 is High. The guard's purpose and its fail-closed fix stand; the
problem was that with 'enforce' as the default it gates EVERY scheduled
contract, and three legitimate states produce a refusal - a clone legitimately
ahead of origin/master mid-review, a detached HEAD, and an offline or failed
fetch - so any of them would turn the fleet's monitoring into withheld
verdicts. That risk outweighs the staleness the guard catches.
1. DEFAULT IS NOW 'warn'. 'enforce' remains available and documented. The
criteria for flipping the default later are written into the doc as a
decision with evidence - a sustained window (30 days / 200+ runs) with zero
mismatch:* and zero cannot-verify:* refusals, no fetch blips, and a pinned
clone demonstrably kept current - explicitly as its own change, not a silent
flip.
2. 'COULD NOT CHECK' IS NOW DISTINGUISHABLE FROM 'THIS COPY IS WRONG'. Every
non-zero exit prints a machine-readable REASON=<class> line:
cannot-verify:fetch-failed | cannot-verify:ref-unresolvable (exit 2)
mismatch:path-absent | mismatch:content
mismatch:detached-head | mismatch:clone-ahead (exit 1)
detached-head and clone-ahead are named separately because they are
legitimate states, far less alarming than a hand-edited file. clone-ahead
requires HEAD to be STRICTLY ahead; an uncommitted edit on a commit that IS
the ref is a plain content mismatch (my own first cut got this wrong and the
new test 7d caught it).
3. THE DEFAULT FETCH IS BOUNDED: --fetch-timeout, default 20s, 0 = unbounded,
and a missing 'timeout' binary is itself a cannot-verify rather than an
unbounded fetch inside a scheduled contract.
4. TEST COVERAGE ADDED for every new class: fetch failure, fetch timeout
(asserted to return promptly under a 1s bound), unresolvable ref, detached
HEAD, clone-ahead, genuine content mismatch, and the contract-run.sh default.
The pre-fix draft fixture comparisons are kept: 31 passed, 0 failed.
5. MERGE-TIME SEQUENCE documented: fast-forward /opt/contract-runner, confirm
clean, prove a contract runs and reports. Baseline recorded as of today -
firstmate has already fast-forwarded it to 9faffe4 - with the note that an
untracked file blocks a fast-forward even when byte-identical.
Live behaviour re-verified on the real runner path:
default: REASON=mismatch:clone-ahead -> 'continuing because ...=warn' -> VERDICT: PASS, exit 0
enforce: REASON=mismatch:clone-ahead -> 'VERDICT WITHHELD: mismatch:clone-ahead', exit 2
MANDATORY CHECKS (master went red once from a credential-SHAPED string, so
these are now run on every shippable branch):
bash scripts/prose-lint.sh -> LINT PASSED (18 warning(s))
secret scan -> secret scan clean (tree; 34 allowlisted,
24 inert value(s) ignored); No committed credentials
shellcheck revision-preflight.sh -> clean
shellcheck test_revision_preflight.sh -> clean
shellcheck contract-run.sh -> SC2034 x1, SC2086 x2 - byte-identical on
master, i.e. pre-existing, none introduced
tests/test_probe_drift.py::test_prose_lint_accepts_report_format_with_provenance
fails both before and after this branch (it runs prose-lint from a temp CWD and
cannot find its sibling secret-scan.sh). Pre-existing, unrelated, not fixed here.
210 lines
8.8 KiB
Bash
Executable File
210 lines
8.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# revision-preflight.sh — prove the copy a contract is about to execute is the
|
|
# copy that is merged.
|
|
#
|
|
# Usage:
|
|
# revision-preflight.sh [options] <script-path> <clone-path>
|
|
#
|
|
# Options:
|
|
# --ref <ref> Ref to compare against (default: origin/master)
|
|
# --no-fetch Do not refresh the ref first (see FRESHNESS)
|
|
# --fetch-timeout <secs> Bound the default fetch (default: 20; 0 = no bound)
|
|
# --quiet Print nothing on success
|
|
# -h, --help Show this help
|
|
#
|
|
# Exit codes:
|
|
# 0 the executing script byte-matches <ref>:<repo-relative-path>
|
|
# 1 the copy is NOT the merged one -> REASON=mismatch:<class>
|
|
# 2 the check could not be performed -> REASON=cannot-verify:<class>
|
|
#
|
|
# Every non-zero exit prints one machine-readable line
|
|
# REASON=<class>
|
|
# followed by the human explanation. The two top-level classes are deliberately
|
|
# distinct: "I could not check" is a different situation from "this copy is
|
|
# wrong", and an operator must never have to guess which they are looking at.
|
|
#
|
|
# cannot-verify:fetch-failed the remote could not be reached (or timed out)
|
|
# cannot-verify:ref-unresolvable <ref> does not exist in the clone
|
|
# mismatch:path-absent the script does not exist in <ref>
|
|
# mismatch:content the script differs from <ref>
|
|
# mismatch:detached-head the clone is on a detached HEAD
|
|
# mismatch:clone-ahead local HEAD is ahead of <ref> (mid-review?)
|
|
#
|
|
# FRESHNESS
|
|
# A guard is only as good as the ref it compares against. On 2026-09-25 a
|
|
# stale local origin/master made an ancestry check on this fleet report
|
|
# "unlanded work" for a branch that had in fact merged, and it would equally
|
|
# have passed a stale script as current. So by default this guard FETCHES the
|
|
# remote before comparing, bounded by --fetch-timeout so a hung remote cannot
|
|
# block a scheduled contract. With --no-fetch it compares against whatever the
|
|
# local ref points at and says so out loud; it never silently assumes
|
|
# freshness.
|
|
#
|
|
# FAIL CLOSED
|
|
# An unresolvable path or ref is a FAILURE, never a warning. "Cannot verify"
|
|
# is precisely the state a stale or hand-edited copy produces, so treating it
|
|
# as success would defeat the guard. The original draft did exactly that: it
|
|
# resolved the master revision with
|
|
# `git show origin/master:$(basename "$SCRIPT")`, which drops the scripts/
|
|
# prefix, queries the repo root, fails, and exited 0 — passing a script that
|
|
# exists in no revision at all.
|
|
#
|
|
# WHICH CLONE
|
|
# Pass the clone the contract is actually executing from. See
|
|
# docs/contract-execution-pinning.md for which clone each contract pins.
|
|
|
|
set -euo pipefail
|
|
|
|
REF="origin/master"
|
|
FETCH=1
|
|
QUIET=0
|
|
FETCH_TIMEOUT="${REVISION_PREFLIGHT_FETCH_TIMEOUT:-20}"
|
|
|
|
usage() {
|
|
sed -n '2,55p' "$0" | sed 's/^# \{0,1\}//'
|
|
}
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--ref)
|
|
[[ $# -ge 2 ]] || { echo "revision-preflight: --ref needs a value" >&2; exit 2; }
|
|
REF="$2"; shift 2 ;;
|
|
--no-fetch) FETCH=0; shift ;;
|
|
--fetch-timeout)
|
|
[[ $# -ge 2 ]] || { echo "revision-preflight: --fetch-timeout needs a value" >&2; exit 2; }
|
|
FETCH_TIMEOUT="$2"; shift 2 ;;
|
|
--quiet) QUIET=1; shift ;;
|
|
-h|--help) usage; exit 0 ;;
|
|
--) shift; break ;;
|
|
-*) echo "revision-preflight: unknown option: $1" >&2; exit 2 ;;
|
|
*) break ;;
|
|
esac
|
|
done
|
|
|
|
if [[ $# -lt 2 ]]; then
|
|
usage >&2
|
|
exit 2
|
|
fi
|
|
|
|
SCRIPT="$1"
|
|
CLONE="$2"
|
|
|
|
say() { [[ $QUIET -eq 1 ]] || echo "$@" >&2; }
|
|
|
|
# refuse <class> <explanation...> -> the copy is not the merged one
|
|
refuse() {
|
|
local class="$1"; shift
|
|
echo "REASON=mismatch:${class}" >&2
|
|
echo "❌ revision-preflight: MISMATCH (${class}) — refusing to report from this copy" >&2
|
|
for line in "$@"; do echo " $line" >&2; done
|
|
exit 1
|
|
}
|
|
|
|
# unverifiable <class> <explanation...> -> the check could not be performed
|
|
unverifiable() {
|
|
local class="$1"; shift
|
|
echo "REASON=cannot-verify:${class}" >&2
|
|
echo "❌ revision-preflight: CANNOT VERIFY (${class}) — refusing to report unverified" >&2
|
|
for line in "$@"; do echo " $line" >&2; done
|
|
exit 2
|
|
}
|
|
|
|
# ── 1. inputs must exist ──────────────────────────────────────────────────────
|
|
if [[ ! -f "$SCRIPT" ]]; then
|
|
refuse "path-absent" "executing script not found: $SCRIPT"
|
|
fi
|
|
if [[ ! -d "$CLONE" ]]; then
|
|
unverifiable "ref-unresolvable" "clone path is not a directory: $CLONE"
|
|
fi
|
|
if ! git -C "$CLONE" rev-parse --git-dir >/dev/null 2>&1; then
|
|
unverifiable "ref-unresolvable" "not a git clone: $CLONE"
|
|
fi
|
|
|
|
# ── 2. resolve the repo-relative path (the original defect) ───────────────────
|
|
CLONE_ABS=$(cd "$CLONE" && pwd)
|
|
SCRIPT_ABS=$(cd "$(dirname "$SCRIPT")" && pwd)/$(basename "$SCRIPT")
|
|
case "$SCRIPT_ABS" in
|
|
"$CLONE_ABS"/*) REL="${SCRIPT_ABS#"$CLONE_ABS"/}" ;;
|
|
*) refuse "content" "script is outside the clone: $SCRIPT_ABS is not under $CLONE_ABS" ;;
|
|
esac
|
|
|
|
# ── 3. refresh the ref, bounded, so a hung remote cannot block a contract ────
|
|
if [[ $FETCH -eq 1 ]]; then
|
|
REMOTE="${REF%%/*}"
|
|
[[ "$REMOTE" == "$REF" ]] && REMOTE="origin"
|
|
FETCH_CMD=(git -C "$CLONE" fetch --quiet "$REMOTE")
|
|
if [[ "$FETCH_TIMEOUT" != "0" ]]; then
|
|
if ! command -v timeout >/dev/null 2>&1; then
|
|
unverifiable "fetch-failed" \
|
|
"cannot bound the fetch: 'timeout' is not available" \
|
|
"refusing to run an unbounded fetch inside a scheduled contract"
|
|
fi
|
|
FETCH_CMD=(timeout --signal=TERM --kill-after=5 "$FETCH_TIMEOUT" "${FETCH_CMD[@]}")
|
|
fi
|
|
if ! "${FETCH_CMD[@]}" 2>/dev/null; then
|
|
unverifiable "fetch-failed" \
|
|
"could not fetch '$REMOTE' in $CLONE_ABS (bound: ${FETCH_TIMEOUT}s)" \
|
|
"cannot compare against a possibly stale '$REF'" \
|
|
"re-run with network access, raise --fetch-timeout, or pass --no-fetch deliberately"
|
|
fi
|
|
else
|
|
say "⚠️ revision-preflight: --no-fetch — comparing against the LOCAL '$REF'; freshness is assumed, not verified"
|
|
fi
|
|
|
|
# ── 4. resolve the merged revision; unresolvable is a failure ────────────────
|
|
if ! git -C "$CLONE" rev-parse --verify --quiet "$REF" >/dev/null; then
|
|
unverifiable "ref-unresolvable" \
|
|
"ref '$REF' does not resolve in $CLONE_ABS" \
|
|
"the clone may never have fetched, or the ref name may be wrong"
|
|
fi
|
|
REF_COMMIT=$(git -C "$CLONE" rev-parse --short "$REF")
|
|
|
|
TMPFILE=$(mktemp)
|
|
trap 'rm -f "$TMPFILE"' EXIT
|
|
|
|
if ! git -C "$CLONE" show "$REF:$REL" > "$TMPFILE" 2>/dev/null; then
|
|
refuse "path-absent" \
|
|
"'$REL' does not exist in $REF ($REF_COMMIT)" \
|
|
"a path absent from $REF can never be a merged copy" \
|
|
"script: $SCRIPT_ABS"
|
|
fi
|
|
|
|
# ── 5. compare ───────────────────────────────────────────────────────────────
|
|
EXEC_SHA=$(sha256sum "$SCRIPT" | cut -d' ' -f1)
|
|
MERGED_SHA=$(sha256sum "$TMPFILE" | cut -d' ' -f1)
|
|
|
|
if [[ "$EXEC_SHA" != "$MERGED_SHA" ]]; then
|
|
DETAIL=("script: $SCRIPT_ABS"
|
|
"clone: $CLONE_ABS"
|
|
"executed: $EXEC_SHA"
|
|
"merged: $MERGED_SHA ($REF:$REL @ $REF_COMMIT)")
|
|
|
|
# Name WHY it differs: a detached HEAD or a branch legitimately ahead of the
|
|
# ref is a much more benign situation than a hand-edited file, and the
|
|
# operator must be able to tell them apart.
|
|
if ! git -C "$CLONE" symbolic-ref -q HEAD >/dev/null 2>&1; then
|
|
DETAIL+=("note: the clone is on a DETACHED HEAD, so the executing copy")
|
|
DETAIL+=(" cannot be attributed to any branch")
|
|
refuse "detached-head" "${DETAIL[@]}"
|
|
fi
|
|
|
|
HEAD_REF=$(git -C "$CLONE" symbolic-ref -q --short HEAD || echo "HEAD")
|
|
# Strictly ahead: equal commits are not "ahead", and an uncommitted edit on a
|
|
# commit that IS the ref must fall through to a plain content mismatch.
|
|
REF_OID=$(git -C "$CLONE" rev-parse "$REF" 2>/dev/null || echo "")
|
|
HEAD_OID=$(git -C "$CLONE" rev-parse HEAD 2>/dev/null || echo "")
|
|
if [[ -n "$REF_OID" && "$REF_OID" != "$HEAD_OID" ]] \
|
|
&& git -C "$CLONE" merge-base --is-ancestor "$REF" HEAD 2>/dev/null; then
|
|
AHEAD=$(git -C "$CLONE" rev-list --count "$REF..HEAD" 2>/dev/null || echo "?")
|
|
DETAIL+=("note: '$HEAD_REF' is AHEAD of $REF by $AHEAD commit(s)")
|
|
DETAIL+=(" (a legitimate mid-review state, not a hand-edited file)")
|
|
refuse "clone-ahead" "${DETAIL[@]}"
|
|
fi
|
|
|
|
DETAIL+=("branch: $HEAD_REF")
|
|
refuse "content" "${DETAIL[@]}"
|
|
fi
|
|
|
|
say "✅ revision-preflight: $REL matches $REF @ $REF_COMMIT ($EXEC_SHA)"
|
|
exit 0
|