Files
DarkRoom/.gitea/workflows/build-and-test.yml
T
dtourolle 6b1aac477d Put the developer docs under docs/dev and index the folder for users first
docs/ had 26 developer documents flat beside the manual, and the two
audiences are very differently sized: most readers want the manual and
the gesture reference, a few want the register, the designs and the
measurements. The manual and gestures.md stay at the top; everything for
someone changing the code moves to docs/dev/, and the two documents that
name their own successors — the v0.1 milestone and the UI-refinement plan
— go to docs/dev/archive/ rather than being deleted, since both are still
cited. docs/README.md is the index, users first.

Every reference follows: code comments, Cargo manifests, the workflows,
the pre-commit hook, the bench and traceability tools (which locate the
repo root by docs/dev/requirements.md now), packaging, the Docker READMEs,
CLAUDE.md, CONTRIBUTING.md and the README. The matrix links one level
deeper and is regenerated. Links out of the moved documents into the tree
gain a level; a link checker over every Markdown file finds none broken.
2026-09-20 16:20:15 +02:00

517 lines
24 KiB
YAML

name: Build and test
# Desktop and Android are built on every push, per the v0.1 decision to carry
# both platforms from the first commit. An Android break is then caught the day
# it lands rather than at a porting milestone.
on:
push:
branches: [main, master, develop]
pull_request:
branches: [main, master, develop]
jobs:
# The Android job runs inside an image that this repo builds. Ensure it is in
# the registry before anything tries to pull it — see android-image.yml for
# why this is a job rather than a documented manual step. It is a no-op of a
# few seconds unless docker/android actually changed.
android-image:
uses: ./.gitea/workflows/android-image.yml
desktop:
runs-on: linux/amd64
name: Desktop (Linux)
# actions/checkout and actions/cache are JavaScript actions: the runner
# executes them with Node from inside this container. The bare runner image
# has none, so the job failed at checkout before reaching any build step.
container:
image: catthehacker/ubuntu:act-latest
# This job filled the runner's disk and died mid-link with "No space left
# on device" — LLVM reporting an IO failure on its output stream, which
# reads like a compiler crash and is not one.
#
# `target/debug` was 24 GB against `target/release`'s 2.6 GB: 15 GB of it
# debug info in `debug/deps`, 3.6 GB incremental state. Neither earns its
# space here. Nothing attaches a debugger to a CI run, and incremental
# compilation exists to make the *second* build in a working tree fast,
# which is not a thing a fresh checkout has. Turning both off is the
# standard CI setting rather than a trick.
#
# Measured on this workspace: the same `cargo test --workspace --no-run`
# tree goes from 24 GB to 3.3 GB, `debug/deps` from 15 GB to 2.8 GB.
#
# Backtraces still name functions without debug info; they lose file and
# line numbers. If a test failure ever needs those, drop DEBUG to 1
# (line-tables-only) rather than back to 2.
#
# This is a mitigation, not a fix. If the runner is full of anything other
# than this job's own output, it will still be full afterwards.
env:
CARGO_INCREMENTAL: 0
CARGO_PROFILE_DEV_DEBUG: 0
steps:
- name: Checkout
uses: actions/checkout@v4
# The model, which is in LFS and is not optional.
#
# `models/**/*.onnx` is tracked in LFS (.gitattributes), so a
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
# `dr-segment`'s build script panics by design rather than embedding a
# pointer and failing at inference. That failure reads like a broken build
# instead of a missing fetch, which is how it went unnoticed.
#
# Not `lfs: true` on the checkout above, and no `Authorization` header
# here either. Both install a blanket header for every request to this
# host, and the object download is the one request that already carries
# one: `git lfs pull` asks `/info/lfs/objects/batch` first, and Gitea
# answers with a short-lived `Bearer` JWT scoped to that object. git-lfs
# then sends the JWT *and* the configured header, and two `Authorization`
# headers is a 400 from Gitea — reported as
# LFS: Client error: .../info/lfs/objects/<oid>
# one step after the batch call that had just succeeded, which reads like
# a rejected credential rather than a duplicated one. A lone token header
# is understood fine; it is only the collision that fails.
#
# So: strip the headers and hand the token to git-lfs as an ordinary
# credential instead. It authenticates the batch call and leaves the
# per-object JWT untouched. Gitea authenticates on the password, so the
# username is a placeholder. Nothing later in this job talks to the
# remote, so dropping checkout's header costs us nothing.
#
# `continue-on-error` deliberately: if this cannot authenticate, the build
# below still runs and fails with the build script's own message, which
# names the real problem. A checkout that dies here says nothing.
- name: Fetch the segmentation model
continue-on-error: true
env:
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
run: |
set -e
git lfs install --local
git config --local --get-regexp '^http\..*extraheader$' \
| cut -d' ' -f1 | sort -u \
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
ls -lR models/
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: desktop-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}
# Slint and winit need these at build time; the runner image is minimal.
- name: Build dependencies
run: |
apt-get update -qq
apt-get install -y -qq pkg-config libfontconfig1-dev libxkbcommon-dev
# The act image ships Node but no Rust. Pinned to the workspace
# rust-version so CI, the Android image, and local builds agree — a
# floating toolchain turns an unrelated push into a mystery failure.
#
# The component list mirrors rust-toolchain.toml's, rust-analyzer
# included, even though nothing in this job runs it. rustup reconciles
# that file against the installed toolchain on the first cargo call in
# the work tree and fetches whatever is missing — so leaving it out does
# not save the download, it only moves it into the middle of a build
# step where it is nobody's line item. Naming it here keeps every fetch
# inside the step whose name says it is installing things.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal \
--default-toolchain 1.92.0 --component rustfmt,clippy,rust-analyzer
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# Free space before and after the expensive steps, so a repeat of the
# disk exhaustion above is one line to diagnose instead of a puzzling
# LLVM error.
- name: Disk before
run: df -h /workspace 2>/dev/null || df -h .
- name: Format
run: cargo fmt --all -- --check
- name: Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
# GPU tests skip themselves where no adapter is present rather than
# failing — CI runners generally have none, and a test that cannot run is
# not evidence either way.
- name: Test
run: cargo test --workspace
- name: Build
run: cargo build --workspace --release
- name: Disk after
if: always()
run: df -h /workspace 2>/dev/null || df -h .
android:
runs-on: linux/amd64
name: Android (aarch64)
# Waits for the image build. Without this the pull races the push and the
# job dies with "manifest unknown" before its first step, which is the
# failure mode this ordering exists to remove.
needs: android-image
container:
image: gitea.tourolle.paris/dtourolle/darkroom-android:latest
steps:
- name: Checkout
uses: actions/checkout@v4
# The model, which is in LFS and is not optional.
#
# `models/**/*.onnx` is tracked in LFS (.gitattributes), so a
# plain checkout writes a ~130-byte pointer where 11 MB should be, and
# `dr-segment`'s build script panics by design rather than embedding a
# pointer and failing at inference. That failure reads like a broken build
# instead of a missing fetch, which is how it went unnoticed.
#
# Not `lfs: true` on the checkout above, and no `Authorization` header
# here either. Both install a blanket header for every request to this
# host, and the object download is the one request that already carries
# one: `git lfs pull` asks `/info/lfs/objects/batch` first, and Gitea
# answers with a short-lived `Bearer` JWT scoped to that object. git-lfs
# then sends the JWT *and* the configured header, and two `Authorization`
# headers is a 400 from Gitea — reported as
# LFS: Client error: .../info/lfs/objects/<oid>
# one step after the batch call that had just succeeded, which reads like
# a rejected credential rather than a duplicated one. A lone token header
# is understood fine; it is only the collision that fails.
#
# So: strip the headers and hand the token to git-lfs as an ordinary
# credential instead. It authenticates the batch call and leaves the
# per-object JWT untouched. Gitea authenticates on the password, so the
# username is a placeholder. Nothing later in this job talks to the
# remote, so dropping checkout's header costs us nothing.
#
# `continue-on-error` deliberately: if this cannot authenticate, the build
# below still runs and fails with the build script's own message, which
# names the real problem. A checkout that dies here says nothing.
- name: Fetch the segmentation model
continue-on-error: true
env:
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
run: |
set -e
git lfs install --local
git config --local --get-regexp '^http\..*extraheader$' \
| cut -d' ' -f1 | sort -u \
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
ls -lR models/
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
/opt/cargo/registry
target-android
key: android-${{ hashFiles('**/Cargo.lock') }}
# A fast gate on the crates most likely to break the cross-compile, run
# before the expensive part. It is `cargo check`, so it type-checks
# without linking and returns in a fraction of the time the step below
# takes.
#
# Not a statement that only these crates cross-compile — `darkroom-android`
# and the whole UI stack beneath it build for aarch64 too, which is what
# the API-level step below does. This one exists to fail fast and name a
# smaller suspect when it does.
- name: Cross-compile core
env:
CARGO_TARGET_DIR: target-android
run: cargo check -p dr-types -p dr-gpu -p dr-sync --target aarch64-linux-android
# The linker targets MIN_API, not the compile SDK. cargo-ndk otherwise
# defaults to API 21, far below the Vulkan floor this app needs — and the
# mismatch is invisible until a device refuses to install.
#
# Look under the target triple, and fail on a mismatch. Searching the
# whole target dir for the first `*.so` found the host proc-macro
# libraries in target-android/debug/deps instead — x86-64 objects built
# by the runner's gcc, whose .comment section says nothing about Android
# and can never contradict the expected API. The step passed regardless
# of what the linker actually did, which is the one thing it exists to
# rule out.
- name: Verify minimum API level
env:
CARGO_TARGET_DIR: target-android
run: |
set -e
# `darkroom-android`, not a core crate: this step reads the API level
# out of a *linked* object, and only that crate produces one. It is
# the workspace's single `crate-type = ["cdylib"]`; a library crate
# builds an rlib, which is an archive of object files that no linker
# has yet touched and that `file` therefore has nothing to say about.
# Asking for `-p dr-gpu` here could only ever reach the "no aarch64
# .so was produced" branch below, whatever the linker did.
#
# It is also the honest artefact to check: the .so this names is the
# one that ships in the APK, so the API level verified here is the
# API level a device will refuse to install against.
cargo ndk -t arm64-v8a -o target-android/jniLibs \
build -p darkroom-android --release
MIN_API=$(sed -n 's/^ARG MIN_API=\([0-9]*\).*/\1/p' docker/android/Dockerfile)
# Empty on both sides would compare equal and pass, so neither side
# is allowed to be the result of a failed parse.
if [ -z "$MIN_API" ]; then
echo "no ARG MIN_API= in docker/android/Dockerfile"
exit 1
fi
SO=$(find target-android/aarch64-linux-android/release -maxdepth 1 -name '*.so' | head -1)
if [ -z "$SO" ]; then
echo "no aarch64 .so was produced"
exit 1
fi
echo "checking $SO"
# `file` is kept for the log — it names the NDK that built this — but
# the check no longer depends on it.
file "$SO" || true
# The API level is the first word of the `.note.android.ident` ELF
# note, little-endian. Read the note rather than asking `file` for it:
# `file` only prints "for Android 28" when its magic database is new
# enough to decode that note, and this image's is not. The parse then
# produced nothing, `${API:-unknown}` reported "unknown", and every
# push failed here for weeks on a .so that was linked perfectly
# correctly. A note read straight out of the ELF cannot go stale that
# way.
readelf -n "$SO" | sed -n '/android.ident/,+3p'
HEX=$(readelf -n "$SO" 2>/dev/null \
| awk '/description data:/ { print $6 $5 $4 $3; exit }')
if [ -z "$HEX" ]; then
echo "FAIL: no .note.android.ident in $SO — nothing states an API level"
exit 1
fi
API=$(( 0x$HEX ))
if [ "$API" != "$MIN_API" ]; then
echo "FAIL: linked for Android $API, expected $MIN_API"
exit 1
fi
echo "OK: linked for Android $API"
# The APK itself, so a run leaves something installable behind rather
# than only the knowledge that it would have linked. The assembly is
# `docker/android/assemble-apk.sh`, shared with `package.sh` so the file
# a device gets from `package.sh --install` and the file published here
# are built by the same code — see that script's header.
#
# `KEYSTORE` deliberately points at a throwaway directory instead of its
# default under `target-android`: that directory is what `actions/cache`
# restores and saves, and a signing key has no business in a build cache
# or in anything this job uploads. A fresh debug key per run is the right
# trade for an artefact whose purpose is getting the app onto a test
# device; nothing upgrades in place over it, which is the one thing a
# stable key would buy.
- name: Package the APK
env:
CARGO_TARGET_DIR: target-android
# Absent secrets mean a debug signature, which is what a fork or a
# branch build should get. Set all three (see docs/dev/android-signing.md)
# and the same job produces a release-signed APK instead.
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
KEYSTORE_PASS: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
KEY_PASS: ${{ secrets.ANDROID_KEY_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
run: |
set -e
KEYDIR="$(mktemp -d)"
chmod 700 "$KEYDIR"
trap 'rm -rf "$KEYDIR"' EXIT
if [ -n "$ANDROID_KEYSTORE_BASE64" ]; then
# The keystore reaches the runner base64-encoded because a secret
# is a string. It is written under a 0700 mktemp directory, never
# into the workspace: `target-android` is what actions/cache saves,
# and the upload step globs the workspace.
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > "$KEYDIR/release.keystore"
export KEYSTORE="$KEYDIR/release.keystore"
else
# Not an error. Unset the rest so assemble-apk.sh takes its debug
# path cleanly rather than seeing a half-configured release one.
export KEYSTORE="$KEYDIR/debug.keystore"
unset KEYSTORE_PASS KEY_PASS KEY_ALIAS
fi
REPO="$PWD" TARGET_DIR="$PWD/target-android" \
bash docker/android/assemble-apk.sh
# v3, not v4. v4 is untested against this Gitea and its runner; v3 is
# what JellyTau uploads its APK with on this same runner, so it is the
# version known to work here rather than the version that ought to.
#
# `if-no-files-found: error` because the failure this guards against is
# a green run with an empty artefact list, which reads as success until
# somebody goes looking for the file.
- name: Upload the APK
uses: actions/upload-artifact@v3
with:
name: darkroom-arm64-v8a-apk
path: target-android/apk/darkroom.apk
if-no-files-found: error
windows-image:
uses: ./.gitea/workflows/windows-image.yml
# TRACES: FR-PLAT-WIN-3
# The Windows executable and its installer, cross-built from Linux
# (docs/dev/windows.md §7). No Windows machine anywhere in this job: what it
# can prove is that the binary links, is a Windows executable with no
# MinGW runtime imports, starts under Wine, and that the installer installs
# and uninstalls under Wine. What it cannot prove — a Vulkan device, a
# render, the secret store — is a release step on a real machine (§6).
windows:
runs-on: linux/amd64
name: Windows (x86_64, cross)
needs: windows-image
container:
image: gitea.tourolle.paris/dtourolle/darkroom-windows:latest
env:
CARGO_INCREMENTAL: 0
CARGO_PROFILE_DEV_DEBUG: 0
CARGO_TARGET_DIR: target-windows
# Wine keeps its prefix under $HOME, which the image points at a
# directory that does not exist in a fresh container.
HOME: /tmp/home
steps:
- name: Checkout
uses: actions/checkout@v4
# Same step as the desktop leg: the models are LFS objects and the
# packager refuses pointers.
- name: Fetch the models
env:
LFS_TOKEN: ${{ secrets.GITEA_TOKEN || github.token }}
run: |
set -e
git lfs install --local
git config --local --get-regexp '^http\..*extraheader$' \
| cut -d' ' -f1 | sort -u \
| while read -r key; do git config --local --unset-all "$key"; done || true
git config --local lfs.url \
"https://x-access-token:${LFS_TOKEN}@gitea.tourolle.paris/dtourolle/DarkRoom.git/info/lfs"
git lfs pull --exclude="fixtures/**,docs/manual/media/**"
ls -l models/face models/scene
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
/opt/cargo/registry
target-windows
key: windows-${{ hashFiles('**/Cargo.lock') }}
# The cfg(windows) branches are linted here and nowhere else: the
# desktop leg's clippy never compiles them.
- name: Clippy for the target
run: cargo clippy --release --target x86_64-pc-windows-gnu -p darkroom-desktop -- -D warnings
- name: Build
run: cargo build --release --target x86_64-pc-windows-gnu -p darkroom-desktop
- name: Smoke-test the executable
run: |
set -e
mkdir -p "$HOME"
EXE=target-windows/x86_64-pc-windows-gnu/release/darkroom-desktop.exe
file "$EXE"
file "$EXE" | grep -q 'PE32+' || { echo "FAIL: not a PE32+ executable"; exit 1; }
file "$EXE" | grep -q '(GUI)' || { echo "FAIL: not a GUI-subsystem executable"; exit 1; }
if x86_64-w64-mingw32-objdump -p "$EXE" | grep -iE 'libwinpthread|libgcc|libstdc'; then
echo "FAIL: the executable imports a MinGW runtime DLL"
exit 1
fi
x86_64-w64-mingw32-objdump -p "$EXE" | grep 'DLL Name' | sort -u
wineboot --init >/dev/null 2>&1 || true
OUT=$(wine "$EXE" --version 2>/dev/null)
echo "wine: $OUT"
echo "$OUT" | grep -q '^darkroom-desktop ' || { echo "FAIL: --version did not answer under Wine"; exit 1; }
- name: Package the installer
run: bash docker/windows/package.sh
- name: Smoke-test the installer
run: |
set -e
SETUP=$(ls target-windows/installer/DarkRoom-*-x86_64-setup.exe)
file "$SETUP" | grep -q 'PE32+' || { echo "FAIL: the installer is not 64-bit"; exit 1; }
wine "$SETUP" /S 2>/dev/null
INST=$(echo "$HOME"/.wine/drive_c/users/*/AppData/Local/Programs/DarkRoom)
ls "$INST"
[ "$(ls "$INST/models" | wc -l)" = 7 ] || { echo "FAIL: expected 7 model files"; exit 1; }
wine reg query 'HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\DarkRoom' 2>/dev/null \
| grep -q DisplayVersion || { echo "FAIL: no uninstall registry key"; exit 1; }
wine "$INST/darkroom.exe" --version 2>/dev/null | grep -q '^darkroom-desktop ' \
|| { echo "FAIL: the installed executable does not run"; exit 1; }
wine "$INST/uninstall.exe" /S 2>/dev/null
sleep 3
[ ! -e "$INST" ] || { echo "FAIL: uninstall left $INST behind"; ls -R "$INST"; exit 1; }
echo "OK: installed and uninstalled under Wine"
- name: Upload the installer
uses: actions/upload-artifact@v3
with:
name: darkroom-windows-x86_64-setup
path: target-windows/installer/DarkRoom-*-x86_64-setup.exe
if-no-files-found: error
layering:
runs-on: linux/amd64
name: Layer separation
# Node for the JS actions, as above. cargo comes from rustup below.
container:
image: catthehacker/ubuntu:act-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# `cargo tree` resolves the dependency graph, so it needs the registry
# index but no system libraries — this job builds nothing.
#
# rust-analyzer is named for the reason given in the desktop job: rustup
# installs rust-toolchain.toml's components on the first cargo call
# whether or not this step asks for them, and an unasked-for download is
# the one nobody can find in the log.
- name: Install Rust 1.92.0
run: |
set -e
curl -fsSL https://sh.rustup.rs | sh -s -- \
-y --no-modify-path --profile minimal --default-toolchain 1.92.0 \
--component rust-analyzer
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# ARCH §6.5a: no core/ crate may depend on the UI toolkit. One stray
# `use slint::` costs headless golden-image testing and the
# one-operation-two-presentations property together, and nothing else
# would notice.
- name: Core crates must not depend on the UI
run: |
set -e
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
exit $FAILED