diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3de21d4d..5d3e18a00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,7 +42,6 @@ on: - 'build_linux.sh' - 'build_windows.ps1' - 'Dockerfile' - - 'docker/**' - 'scripts/**' - 'devtools/**' - 'packaging/**' @@ -533,9 +532,7 @@ jobs: - uses: actions/checkout@v4 - name: Build Docker image id: docker_build - run: | - docker build -f docker/Dockerfile.base -t ouroboros-base:local . - docker build -t ouroboros-web:test . + run: docker build -t ouroboros-web:test . - uses: ./.github/actions/setup-python-env id: setup_python - name: Install UI smoke browser binaries @@ -554,7 +551,7 @@ jobs: docker run --rm --entrypoint sh \ -e OUROBOROS_EXPECT_BROWSER_ENGINES=chromium,webkit \ ouroboros-web:test -c \ - "PLAYWRIGHT_BROWSERS_PATH=0 python -m pytest tests/test_browser_tools_smoke.py -m browser -q --tb=short" + "python -m pytest tests/test_browser_tools_smoke.py -m browser -q --tb=short" docker-portable-test: if: | @@ -564,13 +561,11 @@ jobs: steps: - uses: actions/checkout@v4 - name: Build Docker image - run: | - docker build -f docker/Dockerfile.base -t ouroboros-base:local . - docker build -t ouroboros-web:test . + run: docker build -t ouroboros-web:test . - name: Run portable detail tests in Docker run: | docker run --rm --entrypoint sh -e OUROBOROS_EXPECT_HEADLESS_SHELL=1 ouroboros-web:test -c \ - "PLAYWRIGHT_BROWSERS_PATH=0 python -m playwright install --only-shell chromium && python -m pytest tests/ -m portable_detail -q --tb=short" + "python -m pytest tests/ -m portable_detail -q --tb=short" # ────────────────────────────────────────────────────────────────── # Scheduled: the KEYLESS system_e2e scenario lane (tests/system_e2e/). diff --git a/Dockerfile b/Dockerfile index bde54f5f2..e4bcadcef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,46 @@ -# syntax=docker/dockerfile:1 - -# Ouroboros — application image for web UI runtime +# Ouroboros — Docker image for web UI runtime # Usage: -# docker build -f docker/Dockerfile.base -t ouroboros-base:local . # docker build -t ouroboros-web . # docker run --rm -p 8765:8765 ouroboros-web +# The RUN --mount caches need BuildKit, Docker's default builder. -ARG OUROBOROS_BASE_IMAGE=ouroboros-base:local -FROM ${OUROBOROS_BASE_IMAGE} +FROM ghcr.io/astral-sh/uv:0.12.1 AS uv +FROM python:3.10-slim -# Application environment -ENV APP_HOME=/app \ +COPY --from=uv /uv /uvx /bin/ + +# Browsers first, dependencies second, sources last: every release rewrites +# pyproject.toml/uv.lock, so anything below the lock copy is rebuilt per +# release while the apt packages and the Chromium/WebKit downloads above it +# are reused. The Playwright pin must equal the locked version so the +# downloaded browser revisions match the venv's driver +# (tests/test_build_scripts.py::TestDockerfile). The installer runs from an +# ephemeral uvx tool environment, so the image carries one Playwright: the +# venv's. Browsers live in a shared path the runtime honors as-is +# (ouroboros/tools/browser.py) — not inside the package tree, which the +# per-release dependency layer would rebuild. +ARG PLAYWRIGHT_VERSION=1.62.0 +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ UV_LINK_MODE=copy \ - UV_COMPILE_BYTECODE=1 \ - UV_PROJECT_ENVIRONMENT=/opt/venv \ - PATH="/opt/venv/bin:$PATH" + UV_COMPILE_BYTECODE=1 + +# System dependencies: git for the agent's own history and updates, plus +# every Chromium/WebKit native library from Playwright's authoritative list. +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update \ + && apt-get install -y --no-install-recommends git \ + && uvx --from "playwright==${PLAYWRIGHT_VERSION}" playwright install-deps chromium webkit \ + && uvx --from "playwright==${PLAYWRIGHT_VERSION}" playwright install chromium webkit + +# Working directory +ENV APP_HOME=/app \ + PATH="/app/.venv/bin:$PATH" WORKDIR ${APP_HOME} -# Install locked project dependencies separately so source edits reuse this layer. +# Resolve only from the reviewed lock; the project itself is installed after +# the source copy so source edits reuse this layer. COPY pyproject.toml uv.lock ./ RUN --mount=type=cache,target=/root/.cache/uv \ uv sync --locked --no-dev --extra browser --no-install-project diff --git a/README.md b/README.md index e988b4a06..39957876b 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,6 @@ uv export --locked --no-dev --extra browser --no-emit-project --no-hashes --no-a ### Docker ```bash -docker build -f docker/Dockerfile.base -t ouroboros-base:local . docker build -t ouroboros-web . docker run --rm -p 8765:8765 \ -e OUROBOROS_NETWORK_PASSWORD='choose-a-password' \ @@ -355,10 +354,9 @@ docker run --rm -p 8765:8765 \ ouroboros-web ``` -The base image contains only runtime prerequisites: Playwright, Chromium/WebKit, -their system libraries, Git, and trusted certificates from `docker/certs`. -The application image owns the project environment and locked dependencies. Docker runs -the web runtime, not the native desktop shell; use [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) for network and container policy. +Docker runs the web runtime, not the native desktop shell. The image bundles Chromium and WebKit for the locked +Playwright version and needs Docker's default BuildKit builder; use [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) for network +and container policy, including how to trust an extra CA (`OUROBOROS_EXTRA_CA_BUNDLE`). ### Release tag prerequisite diff --git a/docker/Dockerfile.base b/docker/Dockerfile.base deleted file mode 100644 index b2938ebab..000000000 --- a/docker/Dockerfile.base +++ /dev/null @@ -1,28 +0,0 @@ -# syntax=docker/dockerfile:1 - -# Ouroboros runtime-dependency base. Rebuild when this file or docker/certs changes: -# docker build -f docker/Dockerfile.base -t ouroboros-base:local . - -ARG UV_IMAGE=ghcr.io/astral-sh/uv:0.12.1 -ARG PYTHON_IMAGE=python:3.10-slim - -FROM ${UV_IMAGE} AS uv -FROM ${PYTHON_IMAGE} - -ARG DEBIAN_FRONTEND=noninteractive -ARG PLAYWRIGHT_VERSION=1.62.0 - -COPY --from=uv /uv /uvx /bin/ -COPY docker/certs/ /usr/local/share/ca-certificates/ -ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \ - UV_SYSTEM_CERTS=true - -RUN --mount=type=cache,target=/root/.cache/uv \ - --mount=type=cache,target=/var/cache/apt,sharing=locked \ - --mount=type=cache,target=/var/lib/apt,sharing=locked \ - apt-get update \ - && apt-get install -y --no-install-recommends ca-certificates git \ - && update-ca-certificates \ - && uv pip install --system "playwright==${PLAYWRIGHT_VERSION}" \ - && python3 -m playwright install-deps chromium webkit \ - && python3 -m playwright install chromium webkit diff --git a/docker/Dockerfile.base.dockerignore b/docker/Dockerfile.base.dockerignore deleted file mode 100644 index c6f195d5b..000000000 --- a/docker/Dockerfile.base.dockerignore +++ /dev/null @@ -1,4 +0,0 @@ -** -!docker/ -!docker/certs/ -!docker/certs/*.crt diff --git a/docker/certs/russian-trusted-root-ca.crt b/docker/certs/russian-trusted-root-ca.crt deleted file mode 100644 index fe7ad3075..000000000 --- a/docker/certs/russian-trusted-root-ca.crt +++ /dev/null @@ -1,33 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx -PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu -ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg -Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS -VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg -YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v -dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n -qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q -XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U -zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX -YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y -Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD -U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD -4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9 -G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH -BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX -ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa -OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf -BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS -BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF -AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH -tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq -W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+ -/3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS -AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj -C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV -4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d -WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ -D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC -EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq -391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4= ------END CERTIFICATE----- diff --git a/docker/certs/russian-trusted-sub-ca.crt b/docker/certs/russian-trusted-sub-ca.crt deleted file mode 100644 index 506e76754..000000000 --- a/docker/certs/russian-trusted-sub-ca.crt +++ /dev/null @@ -1,41 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIHQjCCBSqgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx -PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu -ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg -Q0EwHhcNMjIwMzAyMTEyNTE5WhcNMjcwMzA2MTEyNTE5WjBvMQswCQYDVQQGEwJS -VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg -YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi -IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9YPqBKOk19NFymrE -wehzrhBEgT2atLezpduB24mQ7CiOa/HVpFCDRZzdxqlh8drku408/tTmWzlNH/br -HuQhZ/miWKOf35lpKzjyBd6TPM23uAfJvEOQ2/dnKGGJbsUo1/udKSvxQwVHpVv3 -S80OlluKfhWPDEXQpgyFqIzPoxIQTLZ0deirZwMVHarZ5u8HqHetRuAtmO2ZDGQn -vVOJYAjls+Hiueq7Lj7Oce7CQsTwVZeP+XQx28PAaEZ3y6sQEt6rL06ddpSdoTMp -BnCqTbxW+eWMyjkIn6t9GBtUV45yB1EkHNnj2Ex4GwCiN9T84QQjKSr+8f0psGrZ -vPbCbQAwNFJjisLixnjlGPLKa5vOmNwIh/LAyUW5DjpkCx004LPDuqPpFsKXNKpa -L2Dm6uc0x4Jo5m+gUTVORB6hOSzWnWDj2GWfomLzzyjG81DRGFBpco/O93zecsIN -3SL2Ysjpq1zdoS01CMYxie//9zWvYwzI25/OZigtnpCIrcd2j1Y6dMUFQAzAtHE+ -qsXflSL8HIS+IJEFIQobLlYhHkoE3avgNx5jlu+OLYe0dF0Ykx1PGNjbwqvTX37R -Cn32NMjlotW2QcGEZhDKj+3urZizp5xdTPZitA+aEjZM/Ni71VOdiOP0igbw6asZ -2fxdozZ1TnSSYNYvNATwthNmZysCAwEAAaOCAeUwggHhMBIGA1UdEwEB/wQIMAYB -Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTR4XENCy2BTm6KSo9MI7NM -XqtpCzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzCBxwYIKwYBBQUH -AQEEgbowgbcwOwYIKwYBBQUHMAKGL2h0dHA6Ly9yb3N0ZWxlY29tLnJ1L2NkcC9y -b290Y2Ffc3NsX3JzYTIwMjIuY3J0MDsGCCsGAQUFBzAChi9odHRwOi8vY29tcGFu -eS5ydC5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNydDA7BggrBgEFBQcwAoYv -aHR0cDovL3JlZXN0ci1wa2kucnUvY2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQw -gbAGA1UdHwSBqDCBpTA1oDOgMYYvaHR0cDovL3Jvc3RlbGVjb20ucnUvY2RwL3Jv -b3RjYV9zc2xfcnNhMjAyMi5jcmwwNaAzoDGGL2h0dHA6Ly9jb21wYW55LnJ0LnJ1 -L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMDWgM6Axhi9odHRwOi8vcmVlc3Ry -LXBraS5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF -AAOCAgEARBVzZls79AdiSCpar15dA5Hr/rrT4WbrOfzlpI+xrLeRPrUG6eUWIW4v -Sui1yx3iqGLCjPcKb+HOTwoRMbI6ytP/ndp3TlYua2advYBEhSvjs+4vDZNwXr/D -anbwIWdurZmViQRBDFebpkvnIvru/RpWud/5r624Wp8voZMRtj/cm6aI9LtvBfT9 -cfzhOaexI/99c14dyiuk1+6QhdwKaCRTc1mdfNQmnfWNRbfWhWBlK3h4GGE9JK33 -Gk8ZS8DMrkdAh0xby4xAQ/mSWAfWrBmfzlOqGyoB1U47WTOeqNbWkkoAP2ys94+s -Jg4NTkiDVtXRF6nr6fYi0bSOvOFg0IQrMXO2Y8gyg9ARdPJwKtvWX8VPADCYMiWH -h4n8bZokIrImVKLDQKHY4jCsND2HHdJfnrdL2YJw1qFskNO4cSNmZydw0Wkgjv9k -F+KxqrDKlB8MZu2Hclph6v/CZ0fQ9YuE8/lsHZ0Qc2HyiSMnvjgK5fDc3TD4fa8F -E8gMNurM+kV8PT8LNIM+4Zs+LKEV8nqRWBaxkIVJGekkVKO8xDBOG/aN62AZKHOe -GcyIdu7yNMMRihGVZCYr8rYiJoKiOzDqOkPkLOPdhtVlgnhowzHDxMHND/E2WA5p -ZHuNM/m0TXt2wTTPL7JH2YC0gPz/BvvSzjksgzU5rLbRyUKQkgU= ------END CERTIFICATE----- diff --git a/docs/CHECKLISTS.md b/docs/CHECKLISTS.md index 7487e293a..5d025c336 100644 --- a/docs/CHECKLISTS.md +++ b/docs/CHECKLISTS.md @@ -153,7 +153,7 @@ or Intent/Scope checklists are. | 6 | New tool added? | `get_tools()` exports it, its schema description says WHEN to choose it (each profile receives its visible schema set every round, so the schema is the SSOT of the per-tool contract; `prompts/SYSTEM.md` is the cross-tool selection policy and mentions a tool only when the change alters that policy, never as a catalog entry; mechanism documentation lives in ARCHITECTURE/DEVELOPMENT), the handler signature matches the declared schema, and (if it mutates repo state) it is routed through the reviewed commit path rather than ad-hoc `run_command`. Also add an explicit entry in `ouroboros/safety.py::TOOL_POLICY` (`POLICY_SKIP` for trusted built-ins, `POLICY_CHECK` for opaque or outward-facing ones) — the `test_tool_policy_covers_all_builtin_tools` invariant will fail otherwise, and without an entry the tool falls through to `DEFAULT_POLICY = check` and pays a light-model LLM call per invocation. | | 7 | Tests green before first `commit_reviewed`? | Run `pytest -x` on the narrowest relevant target(s) you can name before the first `preflight_review` / `commit_reviewed` attempt. Size gates no longer block locally: they live in the official-CI-only `size_ratchet` pytest lane (manifest exactness plus the pairwise base-vs-tip shrink-only transition), and local surfaces (`check_worktree_readiness`, `codebase_health`) surface the same `validate_size_ratchet` findings as "official CI will enforce" warnings. When a size warning appears — or a new `.py` file lands under `ouroboros/` or `supervisor/` — run `pytest tests/ -m size_ratchet` and `scripts/regenerate_size_ratchet.py` locally to preview and fix what official CI would reject. A red test suite before the first commit attempt has caused repeated $2-5 blocked-review cycles. | | 8 | Adding a `README.md` version row? | BIBLE.md P9 hard cap: ≤ 2 major, ≤ 5 minor, ≤ 5 patch visible entries. Categories are mutually exclusive: major = `X.0.0` (minor=0, patch=0); minor = `X.Y.0` (patch=0, Y≠0); patch = all other `X.Y.Z` (Z≠0). Count existing rows in the category you are adding to. Easy check: `run_command(["python", "-c", "import sys; from ouroboros.tools.release_sync import check_history_limit; warns=check_history_limit(open('README.md').read()); print(warns or 'OK')"])` — if it prints warnings, trim the oldest row in the over-limit category **in the same edit** before committing. | -| 9 | Changing any of `build.sh`, `build_linux.sh`, `build_windows.ps1`, `Dockerfile`, `docker/Dockerfile.base`, or `ouroboros/tools/browser.py`? | Cross-surface doc sync is mandatory. Check ALL of: `README.md` Install section (Linux native-lib caveat), `README.md` Build section (per-platform instructions), `docs/ARCHITECTURE.md` browser tools paragraph, WebKit/mobile verification notes, and inline comments in the touched build script. Any one of these being stale has blocked review twice. Verify before staging. | +| 9 | Changing any of `build.sh`, `build_linux.sh`, `build_windows.ps1`, `Dockerfile`, or `ouroboros/tools/browser.py`? | Cross-surface doc sync is mandatory. Check ALL of: `README.md` Install section (Linux native-lib caveat), `README.md` Build section (per-platform instructions), `docs/ARCHITECTURE.md` browser tools paragraph, WebKit/mobile verification notes, and inline comments in the touched build script. Any one of these being stale has blocked review twice. Verify before staging. | | 10 | Changing `ouroboros/tools/commit_gate.py`? | Coupled surfaces that MUST be updated atomically in the same commit: (a) `claude_advisory_review.py::get_tools()` tool description for `preflight_review` and `review_status`; (b) `claude_advisory_review.py::_next_step_guidance()` strings; (c) `docs/DEVELOPMENT.md` Review & Commit Protocol section; (d) the `prompts/SYSTEM.md` Self-Modification section IF the commit-gate rule it states changed. Missing any one has blocked review. | | 11 | Changing VERSION + pyproject.toml? | Ordering matters: (1) write `VERSION`, `pyproject.toml`, `uv.lock`, `web/package.json` and `web/modules/api_types.js` first; (2) then write the `README.md` badge + changelog row + download links, both install pages' download links, and the `docs/ARCHITECTURE.md` header; (3) then run `pytest`. Never interleave — updating README before VERSION means `test_version_in_readme` will catch a stale badge. | | 12 | Writing or editing any JS file under `web/modules/`? | New or changed static inline visual properties are blocked: inspect the diff for added/changed `style=""` markup and `.style.` assignments, and use CSS classes/tokens plus `classList`/`hidden` instead. Unchanged legacy hits are debt, not a blocker. A dynamic measured value may update a narrowly named CSS custom property when that is the actual runtime data flow. | diff --git a/docs/architecture/08-git-branching-ci-and-build.md b/docs/architecture/08-git-branching-ci-and-build.md index 90c467b21..a3c437b74 100644 --- a/docs/architecture/08-git-branching-ci-and-build.md +++ b/docs/architecture/08-git-branching-ci-and-build.md @@ -60,4 +60,6 @@ Docker runs the web and server runtime without PyWebView. The image binds `0.0.0 The root `.dockerignore` filters `COPY . .`: secrets, host virtualenvs, `node_modules`, caches and runtime/review/operator state stay out of image layers; `.git`, `tests/` and sources stay in, because CI runs pytest inside the image. +The image is one self-contained `Dockerfile` built with Docker's default BuildKit builder: `python:3.10-slim` with the pinned `uv`, then git plus Playwright's native libraries and the Chromium/WebKit binaries for the Playwright version `uv.lock` pins, installed from an ephemeral `uvx` tool environment into `/ms-playwright` (the `PLAYWRIGHT_BROWSERS_PATH` the runtime honors as-is), then `uv sync --locked` into `/app/.venv`, then the sources. Browsers and apt packages sit above the lock copy because every release rewrites `pyproject.toml`/`uv.lock`, and `RUN --mount=type=cache` keeps the uv and apt caches out of the layers. `tests/test_build_scripts.py::TestDockerfile` pins the version match, the order, the shared browser path and the absence of a runtime `UV_PROJECT_ENVIRONMENT` (a baked one would let an agent's `uv sync` in a task workspace rewrite the runtime venv); the tag-only `docker-ui-smoke` and `docker-portable-test` lanes exercise the shipped browsers without a download. Trust anchors are not baked into the image: a deployment that needs an extra CA sets `OUROBOROS_EXTRA_CA_BUNDLE` (§7; `docs/DEPLOYMENT.md`). + --- diff --git a/tests/test_build_scripts.py b/tests/test_build_scripts.py index 80ac58dcc..7adf63bef 100644 --- a/tests/test_build_scripts.py +++ b/tests/test_build_scripts.py @@ -544,104 +544,60 @@ class TestDockerignore: class TestDockerfile: - """The Docker base must bundle Playwright Chromium/WebKit for child images.""" + """One self-contained Dockerfile ships Chromium/WebKit for the locked Playwright. - def test_application_image_uses_dependency_base(self): + The pins below are the contract the tag-only ``docker-ui-smoke`` and + ``docker-portable-test`` lanes rely on: browsers land in the shared + ``/ms-playwright`` the runtime honors as-is, they are downloaded ABOVE the + lock copy (every release rewrites ``pyproject.toml``/``uv.lock``), and CI + exercises the image's own browsers instead of re-downloading them. + """ + + def test_build_is_self_contained(self): + """``docker build -t ouroboros-web .`` must not depend on a locally built base tag.""" src = _read("Dockerfile") - assert "ARG OUROBOROS_BASE_IMAGE=ouroboros-base:local" in src - assert "FROM ${OUROBOROS_BASE_IMAGE}" in src + assert "FROM python:3.10-slim" in src + assert "FROM ${" not in src, "the image must not start from an unpublished local tag" - def test_application_owns_project_environment(self): - app = _read("Dockerfile") - base = _read("docker/Dockerfile.base") - assert "UV_PROJECT_ENVIRONMENT=/opt/venv" in app - assert 'PATH="/opt/venv/bin:$PATH"' in app - assert "uv sync --locked --no-dev --extra browser --no-install-project" in app - assert "UV_PROJECT_ENVIRONMENT" not in base - assert "uv sync" not in base - - def test_base_installs_project_certificates(self): - src = _read("docker/Dockerfile.base") - assert "COPY docker/certs/ /usr/local/share/ca-certificates/" in src - assert "update-ca-certificates" in src - - def test_playwright_install_chromium_present(self): - src = _read("docker/Dockerfile.base") - assert "playwright install chromium webkit" in src, ( - "Dockerfile must call 'playwright install chromium webkit' to bundle the browsers" - ) - - def test_playwright_uses_shared_browser_path(self): - src = _read("docker/Dockerfile.base") - assert "PLAYWRIGHT_BROWSERS_PATH=/ms-playwright" in src - - def test_base_playwright_version_matches_lock(self): - src = _read("docker/Dockerfile.base") + def test_playwright_pin_matches_lock(self): + src = _read("Dockerfile") match = re.search(r'\[\[package\]\]\nname = "playwright"\nversion = "([^"]+)"', _read("uv.lock")) - assert match is not None - assert f"PLAYWRIGHT_VERSION={match.group(1)}" in src - - def test_playwright_install_deps_present(self): - """Dockerfile must use 'playwright install-deps chromium webkit' (the authoritative - Playwright dependency resolver) rather than a hand-curated apt library list. - This ensures all runtime native libs required by Chromium/WebKit are present.""" - src = _read("docker/Dockerfile.base") - assert "playwright install-deps chromium webkit" in src, ( - "Dockerfile must call 'playwright install-deps chromium webkit' to install all " - "native system libraries required by Chromium/WebKit via Playwright's authoritative " - "dependency resolver" + assert match is not None, "uv.lock must lock playwright" + assert f"PLAYWRIGHT_VERSION={match.group(1)}" in src, ( + "Dockerfile must pin the browser installer to the locked Playwright version" ) - def test_install_deps_before_install_chromium(self): - """Native system dependencies must be installed BEFORE the Chromium binary - is downloaded, so the binary can find its runtime libraries on first launch.""" - src = _read("docker/Dockerfile.base") + def test_shared_browser_path_is_the_image_environment(self): + src = _read("Dockerfile") + assert "PLAYWRIGHT_BROWSERS_PATH=/ms-playwright" in src + assert "PLAYWRIGHT_BROWSERS_PATH=0" not in src, ( + "browsers must not live in the package tree: the dependency layer is rebuilt on every release" + ) + + def test_browsers_install_before_the_lock_copy(self): + src = _read("Dockerfile") deps_pos = src.find("playwright install-deps chromium webkit") - src.find("playwright install chromium webkit") - # binary_pos must not match the install-deps line itself - # find the standalone 'playwright install chromium webkit' (not install-deps) - import re as _re - binary_match = _re.search(r"(? 20800 (PR #1255; measured 20768): the Docker subsection maps the new root # .dockerignore (what it keeps out of image layers and why .git/tests/ must stay in), # a config BIBLE P6 requires on the map. - "docs/architecture/08-git-branching-ci-and-build.md": 20800, + # 20800 -> 22000 (PR #1300; measured 21921): the Docker subsection maps the single-Dockerfile layout + # (browsers above the lock copy, the shared browser path, cache mounts, the CI lanes that exercise + # them) and points at the extra-CA setting; the base sat 16 bytes under the previous budget. + "docs/architecture/08-git-branching-ci-and-build.md": 22000, # 12405 -> 14400 (issue #1142): the ordinary-close paragraph gains the mechanism the chapter had # no text for — graceful stop signals the server PID only, the server half (stop event at the # signal, bounded uvicorn drain) is self-sufficient against an old group-SIGTERM launcher. diff --git a/tests/test_windows_zip_path_length.py b/tests/test_windows_zip_path_length.py index 3b5294965..d1c9f23e1 100644 --- a/tests/test_windows_zip_path_length.py +++ b/tests/test_windows_zip_path_length.py @@ -9,17 +9,31 @@ import pytest pytestmark = pytest.mark.portable_detail -def test_bundled_playwright_headless_shell_paths_stay_short(): +def _bundled_browsers_root() -> pathlib.Path: + """Where the shipped artifact keeps its Playwright browsers. + + Packaged builds install into the Playwright package tree + (``PLAYWRIGHT_BROWSERS_PATH=0``); the Docker image hands the runtime a + shared directory through the same variable. Either way the test measures + the artifact as shipped, never a download it made itself. + """ + configured = os.environ.get("PLAYWRIGHT_BROWSERS_PATH", "") + if configured and configured != "0": + return pathlib.Path(configured) playwright = pytest.importorskip("playwright", reason="Playwright is not installed") - root = pathlib.Path(playwright.__file__).parent / "driver" / "package" / ".local-browsers" + return pathlib.Path(playwright.__file__).parent / "driver" / "package" / ".local-browsers" + + +def test_bundled_playwright_headless_shell_paths_stay_short(): + root = _bundled_browsers_root() if not root.is_dir(): if os.environ.get("OUROBOROS_EXPECT_HEADLESS_SHELL") == "1": - pytest.fail("Expected Playwright local browser bundle in this CI lane") + pytest.fail(f"Expected Playwright browser bundle at {root} in this CI lane") pytest.skip("Playwright local browser bundle not present") shells = sorted(root.glob("chromium_headless_shell-*")) if not shells: if os.environ.get("OUROBOROS_EXPECT_HEADLESS_SHELL") == "1": - pytest.fail("Expected Playwright headless-shell bundle in this CI lane") + pytest.fail(f"Expected Playwright headless-shell bundle under {root} in this CI lane") pytest.skip("Playwright headless-shell bundle not present") too_long = [] for shell in shells: