From 29c07c3d2b10fa2e638029bfe87183f4a4c916bb Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 21 Sep 2026 18:35:30 +0200 Subject: [PATCH 1/4] ci: let the sanitizer-instrumentation check answer "is this one binary 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 ` 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 ___`, 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) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- .github/workflows/drift-guard.yml | 21 ++ docs/spec/testing_charter.md | 2 +- scripts/check_sanitizer_instrumentation.sh | 101 +++++- .../test_check_sanitizer_instrumentation.sh | 319 ++++++++++++++++++ 4 files changed, 433 insertions(+), 10 deletions(-) create mode 100755 scripts/test_check_sanitizer_instrumentation.sh diff --git a/.github/workflows/drift-guard.yml b/.github/workflows/drift-guard.yml index d97a1dd57..bed24f4a2 100644 --- a/.github/workflows/drift-guard.yml +++ b/.github/workflows/drift-guard.yml @@ -73,6 +73,27 @@ jobs: - name: Assert UBSan halts instead of recovering run: bash scripts/check_sanitizer_can_fail.sh + # scripts/check_sanitizer_instrumentation.sh's own self-test, and the + # neighbouring half of the same question: that gate asserts every binary + # ctest runs on a sanitizer leg carries that sanitizer's symbols + # (morph#542), where the step above asserts the instrumentation can + # actually fail the process (morph#541). The gate itself needs a built, + # test-registered sanitizer tree and so runs in ci.yml's three sanitizer + # jobs; its self-test needs neither -- hand-written CTestTestfile.cmake + # documents and fixtures built by `cc` from two lines of C -- so it runs + # here, in seconds, and still reports when one of those 20-minute legs + # has gone blind. + # + # It is also the only thing holding morph#675's narrow `--binary` mode to + # its terms. That mode answers "is this one file instrumented?" and + # applies no floor, which is correct for a developer with a single-target + # build and would be a vacuous pass in a workflow step. The self-test + # pins both halves: the mode is refused under GITHUB_ACTIONS, and the + # sweep still rejects a one-binary tree on the floor. Both assertions + # were confirmed to fail against a mutated checker before being trusted. + - name: Self-test the sanitizer-instrumentation checker + run: bash scripts/test_check_sanitizer_instrumentation.sh + dep-cache-selftest: name: Dependency-cache self-test runs-on: ubuntu-24.04 diff --git a/docs/spec/testing_charter.md b/docs/spec/testing_charter.md index 79594902b..0299c95bc 100644 --- a/docs/spec/testing_charter.md +++ b/docs/spec/testing_charter.md @@ -68,7 +68,7 @@ floor means anything. | UndefinedBehaviorSanitizer | Compiler instrumentation | `linux-sanitizers` (`clang-ubsan`), `ladder-sanitizers` | The CI job | | ThreadSanitizer | Compiler instrumentation | `linux-sanitizers` (`clang-tsan`), `kanban-tsan` | The CI job | | A sanitizer leg can fail at all | `scripts/check_sanitizer_can_fail.sh` | `drift-guard` | The check: real undefined behaviour compiled with `apply_sanitizers()`'s own flags must make the process exit non-zero, and an unknown `AF_SANITIZER` must fail the configure (morph#541) | -| Every sanitized binary is really sanitized | `scripts/check_sanitizer_instrumentation.sh` | `linux-sanitizers`, `ladder-sanitizers`, `kanban-tsan` | The check: every binary `ctest` will run on a sanitizer leg must carry that sanitizer's runtime symbols (morph#542) | +| Every sanitized binary is really sanitized | `scripts/check_sanitizer_instrumentation.sh` | `linux-sanitizers`, `ladder-sanitizers`, `kanban-tsan` (self-tested in `drift-guard.yml`) | The check: every binary `ctest` will run on a sanitizer leg must carry that sanitizer's runtime symbols (morph#542). Its `--binary ` mode answers the same question about one named file, with no floor, and is refused under `GITHUB_ACTIONS` so it cannot stand in for the sweep on a CI leg (morph#675) | | Valgrind (memcheck) | Runtime instrumentation | `valgrind` CI job | The CI job | | Long-running / soak | `tests/soak/` | Local / on demand (`-DMORPH_BUILD_LOAD_TESTS=ON`; not a CI leg — see `docs/spec/testing_strategy.md`) | Those tests' own assertions over many cycles | | Compile-time contract checks | `tests/compile_checks/` | Every configure that reaches `tests/CMakeLists.txt` | `FATAL_ERROR` at configure time | diff --git a/scripts/check_sanitizer_instrumentation.sh b/scripts/check_sanitizer_instrumentation.sh index d866cbed8..f95d57499 100755 --- a/scripts/check_sanitizer_instrumentation.sh +++ b/scripts/check_sanitizer_instrumentation.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash -# Usage: bash scripts/check_sanitizer_instrumentation.sh +# Usage: +# bash scripts/check_sanitizer_instrumentation.sh +# bash scripts/check_sanitizer_instrumentation.sh --binary # # A sanitizer job whose binaries are not actually instrumented is worse than no # job: it runs the whole suite, reports success, and every reader treats that as @@ -19,10 +21,47 @@ # The expected symbol is keyed on the mode. An `__asan_`-only assertion is # vacuous on the ubsan and tsan legs, which is the same "control that measures # nothing" this check exists to prevent. +# +# ── The two questions, and why --binary exists (morph#675) ─────────────────── +# +# The sweep above answers "did this check examine a representative set?", and +# its floor (below) is what makes that answer mean something. A developer who +# has built *one* target under a sanitizer preset is asking a different +# question -- "is this one binary instrumented?" -- which is a yes/no about a +# single file and needs no floor at all. The sweep refused that case outright +# ("only examined 1 binaries ... too few for this check to mean anything"), so +# it was answered by hand with `nm | grep __tsan_` instead, which is how the +# per-mode symbol table and the SIGPIPE trap below get re-derived, wrongly, +# each time. +# +# `--binary ` answers the second question against the same symbol +# table, and skips the floor because there is no set to be representative of. +# +# It cannot be used to satisfy the first question. It refuses outright when +# GITHUB_ACTIONS is set in the environment, so no step of any workflow in this +# repository can reach it -- a CI invocation that tried would fail with exit 2 +# rather than silently pass having examined one file. That is the whole safety +# of the addition: the narrow mode is unavailable exactly where the floor was +# meant to apply. (`env -u GITHUB_ACTIONS` defeats it, as it defeats any +# environment-keyed guard. That is deliberate circumvention, not the accident +# the guard is for.) +# +# scripts/test_check_sanitizer_instrumentation.sh drives both modes against +# fixtures whose right answers are known, including the refusal above and the +# floor the sweep must still enforce. set -euo pipefail -build_dir="${1:?usage: check_sanitizer_instrumentation.sh }" -mode="${2:?usage: check_sanitizer_instrumentation.sh }" +usage="usage: check_sanitizer_instrumentation.sh + or: check_sanitizer_instrumentation.sh --binary (not available under GITHUB_ACTIONS)" + +narrow=0 +if [ "${1:-}" = "--binary" ]; then + narrow=1 + shift +fi + +target="${1:?${usage}}" +mode="${2:?${usage}}" case "${mode}" in asan) symbol="__asan_" ;; @@ -31,6 +70,55 @@ case "${mode}" in *) echo "::error::check_sanitizer_instrumentation: unknown mode '${mode}' (expected asan, tsan or ubsan)"; exit 2 ;; esac +# `grep -c`, not `grep -q`: under `set -o pipefail`, a `grep -q` that finds its +# match exits at once, `nm` takes SIGPIPE, and the pipeline reports failure -- +# which would mark every *instrumented* binary as uninstrumented. Caught by +# running this check against a known-good build before trusting it; a checker +# that inverts its own verdict is the worst kind. Both modes go through here so +# there is one copy of that reasoning rather than one per caller. +count_symbols() { + nm -C "$1" 2>/dev/null | grep -c -- "${symbol}" || true +} + +# ── Narrow mode: one named file, no floor, never in CI (morph#675) ─────────── +# +# The refusal below is what keeps the floor intact. Everything after it is a +# statement about a single file, so there is no set for a floor to be about -- +# but for the same reason it must never be reachable from a workflow step, +# where "examined 1 binary, all good" is exactly the vacuous pass the sweep's +# floor exists to prevent. GITHUB_ACTIONS is set by the runner for every step +# of every job, so the check below is unconditional there. +if [ "${narrow}" -eq 1 ]; then + if [ -n "${GITHUB_ACTIONS:-}" ]; then + echo "::error::check_sanitizer_instrumentation: --binary is a local, single-file mode and is refused under GITHUB_ACTIONS -- a CI leg must run the build-tree sweep, whose floor is the only thing that makes 'all instrumented' mean 'all of them were looked at'" + exit 2 + fi + + # A narrow answer over a path that is not there, or is not a binary, would + # be the vacuous pass in miniature: nothing examined, nothing reported. + if [ ! -f "${target}" ]; then + echo "::error::check_sanitizer_instrumentation: --binary ${target}: no such file -- nothing was examined" + exit 1 + fi + if ! file -b "${target}" 2>/dev/null | grep -qE 'ELF|Mach-O'; then + echo "::error::check_sanitizer_instrumentation: --binary ${target}: not an ELF or Mach-O binary -- nm has nothing to report on it" + exit 1 + fi + + narrow_base="$(basename "${target}")" + narrow_count="$(count_symbols "${target}")" + if [ "${narrow_count}" -eq 0 ]; then + echo "::error file=${target}::check_sanitizer_instrumentation: ${narrow_base} carries no ${symbol} symbols -- it is not ${mode}-instrumented, so any result it printed proves nothing" + exit 1 + fi + + echo "check_sanitizer_instrumentation: ${narrow_base} carries ${narrow_count} ${symbol} symbols -- ${mode}-instrumented." + echo "check_sanitizer_instrumentation: this examined one file and applied no floor; it is not a substitute for the build-tree sweep a CI leg runs." + exit 0 +fi + +build_dir="${target}" + # Binaries that are deliberately uninstrumented, with the reason. Keep this # list short and justified: every entry is a hole in the check. # @@ -84,12 +172,7 @@ for binary in "${commands[@]}"; do fi checked=$((checked + 1)) - # `grep -c`, not `grep -q`: under `set -o pipefail`, a `grep -q` that finds - # its match exits at once, `nm` takes SIGPIPE, and the pipeline reports - # failure -- which would mark every *instrumented* binary as uninstrumented. - # Caught by running this check against a known-good build before trusting - # it; a checker that inverts its own verdict is the worst kind. - symbol_count="$(nm -C "${binary}" 2>/dev/null | grep -c -- "${symbol}" || true)" + symbol_count="$(count_symbols "${binary}")" if [ "${symbol_count}" -eq 0 ]; then echo "::error file=${binary}::check_sanitizer_instrumentation: ${base} is run by ctest on the ${mode} leg but carries no ${symbol} symbols -- it is not instrumented, so running it proves nothing" missing=$((missing + 1)) diff --git a/scripts/test_check_sanitizer_instrumentation.sh b/scripts/test_check_sanitizer_instrumentation.sh new file mode 100755 index 000000000..6389b0e48 --- /dev/null +++ b/scripts/test_check_sanitizer_instrumentation.sh @@ -0,0 +1,319 @@ +#!/usr/bin/env bash +# Usage: bash scripts/test_check_sanitizer_instrumentation.sh +# +# Self-test for scripts/check_sanitizer_instrumentation.sh, the gate that fails +# when a sanitizer leg runs a binary carrying none of that sanitizer's runtime +# symbols (morph#542). A lint gate nobody tests reports green whether or not it +# still detects anything, and this one guards a silence: an uninstrumented +# sanitizer leg builds, runs the whole suite, passes, and costs its full +# runtime -- indistinguishable from a leg that found nothing wrong. +# +# morph#675 added a second mode (`--binary `) for the question the +# sweep refused: "is this one binary instrumented?", asked by a developer who +# built a single target under a sanitizer preset. The important property of +# that mode is not what it reports but where it cannot be used -- if it could +# satisfy a CI invocation, the sweep's floor would be gone and the gate would +# be back to passing on one file. Case 6 is that property; case 7 is the floor +# itself, asserted unchanged. +# +# No compiler is needed and no sanitizer runtime is involved. The fixtures are +# ordinary binaries carrying a hand-written function whose *name* is what `nm` +# reports, which is exactly what the gate inspects -- so this runs in seconds +# in drift-guard.yml rather than behind a sanitizer build. +# +# Asserts eight directions: +# +# 1. sweep: two instrumented ctest binaries -> pass +# 2. sweep: one of them uninstrumented -> fail, naming it +# 3. sweep: ctest lists no tests -> fail, not a vacuous pass +# 4. narrow: an instrumented binary -> pass, reporting the count +# 5. narrow: an uninstrumented binary -> fail, naming it +# 6. narrow: GITHUB_ACTIONS set -> refused, exit 2 +# 7. sweep: a single-binary tree -> still fails on the floor +# 8. narrow: a missing path and a non-binary -> fail, not a vacuous pass +# +# 3, 6, 7 and 8 matter as much as 2. Without 7 the floor could have been +# lowered rather than sidestepped, which is the change this gate must not have +# made; without 6 the narrow mode would be a way to satisfy a workflow step +# having examined one file; and without 8 it would report success over a path +# that is not there. +set -euo pipefail + +readonly repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +readonly checker="${repo_root}/scripts/check_sanitizer_instrumentation.sh" + +failures=0 + +note() { printf '%s\n' "$*"; } +fail() { printf 'error: %s\n' "$*" >&2; failures=$((failures + 1)); } + +for tool in cc ctest jq nm file; do + if ! command -v "$tool" >/dev/null 2>&1; then + printf 'error: %s is required by this self-test and is not on PATH\n' "$tool" >&2 + exit 1 + fi +done + +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +# ── Fixtures ──────────────────────────────────────────────────────────────── +# The gate's entire measurement is `nm -C | grep -c ___`, so a +# binary that defines a function of that name is indistinguishable from an +# instrumented one *to the gate*, which is what makes it the right fixture: it +# exercises the gate rather than the compiler. `-fsanitize=undefined` would +# make the fixture depend on a sanitizer runtime being installed, and would +# test clang instead of this script. +# +# scripts/check_sanitizer_can_fail.sh is the complementary gate and does +# compile real instrumentation, behaviourally, against the flags +# apply_sanitizers() actually emits. Neither replaces the other. + +case_dir() { + local dir="${work}/$1" + rm -rf "$dir" + mkdir -p "$dir" + printf '%s' "$dir" +} + +# make_binary +# `-` produces a binary with no sanitizer-shaped symbol at all. +make_binary() { + local dir="$1" name="$2" prefix="$3" + local src="${work}/${name}.c" + if [ "$prefix" = "-" ]; then + printf 'int main(void) { return 0; }\n' > "$src" + else + printf 'void %shandle_type_mismatch_v1(void) { }\nint main(void) { return 0; }\n' \ + "$prefix" > "$src" + fi + # No -s / no strip: the gate reads the symbol table, so the fixture has to + # keep one. Default cc output on both Linux and macOS does. + cc -O0 -o "${dir}/${name}" "$src" + printf '%s' "${dir}/${name}" +} + +# A hand-written CTestTestfile.cmake is all `ctest --show-only=json-v1` needs, +# which is what keeps this self-test free of a configure. +ctest_file() { + local dir="$1" + shift + : > "${dir}/CTestTestfile.cmake" + local path + for path in "$@"; do + printf 'add_test([=[%s]=] "%s")\n' "$(basename "$path")" "$path" >> "${dir}/CTestTestfile.cmake" + done +} + +# `grep <<<` rather than a pipe, for the reason +# scripts/test_check_coverage_objects.sh states: under `set -o pipefail` a +# `grep -q` that matches early can close the pipe under its producer and the +# pipeline then reports the producer's SIGPIPE status rather than grep's match. +mentions() { + grep -q -- "$1" <<< "$2" +} + +# Every invocation below runs with GITHUB_ACTIONS unset, except case 6 which +# sets it deliberately. The self-test itself runs *in* GitHub Actions, where +# the variable is set for every step, so inheriting it would turn case 4 into +# an accidental re-run of case 6 and leave the narrow mode's actual verdict +# untested in the one place this file runs. +run_checker() { + env -u GITHUB_ACTIONS bash "$checker" "$@" +} + +# ── 1. sweep: two instrumented ctest binaries -> pass ─────────────────────── +dir="$(case_dir sweep_clean)" +a="$(make_binary "$dir" morph_tests __ubsan_)" +b="$(make_binary "$dir" morph_net_tests __ubsan_)" +ctest_file "$dir" "$a" "$b" + +if output="$(run_checker "$dir" ubsan 2>&1)"; then + if mentions '2 ctest binaries' "$output"; then + note "ok: a fully instrumented tree is accepted, and the count reported" + else + fail "the tree was accepted but the summary does not report two binaries:" + printf '%s\n' "$output" >&2 + fi +else + fail "a fully instrumented tree was rejected:" + printf '%s\n' "$output" >&2 +fi + +# ── 2. sweep: one uninstrumented binary -> fail, naming it ────────────────── +# morph#542 itself, in miniature. A nonzero exit alone is not enough: the gate +# has several failure paths and one of them firing for an unrelated reason +# would look like a pass of this case, so the message must name the binary. +dir="$(case_dir sweep_dirty)" +a="$(make_binary "$dir" morph_tests __ubsan_)" +b="$(make_binary "$dir" morph_net_tests -)" +ctest_file "$dir" "$a" "$b" + +if output="$(run_checker "$dir" ubsan 2>&1)"; then + fail "an uninstrumented ctest binary was accepted -- this is the defect the gate exists for:" + printf '%s\n' "$output" >&2 +elif ! mentions 'morph_net_tests' "$output"; then + fail "the uninstrumented binary was rejected, but the message does not name morph_net_tests:" + printf '%s\n' "$output" >&2 +else + note "ok: an uninstrumented ctest binary is rejected, and named" +fi + +# ── 2b. the symbol is keyed on the mode ───────────────────────────────────── +# The same tree that passes as `ubsan` must fail as `tsan`. Without this, an +# `__asan_`-only assertion -- the exact defect morph#542 records -- would +# satisfy every other case here. +dir="$(case_dir sweep_wrong_mode)" +a="$(make_binary "$dir" morph_tests __ubsan_)" +b="$(make_binary "$dir" morph_net_tests __ubsan_)" +ctest_file "$dir" "$a" "$b" + +if output="$(run_checker "$dir" tsan 2>&1)"; then + fail "a ubsan-only tree was accepted as tsan-instrumented -- the symbol is not keyed on the mode:" + printf '%s\n' "$output" >&2 +else + note "ok: a ubsan-instrumented tree does not satisfy the tsan mode" +fi + +# ── 3. sweep: ctest lists no tests -> fail, not a vacuous pass ────────────── +dir="$(case_dir sweep_empty)" +: > "${dir}/CTestTestfile.cmake" + +if output="$(run_checker "$dir" ubsan 2>&1)"; then + fail "an empty ctest test list was reported as clean -- the gate verified nothing:" + printf '%s\n' "$output" >&2 +elif ! mentions 'listed no tests' "$output"; then + fail "the empty test list was rejected, but not for being empty:" + printf '%s\n' "$output" >&2 +else + note "ok: an empty ctest test list is rejected rather than passing vacuously" +fi + +# ── 4. narrow: an instrumented binary -> pass, reporting the count ────────── +dir="$(case_dir narrow_clean)" +one="$(make_binary "$dir" morph_tests __tsan_)" + +if output="$(run_checker --binary "$one" tsan 2>&1)"; then + if ! mentions 'morph_tests carries' "$output"; then + fail "the narrow mode passed but does not name the binary and its count:" + printf '%s\n' "$output" >&2 + elif ! mentions 'not a substitute' "$output"; then + fail "the narrow mode passed without saying it applied no floor -- a reader could take it for the sweep:" + printf '%s\n' "$output" >&2 + else + note "ok: --binary reports one instrumented binary, and says what it did not do" + fi +else + fail "--binary rejected an instrumented binary:" + printf '%s\n' "$output" >&2 +fi + +# ── 5. narrow: an uninstrumented binary -> fail, naming it ────────────────── +dir="$(case_dir narrow_dirty)" +one="$(make_binary "$dir" morph_tests -)" + +if output="$(run_checker --binary "$one" tsan 2>&1)"; then + fail "--binary accepted a binary with no __tsan_ symbols:" + printf '%s\n' "$output" >&2 +elif ! mentions 'morph_tests' "$output"; then + fail "--binary rejected the binary but did not name it:" + printf '%s\n' "$output" >&2 +else + note "ok: --binary rejects an uninstrumented binary, and names it" +fi + +# ── 6. narrow: refused under GITHUB_ACTIONS ──────────────────────────────── +# The whole safety of morph#675's addition. If this passes, the narrow mode is +# reachable from a workflow step, and a leg could report "instrumented" having +# examined one file -- which is the sweep's floor removed by another route. +# Asserted on a fixture that would otherwise *pass* (case 4's), so a refusal +# here cannot be some other failure wearing the right exit code. +dir="$(case_dir narrow_in_ci)" +one="$(make_binary "$dir" morph_tests __tsan_)" + +set +e +output="$(GITHUB_ACTIONS=true bash "$checker" --binary "$one" tsan 2>&1)" +status=$? +set -e + +if [ "$status" -eq 0 ]; then + fail "--binary ran under GITHUB_ACTIONS -- the floor can now be bypassed from a workflow step:" + printf '%s\n' "$output" >&2 +elif [ "$status" -ne 2 ]; then + fail "--binary under GITHUB_ACTIONS exited ${status}; expected 2 (a usage refusal, not a finding):" + printf '%s\n' "$output" >&2 +elif ! mentions 'refused under GITHUB_ACTIONS' "$output"; then + fail "--binary failed under GITHUB_ACTIONS, but not with the refusal:" + printf '%s\n' "$output" >&2 +else + note "ok: --binary is refused under GITHUB_ACTIONS, on a fixture that otherwise passes" +fi + +# ── 7. sweep: a single-binary tree still fails on the floor ──────────────── +# morph#675 is about making the narrow case answerable, not about lowering the +# floor. This is the regression guard for the difference: the sweep over a +# one-binary tree must still refuse, with the floor's own message, even though +# that binary is instrumented. +dir="$(case_dir sweep_single)" +one="$(make_binary "$dir" morph_tests __ubsan_)" +ctest_file "$dir" "$one" + +if output="$(run_checker "$dir" ubsan 2>&1)"; then + fail "the sweep accepted a one-binary tree -- the floor has been lowered:" + printf '%s\n' "$output" >&2 +elif ! mentions 'too few for this check to mean anything' "$output"; then + fail "the one-binary tree was rejected, but not by the floor:" + printf '%s\n' "$output" >&2 +else + note "ok: the sweep's floor is unchanged by the narrow mode" +fi + +# ── 8. narrow: a missing path and a non-binary -> fail ───────────────────── +if output="$(run_checker --binary "${work}/definitely-absent" ubsan 2>&1)"; then + fail "--binary reported success over a path that does not exist:" + printf '%s\n' "$output" >&2 +elif ! mentions 'no such file' "$output"; then + fail "--binary rejected the missing path, but not for being missing:" + printf '%s\n' "$output" >&2 +else + note "ok: --binary over a missing path is a failure, not a vacuous pass" +fi + +dir="$(case_dir narrow_not_a_binary)" +printf 'this is a shell script, not an ELF file\n' > "${dir}/not_a_binary" +chmod +x "${dir}/not_a_binary" + +if output="$(run_checker --binary "${dir}/not_a_binary" ubsan 2>&1)"; then + fail "--binary reported success over a file nm cannot read:" + printf '%s\n' "$output" >&2 +elif ! mentions 'not an ELF or Mach-O binary' "$output"; then + fail "--binary rejected the non-binary, but not for being one:" + printf '%s\n' "$output" >&2 +else + note "ok: --binary over a non-binary is a failure, not a vacuous pass" +fi + +# ── 9. an unknown mode is refused in both modes ──────────────────────────── +dir="$(case_dir unknown_mode)" +one="$(make_binary "$dir" morph_tests __ubsan_)" +ctest_file "$dir" "$one" "$one" + +for args in "$dir msan" "--binary $one msan"; do + # shellcheck disable=SC2086 + if output="$(run_checker $args 2>&1)"; then + fail "the unknown mode 'msan' was accepted (args: ${args}):" + printf '%s\n' "$output" >&2 + elif ! mentions "unknown mode 'msan'" "$output"; then + fail "'msan' was rejected, but not as an unknown mode (args: ${args}):" + printf '%s\n' "$output" >&2 + else + note "ok: the unknown mode 'msan' is refused (args: ${args})" + fi +done + +if [ "$failures" -ne 0 ]; then + printf '\n%d self-test check(s) failed\n' "$failures" >&2 + exit 1 +fi + +printf '\nall self-test checks passed\n' From 6dd239637c4dca05a089e3358cc32a5cb64554b4 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 21 Sep 2026 18:37:20 +0200 Subject: [PATCH 2/4] ci: make a failed download say it failed, instead of letting tar report 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 ` 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) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- .github/workflows/ci.yml | 112 +++++++++++++++++++++++++++------ .github/workflows/docs.yml | 8 ++- .github/workflows/mutation.yml | 7 ++- 3 files changed, 107 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3be12e6c3..bbde4416f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -308,8 +308,36 @@ jobs: - name: Install sccache if: "!contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')" run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail`, and a file rather than a pipe into tar (morph#672). On + # 2026-09-21 both GCC legs of PR #671 died in this step with + # + # gzip: stdin: not in gzip format + # tar: Child returned status 1 + # tar: Error is not recoverable: exiting now + # + # The download host had served something that is not a tarball -- + # an error page -- and plain `curl` reports an HTTP 4xx/5xx as a + # *successful* transfer of that page, exit 0, straight down the + # pipe. So the only diagnostic anyone saw named the decompressor, + # and the event that actually happened (the download failed, with a + # status) appeared nowhere in the log. The step could not be + # salvaged by `set -o pipefail` either: GitHub runs `run:` under + # `bash -e`, without pipefail, so the pipeline's status is tar's + # regardless of what curl did. + # + # `--fail` makes curl exit non-zero and print `curl: (22) The + # requested URL returned error: 503`; writing to a file means tar + # never runs at all when it does. This is not a retry policy and it + # does not make the outage less likely -- it makes the log name the + # thing that happened, which is the half of morph#672 that needs no + # CI-wide decision. The rest of that class -- a retry policy, or a + # marker that distinguishes "the environment failed" from "the + # change failed" in the check list, and the apt/PPA outages that + # are the other three quarters of the ticket -- is still open. + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache # No -DCMAKE_..._COMPILER_LAUNCHER=sccache here on purpose: leaving the # launcher unset lets cmake/CompileCache.cmake's own auto-detection run, @@ -459,8 +487,14 @@ jobs: - name: Install sccache if: "!contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')" run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache # morph::net and the SQLite offline queue are opt-in, but they are also # where the memory/threading/UB risk actually lives (raw sockets, an I/O @@ -642,8 +676,14 @@ jobs: - name: Install sccache run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache - name: Configure run: | @@ -832,8 +872,14 @@ jobs: - name: Install sccache run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache # MORPH_LADDER_RUNGS=kanban (a single rung, not "all"): examples/CMakeLists.txt's # rung-selection loop (`if(MORPH_LADDER_RUNGS STREQUAL "all" OR _rung IN_LIST @@ -955,8 +1001,14 @@ jobs: - name: Install sccache run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache - name: Configure (gcc-debug with MORPH_BUILD_QT=ON) run: | @@ -1233,8 +1285,14 @@ jobs: - name: Install sccache if: "steps.filter.outputs.run == 'true' && !contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')" run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache # No -DCMAKE_..._COMPILER_LAUNCHER=sccache: see linux-compilers' # Configure steps for why leaving it unset is what lets fastcache-cc @@ -1559,8 +1617,14 @@ jobs: - name: Install sccache if: "steps.filter.outputs.run == 'true' && !contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')" run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache # No -DCMAKE_..._COMPILER_LAUNCHER=sccache: see linux-compilers' # Configure steps for why leaving it unset is what lets fastcache-cc @@ -1821,8 +1885,14 @@ jobs: - name: Install sccache if: "!contains(needs.probe-self-hosted.outputs.runs_on, 'self-hosted')" run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache # MORPH_REQUIRE_VETTED_HMAC is deliberately combined with # MORPH_BUILD_TESTS=ON (the preset default). docs/spec/security.md @@ -2032,8 +2102,14 @@ jobs: - name: Install sccache run: | - curl -sSL https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz \ - | tar -xz --strip-components=1 -C /usr/local/bin sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache # morph::net and the SQLite queue are where the raw pointers, manual # buffers and C API calls live — precisely what memcheck is for — so they diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index afb8128b5..46785dc2a 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -49,7 +49,13 @@ jobs: # instead of relying on the distro package. - name: Install Doxygen 1.17.0 run: | - curl -sL https://github.com/doxygen/doxygen/releases/download/Release_1_17_0/doxygen-1.17.0.linux.bin.tar.gz -o /tmp/doxygen.tar.gz + # `--fail` and `-S`, for the reason ci.yml's Install sccache step + # gives at length (morph#672): without them, an HTTP 503 from the + # download host is a *successful* transfer of an error page, curl + # exits 0 having written it to the file, and the only diagnostic + # anyone sees is `gzip: stdin: not in gzip format` from tar -- which + # names the decompressor rather than the outage. + curl -sSL --fail https://github.com/doxygen/doxygen/releases/download/Release_1_17_0/doxygen-1.17.0.linux.bin.tar.gz -o /tmp/doxygen.tar.gz tar -xzf /tmp/doxygen.tar.gz -C /tmp echo "/tmp/doxygen-1.17.0/bin" >> "$GITHUB_PATH" diff --git a/.github/workflows/mutation.yml b/.github/workflows/mutation.yml index 4efa6ffa7..03ae0cea2 100644 --- a/.github/workflows/mutation.yml +++ b/.github/workflows/mutation.yml @@ -96,7 +96,12 @@ jobs: - name: Install Mull ${{ env.MULL_VERSION }} for LLVM ${{ env.MULL_LLVM_MAJOR }} run: | asset="Mull-${MULL_LLVM_MAJOR}-${MULL_VERSION}-LLVM-22.1.2-ubuntu-amd64-26.04.deb" - curl -sSLO "https://github.com/mull-project/mull/releases/download/${MULL_VERSION}/${asset}" + # `--fail`, for the reason ci.yml's Install sccache step gives at + # length (morph#672): without it an HTTP 503 is a successful + # transfer of an error page, curl exits 0, and the 64 bytes of HTML + # land in ${asset} -- so the first thing to complain is dpkg-deb, + # about the archive, rather than curl about the download. + curl -sSLO --fail "https://github.com/mull-project/mull/releases/download/${MULL_VERSION}/${asset}" mkdir -p "${PWD}/mull-${MULL_LLVM_MAJOR}" dpkg-deb -x "${asset}" "${PWD}/mull-${MULL_LLVM_MAJOR}" echo "MULL_PREFIX=${PWD}/mull-${MULL_LLVM_MAJOR}/usr" >> "$GITHUB_ENV" From eedacd68743a1986e8c7e6135d94ca68fa566896 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 21 Sep 2026 19:11:18 +0200 Subject: [PATCH 3/4] ci/bank: put the bank example under a sanitizer, and instrument the targets that were never going to carry one (fixes #679) Measured on 5fc5e788: `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) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- .github/workflows/ci.yml | 189 +++++++++++++++++++++++++++++++ docs/spec/testing_charter.md | 2 +- examples/bank/CMakeLists.txt | 71 +++++++++++- examples/bank/gui/CMakeLists.txt | 10 ++ 4 files changed, 268 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbde4416f..704a6d7d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -962,6 +962,195 @@ jobs: path: /home/runner/.cache/sccache key: sccache-clang-tsan-${{ github.sha }} + # ── Linux: the bank example under UndefinedBehaviorSanitizer ────────── + # Until morph#679, no sanitizer leg built the bank example at all. Measured + # on 5fc5e788: linux-sanitizers, kanban-tsan, ladder-sanitizers and valgrind + # set zero MORPH_BUILD_BANK_* flags, against eight places elsewhere in this + # file that set MORPH_BUILD_BANK_EXAMPLE=ON. Bank is not a rung (it is absent + # from examples/rungs.txt and never calls morph_add_rung(), for the reasons + # that file gives), so MORPH_BUILD_LADDER=ON does not reach it either, and + # nothing in those four jobs' comments argued for excluding it -- it was an + # option nobody turned on rather than a decision anybody made. + # + # The cost of that was paid in morph#663: an out-of-range double -> + # std::int64_t conversion in examples/bank/gui/controllers/Format.hpp, on the + # path of every amount typed into the bank GUI, found by a lane reading the + # code. Rebuilding that header 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. Measured too: with the pre-morph#663 header + # *and* the pre-morph#663 test set, this leg is green. The sanitizer gap was + # real and is what this job closes; it was not on its own what let morph#663 + # survive, and this job's reach is bounded by how much of bank the suites + # actually drive. + # + # Why a job of its own rather than a flag on linux-sanitizers' clang-ubsan + # leg -- the shape the ticket proposed. Two measured reasons. (1) That leg + # builds no Qt, and its own comment reserves the matrix against GUI stacks; + # bank's GUI is where the UB was, so covering it means adding Qt there. (2) + # Cold, cacheless, 12 cores, clang 22.1.8: the leg's current shape + # (core + net + offline_sqlite, no Qt) is 36s configure / 144s build over 144 + # ninja edges, and this job's shape is 64s / 391s over 287. 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 are + # untouched -- their flags are not changed by morph#679 at all -- and this + # runs beside them. Same precedent, and the same argument, as kanban-tsan + # above. + # + # ubsan rather than asan: UBSan is the sanitizer that diagnoses morph#663's + # class, and an ASan run over a Qt GUI needs the detect_leaks=0 and + # suppression story ladder-sanitizers already carries, which this job would + # have to acquire before it could be believed. Bank under ASan is worth + # having and is not closed by this job. + # + # The first bill was measured before this landed, per morph#646 (84 findings) + # and morph#656 (97): turning bank on and instrumenting every one of its + # targets produced **zero** UBSan findings. bank_tests (145 assertions in 21 + # cases), bank_gui_tests (19 in 5) and bank_gui_qml_tests (32 in 2) all pass + # clean, and so does the full 1696-test suite of this configure. Nothing is + # suppressed here and there is no allowlist entry. + bank-sanitizers: + name: Bank example / UBSan + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: Cache apt packages + uses: actions/cache@v4 + with: + path: /var/cache/apt/archives + key: apt-bank-ubsan-${{ hashFiles('.github/workflows/ci.yml') }} + restore-keys: apt-bank-ubsan- + + - name: Install Clang ${{ env.CLANG_VERSION }} from apt.llvm.org + run: | + sudo apt-get update -q + # The same set kanban-tsan and ladder-sanitizers install, and for + # the same reasons: unixodbc-dev + libsqliteodbc because + # examples/bank/CMakeLists.txt fetches the Lightweight ORM, whose + # CMake runs `pkg_check_modules(ODBC REQUIRED odbc)` and whose + # fixtures open a real `DRIVER=SQLite3` connection at test time; + # libyaml-cpp-dev + libzip-dev because Lightweight's own + # CMakeLists.txt finds them as system CONFIG packages rather than + # through CPM; libgl1-mesa-dev for Qt's GL platform integration, + # which every job configuring MORPH_BUILD_QT=ON alongside a Qt GUI + # target installs. + sudo apt-get install -y ninja-build catch2 libsqlite3-dev \ + unixodbc-dev libsqliteodbc libyaml-cpp-dev libzip-dev libgl1-mesa-dev + wget -qO- https://apt.llvm.org/llvm.sh | sudo bash -s -- ${{ env.CLANG_VERSION }} + + # Not the distro's Qt: examples/common/CMakeLists.txt requires 6.5+ + # unconditionally and Ubuntu 24.04 still ships 6.4.2 — the same gap + # every other job that builds Qt on Linux already documents. + - name: Install Qt ${{ env.QT_VERSION }} + uses: jurplel/install-qt-action@v4 + with: + version: ${{ env.QT_VERSION }} + modules: qtwebsockets + cache: true + + # Keyed by preset alone (clang-ubsan), matching linux-sanitizers' cache: + # this job's clang-ubsan+Qt+bank build shares the same compiler and much + # of the same core-library object set as that job's clang-ubsan leg. See + # linux-compilers' identical comment for why sharing the key is safe. + - name: Restore sccache + uses: actions/cache/restore@v4 + with: + path: /home/runner/.cache/sccache + key: sccache-clang-ubsan-${{ github.sha }} + restore-keys: sccache-clang-ubsan- + + - name: Install sccache + run: | + # `--fail` and a file rather than a pipe into tar: see + # linux-compilers' identical Install sccache step for why an + # unchecked download reported itself as "not in gzip format" + # (morph#672). + curl -sSL --fail -o /tmp/sccache.tar.gz \ + https://github.com/mozilla/sccache/releases/download/v0.9.1/sccache-v0.9.1-x86_64-unknown-linux-musl.tar.gz + tar -xzf /tmp/sccache.tar.gz --strip-components=1 -C /usr/local/bin \ + sccache-v0.9.1-x86_64-unknown-linux-musl/sccache + + # MORPH_BUILD_BANK_GUI=ON, not MORPH_BUILD_BANK_EXAMPLE alone: the + # controllers and Format.hpp -- where morph#663 was -- live in + # bank_gui_lib, which only exists when the GUI option adds gui/. Without + # it this job would instrument bank_lib and the ORM and miss the half of + # bank the defect was in. No MORPH_BUILD_LADDER: bank is not a rung, so + # it would add every rung's tree for nothing. + - name: Configure (clang-ubsan, bank example + GUI) + run: | + cmake --preset clang-ubsan \ + -DMORPH_BUILD_QT=ON \ + -DMORPH_BUILD_BANK_EXAMPLE=ON \ + -DMORPH_BUILD_BANK_GUI=ON \ + -DCMAKE_C_COMPILER=clang-${{ env.CLANG_VERSION }} \ + -DCMAKE_CXX_COMPILER=clang++-${{ env.CLANG_VERSION }} \ + -DCMAKE_C_COMPILER_LAUNCHER=sccache \ + -DCMAKE_CXX_COMPILER_LAUNCHER=sccache + + # QT_QPA_PLATFORM=offscreen at build time as well as test time: Catch2's + # catch_discover_tests() runs each Qt-linked test binary once during the + # build to enumerate its cases — see ladder-tests' own Build step. + - name: Build + env: + QT_QPA_PLATFORM: offscreen + run: cmake --build --preset clang-ubsan + + # The same assertion linux-sanitizers, ladder-sanitizers and kanban-tsan + # make (morph#542). It is not a formality here: before morph#679, bank's + # targets carried no apply_sanitizers() call except ladder_bank_server's, + # and this sweep is what says so rather than letting the leg report a + # clean bank run over uninstrumented binaries. Measured on the tree as it + # stood, with bank configured on and the AF_SANITIZER 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. + - name: Every ctest binary is instrumented + run: bash scripts/check_sanitizer_instrumentation.sh build/clang-ubsan ubsan + + # -L bank, not the whole suite: this configure also builds morph_tests + # and morph_qt_tests, which linux-sanitizers' clang-ubsan leg already + # runs under the identical instrumentation -- 1696 tests and 212s + # measured locally, against 28 tests and 6s for bank's own three suites. + # The label is applied by examples/bank/CMakeLists.txt's + # catch_discover_tests calls; a filter that matched nothing would be this + # repository's named failure mode, and CMakePresets.json's base-test sets + # `noTestsAction: error` so it exits 8 with "No tests were found!!!" + # instead (confirmed against a tree built without bank). + - name: Test (bank's suites only) + env: + QT_QPA_PLATFORM: offscreen + # halt_on_error, because apply_sanitizers()'s ubsan arm already + # passes -fno-sanitize-recover=undefined -- this makes the runtime + # agree with the compile line rather than leaving the two to differ, + # the same pair ladder-sanitizers' Test step sets. + UBSAN_OPTIONS: print_stacktrace=1:halt_on_error=1 + run: ctest --preset clang-ubsan -L bank --output-on-failure + + # Cumulative hit/miss for this leg. Without it the cache is + # unfalsifiable: a thrashing cache and a working one look identical from + # the outside, and only the build-step duration hints at which one you + # have. `|| true` so a stats failure never fails the job. + - name: sccache stats + if: always() + run: sccache --show-stats || true + + # Save only on a push to master, for the cache-budget reason every other + # leg's identical step gives (morph#109). + - name: Save sccache + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main') + uses: actions/cache/save@v4 + with: + path: /home/runner/.cache/sccache + key: sccache-clang-ubsan-${{ github.sha }} + # ── Linux: Qt WebSocket backend build + tests ───────────────────────── linux-qt: name: Linux / Qt6 WebSockets diff --git a/docs/spec/testing_charter.md b/docs/spec/testing_charter.md index 0299c95bc..8bcba6c56 100644 --- a/docs/spec/testing_charter.md +++ b/docs/spec/testing_charter.md @@ -65,7 +65,7 @@ floor means anything. | I/O error injection (ladder only) | `examples/common/testkit/fault_proxy.hpp` | Ladder Qt test suites | Those tests' own assertions | | Fuzzing | `tests/fuzz/` (`fuzz_wire_decode`, `fuzz_dispatch_execute`), libFuzzer | Local / on demand (`-DMORPH_BUILD_FUZZERS=ON`; not a CI leg) | A crash, hang, or sanitizer trip during a campaign; regression cases preserved under `tests/fuzz/findings/` | | AddressSanitizer | Compiler instrumentation | `linux-sanitizers` (`clang-asan`), `ladder-sanitizers` | The CI job (nonzero exit on any diagnostic) | -| UndefinedBehaviorSanitizer | Compiler instrumentation | `linux-sanitizers` (`clang-ubsan`), `ladder-sanitizers` | The CI job | +| UndefinedBehaviorSanitizer | Compiler instrumentation | `linux-sanitizers` (`clang-ubsan`), `ladder-sanitizers`, `bank-sanitizers` | The CI job | | ThreadSanitizer | Compiler instrumentation | `linux-sanitizers` (`clang-tsan`), `kanban-tsan` | The CI job | | A sanitizer leg can fail at all | `scripts/check_sanitizer_can_fail.sh` | `drift-guard` | The check: real undefined behaviour compiled with `apply_sanitizers()`'s own flags must make the process exit non-zero, and an unknown `AF_SANITIZER` must fail the configure (morph#541) | | Every sanitized binary is really sanitized | `scripts/check_sanitizer_instrumentation.sh` | `linux-sanitizers`, `ladder-sanitizers`, `kanban-tsan` (self-tested in `drift-guard.yml`) | The check: every binary `ctest` will run on a sanitizer leg must carry that sanitizer's runtime symbols (morph#542). Its `--binary ` mode answers the same question about one named file, with no floor, and is refused under `GITHUB_ACTIONS` so it cannot stand in for the sweep on a CI leg (morph#675) | diff --git a/examples/bank/CMakeLists.txt b/examples/bank/CMakeLists.txt index 06034d805..6e1c621de 100644 --- a/examples/bank/CMakeLists.txt +++ b/examples/bank/CMakeLists.txt @@ -95,12 +95,40 @@ target_compile_features(bank_lib PUBLIC cxx_std_23) apply_bigobj(bank_lib) # Note: deliberately NOT calling apply_warnings() here — the third-party ORM # headers are not -Werror clean and would fail the build. +# +# apply_sanitizers() is a separate question and the answer is yes (morph#679). +# Until this line, `ladder_bank_server` was the *only* bank target that carried +# an AF_SANITIZER block, so a sanitizer configure with +# -DMORPH_BUILD_BANK_EXAMPLE=ON built bank_lib, bank_cli and every bank test +# binary with no instrumentation at all — and, worse than being uncovered, +# would have failed scripts/check_sanitizer_instrumentation.sh, because those +# test binaries *are* ctest commands. examples/TESTING.md states the +# convention this now follows: every target gets the block, not just the ones +# someone remembered. +# +# Instrumenting bank_lib does mean instrumenting the vendored Lightweight ORM +# headers it compiles in. Measured rather than assumed: ci.yml's +# `bank-sanitizers` job is the leg that runs this, and its banner records the +# finding count (zero) and the build cost. +# +# All of the blocks below are required together, not one of them optional. An +# instrumented bank_lib with an uninstrumented executable does not merely go +# unchecked, it fails to link: measured by removing bank_tests' block alone, +# +# undefined reference to `__ubsan_handle_type_mismatch_v1_abort' +# clang++: error: linker command failed with exit code 1 +if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_lib ${AF_SANITIZER}) +endif() # ── CLI driver ─────────────────────────────────────────────────────────────── add_executable(bank_cli src/cli/main.cpp) target_link_libraries(bank_cli PRIVATE bank_lib) target_compile_features(bank_cli PRIVATE cxx_std_23) apply_bigobj(bank_cli) +if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_cli ${AF_SANITIZER}) +endif() # ── ladder_bank_server: standalone server binary ───────────────────────────── # Named `ladder_bank_server` because that is the name the scenario tooling @@ -186,10 +214,29 @@ if(MORPH_BUILD_TESTS) target_link_libraries(bank_tests PRIVATE bank_lib morph_test_main) target_compile_features(bank_tests PRIVATE cxx_std_23) apply_bigobj(bank_tests) + # A ctest command, so this block is not optional: without it the + # binary runs on a sanitizer leg carrying nothing, and + # scripts/check_sanitizer_instrumentation.sh fails the job rather than + # letting it report a clean run over an uninstrumented suite + # (morph#679). + if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_tests ${AF_SANITIZER}) + endif() list(APPEND CMAKE_MODULE_PATH ${Catch2_DIR}) include(Catch) - catch_discover_tests(bank_tests DISCOVERY_MODE PRE_TEST) + # LABELS "bank" on all three bank suites (morph#679). Bank is not a + # rung, so morph_add_rung()'s "ladder"/"ladder-" labels never + # reach it and nothing else distinguished a bank test from a core one + # -- which is what a leg that wants to run bank's suites and only + # bank's suites needs. Catch2 tags are not ctest labels (no + # catch_discover_tests call in this repository passes + # ADD_TAGS_AS_LABELS), so `-L bank` is the only selector available, + # and CMakePresets.json's base-test sets `noTestsAction: error`, so a + # label that stopped matching fails the leg instead of running nothing. + catch_discover_tests(bank_tests + DISCOVERY_MODE PRE_TEST + PROPERTIES LABELS "bank") # ── bank_gui_tests: the GUI's own suite ────────────────────────────── # A second binary rather than more sources in bank_tests: bank_tests @@ -240,8 +287,19 @@ if(MORPH_BUILD_TESTS) target_compile_definitions(bank_gui_tests PRIVATE MORPH_LADDER_SOURCE_ROOT="${PROJECT_SOURCE_DIR}") apply_bigobj(bank_gui_tests) + # This is the binary that would have caught morph#663: the + # out-of-range double -> int64_t conversion lived in + # gui/controllers/Format.hpp, which this suite compiles and drives + # directly (tests/gui/test_bank_gui_format.cpp) with Qt6::Core and + # no display. Uninstrumented it exercised the conversion on every + # run and said nothing (morph#679). + if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_gui_tests ${AF_SANITIZER}) + endif() - catch_discover_tests(bank_gui_tests DISCOVERY_MODE PRE_TEST) + catch_discover_tests(bank_gui_tests + DISCOVERY_MODE PRE_TEST + PROPERTIES LABELS "bank") # ── bank_gui_qml_tests: the QML layer, with a live engine ──────── # A third binary, and the reason is the second one's defining @@ -296,6 +354,11 @@ if(MORPH_BUILD_TESTS) MORPH_LADDER_SOURCE_ROOT="${PROJECT_SOURCE_DIR}" MORPH_LADDER_TESTKIT_GUI_APP) apply_bigobj(bank_gui_qml_tests) + # Also a ctest command, so also not optional -- see bank_tests + # above (morph#679). + if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_gui_qml_tests ${AF_SANITIZER}) + endif() # offscreen, because the runner may have no display and this suite # never looks at a pixel -- it reads property values off the items @@ -303,7 +366,9 @@ if(MORPH_BUILD_TESTS) # set for ctest (examples/TESTING.md). catch_discover_tests(bank_gui_qml_tests DISCOVERY_MODE PRE_TEST - PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + PROPERTIES + LABELS "bank" + ENVIRONMENT "QT_QPA_PLATFORM=offscreen") endif() endif() endif() diff --git a/examples/bank/gui/CMakeLists.txt b/examples/bank/gui/CMakeLists.txt index f47a6a362..c057ba7df 100644 --- a/examples/bank/gui/CMakeLists.txt +++ b/examples/bank/gui/CMakeLists.txt @@ -39,6 +39,13 @@ apply_bigobj(bank_gui_lib) # Note: deliberately NOT calling apply_warnings() here, for the same reason # bank_lib does not — this target includes the third-party ORM headers # transitively and they are not -Werror clean. +# +# apply_sanitizers() is not the same question and does apply — see bank_lib's +# own block in ../CMakeLists.txt (morph#679). This is the library holding +# controllers/Format.hpp, where morph#663's undefined conversion was. +if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_gui_lib ${AF_SANITIZER}) +endif() # ── bank_gui: the desktop client ───────────────────────────────────────────── qt_add_executable(bank_gui @@ -68,3 +75,6 @@ target_include_directories(bank_gui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(bank_gui PRIVATE bank_gui_lib Qt6::Quick Qt6::Qml Qt6::QuickControls2) target_compile_features(bank_gui PRIVATE cxx_std_23) apply_bigobj(bank_gui) +if(DEFINED AF_SANITIZER) + apply_sanitizers(bank_gui ${AF_SANITIZER}) +endif() From 948e26b038dfdb1ff89e0fe2067cccb08bca2603 Mon Sep 17 00:00:00 2001 From: Yaraslau Tamashevich Date: Mon, 21 Sep 2026 20:20:06 +0200 Subject: [PATCH 4/4] ci: pin install-qt-action to v4.3.1, so a moving major tag cannot break 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) Claude-Session: https://claude.ai/code/session_01VptDWG2fKr2vBnLSJcgzgW --- .github/workflows/ci.yml | 14 +++++++------- .github/workflows/wasm-demo.yml | 4 ++-- .github/workflows/wasm-ladder.yml | 4 ++-- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 704a6d7d7..66da9b468 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -661,7 +661,7 @@ jobs: # alongside aqtinstall's Qt, which would leave two Qt6 installs on the # same runner for find_package() to pick between. - name: Install Qt ${{ env.QT_VERSION }} - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} modules: qtwebsockets @@ -852,7 +852,7 @@ jobs: # unconditionally and Ubuntu 24.04 still ships 6.4.2 — the same gap # every other job that builds the ladder on Linux already documents. - name: Install Qt ${{ env.QT_VERSION }} - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} modules: qtwebsockets @@ -1047,7 +1047,7 @@ jobs: # unconditionally and Ubuntu 24.04 still ships 6.4.2 — the same gap # every other job that builds Qt on Linux already documents. - name: Install Qt ${{ env.QT_VERSION }} - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} modules: qtwebsockets @@ -1449,7 +1449,7 @@ jobs: # (it is not gated on MORPH_BUILD_FORMS_QML), so the floor still bites here. - name: Install Qt ${{ env.QT_VERSION }} if: steps.filter.outputs.run == 'true' - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} modules: qtwebsockets @@ -1782,7 +1782,7 @@ jobs: # every other job that builds the ladder on Linux already documents. - name: Install Qt ${{ env.QT_VERSION }} if: steps.filter.outputs.run == 'true' - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} modules: qtwebsockets @@ -2048,7 +2048,7 @@ jobs: # floor in CMakeLists.txt) and Ubuntu 24.04 still ships 6.4.2, whose # QQmlApplicationEngine has no loadFromModule. - name: Install Qt ${{ env.QT_VERSION }} - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} modules: qtwebsockets @@ -2507,7 +2507,7 @@ jobs: # See the linux-all-features job: the QML renderer needs Qt 6.5+. - name: Install Qt ${{ env.QT_VERSION }} - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} modules: qtwebsockets diff --git a/.github/workflows/wasm-demo.yml b/.github/workflows/wasm-demo.yml index c9bba2335..60cdef30b 100644 --- a/.github/workflows/wasm-demo.yml +++ b/.github/workflows/wasm-demo.yml @@ -48,7 +48,7 @@ jobs: # aqtinstall gives a matched host + wasm Qt pair (same cmake glue), so no # host/target version skew. - name: Install Qt (host desktop) - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} host: linux @@ -59,7 +59,7 @@ jobs: # Qt 6.7+ ships WebAssembly under host=all_os / target=wasm (not # linux/desktop). The matching host desktop Qt above provides the tools. - name: Install Qt (wasm, single-threaded) - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} host: all_os diff --git a/.github/workflows/wasm-ladder.yml b/.github/workflows/wasm-ladder.yml index 70265ff2e..a1942be9a 100644 --- a/.github/workflows/wasm-ladder.yml +++ b/.github/workflows/wasm-ladder.yml @@ -91,7 +91,7 @@ jobs: # (examples/IMPLEMENTATION.md rule 4's WASM clause), so the transport is # not optional here the way it is for bank's local-only demo. - name: Install Qt (host desktop) - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} host: linux @@ -101,7 +101,7 @@ jobs: dir: ${{ runner.temp }}/qt - name: Install Qt (wasm, single-threaded) - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@v4.3.1 with: version: ${{ env.QT_VERSION }} host: all_os