diff --git a/.github/workflows/lockfiles.yml b/.github/workflows/lockfiles.yml new file mode 100644 index 000000000..8f363f3a2 --- /dev/null +++ b/.github/workflows/lockfiles.yml @@ -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 diff --git a/scripts/check-lockfiles-selftest.sh b/scripts/check-lockfiles-selftest.sh new file mode 100755 index 000000000..a113adb23 --- /dev/null +++ b/scripts/check-lockfiles-selftest.sh @@ -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 [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." diff --git a/scripts/check-lockfiles.sh b/scripts/check-lockfiles.sh new file mode 100755 index 000000000..0117406c7 --- /dev/null +++ b/scripts/check-lockfiles.sh @@ -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."