Files
Pierre Warnier 02fefda42c ci: compare against GNU for real, pin the actions by digest, one make check (#275)
* ci: pin the actions by digest

A tag is a moving pointer. Whoever controls the action's repository can
repoint v7 at any commit, and every workflow in the project runs it on
the next push with write access to the checkout. A digest cannot be
repointed.

The tag stays in a trailing comment so the version is still readable, and
Renovate's pinGitHubActionDigests preset is enabled so the digests are
kept current the same way every other dependency is -- as a pull request
that CI has run, rather than silently.

* tests: make the GNU comparison real, and run it in CI

The suite compared full output for exactly two commands and, for the
other twelve, only that both tools exit 0 on --help. It ran nowhere.

It now compares what is actually a contract. 'passwd -S' and 'chage -l'
are parsed by monitoring and provisioning scripts, so their output is
compared byte for byte across eleven aging configurations -- including
the cases this project got wrong until recently: a last change of 0, the
threshold above which a maximum age reads never, and cleared fields. Exit
codes are compared on ten error paths. --help output is deliberately not
compared: ours comes from clap and is meant to differ.

One difference is deliberate and now asserted as such, so it cannot be
mistaken for a regression the day it changes by accident. GNU parses
dates leniently and rolls them over -- 'chage -d 2025-02-29' stores 1
March and '-d 2025-13-01' stores 1 January 2026. An administrator who
typed one of those made a mistake, and silently storing a different date
than the one they wrote is a poor answer for a field that decides when an
account stops working. We refuse it; chage(1) says so.

38 comparisons, all as expected, and a CI job runs them.

'make check' is now the single definition of what CI gates on -- fmt,
clippy with and without pam, and the tests in both configurations. The
pre-push hook and CONTRIBUTING refer to it instead of each carrying their
own copy of the command list, and the two hook systems are documented as
the non-overlapping things they are.
2026-09-04 16:58:54 +02:00

82 lines
2.8 KiB
Bash
Executable File

#!/usr/bin/env bash
# shadow-rs pre-push hook.
# Install: ./hooks/install.sh
#
# Runs the workspace test suite once, on Debian, before a push that carries new
# commits.
#
# It deliberately does *not* reproduce CI. CI runs fmt, clippy and the tests
# with and without `pam`, the MSRV build, cargo-deny, the static musl archive,
# the Debian/Alpine/Fedora matrix as root and the end-to-end deployment image,
# in parallel and with retries. A hook that tried to match that took minutes on
# every push, so every push used --no-verify -- and a hook that is always
# skipped protects nothing while still costing time whenever someone forgets to
# skip it.
#
# What is left is the one check worth having before the code leaves the
# machine: the tests actually run. `make test` is the single definition of
# what that means -- with and without `pam`, since the feature changes which
# code paths exist.
set -euo pipefail
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
ZERO='0000000000000000000000000000000000000000'
# Does this push carry any commit that is not already on the remote?
#
# git feeds the hook one line per ref: <local ref> <local sha> <remote ref>
# <remote sha>. Pushing a tag, deleting a branch, or re-pushing a branch the
# remote already has moves no commits, and running a test suite for it is pure
# waste -- pushing the 0.2.2 tag moved one object and blocked for two minutes.
carries_commits() {
local local_ref local_sha remote_ref remote_sha
local found=1
while read -r local_ref local_sha remote_ref remote_sha; do
[ -z "${local_ref:-}" ] && continue
# A tag names commits that are already being pushed on their branch.
case "$local_ref" in
refs/tags/*) continue ;;
esac
# Deleting a ref pushes nothing.
[ "$local_sha" = "$ZERO" ] && continue
local range=()
if [ "$remote_sha" = "$ZERO" ]; then
# A new branch: everything on it that no other remote branch has.
range=("$local_sha" --not --remotes=origin)
else
range=("$remote_sha..$local_sha")
fi
if [ -n "$(git rev-list -n 1 "${range[@]}" 2>/dev/null)" ]; then
found=0
fi
done
return "$found"
}
# No stdin means the hook was invoked by hand; take the cautious path and run.
if [ -t 0 ]; then
:
elif ! carries_commits; then
echo "pre-push: no new commits in this push, skipping the test suite"
exit 0
fi
echo -e "${YELLOW}pre-push${NC}: make test (debian)"
if ! docker compose run --rm -T debian make test 2>&1; then
echo -e "${RED}FAILED${NC}: make test"
echo "The full matrix -- fmt, clippy, ±pam, MSRV, musl, three distros,"
echo "end-to-end -- runs in CI on the pull request."
exit 1
fi
echo -e "${GREEN}pre-push: tests passed${NC}"