#!/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}