This repo had eight scripts, six selftest suites and nothing that ran any of
them. It is a composite action consumed at `@v1` — a moving tag — by three
repos' CI, so a bad edit here reaches zemyna, emowheel and lublub at once and
is discovered by whichever of them builds next.
One job: `shellcheck -x --source-path=scripts scripts/*.sh`, then
`bash scripts/selftest.sh`. `-x` follows the `. cache-lib.sh` every script
sources, which is where most of the logic being checked lives; without it
shellcheck reports SC1091 and analyses each file with a hole in it.
The full suite, not `--fast`: `hardlink-clone-selftest.sh` and
`restore-mtimes-selftest.sh` are the two that drive a real Cargo rather than a
fixture, and selftest.sh's own header says `--fast` is for iterating, not for
signing off a change. So the job installs a stable toolchain. The scratch
workspaces they build use path dependencies only, so nothing reaches
crates.io, and the job references no credentials at all.
Modelled on daniel/gitdan's workflow, including the two clauses of the
draft-skip guard (`github.event_name != 'pull_request' ||
!github.event.pull_request.draft` — the first is what stops the second from
also skipping pushes to `main`, where there is no `pull_request` context) and
the per-commit concurrency group for `push`.
No `container.volumes:` entry, deliberately: the suites want a cold target dir
every run, since "did this rebuild?" is exactly what restore-mtimes-selftest
asks. This job takes no share of the shared CI cache disk budget.
Turning the gate on surfaced three pre-existing findings, all fixed here
rather than suppressed or waived:
- `restore-mtimes-selftest.sh` SC2038: `find | xargs touch` -> `-print0 | xargs -0`.
- `hardlink-clone-selftest.sh` SC2295: `${f#$base_fix/}` -> `${f#"$base_fix"/}`.
- `cache-lib.sh` SC2016: a per-line disable with the rationale. The single
quotes are load-bearing — the program is for the inner shell, where `$f` is
its loop variable, `$rc` its accumulator and `$$` its pid — so this is the
documented-false-positive case, not a stopgap.
179 lines
7.1 KiB
Bash
Executable File
179 lines
7.1 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Regression test for the single assumption this whole caching scheme rests
|
|
# on: that a build running inside a hardlink clone cannot mutate the directory
|
|
# it was cloned from.
|
|
#
|
|
# That assumption is FALSE for a plain `cp -al`. Measured, and asserted below
|
|
# as an explicit control: build in a raw `cp -al` clone and the source's
|
|
# `.fingerprint/<unit>/dep-*` (under CARGO_UNSTABLE_CHECKSUM_FRESHNESS),
|
|
# `build/<pkg>/output`, `build/<pkg>/out/**` and `deps/*.d` all change,
|
|
# because Cargo and build scripts write those with a plain truncating write
|
|
# rather than the write-then-rename Cargo uses for real artifacts.
|
|
#
|
|
# The consequence is not a slow build, it is a wrong one: a PR clone rewrites
|
|
# the base's dep-info to describe the PR's sources while the base's cache
|
|
# still holds the artifact built from the base's sources; once the PR merges,
|
|
# the base's next run finds the checksums match its (now merged) sources,
|
|
# reports `Fresh`, and links a binary built from the pre-merge code.
|
|
#
|
|
# cache-lib.sh's unshare_mutable_paths() is what closes that, and this test is
|
|
# what proves it stays closed. The control matters as much as the fix: a
|
|
# scenario that passes for both would prove nothing.
|
|
#
|
|
# Needs a working cargo on PATH. Everything happens under a mktemp -d scratch
|
|
# tree. Run by hand: bash scripts/hardlink-clone-selftest.sh
|
|
set -euo pipefail
|
|
script_dir=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
|
. "$script_dir/cache-lib.sh"
|
|
|
|
command -v cargo >/dev/null || { echo "SKIP: no cargo on PATH"; exit 0; }
|
|
|
|
scratch=$(mktemp -d)
|
|
trap 'rm -rf "$scratch"' EXIT
|
|
pass_count=0
|
|
|
|
fail() { echo "ASSERTION FAILED: $*" >&2; exit 1; }
|
|
ok() { pass_count=$((pass_count + 1)); echo "PASS: $*"; }
|
|
|
|
# Content hash of every file in a tree, keyed by relative path.
|
|
snapshot_tree() { (cd "$1" && find . -type f -print0 | sort -z | xargs -0 -r sha1sum) 2>/dev/null; }
|
|
|
|
# `diff` exits 1 when the trees differ, which is the expected case here and
|
|
# must not trip `pipefail` — the difference IS the result.
|
|
mutated_paths() {
|
|
{ diff <(printf '%s' "$1") <(printf '%s' "$2") || true; } 2>/dev/null \
|
|
| awk '/^[<>]/ { print $3 }' | sort -u
|
|
}
|
|
|
|
# A crate with a build script, because build-script OUT_DIR writes are one of
|
|
# the two mutation families and are invisible without one.
|
|
mkcrate() {
|
|
local dir="$1"
|
|
mkdir -p "$dir/src"
|
|
cat > "$dir/Cargo.toml" <<'TOML'
|
|
[package]
|
|
name = "probe"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
[workspace]
|
|
TOML
|
|
cat > "$dir/build.rs" <<'RS'
|
|
use std::{env, fs, path::PathBuf};
|
|
fn main() {
|
|
println!("cargo::rerun-if-changed=src/lib.rs");
|
|
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
|
|
let src = fs::read_to_string("src/lib.rs").unwrap();
|
|
fs::write(out.join("gen.txt"), format!("generated from {} bytes", src.len())).unwrap();
|
|
}
|
|
RS
|
|
}
|
|
|
|
crate_dir="$scratch/probe"
|
|
mkcrate "$crate_dir"
|
|
cd "$crate_dir"
|
|
|
|
export CARGO_INCREMENTAL=0
|
|
# Checksum freshness is where the worst failure lives (the dep-* file carries
|
|
# per-source checksums and is rewritten in place). Only available on nightly;
|
|
# without it the test still covers the build/ and *.d families.
|
|
CHECKSUM_MODE="off"
|
|
if cargo +nightly -V >/dev/null 2>&1; then
|
|
export CARGO_UNSTABLE_CHECKSUM_FRESHNESS=true
|
|
CARGO_BIN=(cargo +nightly)
|
|
CHECKSUM_MODE="on"
|
|
else
|
|
CARGO_BIN=(cargo)
|
|
fi
|
|
echo "=== checksum-freshness mode: ${CHECKSUM_MODE} ==="
|
|
|
|
CONTENT_A='pub fn f() -> u32 { 1 }'
|
|
CONTENT_B='pub fn f() -> u32 { 22222 } pub fn g() -> u32 { 7 }'
|
|
|
|
build_base() {
|
|
local dir="$1"
|
|
printf '%s\n' "$CONTENT_A" > src/lib.rs
|
|
CARGO_TARGET_DIR="$dir" "${CARGO_BIN[@]}" build -q
|
|
}
|
|
|
|
echo
|
|
echo "=== control: a raw \`cp -al\` clone DOES mutate its source ==="
|
|
base_ctl="$scratch/base-ctl"; clone_ctl="$scratch/clone-ctl"
|
|
build_base "$base_ctl"
|
|
before=$(snapshot_tree "$base_ctl")
|
|
cp -al "$base_ctl" "$clone_ctl"
|
|
strip_cargo_locks "$clone_ctl" # locks alone are not the hazard under test
|
|
printf '%s\n' "$CONTENT_B" > src/lib.rs
|
|
CARGO_TARGET_DIR="$clone_ctl" "${CARGO_BIN[@]}" build -q
|
|
after=$(snapshot_tree "$base_ctl")
|
|
ctl_mutated=$(mutated_paths "$before" "$after")
|
|
if [ -z "$ctl_mutated" ]; then
|
|
fail "control produced no mutation — the test can no longer distinguish fixed from broken"
|
|
fi
|
|
ok "raw cp -al clone mutates the source ($(printf '%s\n' "$ctl_mutated" | wc -l) paths)"
|
|
printf '%s\n' "$ctl_mutated" | sed 's/^/ /'
|
|
|
|
if [ "$CHECKSUM_MODE" = "on" ]; then
|
|
if printf '%s' "$ctl_mutated" | grep -q '\.fingerprint/.*/dep-'; then
|
|
ok "control confirms the checksum-freshness dep-info file is among the mutated set"
|
|
else
|
|
fail "expected .fingerprint/*/dep-* in the control's mutated set under checksum freshness"
|
|
fi
|
|
fi
|
|
|
|
echo
|
|
echo "=== fix: hardlink_clone_into() leaves the source byte-identical ==="
|
|
base_fix="$scratch/base-fix"; clone_fix="$scratch/clone-fix"
|
|
build_base "$base_fix"
|
|
before=$(snapshot_tree "$base_fix")
|
|
hardlink_clone_into "$base_fix" "$clone_fix" "selftest" || fail "hardlink_clone_into reported the destination already existed"
|
|
|
|
# The clone's contract, asserted before anything builds in it: artifacts
|
|
# share inodes (that is what makes the clone near-free), and every file Cargo
|
|
# rewrites in place does not (that is what makes it sound). Checking after a
|
|
# rebuild would prove nothing — the rebuild replaces those files anyway.
|
|
shared=0; unshared=0
|
|
while IFS= read -r f; do
|
|
rel="${f#"$base_fix"/}"
|
|
[ -e "$clone_fix/$rel" ] || continue
|
|
if [ "$(stat -c '%i' "$f")" = "$(stat -c '%i' "$clone_fix/$rel")" ]; then
|
|
case "$rel" in
|
|
*/.fingerprint/*|*/build/*|*.d|.rustc_info.json)
|
|
fail "mutable path still shares an inode with the source: $rel" ;;
|
|
esac
|
|
shared=$((shared + 1))
|
|
else
|
|
unshared=$((unshared + 1))
|
|
fi
|
|
done < <(find "$base_fix" -type f)
|
|
[ "$shared" -gt 0 ] || fail "nothing is shared — the clone degenerated into a full copy"
|
|
[ "$unshared" -gt 0 ] || fail "nothing was unshared — unshare_mutable_paths did not run"
|
|
ok "fresh clone shares ${shared} artifact files and privately owns ${unshared} mutable ones"
|
|
|
|
printf '%s\n' "$CONTENT_B" > src/lib.rs
|
|
CARGO_TARGET_DIR="$clone_fix" "${CARGO_BIN[@]}" build -q
|
|
after=$(snapshot_tree "$base_fix")
|
|
fix_mutated=$(mutated_paths "$before" "$after")
|
|
if [ -n "$fix_mutated" ]; then
|
|
echo " still mutated:" >&2
|
|
printf '%s\n' "$fix_mutated" | sed 's/^/ /' >&2
|
|
fail "a build in the clone mutated the source through a shared inode"
|
|
fi
|
|
ok "no file in the source changed after a full rebuild in the clone"
|
|
|
|
echo
|
|
echo "=== the whole point: the source's next build is still correct ==="
|
|
# The source's cache holds artifacts built from CONTENT_A. Advance the source
|
|
# to CONTENT_B (as a merge would) and rebuild in it. If the clone had
|
|
# corrupted its dep-info, Cargo would report Fresh and keep the stale rlib.
|
|
printf '%s\n' "$CONTENT_B" > src/lib.rs
|
|
touch -d '@1000000000' src/lib.rs
|
|
log="$scratch/rebuild.log"
|
|
CARGO_TARGET_DIR="$base_fix" "${CARGO_BIN[@]}" build -v > "$log" 2>&1 || { cat "$log"; fail "rebuild in the source failed"; }
|
|
if grep -qE '^\s+Fresh probe' "$log"; then
|
|
fail "source declared its own crate Fresh against sources it has never built — stale-artifact reuse"
|
|
fi
|
|
ok "source correctly rebuilt its crate after advancing to the clone's content"
|
|
|
|
echo
|
|
echo "hardlink-clone-selftest: ${pass_count} assertions passed"
|