bridge + ci-hygiene: Docker-needing CI jobs (option A)

- bridge: BRIDGE_NO_DAEMON names image-build jobs whose name lacks docker
  (default eternitas:ci/build); never posted, like the docker-named ones.
- ci-hygiene: flag docker build/buildx/run/compose, docker-compose and
  docker/build-push-action in workflow steps ("needs docker") with the fix:
  job services: + a no-Docker smoke test; the image builds at deploy.
- test_guards_report: owner column (14ed23a broke it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kit OC5
2026-09-23 19:09:09 -04:00
parent 8b1ae4ac60
commit 0a57d96f2e
6 changed files with 77 additions and 6 deletions

View File

@@ -116,3 +116,26 @@ def test_optional_lock_globs_are_flagged(text):
])
def test_pinned_images_and_real_locks_pass(path, text):
assert hy.scan_line(path, text) == []
@pytest.mark.parametrize("text", [
" - run: docker compose -f docker-compose.yml -f docker-compose.ci.yml build", # eternitas ci/build
" run: docker build -t windy-mail .",
" - run: docker-compose up -d",
" run: docker buildx build --load .",
" - uses: docker/build-push-action@v6",
])
def test_docker_in_ci_is_flagged_with_the_fix(text):
hits = hy.scan_line(WF, text)
assert [k for k, _ in hits] == ["needs docker"]
assert "no-Docker smoke test" in hits[0][1]
@pytest.mark.parametrize("path, text", [
("Dockerfile", "RUN docker build ."), # not a workflow
(WF, " run: ssh host 'docker compose up -d'"), # remote host has a daemon
(WF, " # docker compose build"), # comment
(WF, " run: echo docker build"),
])
def test_docker_not_flagged_outside_ci_steps(path, text):
assert [k for k, _ in hy.scan_line(path, text) if k == "needs docker"] == []

View File

@@ -57,7 +57,8 @@ def test_render_splits_lane_and_grant_counts():
md = gr.render(res)
assert "| ci-hygiene (house rule 6) | 1 | 1 | ❌ not yet |" in md
assert "| compute-guard (Mind is the only door) | 0 | 0 | ✅ YES |" in md
assert "| windy-git | bbbbbbb | 0 | 0 | clean ✅ |" in md
assert "| windy-git | Windy Git | bbbbbbb | 0 | 0 | clean ✅ |" in md
assert "| windy-pro | Windy Hub | aaaaaaa |" in md # owner = session to message
assert "(job reality-check)" in md

View File

@@ -411,3 +411,19 @@ def test_unchanged_base_leaves_the_mirror_alone(fake):
f = fake(gh_prs=gh, wg_prs=[{"number": 3, "title": "[GH#5] t", "base": {"ref": "main"}}])
bridge.sync_prs("windy-chat")
assert f.closed == [] and f.opened == []
def test_named_no_daemon_job_is_not_posted_for_that_repo_only(fake, monkeypatch):
"""eternitas ci/build needs Docker but its name doesn't say so (option A, 09-23)."""
monkeypatch.setattr(bridge, "NO_DAEMON_NAMED", {"eternitas": {"ci/build"}})
runs = [_run(1, "ci.yml", "build", "failure"), _run(2, "ci.yml", "test", "success")]
f = fake(runs=runs)
bridge.post_statuses("eternitas", SHA)
assert [p["context"] for p in f.posted] == ["windy-git/ci/test"]
f2 = fake(runs=runs)
bridge.post_statuses("windy-chat", SHA)
assert sorted(p["context"] for p in f2.posted) == ["windy-git/ci/build", "windy-git/ci/test"]
def test_default_no_daemon_named_is_eternitas_build():
assert bridge.NO_DAEMON_NAMED.get("eternitas") == {"ci/build"}

View File

@@ -110,7 +110,12 @@ their CI permanently, not a stopgap:
- **Image-build jobs** (name matches `docker`) post nothing: job containers
have no Docker daemon by design (I-5), so they are red on every commit. A
rootless builder (BuildKit rootless / buildx in the capped dind) is the open
decision that would bring them back.
decision that would bring them back. Jobs that need Docker but are named otherwise go in
`BRIDGE_NO_DAEMON` (default `eternitas:ci/build`). DECIDED 09-23 (orchestrator,
option A): lanes convert these jobs to no-Docker smoke tests (job `services:` +
start the app + curl /health); the real image build is the deploy step on the
target host. ci-hygiene flags docker build/compose/run in workflows ("needs docker").
No host Docker socket for CI without a separate decision.
**Onboarding another private repo** — the promotion steps below, then:

View File

@@ -45,9 +45,11 @@ MODE = os.environ.get("CI_HYGIENE_MODE", "warn")
INCLUDE = re.compile(r"(^|/)\.(github|gitea)/workflows/[^/]+\.ya?ml$|(^|/)(Dockerfile[^/]*|[^/]+\.Dockerfile)$")
NEVER = re.compile(r"(^|/)(node_modules|vendor|third_party)/")
PREFILTER = (r"pip3? install|pip install|uv sync|npm (install|i )|yarn install|pnpm install"
r"|^\s*-\s*['\"]?[0-9]+:[0-9]+|:latest|lock[^ ]*\*")
r"|^\s*-\s*['\"]?[0-9]+:[0-9]+|:latest|lock[^ ]*\*"
r"|docker[ -]compose|docker (build|buildx|run)|docker/build-push-action")
TOOLING = {"pip", "setuptools", "wheel"}
NO_DOCKER_FIX = "use job services: + a no-Docker smoke test; the image builds at deploy"
DOCKER_FILE = re.compile(r"(^|/)(Dockerfile[^/]*|[^/]+\.Dockerfile)$")
LATEST = re.compile(r"(?:^\s*FROM\s+(?:--platform=\S+\s+)?|--from=|image:\s*['\"]?|docker://)([\w./-]+):latest\b", re.I)
LOCKNAME = re.compile(r"(uv\.lock|poetry\.lock|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|requirements[^ ]*\.(txt|lock))", re.I)
@@ -121,8 +123,19 @@ def scan_line(path: str, text: str) -> list[tuple[str, str]]:
globbed = [t for t in text.split() if "*" in t and LOCKNAME.search(t)]
if globbed:
hits.append(("optional lock", f"COPY {globbed[0]} (must fail if the lock is missing)"))
# Windy Git jobs get NO Docker daemon (I-5), so a docker build/compose/run
# step in CI can never pass here (orchestrator 09-23, option A). The real
# image build is the deploy step on the target host.
if "/workflows/" in path and re.search(r"uses:\s*['\"]?docker/build-push-action", text):
hits.append(("needs docker", "docker/build-push-action in CI (no Docker daemon on Windy Git; " + NO_DOCKER_FIX + ")"))
for toks in _commands(text):
low = [t.lower() for t in toks]
if "/workflows/" in path and (
low[:2] in (["docker", "build"], ["docker", "buildx"], ["docker", "run"], ["docker", "compose"])
or low[:1] == ["docker-compose"]
):
hits.append(("needs docker", f"{' '.join(low[:2])} in CI (no Docker daemon on Windy Git; " + NO_DOCKER_FIX + ")"))
continue
# pip install / python -m pip install / uv pip install
for i in range(len(low) - 1):
if os.path.basename(low[i]) in ("pip", "pip3") and low[i + 1] == "install":

View File

@@ -75,6 +75,19 @@ MIRROR_TAG = "[GH#"
# always red trains everyone to ignore red. Not posted until a rootless builder
# exists; that is a decision, recorded in docs/CUTOVER.md, not a failure.
NO_DAEMON_JOB = re.compile(r"docker", re.IGNORECASE)
# Image-build jobs whose NAME doesn't say docker (orchestrator 09-23, option A:
# each lane converts the job to a no-Docker smoke test; until then it is not
# posted). Format: "repo:workflow/job,...;repo2:...".
NO_DAEMON_NAMED: dict[str, set[str]] = {}
for _entry in os.environ.get("BRIDGE_NO_DAEMON", "eternitas:ci/build").split(";"):
if ":" in _entry:
_repo, _jobs = _entry.split(":", 1)
NO_DAEMON_NAMED[_repo.strip()] = {j.strip() for j in _jobs.split(",") if j.strip()}
def needs_daemon(repo: str, wf: str, job: str) -> bool:
"""True for image-build jobs, which cannot run here (no Docker daemon, I-5)."""
return bool(NO_DAEMON_JOB.search(job)) or f"{wf}/{job}" in NO_DAEMON_NAMED.get(repo, ())
# Jobs Grant ruled NON-BLOCKING (GRANT_DECISIONS_2026-09-23): still run on
# Windy Git and visible there, but not posted to GitHub, so they cannot turn a
@@ -294,7 +307,7 @@ def post_statuses(repo: str, sha: str) -> None:
break
latest: dict[str, dict] = {}
for r in runs:
if r["head_sha"] != sha or NO_DAEMON_JOB.search(r["name"]):
if r["head_sha"] != sha or needs_daemon(repo, r["workflow_id"].removesuffix(".yml"), r["name"]):
continue
if f"{r['workflow_id'].removesuffix('.yml')}/{r['name']}" in NON_BLOCKING.get(repo, ()):
continue
@@ -304,9 +317,9 @@ def post_statuses(repo: str, sha: str) -> None:
# Queued jobs: `pending` where nothing newer has been picked up. A re-run
# queued behind an old failure must read pending, not the stale red.
for q in queued_jobs(repo, sha):
if NO_DAEMON_JOB.search(q["name"]):
continue
wf = q["workflow_id"].removesuffix(".yml")
if needs_daemon(repo, wf, q["name"]):
continue
if f"{wf}/{q['name']}" in NON_BLOCKING.get(repo, ()):
continue
ctx = f"windy-git/{wf}/{q['name']}"