ci(release): gate releases on a two-sided auto-update canary (#5222) - #5236
Conversation
Auto-update was broken fleet-wide for two consecutive releases (v0.2.120, v0.2.121) and nothing noticed. #5104 made the node's release-tag fetch return the tag verbatim ("v0.2.121") and normalised it at only one of its two consumers; the detection path kept the raw tag, semver parsing failed, and every update was dropped with a warn!. ~1,100 nodes had to be told to run `freenet update` by hand, because a broken updater cannot deliver its own fix. Nothing caught it because every signal was one-sided: the release built, published, installed and ran. The one machine positioned to notice (framework, the real-NAT pre-release smoke peer) had been running with --disable-auto-update for nine days after a #5040 measurement window. Adds scripts/auto-update-canary.sh and wires it in as two gates: Gate A (blocking, pre-publish) runs the binary about to ship and requires its updater to read GitHub's current release tag. It sits between asset upload and un-draft in attach-to-release, so a failure leaves a stuck draft rather than a stranded fleet. Verified against real binaries: v0.2.120 fails it, v0.2.122 passes. Had this gate existed, v0.2.120 would never have published. Gate B (post-publish) takes the previous release and requires it to detect this one, exit 42, and self-replace via `freenet update` - the transition the fleet actually makes. Verified end to end: 0.2.119 -> 0.2.122 passes, 0.2.121 -> 0.2.122 fails. It cannot run earlier; the detection path is hardwired to /releases/latest and a draft release does not appear there. Both gates live in the workflow's needs-chain rather than listening for release.published, so they cannot silently stop running if RELEASE_PAT lapses. The assertion is two-sided on purpose: the "Startup update check against GitHub" line must be PRESENT and the "failed to parse latest version" warning absent. Absence of the error alone proves nothing - it is equally consistent with the check never running. The canary also fails outright if the node under test has auto-update disabled, which turns "the canary was quietly switched off" from a human-memory dependency into a red build. scripts/auto-update-canary_test.sh pins the assertion with verbatim log lines captured from real v0.2.119 (healthy) and v0.2.121 (broken) runs, and is wired into ci.yml. Its load-bearing cases are the vacuous ones - a log with no update check, and a disabled node - both of which a one-sided "grep for the error" check waves through. Mutation testing confirmed each branch is load-bearing; it also showed the exit code alone could not detect deleting the disabled-node branch, so that case asserts on the diagnosis. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
Rule Review: Minor commit-message hygiene onlyRules checked: git-workflow.md, code-style.md, testing.md The two Rust changes are limited to adding The rest of the diff (CI workflow wiring, WarningsNone. Info
Rule review against |
Two blind reviewers (code-first and release-pipeline-risk lenses) found four blocking issues and several important ones. All are fixed here. BLOCKING - release.sh un-drafted the release independently, so Gate A did not actually block anything. publish_draft_release() fires as soon as all assets are attached, without checking the workflow's conclusion — which is precisely the multi-minute window the canary now opens between upload and publish. It now refuses while the tag's cross-compile run is unfinished or failed, so the local driver cannot race in and publish a release whose updater the gate is in the middle of rejecting. - attach-to-release kept timeout-minutes: 10, which the canary's retry path could exceed. The job would have been cancelled mid-canary, skipping the Publish step and leaving a permanently stuck draft — the exact failure the retry existed to ride out. Raised to 25, bounded the canary step at 12, and cut the worst case (180s node timeout, 2 attempts, no trailing sleep). - check-token-coalesce.yml would have failed this PR deterministically: Gate B's read-only `gh api` step used a bare GITHUB_TOKEN. Marked coalesce-exempt with a reason. - The failure notification hung off Gate B alone. A Gate A failure fails attach-to-release, which SKIPS Gate B, so no message was sent — the BLOCKING gate was the silent one, leaving a stuck draft with nobody told. That is the silent-fail-closed shape this change exists to remove. It now covers both gates and reports which one failed. IMPORTANT - MARKER_TRIGGERED was 'triggering auto-update', a substring of the #4073 refusal line "...not triggering auto-update". A node that deliberately declined an update read as one that requested it. Anchored on the full positive phrase and pinned in both directions in the test. - Gate A's retry wiped only the logs, leaving the node's persisted GitHub rate-limit cooldown in place, so all attempts re-read the same cooldown and reported the same INDETERMINATE without asking GitHub again. A retry that cannot produce a different answer is not a retry. Each attempt now gets a fresh state tree. - Gate B turned "GitHub unreachable" into an alarm reading "the fleet will not converge". Crying wolf on a network blip is how an alarm gets ignored. It still fails (green on an unverified run is the vacuous pass this whole change is against) but is worded as UNVERIFIED, and the notification no longer asserts which failure occurred. - A node that fails to boot was diagnosed as a broken updater, pointing whoever is on call at the wrong subsystem. Now reported distinctly. - The Gate A recovery advice was actively harmful: release.yml publishes to crates.io BEFORE pushing the tag, so a block leaves the crate live with no GitHub release, and the docs said to delete the tag. Rewritten to name the split state, to try a re-run first for infrastructure failures, and to warn that a dirty local build disables auto-update and cannot reproduce the gate. Also: unchecked tar/curl in Gate B, curl --max-time, and an exact rather than substring version match ("0.2.12" matched "0.2.121"). Re-verified against real binaries after the changes: Gate A still fails v0.2.120 and passes v0.2.122; Gate B still passes 0.2.119 -> 0.2.122 end to end and fails 0.2.121 -> 0.2.122. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
Multi-perspective review (Full tier) — all findings addressedThis touches release/CI configuration, which is a Full-tier surface, so it got two independent blind reviewers with distinct lenses: code-first (read the code before the description, flag intent/implementation mismatches) and release-pipeline-risk (single question: could this break or stall releases?). Both read the checked-out code, not just the diff. They agreed on more than I expected, and between them found four blocking issues. All are fixed in f4eb3f9 and the gates were re-validated against real binaries afterwards. Blocking1. Gate A did not actually block anything. This was the most important finding: without it, the PR's central claim was false. 2. 3. 4. The failure notification covered only the non-blocking gate. A Gate A failure fails Important
Plus unchecked Dismissed, with reasons
Re-validation after the fixesBecause the changes touched the marker, the retry loop and the version match, both gates were re-run against real release binaries and the real published v0.2.122:
[AI-assisted - Claude] |
…canary A re-review of the previous fix commit, plus re-validating against real release binaries, found three problems the fix commit itself introduced. Two of them would have hurt more than the bug being fixed. BLOCKING — release.sh announced a release it had just refused to publish. publish_draft_release() returned 0 when the gate declined, so the caller reported success and the driver went on to update the gateways and announce to Matrix and River a release that was still an unpublished draft. It now returns non-zero (release.sh is set -euo pipefail, so that aborts the driver), and the function is restructured to check is-it-still-a-draft FIRST so an already-published release stays a clean no-op. Also inverted: an EMPTY `gh run list` does not yield "" as assumed, it yields the literal "null:null" (jq interpolates .[0] == null). So "no run found" refused, while the only path to publish-anyway was `gh` itself failing — the fail-open the guard exists to prevent, sitting exactly where an auth expiry or rate limit lands. Now anything other than completed:success refuses. FALSE RED — the canary reported a healthy binary as broken. Two causes, both verified empirically rather than reasoned about: - The process-group kill did not work. `set -m` gave the SUBSHELL its own group, but job control is inherited, so the `timeout` inside started a group of its own and the group kill missed it (the pgids differ). Every run left a node alive holding its ports and burning CPU. `exec`-ing the timeout collapses the two so the job pid IS timeout's pid and its child shares the group. Verified: four consecutive gate runs, zero survivors. - The poll loop charged 3s per pass while each pass also paid for a grep and a process check, so its window expired well before the nominal timeout. On a loaded machine a slow-booting node was reported as "the startup update check never ran" — a false BLOCKING failure on a good release, which is worse than no canary at all: the first person to hit it learns to override the gate. Now measured against the clock. MARKER — narrowing MARKER_TRIGGERED to one call site's full phrase (the previous commit's fix for the substring bug) missed two of the four real trigger sites: freenet.rs logs "triggering auto-update" at :524, :650, :731 and :853, and the #4073 refusal at :519. Match the phrase and subtract the refusal, which is the only form that gets both properties. The test now exercises all five lines through the real helper. The markers are also pinned against crates/core/src/bin/*.rs, so a reword there fails CI instead of leaving the canary matching strings nothing emits any more — the self-matching-pin failure mode AGENTS.md warns about, which the previous commit's hardcoded assertions had. Alarm wording no longer claims an auto-update fault for any failure of the ~20-step publish job (artifact download, checksums, signing). Re-validated after all of it: Gate A fails v0.2.120 and passes v0.2.122 across two rounds each with no surviving nodes; Gate B passes 0.2.119 -> 0.2.122 end to end and fails 0.2.121 -> 0.2.122. Mutation testing confirms the new assertions catch reverting each fix, including the one-site anchoring bug above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
Second review round — re-reviewed the fix commit, found a blocking bug in itPer the per-code-content rule, the first review went stale when I pushed fixes, so I re-reviewed the fix commit blind. That was worth doing: the fix commit had introduced a blocking bug and a false-red flake, either of which would have been worse than the original gap. Blocking:
|
| Gate | Case | Result |
|---|---|---|
| A | preflight v0.2.120 × 2 rounds |
fails (correct), 0 surviving nodes |
| A | preflight v0.2.122 × 2 rounds |
passes, 0 surviving nodes |
| B | selfupdate 0.2.119 → 0.2.122 |
passes end-to-end |
| B | selfupdate 0.2.121 → 0.2.122 |
fails (correct) |
Mutation testing confirms the assertions catch reverting each fix, including the one-site anchoring bug above. 18 self-test assertions, shellcheck clean.
Accepted as follow-ups, not fixed here
.[0]of the releases API iscreated_at-ordered, not semver-ordered. Harmless for freenet's linear release flow.- Gate B's
freenet updatespends one unauthenticatedapi.github.comrequest from a shared runner IP. Bounded, and it fails loudly rather than silently, but it is the most likely source of a future flaky Gate B.
[AI-assisted - Claude]
…laim The rule-review check was right: the previous commit fixed two bugs in code that no test exercised. auto-update-canary_test.sh only drives the pure functions, so the part that actually runs a process — where both bugs were — had no coverage at all. Adds scripts/auto-update-canary_lifecycle_test.sh, which drives the REAL `run_node_until_check` and `cmd_preflight` against a fake node binary that emits the same log lines. No network, no real node, deterministic, ~10s: 1. a healthy binary must make the gate exit 0 (can it ever go green?) 2. a broken updater must make it exit 1 (can it ever go red?) 3. NODE_EXIT must carry the node's own exit 42, not our SIGTERM 4. no node may outlive run_node_until_check Case 4 is the load-bearing one and is mutation-verified: dropping the `exec` and reverting to a plain `kill $node_pid` makes it fail. Case 1 is verified to fail when the gate is forced to reject everything. CORRECTION to the previous commit message. It claimed the counting-based poll loop "charged 3s per pass while each pass also paid for a grep and a process check, so its window expired well before the nominal timeout". That is wrong, and in the wrong direction: charging 3s per pass while each pass costs slightly more makes the loop run marginally LONGER than nominal, not shorter. It was never the cause of the false "check never ran" verdict. The actual cause was the leaked nodes from earlier runs stealing CPU so the node under test booted too slowly. Moving to wall-clock is still the right way to express a time budget, but it fixed no bug and the comment no longer says it did. Also corrected: the new test file initially described case 1 as pinning the `trap ... RETURN` scoping bug. That bug was fixed during local development and never reached a commit, so there is nothing to pin against — and a hand-written mutation of it did not reproduce the failure, which is how the overclaim was caught. Case 1 is now described as what it actually guarantees: the gate cannot degenerate into one that only ever fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JHwV1j9kGJEa5D6CxAyb6T
Rule-review warning addressed, plus a correction to my own commit messageThe New:
|
…ealth Gate A could report OK with no evidence, which is the one failure mode the canary exists to prevent. `run_node_until_check` slept a flat 5s after the "Startup update check against GitHub" line and then killed the node's process group. That line is logged BEFORE the network request, and the request is bounded by PROBE_CHAIN_TIMEOUT (10s, whole-chain). So a GitHub that answered between 5s and 10s -- a loaded runner, a redirect hop, a mild rate-limit backoff -- got its node SIGTERMed before it could log success, `failed to parse latest version`, or `failed to fetch latest version`. `assert_detection_healthy` then saw CHECK_RAN, no DISABLED, no PARSE_FAIL, no FETCH_FAIL, and fell through to `return 0`. A binary carrying the exact #5104/#5221 bug this PR exists to catch would have passed Gate A and been published, whenever the parse failure happened to land more than 5s after the check-ran line. The root cause is deeper than the sleep: there was no success marker at all. The healthy Gate A outcome -- the shipping binary is NEWER than the latest release, so the check finishes without triggering -- was a `tracing::debug!`, and release builds set `release_max_level_info`, which compiles `debug!` out entirely. On every shipped binary the most common outcome of the whole check was invisible, so "finished, staying put" and "killed mid-request" were byte-for-byte identical in the log. No amount of waiting fixes that; polling for an outcome that is never emitted just turns every healthy release INDETERMINATE. So: - freenet.rs: promote that outcome to INFO ("Startup update check complete"). It is reached on every non-triggering path, so it asserts only that the check ENDED; the WARN above it, if any, still says what it found. - auto-update-canary.sh: poll for a terminal outcome instead of sleeping, on a budget with real headroom over PROBE_CHAIN_TIMEOUT (20s, overridable), returning the moment it arrives -- so the happy path is FASTER than the old fixed sleep, not slower. - assert_detection_healthy: require a terminal outcome. A check that started and never finished is INDETERMINATE (exit 2, retried, then failed as UNVERIFIED), never OK. Unknown and pass are now different answers. Tests, all mutation-verified rather than merely written: - lifecycle case 5 is the regression pin: a fake node that logs its parse failure 8s after the check line -- inside what production allows, outside what the gate used to watch. Against the pre-fix script it returns 0, reporting a BROKEN updater as healthy; after, it fails with the parse diagnosis. Both directions confirmed by running the new file against the old script. - lifecycle case 6: an outcome that never arrives is UNVERIFIED, not OK. - pure tests: the up-to-date healthy shape (the one Gate A actually sees) and the started-but-no-outcome shape, plus source pins that the completion marker exists AND is still INFO -- demoting it back to `debug!` fails CI, verified by mutation. All 22 pre-existing assertions still pass unchanged. Two related fixes found by the same review: - ci.yml shellchecked four scripts but not the three canary scripts, despite the claim they were clean. Added, with -x since the tests source the canary. - release.sh gated on the cross-compile RUN's aggregate status, which also covers Gate B -- deliberately non-blocking and running only AFTER the release is published. A Gate B failure therefore returned non-zero from `wait_for_binaries`, which is called bare under `set -e`, aborting the driver before the gateway updates and the Matrix/River announcements for a release that had published perfectly well. Now watches the `attach-to-release` job's own conclusion; an unknown state still refuses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vyhqRVQ8X61gJfent8GWG
Fixed: Gate A could pass with no evidenceAdversarial review found a blocking bug in the gate this PR adds. Pushed a fix in 13876fc. The bug
A binary carrying the exact #5104/#5221 bug this PR exists to catch would have passed Gate A and been published, whenever its parse failure happened to land more than 5s after the check-ran line. Why waiting longer was not the whole fixThe deeper problem: there was no success marker at all. The healthy Gate A outcome — the shipping binary is newer than the latest release, so the check finishes without triggering — was a So on every shipped binary the most common outcome of the whole check was invisible, and "finished, staying put" was byte-for-byte identical to "killed mid-request". Polling for an outcome that is never emitted would just have turned every healthy release INDETERMINATE and blocked every release. The fix
Proof, both directionsLifecycle case 5 is the regression pin: a fake node that logs its parse failure 8s after the check line — inside what production allows, outside what the gate used to watch. Running the new test file against the pre-fix script (extracted via After the fix, all six pass, and case 5 asserts on the diagnosis ( Also mutation-tested the new source pins: demoting the completion marker back to Two related findings from the same review
One disclosureA previous release built before this change cannot emit the completion marker. That affects only Gate B, whose subject is the previous release: a healthy one triggers an update and settles on that, but an old one that neither triggers nor fails now reports UNVERIFIED instead of "did NOT decide to update". Still a refusal, and still the correct one — just a less specific message, and only until the previous release is itself post-fix. Not merged, no auto-merge set. [AI-assisted - Claude] |
Follow-up to the previous commit's release.sh change. Watching the `attach-to-release` job instead of the whole run fixed the Gate B false-abort, but cost a fast exit: a run cancelled before that job was created has no job to report, so `wait_for_binaries` would poll for the full 20 minutes and only then say "Timeout". Loud and safe, but slow, and the run-level check it replaced caught that case in one poll. So when no job state is available AND the run itself has finished, stop immediately. Reported as UNKNOWN with the likely causes named (cancelled before the job started, or renamed out from under ATTACH_JOB_NAME) -- never as a pass, which is the property the whole guard exists for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017vyhqRVQ8X61gJfent8GWG
Consolidated review — 5 independent lensesReviewed at The headline claim holds and is mutation-verified: the original vacuous pass is But two demonstrated false-greens remain, both one ordinary edit away from live. The BLOCKING — the parse-failure source pin matches a comment, not the code
I confirmed both occurrences. Mutation-proved end to end by one lens: reword ONLY the So a routine reword of a log message silently turns Gate A back into the thing this PR Being fixed two ways, because the first alone would work only by luck: shorten the marker BLOCKING — an unenumerated parse failure, with the gate printing the opposite
This is #5104's mistake applied to the other operand. Not reachable with today's IMPORTANT — nothing pins the workflow wiringNothing asserts that The repo already has this pattern: IMPORTANT — an unprotected
|
… release
`scripts/release.sh` runs under `set -euo pipefail`, where a bare
`var=$(cmd)` is a simple command whose exit status IS `cmd`'s. Every
unguarded `$(gh ...)` in the cross-compile wait was therefore a live
abort, and `set -o pipefail` extends that to `$(gh ... | head -1)` --
`head` does not absorb `gh`'s failure.
`wait_for_binaries` is called bare at release.sh:1598, so errexit is
armed inside it, and it polls for up to 20 minutes. A single rate-limit
or 5xx anywhere in that window killed the driver AFTER the release had
published but BEFORE `trigger_gateway_updates`, `announce_to_matrix` and
`announce_to_river` -- exactly the failure release.sh:1216 already warns
about in its own words: "A release that published perfectly well would
silently never be announced." Silent, and indistinguishable from a
release nobody cut.
`attach_job_state` already documented that empty output means "we do not
know", explicitly including "or `gh` failed". The code did not deliver
that. Four sites now do, via the `|| echo ""` this file already uses for
`run_id` in `publish_draft_release`:
- the attach-job read in the poll loop, and the run-status read under
it. The latter is gated to the empty-`job_state` phase, i.e. the
whole multi-minute build window -- the busiest `gh` call in a
release.
- the run-discovery retry loop, which exists specifically to retry
while GitHub starts the workflow, so aborting on the first blip
defeated its only purpose.
- the publish gate, where the abort landed one line before the
refusal message, turning a documented "NOT publishing, gate state
unknown" into a silent death.
Behaviour on a SUCCESSFUL but empty `gh` response is unchanged: that
still means "the job has not started, keep waiting". The failure paths
are unchanged too -- a genuinely failed attach job, and a run that
finished without ever reporting the job, both still stop the release
loudly rather than being softened into a retry.
Adds scripts/release_wait_for_binaries_test.sh, which extracts the real
functions from release.sh (the technique release_state_restore_test.sh
uses) and drives them against a scripted `gh` stub on PATH. It calls
`wait_for_binaries` bare under `set -euo pipefail`, as release.sh:1598
does, and asserts DRIVER_CONTINUED -- the stand-in for the gateway
update and the announcements. Reverting the guards individually shows
each is load-bearing: gate-1259 fails case 6, findrun-1339 fails case 2,
and each of poll-1370 / runstatus-1381 fails case 1. The three negative
cases exist because "survive a `gh` failure" is one `|| true` away from
"survive everything".
Also fixes a source pin in scripts/auto-update-canary_test.sh that was
passing by coincidence. `pin_marker` flattened newlines into spaces, so
a Rust `\`-continuation left a stray `\` mid-phrase and the needle never
matched there. `not triggering auto-update` is emitted at two sites in
freenet.rs and only one has the phrase unbroken, so the pin was tracking
formatting rather than the marker: reflowing that one site would have
reported the marker gone while it was still emitted. Verified both ways
-- reflowing the intact site is a false alarm under the old pin and
clean under the new one, and removing the marker from both sites still
goes red.
Wires the new test into the Fmt job and its shellcheck list.
[AI-assisted - Claude]
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017vyhqRVQ8X61gJfent8GWG
## Problem
Gate A's healthy verdict was byte-identical to a silently-wrong
comparator's. Every assertion in `assert_detection_healthy` looked for
the ABSENCE of an error, and absence is satisfied just as well by a
comparator that parses the wrong thing as by one that works: a
`version_from_tag` regressed to a constant, or a normaliser truncating
`0.2.121` to `0.2.12`, parses, compares, declines to update, and logs a
clean completion. Nothing in the log distinguished it from health.
That is the same defect class this PR exists to close -- a gate that
cannot fail -- so leaving it open would be self-defeating.
## Approach
Emit the OBSERVED value, then assert positive equality against it.
`startup_update_check_with_fetcher` now logs the latest release it
fetched, at INFO (a `debug!` is compiled out of release builds by
`release_max_level_info`, the mistake that made the completion marker
unobservable). The canary resolves the same value independently and
fails on a mismatch.
The resolver deliberately reads `github.com/{repo}/releases/latest`'s
302 Location -- the SAME source the node uses. Comparing against
`api.github.com` would compare two things allowed to disagree, and would
spend the 60/hour unauthenticated REST budget that #5102 moved the node
off. `assert_detection_healthy` stays pure: the expected tag arrives via
`CANARY_EXPECTED_LATEST`, set by `cmd_preflight`, which returns
UNVERIFIED rather than passing if it cannot resolve it.
Also fixes four review minors:
- The trigger-site enumeration was wrong. `MARKER_TRIGGERED` was a fixed
string, so it never matched the urgent site (freenet.rs:609,
"triggering IMMEDIATE auto-update") and a node taking that path was
reported as never having decided to update. Now a regex, with the
comment's stale line numbers corrected and the site COUNT pinned at
five so a sixth cannot appear silently.
- `grep -q .` at the end of a pipe took SIGPIPE under `pipefail` once
output passed the 64 KB pipe buffer (rc=141 read as "false").
- `CANARY_TIMEOUT_SECS` was unvalidated while both its neighbours had
numeric guards.
- `FREENET_DISABLE_LOG_RATE_LIMIT=1` in the canary's node. A dropped
parse-failure WARN with a surviving completion line is a false GREEN;
the env var removes the class.
## Testing
31 canary assertions (was 23). New: the truncated-tag and
constant-comparator fixtures, both of which passed as healthy before;
a missing observed-latest line; the urgent trigger site; and source pins
for the new INFO marker and the trigger-site count.
Refs #5236
Three of the four minors fixed in the previous commit had no test, which is the same gap the review kept finding: a mechanism that cannot be seen failing. - normalise_release_tag: split out of resolve_expected_latest so it can be tested without a network. It has to agree with version_from_tag exactly -- a normaliser that strips differently makes the equality check compare two spellings of the same release and fail a release for a non-bug. Pinned including the at-most-one-`v` case that separates `strip_prefix` from the greedy `trim_start_matches`. - SIGPIPE: 700 matching lines, enough to pass the 64 KB pipe buffer. A small fixture cannot observe this bug at all. - CANARY_TIMEOUT_SECS: the sanitiser its two neighbours already had. 39 assertions, up from 31. Refs #5236
assert_detection_healthy skips the positive-equality check when CANARY_EXPECTED_LATEST is unset. That keeps the function pure and unit-testable, but it means the check is only as real as the caller that sets it -- and nothing pinned the caller. A refactor dropping the assignment would have left every assertion green while Gate A reverted to "the node did not complain". A source comment already claimed this pin existed. It did not. Making the claim true rather than deleting it, because the pin is the right thing to have. Scoped to cmd_preflight's body: the variable is named in comments elsewhere in the file, so a whole-file grep would be satisfied by the prose describing the mechanism instead of the code implementing it -- the same anchor failure the parse-failure pin had. 41 assertions, up from 39. Refs #5236
Running the FULL suite caught this: cmd_preflight now resolves the expected release before booting the node, so every lifecycle case that drives it was reaching GitHub, and the healthy case failed outright -- its fake node logs no observed-latest line, so it looked exactly like the wrong-comparator failure the change adds. cmd_preflight now honours a caller-supplied CANARY_EXPECTED_LATEST instead of always resolving. Safe by construction: a pinned value can only make the equality check FAIL. Skipping it requires an EMPTY value, which is treated as unset and then resolves from GitHub or refuses -- so this cannot become a way to quietly disarm the gate. Adds lifecycle case 2b: a node that parses fine, fetches fine and runs to completion but compares against the WRONG release must fail. Driven through cmd_preflight rather than assert_detection_healthy so the resolve/export wiring is covered end-to-end. Before this PR that node passed Gate A. 7 lifecycle assertions, up from 6. Refs #5236
The marker row already covered LEVEL (a debug! marker is compiled out) and ANCHOR (a whole-file grep matches prose). This PR's review surfaced three more ways a log-grep gate goes vacuous, all of them found in this same canary: - Asserting the ABSENCE of an error passes a component that is silently WRONG, not just one that works. - A marker meant to match a SET of call sites can miss one indefinitely when it fails closed. Fail-closed is not correct; it is the condition under which a wrong enumeration survives longest. - A gate with a skip branch is only as real as the caller that supplies the input, so pin that caller. Also corrects the pin inventory, which no longer matched the file. Refs #5236
…on_healthy The four checks in `assert_detection_healthy` piped a shell variable holding the whole log into `grep -aqF`. `grep -q` exits at its first match, the upstream `printf` dies with SIGPIPE (141), and `set -o pipefail` promotes that to the pipeline's status -- so the `if` reads a marker that IS PRESENT as ABSENT. This is the mechanism this same commit already diagnosed and fixed in `node_decided_to_update` and `node_check_settled`. The helpers were fixed; the call sites in the function they serve were not, and unlike the helpers this one was not latent: it hit 2 of 3 real preflight runs. Gate A's normal path is a binary NEWER than latest, so the node does not exit 42 and keeps logging (~33 KB/s measured) until the canary kills it 1-4s later, putting the markers well behind the 64 KB pipe buffer. On a real 3.65 MB node log: `grep -acF` = 1 (the line is there), piped `grep -q` = 141, direct `grep -q` = 0. Same content with only trailing volume varied: 1 KB passes, 200 KB reports 'the startup update check never ran'. It fails closed -- the positive check runs first, so no false GREEN was constructible -- but `cmd_preflight` does not retry an rc=1, so a healthy release is blocked by an error naming the wrong subsystem. Fix: grep the log FILES directly, via `log_has`/`log_lines`, exactly as the two helpers already do. Emptiness is tested the same way rather than by slurping the logs into a variable. Regression tests drive >64 KB fixtures through `assert_detection_healthy` in both directions: healthy-still-passes (what actually broke) and parse-failure-still-fails (the false-GREEN direction, if the check ordering ever changes). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
…egex it audits
The count pin computed `actual_sites` as `grep -cE "$MARKER_TRIGGERED_RE"` --
the very regex under audit. A trigger site the regex fails to match is
therefore missing from the count as well, and the two errors cancel: the pin
cannot detect the one thing it exists to detect.
Demonstrated: adding a sixth trigger site worded 'triggering a fresh
auto-update' left the suite fully green, including the assertion claiming
exactly five sites. (Rewording an EXISTING site was caught, so the pin was not
useless -- just blind in the direction that matters.)
Derive the expectation from the code decision instead. Every real trigger ends
in `update_tx.send(...)`; the log line is commentary on that send. Two
structural anchors, neither of them the regex: total send sites (7) and
version-detecting sends `update_tx.send(new_version)` (5). The regex must then
match all 5 -- an assertion the old pin could not make, because both of its
operands were the same grep. The remaining 2 sends are the forced-exit
sentinel paths ('unknown (hard timeout)', 'unknown (gateway mismatch)'), which
deliberately carry no trigger phrase and are unreachable in a canary run.
Also correct two overstatements in bug-prevention-patterns.md: the count-pin
row claimed the pin caught 'a site is worded so the marker misses it', which
it did not for a NEW site; and the parse-marker row cited :1546/:1757, which
this same commit's +23 lines had already shifted to :1569/:1780. Both now
quote the distinctive literal instead of a line number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
…hat were not Three related holes around the positive-equality check's skip branch. 1. The 'unset expected-latest -> says it skipped' case asserted an exit code and nothing else, so replacing the entire NOTE in auto-update-canary.sh with `:` left it green. A silent skip is the vacuous pass this gate exists to remove: a reader of a green log cannot tell the check ran from it having been skipped. It now asserts the message, and explicitly unsets the variable so ambient state cannot send the case down the equality branch instead (SEEN_OK carries the value the previous case expects). 2. `cmd_selfupdate` runs in its own process, so nothing Gate A exported reaches it: the deliberately-loud unset NOTE fired on EVERY healthy Gate B run, which is how a warning becomes something everybody scrolls past. Gate B already knows which release it just published -- pass it through rather than re-resolving, since a second source allowed to disagree fails releases for reasons that are not bugs. Version-gated on MARKER_LATEST_SEEN_SINCE: the observed-latest line is new in #5236 and Gate B's subject is the PREVIOUS release, so for exactly one release the binary under test predates the marker. Arming against it would block a release for a line that binary was never built to emit. Self- retiring -- permanently armed from the release after 0.2.124. 3. The workflow does not set CANARY_EXPECTED_LATEST, which is correct and now pinned. Also corrects the claim in two comments that 'a pinned value can only make the equality check FAIL, never pass'. True of SKIPPING; not true of passing. A pinned value that agrees with a silently-wrong comparator makes the check confirm the wrong answer -- asserted rather than resolved, the shape the gate replaces. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
Gate A checks the version the node observed against the tag releases/latest resolves to, so a wrong fetch or normaliser is caught. The comparison that follows is not: mutate compare_versions_for_startup's `latest_ver > current_ver` to `<` and every marker the gate reads is what a healthy run produces. Structural, not a defect in the gate. Gate A's subject is by construction newer than releases/latest (its own release is still a draft), so there is no newer release to find and 'declined to update' is the correct healthy outcome -- an input that can only produce one answer cannot distinguish comparators by their answer. Records the direction too, because the obvious reading is backwards: inverting the operator makes `latest < current` TRUE for a Gate A run, so the node requests an update to the OLDER release. Gate A accepts a trigger as a healthy outcome, so it reports green on a binary that would downgrade itself. Notes that asserting 'the shipping binary declined to update' would close this, and why it has not been added (it would fail a hotfix cut below releases/latest). The comparison is covered by the Rust unit tests on compare_versions_for_startup (newer / equal / older, all three directions) and end-to-end by Gate B, but Gate B only ever runs the PREVIOUS release's binary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
The marker comment claimed the count pin meant 'a sixth site cannot be added silently'. That was only true for a site the regex already matched. Describe the pin as it now works: the count comes from update_tx.send(new_version), a structural anchor the regex cannot influence, and the regex must then match all of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
Adds a bug-prevention-patterns.md section for the pattern itself rather than leaving it as a one-off fix in one script. Piping a producer into a short-circuiting consumer (grep -q, head, read) under pipefail makes the PRODUCER die with SIGPIPE, and pipefail promotes 141 to the pipeline's status -- so a present marker reads as absent. Why it earns a permanent row: - Volume-dependent, therefore intermittent. Below the 64 KB pipe buffer it never fires. Measured: same content, 1 KB -> rc=0; 200 KB -> rc=1 with a wrong diagnosis; a real 3.65 MB node log -> piped `grep -q` exits 141 while `grep -acF` finds the line. No small-fixture test can see it. - It corrupts the DIAGNOSIS: Gate A blamed "the check never ran", sending the next reader at auto-update detection for a shell-pipeline fault. - Framing decides whether it fires, invisibly. Same file, same consumer, match on line 1 of a 165 KB source: `sed ... | grep -qF` exits 141, but `sed ... | tr -d '[:space:]' | grep -qF` exits 0, because tr leaves one line grep must read to EOF. pin_marker depended on that accident without knowing it -- deleting the tr as a simplification would have armed the hazard on every source pin in that file at once. - The real lesson is the partial fix: the commit that first diagnosed this fixed two helpers and left four call sites in the function they serve. So the rule is "grep the repo and fix the set", with the safe forms listed. Also fixes the two remaining instances in scripts this PR owns -- pin_marker and the lifecycle test's diagnosis check, both latent -- and adds a pin over the five pipefail release-gate scripts that fails if the form reappears. Extends the rules file's paths: frontmatter to scripts/**, since the marker section above it already governs the canary scripts and could not load on a script edit. The rest of scripts/ is explicitly recorded as unpinned and unaudited, with the two sites that look most exposed named. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
…river `verify_required_binaries` tested asset presence with `echo "$assets" | grep -xqF "$bin"`. Under this script's `set -euo pipefail`, `grep -q` short-circuits on a match, `echo` takes SIGPIPE and exits 141, and pipefail promotes that to the pipeline's status -- so a binary that IS present reads as MISSING. Measured on this branch: 46 false verdicts in 20000 iterations under 24-way CPU load, 0 in a quiet window. Load-dependent, which is why it survived, and why it matters: CI runners are contended. The consequence is the one the comment above `verify_release_published` warns about verbatim. `wait_for_binaries` is called bare at the `set -e` call site, so a false "missing" kills the driver AFTER crates and the release are published but BEFORE the gateway updates and the Matrix/River announcements. Fixed the whole set rather than the one site that fired, which is this change's own stated lesson: all 8 status-consuming `| grep -q` pipelines in the file are replaced with pipe-free forms (bash glob, or asking git for the single ref). The two `cargo search` sites move the pipeline into an assignment, so they take `|| true` -- the old form sat inside an `if` condition where errexit is disabled, and a bare assignment is not. Also adds `release.sh` to SIGPIPE_SCRIPTS so the existing regex pin covers the driver, and makes a renamed entry in that list fail loudly instead of dropping out of the audit behind `2>/dev/null`. Corrects the volume-fixture comment: the broken case tests volume-resistance, not check ordering -- `log_has` greps the fixture files directly, so ordering does not affect it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
Each of these was demonstrated by applying the regression and watching every suite stay green. 1. The publish step's own `if:`. Assertion 3 asked whether the CANARY step was disabled; nothing asked whether the PUBLISH step was made unconditional. Same outcome -- a red canary that no longer blocks publication -- and only one was pinned. Adding `if: always()` to `Publish release` neutered Gate A with all 6 wiring assertions green. Steps default to running only after every earlier step succeeded, and that default IS the gate; the new pin fails when an `if:` names always()/failure()/cancelled(), and allows a conditional that does not override the default. 2. The notify job. Deleting the two `needs.attach-to-release.result` clauses reinstates the silent-Gate-A regression the workflow comment describes, and left all four suites green. Nothing referenced the notify job or Gate B's job at all. Now pinned: both jobs exist, the notify job needs both, its `if:` calls always(), and all four failure/cancelled clauses are present. 3. Gate B's version gate. Inverting `if version_at_least ...` to `if ! ...` left all 53 assertions green, because the pin grepped for the bare call text -- which the negated form also contains. Inverted, Gate B skips its only positive assertion on every modern release: permanently vacuous, and silent. The decision moves into `prev_emits_latest_seen`, whose behaviour is now tested directly (5 cases), and the call site is matched including its `if ` prefix so a `!` cannot slip in between. Also extracts `step_block` in the wiring test, since finding the whole step rather than the invocation line is what both step-level assertions need. Assertions: canary 53 -> 58, wiring 6 -> 13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
The suite behind the canary is large and green, which makes it easy to assume it covers Gate B. It does not: `cmd_selfupdate` and `resolve_expected_latest` appear in the tests only as source-scrape needles, never as calls. So the tarball download, the extraction check, the exit-42 assertion, `freenet update --quiet`, and the awk field split that reads the updated binary's version all run for the first time during a real release. Stated alongside the other two "what the gates do not cover" entries, and kept distinct from them: those are about what a gate cannot observe while running, this one is about the tests behind it. Also says what IS pinned there, since the distinction changes how to read a Gate B failure -- the arming decision is now behaviourally tested, the I/O sequence it guards is not. Closing it needs a runtime test with a stubbed release archive. Deliberately deferred; recorded so it is a known gap rather than an assumed pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
Appending `|| true` to the canary invocation left all wiring assertions green. The step is still present, still before `--draft=false`, still without `continue-on-error` -- and assertion 1 matches the invocation as a SUBSTRING, so anything appended to that line is invisible to it. This is the likelier neutering route and the worse one. `|| true` is the reflex fix when a gate false-positives at 2am, and it does not read as disabling a gate. The value of this gate is that removing it cannot be quiet. Two assertions, because the two existing ones are blind in different ways: - the canary step's shell must not swallow its own status (`|| true`, `|| :`, `set +e`). The step-key checks cannot see inside `run:`. - the publish step must carry no `if:` AT ALL, tightened from the previous "no always()/failure()/cancelled()". Whether an expression can evaluate true after a failed step is not a judgement a grep should make: `if: success() || github.actor == 'x'` overrides the on-success default without naming any of those functions, and passed the narrower form. An `if:` on the one step whose conditional execution IS the gate should get a human look. Mutations verified RED and reverted GREEN: `|| true` on the invocation, `set +e` in the run block, and the `success() || …` form above. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
…rt collision
Gate A's real path runs for the first time on the next release tag, so the two
things that matter most are what it leaves behind when it blocks, and whether
the reason it gives is true.
EVIDENCE. The two branches likeliest to fire on a HEALTHY release -- "the check
never ran" and "started but never logged an outcome" -- printed no node output
at all, unlike the parse-fail and fetch-fail branches. `cleanup` then deletes
the workdir on EXIT, so a real blocking run left nothing behind, while
RELEASING.md told the operator to "read the job log; it names the offending
line". True for a parse failure, false for exactly the two branches most likely
to block a good release. `dump_node_evidence` tails node.out and the node log to
stderr on every non-zero verdict in both gates. It lives outside
`assert_detection_healthy` on purpose: that function is pure, which is what
makes it fixture-testable.
PORT COLLISION. Reproduced: two `preflight` runs 2s apart, the second reporting
"the startup update check never ran". Exit 43 is EXIT_CODE_ALREADY_RUNNING --
the node died before the update task existed. `assert_detection_healthy` never
consults NODE_EXIT, so the log assertion was the only thing that spoke, and it
named the wrong subsystem. Three parts:
- both gates now classify 43 explicitly. In Gate A it returns 2, not 1: rc=1
skips the retry loop, and ports are redrawn per attempt, so the very next
attempt would have succeeded. Gate A now self-heals; Gate B has no retry, so
there it corrects only the diagnosis.
- ports come from a random 8-port block per run instead of fixed constants,
which is what made two runs on one host collide by construction. CI is a
fresh VM today, but this repo already uses self-hosted runners elsewhere,
where it would be a silent, permanently-misdiagnosed release blocker.
- the header's "safe to run on a machine already running a node" was false for
anything holding those ports. Corrected.
Also, from the same review pass:
- the "check never ran" message is hedged like its empty-log neighbour. A
fresh config dir makes NodeConfig::new fetch the remote gateway index, so a
runner that cannot reach freenet.org dies before the updater exists and
lands here. Naming only the update path misdirects.
- `resolve_expected_latest` gets `--retry 2 --retry-all-errors`. It had none,
and its failure returns before the attempt loop, so one transient blip
blocked a release while every node-side indeterminate got two tries.
- the settle wait is clamped by the outer deadline; it was the one arm that
ignored it, bounded only by the node's own `timeout`.
- the `grep -a` comment claimed dropping `-a` would satisfy every NEGATIVE
check (a vacuous pass). Measured on GNU grep 3.11: wrong. `grep -q` still
matches in binary files, so `log_has` is unaffected both ways; `log_lines`
stdout goes empty and the equality check fails closed. A spurious block, not
a vacuous pass.
- noted that `freenet.*.log` also matches `freenet.error.*.log`, so WARN
markers match twice -- harmless today, wrong for any future count.
Lifecycle cases 7 and 8 cover the two behavioural changes, driven through the
real `cmd_preflight` against fake nodes: 7 asserts a 43 is diagnosed as a
collision AND no longer as "the startup update check never ran"; 8 asserts the
node's own log line survives into the output. 7 -> 9 assertions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
…d pin the version constant Two more pins that verified nothing, both found by mutation rather than reading. TRIGGER PHRASE. `MARKER_TRIGGERED` whitespace-stripped is `triggeringauto-update`, which is a SUBSTRING of the #4073 refusal `not triggering auto-update`. A containment check is therefore satisfied by the refusal alone: the pin was tracking a line whose job is to say the opposite of the thing it claimed to pin. Demonstrated by rewording all four plain trigger sites in freenet.rs -- that assertion stayed green and only the count pin went red. Replaced with the same NEGATIVE SUBTRACTION `node_decided_to_update` has always done: count occurrences of the phrase, subtract the ones that are refusals, and require at least one site left. Counting occurrences rather than testing containment is what makes the subtraction possible at all. It reports 4 today, matching the four plain sites (the fifth versioned send says "triggering immediate auto-update", which only the regex covers). MARKER_LATEST_SEEN_SINCE. Gate B's version gate is now pinned in both directions, but nothing looked at the constant being compared against. Raising it 0.2.124 -> 0.2.999 left the suite green while permanently disarming Gate B's only positive assertion -- the same silent direction as the `!` inversion, reached by editing a different line. Anchored against the crate version in Cargo.toml, which the constant cannot influence. The relationship is real: the constant names the first release whose binary emits MARKER_LATEST_SEEN, that marker is emitted by this source tree, and this tree ships as the next release -- so the constant must sit just ahead of Cargo.toml's version, not behind it and not far past it. Both directions fail with a specific message. The window is a guard against a wrong constant rather than a proof of a right one, and says so; a genuinely slipped release means updating the value deliberately, which is the point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
…rong
SHELLCHECK. `release.sh` is the release driver and gains ~100 lines in this
branch, but was not in the CI shellcheck step -- which is how the SIGPIPE bug
fixed earlier in this branch sat in it unnoticed. Adding it needed the 21
existing findings cleared first.
The 19 SC2155 sites all had the same shape and the same trap. `local x=$(cmd)`
returns `local`'s status, which is always 0, so under this script's `set -e` a
failing command never aborted anything. Splitting the declaration -- the fix
shellcheck asks for -- makes the bare assignment propagate that status, so a
transient `gh` failure would start killing the driver mid-release. Every split
therefore keeps an explicit `|| true`, except where the substitution already
ends in `|| echo ""` and exits 0 on its own. Behaviour is unchanged; the masking
is now written down instead of being a side effect of `local`.
SC2001 becomes a line-wise read rather than `sed`, deliberately not a `printf`
with an unquoted expansion, which would word-split job names. The two SC2034s
are genuinely dead: `--deploy-local` / `--deploy-remote` are deprecated and
their handler only prints a note, so the variables were written once and never
read.
Verified beyond the linter, since this is the live driver: `version_compare`
still answers all five ordering cases under `set -euo pipefail` (an errexit
regression would hang or kill the subshell rather than return a wrong answer),
`--help` still runs, and `verify_required_binaries` still matches whole lines
including rejecting a prefix. The other three release suites stay green.
RELEASING.md. Two corrections, both about telling an operator something untrue
at the moment they are debugging a blocked release:
- "Read the job log; it names the offending line" is true of the parse-fail
and fetch-fail branches and false of the two most likely to fire on a
healthy release. Rewritten to point at the new `canary node evidence` group
and to name the port-collision outcome.
- The orphaned node observed once during review is recorded as a known gap.
It did not reproduce (0 in 9 runs), so there is no mechanism to chase, and
the note says plainly that lifecycle case 4 pins the property against a bash
fake node that cannot produce the fault -- a green case 4 is not evidence
the leak is gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dyjTKM35KGZXjE7jmDTsX
Problem
Auto-update was broken fleet-wide for two consecutive releases (v0.2.120 and v0.2.121) and nothing noticed. #5104 made the node's release-tag fetch return the tag verbatim (
v0.2.121) and normalised it at only one of its two consumers. The detection path kept the raw tag,semver::Version::parse("v0.2.121")failed, every update was dropped with awarn!, and ~1,100 nodes had to be told to runfreenet updateby hand — because a broken updater cannot deliver its own fix.Nothing caught it because every signal we had was one-sided: the release built, published, installed and ran. The one machine positioned to notice —
framework, the designated real-NAT pre-release smoke peer — had been running with--disable-auto-updatesince a #5040 measurement window nine days earlier. The existing pre-release smoke test validates contract subscribe/UPDATE over real NAT; it never tested the updater.Approach
scripts/auto-update-canary.sh, wired in as two gates.Gate A — pre-flight, BLOCKING. Runs the binary that is about to ship and requires its updater to read GitHub's current release tag. It sits inside
attach-to-releasebetween asset upload and un-draft, so the assets are real but nothing has reached a user: a failure costs a stuck draft rather than a stranded fleet. Adds ~1 minute.Gate B — self-update, post-publish. Takes the previous release and requires it to detect this one, exit 42, and self-replace via
freenet update. That is the transition the fleet actually makes — not "the log looks right" but "the old version ends up on the new one". It cannot run earlier: the detection path is hardwired to GitHub's/releases/latest, and a draft release does not appear there. So Gate A is the blocking half and Gate B is the alarm (red job + River dev-room message).scripts/release.shis also changed: itspublish_draft_release()un-drafted the release as soon as all assets were attached, without checking the workflow's conclusion — which is exactly the window Gate A opens between upload and publish. The local release driver would have raced in and published a release whose updater the gate was mid-way through rejecting, silently turning a blocking gate into no gate. It now refuses (non-zero, so the driver aborts rather than announcing) unless the tag's cross-compile attach job concluded successfully. (Deliberately the job, not the run: reading the run would treat a failing non-blocking Gate B as a failed release.)Both gates are
needs-chained steps rather thanrelease: publishedlisteners, so they cannot silently stop running ifRELEASE_PATlapses — that token already suppresses downstream events when unset (#4118), which is the same silent-lapse shape as the incident.Why the assertion is two-sided
assert_detection_healthyrequires theStartup update check against GitHubline to be present andStartup update check: failed to parseto be absent. Absence of the error alone proves nothing: it is equally consistent with the check never running, which is exactly what--disable-auto-update, a dirty build, or a node that never reached the update task all produce.The marker is deliberately truncated at
parseso it covers both arms ofcompare_versions_for_startup— the latest-version arm (the #5221 break) and the current-version arm. Both returnNoneand both then reach the completion line, so under a longer marker a node that failed the current-version parse looked identical to a healthy one.The canary also fails outright if the node under test has auto-update disabled. That turns "the canary was quietly switched off" from a human-memory dependency — which lapsed for nine days and is the actual root cause of the two-release blind spot — into a red build.
GitHub being unreachable is a distinct third outcome (retry), so a network blip neither fails a good release nor gets papered over with a retry that would also swallow a real parse failure.
Gate A asserts a positive fact, not the absence of an error
The node now logs the release it actually compared against (
latest=), and the gate resolves the expected value independently from the samereleases/latestredirect the node uses, then requires them to match.This is load-bearing. "No error appeared" is satisfied by a component that is silently wrong as well as by one that works: a
version_from_tagregressed to a constant, or a normaliser truncating0.2.123to0.2.12, still parses, still compares, still declines to update, and logs a completion line byte-identical to a healthy node's. Verified by mutation — a truncating comparator drives the gate red with a specific diagnosis, while the unmutated build on the same command passes.Testing
Correction to an earlier revision of this section. It previously claimed
preflight v0.2.122 → passes. That is no longer true and should not be relied on: Gate A now requires two markers that no already-shipped binary emits —Startup update check complete(was a compiled-outdebug!) and the newlatest=observed-release line. Runningpreflightagainst a published binary such as v0.2.123 therefore returns a blocking UNVERIFIED, correctly. Gate A's subject is the binary about to ship, which carries both markers.Validated at this head:
preflightagainst a release build of this branchpreflight v0.2.120preflightagainst any published binaryselfupdate 0.2.119 -> 0.2.122freenet update, new binary)selfupdate 0.2.121 -> 0.2.122The counterfactual holds: had Gate A existed at v0.2.120, it would have blocked publication and the outage would not have happened.
Suites, run unfiltered:
auto-update-canary_test.sh59,release_canary_wiring_test.sh14,release_wait_for_binaries_test.sh16,auto-update-canary_lifecycle_test.sh9.shellcheck -xclean;release.shis now in the CI shellcheck list.Every pin here has been mutation-tested — applied the regression it names, observed it go red, reverted. That discipline is the point of the PR and it repeatedly caught pins that verified nothing (below).
Review
Full-tier across several rounds and multiple independent blind lenses (code-first, testing, adversarial, plus repeated verification passes). Together they found fifteen findings, every one fixed. The ones worth naming, because they are the PR's own subject recurring inside it:
Four pins that verified nothing.
//comment inauto_update.rs's own#[cfg(test)]block. Rewording the productionwarn!left all assertions green while the gate reported OK for a node that had failed to parse. Fixed by shortening the marker and binding the pin to thewarn!macro.update_tx.send(new_version), a structural fact the regex cannot influence.source pin: trigger phrasewas satisfied by the Move release pipeline to GitHub Actions, with HTTP update-agent on gateways #4073 refusal line — whitespace-stripped, its needle is a substring ofnot triggering auto-update, so the pin tracked a line saying the opposite of what it pinned.want_msg, so the harness compared only exit codes and the message could be replaced with a no-op undetected.Two routes to silently disabling the gate, both leaving every wiring assertion green:
if: always()on the publish step, and|| trueappended to the canary invocation. The second matters more — it is the reflex fix when a gate false-positives at 2am and it does not look like disabling a gate. Root cause was structural: the wiring test policed the canary step while nothing asserted anything about the publish step, which is the step whose conditional execution is the gate. Both now pinned.A live bug in the release driver, pre-existing on
main.verify_required_binariespiped a variable intogrep -xqFunderset -euo pipefail;grep -qshort-circuits, the producer takes SIGPIPE,pipefailpromotes 141, and a binary that is present reads as missing. Measured at 46 in 20,000 under CPU contention, 0 in a quiet window. Consequence:wait_for_binariesreturns 1 from a bare call underset -eand the driver dies after publish, before gateway updates and announcements — verbatim the failurerelease.shwarns about. It also maderelease_wait_for_binaries_test.shgenuinely flaky (~3 failures in 25 runs, a different binary each time); after the fix, 30 of 30 runs pass. All eight status-consuming sites inrelease.shwere fixed, the file added to the SIGPIPE pin, and the hazard documented in.claude/rules/bug-prevention-patterns.mdwith an audit grep.Gate A destroyed its own evidence. The two branches most likely to fire on a healthy release printed no node log at all, and cleanup then deleted the workdir — while
RELEASING.mdtold the operator to read the job log. Fixed; the log is now retained.Overlapping runs were misdiagnosed. Two
preflightruns on one host collided on fixed ports; the node exited 43 (ALREADY_RUNNING) and the gate reported "the startup update check never ran" — an auto-update fault for a port collision — then returned 1, skipping the retry that would have fixed it. Ports are now redrawn per attempt, exit 43 is classified environmental (rc=2, retried), and the header's false "safe to run on a machine already running a node" claim is corrected.Also fixed: the gate's own
curlhad no--retry, so a single transient blip blocked a release; a gateway-index fetch failure was reported as "the update check never ran", pointing at the wrong subsystem; theMARKER_LATEST_SEEN_SINCEconstant and Gate B's version gate were unpinned and survived inversion; and a code comment stated the wrong failure direction forgrep -a.Limitations, stated plainly
latest_ver > current_verlogs correctly, declines to update, and the gate stays green — structurally, because the shipping binary is by construction newer than latest, so "declined" is the right outcome. Covered by the Rust unit tests; Gate B only exercises the previous release's binary.freenet updateinvocation and version comparison are exercised only in production. Deliberately deferred rather than dropped; recorded indocs/RELEASING.md.Also done (not in this diff)
The stale
99-disable-auto-update-5040.confdrop-in has been deleted fromframeworkandsystemctl --user daemon-reloadrun; the running process is nowfreenet networkwith no--disable-auto-update, and the #5040 capture is confirmed complete (results posted 2026-07-30). The node was not stopped and stays on the network.Refs #5222, #5221, #5104.
[AI-assisted - Claude]