Files
cleveragents-core/tools/preflight_phase01.sh
T
drew 44752dc87b Fix sentinel rollout: head-ref filter, cursor partitioning, deterministic tests
The Phase 2 fork-rollout exposed three bugs that all only manifest when
targeting a non-canonical Forgejo repo:

1. tools/duplicate_prs_to_fork.py — Forgejo silently ignores the
   ?head=owner:branch query parameter that the lookup relied on, so
   every PR after the first was reported as already_exists. Replace
   with a paginated client-side filter on head.ref.

2. tools/preflight_phase01.sh — the cursor-existence check used the
   canonical filename even when targeting a fork, so step 1.4a was
   SKIPped against the fork's empty state, leaving step 1.4 to FAIL
   on 50 historical commits. Mirror verify_invariant.py's
   per-<owner>.<repo> partitioning in shell.

3. tests/auto_agents — three tests hard-coded
   /repos/cleveragents/cleveragents-core URLs and broke under
   FORGEJO_OWNER=drew. Derive expected URLs from mod.REPO_OWNER /
   mod.REPO_NAME so they're independent of the operator's shell env.
   Also add tests/auto_agents/conftest.py with an autouse fixture
   that clears every FORGEJO_* env var before each test, locking
   the suite to a deterministic canonical baseline (env-override
   tests that use monkeypatch.setenv still take precedence).

Two new unit tests cover the head-ref filter mismatch and pagination
paths of existing_fork_pr (157 tests passing, up from 155).

Documents the Forgejo API quirks observed (head=owner:branch ignored,
fork creation returns 202 without immediate availability, forks don't
inherit org-level labels, upstream labels not auto-created in forks)
in AGENTS.md so future maintainers don't repeat them.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-04 13:13:52 -04:00

488 lines
18 KiB
Bash
Executable File

#!/usr/bin/env bash
# tools/preflight_phase01.sh — Pre-production validation: Phases 0 & 1.
#
# Runs the full read-only validation suite for the deterministic
# auto-agents pipeline (the commit on `dmpipeline`) and emits a single
# pass/fail report.
#
# Phase 0 — Unit tests (offline, no network)
# 0.1 pytest tests/auto_agents/ ─ all unit tests for the new tools.
#
# Phase 1 — Read-only smoke tests (production Forgejo, no mutations)
# 1.1 tools/forgejo_audit.py — Tier 0A audit
# 1.2 tools/audit_branch_protection.py — read-only
# 1.3 tools/merge_drive.py --status — driver config dump
# 1.4a tools/verify_invariant.py --seed-cursor — first-run only
# 1.4 tools/verify_invariant.py --mode observe --dry-run
# 1.5 tools/flag_stale_prs.py --dry-run — stale-PR planner
# 1.6 tools/setup_auto_labels.py --dry-run — label provisioning
# 1.7 tools/setup_branch_protection.py --dry-run
# 1.8 tools/migrate_to_new_driver.py --dry-run — claim/branch sweep
# 1.9 list_prs.ts --exclude-claimed — TS-side filter check
#
# Step 1.4a runs only on the very first preflight (when the verifier's
# cursor file does not yet exist), and writes a single local file —
# no Forgejo mutations. Subsequent runs SKIP it as a no-op.
#
# Mutating actions (setup_*, migrate_to_new_driver --apply, the driver
# itself) are intentionally NOT run here — those are Phase 2+ in the
# pre-production plan.
#
# Usage
# -----
#
# tools/preflight_phase01.sh # full Phase 0 + Phase 1
# tools/preflight_phase01.sh --phase 0 # only unit tests
# tools/preflight_phase01.sh --phase 1 # only smoke tests
# tools/preflight_phase01.sh --skip-ts # skip the TS smoke test
# tools/preflight_phase01.sh --keep-going # run every step on failure
# (default; here for symmetry)
# tools/preflight_phase01.sh --fail-fast # stop after first failure
# tools/preflight_phase01.sh --help
#
# Output
# ------
#
# Per-step logs are written to:
# tools/.cache/preflight/<UTC_TS>/NN.<step>.log
# A machine-readable summary is written to:
# tools/.cache/preflight/<UTC_TS>/summary.json
# A human-readable summary is printed to stdout at the end.
#
# Exit codes
# ----------
#
# 0 — every selected step PASSed (SKIPped steps don't fail the run)
# 1 — at least one step FAILed
# 2 — argument error / missing prerequisite
set -uo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"
# ─── Argument parsing ─────────────────────────────────────────────────────
phase_filter="all" # all | 0 | 1
skip_ts=0
fail_fast=0
usage() {
sed -n '2,49p' "$0" | sed 's/^# \{0,1\}//'
}
while [[ $# -gt 0 ]]; do
case "$1" in
--phase) phase_filter="${2:-}"; shift 2 || { echo "ERROR: --phase needs an argument" >&2; exit 2; } ;;
--phase=*) phase_filter="${1#*=}"; shift ;;
--skip-ts) skip_ts=1; shift ;;
--fail-fast) fail_fast=1; shift ;;
--keep-going) fail_fast=0; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown flag: $1" >&2; usage >&2; exit 2 ;;
esac
done
case "$phase_filter" in
all|0|1) ;;
*) echo "ERROR: --phase must be 'all', '0', or '1' (got: $phase_filter)" >&2; exit 2 ;;
esac
# ─── Workspace ────────────────────────────────────────────────────────────
RUN_TS="$(date -u +%Y%m%dT%H%M%SZ)"
RUN_DIR="$REPO_ROOT/tools/.cache/preflight/$RUN_TS"
mkdir -p "$RUN_DIR"
# Pick the best Python interpreter we can find.
if [[ -x "$REPO_ROOT/.venv/bin/python3" ]]; then
PY="$REPO_ROOT/.venv/bin/python3"
elif command -v python3 >/dev/null 2>&1; then
PY="$(command -v python3)"
else
echo "ERROR: no python3 interpreter found (.venv/bin/python3 or PATH)" >&2
exit 2
fi
# ─── Token resolution (mirrors the helpers in tools/*.py) ─────────────────
resolve_gitea_token() {
if [[ -n "${GITEA_TOKEN:-}" ]]; then
printf '%s' "$GITEA_TOKEN"
return 0
fi
for f in "$REPO_ROOT/.devcontainer/.env" "$REPO_ROOT/.env"; do
[[ -f "$f" ]] || continue
local val
val="$(awk -F= '
/^[[:space:]]*GITEA_TOKEN[[:space:]]*=/ {
sub(/^[[:space:]]*GITEA_TOKEN[[:space:]]*=[[:space:]]*"?/, "")
sub(/[" \t#].*$/, "")
print
exit
}' "$f" 2>/dev/null || true)"
if [[ -n "$val" ]]; then
printf '%s' "$val"
return 0
fi
done
return 1
}
GITEA_TOKEN_RESOLVED=""
if GITEA_TOKEN_RESOLVED="$(resolve_gitea_token)"; then
export GITEA_TOKEN="$GITEA_TOKEN_RESOLVED"
fi
# ─── Step harness ─────────────────────────────────────────────────────────
# We track every step's result in parallel arrays and emit a final table.
STEP_IDS=()
STEP_NAMES=()
STEP_PHASES=()
STEP_RESULTS=() # PASS | FAIL | SKIP
STEP_DURATIONS=()
STEP_LOGFILES=()
STEP_DETAILS=() # short human-readable explanation
step_index=0
# Print a colourised label if stdout is a TTY.
if [[ -t 1 ]]; then
C_PASS=$'\e[32m'; C_FAIL=$'\e[31m'; C_SKIP=$'\e[33m'; C_DIM=$'\e[2m'; C_BOLD=$'\e[1m'; C_OFF=$'\e[0m'
else
C_PASS=""; C_FAIL=""; C_SKIP=""; C_DIM=""; C_BOLD=""; C_OFF=""
fi
label_for() {
case "$1" in
PASS) printf '%sPASS%s' "$C_PASS" "$C_OFF" ;;
FAIL) printf '%sFAIL%s' "$C_FAIL" "$C_OFF" ;;
SKIP) printf '%sSKIP%s' "$C_SKIP" "$C_OFF" ;;
*) printf '%s' "$1" ;;
esac
}
# Run a single step. Args:
# $1 step id (e.g. "0.1", "1.3")
# $2 step phase ("0" or "1")
# $3 step name (short)
# $4 step short detail (printed in the summary table)
# $5+ command and args to run
run_step() {
local id="$1"; shift
local phase="$1"; shift
local name="$1"; shift
local detail="$1"; shift
step_index=$((step_index + 1))
local pad_id; printf -v pad_id '%02d' "$step_index"
local logfile="$RUN_DIR/${pad_id}.${id}.log"
# Phase filter.
if [[ "$phase_filter" != "all" && "$phase_filter" != "$phase" ]]; then
STEP_IDS+=("$id"); STEP_NAMES+=("$name"); STEP_PHASES+=("$phase")
STEP_RESULTS+=("SKIP"); STEP_DURATIONS+=("0")
STEP_LOGFILES+=("$logfile"); STEP_DETAILS+=("filtered out by --phase")
printf ' %s [%s] %-44s %s\n' "$(label_for SKIP)" "$id" "$name" \
"${C_DIM}filtered out by --phase=$phase_filter${C_OFF}"
: >"$logfile"
echo "[skipped] phase $phase not selected (filter=$phase_filter)" >"$logfile"
return 0
fi
printf ' %s [%s] %-44s %s\n' \
"${C_BOLD}RUN${C_OFF} " "$id" "$name" "${C_DIM}$detail${C_OFF}"
local t0 t1 dur status="FAIL"
t0="$(date +%s)"
if "$@" >"$logfile" 2>&1; then
status="PASS"
fi
t1="$(date +%s)"
dur=$((t1 - t0))
# Move the cursor up + redraw the line so the user sees PASS/FAIL inline.
if [[ -t 1 ]]; then
printf '\e[1A\e[2K'
fi
printf ' %s [%s] %-44s %s (%ss, log: %s)\n' \
"$(label_for "$status")" "$id" "$name" "${C_DIM}$detail${C_OFF}" \
"$dur" "$(realpath --relative-to="$REPO_ROOT" "$logfile" 2>/dev/null || echo "$logfile")"
STEP_IDS+=("$id"); STEP_NAMES+=("$name"); STEP_PHASES+=("$phase")
STEP_RESULTS+=("$status"); STEP_DURATIONS+=("$dur")
STEP_LOGFILES+=("$logfile"); STEP_DETAILS+=("$detail")
if [[ "$status" == "FAIL" && $fail_fast -eq 1 ]]; then
echo
echo "${C_FAIL}--fail-fast: stopping at first failure (step $id).${C_OFF}" >&2
finalize_and_exit
fi
}
# Mark a step SKIPped (e.g. missing prerequisite).
skip_step() {
local id="$1" phase="$2" name="$3" detail="$4"
step_index=$((step_index + 1))
local pad_id; printf -v pad_id '%02d' "$step_index"
local logfile="$RUN_DIR/${pad_id}.${id}.log"
: >"$logfile"
echo "[skipped] $detail" >"$logfile"
STEP_IDS+=("$id"); STEP_NAMES+=("$name"); STEP_PHASES+=("$phase")
STEP_RESULTS+=("SKIP"); STEP_DURATIONS+=("0")
STEP_LOGFILES+=("$logfile"); STEP_DETAILS+=("$detail")
printf ' %s [%s] %-44s %s\n' "$(label_for SKIP)" "$id" "$name" \
"${C_DIM}$detail${C_OFF}"
}
# ─── Final summary ────────────────────────────────────────────────────────
finalize_and_exit() {
local pass=0 fail=0 skip=0
local i
for i in "${!STEP_RESULTS[@]}"; do
case "${STEP_RESULTS[$i]}" in
PASS) pass=$((pass + 1)) ;;
FAIL) fail=$((fail + 1)) ;;
SKIP) skip=$((skip + 1)) ;;
esac
done
echo
echo "=========================================================================="
printf '%sPreflight summary%s — %s\n' "$C_BOLD" "$C_OFF" "$RUN_TS"
echo " run dir: $RUN_DIR"
echo "--------------------------------------------------------------------------"
printf ' %-6s %-6s %-44s %-6s %s\n' "STEP" "PHASE" "NAME" "TIME" "RESULT"
for i in "${!STEP_RESULTS[@]}"; do
local status="${STEP_RESULTS[$i]}"
printf ' %-6s %-6s %-44s %-6s %s\n' \
"${STEP_IDS[$i]}" "${STEP_PHASES[$i]}" "${STEP_NAMES[$i]}" \
"${STEP_DURATIONS[$i]}s" "$(label_for "$status")"
done
echo "--------------------------------------------------------------------------"
printf ' total: %d %sPASS%s: %d %sFAIL%s: %d %sSKIP%s: %d\n' \
"${#STEP_RESULTS[@]}" "$C_PASS" "$C_OFF" "$pass" \
"$C_FAIL" "$C_OFF" "$fail" "$C_SKIP" "$C_OFF" "$skip"
echo "=========================================================================="
# Failures: dump the last 30 lines of each failing log inline.
if [[ $fail -gt 0 ]]; then
echo
echo "${C_BOLD}Failure tails${C_OFF} (last 30 lines of each FAILed step's log):"
for i in "${!STEP_RESULTS[@]}"; do
[[ "${STEP_RESULTS[$i]}" == "FAIL" ]] || continue
echo
echo "── [${STEP_IDS[$i]}] ${STEP_NAMES[$i]}${STEP_LOGFILES[$i]}"
tail -n 30 "${STEP_LOGFILES[$i]}" 2>/dev/null | sed 's/^/ /'
done
echo
fi
# Machine-readable summary.
{
printf '{\n'
printf ' "run_ts": "%s",\n' "$RUN_TS"
printf ' "run_dir": "%s",\n' "$RUN_DIR"
printf ' "totals": { "pass": %d, "fail": %d, "skip": %d, "total": %d },\n' \
"$pass" "$fail" "$skip" "${#STEP_RESULTS[@]}"
printf ' "steps": [\n'
local last=$((${#STEP_RESULTS[@]} - 1))
for i in "${!STEP_RESULTS[@]}"; do
printf ' { "id": "%s", "phase": "%s", "name": %s, "result": "%s", "duration_s": %s, "logfile": %s, "detail": %s }' \
"${STEP_IDS[$i]}" \
"${STEP_PHASES[$i]}" \
"$(json_str "${STEP_NAMES[$i]}")" \
"${STEP_RESULTS[$i]}" \
"${STEP_DURATIONS[$i]}" \
"$(json_str "${STEP_LOGFILES[$i]}")" \
"$(json_str "${STEP_DETAILS[$i]}")"
if [[ "$i" -lt "$last" ]]; then printf ',\n'; else printf '\n'; fi
done
printf ' ]\n'
printf '}\n'
} >"$RUN_DIR/summary.json"
if [[ $fail -gt 0 ]]; then
exit 1
fi
exit 0
}
# Tiny JSON string escaper for the summary file.
json_str() {
local s="$1"
s="${s//\\/\\\\}"
s="${s//\"/\\\"}"
s="${s//$'\n'/\\n}"
s="${s//$'\t'/\\t}"
printf '"%s"' "$s"
}
# Trap so partial runs still emit a summary.
trap 'finalize_and_exit' INT TERM
# ─── Banner ───────────────────────────────────────────────────────────────
echo "=========================================================================="
printf '%sPreflight Phase 0 + Phase 1%s — auto-agents pipeline\n' "$C_BOLD" "$C_OFF"
echo " repo: $REPO_ROOT"
echo " run timestamp: $RUN_TS"
echo " python: $PY"
echo " log dir: $RUN_DIR"
if [[ -n "${GITEA_TOKEN:-}" ]]; then
# Don't print the token, just confirm presence + length.
echo " GITEA_TOKEN: set (${#GITEA_TOKEN} chars)"
else
echo " GITEA_TOKEN: NOT SET — Phase 1 steps that need it will be SKIPped"
fi
# Surface the Forgejo target so it's obvious when the user is pointing
# the harness at a fork instead of the canonical repo.
TARGET_OWNER="${FORGEJO_OWNER:-cleveragents}"
TARGET_REPO="${FORGEJO_REPO:-cleveragents-core}"
TARGET_API="${FORGEJO_API_BASE:-https://git.cleverthis.com/api/v1}"
echo " forgejo target: $TARGET_OWNER/$TARGET_REPO (API: $TARGET_API)"
if [[ "$TARGET_OWNER/$TARGET_REPO" != "cleveragents/cleveragents-core" ]]; then
echo " ⚠ NON-DEFAULT TARGET — sentinel/fork mode"
fi
echo " phase filter: $phase_filter"
echo " fail mode: $([[ $fail_fast -eq 1 ]] && echo fail-fast || echo keep-going)"
echo "=========================================================================="
echo
# ─── Phase 0 — Unit tests ─────────────────────────────────────────────────
echo "${C_BOLD}Phase 0 — unit tests${C_OFF}"
run_step "0.1" "0" "pytest tests/auto_agents" \
"all auto-agents pipeline unit tests" \
"$PY" -m pytest -q tests/auto_agents
# ─── Phase 1 — Read-only smoke tests ──────────────────────────────────────
echo
echo "${C_BOLD}Phase 1 — read-only smoke tests against production Forgejo${C_OFF}"
needs_token() {
if [[ -z "${GITEA_TOKEN:-}" ]]; then return 1; fi
return 0
}
# 1.1 forgejo_audit.py — read-only audit, JSON output.
if needs_token; then
run_step "1.1" "1" "forgejo_audit.py" \
"Tier 0A: branch protection, bot identity, merge config" \
"$PY" tools/forgejo_audit.py --format json
else
skip_step "1.1" "1" "forgejo_audit.py" "needs GITEA_TOKEN"
fi
# 1.2 audit_branch_protection.py — dismiss_stale_approvals audit (no --apply).
if needs_token; then
run_step "1.2" "1" "audit_branch_protection.py" \
"Tier 1A: dismiss_stale_approvals state (read-only)" \
"$PY" tools/audit_branch_protection.py --format json
else
skip_step "1.2" "1" "audit_branch_protection.py" "needs GITEA_TOKEN"
fi
# 1.3 merge_drive.py --status — driver config + heartbeat snapshot.
if needs_token; then
run_step "1.3" "1" "merge_drive.py --status" \
"driver config dump (no API mutations, no lock acquired)" \
"$PY" tools/merge_drive.py --status
else
skip_step "1.3" "1" "merge_drive.py --status" "needs GITEA_TOKEN"
fi
# 1.4a Seed the verify_invariant cursor on first run so 1.4 audits a
# well-defined window (current master HEAD onward) instead of the
# last 50 historical commits — those pre-date the new merge
# invariant and would surface as expected-but-noisy violations.
# Idempotent: SKIPped on every subsequent run.
# Mirror the per-repo cursor partitioning in tools/verify_invariant.py so
# fork-mode preflight resolves to the fork's own cursor file. Default
# canonical cleveragents/cleveragents-core retains the unsuffixed name for
# backward compatibility.
__VC_OWNER="${FORGEJO_OWNER:-cleveragents}"
__VC_REPO="${FORGEJO_REPO:-cleveragents-core}"
if [[ "$__VC_OWNER" == "cleveragents" && "$__VC_REPO" == "cleveragents-core" ]]; then
VERIFY_CURSOR_PATH="$REPO_ROOT/tools/.cache/verify_invariant_cursor.txt"
else
__VC_SAFE_OWNER="$(printf '%s' "$__VC_OWNER" | tr -c 'a-zA-Z0-9._-' '-')"
__VC_SAFE_REPO="$(printf '%s' "$__VC_REPO" | tr -c 'a-zA-Z0-9._-' '-')"
VERIFY_CURSOR_PATH="$REPO_ROOT/tools/.cache/verify_invariant_cursor.${__VC_SAFE_OWNER}.${__VC_SAFE_REPO}.txt"
fi
if needs_token; then
if [[ -f "$VERIFY_CURSOR_PATH" ]]; then
skip_step "1.4a" "1" "verify_invariant.py --seed-cursor" \
"cursor already exists; first-run-only step"
else
run_step "1.4a" "1" "verify_invariant.py --seed-cursor" \
"first-run: pin cursor to current master HEAD" \
"$PY" tools/verify_invariant.py --seed-cursor --format json
fi
else
skip_step "1.4a" "1" "verify_invariant.py --seed-cursor" "needs GITEA_TOKEN"
fi
# 1.4 verify_invariant.py --mode observe --dry-run.
if needs_token; then
run_step "1.4" "1" "verify_invariant.py (observe, dry-run)" \
"Tier 2E: master-window audit, log-only" \
"$PY" tools/verify_invariant.py --mode observe --dry-run --format json
else
skip_step "1.4" "1" "verify_invariant.py" "needs GITEA_TOKEN"
fi
# 1.5 flag_stale_prs.py --dry-run.
if needs_token; then
run_step "1.5" "1" "flag_stale_prs.py --dry-run" \
"Tier 1C: stale-PR planner, no labels written" \
"$PY" tools/flag_stale_prs.py --dry-run --format json
else
skip_step "1.5" "1" "flag_stale_prs.py" "needs GITEA_TOKEN"
fi
# 1.6 setup_auto_labels.py --dry-run.
if needs_token; then
run_step "1.6" "1" "setup_auto_labels.py --dry-run" \
"Tier 0B label inventory diff (no creations)" \
"$PY" tools/setup_auto_labels.py --dry-run --format json
else
skip_step "1.6" "1" "setup_auto_labels.py" "needs GITEA_TOKEN"
fi
# 1.7 setup_branch_protection.py --dry-run.
if needs_token; then
run_step "1.7" "1" "setup_branch_protection.py --dry-run" \
"Tier 0C push whitelist check (no PATCH)" \
"$PY" tools/setup_branch_protection.py --dry-run --format json
else
skip_step "1.7" "1" "setup_branch_protection.py" "needs GITEA_TOKEN"
fi
# 1.8 migrate_to_new_driver.py --dry-run.
if needs_token; then
run_step "1.8" "1" "migrate_to_new_driver.py --dry-run" \
"Tier 0B.5 claim/branch sweep plan (no mutations)" \
"$PY" tools/migrate_to_new_driver.py --dry-run --format json
else
skip_step "1.8" "1" "migrate_to_new_driver.py" "needs GITEA_TOKEN"
fi
# 1.9 list_prs.ts --exclude-claimed (TS smoke; optional).
if [[ $skip_ts -eq 1 ]]; then
skip_step "1.9" "1" "list_prs.ts --exclude-claimed" "skipped via --skip-ts"
elif ! command -v npx >/dev/null 2>&1; then
skip_step "1.9" "1" "list_prs.ts --exclude-claimed" "npx not available"
elif ! needs_token; then
skip_step "1.9" "1" "list_prs.ts --exclude-claimed" "needs GITEA_TOKEN"
else
FORGEJO_URL="${FORGEJO_URL:-https://git.cleverthis.com}"
FORGEJO_OWNER="${FORGEJO_OWNER:-cleveragents}"
FORGEJO_REPO="${FORGEJO_REPO:-cleveragents-core}"
run_step "1.9" "1" "list_prs.ts --exclude-claimed" \
"TS Tier 2B filter; reads PRs, no writes" \
npx --yes tsx \
.opencode/skills/auto-agents-system/scripts/list_prs.ts \
--url "$FORGEJO_URL" --pat "$GITEA_TOKEN" \
--owner "$FORGEJO_OWNER" --repo "$FORGEJO_REPO" \
--exclude-claimed --state open
fi
finalize_and_exit