Skip to content

ci(release): gate releases on a two-sided auto-update canary (#5222) - #5236

Merged
sanity merged 27 commits into
mainfrom
ci/auto-update-release-canary
Aug 12, 2026
Merged

ci(release): gate releases on a two-sided auto-update canary (#5222)#5236
sanity merged 27 commits into
mainfrom
ci/auto-update-release-canary

Conversation

@sanity

@sanity sanity commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

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 a warn!, and ~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 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-update since 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-release between 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.sh is also changed: its publish_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 than release: published listeners, so they cannot silently stop running if RELEASE_PAT lapses — 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_healthy requires the Startup update check against GitHub line to be present and Startup update check: failed to parse to 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 parse so it covers both arms of compare_versions_for_startup — the latest-version arm (the #5221 break) and the current-version arm. Both return None and 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 same releases/latest redirect 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_tag regressed to a constant, or a normaliser truncating 0.2.123 to 0.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 emitsStartup update check complete (was a compiled-out debug!) and the new latest= observed-release line. Running preflight against 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:

Gate Case Result
A preflight against a release build of this branch passes; reports the release compared against
A comparator mutated to truncate the version fails, naming the wrong release it compared against
A preflight v0.2.120 fails — would have blocked that release
A preflight against any published binary UNVERIFIED by construction — markers absent (see above)
B selfupdate 0.2.119 -> 0.2.122 passes end-to-end (detect, exit 42, freenet update, new binary)
B selfupdate 0.2.121 -> 0.2.122 fails — the fleet's actual situation

The 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.sh 59, release_canary_wiring_test.sh 14, release_wait_for_binaries_test.sh 16, auto-update-canary_lifecycle_test.sh 9. shellcheck -x clean; release.sh is 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.

  • A whole-file grep for the parse-failure marker was satisfied by a // comment in auto_update.rs's own #[cfg(test)] block. Rewording the production warn! 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 the warn! macro.
  • The trigger-site count pin derived the expected count from the very regex it audited, so a site the regex missed was invisible to the count too. Now anchored on update_tx.send(new_version), a structural fact the regex cannot influence.
  • The source pin: trigger phrase was 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 of not triggering auto-update, so the pin tracked a line saying the opposite of what it pinned.
  • An assertion passed no 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 || true appended 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_binaries piped a variable into grep -xqF under set -euo pipefail; grep -q short-circuits, the producer takes SIGPIPE, pipefail promotes 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_binaries returns 1 from a bare call under set -e and the driver dies after publish, before gateway updates and announcements — verbatim the failure release.sh warns about. It also made release_wait_for_binaries_test.sh genuinely flaky (~3 failures in 25 runs, a different binary each time); after the fix, 30 of 30 runs pass. All eight status-consuming sites in release.sh were fixed, the file added to the SIGPIPE pin, and the hazard documented in .claude/rules/bug-prevention-patterns.md with 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.md told the operator to read the job log. Fixed; the log is now retained.

Overlapping runs were misdiagnosed. Two preflight runs 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 curl had 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; the MARKER_LATEST_SEEN_SINCE constant and Gate B's version gate were unpinned and survived inversion; and a code comment stated the wrong failure direction for grep -a.

Limitations, stated plainly

  • Gate A verifies that the shipping binary can parse the current latest tag. It cannot verify it against a tag format that does not exist yet. It would have caught fix(update): restore the node-bucket pin that #5103 silently voided #5104; it would not catch a future change to the tag format itself. Gate B covers that one release later, the earliest the real detection path can be exercised at all.
  • A wrong comparison of correct values is invisible to Gate A. Inverting latest_ver > current_ver logs 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.
  • Gate B's own code is never executed by a test. The download, extraction check, exit-42 assertion, freenet update invocation and version comparison are exercised only in production. Deliberately deferred rather than dropped; recorded in docs/RELEASING.md.
  • A node process was once observed outliving the script by ~4 minutes, reaped by its own timeout. Never reproduced, and the existing lifecycle pin cannot see it because it drives a fake node with none of a real node's shutdown behaviour. Documented rather than chased.

Also done (not in this diff)

The stale 99-disable-auto-update-5040.conf drop-in has been deleted from framework and systemctl --user daemon-reload run; the running process is now freenet network with 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]

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
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Rule Review: Minor commit-message hygiene only

Rules checked: git-workflow.md, code-style.md, testing.md
Files reviewed: 12 (2 matched code-style.md/testing.md scope: crates/core/src/bin/commands/auto_update.rs, crates/core/src/bin/freenet.rs; the rest — workflow YAML, shell scripts, docs — fall outside the paths these three rule files cover)

The two Rust changes are limited to adding tracing::info! calls (raising debug!info! so release builds emit the marker, plus a new positive-fact latest= log line) with load-bearing WHY comments explaining the level choice. No .unwrap(), no spawns, no retry/backoff loops, no biased; select, no new config fields, no public API — nothing in code-style.md or testing.md is implicated by these hunks.

The rest of the diff (CI workflow wiring, scripts/*.sh, docs/RELEASING.md) isn't in scope for the three loaded rule files (code-style.md is Rust-only via its path frontmatter, testing.md is scoped to crates/core/**), so it wasn't evaluated against them here.

Warnings

None.

Info

  • commits.txt:19 (303ac7c2 wip: in-progress review fixups (recovered from stopped session)) — subject line uses wip:, which is not one of the valid conventional-commit prefixes (rule: git-workflow.md "WHEN creating a commit message"). Likely immaterial if the PR is squash-merged under a compliant title, but flagging per the letter of the rule.

Rule review against .claude/rules/. WARNING findings block merge.

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
@sanity

sanity commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Multi-perspective review (Full tier) — all findings addressed

This 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.

Blocking

1. Gate A did not actually block anything. scripts/release.sh's publish_draft_release() un-drafts the release the moment all assets are attached, without checking the workflow's conclusion — which is exactly the multi-minute window this PR opens between upload and publish. The local release driver would have raced in and published a release whose updater the gate was in the middle of rejecting, silently turning a blocking gate into no gate. It now refuses while the tag's cross-compile run is unfinished or failed.

This was the most important finding: without it, the PR's central claim was false.

2. attach-to-release kept timeout-minutes: 10, which the canary's retry path could exceed (measured baseline for that job is ~40s, so nobody had ever needed the headroom). GitHub would have cancelled the job mid-canary, skipping the Publish step and leaving a permanently stuck draft — the precise failure the retry existed to ride out. Raised to 25, canary step bounded at 12, and the worst case cut (180s node timeout, 2 attempts, no trailing sleep).

3. 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.

4. The failure notification covered only the non-blocking gate. A Gate A failure fails attach-to-release, which skips Gate B, so needs.auto-update-selfupdate-canary.result is 'skipped' and no message went out. The blocking gate was the silent one — a release stuck as an unpublished draft with nobody told. That is the same silent-fail-closed shape this PR exists to remove, reintroduced inside the fix. The notification now covers both gates and reports which failed.

Important

  • MARKER_TRIGGERED was 'triggering auto-update' — a substring of the Move release pipeline to GitHub Actions, with HTTP update-agent on gateways #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 could not produce a different answer. It wiped only the logs, leaving the node's persisted GitHub rate-limit cooldown in place, so every attempt re-read the same cooldown and reported the same INDETERMINATE without asking GitHub again. Each attempt now gets a fresh state tree.
  • Gate B cried wolf on infrastructure failures, alarming "the fleet will not converge" when GitHub was merely unreachable. 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.
  • The Gate A recovery advice was actively harmful. release.yml publishes to crates.io before pushing the tag, so a Gate A block leaves the crate live with no GitHub release — and the docs told you to delete the tag, stranding an unyankable crates.io version pointing at nothing. Rewritten to name the split state, try a re-run first for infrastructure failures, and warn that a dirty local build disables auto-update and so cannot reproduce the gate.

Plus unchecked tar/curl in Gate B, curl --max-time, and an exact rather than substring version match (0.2.12 matched 0.2.121).

Dismissed, with reasons

  • "Gate A should not block on a node that fails to boot" — kept blocking. A shipping binary that cannot start at all is worth stopping a release for; the fix was to make the diagnosis distinct so it does not send someone hunting in the update path.
  • ".[0] of the releases API is created_at-ordered, not semver-ordered" — real, but harmless for freenet's linear release flow, and a semver sort would pick a less representative previous release for the fleet. Noted, not changed.
  • "first-release / empty-jq case fails" — correct fail-closed shape on a repo with one release; not reachable here.

Re-validation after the fixes

Because 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:

Gate Case Result
A preflight v0.2.120 fails (correct)
A preflight v0.2.122 passes
B selfupdate 0.2.119 -> 0.2.122 passes end-to-end
B selfupdate 0.2.121 -> 0.2.122 fails (correct)

shellcheck clean; the 9-case self-test passes; mutation testing still catches deletion of each assertion branch.

[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
@sanity

sanity commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Second review round — re-reviewed the fix commit, found a blocking bug in it

Per 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: release.sh announced a release it had just refused to publish

publish_draft_release() returned 0 when the new guard declined, so the caller reported success and the driver went on to update the gateways and announce to Matrix and River — for a release still sitting as an unpublished draft. It now returns non-zero (release.sh is set -euo pipefail, so that aborts the driver), and the function checks is it still a draft first, so an already-published release stays a clean no-op.

The same guard was also inverted. An empty gh run list does not yield "" as I assumed — it yields the literal null:null, because jq interpolates .[0] == null. Verified:

$ gh run list --workflow=cross-compile.yml --branch v9.9.9-nonexistent \
    --json status,conclusion --jq '.[0] | "\(.status):\(.conclusion)"'
null:null

So "no run found" refused, while the only route 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.

(The reviewer also checked the thing most likely to have silently voided the guard — whether gh run list --branch matches tag-triggered runs. It does: --branch v0.2.122 returns completed:success.)

False red: the canary failed a healthy binary

Caught by re-running the gates against real binaries rather than trusting the diff. Two causes, both verified empirically:

  • The process-group kill didn't 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 (pgids differ). Every run left a node alive holding its ports. exec-ing the timeout collapses the two. My first test of this fix used exec -a and so wasn't representative of the real multi-statement subshell — retested in the real shape.
  • The poll loop's budget was dishonest, charging 3s per pass while each pass also paid for a grep and a process check. On a loaded machine a slow-booting node was reported as "the startup update check never ran". A false blocking failure is worse than no canary: the first person to hit it learns to override the gate. Now measured against the clock.

Marker: my previous fix broke it a different way

Narrowing MARKER_TRIGGERED to one call site's full phrase (fixing the substring bug) missed two of the four real trigger sitesfreenet.rs logs triggering auto-update at :524, :650, :731, :853, and the #4073 refusal at :519. Matching the phrase and subtracting the refusal is the only form with both properties. The test now drives all five lines through the real helper.

The markers are now also pinned against crates/core/src/bin/*.rs, so a reword there fails CI rather than leaving the canary matching strings nothing emits — the self-matching-pin failure mode AGENTS.md warns about, which my previous hardcoded assertions had.

Re-validation

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 is created_at-ordered, not semver-ordered. Harmless for freenet's linear release flow.
  • Gate B's freenet update spends one unauthenticated api.github.com request 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
@sanity

sanity commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

Rule-review warning addressed, plus a correction to my own commit message

The rule-review/warnings check flagged that the previous commit fixed two bugs in code no test exercised. It was right — auto-update-canary_test.sh only drives the pure functions, so the part that actually runs a process, which is where both bugs were, had zero coverage.

New: scripts/auto-update-canary_lifecycle_test.sh

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: my previous commit message stated a wrong cause

Checking the rule-review's premise made me re-derive the reasoning, and one claim does not hold. The previous commit said 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 wrong in the opposite direction: charging 3s per pass while each pass costs slightly more than 3s makes the loop run marginally longer than nominal, not shorter. It was never the cause of the false "check never ran" verdict. The real cause was entirely the leaked nodes — earlier runs' nodes were still alive stealing CPU, so the node under test booted too slowly to log its check inside the window.

Moving to wall-clock is still the right way to express a time budget, but it fixed no bug, and the code comment no longer claims it did.

Related overclaim, same root: 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 — which is exactly how the overclaim surfaced, since a hand-written mutation of it did not reproduce the failure. Case 1 now says what it actually guarantees.

Both corrections are in fba043e68. Nothing about the gates' behaviour changes; the earlier validation results stand.

I have not added the review-override label — the warning identified a real gap and it is now closed rather than waived.

[AI-assisted - Claude]

…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
@sanity

sanity commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Fixed: Gate A could pass with no evidence

Adversarial review found a blocking bug in the gate this PR adds. Pushed a fix in 13876fc.

The bug

run_node_until_check slept a flat sleep 5 after the "Startup update check against GitHub" line, 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: DNS, connect, redirects). So a GitHub that answered between 5s and 10s — loaded runner, redirect hop, mild rate-limit backoff — got its node SIGTERM'd before it could log success, failed to parse latest version, or failed to fetch latest version.

assert_detection_healthy then saw CHECK_RAN present, no DISABLED, no PARSE_FAIL, no FETCH_FAIL, and fell through to return 0OK.

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 fix

The 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 tracing::debug!, and crates/core/Cargo.toml sets release_max_level_info, which compiles debug! out of release builds entirely.

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

  • 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, CANARY_OUTCOME_WAIT_SECS), returning the instant it arrives. The happy path is now faster than the old fixed sleep.
  • 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.

Proof, both directions

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.

Running the new test file against the pre-fix script (extracted via git show HEAD:scripts/auto-update-canary.sh):

ok   - cmd_preflight returns 0 for a healthy binary
ok   - cmd_preflight fails a binary whose updater cannot parse the tag
ok   - NODE_EXIT preserves the node's own exit 42
ok   - no node survives run_node_until_check
FAIL - cmd_preflight returned OK for a binary whose updater FAILED TO PARSE, because the
       failure was logged 8s after the check started (the #5236 vacuous pass)
FAIL - cmd_preflight returned OK for a node that logged NO outcome

After the fix, all six pass, and case 5 asserts on the diagnosis (could not parse the version GitHub returned), not just the exit code — the point is that the canary now sees the parse failure, not merely that it stopped saying OK.

Also mutation-tested the new source pins: demoting the completion marker back to tracing::debug! fails CI with a specific message. All 22 pre-existing assertions still pass, unchanged.

Two related findings from the same review

  • ci.yml shellchecked four scripts but not the three canary scripts, despite this PR claiming they were shellcheck clean — nothing was holding them to it. Added (with -x, since the tests source the canary). All seven pass.
  • 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 to publish.

One disclosure

A 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
@sanity

sanity commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator Author

Consolidated review — 5 independent lenses

Reviewed at c91a8a4bd. Lenses: two testing, two skeptical, one big-picture, each blind
to the others. Between them they executed both suites, ran nine-plus mutations against
throwaway copies, reconstructed the pre-fix script to check the claimed reproduction, and
traced the gate into the real node source. Findings I verified myself are marked.

The headline claim holds and is mutation-verified: the original vacuous pass is
genuinely fixed, the process-running path (spawn / poll / kill / classify) is now
exercised rather than only the pure helpers, and no path was found where a missing
marker, timeout, crashed node, missing binary, network failure, or unparseable log yields
rc=0 on today's code.

But two demonstrated false-greens remain, both one ordinary edit away from live. The
gate's green is an inference from the absence of two exact strings, the enumeration is
incomplete, and the pin meant to keep it honest is vacuous.


BLOCKING — the parse-failure source pin matches a comment, not the code

auto-update-canary_test.sh:226 pins MARKER_PARSE_FAIL with a whole-file grep -qF.
The string occurs twice in crates/core/src/bin/commands/auto_update.rs:

  • :1546 — the production tracing::warn! the pin exists to protect
  • :1757 — inside a // comment in the #[cfg(test)] mod tests block

I confirmed both occurrences. Mutation-proved end to end by one lens: reword ONLY the
production warn! and all 22 assertions still pass, including
ok - source pin: parse-failure marker. A node then carrying the #5221 bug logs
check-ran + reworded-warn + check-complete, and the real script returns rc=0 with
OK: startup update check ran to completion and parsed GitHub's response.

So a routine reword of a log message silently turns Gate A back into the thing this PR
removes, CI green throughout. This is the trap AGENTS.md names directly ("a bare anchor
matches a later occurrence").

Being fixed two ways, because the first alone would work only by luck: shorten the marker
(below), and bind the pin to the tracing::warn! call itself, the way the INFO-level
check-complete pin at _test.sh:217 already does for its target. Mutation-tested against
that exact reword.

BLOCKING — an unenumerated parse failure, with the gate printing the opposite

auto_update.rs:1535 emits "Startup update check: failed to parse CURRENT version...".
No marker matches it; it returns None, falls through to the completion line, and reads
healthy. Demonstrated by execution on a three-line fixture:

OK: startup update check ran to completion and parsed GitHub's response.   rc=0

This is #5104's mistake applied to the other operand. Not reachable with today's
build_info::VERSION (Cargo enforces semver), which is the only reason it is not filed
higher. Fix is one string: MARKER_PARSE_FAIL='Startup update check: failed to parse'
covers both operands — and, as it happens, stops the comment match above too.

IMPORTANT — nothing pins the workflow wiring

Nothing asserts that cross-compile.yml still runs the preflight step, or that it runs
before gh release edit --draft=false. Delete the step or move the publish above it
and the blocking gate becomes a no-op with the whole suite green — the "silently
removable gate" shape #5222 exists to eliminate. ATTACH_JOB_NAME (release.sh:1206) must
also match cross-compile.yml:362 verbatim, with no pin on the pair.

The repo already has this pattern: release_mergequeue_test.sh:45 greps release.yml for
gh pr merge, and merge_group_concurrency_test.sh pins concurrency keys. Being added,
with order asserted rather than mere presence.

IMPORTANT — an unprotected gh call can abort the release driver mid-build

release.sh:1381 (added here) is a bare run_status=$(gh run view ... 2>/dev/null) under
set -euo pipefail, inside wait_for_binaries, which is called bare at :1598 — so
errexit is live. A transient gh failure kills the whole driver. Verified:

$ bash -c 'set -euo pipefail; f(){ return 1; }; x=$(f); echo reached'; echo "exit:$?"
exit:1          # "reached" never printed

The consequence is in the script's own comment at :1216-1218 — the abort happens before
the gateway update and the Matrix/River announcement, so "a release that published
perfectly well would silently never be announced."
The sibling at :1370
(attach_job_state) has the same shape and is pre-existing; both are being fixed, since
leaving one is leaving the bug.

Related, same function: publish_draft_release's is_draft=$(gh release view ... || echo "false") returns 0 on a gh failure — "unknown, nothing to gate" — while every other
unknown in that function refuses with 1. It cannot publish an ungated release, but it lets
the driver proceed for a release that may still be a draft.

IMPORTANT — the new release.sh publish-gate logic has no tests

release.sh:1206-1275 adds ~100 lines of new decision logic on the release critical path,
in a function whose own comment records a prior fail-open. Nothing covers job_state
values "" / in_progress: / completed:failure / completed:success.

IMPORTANT — Gate A's healthy verdict is byte-identical to a silently-wrong comparator

The expected answer IS "no newer version", so version_from_tag regressing to a constant,
or a truncating normaliser whose output still parses, produces the same log and the same
green. Gate A can only see failures that emit a WARN.

The structural fix dissolves this and the two BLOCKING items as a class rather than one at
a time: have the completion line carry what it compared against (latest = ... beside the
existing current = ...) and have the canary assert it equals the tag CI already knows.
That converts absence-of-error into a positive equality check. Worth considering as a
follow-up even after the string fixes land.

IMPORTANT — what Gate A proves is narrower than its name suggests

Gate A exercises fetch -> tag normalise -> semver parse -> compare, and nothing else. It
does not exercise signature verification, checksum matching, asset download, binary
swap, the exit-42 supervisor plumbing, or crash-loop rollback. Gate B does cover
download/signature/checksum/swap, but it is post-publish, non-blocking, and runs the
previous release's binary — so a break in the shipping binary's installer half is
caught one release later. That is one release of exposure rather than two, better than the
incident, but the same shape.

RELEASING.md describes Gate A accurately but never states its limits, and the PR's
"Limitation, stated plainly" section names only the future-tag-format case. A paragraph is
being added, because the next person hitting a broken installer will reasonably ask "we
have an auto-update canary, why didn't it catch this?"


Minor

  • A fifth trigger site the detector cannot see. auto-update-canary.sh:73-83
    enumerates "FOUR such sites" and _test.sh:151 claims to cover "ALL FOUR". There are
    five: freenet.rs:609 logs "Urgent update confirmed on GitHub, triggering IMMEDIATE
    auto-update", which MARKER_TRIGGERED does not match. Fail-closed, but comment and test
    both assert an enumeration that is wrong.
  • pipefail + SIGPIPE in node_decided_to_update / node_check_settled
    (:155-156, :168): grep ... | grep -q . returns FALSE on a log that does contain
    the marker once output exceeds the 64 KB pipe buffer. Measured: 400 matching lines
    rc=0, 700 lines rc=141, 30/30 reproducible above threshold. Unreachable at canary
    volumes and fail-closed, but a live trap for anyone reusing these helpers.
  • CANARY_TIMEOUT_SECS is unvalidated (:114) while its two neighbours at :125 and
    :140 both get numeric guards. A non-numeric override dies at the arithmetic under
    set -u.
  • The INFO pin is not function-bounded. It is a cross-file scrape so the self-match
    trap cannot apply, but it would be satisfied by the literal appearing anywhere in
    freenet.rs including its test module. One occurrence today.
  • Lifecycle case 4's leak check is timing-dependent — the fake runs under a 15s
    timeout and the test greps ~6s in, so on a loaded runner the leak could already be
    dead and the case would pass vacuously.
  • Log rate limiting is on in release builds (1000 events/s global, per-callsite cap).
    A dropped WARN with a surviving completion line is a theoretical false green; the window
    is microseconds and a canary node is nowhere near that rate, but
    FREENET_DISABLE_LOG_RATE_LIMIT=1 in the canary subshell removes the class for free.
  • Fixture drift: case 5's fake models a PRE-ci(release): gate releases on a two-sided auto-update canary (#5222) #5236 binary (parse-fail, no completion
    line). A post-ci(release): gate releases on a two-sided auto-update canary (#5222) #5236 binary with a Fleet cannot auto-update: #5104 made the node parse the raw 'v0.2.121' tag as semver and silently drop every update #5221-class bug logs parse-fail THEN completion. The
    verdict is identical either way, so a missing fixture rather than a hole.

Confirmed sound

assert_detection_healthy has no OK-without-evidence path — all seven exits enumerated,
and the only return 0 requires real emitted-log evidence, never inferred from absence.
INDETERMINATE is not an ignore-hatch: both callers convert it to hard failure once retries
exhaust, so it buys a retry, not a pass. No CI-chasing found — the timeout raise is
bounded and justified, CANARY_OUTCOME_WAIT_SECS=20 is derived from the real
PROBE_CHAIN_TIMEOUT=10s rather than tuned until green, and the sleep-to-polling change
tightened the gate. Nothing removed or weakened in the test files (22 -> 28, all
additions). The debug!->info! promotion is justified, fires at most once per process
on a non-hot path, is not an enumerated telemetry event, and carries a 17-line guard
comment naming the script that greps it. Both un-draft sites are gated. shellcheck -x
clean. The new tests run on every PR and block merge.

Filed separately, deliberately out of scope here

#5274 — Gate B (cmd_selfupdate) has zero test coverage, and the "node never logs at all"
path is untested while running ~8.3 min against a 12-min step budget.

Lenses: code-first, testing x2, skeptical x2, big-picture.

[AI-assisted - Claude]

sanity and others added 17 commits August 11, 2026 20:20
… 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
sanity and others added 4 commits August 12, 2026 10:00
…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
@sanity
sanity added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit 9e111b0 Aug 12, 2026
18 checks passed
@sanity
sanity deleted the ci/auto-update-release-canary branch August 12, 2026 16:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant