ci: every committed Cargo.lock is current #227
3 changed files with 199 additions and 0 deletions
51
.github/workflows/lockfiles.yml
vendored
Normal file
51
.github/workflows/lockfiles.yml
vendored
Normal 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
|
||||
58
scripts/check-lockfiles-selftest.sh
Executable file
58
scripts/check-lockfiles-selftest.sh
Executable 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
90
scripts/check-lockfiles.sh
Executable 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."
|
||||
Loading…
Reference in a new issue