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

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:
2026-08-26 20:31:25 +02:00
co-authored by Claude Opus 5
parent d06b24c485
commit 871a0eac28
+97
View File
@@ -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}