#!/bin/sh # script/cibuild: run the CI build. The Dockerfile runs the checks # (gofmt, config verify, lint, test), so a successful build implies a # green repo. The Gitea workflow runs this on push. # # Only if the checks actually ran, though, and a green `docker build` is # no evidence that they did: # # 1. On an unchanged tree every layer comes from cache and the build # exits 0 in under a second having executed nothing. Hence the two # --no-cache-filter flags. # # 2. BuildKit silently ignores a --no-cache-filter naming a stage that # does not exist, so a rename restores that green no-op. # # 3. BuildKit builds only the final stage's dependency graph. A stage # nothing references is never built, and with no --target the final # stage is whichever is last in the file — so appending a stage, or # moving a reference into a stage that is itself unreachable, drops # the checks out of the build while every reference to them is still # there to read. # # Rather than predict any of that from the Dockerfile's text, this asks # the finished build what it did: script/assert-step-ran requires that # the log contain a lint step and a test step that ran, and that each # wrote the line its own tool writes on success. Every trap above ends # with the step absent from the log or served from cache, and both fail # that assertion. See that script for what it does not cover. # # 4. A step that ran is not a step that saw the repo. .dockerignore, or # a COPY narrower than the tree, removes files from the build # context, and the linter then reports `0 issues.` over what is left # while `go test ./...` never compiles the package that went missing. # So each source-consuming stage inventories what reached it, and # script/assert-context-complete compares that inventory against the # git index — a source .dockerignore cannot reach. set -eu SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)" dockerfile=Dockerfile lint_stage=lint test_stage=builder # `ok 1.234s`: go test prints `(cached)` in place of the duration # when it replays a result, so requiring the duration requires a package # that was really exercised. test_ran='^ok[[:space:]]+[^[:space:]]+[[:space:]]+[0-9]+[.][0-9]+s' main() { cd "$ROOT" tmp="$(mktemp -d "${TMPDIR:-/tmp}/$("$SCRIPT_DIR/projectname")-cibuild.XXXXXX")" trap 'rm -rf "$tmp"' EXIT INT TERM # --progress=plain is load-bearing: the assertions below read the # steps' own output out of this log, and the tty renderer discards # all but the tail of it. The exit status travels through a file # because a pipeline's status is the last command's; `set +e` is # what lets the recording line run at all, since errexit would # otherwise abandon the subshell on a failing build. A missing file # reads as failure. ( set +e docker build \ --progress=plain \ --no-cache-filter="$lint_stage" \ --no-cache-filter="$test_stage" \ -f "$dockerfile" . 2>&1 echo "$?" >"$tmp/status" ) | tee "$tmp/build.log" status="$(cat "$tmp/status" 2>/dev/null || echo 1)" [ "${status:-1}" -eq 0 ] || exit "${status:-1}" script/assert-step-ran "$tmp/build.log" "$lint_stage" \ '^RUN golangci-lint run' '^0 issues[.]$' 'the linter' script/assert-step-ran "$tmp/build.log" "$test_stage" \ '^RUN go test' "$test_ran" 'the tests' # Both stages, separately: each has its own COPY, so an intact # context in one is no evidence about the other. script/repo-source-manifest >"$tmp/expected" script/assert-context-complete "$tmp/build.log" "$lint_stage" "$tmp/expected" script/assert-context-complete "$tmp/build.log" "$test_stage" "$tmp/expected" } main "$@"