6 Commits

Author SHA1 Message Date
Kit OC5
83fbf1f992 guards_report: chat lane is now the Windy Chat session
All checks were successful
check / gate (push) Successful in 10s
canary / probe (push) Successful in 6s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:12:40 -04:00
Kit OC5
9b5334fee7 test: allow-list entries must be line-scoped (replaces is-empty check)
f71a5aa pushed with this test red (tail hid pytest rc); fixed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:11:53 -04:00
Kit OC5
f71a5aab39 ci-hygiene allow: windy-pro disabled deploy job (needs-docker false positive)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:11:20 -04:00
Kit OC5
f219437282 ci-hygiene report: same disabled-workflow filter as check()
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:10:35 -04:00
Kit OC5
b15584d33a ci-hygiene: needs-docker skips workflows disabled on Windy Git
deploy/release workflows run on the target host (real daemon) and are
disabled here (repo_unit DisabledWorkflows); one bounded query per process,
flag everything if it fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 19:10:11 -04:00
Kit OC5
0a57d96f2e 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>
2026-09-23 19:09:09 -04:00
8 changed files with 157 additions and 11 deletions

View File

@@ -83,8 +83,17 @@ def test_warn_mode_never_turns_red(monkeypatch):
assert state == "success" and desc.startswith("⚠ WARN (not blocking): 1 CI hygiene issue in CI/Dockerfiles") assert state == "success" and desc.startswith("⚠ WARN (not blocking): 1 CI hygiene issue in CI/Dockerfiles")
def test_allow_file_loads_and_is_empty_today(): def test_allow_file_is_line_scoped_exceptions_only():
assert hy.cg.load_allow(hy.ALLOW_FILE) == [] """Every exception is line-scoped (`matches`), so an allowed file can't hide a
NEW floating install or docker step. Today: windy-pro's if:false deploy job."""
allow = hy.cg.load_allow(hy.ALLOW_FILE)
assert [(e["repo"], e["paths"]) for e in allow] == [("windy-pro", [".github/workflows/ci.yml"])]
assert all(e.get("matches") for e in allow)
ok = " run: docker build -f account-server/Dockerfile -t windy-pro:${{ github.sha }} ."
new = " run: docker build -t windy-pro-api ."
assert hy.cg.allowed("windy-pro", WF, allow, ok)
assert not hy.cg.allowed("windy-pro", WF, allow, new)
assert not hy.cg.allowed("windy-chat", WF, allow, ok)
@pytest.mark.parametrize("path, text, want", [ @pytest.mark.parametrize("path, text, want", [
@@ -116,3 +125,40 @@ def test_optional_lock_globs_are_flagged(text):
]) ])
def test_pinned_images_and_real_locks_pass(path, text): def test_pinned_images_and_real_locks_pass(path, text):
assert hy.scan_line(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"] == []
def test_needs_docker_skips_workflows_disabled_on_windy_git(monkeypatch):
"""deploy.yml runs on the target host (a real daemon); Gitea has it disabled here."""
F = hy.cg.Finding
monkeypatch.setattr(hy, "_DISABLED", {"eternitas": {"deploy.yml"}})
got = hy._runs_here("Eternitas", [
F(".github/workflows/deploy.yml", 70, "needs docker", "docker compose in CI"),
F(".github/workflows/ci.yml", 176, "needs docker", "docker compose in CI"),
F(".github/workflows/deploy.yml", 12, "floating install", "npm install"),
])
assert [(f.path.rsplit("/", 1)[1], f.kind) for f in got] == [
("ci.yml", "needs docker"), ("deploy.yml", "floating install")]
assert hy._runs_here("eternitas", None) is None

View File

@@ -57,7 +57,8 @@ def test_render_splits_lane_and_grant_counts():
md = gr.render(res) md = gr.render(res)
assert "| ci-hygiene (house rule 6) | 1 | 1 | ❌ not yet |" in md 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 "| 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 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"}}]) f = fake(gh_prs=gh, wg_prs=[{"number": 3, "title": "[GH#5] t", "base": {"ref": "main"}}])
bridge.sync_prs("windy-chat") bridge.sync_prs("windy-chat")
assert f.closed == [] and f.opened == [] 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

@@ -2,4 +2,12 @@
# a host port. House rule 6 (09-23): installs come from a lockfile. Every entry # a host port. House rule 6 (09-23): installs come from a lockfile. Every entry
# is an exception and MUST say why. Paths are fnmatch globs from the repo root. # is an exception and MUST say why. Paths are fnmatch globs from the repo root.
# Owner: Windy Git lane (13); changes go through the orchestrator. # Owner: Windy Git lane (13); changes go through the orchestrator.
allow: [] allow:
- repo: windy-pro
paths: [".github/workflows/ci.yml"]
# ONLY the old deploy job's two docker lines. That job is `if: false`
# (CD boundary, 2026-07), and the compose line runs ON windyword.ai inside
# the ssh string. Any other docker step in ci.yml still flags.
matches: ['docker build -f account-server/Dockerfile -t windy-pro:', 'docker compose down && docker compose up -d --build']
reason: "needs-docker false positive: the deploy job is if: false and its compose runs on the remote host over ssh. Added with the needs-docker rule (orchestrator option A, 09-23)."

View File

@@ -110,7 +110,12 @@ their CI permanently, not a stopgap:
- **Image-build jobs** (name matches `docker`) post nothing: job containers - **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 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 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: **Onboarding another private repo** — the promotion steps below, then:

View File

@@ -28,9 +28,11 @@ Exceptions: ci/ci-hygiene-allow.yml, one reason per entry.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import json
import os import os
import re import re
import shlex import shlex
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
@@ -45,9 +47,11 @@ MODE = os.environ.get("CI_HYGIENE_MODE", "warn")
INCLUDE = re.compile(r"(^|/)\.(github|gitea)/workflows/[^/]+\.ya?ml$|(^|/)(Dockerfile[^/]*|[^/]+\.Dockerfile)$") INCLUDE = re.compile(r"(^|/)\.(github|gitea)/workflows/[^/]+\.ya?ml$|(^|/)(Dockerfile[^/]*|[^/]+\.Dockerfile)$")
NEVER = re.compile(r"(^|/)(node_modules|vendor|third_party)/") NEVER = re.compile(r"(^|/)(node_modules|vendor|third_party)/")
PREFILTER = (r"pip3? install|pip install|uv sync|npm (install|i )|yarn install|pnpm install" 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"} 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)$") 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) 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) LOCKNAME = re.compile(r"(uv\.lock|poetry\.lock|package-lock\.json|pnpm-lock\.yaml|yarn\.lock|requirements[^ ]*\.(txt|lock))", re.I)
@@ -121,8 +125,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)] globbed = [t for t in text.split() if "*" in t and LOCKNAME.search(t)]
if globbed: if globbed:
hits.append(("optional lock", f"COPY {globbed[0]} (must fail if the lock is missing)")) 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): for toks in _commands(text):
low = [t.lower() for t in toks] 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 # pip install / python -m pip install / uv pip install
for i in range(len(low) - 1): for i in range(len(low) - 1):
if os.path.basename(low[i]) in ("pip", "pip3") and low[i + 1] == "install": if os.path.basename(low[i]) in ("pip", "pip3") and low[i + 1] == "install":
@@ -143,7 +158,49 @@ def scan_line(path: str, text: str) -> list[tuple[str, str]]:
return hits return hits
_DISABLED: dict[str, set[str]] | None = None
def disabled_workflows() -> dict[str, set[str]]:
"""Workflow file names Gitea has DISABLED per repo (lowercased repo name).
Deploy/release workflows are disabled on Windy Git: they run on the target
host, where a Docker daemon really exists, so "needs docker" must not flag
them. One bounded query per process; on any failure nothing is excused
(flag rather than hide).
"""
global _DISABLED
if _DISABLED is not None:
return _DISABLED
_DISABLED = {}
query = ("select coalesce(json_object_agg(r.lower_name, u.config::json->'DisabledWorkflows'), '{}'::json)"
" from repo_unit u join repository r on r.id = u.repo_id"
" where u.type = 10 and u.config like '%DisabledWorkflows%';")
try:
out = subprocess.run(
["docker", "exec", "-i", "windy-git-db-1", "sh", "-c",
'psql -U "$POSTGRES_USER" -d gitea -At -v ON_ERROR_STOP=1'],
input=query, capture_output=True, text=True, check=True, timeout=30,
).stdout.strip()
_DISABLED = {k: set(v or []) for k, v in json.loads(out or "{}").items()}
except (subprocess.SubprocessError, OSError, ValueError):
pass
return _DISABLED
def _runs_here(repo: str, findings):
"""Drop "needs docker" hits in workflows that never run on Windy Git."""
if findings is None:
return None
off = disabled_workflows().get(repo.lower(), set())
return [f for f in findings if not (f.kind == "needs docker" and Path(f.path).name in off)]
def check(repo: str, sha: str, default_branch: str, is_default_head: bool): def check(repo: str, sha: str, default_branch: str, is_default_head: bool):
return _runs_here(repo, _check(repo, sha, default_branch, is_default_head))
def _check(repo: str, sha: str, default_branch: str, is_default_head: bool):
bare = cg.WORK / f"{repo}.git" bare = cg.WORK / f"{repo}.git"
if not bare.is_dir() or not cg.fetched(bare, sha): # pushed after the fetch: next cycle if not bare.is_dir() or not cg.fetched(bare, sha): # pushed after the fetch: next cycle
return None return None
@@ -179,7 +236,7 @@ def report(repos: list[str]) -> int:
continue continue
head = cg._git(bare, "symbolic-ref", "--short", "HEAD").strip() head = cg._git(bare, "symbolic-ref", "--short", "HEAD").strip()
sha = cg._git(bare, "rev-parse", head).strip() sha = cg._git(bare, "rev-parse", head).strip()
fs = cg.scan_tree(repo, bare, sha, allow, line_fn=scan_line, path_ok=path_ok, prefilter=PREFILTER) fs = _runs_here(repo, cg.scan_tree(repo, bare, sha, allow, line_fn=scan_line, path_ok=path_ok, prefilter=PREFILTER))
total += len(fs) total += len(fs)
print(f"## {repo} ({head} {sha[:7]}): {len(fs)} issue(s)") print(f"## {repo} ({head} {sha[:7]}): {len(fs)} issue(s)")
for f in fs: for f in fs:

View File

@@ -38,7 +38,7 @@ REPOS = os.environ.get("BRIDGE_REPOS", "").split() or [
# windy-admin/telemetry -> "Windy Admin"). windy-pro here = its server/web side; the desktop # windy-admin/telemetry -> "Windy Admin"). windy-pro here = its server/web side; the desktop
# app is Grant-owned and listed in its own section below. # app is Grant-owned and listed in its own section below.
OWNERS = { OWNERS = {
"windy-chat": "grantwhitmer-ca", "windy-mail": "Windy Mail", "windy-calendar": "Windy Calender", "windy-chat": "Windy Chat", "windy-mail": "Windy Mail", "windy-calendar": "Windy Calender",
"Windy-Clone": "Windy Clone", "WindyCloud": "Windy Cloud", "windy-cloud-sites": "Windy Cloud", "Windy-Clone": "Windy Clone", "WindyCloud": "Windy Cloud", "windy-cloud-sites": "Windy Cloud",
"windy-cloud-domains": "Windy Cloud", "windy-cloud-vps": "Windy Cloud", "windy-search": "Windy Search", "windy-cloud-domains": "Windy Cloud", "windy-cloud-vps": "Windy Cloud", "windy-search": "Windy Search",
"windy-connect": "Windy Connect", "windy-drops": "Windy Drops", "windy-registry": "Windy Drops", "windy-connect": "Windy Connect", "windy-drops": "Windy Drops", "windy-registry": "Windy Drops",

View File

@@ -75,6 +75,19 @@ MIRROR_TAG = "[GH#"
# always red trains everyone to ignore red. Not posted until a rootless builder # 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. # exists; that is a decision, recorded in docs/CUTOVER.md, not a failure.
NO_DAEMON_JOB = re.compile(r"docker", re.IGNORECASE) 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 # 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 # 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 break
latest: dict[str, dict] = {} latest: dict[str, dict] = {}
for r in runs: 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 continue
if f"{r['workflow_id'].removesuffix('.yml')}/{r['name']}" in NON_BLOCKING.get(repo, ()): if f"{r['workflow_id'].removesuffix('.yml')}/{r['name']}" in NON_BLOCKING.get(repo, ()):
continue 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 jobs: `pending` where nothing newer has been picked up. A re-run
# queued behind an old failure must read pending, not the stale red. # queued behind an old failure must read pending, not the stale red.
for q in queued_jobs(repo, sha): for q in queued_jobs(repo, sha):
if NO_DAEMON_JOB.search(q["name"]):
continue
wf = q["workflow_id"].removesuffix(".yml") wf = q["workflow_id"].removesuffix(".yml")
if needs_daemon(repo, wf, q["name"]):
continue
if f"{wf}/{q['name']}" in NON_BLOCKING.get(repo, ()): if f"{wf}/{q['name']}" in NON_BLOCKING.get(repo, ()):
continue continue
ctx = f"windy-git/{wf}/{q['name']}" ctx = f"windy-git/{wf}/{q['name']}"