diff --git a/docker/ci-preflight.sh b/docker/ci-preflight.sh new file mode 100755 index 0000000..3b9257c --- /dev/null +++ b/docker/ci-preflight.sh @@ -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}