Thirteen failure emails between 15:26 and 16:53 UK. The classes and what closes them: - the box-locks check on a hosted runner (10 runs): closed bybd6fcb88and165e8b35earlier - windows-ci's stale payload-inputs pin (3 runs): closed on master by 4b4e1bc1; update-return's dispatches still carry e69e8a39 - three hosted site jobs on master hung in the tree gate for over two hours (no timeout-minutes): ci.yml now carries site 15, changes 10, pow 60, sims 45, the overlap sweep runs under a 10-minute wall clock where GNU timeout exists, and tools/ci/workflow-timeouts-check.sh fails a job without a budget (self-test: a job without the key, a wrong budget) - a branch merged with no ci run of its own (era-vdf61421005, 16:31 UK): master's igneum-pow suite went red and five docs-only merges landed green over it because their runs skip the compile job. tools/ci/ci-state.mjs reads the runs API through gh (a commit's newest run, master's last COMPILED run, a branch's last red); merge-to-master.sh pushes an unrun branch for a run, waits for a queued one printing the clock, refuses a red one and refuses any merge onto a red master except the declared fix (--fixes-master); the pre-push hook refuses a push to master whose commit, or whose merge's branch parent, has no green run on that exact sha; a feature-branch push prints the branch's previous red first. Self-tests with a fake gh in all three. - ci-red.yml fires on failure, cancelled and timed_out and hands the conclusion to red-watch.mjs, whose line names the kind (CI red, CI cancelled, CI timed out); the self-test reads the workflow file for the three conclusions - tools/ci/retry-once.sh: one retry before red for the box-locks check, the scene parity check and the live public API check (each keeps its own skip line on a runner without the resource) GitHub's branch protection cannot be applied: the organisation is on the free plan and the repository is private (the API answers 403, "Upgrade to GitHub Pro or make this repository public"), so the two scripts are the enforcement; the rule is one line in CLAUDE.md under the CI block. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
127 lines
9.7 KiB
Bash
Executable file
127 lines
9.7 KiB
Bash
Executable file
#!/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.
|
|
#
|
|
# The CI rule (standing rule, 7 October 2026, 17:2x UK): a merge lands only when the branch's OWN ci run is green on the exact
|
|
# commit being merged, read from the runs API (tools/ci/ci-state.mjs), not the local stamp alone. No run yet: the branch is pushed
|
|
# so CI runs it. A queued or running run: this tool waits, printing the UK clock, up to --ci-wait minutes (default 25). A red run:
|
|
# refused with the run's red check; fix the branch and push a new commit. And master itself must not be red: the newest master run
|
|
# that compiled (docs-only runs skip the compile job and their green says nothing) must be success, or the merge is refused unless
|
|
# it is the fix (--fixes-master). Record: era-vdf's tip 0e2d6b1c was merged with no ci run; master's igneum-pow suite was red from
|
|
# 16:31 UK and five docs-only merges landed green over it. The pre-push hook holds the same rule (pre-push.sh master_ci_ok).
|
|
#
|
|
# tools/ci/merge-to-master.sh [<branch>] [--tries N] [--ci-wait MIN] [--fixes-master] default: the current branch, 6 tries
|
|
# tools/ci/merge-to-master.sh --self-test the CI verdicts, with a fake ci-state: green goes, red refuses, master red refuses
|
|
# unless --fixes-master, none pushes the branch, pending waits then goes
|
|
set -euo pipefail
|
|
ROOT=$(git rev-parse --show-toplevel); cd "$ROOT"
|
|
BRANCH="$(git rev-parse --abbrev-ref HEAD)"; TRIES=6; CI_WAIT_MIN="${CI_WAIT_MIN:-25}"; FIXES_MASTER=0; SELF_TEST=0
|
|
while [ $# -gt 0 ]; do case "$1" in --tries) TRIES="$2"; shift 2 ;; --ci-wait) CI_WAIT_MIN="$2"; shift 2 ;; --fixes-master) FIXES_MASTER=1; shift ;; --self-test) SELF_TEST=1; shift ;; -*) echo "unknown option $1" >&2; exit 2 ;; *) BRANCH="$1"; shift ;; esac; done
|
|
CI_STATE="${CI_STATE_CMD:-node tools/ci/ci-state.mjs}" # the self-test swaps in a fake
|
|
clock() { TZ=Europe/London date '+%H:%M %Z'; }
|
|
|
|
# ci_gate <sha> <branch> <push-cmd...>: 0 when the branch's own ci run on <sha> is green; pushes the branch when there is no run;
|
|
# waits on a queued or running run; refuses (1) a red or unknown one. master_gate: refuses (1) when master's last compiled run is red.
|
|
ci_gate() {
|
|
local sha="$1" branch="$2"; shift 2; local line state deadline pushed=0
|
|
deadline=$(( $(date +%s) + CI_WAIT_MIN * 60 ))
|
|
while :; do
|
|
line=$($CI_STATE "$sha" 2>&1); state="${line%% *}"
|
|
case "$state" in
|
|
success) echo "merge-to-master: ci on ${sha:0:8} is green: $line"; return 0 ;;
|
|
none) if [ "$pushed" = 1 ]; then echo "merge-to-master: waiting for a ci run to appear on ${sha:0:8} ($(clock))"; else echo "merge-to-master: no ci run on ${sha:0:8} yet; pushing $branch so CI runs it ($(clock))"; "$@" || { echo "merge-to-master: the branch push failed; CI cannot run ${sha:0:8}" >&2; return 1; }; pushed=1; fi ;;
|
|
pending) echo "merge-to-master: ci on ${sha:0:8} is ${line#* * * }; waiting ($(clock))" ;;
|
|
*) echo "merge-to-master: REFUSED. ci on ${sha:0:8} is $line" >&2; echo " A branch whose own ci run is not green never merges (CI red is stop-the-line). Fix the branch, push a new commit, and run this again." >&2; return 1 ;;
|
|
esac
|
|
[ "$(date +%s)" -lt "$deadline" ] || { echo "merge-to-master: REFUSED. ci on ${sha:0:8} gave no verdict in $CI_WAIT_MIN minutes ($line); run it again when the queue drains (gh run list --branch $branch --limit 3)" >&2; return 1; }
|
|
sleep "${CI_POLL_SECS:-30}"
|
|
done
|
|
}
|
|
master_gate() {
|
|
local line state; line=$($CI_STATE --master-code 2>&1); state="${line%% *}"
|
|
case "$state" in
|
|
success|none|pending) echo "merge-to-master: master's last compiled run: $line"; return 0 ;;
|
|
*) if [ "$FIXES_MASTER" = 1 ]; then echo "merge-to-master: master's last compiled run is $state and this merge is declared the fix (--fixes-master): $line"; return 0; fi
|
|
echo "merge-to-master: REFUSED. master is red: $line" >&2; echo " CI red is stop-the-line: the owner fixes or reverts first; only the fix merges, with --fixes-master." >&2; return 1 ;;
|
|
esac
|
|
}
|
|
|
|
if [ "$SELF_TEST" = 1 ]; then
|
|
fails=0; d=$(mktemp -d); fake="$d/ci-state.sh"
|
|
# the fake answers from $d/answer-<sha> (one line per call, consumed top to bottom; the last line repeats) and $d/answer-master
|
|
cat > "$fake" <<'FAKE'
|
|
#!/usr/bin/env bash
|
|
d=$(dirname "$0"); key="$1"; [ "$key" = --master-code ] && key=master
|
|
f="$d/answer-$key"; [ -f "$f" ] || { echo "none - - no ci run on $key yet"; exit 0; }
|
|
n=$(wc -l < "$f" | tr -d ' '); i=$(cat "$d/i-$key" 2>/dev/null || echo 1); [ "$i" -gt "$n" ] && i=$n
|
|
sed -n "${i}p" "$f"; echo $((i + 1)) > "$d/i-$key"
|
|
FAKE
|
|
chmod +x "$fake"; export CI_STATE_CMD="$fake" CI_POLL_SECS=0 CI_WAIT_MIN=1
|
|
CI_STATE="$fake"
|
|
printf 'success 1 u push run at 16:00 UK
|
|
' > "$d/answer-green"
|
|
ci_gate green b false >/dev/null || { echo "self-test failed: a green branch run was refused"; fails=1; }
|
|
printf 'failure 2 u push run: igneum-pow tests at "igneum-pow tests (release)"
|
|
' > "$d/answer-red"
|
|
ci_gate red b false >/dev/null 2>&1 && { echo "self-test failed: a red branch run merged"; fails=1; }
|
|
printf 'unknown - - gh: exit 1
|
|
' > "$d/answer-unk"
|
|
ci_gate unk b false >/dev/null 2>&1 && { echo "self-test failed: an unknown verdict merged"; fails=1; }
|
|
printf 'pending 3 u queued since 16:00 UK
|
|
pending 3 u in_progress since 16:00 UK
|
|
success 3 u push run at 16:05 UK
|
|
' > "$d/answer-wait"
|
|
out=$(ci_gate wait b false 2>&1) || { echo "self-test failed: a pending run that turned green was refused: $out"; fails=1; }
|
|
[ "$(printf '%s
|
|
' "$out" | grep -c 'waiting')" = 2 ] || { echo "self-test failed: the wait did not print the clock twice: $out"; fails=1; }
|
|
# no run: the branch is pushed (the push command runs once), then the run appears and goes green
|
|
printf 'none - - no ci run on none yet
|
|
success 4 u push run
|
|
' > "$d/answer-none"; : > "$d/pushes"
|
|
ci_gate none b bash -c "echo pushed >> '$d/pushes'" >/dev/null || { echo "self-test failed: an unrun branch was refused instead of pushed"; fails=1; }
|
|
[ "$(wc -l < "$d/pushes" | tr -d ' ')" = 1 ] || { echo "self-test failed: the branch was pushed $(wc -l < "$d/pushes") times"; fails=1; }
|
|
# master red: refused; with --fixes-master: goes; master green or unknown-none: goes
|
|
printf 'failure 5 u master @1210158d, compile job failure
|
|
' > "$d/answer-master"
|
|
FIXES_MASTER=0 master_gate >/dev/null 2>&1 && { echo "self-test failed: a merge onto a red master was let through"; fails=1; }
|
|
rm -f "$d/i-master"; FIXES_MASTER=1 master_gate >/dev/null || { echo "self-test failed: the declared fix was refused on a red master"; fails=1; }
|
|
printf 'success 6 u master @e5171a32, compile job success
|
|
' > "$d/answer-master"; rm -f "$d/i-master"
|
|
FIXES_MASTER=0 master_gate >/dev/null || { echo "self-test failed: a green master refused a merge"; fails=1; }
|
|
rm -rf "$d"
|
|
[ "$fails" = 0 ] && echo "self-test passed: a green branch run merges; a red or unknown one is refused; a queued run is waited for with the clock; an unrun branch is pushed once and then waited for; a red master refuses every merge but the declared fix"
|
|
exit $fails
|
|
fi
|
|
[ -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 (then CI's own verdict on it is read)"
|
|
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])
|
|
# the CI rule: the branch's own run on this exact commit must be green (pushed for a run when there is none, waited for when queued), and master must not be red
|
|
ci_gate "$SHA" "$BRANCH" git push -q origin "$SHA:refs/heads/$BRANCH" || exit 1
|
|
master_gate || exit 1
|
|
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 # the hook asks ci-state about ${SHA:0:8} once more on this push
|
|
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
|