ci: three robustness gaps — a check that refused the narrow case, a download that blamed tar, and a sanitizer matrix the bank example was never in (fixes #675, fixes #679, refs #672) - #683
Conversation
…y instrumented?", without lowering the floor that makes its sweep mean something (fixes #675) `scripts/check_sanitizer_instrumentation.sh` conflated two questions behind one floor. "Did this check examine a representative set?" needs the floor, and that is the CI invocation. "Is this one binary instrumented?" is a yes/no about a single file and needs no floor at all -- but the script refused it: only examined 1 binaries -- too few for this check to mean anything so a developer who had built one target under a sanitizer preset answered it by hand with `nm | grep __tsan_`, re-deriving the per-mode symbol table and the SIGPIPE trap the script already encodes. The #673 lane did exactly that. `--binary <file> <mode>` answers the second question against the same symbol table and skips the floor. The constraint that matters is that it must not be usable where the floor was meant to apply, and that is enforced structurally rather than by convention: the mode refuses outright, exit 2, when GITHUB_ACTIONS is set, so no step of any workflow in this repository can reach it. The sweep is unchanged -- same floor, same message, same exit codes. Both halves are pinned by a new self-test, following the repository's scripts/test_check_*.sh convention and running in drift-guard.yml's sanitizer-can-fail job. It needs no compiler beyond `cc` and no sanitizer runtime: the gate's whole measurement is `nm | grep -c __<mode>_`, so a fixture that merely defines a function of that name exercises the gate rather than clang, and a hand-written CTestTestfile.cmake is all `ctest --show-only` needs. Measured, not asserted. The self-test's two load-bearing cases were confirmed to fail against a mutated checker on this revision: - guard replaced by `if false` -> error: --binary ran under GITHUB_ACTIONS -- the floor can now be bypassed from a workflow step: 1 self-test check(s) failed - floor lowered from 2 to 1 -> error: the sweep accepted a one-binary tree -- the floor has been lowered: 1 self-test check(s) failed and all twelve cases pass on the unmutated script. Not verified: the narrow mode against a real sanitizer-instrumented binary -- the fixtures carry the symbol name, not a sanitizer runtime. That is deliberate (it keeps the self-test in a dependency-free job), and the sweep path, which shares the same `count_symbols` helper, is exercised against real instrumented binaries by the three CI legs that already run it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…rt it as "not in gzip format" (refs #672) On 2026-09-21 both GCC legs of PR #671 died in the `Install sccache` step with gzip: stdin: not in gzip format tar: Child returned status 1 tar: Error is not recoverable: exiting now ##[error]Process completed with exit code 2 Nothing compiled, and nothing in the log named the download. The step piped `curl -sSL <url>` straight into `tar -xz`, and plain curl treats an HTTP 4xx or 5xx as a *successful* transfer of whatever body came back -- so an error page went down the pipe and the decompressor was the only thing that complained. `set -o pipefail` would not have helped: GitHub runs `run:` under `bash -e` without it, so the pipeline's status is tar's regardless. Reproduced locally against a server that returns 503, the old shape and the new one, both under `bash -e`: =========== OLD SHAPE, 503 (bash -e, no pipefail) =========== gzip: stdin: not in gzip format tar: Child returned status 1 tar: Error is not recoverable: exiting now exit=2 =========== NEW SHAPE, 503 =========== curl: (22) The requested URL returned error: 503 exit=22 =========== NEW SHAPE, 200 =========== exit=0 total 4 -rwxr-xr-x 1 yaraslau yaraslau 28 Sep 21 18:36 sccache -- the third block being the success path over a tarball with the real release's member layout, so `--strip-components=1` still lands the binary in place. Applied to all nine copies of the step in ci.yml (the first carries the full reasoning, the other eight point at it), and to the two other downloads in this repository with the identical defect and the identical one-flag fix: docs.yml's Doxygen tarball and mutation.yml's Mull .deb. Those two are the same finding, not a separate one -- the Mull case is measurably worse, since a 503 puts 64 bytes of HTML in `${asset}` and `dpkg-deb` is left to object to the archive. Deliberately NOT in scope, and #672 stays open for them: - a retry policy for dependency installation; - a CI-wide marker that distinguishes "the environment failed" from "the change failed" in the check list. Both are decisions rather than implementations, and neither is needed for the one case where the log actively misled. The three apt/PPA outages that make up the rest of #672 are untouched by this. Found while doing it, filed rather than folded: `wget -qO- https://apt.llvm.org/ llvm.sh | sudo bash` (nine sites in ci.yml, one in mutation.yml) fails the other way. Measured against the same 503 server: `wget -qO-` exits 8 and writes zero bytes, `bash` reads empty input and exits 0, and the pipeline exits 0 -- a clang-installation step that reports success having installed nothing. Filed separately because it is a different failure shape needing a different fix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
…argets that were never going to carry one (fixes #679) Measured on 5fc5e78: `linux-sanitizers`, `kanban-tsan`, `ladder-sanitizers` and `valgrind` set zero `MORPH_BUILD_BANK_*` flags, against eight places elsewhere in ci.yml that set `MORPH_BUILD_BANK_EXAMPLE=ON`. Bank is not a rung, so `MORPH_BUILD_LADDER=ON` does not reach it either. Nothing was written down about excluding it; it was an option nobody turned on. Two halves, and the second was not in the ticket. **1. bank's targets never called `apply_sanitizers()`.** `ladder_bank_server` was the only one that did. So flipping the CI flag alone would not have instrumented anything -- it would have built `bank_lib`, `bank_cli` and all three test binaries blind. Measured, on a `clang-ubsan` configure with bank on and the blocks absent: ::error::check_sanitizer_instrumentation: 3 of 9 ctest binaries are not ubsan-instrumented naming bank_tests, bank_gui_tests and bank_gui_qml_tests. With the blocks in place the same sweep reports 9 of 9. The blocks are also required *together*: removing bank_tests' alone, with bank_lib's kept, does not merely leave the binary unchecked, it fails to link with `undefined reference to __ubsan_handle_type_mismatch_v1_abort`. **2. a `bank-sanitizers` job (clang-ubsan, Qt + bank + GUI)**, rather than a flag on `linux-sanitizers`' clang-ubsan leg, which is what the ticket proposed. Two measured reasons. That leg builds no Qt and its own comment reserves the matrix against GUI stacks, while bank's GUI is where the UB was. And, cold and cacheless on 12 cores with clang 22.1.8: leg's current shape (core + net + offline_sqlite, no Qt) configure 36s build 144s 144 ninja edges this job's shape (core + Qt + bank + bank GUI) configure 64s build 391s 287 ninja edges Folding one into the other roughly triples the slowest leg of a three-leg matrix, whose duration is then the matrix's. Split out, **the three existing legs' flags are not changed at all, so the wall-clock delta on them is zero**, and this runs beside them. Same precedent and same argument as kanban-tsan. Bank's isolated build cost -- the number the ticket asked for -- is the difference between this job and the same configure without bank (51s / 151s / 150 edges): **+13s configure, +240s build, +137 edges**. `ubsan` rather than `asan`: UBSan diagnoses this class, and ASan over a Qt GUI needs the `detect_leaks=0` and suppression story ladder-sanitizers carries, which this job would have to acquire before it could be believed. Bank under ASan is not closed by this. **The first bill is zero.** Per the morph#646 (84) and morph#656 (97) precedents, measured before landing: bank_tests (145 assertions in 21 cases), bank_gui_tests (19 in 5) and bank_gui_qml_tests (32 in 2) all pass clean under `-fsanitize=undefined -fno-sanitize-recover=undefined`, and so does the whole 1696-test suite of this configure (212s serial, 100% passed, zero `runtime error` lines). Nothing is suppressed and no allowlist entry was added. **A finding that cuts against the ticket's framing, stated rather than shipped around.** The ticket says this gap is why morph#663 survived to be found by reading code. Half true. Rebuilding Format.hpp as it stood before the fix, in this job's exact configuration, bank_gui_tests exits 1 with examples/bank/gui/controllers/Format.hpp:76:38: runtime error: 9.2e+19 is outside the range of representable values of type 'long' -- but only because morph#663's fix also added the test that calls parseMinor with such a value. With the pre-morph#663 header *and* the pre-morph#663 test set, this leg is green: no pre-existing bank test drove that path. The sanitizer gap was real and is what this closes; it was not on its own what let morph#663 through, and this job's reach is bounded by how much of bank the suites actually drive. That is written into the job's banner, not just here. Also here, because the job needs it: a `bank` ctest label on the three suites, so the leg runs bank's 28 tests (6s) rather than re-running the 1696 (212s) that linux-sanitizers' clang-ubsan leg already runs under identical instrumentation. A label filter that matched nothing would be this repository's named failure mode; CMakePresets.json's `base-test` already sets `noTestsAction: error`, confirmed to exit 8 with `No tests were found!!!` against a tree built without bank. Not verified: bank under ASan or TSan; whether the Lightweight ORM is clean under anything other than UBSan; and the job's real duration on a hosted runner, which adds an apt install and a Qt install this local measurement does not model. Found while measuring, filed rather than folded: bank's 21 ctest cases share one SQLite file and cannot run concurrently -- `ctest -j 12` fails 21 of them with `[SQLite]disk I/O error (10)` where the same binaries pass serially. CI runs ctest serially (no test preset sets a parallel level), so it is latent rather than active. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Runner verification#675's guard does what it claims, measured not read: Making the narrow mode structurally unreachable from CI is a better answer than documenting a convention, and the rejection of the issue's own #681 is the best thing in this batch and I reproduced it:
The correction to my triage of #679I wrote that the sanitizer gap "explains why #663 survived to be found by hand". That is half false and the lane did not ship around it: rebuilding pre-#663 The honest statement is narrower than mine: a sanitizer only catches UB on a path something executes, so the missing leg and the missing test were both necessary. The gap was real and is now closed; it was not on its own sufficient to have caught #663. Writing that into the job's banner is the right place for it — the next person to wonder why bank has its own sanitizer job gets the true reason. That is the third of my triage claims corrected by a lane today, and the pattern is consistent: each was a causal story that sounded specific and went one step further than the evidence. Two findings the ticket did not havebank's targets never called A first bill of zero, with the denominator shown — 1696 tests, 100% passed, no suppression and no allowlist entry. A zero is only meaningful next to what was examined, and this one has it. The new job rather than a flag on Not verified by meThe timings, the 1696-test run, the instrumentation counts, and #682's parallel-ctest failure — all need builds I did not run. And the branch's load-bearing claim ( 🤖 Generated with Claude Code |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Runner: the one red leg is upstream, and the guard this PR adds is what caught it
Read those in the right order. The Qt install action failed, so nothing Qt-dependent configured, so The upstream causeNot a defect here, and not a moved tag. # Reference same-repository actions with self-repository syntax - GitHub Changelog
# https://github.blog/changelog/2026-07-30-reference-same-repository-actions-with-self-repository-syntax/
uses: $/action
So this is runner-image luck against a floating action tag, which is the fifth distinct infrastructure failure today and a mechanism the earlier four did not have. I have added it to #672. What I am doingRe-running the failed job — GitHub refuses while the workflow still has legs in flight, so it is queued behind those. If it recurs, the durable fix is pinning I am also reproducing the job locally (same preset and flags, cold build dir) to confirm the instrumentation sweep reports all binaries instrumented on a tree that actually built. That answers the only question the red leg leaves open: whether the sweep would have passed had Qt installed. 🤖 Generated with Claude Code |
…ak Qt
`Bank example / UBSan` failed with the Qt install step rejected before any
build ran:
The template is not valid. jurplel/install-qt-action/v4/action.yml
(Line: 160, Col: 11): Expected format {org}/{repo}[/path]@ref.
Actual '$/action'
Upstream's action.yml now contains `uses: $/action` -- GitHub's
self-repository syntax, added in their 2026-07-30 changelog. Some runner
images resolve it and some do not. In the same workflow run, `Linux / Qt6
WebSockets` started 17:13:34Z with a byte-identical invocation and passed;
this job started 17:23:29Z and did not. Ten minutes and a different runner
apart, with no change on our side.
`@v4` is a major alias that upstream moves on every release, so this repository
has no say in when that syntax arrives. `v4.3.1` is the last release whose
action.yml does not use it (v4.4.0 and v4.4.1 both do), and it declares every
input used here -- arch, cache, dir, host, modules, target, version. All 11
call sites across ci.yml, wasm-demo.yml and wasm-ladder.yml are pinned
together, because a partial pin leaves the same lottery running on whichever
job was missed.
Verified: no floating `@v4` remains; all seven workflows parse; banner-lint,
option-coverage and catch2-pin pass.
This does not fix the class -- a pinned tag is still a tag, and the durable
answer is a commit SHA. That trade (immutability against a version nobody can
read) is recorded in morph#672 rather than decided here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW
Fixed in
|
…all ten sites (fixes #681) (#684) Ten steps across `ci.yml` and `mutation.yml` installed the pinned Clang with wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }} `wget -q` writes no error document, so on an HTTP 4xx/5xx it exits 8 having written zero bytes. `bash` then reads an empty script, does nothing, and exits 0. GitHub runs a bare `run:` as `bash -e {0}` -- errexit, and *not* pipefail -- so the pipeline's status is bash's. The step reported success having installed no compiler, and the leg went red several steps later at Configure, naming a missing `clang++` rather than the download that never happened. All ten now use the same three lines the `Install sccache` steps got in #683 (morph#672), so the repository has one download idiom rather than two: curl -sSL --fail -o /tmp/llvm.sh https://apt.llvm.org/llvm.sh test -s /tmp/llvm.sh sudo bash /tmp/llvm.sh ${{ env.CLANG_VERSION }} `--fail` makes curl report the HTTP status as its own exit code; writing to a file means nothing downstream runs when it does not arrive; `test -s` covers the one case `--fail` cannot see, a 200 with an empty body. This is not a retry policy and it does not make an apt.llvm.org outage less likely -- it makes the step that failed be the step the log names. The sites: ci.yml's linux-compilers, linux-sanitizers, linux-coverage, kanban-tsan, bank-sanitizers, ladder-sanitizers, linux-all-features (the `else` branch of its gcc/clang split), clang-format and clang-tidy, plus mutation.yml's install step. The full argument lives once, on linux-compilers; the other nine carry a three-line pointer to it. Proof, not assertion: each shape written to a file and run as `bash -e <file>`, which is what GitHub does, against a local origin that serves `/llvm.sh` and 404s everything else. =========== OLD SHAPE, 404 (the defect) =========== step exit=0 =========== OLD SHAPE, 200 (the happy path) =========== llvm.sh ran, version argument = 22 step exit=0 =========== NEW SHAPE, 404 (must be non-zero) =========== curl: (22) The requested URL returned error: 404 step exit=22 =========== NEW SHAPE, 200 EMPTY BODY (must be non-zero) =========== step exit=1 =========== NEW SHAPE, 200 (must be zero and must run the script) =========== llvm.sh ran, version argument = 22 step exit=0 The old shape's 404 and its 200 are indistinguishable, which is the ticket. And against apt.llvm.org itself rather than a local stand-in: $ curl -sSL -o llvm.sh https://apt.llvm.org/does-not-exist.sh exit=0 564 bytes of error page $ curl -sSL --fail -o llvm.sh https://apt.llvm.org/does-not-exist.sh curl: (22) The requested URL returned error: 404 exit=22 Measured on 7d4ca45, GNU wget 1.25.0, curl 8.22.0, bash 5.3.15, Arch Linux. Not verified: the behaviour of `ubuntu-24.04`'s own curl and wget builds, and no CI run has yet exercised a deliberate 404 on a runner. The claim this rests on is that `curl --fail` exits non-zero on an HTTP error status, which is curl's documented contract and is the same property #683 already depends on at ten sccache sites on those same runners. Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three CI-robustness tickets, one commit each, batched into one PR per the runner's instruction. Branched from
origin/masterat95aa5686(PR #673 had landed by the time theci.ymlboundary was reached, so no ticket was blocked on it).29c07c3d--binarymode, floor intact6dd23963curl --fail; the class stays openeedacd68bank-sanitizersjob; first bill zero#675 — the sanitizer-instrumentation check refused a single-target tree
scripts/check_sanitizer_instrumentation.shconflated two questions behind one floor. "Did this check examine a representative set?" needs the floor — that is the CI invocation. "Is this one binary instrumented?" is a yes/no about a single file and needs no floor, but the script refused it (only examined 1 binaries -- too few for this check to mean anything), so the #673 lane answered it by hand withnm | grep __tsan_, re-deriving the per-mode symbol table and the SIGPIPE trap the script already encodes.--binary <file> <mode>answers the second question against the same symbol table.How the narrow mode is kept out of the CI invocation
Structurally, not by convention: it refuses outright, exit 2, when
GITHUB_ACTIONSis set. No step of any workflow in this repository can reach it. A CI invocation that tried would fail loudly rather than pass having examined one file. The sweep is untouched — same floor, same message, same exit codes.env -u GITHUB_ACTIONSdefeats it, as it defeats any environment-keyed guard. That is deliberate circumvention, not the accident the guard is for, and the script says so.Why a mode rather than the documented one-liner
The issue offers
nm | grep __tsan_in a comment as a legitimate close, and it nearly is. Against it: the one-liner is not one line. It is the per-mode symbol table (__asan_/__tsan_/__ubsan_), plus the reason it must begrep -cand notgrep -q(underpipefail,nmtakes SIGPIPE and an instrumented binary reports as uninstrumented — the script's own comment records catching that), plus the fact that a count of zero is the verdict. Writing that down creates a second copy that can drift from the first. The mode is ~40 lines and keeps one.Verification
New
scripts/test_check_sanitizer_instrumentation.sh, following the repository'stest_check_*.shconvention, wired intodrift-guard.yml'ssanitizer-can-failjob. Twelve cases; no compiler beyondccand no sanitizer runtime, because the gate's whole measurement isnm | grep -c __<mode>_, so a fixture that defines a function of that name exercises the gate rather than clang, and a hand-writtenCTestTestfile.cmakeis allctest --show-onlyneeds.The two load-bearing cases were confirmed to fail against a mutated checker, not assumed:
All twelve pass unmutated. Separately, the narrow mode was exercised against a real UBSan-instrumented binary during #679's measurement:
#672 — an apt/PPA outage fails a leg the same way a defect does
Scoped deliberately to the narrow, cheap part, and the class stays open. Not implemented, because they are decisions rather than implementations: a retry policy for dependency installation, and a CI-wide marker distinguishing "the environment failed" from "the change failed" in the check list. The three apt/PPA outages that make up the rest of #672 are untouched.
What is fixed is the one case where the log actively misled. The installer piped
curl -sSL <url>straight intotar -xz; plain curl treats an HTTP 5xx as a successful transfer of the error page, so the decompressor was the only thing that complained.set -o pipefailwould not have helped — GitHub runsrun:underbash -ewithout it, so the pipeline's status is tar's regardless.Reproduced against a local server returning 503, both shapes under
bash -e:The first block is #672's log verbatim. The third is the success path over a tarball with the real release's member layout, so
--strip-components=1still lands the binary.Applied to all nine copies in
ci.yml(the first carries the reasoning, the other eight point at it) and to the two other downloads with the identical defect and the identical one-flag fix:docs.yml's Doxygen tarball andmutation.yml's Mull.deb. Those two are the same finding, not separate ones — the Mull case is measurably worse, since a 503 puts 64 bytes of HTML in${asset}and leavesdpkg-debto object to the archive.#679 — no sanitizer leg builds the bank example
Confirmed:
linux-sanitizers,kanban-tsan,ladder-sanitizersandvalgrindset zeroMORPH_BUILD_BANK_*flags, against eight sites elsewhere inci.yml. Bank is not a rung, soMORPH_BUILD_LADDER=ONdoes not reach it either.The half the ticket did not have
bank's targets never called
apply_sanitizers().ladder_bank_serverwas the only one that did. Flipping the CI flag alone would have builtbank_lib,bank_cliand all three test binaries blind. Measured, bank configured on with the blocks absent:naming
bank_tests,bank_gui_tests,bank_gui_qml_tests. With the blocks in, 9 of 9. They are also required together: removingbank_tests' alone, keepingbank_lib's, does not leave the binary unchecked — it fails to link (undefined reference to __ubsan_handle_type_mismatch_v1_abort).Why a separate job, not a flag on
linux-sanitizersThe ticket proposed the flag. Two measured reasons against it. That leg builds no Qt and its own comment reserves the matrix against GUI stacks, while bank's GUI is where the UB was. And, cold and cacheless, 12 cores, clang 22.1.8:
linux-sanitizersclang-ubsan leg's current shapeFolding one into the other roughly triples the slowest leg of a three-leg matrix, whose duration is then the matrix's.
Wall-clock delta on existing jobs: zero. No existing job's flags are changed by this PR. Bank's isolated build cost — row 2 to row 3 — is +13s configure, +240s build, +137 ninja edges.
ubsannotasan: UBSan diagnoses this class, and ASan over a Qt GUI needs thedetect_leaks=0and suppression storyladder-sanitizerscarries. Bank under ASan is not closed by this.The first bill: zero
Per the #646 (84) and #656 (97) precedents, measured before landing.
bank_tests(145 assertions / 21 cases),bank_gui_tests(19 / 5),bank_gui_qml_tests(32 / 2) all clean under-fsanitize=undefined -fno-sanitize-recover=undefined; the full 1696-test suite of this configure is 100% passed, 212s serial, zeroruntime errorlines. Nothing suppressed, no allowlist entry.A finding that cuts against the ticket's framing
The ticket says this gap is why #663 survived to be found by reading code. Half true, and the half that is false is the interesting one. Rebuilding
Format.hppas it stood before the fix, in this job's exact configuration:exit 1 — but only because #663's fix also added the test that calls
parseMinorwith such a value. Measured too: with the pre-#663 header and the pre-#663 test set, this leg is green. No pre-existing bank test drove that path. The sanitizer gap was real and is what this closes; it was not on its own what let #663 through, and this job's reach is bounded by how much of bank the suites actually drive. That is written into the job's banner, not only here.Also in this commit, because the job needs it
A
bankctest label on the three suites, so the leg runs bank's 28 tests (6s) rather than re-running the 1696 (212s) thatlinux-sanitizers' clang-ubsan leg already runs under identical instrumentation. A label filter matching nothing would be this repository's named failure mode;CMakePresets.json'sbase-testalready setsnoTestsAction: error— confirmed to exit 8 withNo tests were found!!!against a tree built without bank.Review reasoning (inline, per the runner's constraint — no
/code-review, no/simplify)scripts/check_sanitizer_can_fail.shis the complementary gate that proves the instrumentation behaviourally, and the new file says so.--strip-components=1still landssccachein place, exit 0. The nine copies are byte-identical apart from their comments.noTestsAction: erroron a-L bankfilter that stopped matching, and-fno-sanitize-recover=undefinedalready on the compile line, withhalt_on_error=1making the runtime agree.docs.yml/mutation.yml's curls were folded into ci: an apt/PPA outage fails a leg the same way a defect does, and every PR re-derives that from a log #672 — same defect, same one-flag fix, nine lines away — and that is called out rather than slipped in.Filed, not folded
wget -qO- https://apt.llvm.org/llvm.sh | sudo bashfails the other way. Measured against the same 503 server:wget -qO-exits 8 writing zero bytes,bashreads empty input and exits 0, the pipeline exits 0. A Clang-installation step that reports success having installed nothing. Ten sites. Different failure shape from ci: an apt/PPA outage fails a leg the same way a defect does, and every PR re-derives that from a log #672's, so a different fix.ctest -j 12fails all 21 with[SQLite]disk I/O error (10); serially they pass. CI runs ctest serially (no test preset sets a parallel level), so it is latent — which is exactly why it is worth recording.What I did not verify
file -b | grep -qE 'ELF|Mach-O'test, but no macOS run was made.ubuntu-24.04runner image specifically: wget's "do not write the error document" behaviour is the default and long-standing, but I measured GNU wget 1.25.0 on Arch, not the runner's build.Fixes #675
Fixes #679
Refs #672
🤖 Generated with Claude Code
https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW