name: PR Check — Rust # Rust half of the former check.yml (split in #232). # # Runs on PRs that change Rust, plus once a week on main to refresh the shared # cache (see the note on the triggers). The runner has capacity 1 — jobs queue # instead of running in parallel, so every minute spent here is a minute the # next PR waits. # # No `branches:` filter on purpose. `branches: [main]` never matched a PR # stacked on top of another feature branch, which is what /autopilot produces: # 4 of the 5 PRs in the feature-gating milestone ran no CI at all. on: pull_request: paths: - 'src-tauri/**' - '.forgejo/workflows/check-rust.yml' # Whole directory, not just audit.toml: a later .cargo/config.toml # (rustflags, linker, target dir) would change the Rust build, and an # exact-path entry would let it skip Rust CI unnoticed. - '.cargo/**' # The weekly run is what feeds the cache to pull requests (#234). The runner # (forgejo-runner v12.7.0) isolates what a pull_request run writes to that # PR's ref: later pushes to the same PR read it back, other PRs never see it. # A PR run falls back to the shared, non-isolated entries only when nothing # matches in its own scope. Scheduled and dispatched runs write to the shared # scope, which makes them this workflow's only writers of it (a merged PR's # `closed` event would be another, but this workflow does not subscribe to # it). # # The runner deletes an entry unused for 7 days or created more than 30 days # ago, and keeps only the newest entry per key, whatever its scope; the next # Monday run saves a fresh one. Cold runs that do not mean #234 is back (a # timeout would): # - a new stable Rust (the toolchain floats on `stable`) or a change to this # job's `env:`: the key hashes rustc and the CARGO*, RUST*, CC*, CFLAGS*, # CXX* and CMAKE* variables. Cold until the next Monday; # - a PR run that saves under the shared entry's key evicts it for every PR # until the next Monday. It saves when its own scope holds an older entry # for another Cargo.lock, served before the shared one: a PR that bumped a # dependency, then went back to main's lockfile. Two open PRs on the same # lockfile, different from main's, evict each other the same way; # - PR runs once an entry that only got exact hits, hence never re-saved, # reaches 30 days; # - after a week without any cache request, the Monday restore can fail with # a 404: the lookup does not refresh the entry, and the cleanup it triggers # may delete it (forgejo/runner#1741). The run then saves a fresh entry. # workflow_dispatch re-seeds by hand. schedule: # Mondays 06:30 UTC, after audit.yml (06:00 UTC). - cron: '30 6 * * 1' workflow_dispatch: # What this key does on Forgejo 8.0.3 / runner v12.7.0, as measured on # 2026-10-02 (la-compagnie-maximus docs/forgejo-ops.md, sections "Le schéma du # workflow est validé par le runner" and "Un push annule le run du push # précédent"): # - it passes the runner's schema validation, but what it adds has not been # isolated; # - without it, Forgejo cancels a `push` run when a newer one of the same # workflow is created on the same ref, whether the older run's job was still # queued or already running; # - no pull_request (or pull_request_sync) job has been cancelled by a later # run: on a PR, a force-push does not cancel the obsolete run. # Kept anyway (#376): harmless here, and it keeps its GitHub Actions meaning # should these workflows run where it is honoured (GitHub, a later Forgejo). # There, a group shared with the frontend workflow would make the two cancel # each other on a PR that touches both, hence the distinct group. concurrency: group: ci-rust-${{ github.ref }} cancel-in-progress: true permissions: contents: read jobs: rust: runs-on: ubuntu container: ubuntu:22.04 env: PATH: /root/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin CARGO_TERM_COLOR: always # rust-cache keeps the dependencies and drops this crate's own artifacts # before saving, so incremental data would never be reused — pure # overhead — and test debug info would only make the cached target/ # bigger. CARGO_INCREMENTAL: 0 CARGO_PROFILE_TEST_DEBUG: 0 steps: - name: Install system dependencies, Node.js and Rust run: | apt-get update # zstd: the cache archives are compressed with it when present, # faster and smaller than the gzip fallback. apt-get install -y --no-install-recommends \ curl wget git ca-certificates build-essential pkg-config \ libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev libssl-dev \ libdbus-1-dev zstd # Node.js runs the JavaScript actions: actions/checkout (node20) and # Swatinem/rust-cache (node24 since v2.9). The runner starts them # with whatever `node` the container has, so install the newest # runtime they declare. taiki-e/install-action is a composite action. curl -fsSL https://deb.nodesource.com/setup_24.x | bash - apt-get install -y nodejs # Rust toolchain curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal node --version rustc --version cargo --version - name: Checkout uses: https://github.com/actions/checkout@v4 # Caches ~/.cargo (registry, git, bin) and src-tauri/target, keyed on the # rustc version, the job's CARGO*/RUST* environment, the dependencies # declared in the Cargo manifests and lockfile (not the app's own # version), and this job. Saved only when the job succeeds and the key # was not an exact hit. Served by # the runner's cache proxy on port 28100, which ufw opens to the Docker # job networks only; until 2026-10-03 every restore and save timed out # there (#234, la-compagnie-maximus docs/forgejo-ops.md). What a PR run # saves stays private to that PR: see the note on the triggers. - name: Rust cache uses: https://github.com/Swatinem/rust-cache@v2 with: workspaces: "src-tauri -> target" # --locked (#449): a Cargo.lock that no longer matches Cargo.toml (hand # edit, conflict resolved badly, dependency added without its lockfile) # fails the job here instead of being rewritten in the job's checkout. # The guards and the audit below then read the committed lockfile, the # one release.yml builds the shipped binaries from, also with --locked. # A deliberate `cargo update` in a PR is not affected: it commits the # lockfile it writes. - name: cargo check run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets --locked # Anti-rot guard for the suppressions in .cargo/audit.toml (#310). Each # entry there is justified by its crate being absent from every shipped # target's graph — a property of today's resolved graph, not a permanent # one. rsa is currently reachable from nothing: its only parent in the # lockfile is sqlx-mysql, which this SQLite project never compiles. If a # dependency change ever pulls it into a shipped target, the suppression # would silently hide a live advisory and the daily audit would stay # green: the inverse of the permanent red #310 exists to kill. # # Such a change would itself touch src-tauri, which is exactly what # triggers this workflow. Runs after cargo check so the registry index is # already warm, and --locked so cargo tree cannot rewrite the lockfile the # audit was taken against. # # CRATES must mirror the crates named in .cargo/audit.toml. Adding an # entry there without adding its crate here leaves it unguarded. # # Every check echoes its result, including the passing ones. A guard that # is silent on success cannot be told apart in the log from a guard that # never ran — which is the same silent-skip failure mode this step exists # to catch, one level up. - name: Verify suppressed advisories are still unreachable run: | set -u CRATES="rsa" TARGETS="x86_64-unknown-linux-gnu x86_64-pc-windows-msvc" rc=0 checks=0 for crate in $CRATES; do for target in $TARGETS; do # An absent crate exits 0 with empty stdout ("nothing to print" # goes to stderr). A non-zero exit means cargo tree itself failed # — treat that as a failure rather than as proof of absence. out=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \ -i "$crate" --target "$target" 2>/dev/null) || { echo "cargo tree failed for $crate / $target — cannot verify the suppression" rc=1 continue } if [ -n "$out" ]; then echo "FAIL: $crate is now compiled for $target — its .cargo/audit.toml suppression is no longer justified (see #310)." rc=1 else echo "ok: $crate absent from $target" fi checks=$((checks + 1)) done done # Canary: a crate known to be present on the Linux target, and only # there. If this stops being found, the loop above is broken and its # silence means nothing. webkit2gtk is Tauri's Linux webview (wry): a # Tauri v2 Linux build cannot drop it, and as a Linux-only dependency # it also proves that --target resolves target-conditional edges, which # the per-target check above relies on. It replaced tar, a Linux-only # dependency of the updater until #455 built it without its zip # feature. canary=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \ -i webkit2gtk --target x86_64-unknown-linux-gnu 2>/dev/null) || true if [ -z "$canary" ]; then echo "FAIL: canary 'webkit2gtk' was not found although it is a known dependency. The guard is not proving anything." rc=1 else echo "ok: canary 'webkit2gtk' found for x86_64-unknown-linux-gnu" fi echo "Suppression guard: $checks checks, exit $rc" exit $rc # Pin guard for windows-sys (#345). Seven crates in the lockfile accept # windows-sys through a wide bound, so cargo may point each of them at # any version the graph already holds. All seven point at 0.61.2, the # state every Windows release has shipped with since v0.14.0. An update # such as `cargo update -p hyper` re-resolves the seven edges down to # 0.48/0.52/0.59, and cargo reports only the packages it bumped, never # the edges: the Windows build then compiles five windows-sys versions # instead of three, and nothing says so. Which updates do it cannot be # told from their names (#348), so the step guards the resolved version. # docs/adr/0023-windows-sys-pinning.md holds the measurements, the exit # condition and what to do when this step fails. # # awk reads Cargo.lock: mawk is part of the ubuntu:22.04 base image, # while python3 is only here as a transitive dependency of the apt # packages installed above (run 517), which nothing guarantees. Its exit # code is tested apart from its output: an empty output already fails the # checks below (version set, missing blocks, canary), but an awk that # fails after printing every fact would pass without that test. As in the # step above, every check echoes its result, including the passing ones, # and a canary must be read and checked, or the loop's silence proves # nothing. # # No cargo call, so neither the warm index nor --locked matters here. The # lockfile it reads is the committed one: cargo check runs with --locked # and has already failed the job on a lockfile cargo would have had to # rewrite (#449). - name: Verify windows-sys pinning run: | set -u # The guarded list. Why 0.61.2, why these versions, and when either # may change: docs/adr/0023-windows-sys-pinning.md. Edit CRATES, PIN # or EXPECTED only on the proof that ADR asks for, never to turn this # step green. CRATES="colored dirs-sys errno rustix rustls-platform-verifier tempfile winapi-util" PIN="0.61.2" # Every windows-sys version in the lockfile, all targets included, # as of 2026-10-10. EXPECTED="0.45.0 0.48.0 0.52.0 0.59.0 0.60.2 0.61.2" CANARY="tempfile" LOCK="src-tauri/Cargo.lock" ADR="docs/adr/0023-windows-sys-pinning.md" rc=0 checks=0 canary_checked=0 # One fact per line: "wsys " for each windows-sys package, # and "block [...]" for each [[package]] # block of a crate in CRATES, with one per windows-sys # edge: the version the edge names, or "-" for a bare edge (cargo # drops the version when the lockfile holds a single windows-sys). # A " (registry+...)" source suffix on an edge is dropped. facts=$(awk -v crates="$CRATES" ' function flush( i, line) { if (inpkg && name == "windows-sys") print "wsys", version if (inpkg && (name in wanted)) { line = "block " name " " version for (i = 1; i <= n; i++) line = line " " target[i] print line } inpkg = 0; name = ""; version = ""; n = 0 } BEGIN { k = split(crates, list, " "); for (i = 1; i <= k; i++) wanted[list[i]] = 1 } /^\[\[package\]\]$/ { flush(); inpkg = 1; next } /^\[/ { flush(); next } inpkg && /^name = "/ { name = $3; gsub(/"/, "", name) } inpkg && /^version = "/ { version = $3; gsub(/"/, "", version) } inpkg && /^[ \t]*"windows-sys[ "]/ { s = $0; sub(/^[ \t]*"/, "", s); sub(/".*$/, "", s) m = split(s, f, " "); target[++n] = (m >= 2 ? f[2] : "-") } END { flush() } ' "$LOCK") && awk_rc=0 || awk_rc=$? if [ "$awk_rc" -ne 0 ]; then echo "FAIL: awk exited $awk_rc reading $LOCK, so nothing was checked." echo "windows-sys pinning: $checks checks, exit 1" exit 1 fi # The exact set of windows-sys versions, duplicates kept: a version # that appears or disappears is a decision, not a side effect. found=$(printf '%s\n' "$facts" | awk '$1 == "wsys" { print $2 }' | sort | paste -sd ' ' -) want=$(printf '%s\n' $EXPECTED | sort | paste -sd ' ' -) checks=$((checks + 1)) if [ "$found" = "$want" ]; then echo "ok: windows-sys versions in the lockfile: $found" else echo "FAIL: windows-sys versions in the lockfile are \"$found\", expected exactly \"$want\". A version was added or removed: follow $ADR before editing EXPECTED." rc=1 fi # Every edge of every block of each guarded crate must name PIN. A # bare edge passes only when the lockfile's one windows-sys is PIN. for crate in $CRATES; do blocks=$(printf '%s\n' "$facts" | awk -v c="$crate" '$1 == "block" && $2 == c') if [ -z "$blocks" ]; then checks=$((checks + 1)) echo "FAIL: no [[package]] block named $crate in $LOCK (renamed, or gone from the graph?). See $ADR." rc=1 continue fi while read -r kind name version targets; do if [ -z "$targets" ]; then checks=$((checks + 1)) echo "FAIL: $crate $version has no windows-sys edge left. See $ADR." rc=1 continue fi for target in $targets; do checks=$((checks + 1)) if [ "$target" = "$PIN" ]; then echo "ok: $crate $version -> windows-sys $PIN" elif [ "$target" != "-" ]; then echo "FAIL: $crate $version -> windows-sys $target, pinned at $PIN. See $ADR." rc=1 elif [ "$found" = "$PIN" ]; then echo "ok: $crate $version -> windows-sys (bare edge: the lockfile's only windows-sys is $PIN)" else echo "FAIL: $crate $version -> windows-sys with no version, while the lockfile holds \"$found\". See $ADR." rc=1 fi if [ "$crate" = "$CANARY" ]; then canary_checked=1 fi done done <<<"$blocks" done # Canary: the loop must have read and checked an edge of a crate # known to carry one. Otherwise its silence proves nothing. checks=$((checks + 1)) if [ "$canary_checked" -eq 1 ]; then echo "ok: canary '$CANARY' found, its windows-sys edge read and checked above" else echo "FAIL: canary '$CANARY' was not read and checked. The guard is not proving anything." rc=1 fi echo "windows-sys pinning: $checks checks, exit $rc" exit $rc # --locked, as cargo check above (#449). - name: cargo test run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets --locked # Prebuilt binary. `cargo install --locked cargo-audit` recompiled the # tool from source on every single run (~4m40s). No continue-on-error: # a tooling failure should fail the job rather than be swallowed. - name: Install cargo-audit uses: https://github.com/taiki-e/install-action@v2 with: tool: cargo-audit # Advisories are informational — they can land on unrelated crates and # would otherwise stall unrelated work — so the step is non-blocking. # It no longer hides real failures behind `|| true` though. Daily # RustSec coverage outside Rust PRs lives in audit.yml. - name: cargo audit continue-on-error: true run: cargo audit --file src-tauri/Cargo.lock