#!/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/dev/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/dev/traceability.md "${before}" cargo run -q -p traceability -- report if diff -q "${before}" docs/dev/traceability.md >/dev/null; then record "traceability/matrix" 0 else echo "docs/dev/traceability.md was stale; regenerating changed it:" diff --stat "${before}" docs/dev/traceability.md 2>/dev/null \ || diff "${before}" docs/dev/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}"