Make the CI checks say what they mean, and format the workspace
Build and test / Desktop (Linux) (push) Successful in 1h23m26s
Build and test / Android (aarch64) (push) Failing after 2s
Build and test / Layer separation (push) Successful in 50s
Traceability / Requirement traces (push) Failing after 1m9s

The Android job's "Verify minimum API level" step has never verified the
minimum API level. It took the first `*.so` anywhere under the target
directory, which is a host proc-macro from debug/deps — an x86-64 object
built by the runner's gcc, whose .comment section cannot mention Android
and so can never contradict the expected value. It now reads the artifact
under the target triple, compares against MIN_API parsed from the
Dockerfile rather than a second copy of the number, and fails on a
mismatch. Both sides are checked non-empty first: two failed parses would
otherwise compare equal and pass, which is the same silent success in a
new costume.

The Android image installs one SDK package per layer and keeps the
output. sdkmanager is a JVM program that aborts when it cannot get memory,
and the single `> /dev/null` step reported that as a bare "exit code 134"
while a retry re-downloaded everything that had already succeeded.

tools/ci-local.sh runs all four jobs — desktop, android, layering,
traceability — against the host toolchain, which is pinned to the same
1.92.0 CI installs. Its matrix check compares regeneration against the
working tree rather than against HEAD: CI starts from a clean checkout, so
git's answer is the right one there and reports every local run stale here.

The rest is rustfmt across the workspace, and the clippy findings that
surfaced once it did: manual_contains in dr-thumbs and collections_ui, a
map iterated as pairs for its keys, an index loop over a slice, and two
runtime assertions on a constant now made at compile time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 12:02:51 +02:00
co-authored by Claude Opus 5
parent 94a2686dcb
commit 03326242a1
31 changed files with 927 additions and 387 deletions
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
# Run what .gitea/workflows/ runs, on this machine.
#
# ./tools/ci-local.sh # every job
# ./tools/ci-local.sh desktop # one job
# ./tools/ci-local.sh desktop layering # several
# ./tools/ci-local.sh --list
#
# Jobs: desktop, android, layering, traceability.
#
# Fidelity and its limits. The desktop, layering and traceability jobs run
# natively rather than inside catthehacker/ubuntu:act-latest, which is honest
# only because the host toolchain is pinned to the same 1.92.0 CI installs —
# the check below refuses to run otherwise, since a clippy lint set that drifts
# between here and CI makes a green local run worthless. What this cannot catch
# is a missing *system* library: CI apt-installs libfontconfig and libxkbcommon
# into a minimal image, so a build that only succeeds here because the
# development box has some other -dev package will still break CI. The android
# job does run in its container, because nothing about a cross-compile
# reproduces natively.
#
# Also unmodelled: actions/checkout (this runs against the working tree, dirty
# or not — that is the point) and actions/cache (the host cargo cache stands in,
# which makes repeat runs far faster than CI's).
set -uo pipefail
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "${REPO}"
RUST_PIN="1.92.0"
ALL_JOBS=(desktop android layering traceability)
if [[ "${1:-}" == "--list" ]]; then
printf '%s\n' "${ALL_JOBS[@]}"
exit 0
fi
if [[ $# -gt 0 ]]; then
JOBS=("$@")
for j in "${JOBS[@]}"; do
if [[ ! " ${ALL_JOBS[*]} " == *" ${j} "* ]]; then
echo "error: unknown job '${j}'; try --list" >&2
exit 2
fi
done
else
JOBS=("${ALL_JOBS[@]}")
fi
# Colour only when a terminal is watching; a redirected log stays plain.
if [[ -t 1 ]]; then
B=$'\e[1m'; R=$'\e[31m'; G=$'\e[32m'; Y=$'\e[33m'; Z=$'\e[0m'
else
B=''; R=''; G=''; Y=''; Z=''
fi
RESULTS=()
FAILED=0
step() { printf '\n%s==> %s%s\n' "${B}" "$*" "${Z}"; }
# A step's failure is recorded and the job carries on, the way separate CI
# steps would not — but one local run that reports every problem beats four
# runs each finding the next one.
record() {
local name="$1" code="$2"
if [[ "${code}" -eq 0 ]]; then
RESULTS+=("${G}pass${Z} ${name}")
else
RESULTS+=("${R}FAIL${Z} ${name}")
FAILED=1
fi
}
run() {
local name="$1"; shift
step "${name}: $*"
"$@"
record "${name}" "$?"
}
# CI pins the toolchain so an unrelated push cannot fail on a compiler that
# moved under it. A local run on a different rustc is measuring a different
# thing, and silently so.
check_toolchain() {
local have
have="$(rustc --version | awk '{print $2}')"
if [[ "${have}" != "${RUST_PIN}" ]]; then
printf '%swarning: rustc %s, CI pins %s — clippy and fmt may disagree%s\n' \
"${Y}" "${have}" "${RUST_PIN}" "${Z}" >&2
fi
}
job_desktop() {
check_toolchain
run "desktop/fmt" cargo fmt --all -- --check
run "desktop/clippy" cargo clippy --workspace --all-targets -- -D warnings
run "desktop/test" cargo test --workspace
run "desktop/build" cargo build --workspace --release
}
# The one job that genuinely needs the container: it is a cross-compile, and
# the NDK, the linker and MIN_API all live in the image.
job_android() {
if ! command -v podman >/dev/null 2>&1 && ! command -v docker >/dev/null 2>&1; then
RESULTS+=("${Y}skip${Z} android (no podman or docker)")
return
fi
step "android: cross-compile core"
./docker/android/build.sh \
cargo check -p dr-types -p dr-gpu -p dr-sync --target aarch64-linux-android
record "android/check" "$?"
step "android: verify minimum API level"
./docker/android/build.sh cargo ndk -t arm64-v8a build -p dr-gpu --release
record "android/ndk-build" "$?"
# Asserted, not reported — and looked up under the target triple.
#
# The workflow this mirrors searches the whole target dir for the first
# `*.so` and reads its .comment. Under target-android/debug/deps sit the
# host proc-macro libraries, which are x86-64 ELF built by the Debian gcc,
# so the step happily prints a comment section that says nothing about
# Android and passes. A wrong MIN_API is invisible until a device refuses
# to install, which is exactly the failure the step exists to catch, so it
# is worth failing on here.
local triple_dir so api
triple_dir="${XDG_CACHE_HOME:-${HOME}/.cache}/darkroom-android/target/aarch64-linux-android/release"
so="$(find "${triple_dir}" -maxdepth 1 -name '*.so' -print -quit 2>/dev/null)"
if [[ -z "${so}" ]]; then
echo "no aarch64 .so under ${triple_dir}"
record "android/min-api" 1
return
fi
# MIN_API is the Dockerfile's, read rather than repeated: two copies of the
# number would drift and the drift is the bug being checked for.
local min_api
min_api="$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)"
# Both sides must be non-empty before they are compared: two failed parses
# would otherwise satisfy `"" == ""` and report a pass, which is the same
# silent success this check was rewritten to remove.
if [[ -z "${min_api}" ]]; then
echo "FAIL: no ARG MIN_API= in docker/android/Dockerfile"
record "android/min-api" 1
return
fi
echo "checking ${so}"
file "${so}"
api="$(file "${so}" | sed -n 's/.*for Android \([0-9]*\).*/\1/p')"
if [[ -n "${api}" && "${api}" == "${min_api}" ]]; then
record "android/min-api" 0
else
echo "FAIL: linked for Android '${api:-unknown}', expected ${min_api}"
record "android/min-api" 1
fi
}
# ARCH §6.5a. Builds nothing: `cargo tree` only resolves the graph.
job_layering() {
step "layering: core crates must not depend on the UI"
local failed=0
for crate in dr-types dr-gpu dr-sync; do
if cargo tree -p "${crate}" -e normal 2>/dev/null | grep -qE '\bslint\b|\bi-slint'; then
echo "FAIL: ${crate} depends on Slint (ARCH §6.5a)"
failed=1
else
echo "ok: ${crate}"
fi
done
record "layering" "${failed}"
}
job_traceability() {
run "traceability/self-test" cargo test -p traceability
run "traceability/gate" cargo run -q -p traceability -- check
# Compared against the working tree, not against HEAD.
#
# CI asks `git diff --quiet docs/traceability.md` after regenerating, which
# is right there and wrong here: CI starts from a clean checkout, so the
# only diff it can see is the one regeneration introduced. Locally the file
# is usually already modified for honest reasons — an uncommitted feature
# brings new TRACES tags with it — and the git form would then report the
# matrix as stale on every run, including the runs where it is perfectly
# up to date. Snapshotting first asks the question CI means to ask: does
# regenerating change anything?
step "traceability: matrix is up to date"
local before
before="$(mktemp)"
cp docs/traceability.md "${before}"
cargo run -q -p traceability -- report
if diff -q "${before}" docs/traceability.md >/dev/null; then
record "traceability/matrix" 0
else
echo "docs/traceability.md was stale; regenerating changed it:"
diff --stat "${before}" docs/traceability.md 2>/dev/null \
|| diff "${before}" docs/traceability.md | head -20
record "traceability/matrix" 1
fi
rm -f "${before}"
}
for job in "${JOBS[@]}"; do
printf '\n%s──────── %s ────────%s\n' "${B}" "${job}" "${Z}"
"job_${job}"
done
printf '\n%s──────── summary ────────%s\n' "${B}" "${Z}"
printf '%s\n' "${RESULTS[@]}"
exit "${FAILED}"