#!/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] # # Options: # --ref Ref to compare against (default: origin/master) # --no-fetch Do not refresh the ref first (see FRESHNESS) # --fetch-timeout 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 : # 1 the copy is NOT the merged one -> REASON=mismatch: # 2 the check could not be performed -> REASON=cannot-verify: # # Every non-zero exit prints one machine-readable line # REASON= # 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 does not exist in the clone # mismatch:path-absent the script does not exist in # mismatch:content the script differs from # mismatch:detached-head the clone is on a detached HEAD # mismatch:clone-ahead local HEAD is ahead of (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 -> 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 -> 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