#!/usr/bin/env bash # The one gate. Every fast tree check CI runs, in one script, so the local gate and CI can never drift: the `site` job # of .github/workflows/ci.yml calls `tools/ci/pre-push.sh --ci`, and the pre-push hook (tools/ci/install-hooks.sh) calls # `tools/ci/pre-push.sh --hook` before any push to master or a release-* branch and refuses the push on red. # # tools/ci/pre-push.sh # the full gate over this working tree (any worktree; reads the tree, writes nothing in it) # tools/ci/pre-push.sh --ci # the same, with the site built in place (a CI checkout is disposable) # tools/ci/pre-push.sh --hook # from .git/hooks/pre-push: full gate for master and release-*; the light gate for every # # other ref: the two structural checks (conflict markers, Windows paths) and the two # # never-push classes (the no-secrets check, the identity grep), about 20 s on the Mac # tools/ci/pre-push.sh --self-test # the runner fires on a known failure, passes a known success, the hook picks the # # right gate from the ref lines, and the light gate carries the never-push classes # tools/ci/pre-push.sh --list # the check names, one per line # # What is NOT here, on purpose: the three compile-or-compute jobs (igneum-pow tests, igneum-census, the simulators) and # the live public API check (master only, a network call), which stay separate steps in ci.yml. Everything here finished # in under 25 s on the Mac on 6 October 2026 (no-secrets 10 s, everything else under 3 s each). # # Local mode never writes into the worktree: the site is built in a temporary copy with SITE_DOWNLOADS_OFFLINE=1 # (063bbca, 6 October 2026: a hook that built in place rewrote the downloads snapshot in five worktrees). The link, ledger # and identity checks then read the committed pages; CI builds in place and checks the rebuilt pages, the one difference. set -uo pipefail cd "$(git rev-parse --show-toplevel)" || exit 1 # git hands a hook its own repository through the environment (GIT_DIR, GIT_INDEX_FILE, GIT_PREFIX, ...). Left in place, # every nested git inside the self-tests (remote-run.sh builds a mirror and pushes into it) would act on THIS repository # and fire this hook again inside the fixture: the first master push through the gate died that way (6 October 2026). unset GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_COMMON_DIR GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_QUARANTINE_PATH GIT_PUSH_OPTION_COUNT MODE="${1:-local}"; MODE="${MODE#--}" RED=0; N=0; LOG="$(mktemp)"; trap 'rm -rf "$LOG" "${SITE_TMP:-}"' EXIT T0=$(date +%s) run() { # run : one line per check; the output of a red check is shown in full local name="$1"; shift; N=$((N + 1)) local s=$(date +%s) if "$@" >"$LOG" 2>&1; then printf ' ok %3ds %s\n' "$(( $(date +%s) - s ))" "$name" else printf ' RED %3ds %s\n' "$(( $(date +%s) - s ))" "$name"; sed 's/^/ /' "$LOG" | cut -c1-240; RED=1 fi } run_quiet() { "$@" >/dev/null 2>&1; } # In place ONLY inside GitHub Actions (a disposable checkout); a `--ci` run on a lane's Mac builds in the temporary copy like the hook, # because the in-place build rewrote four tracked site files (bench.html, index.html, journey.html, journey.json) in the running # worktree and every lane had to discard them before a merge (the horizon lane, 7 October 2026). --self-test proves the tree is # unchanged after a site build outside Actions. site_in_place() { [ "$MODE" = ci ] && [ "${GITHUB_ACTIONS:-}" = true ]; } site_build() { if site_in_place; then node site/build.mjs; return; fi SITE_TMP="$(mktemp -d)"; cp -R site "$SITE_TMP/site" (cd "$SITE_TMP/site" && SITE_DOWNLOADS_OFFLINE=1 node build.mjs) } overlap_sweep() { # tools/ci/overlap-check.mjs: the known-failed fixture first, then the built site (the gate's temporary copy locally, the tree in # CI). A browser is needed: CI installs Playwright in the workflow; a machine without one ships the pages to a build box # (nothing heavy on the Mac). IGNEUM_OVERLAP_APPS=1 adds the miner and wallet UIs through their mocks (slower, the box). local dir="site"; [ "$MODE" = ci ] || dir="$SITE_TMP/site" if [ "${IGNEUM_OVERLAP_APPS:-0}" = 1 ]; then node tools/ci/overlap-check.mjs --self-test --site "$dir" --apps .; else node tools/ci/overlap-check.mjs --self-test --site "$dir"; fi } structural_checks() { run "no conflict markers in tracked files" bash tools/ci/no-conflict-markers.sh run "every tracked path is valid on Windows (colon, trailing dot, reserved names, length)" bash tools/ci/windows-paths-check.sh } never_push_checks() { # The two never-push classes, on EVERY ref (7 October 2026: three gate summaries on ca3-v4-node carried a 64-hex key # through eight red CI runs in 80 minutes; the feature-branch hook ran only the structural checks, so no lane saw it). # A secret or an identity leak must not reach the remote on any branch; together about 20 s on the Mac. run "identity grep of the public export list and the served site" bash tools/ci/identity-check.sh run "no secret file names and no 64-hex secrets in the tree" bash -c 'bash tools/ci/no-secrets-check.sh --self-test && bash tools/ci/no-secrets-check.sh' } tree_checks() { run "site build (in a temporary copy here, in place only inside GitHub Actions)" site_build run "internal link check of site/*.html" node tools/ci/link-check.mjs run "every served page carries the slim bar (mark, Mine, Network, Learn, Download) with every route in its panels and the sheet (self-test, then the tree)" bash -c 'node tools/ci/site-nav-check.mjs --self-test && node tools/ci/site-nav-check.mjs' run "vendor marks: site/lib/marks.mjs is brand/marks/vendor-marks.mjs byte for byte (the app and the site draw one set)" cmp brand/marks/vendor-marks.mjs site/lib/marks.mjs run "the phone menu opens and is seen at 390 px on every page (self-test first; needs the box or CI browser, says so without one)" node tools/site/sheet-test.mjs --self-test run "ledger sentences present verbatim on their public pages" node tools/ci/ledger-text-check.mjs never_push_checks run "shell inside .github/workflows parses (bash -n, the PowerShell 5.1 rule)" node tools/ci/check-workflow-shell.mjs run "PowerShell drive-reference check (\$name: in a double-quoted string)" bash tools/ci/ps-drive-ref-check.sh run "copied sources are re-stamped before a build" bash tools/ci/copied-sources-check.sh run "override params files parse with no duplicate key" bash tools/ci/override-json-check.sh run "second-engine playbooks log to a file and end their tree (C35)" bash tools/ci/second-engine-check.sh run "no playbook quits, pauses or resumes the installed app" bash -c 'bash tools/ci/playbook-quit-check.sh --self-test && bash tools/ci/playbook-quit-check.sh' run "no script writes into another worktree or walks Projects" bash -c 'bash tools/ci/no-foreign-tree-writes.sh --self-test && bash tools/ci/no-foreign-tree-writes.sh' run "the signer is never piped into head" bash tools/ci/signer-pipe-check.sh run "bash bodies in PowerShell job scripts pass bash -n" bash -c 'bash tools/ci/bash-body-check.sh --self-test && bash tools/ci/bash-body-check.sh' run "run jobs test their fetched kit before use" bash -c 'bash tools/ci/kit-path-check.sh --self-test && bash tools/ci/kit-path-check.sh' run "every Windows spawn of the app runs with a hidden console" bash -c 'node tools/ci/windows-spawn-check.mjs --self-test && node tools/ci/windows-spawn-check.mjs' run "pinned guest programs match their manifest" bash tools/ci/pinned-guests-check.sh run "root prover playbooks kill the GPU server and unlink its socket" bash tools/ci/prover-socket-check.sh run "commit-string gate self-test" bash tools/ci/commit-string-check.sh --self-test run "build server remote checkout self-test" bash infra/build-server/remote-run.sh --self-test run "a slot holder keeps its own line for the whole run (the watcher-trust rule)" bash infra/build-server/remote-run.sh --self-test-keeper run "the remote checkout resets the mirror's tree before the branch checkout (the stale-overlay class)" bash -c 'bash tools/ci/mirror-reset-check.sh --self-test && bash tools/ci/mirror-reset-check.sh' run "the remote checkout's clean spares a lane's scratch (.igneum-scratch-spare, the fixed prefixes, never -x; the lost-scratch class)" bash -c 'bash tools/ci/scratch-spare-check.sh --self-test && bash tools/ci/scratch-spare-check.sh' run "long-running tools keep their body in one parsed block (the edited-while-running class)" bash -c 'bash tools/ci/whole-body-check.sh --self-test && bash tools/ci/whole-body-check.sh' run "build-remote without a priority flag bounds suites and benches (nice 10, 32 cores); a gate runs unbounded" bash tools/ci/build-kind-default-check.sh run "the class router is a preference with spill-over (a held or overloaded box hands the job to the other one)" bash tools/ci/route-spill-check.sh run "per-core leases, the quiet class and the reaper pass on the box (lease.sh and remote-run.sh self-tests over ssh)" bash tools/ci/box-locks-check.sh $( [ "$MODE" = ci ] && echo --ci ) run "the simulators job runs on master and release-* pushes and pull requests into them only" bash tools/ci/sims-branch-check.sh run "no shell assignment hides behind a trailing comment (the swallowed-defaults class)" bash -c 'bash tools/ci/defaults-line-check.sh --self-test && bash tools/ci/defaults-line-check.sh' run "no script kills or finds a process by a plain name or a file name (pgrep/pkill -f literals, ps | grep)" bash -c 'bash tools/ci/kill-by-name-check.sh --self-test && bash tools/ci/kill-by-name-check.sh' run "the identity check's own self-test (excluded research path passes, exported leak fails)" bash tools/ci/identity-check.sh --self-test run "the Windows paths check's own self-test" bash tools/ci/windows-paths-check.sh --self-test run "the red watcher's own self-test (one line per run, posted once)" node tools/ci/red-watch.mjs --self-test run "faucet unit tests" node --test site/api/faucet.test.mjs run "redesign package data tests (the /api/live contract the pages read)" node tools/site-redesign/tests/data-tests.cjs run "the home hero's loop never idles in view, stops hidden, resumes without a jump" node tools/site-redesign/tests/hero-loop-test.cjs run "chain scene: the site's and the app's copies are scene/ byte for byte, the palette tokens live once (self-test, then the tree)" bash -c 'node tools/scene/sync.mjs --self-test && node tools/scene/sync.mjs --check' run "chain scene: a push paints with the document hidden and no animation frame (the blank /live of 7 Oct 2026; known-failed first)" node tools/scene/paint-test.cjs run "chain scene: the live feed contract (the recorded reply validates; a rewritten miner, a float now, a stray key refused)" node --test tools/scene/feed-contract.test.mjs run "chain scene parity: one recorded feed through the home fold, /live and the app's Inspect view on build-2, three frames each pixel-equal apart from the app's own-key overlay (a changed token fails first; skipped with no box and no Playwright)" bash tools/scene/parity-remote.sh run "no text overlaps: every served page at 390 to 1600 px, light and dark, the hero at each step (self-test first; IGNEUM_OVERLAP_APPS=1 adds the miner and wallet UIs)" overlap_sweep run "explorer, emission and public stats unit tests" node --test site/lib/explorer.test.mjs site/lib/emission.test.mjs site/api/public-stats.test.mjs run "ship tool self-test" node tools/ship-app.mjs --self-test run "relay unit tests" node --test relay/test/parse.test.mjs relay/test/auth.test.mjs relay/test/wake.test.mjs relay/test/ember.test.mjs run "miner app notice strip and update card tests" node --test app/igneum-app/ui/notices.test.mjs app/igneum-app/ui/update-card.test.mjs app/igneum-app/ui/view.test.mjs app/igneum-app/ui/tune-line.test.mjs run "launch gates: every row with its check, the handoff text clean (self-test, then the tree)" bash -c 'node tools/ci/launch-gates-check.mjs --self-test && node tools/ci/launch-gates-check.mjs' run "income per tier: the public table equals its inputs, the schedule arithmetic" bash -c 'node tools/launch/income-tiers.mjs --check && node --test tools/launch/income-tiers.test.mjs' run "hash-origin report: a known-finished day and a known-failed day" node --test tools/observer/hash-origin.test.mjs run "harness summaries never carry a raw 64-hex key (the writer's own redaction and check)" node infra/fast-time/lib/redact-keys.mjs --self-test run "docs-only pushes skip the compile-or-compute CI jobs (the changes job's classifier)" bash tools/ci/docs-only-check.sh --self-test } gated_refs() { # stdin: the pre-push hook's lines " ". Prints "full" when any remote # ref is master or release-*, else "light". local lref lsha rref rsha full=0 while read -r lref lsha rref rsha; do case "$rref" in refs/heads/master|refs/heads/release-*) full=1 ;; esac done [ "$full" = 1 ] && echo full || echo light } # The green stamp (main, 7 October 2026, the push-race class: a 100 s gate on the merge commit against a master that moves every # minute starved every merge, six rejections in a row). A full gate that ends GREEN over a CLEAN tree records the commit it ran on # in .git/igneum-gate-green/ (the repository's own .git, shared by its worktrees). The hook then lets a MERGE commit through # on the light gate when its first parent is exactly the remote tip being replaced (nothing unknown underneath) and its second # parent carries a stamp younger than 12 hours: the branch's own gate was green on that commit, master's tip was green by its # CI, and CI runs the same full gate on the merge the moment it lands (ci.yml), which is the backstop for the union. # tools/ci/merge-to-master.sh is the merge tool that uses it; its merge message names the stamped commit. stamp_dir() { local g; g=$(git rev-parse --git-common-dir 2>/dev/null) || return 1; ( cd "$g" 2>/dev/null && printf '%s/igneum-gate-green' "$(pwd -P)" ); } # absolute: a worktree's answer is relative stamp_green() { # [repo dir]: after a GREEN full gate; only when no tracked file differs from HEAD (a dirty tree would lie) local d="${1:-.}" sha dir [ -z "$(git -C "$d" status --porcelain --untracked-files=no 2>/dev/null)" ] || return 0 sha=$(git -C "$d" rev-parse HEAD 2>/dev/null) || return 0; dir=$(cd "$d" && stamp_dir); mkdir -p "$dir" 2>/dev/null || return 0 date -u +%Y-%m-%dT%H:%M:%SZ > "$dir/$sha" 2>/dev/null && echo " (green stamp recorded for ${sha:0:8})" } deferred_merge() { # [repo dir] -> "defer " or "full " local lsha="$1" rsha="$2" d="${3:-.}" parents p1 p2 dir f age parents=$(git -C "$d" rev-list --parents -n 1 "$lsha" 2>/dev/null | cut -d' ' -f2-) || { echo "full unknown commit"; return; } set -- $parents; p1="${1:-}"; p2="${2:-}"; [ -n "$p2" ] && [ -z "${3:-}" ] || { echo "full not a two-parent merge"; return; } [ "$p1" = "$rsha" ] || { echo "full first parent ${p1:0:8} is not the remote tip ${rsha:0:8}"; return; } dir=$(cd "$d" && stamp_dir); f="$dir/$p2"; [ -f "$f" ] || { echo "full no green stamp for ${p2:0:8}"; return; } age=$(( $(date +%s) - $(stat -f %m "$f" 2>/dev/null || stat -c %Y "$f") )); [ "$age" -le 43200 ] || { echo "full the stamp for ${p2:0:8} is $age s old"; return; } echo "defer $p2" } finish() { local what="$1" secs=$(( $(date +%s) - T0 )) if [ "$RED" = 0 ]; then echo "pre-push gate ($what): GREEN, $N checks in ${secs}s"; [ "${STAMP:-0}" = 1 ] && stamp_green; exit 0; fi echo "pre-push gate ($what): RED after $N checks in ${secs}s. Fix it before pushing (every check above is one CI runs; the same script runs there)." >&2 exit 1 } case "$MODE" in self-test) fails=0 st="$(mktemp)" # run in this shell, not a $(...) subshell, so RED is visible here run "known failure" false >"$st" 2>&1; out="$(cat "$st")"; case "$out" in *"RED"*"known failure"*) ;; *) echo "self-test failed: a failing check was not reported RED"; fails=1 ;; esac [ "$RED" = 1 ] || { echo "self-test failed: a failing check did not set RED"; fails=1; } RED=0 run "known success" true >"$st" 2>&1; out="$(cat "$st")"; rm -f "$st"; case "$out" in *"ok"*"known success"*) ;; *) echo "self-test failed: a passing check was not reported ok"; fails=1 ;; esac [ "$RED" = 0 ] || { echo "self-test failed: a passing check set RED"; fails=1; } [ "$(printf 'refs/heads/x 1 refs/heads/master 2\n' | gated_refs)" = full ] || { echo "self-test failed: a push to master did not select the full gate"; fails=1; } [ "$(printf 'refs/heads/x 1 refs/heads/release-0.3.15 2\n' | gated_refs)" = full ] || { echo "self-test failed: a push to release-0.3.15 did not select the full gate"; fails=1; } [ "$(printf 'refs/heads/x 1 refs/heads/x 2\n' | gated_refs)" = light ] || { echo "self-test failed: a push to a feature branch selected the full gate"; fails=1; } [ "$(printf '' | gated_refs)" = light ] || { echo "self-test failed: a push with no refs selected the full gate"; fails=1; } # the light gate carries the two never-push classes beside the structural checks, and the full gate runs them too declare -f never_push_checks | grep -q 'tools/ci/no-secrets-check.sh' || { echo "self-test failed: the never-push checks do not run the no-secrets check"; fails=1; } declare -f never_push_checks | grep -q 'tools/ci/identity-check.sh' || { echo "self-test failed: the never-push checks do not run the identity grep"; fails=1; } grep -qE '^\s+structural_checks; never_push_checks; finish "feature branch"' "$0" || { echo "self-test failed: the hook's light gate does not run the never-push checks"; fails=1; } # the green stamp and the deferral, in a fixture repository: a merge of a stamped branch onto the remote tip defers; an # unstamped branch, a stale stamp, a merge onto an older tip and a plain commit all take the full gate fx=$(mktemp -d); ( cd "$fx" && git init -q -b master . && git -c user.name=t -c user.email=t@t commit -q --allow-empty -m a ) 2>/dev/null A=$(git -C "$fx" rev-parse HEAD); git -C "$fx" checkout -q -b b; git -C "$fx" -c user.name=t -c user.email=t@t commit -q --allow-empty -m b; B=$(git -C "$fx" rev-parse HEAD) git -C "$fx" checkout -q master; git -C "$fx" -c user.name=t -c user.email=t@t merge -q --no-ff -m "merge b" b; M=$(git -C "$fx" rev-parse HEAD) [ "$(deferred_merge "$M" "$A" "$fx")" = "full no green stamp for ${B:0:8}" ] || { echo "self-test failed: an unstamped branch merge was not sent to the full gate: $(deferred_merge "$M" "$A" "$fx")"; fails=1; } ( cd "$fx" && git checkout -q "$B" && stamp_green . >/dev/null && git checkout -q master ) [ "$(deferred_merge "$M" "$A" "$fx")" = "defer $B" ] || { echo "self-test failed: a stamped branch merge onto the remote tip was not deferred: $(deferred_merge "$M" "$A" "$fx")"; fails=1; } [ "$(deferred_merge "$M" "$B" "$fx")" = "full first parent ${A:0:8} is not the remote tip ${B:0:8}" ] || { echo "self-test failed: a merge onto another tip was deferred"; fails=1; } [ "$(deferred_merge "$B" "$A" "$fx")" = "full not a two-parent merge" ] || { echo "self-test failed: a plain commit was deferred"; fails=1; } touch -t 202001010000 "$(cd "$fx" && stamp_dir)/$B"; case "$(deferred_merge "$M" "$A" "$fx")" in "full the stamp for ${B:0:8} is "*) ;; *) echo "self-test failed: a stale stamp was honoured"; fails=1 ;; esac ( cd "$fx" && echo x > dirty && git add dirty && git -c user.name=t -c user.email=t@t commit -q -m d && echo y > dirty && stamp_green . | grep -q recorded ) && { echo "self-test failed: a dirty tree was stamped"; fails=1; } rm -rf "$fx" # a --ci site build outside GitHub Actions leaves the tracked site files as they are (the horizon lane's four rewritten files) before=$(git status --porcelain -- site | sort); ( MODE=ci GITHUB_ACTIONS= site_build >/dev/null 2>&1 ); after=$(git status --porcelain -- site | sort) [ "$before" = "$after" ] || { echo "self-test failed: a --ci site build outside GitHub Actions changed tracked site files: $(git status --porcelain -- site | tr '\n' ' ')"; fails=1; } MODE=ci GITHUB_ACTIONS= site_in_place && { echo "self-test failed: --ci outside GitHub Actions chose the in-place build"; fails=1; } MODE=ci GITHUB_ACTIONS=true site_in_place || { echo "self-test failed: --ci inside GitHub Actions did not choose the in-place build"; fails=1; } declare -f tree_checks | grep -q 'never_push_checks' || { echo "self-test failed: the full gate does not run the never-push checks"; fails=1; } [ "$fails" = 0 ] && echo "self-test passed: a failing check is RED and fails the gate, a passing one is ok; master and release-* select the full gate, other refs the light one; a merge of a green-stamped branch onto the remote tip defers to CI, every other shape takes the full gate; a --ci site build outside GitHub Actions leaves the tree unchanged (structural checks, no-secrets, identity grep)" exit $fails ;; list) grep -E '^\s+run "' "$0" | sed -E 's/^\s+run "([^"]+)".*/\1/' ;; hook) REFS="$(cat)"; which="$(printf '%s\n' "$REFS" | gated_refs)" if [ "$which" = full ]; then # a merge of a green-stamped branch onto the exact remote tip goes through on the light gate (CI runs the full one) verdict=""; while read -r lref lsha rref rsha; do case "$rref" in refs/heads/master|refs/heads/release-*) verdict=$(deferred_merge "$lsha" "$rsha"); break ;; esac; done <<<"$REFS" case "$verdict" in defer*) echo "pre-push gate: a merge of green-stamped ${verdict#defer } onto the remote tip: the light gate here, the full gate in CI on landing:" structural_checks; never_push_checks; finish "merge of a green branch (full gate deferred to CI)" ;; *) echo "pre-push gate: a push to master or release-*, the full gate (the same checks CI runs)${verdict:+ ($verdict)}:" STAMP=1; structural_checks; tree_checks; finish "push to master or release-*" ;; esac else echo "pre-push gate: a feature branch, the light gate (the two structural checks, the no-secrets check, the identity grep):" structural_checks; never_push_checks; finish "feature branch" fi ;; ci|local) [ "$MODE" = ci ] && echo "pre-push gate in CI (the same script as the local hook):" || echo "pre-push gate over this working tree:" [ "$MODE" = local ] && STAMP=1; structural_checks; tree_checks; finish "$MODE" ;; *) echo "usage: tools/ci/pre-push.sh [--ci|--hook|--self-test|--list]" >&2; exit 2 ;; esac