From 0a57d96f2e87c6bb4c94e9929ef0cc3ccbab3bc6 Mon Sep 17 00:00:00 2001 From: Kit OC5 Date: Wed, 23 Sep 2026 19:09:09 -0400 Subject: [PATCH] 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 --- api/tests/test_ci_hygiene.py | 23 +++++++++++++++++++++++ api/tests/test_guards_report.py | 3 ++- api/tests/test_pr_status_bridge.py | 16 ++++++++++++++++ docs/CUTOVER.md | 7 ++++++- scripts/ci_hygiene.py | 15 ++++++++++++++- scripts/pr_status_bridge.py | 19 ++++++++++++++++--- 6 files changed, 77 insertions(+), 6 deletions(-) diff --git a/api/tests/test_ci_hygiene.py b/api/tests/test_ci_hygiene.py index 8113221..c02630e 100644 --- a/api/tests/test_ci_hygiene.py +++ b/api/tests/test_ci_hygiene.py @@ -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"] == [] diff --git a/api/tests/test_guards_report.py b/api/tests/test_guards_report.py index 5aa8c5d..048477b 100644 --- a/api/tests/test_guards_report.py +++ b/api/tests/test_guards_report.py @@ -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 diff --git a/api/tests/test_pr_status_bridge.py b/api/tests/test_pr_status_bridge.py index b7ea418..6b7dae4 100644 --- a/api/tests/test_pr_status_bridge.py +++ b/api/tests/test_pr_status_bridge.py @@ -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"} diff --git a/docs/CUTOVER.md b/docs/CUTOVER.md index 30083e0..bb78cf1 100644 --- a/docs/CUTOVER.md +++ b/docs/CUTOVER.md @@ -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: diff --git a/scripts/ci_hygiene.py b/scripts/ci_hygiene.py index aed40f3..c585ac7 100644 --- a/scripts/ci_hygiene.py +++ b/scripts/ci_hygiene.py @@ -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": diff --git a/scripts/pr_status_bridge.py b/scripts/pr_status_bridge.py index 1d76e45..e1f6851 100755 --- a/scripts/pr_status_bridge.py +++ b/scripts/pr_status_bridge.py @@ -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']}"