From f97df715d1091c03ad28c84a87ea4e8b24306967 Mon Sep 17 00:00:00 2001 From: emi Date: Tue, 21 Jul 2026 15:59:29 -0700 Subject: [PATCH] Standalone frontend: no-walk paths.py, own Dockerfile, own CI paths.py drops the sibling-directory walk (frontend is now the repo root: static/ and content/ are its own subdirectories, no more sibling walk needed). New Dockerfile (much smaller deps -- fastapi, uvicorn, httpx, jinja2, PyYAML -- no entrypoint script needed at all, frontend has no migrations/pre-boot logic). content/ is a committed starter copy for now, not yet real cross-repo vendoring from thermograph-copy (that repo doesn't exist yet) -- noted in paths.py's own docstring. Real, known gap flagged rather than silently skipped: this process cannot boot standalone (content.register() fetches the IndexNow key from backend at import time with no retry, by design), so build.yml only verifies the image builds, not a live boot+healthz check -- that needs a genuine cross-repo contract-test job (booting a real backend too), separate follow-up work. Same reason tests/ doesn't run here yet (its conftest.py still imports backend's own test fixtures via a sibling path that no longer exists) -- noted directly in the file. --- .forgejo/workflows/build.yml | 34 ++++++++++++++++++++++++++++++++++ .gitignore | 5 +++++ Dockerfile | 31 +++++++++++++++++++++++++++++++ paths.py | 36 ++++++++---------------------------- requirements.txt | 5 +++++ tests/conftest.py | 25 +++++++++++++++++-------- 6 files changed, 100 insertions(+), 36 deletions(-) create mode 100644 .forgejo/workflows/build.yml create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 requirements.txt diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..b6ce42a --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -0,0 +1,34 @@ +name: Build check + +# Proves the split Dockerfile actually builds (repo-split Stage 7b) -- NOT a +# live boot+healthz check. content.register() fetches the IndexNow key from +# backend at import time with no retry (documented Stage 4 behavior, +# unchanged here); without a real, reachable backend this process crashes +# immediately on import, by design -- so a standalone boot check would either +# be fake (skip the real import path) or require checking out and booting +# thermograph-backend too. That's the real fix (a genuine cross-repo +# contract-test job, same pattern the original repo-split plan already +# scoped for the /cell + ETag contract), not yet built -- flagged as a real +# gap, not silently skipped. Full pytest-suite migration is separate, +# deferred follow-up work too, same category as thermograph-copy's deferred +# vendoring. + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + build: + runs-on: docker + steps: + - uses: actions/checkout@v4 + + - name: Install Docker CLI + run: | + apt-get update -qq + apt-get install -y -qq docker.io + + - name: Build + run: docker build -t thermograph-frontend:ci . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..880c44b --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7a26418 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Thermograph frontend: server-rendered content pages, the interactive tool's +# SPA shells, and every static asset. Split from the monorepo (repo-split +# Stage 7). No migrations, no DB, no pre-boot logic -- a plain shell-form CMD +# is enough (unlike backend, no separate entrypoint script needed). +FROM python:3.12-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /tmp/requirements.txt +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +COPY . /app/ + +RUN useradd --system --create-home --uid 10001 thermograph \ + && chown -R thermograph:thermograph /app + +USER thermograph +WORKDIR /app + +ENV PORT=8080 \ + THERMOGRAPH_BASE=/ \ + PYTHONUNBUFFERED=1 + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD curl -fsS http://127.0.0.1:${PORT}/healthz || exit 1 + +CMD uvicorn app:app --host 0.0.0.0 --port ${PORT} diff --git a/paths.py b/paths.py index 547edfe..28adb19 100644 --- a/paths.py +++ b/paths.py @@ -1,32 +1,12 @@ -"""Canonical filesystem locations for the SSR service, resolved from the repo -root. Stage-3-transitional: this package lives at `frontend_ssr/`, a sibling -of the static asset tree `frontend/` (not nested inside it -- nesting would -let StaticFiles serve this package's own source/templates as downloadable -files once app.py mounts frontend/). Still walks up to find `content/` as a -sibling too, same as backend/paths.py does today, since both still live in -one repo. Once frontend is its own repo (Stage 7), content/ is vendored at -build time instead and this goes back to a no-walk resolution, same as -backend's. +"""Canonical filesystem locations for the SSR service, resolved once from the +repo root (repo-split Stage 7). content/ is committed here as a starter copy +extracted alongside frontend/frontend_ssr -- real cross-repo vendoring from +thermograph-copy (a build-time clone at a pinned ref) is deferred follow-up +work, not yet wired up. """ import os -_HERE = os.path.dirname(os.path.abspath(__file__)) - - -def _repo_root() -> str: - d = _HERE - while True: - if os.path.isdir(os.path.join(d, "frontend")) and os.path.isdir(os.path.join(d, "content")): - return d - parent = os.path.dirname(d) - if parent == d: - raise RuntimeError( - "cannot locate the repo root (no ancestor has sibling frontend/ and content/)" - ) - d = parent - - -REPO_DIR = _repo_root() -STATIC_DIR = os.path.join(REPO_DIR, "frontend") -TEMPLATES_DIR = os.path.join(_HERE, "templates") +REPO_DIR = os.path.dirname(os.path.abspath(__file__)) +STATIC_DIR = os.path.join(REPO_DIR, "static") +TEMPLATES_DIR = os.path.join(REPO_DIR, "templates") CONTENT_DIR = os.path.join(REPO_DIR, "content") diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d6bd3ab --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +httpx==0.28.1 +jinja2==3.1.6 +PyYAML==6.0.3 diff --git a/tests/conftest.py b/tests/conftest.py index 21319ea..fed601e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,22 @@ """Shared test setup for the SSR service. -Stage-3-transitional: this package still lives in the same repo as the -backend, so a `client` fixture can fake api_client by calling the REAL -backend payload builders (api.content_payloads) against the same synthetic -history/recent fixtures backend/tests/conftest.py uses, instead of hand- -maintaining a parallel set of literal JSON fixtures. This means a shape drift -between content_payloads.py and how content.py consumes it fails here, not -just in backend/tests/web/test_content.py -- and the two suites assert on -identical numbers, so they can never quietly diverge. +KNOWN GAP (repo-split Stage 7b): this suite does NOT run standalone in this +repo. It still assumes the layout from before the split -- a sibling +`backend/` checkout with its own `tests/conftest.py` (`make_history`/ +`make_recent`) and `api.content_payloads` module, which this repo no longer +has. Extracted verbatim as reference material for the real fix (a genuine +cross-repo contract-test job, or a self-contained set of fixtures owned +here), not yet built -- see this repo's `.forgejo/workflows/build.yml` for +the same gap on the boot-check side. Do not assume `pytest tests` passes +here until that follow-up lands. + +Original docstring, still accurate for what the suite is trying to prove: +this package used to live in the same repo as the backend, so a `client` +fixture could fake api_client by calling the REAL backend payload builders +(api.content_payloads) against the same synthetic history/recent fixtures +backend/tests/conftest.py uses, instead of hand-maintaining a parallel set of +literal JSON fixtures -- so a shape drift between content_payloads.py and how +content.py consumes it failed here too, not just in the backend's own suite. """ import importlib.util import os