Simpl-Resultat/.forgejo/workflows/check-rust.yml
le king fu aad85a412d ci: pass --locked to cargo in check-rust and the release builds, pin cargo-xwin (#449)
check-rust.yml ran cargo check and cargo test without --locked: a
Cargo.lock out of step with Cargo.toml was rewritten in the job's
checkout, and the suppression guard, the windows-sys guard and cargo
audit then read the rewritten lockfile instead of the committed one.
Both now pass --locked, and the windows-sys guard comment says the
lockfile it reads is the committed one.

release.yml built the shipped binaries the same way. Both npx tauri
build calls now end with `-- --locked`, which the Tauri CLI hands to its
runner (cargo, or cargo-xwin for Windows, which passes it to cargo
build).

cargo-xwin, the runner of the Windows build, runs with the updater
signing key in its environment and was installed at whatever version
crates.io served on the day of the tag. It is pinned at 0.23.1, the
version the v0.17.2 release installed (run 570), and its version is
printed in the job log.

Refs #449

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:28:34 -04:00

359 lines
18 KiB
YAML

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 <version>" for each windows-sys package,
# and "block <crate> <version> [<target>...]" for each [[package]]
# block of a crate in CRATES, with one <target> 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