ci-hygiene guard: lockfile-only installs, pinned images, no host-port services (warn-only)
All checks were successful
check / gate (push) Successful in 19s
All checks were successful
check / gate (push) Successful in 19s
House rule 6 (09-23). The bridge now also posts windy-git/ci-hygiene on every PR head (added lines) and default branch (whole files), scanning CI workflows and Dockerfiles for: floating pip / uv pip installs (not -r, not --no-deps, not exact pins), uv sync without --locked/--frozen, npm install instead of npm ci (unless every package is exact-pinned), yarn/pnpm without a frozen lockfile, :latest images and COPY lock* globs (Windy Mail #147), and CI services publishing a HOST port (every job shares one dind: Windy Mind runs 147/176 died on 5432). Warn-only; CI_HYGIENE_MODE=block later. Allow-list ci/ci-hygiene-allow.yml (empty). compute_guard's walker is now parameterised (line_fn / path_ok / prefilter) so both guards share one scanner, cache and allow loader; the bridge posts both through one _post_guard. Today: 95 issues in 21 repos; windy-git, calendar, traveler, traveler-site clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
118
api/tests/test_ci_hygiene.py
Normal file
118
api/tests/test_ci_hygiene.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""CI hygiene guard (house rule 6): lockfile-only installs, no host-port services."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(ROOT / "scripts"))
|
||||
_spec = importlib.util.spec_from_file_location("ci_hygiene", ROOT / "scripts" / "ci_hygiene.py")
|
||||
hy = importlib.util.module_from_spec(_spec)
|
||||
sys.modules["ci_hygiene"] = hy
|
||||
_spec.loader.exec_module(hy)
|
||||
|
||||
WF = ".github/workflows/ci.yml"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, text", [
|
||||
(WF, " .venv/bin/pip install -e \".[dev]\""), # windy-git's own, before e64a1b5
|
||||
("Dockerfile", "RUN pip install --no-cache-dir -e ."), # windy-git image, before e64a1b5
|
||||
(WF, " - run: uv pip install -e \".[dev]\""), # WindyCloud #109's CI
|
||||
(WF, " run: pip install fastapi uvicorn"),
|
||||
(WF, " - run: uv sync --all-extras"), # windy-mind style, not locked
|
||||
(WF, " - run: npm install"), # windy-drops / windytalk
|
||||
(WF, " - run: npm install --no-save --no-audit --no-fund jsdom"), # windy-pro reality-check
|
||||
(WF, " - run: yarn install"),
|
||||
(WF, " - run: cd web && pnpm install"),
|
||||
("docker/api.Dockerfile", "RUN apt-get update && pip install requests"),
|
||||
])
|
||||
def test_floating_installs_are_flagged(path, text):
|
||||
assert [k for k, _ in hy.scan_line(path, text)] == ["floating install"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, text", [
|
||||
(WF, " python3 -m pip install -q uv==0.12.5"), # exact tool pin
|
||||
(WF, " uv sync --locked --extra dev"),
|
||||
(WF, " - run: uv sync --frozen"),
|
||||
(WF, " - run: npm ci"),
|
||||
(WF, " - run: npm install --no-save jsdom@24.1.0"),
|
||||
(WF, " - run: pip install -r requirements.lock --require-hashes"),
|
||||
(WF, " - run: pip install -r requirements.txt"),
|
||||
("Dockerfile", " && pip install --no-cache-dir --require-hashes -r /tmp/requirements.txt \\\\"),
|
||||
("Dockerfile", "RUN pip install --no-cache-dir --no-deps -e ."), # project only, deps from the lock
|
||||
(WF, " .venv/bin/pip install -q --upgrade pip"),
|
||||
(WF, " - run: yarn install --frozen-lockfile"),
|
||||
(WF, " # - run: npm install (commented out)"),
|
||||
(WF, " - run: echo 'pip is great'"),
|
||||
])
|
||||
def test_locked_or_pinned_installs_pass(path, text):
|
||||
assert hy.scan_line(path, text) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text, port", [
|
||||
(" - 5432:5432", "5432"), # windy-mind / eternitas (collided 09-23)
|
||||
(" - '15432:5432'", "15432"), # WindyCloud
|
||||
(' - "6379:6379"', "6379"),
|
||||
])
|
||||
def test_services_publishing_a_host_port_are_flagged(text, port):
|
||||
[(kind, match)] = hy.scan_line(WF, text)
|
||||
assert kind == "host port" and port in match
|
||||
|
||||
|
||||
def test_host_port_rule_is_for_workflows_only():
|
||||
assert hy.scan_line("docker-compose.yml", " - 5432:5432") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, ok", [
|
||||
(".github/workflows/ci.yml", True), (".gitea/workflows/check.yaml", True),
|
||||
("Dockerfile", True), ("api/Dockerfile.prod", True), ("docker/web.Dockerfile", True),
|
||||
("scripts/setup.sh", False), ("README.md", False), ("node_modules/x/Dockerfile", False),
|
||||
(".github/lint/x.yml", False),
|
||||
])
|
||||
def test_scope_is_ci_workflows_and_dockerfiles(path, ok):
|
||||
assert hy.path_ok(path) is ok
|
||||
|
||||
|
||||
def test_warn_mode_never_turns_red(monkeypatch):
|
||||
monkeypatch.setattr(hy, "MODE", "warn")
|
||||
state, desc, f = hy.status_for([hy.cg.Finding(WF, 12, "floating install", "npm install (use npm ci)")], True)
|
||||
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():
|
||||
assert hy.cg.load_allow(hy.ALLOW_FILE) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, text, want", [
|
||||
("Dockerfile", "COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv", "ghcr.io/astral-sh/uv:latest"), # Mail #147
|
||||
("Dockerfile", "FROM python:latest", "python:latest"),
|
||||
("Dockerfile", "FROM --platform=linux/amd64 node:latest AS web", "node:latest"),
|
||||
(WF, " image: postgres:latest", "postgres:latest"),
|
||||
(WF, " - uses: docker://ghcr.io/foo/bar:latest", "ghcr.io/foo/bar:latest"),
|
||||
])
|
||||
def test_latest_images_are_flagged(path, text, want):
|
||||
hits = hy.scan_line(path, text)
|
||||
assert ("floating image", want) in hits
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text", [
|
||||
"COPY pyproject.toml uv.lock* ./", # Windy Mail #147
|
||||
"COPY package.json package-lock.json* ./",
|
||||
])
|
||||
def test_optional_lock_globs_are_flagged(text):
|
||||
assert [k for k, _ in hy.scan_line("Dockerfile", text)] == ["optional lock"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path, text", [
|
||||
("Dockerfile", "COPY --from=ghcr.io/astral-sh/uv:0.12.5 /uv /usr/local/bin/uv"),
|
||||
("Dockerfile", "FROM python:3.12-slim"),
|
||||
("Dockerfile", "COPY pyproject.toml uv.lock ./"),
|
||||
("Dockerfile", "COPY src/*.py ./src/"),
|
||||
("Dockerfile", "RUN echo latest release notes"),
|
||||
])
|
||||
def test_pinned_images_and_real_locks_pass(path, text):
|
||||
assert hy.scan_line(path, text) == []
|
||||
@@ -382,3 +382,11 @@ def test_guard_that_cannot_run_posts_nothing(fake, monkeypatch):
|
||||
monkeypatch.setitem(sys.modules, "compute_guard", _Guard(None))
|
||||
bridge.post_compute_guard("windy-chat", SHA, "main", True)
|
||||
assert f.posted == []
|
||||
|
||||
|
||||
def test_ci_hygiene_posts_under_its_own_context(fake, monkeypatch):
|
||||
f = fake(statuses=[{"context": "windy-git/compute-guard", "state": "success", "description": "WARN 1"}])
|
||||
monkeypatch.setitem(sys.modules, "ci_hygiene", _Guard([_F()]))
|
||||
bridge.post_ci_hygiene("windy-chat", SHA, "main", True)
|
||||
# the compute-guard status with the same description must not suppress it
|
||||
assert [(p["context"], p["description"]) for p in f.posted] == [("windy-git/ci-hygiene", "WARN 1")]
|
||||
|
||||
Reference in New Issue
Block a user