diff --git a/infra/build-server/remote-run.sh b/infra/build-server/remote-run.sh index 955f0057..5bf2a547 100755 --- a/infra/build-server/remote-run.sh +++ b/infra/build-server/remote-run.sh @@ -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 diff --git a/infra/build-server/wave1-0320.sh b/infra/build-server/wave1-0320.sh index 1683489b..af8bb3e0 100755 --- a/infra/build-server/wave1-0320.sh +++ b/infra/build-server/wave1-0320.sh @@ -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 --igneumd --sha256 [--miner ] \ -# --digest [--commit c4459193] [--go] +# infra/build-server/wave1-0320.sh seeds [--override ] --igneumd --sha256 [--miner ] \ +# --digest [--commit ] [--wipe-genesis --genesis ] [--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=, 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- and the node +# starts fresh; the read-back adds the "[igneum-exec] genesis executed" line against --genesis (MATCH/MISMATCH) # infra/build-server/wave1-0320.sh hands --node --override-json '' --digest [--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() { # : runs in the background; prints one RESULT line at the end local name="$1" ip log t0 line @@ -42,24 +46,33 @@ one_seed() { # : 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- (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 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) diff --git a/tools/ci/merge-to-master.sh b/tools/ci/merge-to-master.sh new file mode 100755 index 00000000..11bb7685 --- /dev/null +++ b/tools/ci/merge-to-master.sh @@ -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 [] [--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-labs -c user.email=337424239+[removed]) +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 diff --git a/tools/ci/pre-push.sh b/tools/ci/pre-push.sh index 1d05e2a6..f9964dc7 100755 --- a/tools/ci/pre-push.sh +++ b/tools/ci/pre-push.sh @@ -122,9 +122,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/ (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"; 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 } @@ -146,22 +169,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