All checks were successful
PR Check — Rust / rust (pull_request) Successful in 5m12s
Seven crates in Cargo.lock accept windows-sys through a wide bound (colored, dirs-sys, errno, rustix, rustls-platform-verifier, tempfile, winapi-util), so cargo may point them at any windows-sys version already in the graph. All seven point at 0.61.2, the state every Windows release has shipped with since v0.14.0. Some updates re-resolve them (cargo update -p hyper, -p h2, -p rustls-webpki -p tar did) down to 0.48/0.52/0.59 while cargo only reports the packages it bumped: the Windows build then compiles five windows-sys versions instead of three, and nothing says so. A new blocking step, "Verify windows-sys pinning", reads the lockfile with awk right after the suppression guard and fails if any edge of the seven crates leaves 0.61.2 (a bare edge only passes when the lockfile holds a single windows-sys, at 0.61.2), if a guarded crate has no block, or if the set of windows-sys versions changes. awk's exit code is tested apart from its output, every check is logged with a summary line, and a canary (tempfile) must be read and checked by the loop. The step text was extracted from the YAML and run as is, with the runner's bash options, on copies of the lockfile: main's and the five release tags' locks pass (9 checks, exit 0); the hyper-bumped lock, an added windows-sys 0.53.0, a renamed guarded crate, a missing lockfile and a guarded list without the canary all exit 1. ADR 0023 records why 0.61.2, the measured cost (3 versions compiled for Windows instead of 5), the triggers, the exit condition (windows-sys 0.62, or convergence on a single version) and the repair when the step fails. CLAUDE.md and docs/architecture.md cite the guard next to the suppression check. Cargo.lock and .cargo/audit.toml are untouched. Resolves #345 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
340 lines
17 KiB
YAML
340 lines
17 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"
|
|
|
|
- name: cargo check
|
|
run: cargo check --manifest-path src-tauri/Cargo.toml --all-targets
|
|
|
|
# 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. If this stops being found, the
|
|
# loop above is broken and its silence means nothing.
|
|
canary=$(cargo tree --manifest-path src-tauri/Cargo.toml --locked \
|
|
-i tar --target x86_64-unknown-linux-gnu 2>/dev/null) || true
|
|
if [ -z "$canary" ]; then
|
|
echo "FAIL: canary 'tar' was not found although it is a known dependency. The guard is not proving anything."
|
|
rc=1
|
|
else
|
|
echo "ok: canary 'tar' 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, since the ubuntu:22.04 container has no Python.
|
|
# Its exit code is tested apart from its output, so a read failure fails
|
|
# the step instead of passing for a clean, empty result. 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
|
|
# step reads the lockfile as cargo check left it, which is the committed
|
|
# one unless cargo had to update it.
|
|
- 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
|
|
|
|
- name: cargo test
|
|
run: cargo test --manifest-path src-tauri/Cargo.toml --all-targets
|
|
|
|
# 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
|