Merge remote-tracking branch 'origin/master' into scene-parity-site

This commit is contained in:
igneum-josh 2026-10-07 14:58:19 +01:00
commit 4f85e94f00
5 changed files with 121 additions and 25 deletions

View file

@ -397,7 +397,7 @@ Self-test PASS on each (FNV-1a 448274a57f508cbc); rates 120 to 142 MH/s with the
| v4-era-4 | dd8fdf6ff4f59eed | 115.2 | yes |
| v4-era-5 | 8bf40f5cb858d835 | 117.1 | yes |
Self-test PASS on every pack (cache FNV 448274a57f508cbc); both rows in the ledger entry 43c5bf5b. G1 FOR SUB-VERSION 2 IS COMPLETE on three platforms (Metal, Linux CUDA, Windows CUDA), nothing owed. Per tier: the 5090 rate on sub-version 2 is the same band as sub-version 1 (115 to 130 MH/s), so a miner's rate does not move with the class amendment. The lines left for byte 7 by 19:00Z: the attack-pass lane's two gates and 10^6 count on 8bdcbdd8, the node lane's re-pin and the pairing. 0.3.21's FIRST GATE LINE on 55768f88 (sha256 279b1b690e854fc9, the string read back; pairing igneum-pow 8c728ca3 at byte 5): the digest gate 13:35:41Z to 13:37:19Z SUMMARY PASS (a89be8a7 on both binaries with the peers; db9a85f9 refused, no peer; the live file's eada4bda unmoved); the ten-minute mixed-version gate from 13:37:40Z, line about 13:50Z. The 0.3.21 order as the shipper sent it: 55768f88; f067f7c1 and 70e4601e; b0444f51; 6eb21fc9; db28d331; then the re-pin from 8bdcbdd8 on the coordinator's word; suites between, the digest read after every one; the mirror's release-0.3.20-node back at the pin c4459193, release-0.3.21-node open at 55768f88. THE LATE-JOIN COMMIT (N9's second half, the node lane): 70e4601e on the box mirror as branch proof-hold-fix, from c4459193, two files (igneum/exec/src/proving.rs, protocol/flows/src/v10/proving.rs); the gap was the fetch side on the joiner (the served record ran the native check against the joiner's trailing exec state before anything was stored, the check refused it, the proof was never held, the body rule read "not held" for 20 s and failed the IBD); the fix holds the proof by hash before the checks (the pool entry still needs them) and the serve side says when it holds fewer than asked; the exec suite 32 passed at 13:26Z with the known-failed shape first, the flows check green 13:28Z, igneumd on build-1 at the 0321 worktree path built 13:32Z, sha256 17649eeb2f7d1290, string read back; with the testnet lane (the resume form, B alone); it joins the 0.3.21 staging as its own commit. THE INTEROP FACT stands from the void run: the 5899f603 hub accepted 235 object-byte-5 blocks from the 8097d600 node with 0 rejected, one digest on all five nodes on the live sixteen-field file. The gates: the digest test and the kaspa-pow vector test (the amended devnet epoch-0 id 1a4230699a6b9c60 must equal, c120d7963abdcd96 must differ, the v3 control unchanged) on the box; the mixed-version Devnet 2 gate (the amended 0.3.20 node beside a 5899f603 node for ten minutes on the live file without the v4 fields) after the Mac build; the fresh-join canary the 0.3.20 cut's |
Self-test PASS on every pack (cache FNV 448274a57f508cbc); both rows in the ledger entry 43c5bf5b. G1 FOR SUB-VERSION 2 IS COMPLETE on three platforms (Metal, Linux CUDA, Windows CUDA), nothing owed. Per tier: the 5090 rate on sub-version 2 is the same band as sub-version 1 (115 to 130 MH/s), so a miner's rate does not move with the class amendment. The lines left for byte 7 by 19:00Z: the attack-pass lane's two gates and 10^6 count on 8bdcbdd8, the node lane's re-pin and the pairing. PC 2 DOWN AGAIN (main, 16:5x UK): Josh takes PC 2 down for cable work (PC 1 back but his desk); both PCs out of the sweep's waves, each updates on its poller on return; no PC job to PC 1; the Windows G1 completed before the outage, nothing reruns. 0.3.21's SECOND GATE LINE on 55768f88 (sha256 279b1b690e854fc9): the ten-minute mixed-version gate beside the 5899f603 pair, 13:37:40Z to 13:47:52Z, SUMMARY PASS (one digest b0afb2ee on five nodes; 223 new and 381 old blocks accepted by the old hub, 0 rejected; counts equal at 319, 486 and 604 through both clean joins and the restart step at 13:45:22Z; no panic); the node lane's two lines on 0.3.21's first candidate complete, in plan 6.9 on ca3-v4-node; the fleet's set on it (the bare-child 12 GB line, the wipe, the kept read, the cases) is the fleet's. 0.3.21's FIRST GATE LINE on 55768f88 (sha256 279b1b690e854fc9, the string read back; pairing igneum-pow 8c728ca3 at byte 5): the digest gate 13:35:41Z to 13:37:19Z SUMMARY PASS (a89be8a7 on both binaries with the peers; db9a85f9 refused, no peer; the live file's eada4bda unmoved); the ten-minute mixed-version gate from 13:37:40Z, line about 13:50Z. The 0.3.21 order as the shipper sent it: 55768f88; f067f7c1 and 70e4601e; b0444f51; 6eb21fc9; db28d331; then the re-pin from 8bdcbdd8 on the coordinator's word; suites between, the digest read after every one; the mirror's release-0.3.20-node back at the pin c4459193, release-0.3.21-node open at 55768f88. THE LATE-JOIN COMMIT (N9's second half, the node lane): 70e4601e on the box mirror as branch proof-hold-fix, from c4459193, two files (igneum/exec/src/proving.rs, protocol/flows/src/v10/proving.rs); the gap was the fetch side on the joiner (the served record ran the native check against the joiner's trailing exec state before anything was stored, the check refused it, the proof was never held, the body rule read "not held" for 20 s and failed the IBD); the fix holds the proof by hash before the checks (the pool entry still needs them) and the serve side says when it holds fewer than asked; the exec suite 32 passed at 13:26Z with the known-failed shape first, the flows check green 13:28Z, igneumd on build-1 at the 0321 worktree path built 13:32Z, sha256 17649eeb2f7d1290, string read back; with the testnet lane (the resume form, B alone); it joins the 0.3.21 staging as its own commit. THE WIPE CANARY ON c19-1, c4459193 (sha 45be9b02d1b002f5, string read back): FORM END rc 0 at 13:50:53Z. Wipe synced 13:35:50Z (57 minutes, inside the 98-minute class); mining 13:36:00Z to 13:47:07Z, 66 mined, 66 accepted, 0 rejected, isSynced true at the tip throughout; the hub holds 41 of its blocks in its last 700 with 0 rejects (13:47:09Z); the restart on its kept datadir at 13:47:15Z: the old process stopped at once (the new process's first lock line seven seconds after the marker; the watchdog held nothing, the b7cc37e7 fault closed), synced again at 13:48:39Z after 84 s, 109 templates read with max 3,432 ms and 0 timeouts; the kept read on pool-1's 0.3.17 copy on the same pod passed at 13:38Z (the rewrite line once, a clean second start). The pin's set on c4459193: the digest gate PASS, the mixed-version gate PASS, the wipe canary PASS, the kept read PASS, the restart PASS, the 12 GB line proves and verifies (paid is a race, not a gate); CASES END from c20-1 (about 14:50Z) is the last pin line. THE INTEROP FACT stands from the void run: the 5899f603 hub accepted 235 object-byte-5 blocks from the 8097d600 node with 0 rejected, one digest on all five nodes on the live sixteen-field file. The gates: the digest test and the kaspa-pow vector test (the amended devnet epoch-0 id 1a4230699a6b9c60 must equal, c120d7963abdcd96 must differ, the v3 control unchanged) on the box; the mixed-version Devnet 2 gate (the amended 0.3.20 node beside a 5899f603 node for ten minutes on the live file without the v4 fields) after the Mac build; the fresh-join canary the 0.3.20 cut's |
| Main's rulings (7 October, morning) | no generator change to v4 on the live devnet; the record's null is the window model with numbers, sent by the hash lane to the attack-pass lane so AP-F8-1 re-gates against it; a fault beyond the model (a low-entropy source at site 15) stops at the coordinator with the two options priced (a 0.3.19 class amendment before the flip, or the flip held at the floor), nothing shipping without Josh's word; the tighter tail, an acceptance bound on the hot-set share, is a CLASS V5 item (sent to the v5 lane a6410f3b8abefb762 with the 64-seed census as its gate; the bound's number follows from the model) |
### AP-F4-1, the weak-day MUL draw (the attack-pass lane, 7 October, morning): PASS against v4, a class v5 rule

View file

@ -202,10 +202,11 @@ if [ "${1:-}" = --self-test-slots ]; then
# 4. a quiet measurement is REFUSED (exit 73) while a build holds a slot or a core is leased
fake e 3 & sleep 0.5; fake n 1 1; rc=$?; wait
[ "$rc" = 73 ] && [ ! -f "$t/n.start" ] || fail "a quiet measurement was not refused while a build ran (rc $rc)"
( exec 9>>"$t/locks/core-7"; flock 9; sleep 2 ) & sleep 0.3; fake o 1 1; rc=$?; wait
( exec 9>>"$t/locks/core-7"; flock 9; sleep 6 ) & sleep 0.5; fake o 1 1; rc=$?; wait
[ "$rc" = 73 ] || fail "a quiet measurement was not refused while a core was leased (rc $rc)"
# 4b. a run keeps off leased cores: with core 1 leased, a 2-core bounded run on a 2-core box says so (the exclusion line)
( exec 9>>"$t/locks/core-$(( $(nproc) - 1 ))"; flock 9; sleep 2 ) & sleep 0.3; BR_CORES=2 BR_NICE=10 fake p 1; wait
# the lease outlives the fake's slot take and settle (a loaded box took them past 2 s and the first version read no lease)
( exec 9>>"$t/locks/core-$(( $(nproc) - 1 ))"; flock 9; sleep 12 ) & sleep 0.5; BR_CORES=2 BR_NICE=10 fake p 1; wait
grep -q 'are leased to a measurement; this run keeps to' "$t/p.out" || fail "a run beside a leased core did not exclude it: $(cat "$t/p.out" | tail -3)"
# 5. a probing build leaves a busy slot's holder line intact
fake f 3 & sleep 1.2; fake g 1 & sleep 0.3

View file

@ -3,14 +3,18 @@
# the live one, and a node on the old file refuses a node on the new one as a peer, so the three testnet seeds and the hands move
# AT the publish minute, in parallel with the fleet's wave. Dry run by default (every line printed, no box touched); --go executes.
#
# infra/build-server/wave1-0320.sh seeds --override <file> --igneumd <seed-class igneumd> --sha256 <hex> [--miner <seed-class igneum-miner>] \
# --digest <hex> [--commit c4459193] [--go]
# infra/build-server/wave1-0320.sh seeds [--override <file>] --igneumd <seed-class igneumd> --sha256 <hex> [--miner <seed-class igneum-miner>] \
# --digest <hex> [--commit <sha8>] [--wipe-genesis --genesis <hex>] [--go]
# the three seeds in PARALLEL (seed1/2/3.testnet, root over the ops key, infra/seed-nodes/seeds-testnet.tsv): the binary put
# as /opt/igneum/bin/igneumd.new with its sha256 asserted on the box, the override written to /etc/igneum/override-params.json,
# EXTRA_ARGS in /etc/igneum/seed.env set to --override-params-file=<that>, the unit igneumd stopped, the binary swapped (the old
# one kept as igneumd.prev), the unit started on its kept datadir, then the read-back: commit string in the installed binary,
# the "Consensus params digest" line of the new run against --digest, eth_syncing over the loopback EVM RPC, peers; one line
# per seed, logs in the scratch directory
# per seed, logs in the scratch directory. Without --override the seeds' env is left as it is (no override today).
# --wipe-genesis (the testnet go, docs/plans/testnet-go.md runbook step 2; the shipper, 7 Oct 2026): the genesis re-cut changes
# the genesis itself, so a binary put onto the kept datadir would refuse its own genesis; with the unit stopped the datadir
# /var/lib/igneum/igneum-testnet-1 (height 0, nothing but genesis) is moved aside as igneum-testnet-1.prev-<stamp> and the node
# starts fresh; the read-back adds the "[igneum-exec] genesis <hash> executed" line against --genesis (MATCH/MISMATCH)
# infra/build-server/wave1-0320.sh hands --node <fork worktree on the Mac> --override-json '<object>' --digest <hex> [--go]
# the hands through infra/build-server/hands/move-hand.sh: binary (build + install + override), restart observer-node, restart
# node1, each read back by that tool (first executing line, commit string, digest, powEngine)
@ -28,11 +32,11 @@ say() { echo "$(now) wave1: $*" >&2; }
die() { say "ERROR: $*"; exit 1; }
seed_ip() { awk -F'\t' -v n="$1" '$1==n {print $4}' "$SEEDS_TSV"; }
mode="${1:-}"; [ -n "$mode" ] || { sed -n '2,24p' "$0" | sed 's/^# \{0,1\}//'; exit 2; }; shift
GO=0; OVERRIDE=""; BIN=""; SHA=""; MINER=""; DIGEST=""; COMMIT="c4459193"; NODE=""; OVJSON=""
GO=0; OVERRIDE=""; BIN=""; SHA=""; MINER=""; DIGEST=""; COMMIT="c4459193"; NODE=""; OVJSON=""; WIPE=0; GENESIS=""
while [ $# -gt 0 ]; do case "$1" in
--go) GO=1; shift ;; --override) OVERRIDE="$2"; shift 2 ;; --igneumd) BIN="$2"; shift 2 ;; --sha256) SHA="$2"; shift 2 ;;
--miner) MINER="$2"; shift 2 ;; --digest) DIGEST="$2"; shift 2 ;; --commit) COMMIT="$2"; shift 2 ;; --node) NODE="$2"; shift 2 ;;
--override-json) OVJSON="$2"; shift 2 ;; *) die "unknown option $1" ;; esac; done
--override-json) OVJSON="$2"; shift 2 ;; --wipe-genesis) WIPE=1; shift ;; --genesis) GENESIS="$2"; shift 2 ;; *) die "unknown option $1" ;; esac; done
one_seed() { # <name>: runs in the background; prints one RESULT line at the end
local name="$1" ip log t0 line
@ -42,24 +46,33 @@ one_seed() { # <name>: runs in the background; prints one RESULT line at the e
if [ "$GO" = 0 ]; then
echo "DRY: scp $BIN root@$ip:/opt/igneum/bin/igneumd.new; sha256 asserted = $SHA"
[ -n "$MINER" ] && echo "DRY: scp $MINER root@$ip:/opt/igneum/bin/igneum-miner.new (swapped with the node)"
echo "DRY: scp $OVERRIDE root@$ip:/etc/igneum/override-params.json; seed.env EXTRA_ARGS=--override-params-file=/etc/igneum/override-params.json"
[ -n "$OVERRIDE" ] && echo "DRY: scp $OVERRIDE root@$ip:/etc/igneum/override-params.json; seed.env EXTRA_ARGS=--override-params-file=/etc/igneum/override-params.json" || echo "DRY: no override: seed.env untouched"
[ "$WIPE" = 1 ] && echo "DRY: with the unit stopped: mv /var/lib/igneum/igneum-testnet-1 /var/lib/igneum/igneum-testnet-1.prev-<stamp> (kept); genesis read back against $GENESIS"
echo "DRY: systemctl stop igneumd; mv igneumd igneumd.prev; mv igneumd.new igneumd; systemctl start igneumd"
echo "DRY: read back: grep -c $COMMIT in the binary; journal 'Consensus params digest' == $DIGEST; eth_syncing on 127.0.0.1:26890; peers"
echo "DRY RUN, nothing touched; box $ip answers as $("${SSH[@]}" "root@$ip" 'hostname; systemctl is-active igneumd; sha256sum /opt/igneum/bin/igneumd | cut -c1-12' 2>/dev/null | tr '\n' ' ')"
else
scp -q -i "$KEY" -o BatchMode=yes "$BIN" "root@$ip:/opt/igneum/bin/igneumd.new"
[ -n "$MINER" ] && scp -q -i "$KEY" -o BatchMode=yes "$MINER" "root@$ip:/opt/igneum/bin/igneum-miner.new"
scp -q -i "$KEY" -o BatchMode=yes "$OVERRIDE" "root@$ip:/etc/igneum/override-params.json"
"${SSH[@]}" "root@$ip" bash -s -- "$SHA" "$COMMIT" "$DIGEST" "$([ -n "$MINER" ] && echo 1 || echo 0)" <<'REMOTE'
[ -n "$OVERRIDE" ] && scp -q -i "$KEY" -o BatchMode=yes "$OVERRIDE" "root@$ip:/etc/igneum/override-params.json"
"${SSH[@]}" "root@$ip" bash -s -- "$SHA" "$COMMIT" "$DIGEST" "$([ -n "$MINER" ] && echo 1 || echo 0)" "$([ -n "$OVERRIDE" ] && echo 1 || echo 0)" "$WIPE" "$GENESIS" <<'REMOTE'
set -euo pipefail
SHA="$1"; COMMIT="$2"; DIGEST="$3"; MINER="$4"; cd /opt/igneum/bin
SHA="$1"; COMMIT="$2"; DIGEST="$3"; MINER="$4"; OV="$5"; WIPE="$6"; GENESIS="$7"; cd /opt/igneum/bin
got=$(sha256sum igneumd.new | cut -d' ' -f1); [ "$got" = "$SHA" ] || { echo "sha256 MISMATCH on the box: $got"; exit 1; }
python3 -c 'import json,sys; json.load(open("/etc/igneum/override-params.json"))' || { echo "override file does not parse"; exit 1; }
if [ "$OV" = 1 ]; then
python3 -c 'import json,sys; json.load(open("/etc/igneum/override-params.json"))' || { echo "override file does not parse"; exit 1; }
grep -qE '^EXTRA_ARGS=' /etc/igneum/seed.env && sed -i -E 's#^EXTRA_ARGS=.*#EXTRA_ARGS="--override-params-file=/etc/igneum/override-params.json"#' /etc/igneum/seed.env || echo 'EXTRA_ARGS="--override-params-file=/etc/igneum/override-params.json"' >> /etc/igneum/seed.env
fi
chmod 755 igneumd.new; [ "$MINER" = 1 ] && chmod 755 igneum-miner.new
grep -qE '^EXTRA_ARGS=' /etc/igneum/seed.env && sed -i -E 's#^EXTRA_ARGS=.*#EXTRA_ARGS="--override-params-file=/etc/igneum/override-params.json"#' /etc/igneum/seed.env || echo 'EXTRA_ARGS="--override-params-file=/etc/igneum/override-params.json"' >> /etc/igneum/seed.env
t0=$(date +%s); systemctl stop igneumd
wiped=""
if [ "$WIPE" = 1 ] && [ -d /var/lib/igneum/igneum-testnet-1 ]; then stamp=$(date -u +%Y%m%dT%H%M%SZ); mv /var/lib/igneum/igneum-testnet-1 "/var/lib/igneum/igneum-testnet-1.prev-$stamp"; wiped="datadir moved aside as igneum-testnet-1.prev-$stamp; "; fi
mv -f igneumd igneumd.prev; mv -f igneumd.new igneumd; [ "$MINER" = 1 ] && { mv -f igneum-miner igneum-miner.prev 2>/dev/null || true; mv -f igneum-miner.new igneum-miner; }
systemctl start igneumd; sleep 6
gline=""; if [ "$WIPE" = 1 ]; then for i in $(seq 1 30); do gline=$(journalctl -u igneumd --since "-90 s" --no-pager 2>/dev/null | grep -oE "genesis [0-9a-f]{8,} executed" | tail -1); [ -n "$gline" ] && break; sleep 2; done; fi
gmatch=""; if [ "$WIPE" = 1 ]; then gh=$(echo "$gline" | awk '{print $2}'); if [ -z "$gh" ]; then gmatch="genesis line not seen in 60 s; "; elif [ -n "$GENESIS" ] && [ "${gh#${GENESIS:0:8}}" = "$gh" ]; then gmatch="genesis MISMATCH($gh); "; else gmatch="genesis $gh MATCH; "; fi
# the re-cut's binary prints "Base unit: 10^18" at start and the 5 October one never does (the testnet lane, 7 Oct 2026)
if journalctl -u igneumd --since "-90 s" --no-pager 2>/dev/null | grep -q "Base unit: 10^18"; then gmatch="${gmatch}base unit 10^18 line seen; "; else gmatch="${gmatch}NO base unit line; "; fi; fi
down=$(( $(date +%s) - t0 ))
commits=$(grep -a -c "$COMMIT" igneumd || true)
first=$(journalctl -u igneumd --since "-40 s" --no-pager 2>/dev/null | grep -vE "Started|Stopped|Stopping|Deactivated|Consumed" | head -1 | cut -c1-120)
@ -67,7 +80,7 @@ dig=$(journalctl -u igneumd --since "-40 s" --no-pager 2>/dev/null | grep -oE "C
syncing=$(curl -s -m 5 -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"eth_syncing","params":[]}' http://127.0.0.1:26890 | cut -c1-80)
peers=$(curl -s -m 5 -H 'content-type: application/json' --data '{"jsonrpc":"2.0","id":1,"method":"net_peerCount","params":[]}' http://127.0.0.1:26890 | grep -oE '"result":"[^"]*"' | cut -d'"' -f4)
dmatch=no; [ -n "$dig" ] && [ "$dig" = "$DIGEST" ] && dmatch=MATCH; [ -n "$dig" ] && [ "$dig" != "$DIGEST" ] && dmatch="MISMATCH($dig)"
echo "unit $(systemctl is-active igneumd) down ${down}s; commit strings $commits; digest $dmatch; eth_syncing $syncing; peers $peers; first: $first"
echo "unit $(systemctl is-active igneumd) down ${down}s; ${wiped}${gmatch}commit strings $commits; digest $dmatch; eth_syncing $syncing; peers $peers; first: $first"
REMOTE
fi
} >> "$log" 2>&1; rc=$?
@ -77,11 +90,12 @@ REMOTE
case "$mode" in
seeds)
[ -n "$OVERRIDE" ] && [ -n "$BIN" ] && [ -n "$SHA" ] && [ -n "$DIGEST" ] || die "seeds needs --override --igneumd --sha256 --digest"
[ -f "$OVERRIDE" ] && [ -f "$BIN" ] || die "override or binary file missing"
[ -n "$BIN" ] && [ -n "$SHA" ] && [ -n "$DIGEST" ] || die "seeds needs --igneumd --sha256 --digest (and --override when the seeds take one)"
[ -f "$BIN" ] || die "binary file missing"; [ -z "$OVERRIDE" ] || [ -f "$OVERRIDE" ] || die "override file missing"
[ "$WIPE" = 0 ] || [ -n "$GENESIS" ] || die "--wipe-genesis needs --genesis <hex> for the read-back"
local_sha=$(shasum -a 256 "$BIN" | cut -d' ' -f1); [ "$local_sha" = "$SHA" ] || die "the binary's sha256 is $local_sha, not $SHA"
python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$OVERRIDE" || die "override file does not parse"
say "seeds: $( [ "$GO" = 1 ] && echo GO || echo DRY RUN ); igneumd $SHA; override $OVERRIDE ($(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$OVERRIDE") fields); digest $DIGEST; commit $COMMIT"
[ -z "$OVERRIDE" ] || python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$OVERRIDE" || die "override file does not parse"
say "seeds: $( [ "$GO" = 1 ] && echo GO || echo DRY RUN ); igneumd $SHA; override ${OVERRIDE:-none}; digest $DIGEST; commit $COMMIT; wipe-genesis $WIPE${GENESIS:+ (genesis $GENESIS)}"
for n in seed1.testnet seed2.testnet seed3.testnet; do one_seed "$n" & done; wait
say "seeds done; logs in $S" ;;
hands)

39
tools/ci/merge-to-master.sh Executable file
View file

@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Merge a branch into master through a detached worktree and push, on the gate's fast path (main, 7 October 2026, the push-race
# class: master takes a push every minute and a 100 s hook gate on the merge commit lost six pushes in a row). The branch's own
# full gate must be GREEN on its HEAD over a clean tree (tools/ci/pre-push.sh records the stamp); the merge commit is then a
# two-parent merge of the branch onto the exact remote tip, which the hook lets through on the light gate (20 s) while CI runs
# the full gate on landing. Retries while master moves. Never force; never from a dirty branch.
#
# tools/ci/merge-to-master.sh [<branch>] [--tries N] default: the current branch, 6 tries
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel); cd "$ROOT"
BRANCH="$(git rev-parse --abbrev-ref HEAD)"; TRIES=6
while [ $# -gt 0 ]; do case "$1" in --tries) TRIES="$2"; shift 2 ;; -*) echo "unknown option $1" >&2; exit 2 ;; *) BRANCH="$1"; shift ;; esac; done
[ -z "$(git status --porcelain --untracked-files=no)" ] || { echo "merge-to-master: the tree has uncommitted tracked changes; commit first" >&2; exit 1; }
SHA=$(git rev-parse "$BRANCH"); G=$(cd "$(git rev-parse --git-common-dir)" && pwd -P)
if [ ! -f "$G/igneum-gate-green/$SHA" ]; then
echo "merge-to-master: no green stamp for ${SHA:0:8}; running the full gate on the branch first"
bash tools/ci/pre-push.sh || exit 1
[ -f "$G/igneum-gate-green/$SHA" ] || { echo "merge-to-master: the gate was green but no stamp was written (dirty tree?)" >&2; exit 1; }
fi
AUTHOR=(-c user.name=igneum-josh -c user.email=337424239+igneum-josh@users.noreply.github.com)
for i in $(seq 1 "$TRIES"); do
git fetch -q origin master; TIP=$(git rev-parse origin/master)
if git merge-base --is-ancestor "$SHA" "$TIP"; then echo "merge-to-master: ${SHA:0:8} is already on origin/master $(git log -1 --format=%h origin/master)"; exit 0; fi
W=$(mktemp -d "${TMPDIR:-/tmp}/merge-to-master.XXXXXX"); rmdir "$W"
git worktree add -q --detach "$W" "$TIP"
if ( cd "$W" && git "${AUTHOR[@]}" merge -q --no-ff -m "Merge $BRANCH ${SHA:0:8} into master (gate: green on ${SHA:0:8}, recorded by tools/ci/pre-push.sh; the full gate runs in CI on this merge)" "$SHA" ); then
if ( cd "$W" && git push -q origin HEAD:master ); then
git worktree remove --force "$W"; git fetch -q origin master
echo "merge-to-master: pushed on try $i: origin/master $(git log -1 --format='%h %ci' origin/master) $(TZ=Europe/London date '+%H:%M %Z')"; exit 0
fi
echo "merge-to-master: try $i: the push was rejected (master moved or the hook was red); again"
else
echo "merge-to-master: the merge of $BRANCH onto ${TIP:0:8} does not apply cleanly; resolve on the branch (git merge origin/master) and retry" >&2
git worktree remove --force "$W"; exit 1
fi
git worktree remove --force "$W" 2>/dev/null || true
sleep 5
done
echo "merge-to-master: gave up after $TRIES tries" >&2; exit 1

View file

@ -123,9 +123,32 @@ gated_refs() {
[ "$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"; exit 0; fi
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
}
@ -147,22 +170,41 @@ case "$MODE" in
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"
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 (structural checks, no-secrets, identity grep)"
[ "$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 (structural checks, no-secrets, identity grep)"
exit $fails ;;
list)
grep -E '^\s+run "' "$0" | sed -E 's/^\s+run "([^"]+)".*/\1/' ;;
hook)
which="$(gated_refs)"
REFS="$(cat)"; which="$(printf '%s\n' "$REFS" | gated_refs)"
if [ "$which" = full ]; then
echo "pre-push gate: a push to master or release-*, the full gate (the same checks CI runs):"
structural_checks; tree_checks; finish "push to master or release-*"
# 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:"
structural_checks; tree_checks; finish "$MODE" ;;
[ "$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