diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d34ec3eaf..818df87ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -98,6 +98,22 @@ jobs: - name: Set up Python run: uv python install ${{ env.PYTHON_VERSION }} + - name: Every service's uv.lock matches its pyproject.toml + # M-01: each image installs its service's uv.lock as it stands (``uv + # export --frozen``), while the install below resolves the pyproject + # ranges afresh. A pyproject edit made without a re-lock was therefore + # tested here and missing from the image, with nothing to fail it. + # ``uv lock --check`` fails when re-locking would change the lock. + run: | + stale="" + for service in core-api core-storage-api core-operations core-worker; do + uv lock --check --directory "${service}" || stale="${stale} ${service}/uv.lock" + done + if [ -n "${stale}" ]; then + echo "::error::Out of date:${stale}. Run uv lock in each one's directory and commit the result." + exit 1 + fi + - name: Install dependencies # core-operations is installed for its own ruff/mypy/pytest steps # below. Before it was listed here those steps passed only because @@ -773,6 +789,17 @@ jobs: exit 1 } + # M-01: core-worker's image, the write path in deferred mode, was built by + # no workflow. Its tests run in the CI job's shared venv, where another + # service's dependency can stand in for one core-worker never declared, + # so import the app its CMD serves from what the image itself installed. + - name: Build core-worker image and import its app + run: | + docker build --platform linux/amd64 --target runtime \ + -t core-worker-imagetest \ + -f core-worker/Dockerfile . + docker run --rm core-worker-imagetest python -c "import uvicorn, core_worker.app" + - name: Build core-storage-api image for README quickstart run: | docker build --platform linux/amd64 --target runtime \ diff --git a/tests/test_m01_every_lock_is_checked_and_every_image_built.py b/tests/test_m01_every_lock_is_checked_and_every_image_built.py new file mode 100644 index 000000000..62cc413c7 --- /dev/null +++ b/tests/test_m01_every_lock_is_checked_and_every_image_built.py @@ -0,0 +1,49 @@ +"""M-01: CI checks every service lock and builds every service image. + +Each service image installs its own ``uv.lock`` as it stands (``uv export +--frozen``), while CI tests the pyproject ranges, resolved afresh. A pyproject +edit made without a re-lock was therefore tested here but missing from the +image, and nothing failed. CI now runs ``uv lock --check`` for every service. +core-worker's image, the write path in deferred mode, was built by no workflow, +so a broken Dockerfile or a dependency it never declared would surface only in a +deploy; CI now builds it too. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +import yaml + +pytestmark = pytest.mark.unit + +REPO = Path(__file__).resolve().parents[1] +CI = REPO / ".github/workflows/ci.yml" +SERVICES = {"core-api", "core-storage-api", "core-worker", "core-operations"} +LOCKED = sorted(path.parent.name for path in REPO.glob("*/uv.lock")) +IMAGES = sorted(path.parent.name for path in REPO.glob("*/Dockerfile")) + + +def _runs(command: str) -> str: + """Every ci.yml ``run:`` script that runs ``command``, joined.""" + jobs = yaml.safe_load(CI.read_text())["jobs"].values() + runs = (step.get("run", "") for job in jobs for step in job.get("steps", [])) + return "\n".join(run for run in runs if command in run) + + +def test_the_scans_see_every_service() -> None: + assert set(LOCKED) >= SERVICES + assert set(IMAGES) >= SERVICES + assert "-f core-api/Dockerfile" in _runs("docker build") + + +def test_ci_checks_every_service_lock() -> None: + named = set(re.findall(r"[\w.-]+", _runs("uv lock --check"))) + assert [name for name in LOCKED if name not in named] == [] + + +def test_ci_builds_every_service_image() -> None: + builds = _runs("docker build") + assert [name for name in IMAGES if f"-f {name}/Dockerfile" not in builds] == []