chore(ci): re-vendor conformance.sh (follow the shared-workflow pin)
PHP Test / test (pull_request) Successful in 54s
Tests / test (pull_request) Successful in 54s

private/ci gained the fix that makes ci-composer-audit / ci-composer-validate
follow a project's shared-workflow pin (`uses: private/ci/.../php-test.yaml@v1`)
instead of grepping for inline workflow text that an adopted repo no longer
contains.

That file is VENDORED into this repo at .ci/ (by design — §8.2, so CI never
fetches from the LAN-only private/ci at run time), so moving the v1 tag
updated the workflow but not this copy. Re-vendored with sync-configs.sh.

Before: 2 of 34 checks failed (the two false positives).
After:  All 35 runnable checks passed.

Also brings .ci/validate-bake.py, which the new checker's bake-target-exists
check needs; without it that check correctly reports SKIPPED rather than
silently passing.
This commit is contained in:
2026-09-25 15:27:18 +00:00
parent 74902c0fc7
commit 4d5afa20e9
2 changed files with 225 additions and 3 deletions
+92 -3
View File
@@ -118,6 +118,66 @@ any_file_contains() {
local pat="$1"; shift
grep -rlE "$pat" "$@" >/dev/null 2>&1
}
# caller_disables <knob> <value>
#
# True when one of the repo's own *php-test pins* sets a shared-workflow input
# to <value> — `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 <inline-pattern> [<knob> <disabled-value>]
#
# "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 <ext-glob> <pattern> — 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"
+133
View File
@@ -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())