igneum/tools/ci/pre-push.sh
igneum-labs 0f539f8b39 site-ui-5: the padding and visuals sweep (7 October 2026, 17:xx UK): the /wallet band's padding, tools/ci/padding-check.mjs in the gate, every finding fixed in site.css, the eye pass
the project lead: the ember band on /wallet had no vertical padding (the heading on the band's top edge, the button on its bottom), then
"run a full site sweep for padding and visuals".

- The band: .band .wrap carried the padding and the three bands use .container; both selectors now, 56 px (40 on phones), and
  24 px at the sides on phones.
- tools/ci/padding-check.mjs: renders every served page at 390, 768, 1024, 1440 and 1600 in both themes and flags TIGHT (text or
  a control under 24 px from the edge of the filled band, card or section that holds it; rows of radius 12 and under are the
  app's compact rows and keep their own padding; small filled things such as buttons, pills, badges, tabs, cells and the bar are
  never containers), SCALE (a section's vertical padding not 0 and not a multiple of 4 from 24), TOUCH (controls under 6 px
  apart outside segmented groups), OVERRUN (an image or canvas past its frame) and UNDER-BAR (a heading under the sticky bar on
  load, an anchor the bar would cover). --self-test builds the fixture from the site's own stylesheet and bar with the exact
  /wallet band with its padding lost, a touching pair, an image over its frame and a heading under the bar; each must fail,
  the clean twin must pass. A gate line runs it (self-test, then the built site); where no browser exists it says so.
- Findings fixed (first run 2,740, then 1,040, then 48, now 0 at 230 renders): page heroes 64/48 (were 65/45), the
  observatory head and the 404 the same, the footer's bottom 24, doc sections and the litepaper's 40 (were 42), .sec 72 at
  tablet (was 70), the 404 card 36, /live's motion block 24 (was 25), the scenes lab 48 (was 50); every card at 24 inside
  (download cards, home tiles, the live tiles and stats, the evidence labels, the wallet state cards, the journey phases, the
  live inspector and toolbar, the download card on phones).
- The eye pass over every page at 1440 and 390 dark: the journey's phases are cards now, so the old timeline rail and marker
  (drawn for an unboxed list, sitting on the card's top border) go and the when column keeps clear of the body; tables keep
  words whole (break-word, not anywhere: "regenerat es" on the litepaper) and give the label column 14ch (one word per line on
  /randomx); the litepaper's body paragraphs share one measure; the ledger's count-table chips stay on one line; orphan words
  go through text-wrap: pretty on body text (copy law 18, "measurement." alone on /claims); the dev-fee page lost its doubled
  gutter (a lifted section keeps its own .container) and its table clears the text above; /faucet and /metamask keep a gap
  between headline and lead, line their two cards up at the top and end 72 px above the footer; a heading after a card grid
  gets 40 px.
- The overlap sweep at the same five widths and both themes: 0 overlaps at 290 renders.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 16:30:58 +00:00

231 lines
23 KiB
Bash
Executable file

#!/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 <name> <command...>: 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 "padding and visuals: text 24 px from every band, card and section edge, section padding on the scale, no touching controls, media inside its frame, no heading under the bar, at 390 to 1600 px both themes (known-failed fixture first)" bash -c 'node tools/ci/padding-check.mjs --self-test && node tools/ci/padding-check.mjs --site "${SITE_TMP:-.}/site"'
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 "<local ref> <local sha> <remote ref> <remote sha>". 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/<sha> (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() { # <local sha> <remote sha> [repo dir] -> "defer <branch sha>" or "full <reason>"
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