diff --git a/.ci/conformance.sh b/.ci/conformance.sh index 526c709..d619eab 100755 --- a/.ci/conformance.sh +++ b/.ci/conformance.sh @@ -118,6 +118,66 @@ any_file_contains() { local pat="$1"; shift grep -rlE "$pat" "$@" >/dev/null 2>&1 } + +# caller_disables +# +# True when one of the repo's own *php-test pins* sets a shared-workflow input +# to — `run-composer-audit: 'false'`, `coverage: 'none'`. Accepts the +# bare YAML boolean spelling and both quote styles, ignores indentation and +# trailing comments, and only reads files that pin php-test.yaml (a +# docker-publish caller's knobs are unrelated). +# +# "Any pin disables it" is deliberate: one workflow that switches the step off +# is enough to make the check un-satisfied. Refusing to accept an opt-out +# would turn this fix into a false green, which is the one outcome worse than +# the false red it replaces. +caller_disables() { + local knob="$1" value="$2" got + local pins=() + mapfile -t pins < <(grep -rlE '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows/php-test\.yaml@' .gitea/workflows 2>/dev/null) + [ "${#pins[@]}" -eq 0 ] && return 1 + got=$(grep -hE "^[[:space:]]*${knob}:" "${pins[@]}" 2>/dev/null \ + | sed 's/#.*//' | sed 's/^[^:]*://' | tr -d "[:space:]\042\047" \ + | tr '[:upper:]' '[:lower:]' || true) + printf '%s\n' "$got" | grep -qx "$value" +} + +# ci_runs [ ] +# +# "CI runs X" checks have TWO legitimate shapes, and only one existed when +# they were written — before the move to shared workflows (§8.2(1)): +# +# 1. INLINE — this repo's own workflow contains the command. Grep it. +# 2. DELEGATED — this repo pins private/ci's php-test.yaml, and the command +# runs there. The text is deliberately NOT in this repo any more; the +# caller is a ~15-line pin by design. +# +# Only shape 1 used to be accepted, which turned every adopted repo's audit +# and validate checks red while the steps genuinely ran — a false positive +# that reported a violation where there was none. +# +# For shape 2 the only local evidence is the knobs the caller passes, so that +# is what gets checked: an explicit opt-out (`run-composer-audit: 'false'`, +# `coverage: 'none'`) means the step does NOT run there, and the check must +# keep failing. A check that accepts a switched-off step is a false green. +# +# Deliberately NOT verified here: that the shared pipeline still contains the +# step. This script is vendored and stays offline (see the header), and +# private/ci is LAN-only, so fetching it at run time would reintroduce exactly +# the silent-no-op dependency that vendoring removed. That half of the +# contract is guarded where the file lives: validate-workflows.py fails +# private/ci's own CI if php-test.yaml loses a step the projects' checks take +# on faith, or if a gate's default flips to disabled. +ci_runs() { + local pattern="$1" knob="${2:-}" disabled="${3:-}" + if grep -rqE '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows/php-test\.yaml@' .gitea/workflows 2>/dev/null; then + # Delegated: the caller's knobs are the only local source of truth. + [ -n "$knob" ] && caller_disables "$knob" "$disabled" && return 1 + return 0 + fi + any_file_contains "$pattern" .gitea/workflows +} + # no_file_contains — searches source trees only no_match_in_sources() { local pattern="$1"; shift @@ -194,20 +254,23 @@ check "editorconfig" \ $OUTPUT_JSON || echo "" $OUTPUT_JSON || echo "CI & supply chain" +# These three accept the command either inline or via the shared pipeline — +# see ci_runs above for why, and for what is still required of a delegating +# caller (the opt-out knobs must not be set). check "ci-composer-audit" \ "CI runs 'composer audit'" \ "§8.1" \ - any_file_contains 'composer audit' .gitea/workflows + ci_runs 'composer audit' run-composer-audit false check "ci-coverage" \ "CI measures test coverage" \ "§2.3" \ - any_file_contains 'coverage' .gitea/workflows + ci_runs 'coverage' coverage none check "ci-composer-validate" \ "CI runs 'composer validate --strict'" \ "§8.3" \ - any_file_contains 'composer validate' .gitea/workflows + ci_runs 'composer validate' # Anchored to a REAL `uses:` line, not the string anywhere in the file. # @@ -223,6 +286,32 @@ check "ci-reusable-workflows" \ "§8.2" \ any_file_contains '^[[:space:]]*uses:[[:space:]]*private/ci/\.gitea/workflows' .gitea/workflows +# A bake file describes WHICH Dockerfile stage to build. buildx does not check +# that the stage exists until build time, and `bake --print` — the obvious way +# to validate one — happily resolves a target that names no stage, because it +# never reads the Dockerfile. So a typo there reaches CI and fails after the +# push. This is the check that buildx is missing. +# +# SKIPPED when there is no bake file: most repos use the `action` backend and +# have none, and absence is not a violation. +# +# The helper belongs in the PREREQUISITE, not only in the command. It used to +# be `[ -f ... ] || exit 0` INSIDE the command, which is a different thing: a +# repo that has a bake file but no vendored helper reported a green tick for a +# check that never ran. That is the one outcome this script's header singles +# out as most dangerous, and it was live — preauth adopted a bake file before +# the `validate-bake.py` half of the re-vendor landed, so its next sync would +# have shown a green `bake-target-exists` no matter what the bake file said. +# +# With the helper in the prerequisite the same state reports SKIPPED — its own +# yellow state, explicitly not a pass. (`css-control-size.py`, the other +# helper, has been wired this way since it was added; see below.) +check_opt "bake-target-exists" \ + "docker-bake.hcl targets a stage that exists in the Dockerfile" \ + "§6.2" \ + 'command -v python3 >/dev/null 2>&1 && [ -f docker-bake.hcl ] && [ -f "$CONFORMANCE_DIR/validate-bake.py" ]' \ + bash -c 'exec "$CONFORMANCE_DIR/validate-bake.py" docker-bake.hcl Dockerfile' + $OUTPUT_JSON || echo "" $OUTPUT_JSON || echo "Hygiene & layout" diff --git a/.ci/validate-bake.py b/.ci/validate-bake.py new file mode 100755 index 0000000..09ab11b --- /dev/null +++ b/.ci/validate-bake.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +validate-bake.py — check a docker-bake.hcl against its Dockerfile. + +WHY THIS EXISTS +--------------- +`docker buildx bake --print` resolves the bake file but does NOT read the +Dockerfile, so it happily accepts `target = "app"` when no stage is named +`app`. The failure only surfaces at build time, in CI, after the push: + + ERROR: failed to solve: target stage "app" could not be found + +That is exactly the failure preauth had: its final stage was unnamed +(`FROM dunglas/frankenphp:php8.5-trixie`), so `target = "app"` could never +have resolved. --print reported success. + +This script checks the cross-file contract that buildx does not: + 1. every `target = "..."` matches a named Dockerfile stage + 2. the named stage is the LAST one, so a plain `docker build` still works + 3. every variable the file references is declared + 4. the `default` group only names targets that exist + +Works without a Docker daemon, so it is usable in CI and on the workstation. + +Usage: validate-bake.py [bake-file] [dockerfile] +Exit: 0 = clean, 1 = problems found +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +FROM_RE = re.compile(r"^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?\s*$", re.I) +TARGET_RE = re.compile(r'^\s*target\s*=\s*"([^"]+)"', re.M) +DECL_RE = re.compile(r'^\s*target\s+"([^"]+)"\s*\{', re.M) +VAR_DECL_RE = re.compile(r'^\s*variable\s+"([^"]+)"\s*\{', re.M) +VAR_USE_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") +GROUP_RE = re.compile(r'^\s*group\s+"([^"]+)"\s*\{', re.M) +TARGETS_LIST_RE = re.compile(r"targets\s*=\s*\[([^\]]*)\]") + + +def stages(dockerfile: Path) -> list[tuple[int, str, str]]: + """Return (line_no, image, stage_name_or_empty) for each FROM.""" + out = [] + for i, line in enumerate(dockerfile.read_text().splitlines(), 1): + if m := FROM_RE.match(line): + out.append((i, m.group(1), m.group(2) or "")) + return out + + +def main() -> int: + bake = Path(sys.argv[1] if len(sys.argv) > 1 else "docker-bake.hcl") + dockerfile = Path(sys.argv[2] if len(sys.argv) > 2 else "Dockerfile") + + for f in (bake, dockerfile): + if not f.is_file(): + print(f" ✗ missing file: {f}", file=sys.stderr) + return 1 + + text = bake.read_text() + found_stages = stages(dockerfile) + named = [(ln, n) for ln, _, n in found_stages if n] + names = [n for _, n in named] + + problems: list[str] = [] + notes: list[str] = [] + + # 1 + 2: target/stage contract + for t in TARGET_RE.findall(text): + if t not in names: + problems.append( + f"bake target '{t}' matches no named Dockerfile stage. " + f"Named stages: {names or '(none)'}. " + f"buildx --print does NOT catch this; the build fails." + ) + if names: + # A plain `docker build` builds the LAST stage. If no bake target + # points at it, the two build paths produce different images — worth + # knowing, but legitimate for repos that publish one variant per + # target (task-weaver builds controller + worker and never uses the + # bare `docker build` path). So: note, not error. + bake_targets = TARGET_RE.findall(text) + last_name = named[-1][1] + if bake_targets and last_name not in bake_targets: + notes.append( + f"no bake target selects the LAST Dockerfile stage " + f"('{last_name}'), so a plain `docker build` and `bake` " + f"produce different images." + ) + else: + problems.append("Dockerfile has no named stages; bake needs one.") + + # 3: declared vs used variables + declared = set(VAR_DECL_RE.findall(text)) + used = set(VAR_USE_RE.findall(text)) + for u in sorted(used - declared): + problems.append( + f"${{{u}}} is used but never declared as a `variable` block; " + f"bake would error at load time." + ) + for d in sorted(declared - used): + notes.append(f"variable '{d}' is declared but never referenced.") + + # 4: default group names real targets + declared_targets = set(DECL_RE.findall(text)) + for gname in GROUP_RE.findall(text): + block = text.split(f'group "{gname}"', 1)[1][:400] + if m := TARGETS_LIST_RE.search(block): + for t in re.findall(r'"([^"]+)"', m.group(1)): + if t not in declared_targets: + problems.append( + f"group '{gname}' references target '{t}', " + f"which is not declared." + ) + + name = bake.name + if problems: + print(f" FAIL {name}") + for p in problems: + print(f" ✗ {p}") + else: + print(f" OK {name} (targets: {sorted(declared_targets)} / " + f"stages: {names})") + for n in notes: + print(f" · {n}") + + return 1 if problems else 0 + + +if __name__ == "__main__": + sys.exit(main())