#!/bin/sh # script/assert-step-ran: fail unless a docker build actually executed a # named step, proved by output the step's own tool wrote. # # Usage: script/assert-step-ran LOG STAGE STEP_REGEX EVIDENCE_REGEX WHAT # # LOG must be `docker build --progress=plain` output. The default tty # renderer rewrites lines in place and keeps only the tail of a step's # output, so the flag is load-bearing wherever this is used. # # It passes only if some step in LOG satisfies all three of: # # 1. its header is "#ID [STAGE n/m] CMD" with CMD matching STEP_REGEX, # 2. one of the lines that step wrote matches EVIDENCE_REGEX, # 3. BuildKit reported "#ID DONE". # # This observes the build that happened; it parses no Dockerfile. A # stage renamed, moved, made unreachable, deleted, split by a whitespace # byte BuildKit treats as a separator, or an instruction rewritten — # none of it can make this pass a build in which the step did not run, # because a step that did not run wrote no output, and a step served # from cache writes none either ("#ID CACHED", no output lines). # # The stage name in a header is BuildKit's label for that vertex, not a # reading of the file being built. A vertex shared with an earlier build # of a DIFFERENT Dockerfile is replayed under the label it was first # recorded with, so a `Dockerfile.lint` run can print `[lint 1/8]` for # the main `Dockerfile`'s lint stage. A borrowed label cannot produce a # pass — a replayed vertex prints CACHED and writes no evidence, and a # vertex that really executes really ran the command in the header — but # it does mean a header alone proves nothing about which stages the file # declares. Hence one failure message naming both causes rather than a # split that would claim to know which. # # What this guard cannot see — not an exhaustive list of ways a green can # be untrue, only the ones known: # # - Evidence forged inside the matched step, e.g. # `RUN golangci-lint run ... || echo "0 issues."`. Condition 1 ties # the evidence to a step whose own command is in the log, so the # forgery has to be written into that command in the Dockerfile, in # plain sight. # This guards against a step falling silently out of the build. It does # not certify that what ran examined everything it should have — a # `.dockerignore` entry excluding a package makes the linter genuinely # run and genuinely print `0 issues.` while a real violation sits # unexamined in the repo. That is a different question, asked separately # by script/assert-context-complete, which every caller of this script # also runs. # # What it depends on — BuildKit's plain progress format and the tool's # own success wording — fails the caller loudly if it drifts, because # drift removes a match rather than creating one. set -eu die() { echo "script/assert-step-ran: $*" >&2 exit 1 } [ $# -eq 5 ] || die "usage: $0 LOG STAGE STEP_REGEX EVIDENCE_REGEX WHAT" [ -r "$1" ] || die "cannot read $1" # The regexes travel in the environment, not in -v: awk expands escape # sequences in a -v assignment, which would eat a backslash before the # regex ever sees it. ASR_STAGE="$2" ASR_STEP="$3" ASR_EVIDENCE="$4" ASR_WHAT="$5" awk ' function fail(msg) { printf "script/assert-step-ran: %s\n", msg | "cat 1>&2" close("cat 1>&2") exit 1 } BEGIN { stage = ENVIRON["ASR_STAGE"] step = ENVIRON["ASR_STEP"] evidence = ENVIRON["ASR_EVIDENCE"] what = ENVIRON["ASR_WHAT"] } # Every progress line is "#ID " and then a step header, a status, or one # line the step itself wrote. /^#[0-9]+ / { id = substr($1, 2) rest = substr($0, length($1) + 2) if (rest == "CACHED") { cached[id] = 1; next } if (rest ~ /^DONE /) { done[id] = 1; next } # Header: "[STAGE n/m] CMD", or "[PLATFORM STAGE n/m] CMD" when the # build names a platform. Bracketed spans with no n/m are BuildKit # internals ("[internal] load build context"), not steps. if (substr(rest, 1, 1) == "[") { p = index(rest, "] ") if (p == 0) next n = split(substr(rest, 2, p - 2), part, " ") if (n < 2 || part[n] !~ /^[0-9]+\/[0-9]+$/) next if (part[n - 1] != stage) next if (substr(rest, p + 2) ~ step) matched[id] = 1 next } # Output: "#ID 41.80 ". if (rest ~ /^[0-9]+[.][0-9]+ /) { sub(/^[0-9]+[.][0-9]+ /, "", rest) if (rest ~ evidence) emitted[id] = 1 } } END { for (id in matched) { nmatched++ if (done[id] && emitted[id]) exit 0 if (cached[id]) ncached++ } if (nmatched == 0) fail(sprintf("the build ran no step matching /%s/ in stage \"%s\", so %s did not run. Either the stage is not in the build graph — renamed, deleted, or nothing the final stage builds depends on it any more — or it was built without that command among its steps", step, stage, what)) if (ncached == nmatched) fail(sprintf("every step matching /%s/ in stage \"%s\" was served from cache, so %s did not run on this tree; the cache bust for that stage is not taking effect", step, stage, what)) fail(sprintf("a step matching /%s/ ran in stage \"%s\" but never wrote a line matching /%s/, so there is no evidence %s did the work; the command or the tool that produces that line has changed", step, stage, evidence, what)) } ' "$1"