ci: every committed Cargo.lock is current #227

Merged
dcharlot merged 1 commit from ci/lockfiles-current into main 2026-10-13 00:55:37 -04:00
3 changed files with 199 additions and 0 deletions

51
.github/workflows/lockfiles.yml vendored Normal file
View file

@ -0,0 +1,51 @@
name: lockfiles
# Every committed Cargo.lock is current: cargo would not change it.
#
# The workspaces in this tree depend on one another by path. A version bump in
# one leaves the lockfiles of its dependents behind, and nothing says so until
# a job that passes `--locked` fails on a change that never touched it. This
# job says so on the change that caused it, in about a minute: it resolves each
# workspace against its lock (scripts/check-lockfiles.sh), downloading no crate
# and building nothing.
#
# No `paths:` filter, on purpose. On this forge a filter matches the files of
# the push, not of the pull request, so a later push that touches no manifest
# would leave the head with no run of this check, and a stale lock would merge
# under a green list. The job is short enough to run on every push.
# One run per branch at a time. A push supersedes the run before it.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
on:
push:
branches: [main]
pull_request:
jobs:
current:
name: every Cargo.lock is current
runs-on: linux
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
# The docker runner ships no Rust, so rustup is bootstrapped.
- name: Install Rust 1.98.0
shell: bash
run: |
export PATH="$HOME/.cargo/bin:$PATH"
command -v rustup >/dev/null 2>&1 || \
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain 1.98.0
rustup toolchain install 1.98.0 --profile minimal
rustup default 1.98.0
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
# The check must be able to fail, or it is decoration.
- name: The check fails closed (self-test)
run: ./scripts/check-lockfiles-selftest.sh
- name: Every Cargo.lock is current
run: ./scripts/check-lockfiles.sh

View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
#
# check-lockfiles-selftest.sh — check-lockfiles.sh can fail.
#
# A check that cannot fail is decoration. This builds a scratch workspace of two
# crates, one depending on the other by path, and asserts that the check
# 1. passes when the lock is current;
# 2. fails when the dependency's version is bumped and the lock is not
# regenerated (the case this check exists for);
# 3. fails when a dependency is added and the lock is not regenerated;
# 4. fails when the lock is missing;
# 5. fails, and says it could not check, when the manifest does not parse:
# an error is never read as "current".
# The scratch crates have no registry dependency, so nothing here needs a
# network.
set -uo pipefail
here=$(cd "$(dirname "$0")" && pwd)
check="$here/check-lockfiles.sh"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mk() {
rm -rf "$tmp/ws"; mkdir -p "$tmp/ws/a/src" "$tmp/ws/b/src" "$tmp/ws/c/src"
printf '[workspace]\nmembers = ["a", "b"]\nresolver = "2"\n' > "$tmp/ws/Cargo.toml"
printf '[package]\nname = "selftest-a"\nversion = "0.1.0"\nedition = "2021"\n\n[dependencies]\nselftest-b = { path = "../b" }\n' > "$tmp/ws/a/Cargo.toml"
printf '[package]\nname = "selftest-b"\nversion = "0.1.0"\nedition = "2021"\n' > "$tmp/ws/b/Cargo.toml"
printf '[package]\nname = "selftest-c"\nversion = "0.1.0"\nedition = "2021"\n' > "$tmp/ws/c/Cargo.toml"
: > "$tmp/ws/a/src/lib.rs"; : > "$tmp/ws/b/src/lib.rs"; : > "$tmp/ws/c/src/lib.rs"
(cd "$tmp/ws" && cargo generate-lockfile --offline) >/dev/null 2>&1 || { echo "selftest: could not make the scratch lockfile" >&2; exit 2; }
}
fails=0
expect() { # expect <pass|fail> <what> [text the output must hold]
local want=$1 what=$2 needle=${3:-} out rc
out=$("$check" "$tmp/ws" 2>&1); rc=$?
if [ "$want" = pass ] && [ $rc -ne 0 ]; then echo "FAIL $what: the check failed"; printf '%s\n' "$out" | sed 's/^/ /'; fails=$((fails+1)); return; fi
if [ "$want" = fail ] && [ $rc -eq 0 ]; then echo "FAIL $what: the check passed"; fails=$((fails+1)); return; fi
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -q -- "$needle"; then echo "FAIL $what: the output does not say '$needle'"; printf '%s\n' "$out" | sed 's/^/ /'; fails=$((fails+1)); return; fi
echo "ok $what"
}
mk; expect pass "a current lock passes"
mk; sed -i.bak 's/^version = "0.1.0"/version = "0.2.0"/' "$tmp/ws/b/Cargo.toml"
expect fail "a bumped path dependency with the old lock fails" "STALE"
mk; printf 'selftest-c = { path = "../c" }\n' >> "$tmp/ws/a/Cargo.toml"
expect fail "an added dependency with the old lock fails" "STALE"
mk; rm "$tmp/ws/Cargo.lock"
expect fail "a missing lock fails" "STALE"
mk; printf 'this is not toml\n' >> "$tmp/ws/a/Cargo.toml"
expect fail "a manifest that does not parse fails as unchecked, not as current" "ERROR"
if [ $fails -ne 0 ]; then echo "check-lockfiles-selftest: $fails failure(s)"; exit 1; fi
echo "check-lockfiles-selftest: the check passes a current lock and fails each broken one."

90
scripts/check-lockfiles.sh Executable file
View file

@ -0,0 +1,90 @@
#!/usr/bin/env bash
#
# check-lockfiles.sh — every committed Cargo.lock is current.
#
# A lockfile is stale when cargo would change it: a crate's version was bumped,
# or a dependency was added, and the lock was not regenerated. This tree holds
# many workspaces that depend on one another by path, so a version bump in one
# leaves the locks of the others behind. Nothing notices until a job that
# passes `--locked` fails, for a reason that has nothing to do with what the
# job tests.
#
# For each directory that holds a tracked Cargo.lock this runs
#
# cargo update --workspace --locked
#
# which resolves the workspace against its lock and fails if the lock would
# change. It reads the registry index and any git dependency; it downloads no
# crate and builds nothing.
#
# check-lockfiles.sh every tracked Cargo.lock, less the list below
# check-lockfiles.sh DIR ... exactly these directories
#
# Exit 0: every lock is current. Exit 1: at least one is stale, or could not be
# checked. A lock that could not be checked (no network, say) fails the run too,
# under its own heading, so nobody goes looking for a stale lock that is not
# one.
set -uo pipefail
# Directories that hold a Cargo.lock cargo does not read. Each needs a reason,
# and each must still exist: a stale entry fails the run.
SKIP=(
# A vendored crate, patched into jouledesk-rs. Cargo resolves it as part of
# that workspace, against that workspace's lock; its own lock is the
# upstream's, kept with the vendored source.
"jouledesk/jouledesk-rs/vendor/libwebrtc"
)
skipped() { local d; for d in "${SKIP[@]}"; do [ "$d" = "$1" ] && return 0; done; return 1; }
if [ $# -gt 0 ]; then
dirs=("$@")
else
cd "$(git rev-parse --show-toplevel)" || exit 1
for d in "${SKIP[@]}"; do
if [ ! -f "$d/Cargo.lock" ]; then
echo "check-lockfiles: the skip list names $d, which holds no Cargo.lock. Remove the entry." >&2
exit 1
fi
done
dirs=()
while IFS= read -r l; do
d=$(dirname "$l")
skipped "$d" || dirs+=("$d")
done < <(git ls-files | grep -E '(^|/)Cargo\.lock$')
fi
if [ ${#dirs[@]} -eq 0 ]; then
echo "check-lockfiles: found no Cargo.lock to check." >&2
exit 1
fi
stale=(); unchecked=()
for d in "${dirs[@]}"; do
out=$( (cd "$d" && cargo update --workspace --locked) 2>&1 ); rc=$?
if [ $rc -eq 0 ]; then
continue
elif printf '%s' "$out" | grep -qE -- '--locked was passed|lock file .* (is missing|needs to be updated)'; then
stale+=("$d")
echo "STALE $d"
printf '%s\n' "$out" | grep -E '^error|^Caused by|^ ' | head -6 | sed 's/^/ /'
else
unchecked+=("$d")
echo "ERROR $d (cargo could not resolve it; this is not a verdict on the lock)"
printf '%s\n' "$out" | tail -8 | sed 's/^/ /'
fi
done
if [ ${#stale[@]} -gt 0 ]; then
echo
echo "Stale lockfiles. Regenerate each, and commit the result:"
for d in "${stale[@]}"; do echo " (cd $d && cargo update --workspace)"; done
fi
if [ ${#unchecked[@]} -gt 0 ]; then
echo
echo "Could not be checked: ${unchecked[*]}"
fi
if [ ${#stale[@]} -gt 0 ] || [ ${#unchecked[@]} -gt 0 ]; then
exit 1
fi
echo "check-lockfiles: ${#dirs[@]} lockfiles, all current."