Check the image has the commands before CI finds out it does not
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 19m21s
Build and test / Layer separation (push) Successful in 29s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m3s
🐳 Android image / Build and push (push) Successful in 1s
Build and test / android-image (push) Successful in 1s
Build and test / Desktop (Linux) (push) Successful in 19m21s
Build and test / Layer separation (push) Successful in 29s
Traceability / Requirement traces (push) Successful in 23s
Build and test / Android (aarch64) (push) Failing after 33m3s
Two failures in a row were the same shape: a command the workflow calls was not in the image. git-lfs, then file(1). Each cost a full run to learn, and the android job is expensive to be wrong in -- the step that fails is at the end, so every attempt paid twenty-eight minutes of cross-compile first to reach the line that could not work. Both were visible in ten seconds from here. `docker run <image> command -v file` is the whole diagnosis; it just never occurred to anybody to ask before pushing. So the question gets asked automatically. This reads the `run:` blocks out of the workflows, pulls the commands worth doubting -- the ones a minimal Debian plausibly lacks, not `cd` -- and checks each against the image its job declares. It does not run the workflow and is not a replacement for one. It answers exactly the question that was expensive to answer. `git lfs` is handled specially and the comment says why: it is a subcommand, so the first word of the line is `git`, which is always there. Taking first words alone would have missed the original bug -- and did, in the first version of this script. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+97
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Check that every command the workflows invoke exists in the image that will
|
||||
# run it.
|
||||
#
|
||||
# This exists because two CI failures in a row were the same shape: the image
|
||||
# was missing a command, and finding out took a 28-minute cross-compile each
|
||||
# time because the step that would fail ran last. git-lfs and file(1) were both
|
||||
# absent for as long as the build panicked before ever reaching them.
|
||||
#
|
||||
# Nothing here runs the workflow. It answers one question -- is the toolchain
|
||||
# the steps assume actually installed -- in about ten seconds.
|
||||
#
|
||||
# ./docker/ci-preflight.sh # every job
|
||||
# ./docker/ci-preflight.sh android # one job
|
||||
set -uo pipefail
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO="$(cd "${HERE}/.." && pwd)"
|
||||
WANT="${1:-}"
|
||||
FAILED=0
|
||||
|
||||
# Commands a `run:` block invokes that are worth asserting: the ones a minimal
|
||||
# image plausibly lacks. Shell builtins and coreutils are not interesting; a
|
||||
# missing `cd` is not the failure mode anybody has.
|
||||
readonly INTERESTING='^(git|git-lfs|file|zip|unzip|keytool|curl|cargo|rustup|apt-get|find|sed|awk|base64|shred|adb|python3|node|jq)$'
|
||||
|
||||
jobs_and_images() {
|
||||
python3 - "$REPO" <<'PY'
|
||||
import sys, pathlib, yaml
|
||||
root = pathlib.Path(sys.argv[1])
|
||||
for wf in sorted((root / ".gitea/workflows").glob("*.yml")):
|
||||
doc = yaml.safe_load(wf.read_text()) or {}
|
||||
for job, spec in (doc.get("jobs") or {}).items():
|
||||
image = ((spec.get("container") or {}).get("image"))
|
||||
if not image:
|
||||
continue
|
||||
cmds = set()
|
||||
for step in (spec.get("steps") or []):
|
||||
run = step.get("run")
|
||||
if not run:
|
||||
continue
|
||||
for line in run.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
# first word of the line, and of anything after a pipe
|
||||
for part in line.split("|"):
|
||||
word = part.strip().split(" ")[0].strip()
|
||||
if word.isidentifier() or "-" in word:
|
||||
cmds.add(word)
|
||||
# `git lfs` is a subcommand, so the first word is `git` and the
|
||||
# thing that can actually be absent never appears. This is the
|
||||
# exact bug that shipped an image with no git-lfs in it.
|
||||
if "git lfs" in line:
|
||||
cmds.add("git-lfs")
|
||||
print(f"{job}\t{image}\t{' '.join(sorted(cmds))}")
|
||||
PY
|
||||
}
|
||||
|
||||
while IFS=$'\t' read -r job image cmds; do
|
||||
[[ -n "${WANT}" && "${job}" != "${WANT}" ]] && continue
|
||||
checked=()
|
||||
for c in ${cmds}; do
|
||||
[[ "${c}" =~ ${INTERESTING} ]] && checked+=("${c}")
|
||||
done
|
||||
# `git lfs` is a git subcommand, not a binary on PATH — ask git about it.
|
||||
printf '\n== %s (%s)\n' "${job}" "${image}"
|
||||
if [[ ${#checked[@]} -eq 0 ]]; then
|
||||
echo " nothing to check"
|
||||
continue
|
||||
fi
|
||||
docker image inspect "${image}" >/dev/null 2>&1 || {
|
||||
echo " image not present locally — docker pull ${image}"
|
||||
FAILED=1
|
||||
continue
|
||||
}
|
||||
out=$(docker run --rm --entrypoint bash "${image}" -c '
|
||||
for c in '"${checked[*]}"'; do
|
||||
if [ "$c" = git-lfs ]; then
|
||||
git lfs version >/dev/null 2>&1 && echo "ok git lfs" || echo "MISSING git lfs"
|
||||
elif command -v "$c" >/dev/null 2>&1; then
|
||||
echo "ok $c"
|
||||
else
|
||||
echo "MISSING $c"
|
||||
fi
|
||||
done' 2>&1)
|
||||
echo "${out}" | sed 's/^/ /'
|
||||
grep -q MISSING <<<"${out}" && FAILED=1
|
||||
done < <(jobs_and_images)
|
||||
|
||||
echo
|
||||
if [[ ${FAILED} -eq 0 ]]; then
|
||||
echo "preflight: every command the workflows invoke is present"
|
||||
else
|
||||
echo "preflight: something the workflows invoke is not in the image — fix the Dockerfile before pushing"
|
||||
fi
|
||||
exit ${FAILED}
|
||||
Reference in New Issue
Block a user