chore(ci): re-vendor conformance.sh (follow the shared-workflow pin)
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:
+92
-3
@@ -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"
|
||||
|
||||
|
||||
Executable
+133
@@ -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())
|
||||
Reference in New Issue
Block a user