core/scripts/ci/local-ci.sh
Claude 294bbe7b9b
feat(ci,tests): PR-4b — promote teaching onto the gate, and close pin 3's own blind spot
The reachability gap turns by PROMOTION for the first time: 15 -> 14. PR-3b's
earlier turn was by deletion, which is free. This one costs 22.1s on every push,
which is exactly why it is a decision rather than a default.

THE RECOMMENDATION WAS RE-DERIVED, NOT EXECUTED — and it did not survive as
written. PR-4b recommended `teaching` on the strength of G-22:
test_ratification_ceremony.py was red on main, in `teaching`, run by no gate.
That hole was already closed a different way — G-22's fix promoted that single
file into smoke. So `teaching` would have added 9 net-new files, NONE with a
demonstrated escape, and promoting on a justification that has already been
spent is precisely the failure this arc exists to close.

Promoted on a stronger, doctrinal basis instead.
tests/test_epistemic_invariants.py enforces AGENTS.md's "Teaching and mutation
safety" non-negotiables in substance — INV-22/INV-23: an unmarked proposal and
an unmarked reviewed example default to SPECULATIVE, the status enum has exactly
its four positions, a pack-mutation proposal carries no hardening flag. Durable
corpus mutation is the one irreversible thing in this system, and the suite
guarding it ran on no pre-merge gate. That justification does not require an
incident to have happened yet, which makes it the better one.

Measured: 22.1s / 122 tests across 9 net-new files (the tenth was already on the
gate). Both invocation sites updated, GATE_SUITES extended, `teaching` removed
from UNREACHABLE_BASELINE in the same edit, ratchet lowered <= 15 to <= 14 —
the both-directions discipline that has caught this author twice.

A HOLLOW SPOT IN PIN 3 ITSELF, found by sabotage-testing the promotion rather
than trusting it. _suites_invoked_by_gate_tiers() takes the UNION of
scripts/hooks/pre-push and scripts/ci/local-ci.sh, so a suite present in either
satisfies it. Removing `teaching` from local-ci.sh alone PASSED SILENTLY: the
two scripts could drift from each other invisibly while local-ci.sh states in
prose that it runs "the same four steps as scripts/hooks/pre-push". A developer
running one would get coverage the other advertises and does not deliver.

Closed by test_the_two_gate_scripts_invoke_the_same_suites, scoped to the
numbered (n/m) gate steps — a discriminator both files already use — so it reads
the gate tier without parsing shell control flow. The identical sabotage now
fires. Verified in three directions: removed from one script (fires, and did not
before), removed from both (the pre-existing assertion fires), restored (green).

THIS IS NOT THE CI-PARITY PIN N-9 KILLED, and the distinction is recorded at the
assertion site so it cannot be re-litigated a third time. That one asserted
TEST_SUITES["smoke"] == smoke.yml and was reverted in 50fa287d because
AGENTS.md:280 makes GitHub Actions billing-locked dead signals — parity with a
dead signal buys nothing. Both scripts here are LIVE local merge bars.

Gate is now four steps: smoke + warmed_session + deductive + teaching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wcw2pnMBwyvmNyQg4uPEt4
2026-07-28 08:12:26 +00:00

186 lines
6.8 KiB
Bash
Executable file

#!/bin/sh
# CORE local-first CI runner.
#
# AGENTS.md: "The real CI is local-first. Run validation in the worktree...
# Do not treat remote Actions / Docker job containers as the merge gate."
# This script is that runner — one entry point for the gate tiers, usable on
# any host, with an honest statement of which interpreter produced the result.
#
# sh scripts/ci/local-ci.sh # gate tier (what pre-push runs)
# sh scripts/ci/local-ci.sh --tier smoke
# sh scripts/ci/local-ci.sh --tier full # the whole tests/ tree, parallel
# sh scripts/ci/local-ci.sh --list
#
# WHY THIS EXISTS
# ---------------
# `pyproject.toml` pins `requires-python == "3.12.13"` — an EXACT patch
# version. On a host that does not have precisely 3.12.13, `uv sync --locked`
# fails outright and every gate becomes unrunnable. For a project whose merge
# bar IS the local run, an unrunnable local gate is the failure mode that
# matters most: it is how `tests/test_lane_sha_verifier.py` sat red on clean
# main from the deduction-serve arc until 2026-07-25 with nobody noticing.
#
# THE INTERPRETER CONTRACT (the part to read before trusting a green run)
# ----------------------------------------------------------------------
# The pinned interpreter is CANONICAL. A run on any other interpreter is
# NON-CANONICAL and this script says so, loudly, on every such run.
#
# Fallback is opt-in, never automatic — the same discipline as
# `core.ratified_ledger`'s `missing_ok`: a degraded mode is legitimate, and a
# degraded mode that reports itself as the real thing is not. Default is
# fail-closed with instructions.
#
# --allow-interpreter-fallback run on any 3.12.x, stamped NON-CANONICAL
#
# Measured note, so the flag is not cargo-culted: on 2026-07-25 the full
# committed-SHA / trace_hash / content_sha256 / lane-SHA pin set (119 tests)
# was run on 3.12.11 and 118 passed — the single failure was a stale lane
# roster that fails on 3.12.13 too. That is evidence the exact pin is not
# load-bearing for bit-exactness, NOT a ruling that it should be relaxed.
# Relaxing it is a reproducibility decision and belongs to a human.
set -eu
repo_root="$(git rev-parse --show-toplevel)"
cd "${repo_root}"
TIER="gate"
ALLOW_FALLBACK=0
PYTEST_EXTRA=""
usage() {
cat <<'EOF'
usage: local-ci.sh [--tier TIER] [--allow-interpreter-fallback] [--list] [-- ARGS]
tiers:
smoke smoke suite only (~1 min) = CI smoke.yml parity
gate smoke + warmed_session + deductive + teaching (~9 min) = the pre-push gate
full the entire tests/ tree, parallel (~10 min) = the merge bar
options:
--allow-interpreter-fallback permit a non-pinned 3.12.x; marks run NON-CANONICAL
--list print the tier definitions and exit
-- pass remaining args through to pytest
EOF
}
while [ $# -gt 0 ]; do
case "$1" in
--tier) TIER="$2"; shift 2 ;;
--tier=*) TIER="${1#*=}"; shift ;;
--allow-interpreter-fallback) ALLOW_FALLBACK=1; shift ;;
--list) usage; exit 0 ;;
-h|--help) usage; exit 0 ;;
--) shift; PYTEST_EXTRA="$*"; break ;;
*) echo "[local-ci] unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
case "${TIER}" in
smoke|gate|full) ;;
*) echo "[local-ci] unknown tier: ${TIER} (smoke|gate|full)" >&2; exit 2 ;;
esac
# --- interpreter resolution ------------------------------------------------
pinned="$(cat .python-version 2>/dev/null || echo '')"
[ -n "${pinned}" ] || pinned="3.12.13"
canonical=0
RUNNER=""
if command -v uv >/dev/null 2>&1 && uv python find "${pinned}" >/dev/null 2>&1; then
canonical=1
RUNNER="uv run --"
echo "[local-ci] interpreter: ${pinned} (CANONICAL — matches the repo pin)"
else
if [ "${ALLOW_FALLBACK}" -eq 0 ]; then
cat >&2 <<EOF
[local-ci] BLOCKED: the pinned interpreter (${pinned}) is not available here.
\`pyproject.toml\` pins requires-python == "${pinned}", so \`uv sync --locked\`
cannot resolve and the canonical gate cannot run on this host.
Either provision it: uv python install ${pinned}
or run degraded, knowingly:
sh scripts/ci/local-ci.sh --tier ${TIER} \\
--allow-interpreter-fallback
A degraded run is legitimate. A degraded run reported as canonical is not,
which is why this is opt-in rather than automatic.
EOF
exit 3
fi
fallback=""
for cand in python3.12 python3; do
if command -v "${cand}" >/dev/null 2>&1 && \
"${cand}" -c 'import sys; sys.exit(0 if sys.version_info[:2]==(3,12) else 1)' 2>/dev/null; then
fallback="${cand}"; break
fi
done
if [ -z "${fallback}" ]; then
echo "[local-ci] BLOCKED: no 3.12.x interpreter found at all." >&2
exit 3
fi
actual="$(${fallback} -c 'import sys; print(".".join(map(str, sys.version_info[:3])))')"
RUNNER="${fallback} -m"
cat >&2 <<EOF
############################################################
# NON-CANONICAL RUN #
# interpreter ${actual} != pinned ${pinned}
# Results are indicative, NOT merge evidence. #
# Re-run on ${pinned} before merging.
############################################################
EOF
fi
# Two invocation shapes, one interface. Suite membership is NEVER restated
# here — it is read from ``core/cli_test.py::TEST_SUITES`` through the CLI, so
# this runner cannot drift from the pre-push gate the way smoke.yml drifted
# from it.
if [ "${canonical}" -eq 1 ]; then
suite() { uv run core test --suite "$1" -q; }
pytest_() { uv run python -m pytest "$@"; }
else
suite() { ${RUNNER} core.cli test --suite "$1" -q; }
pytest_() { ${RUNNER} pytest "$@"; }
fi
step() {
label="$1"; shift
echo "[local-ci] ${label} ..."
if ! "$@"; then
echo "[local-ci] FAILED: ${label}" >&2
exit 1
fi
}
# --- gates -----------------------------------------------------------------
start="$(date +%s)"
case "${TIER}" in
smoke)
step "smoke suite" suite smoke
;;
gate)
echo "[local-ci] tier=gate — same four steps as scripts/hooks/pre-push"
step "(1/4) smoke suite" suite smoke
step "(2/4) warmed_session lane" pytest_ tests/test_warmed_session_lane.py -q --no-header
step "(3/4) deductive suite" suite deductive
step "(4/4) teaching suite" suite teaching
;;
full)
jobs="$(nproc 2>/dev/null || echo 4)"
step "full tests/ tree (-n ${jobs})" \
pytest_ tests/ -q --no-header -p no:cacheprovider -n "${jobs}" --dist loadfile
;;
esac
elapsed=$(( $(date +%s) - start ))
if [ "${canonical}" -eq 1 ]; then
echo "[local-ci] PASSED tier=${TIER} in ${elapsed}s — CANONICAL (${pinned})."
else
echo "[local-ci] PASSED tier=${TIER} in ${elapsed}s — NON-CANONICAL; not merge evidence." >&2
fi