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.
2026-07-21 22:59:29 +00:00
|
|
|
# Thermograph frontend: server-rendered content pages, the interactive tool's
|
|
|
|
|
# SPA shells, and every static asset. Split from the monorepo (repo-split
|
2026-07-25 04:13:47 +00:00
|
|
|
# Stage 7), rewritten as a Go service (server/). No migrations, no DB, no
|
|
|
|
|
# pre-boot logic -- a plain exec-form CMD is enough (unlike backend, no
|
|
|
|
|
# separate entrypoint script needed).
|
|
|
|
|
#
|
|
|
|
|
# Multi-stage: the golang builder runs vet + the full Go test suite before
|
|
|
|
|
# building, so every published image provably passed the hermetic tier with
|
|
|
|
|
# the exact toolchain that compiled the shipping binary (this replaces the
|
|
|
|
|
# old in-image pytest step in .forgejo/workflows/build.yml -- the runtime
|
|
|
|
|
# image carries no toolchain to test with). The final stage is Alpine, not
|
|
|
|
|
# distroless: the Swarm stack (infra/deploy/stack/thermograph-stack.yml)
|
|
|
|
|
# bind-mounts a bash entrypoint shim (env-entrypoint.sh) over this image's
|
|
|
|
|
# entrypoint, so bash must exist inside the container; curl serves the
|
|
|
|
|
# HEALTHCHECK, same line as ever.
|
|
|
|
|
FROM golang:1.26 AS builder
|
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.
2026-07-21 22:59:29 +00:00
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
WORKDIR /src
|
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.
2026-07-21 22:59:29 +00:00
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
# Module graph first so the download layer caches across source-only changes.
|
|
|
|
|
COPY server/go.mod server/go.sum ./
|
|
|
|
|
RUN go mod download
|
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.
2026-07-21 22:59:29 +00:00
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
COPY server/ ./
|
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.
2026-07-21 22:59:29 +00:00
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
# internal/content's tests read two directories the same three-levels-up
|
|
|
|
|
# relative path away from the test file's own package dir (go test always
|
|
|
|
|
# runs with cwd set there): the committed golden fixtures (frontend/tests/
|
|
|
|
|
# fixtures/*.json — the same set the Python golden-diff comparison used) and
|
|
|
|
|
# the SSR copy (frontend/content/*.yaml, content_loader.go's LoadGlossary
|
|
|
|
|
# etc.). This stage only copies server/ into /src (so /src has no "frontend/"
|
|
|
|
|
# parent to climb to), which is why both land at container-root paths here
|
|
|
|
|
# instead — same three-levels-up relationship the tests' relative paths
|
|
|
|
|
# expect, just anchored differently.
|
|
|
|
|
COPY tests/fixtures /tests/fixtures
|
|
|
|
|
COPY content /content
|
|
|
|
|
|
frontend: fix the three inconsistencies the onboarding guide found
1. CLAUDE.md and README.md described the superseded Python service. Both now
describe server/ (Go), say plainly that the Python files at that level are
the original the port was made from, and drop CLAUDE.md's claim that
`make test-unit` is "the tier CI runs" — CI's only frontend check is the
Dockerfile builder stage's gofmt + vet + go test.
2. static/units.js's F_REGIONS was guarded by nothing, despite three source
comments claiming "a test asserts all three stay identical": the only check
compared the Go set against the backend's Python. TestFCountriesMatchesUnitsJS
now diffs the browser copy both directions.
That backend cross-check also skips in CI — the image build context is
frontend/, so backend/ is unreachable from the builder stage, which is the
only place CI runs these tests. static/ IS in the context, so the Dockerfile
copies units.js into the builder and the new assertion runs during the image
build. Verified by mutating units.js and confirming the build fails.
3. Both docker-compose.test.yml files defaulted to the retired
emi/thermograph-backend/app path, and the frontend harness pinned the
split-era v0.0.2-split-ci tag. Path corrected in both. Rather than swap one
hardcoded pin for another, backend-for-tests.sh now derives the tag from the
checkout — sha-<12hex of `git log -1 -- backend/`>, the same domain-keyed
rule build-push.yml and deploy.yml use — and compose requires the variable
so a stale pin cannot creep back in.
Verified: backend 429 passed/8 skipped; frontend go vet clean and all packages
ok; frontend image builds; `make backend-up` pulls and serves on the derived
tag; shellcheck zero findings across the tree.
Unrelated pre-existing issue noted in the docs, not fixed here:
`make test-integration` fails 7/16 with 503 against a cold throwaway backend
(empty database, nothing warm). Reproduced identically on the old image, so it
predates this change.
Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
2026-07-25 18:42:16 +00:00
|
|
|
# static/units.js is copied for ONE test: internal/format's
|
|
|
|
|
# TestFCountriesMatchesUnitsJS, which asserts the browser's F_REGIONS still
|
|
|
|
|
# matches the Go/backend Fahrenheit country set. The builder stage is the only
|
|
|
|
|
# place CI ever executes these tests, so without this the check would skip in CI
|
|
|
|
|
# and only ever run on a developer's checkout. Same three-levels-up relationship
|
|
|
|
|
# the test expects (/src/internal/format -> /static/units.js), anchored the same
|
|
|
|
|
# way the two directories above are.
|
|
|
|
|
#
|
|
|
|
|
# The sibling backend cross-check cannot be wired up this way: the build context
|
|
|
|
|
# is frontend/, so backend/ is structurally unreachable and that test stays a
|
|
|
|
|
# checkout-only guard.
|
|
|
|
|
COPY static/units.js /static/units.js
|
|
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./...
|
|
|
|
|
|
|
|
|
|
# Static binary: CGO off (no libc dependency on Alpine), -trimpath for
|
|
|
|
|
# reproducible paths, -s -w to strip debug info the container never uses.
|
|
|
|
|
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
|
|
|
|
|
-o /out/thermograph-frontend .
|
|
|
|
|
|
|
|
|
|
FROM alpine:3.22
|
|
|
|
|
|
|
|
|
|
# bash: required by the Swarm stack's env-entrypoint.sh shim (see above).
|
|
|
|
|
# curl: the HEALTHCHECK below (pulls in ca-certificates as a dependency).
|
|
|
|
|
RUN apk add --no-cache bash curl
|
|
|
|
|
|
|
|
|
|
# Same uid as the Python image: 10001 is the uid infra provisions readable
|
|
|
|
|
# secrets for (deploy-stack.sh installs /etc/thermograph/stack.env
|
|
|
|
|
# uid-10001-readable) -- do not change it. Explicit group (Alpine's `adduser
|
|
|
|
|
# -S` with no -G falls back to an existing system group, not a same-named
|
|
|
|
|
# one -- a bare `--chown=thermograph` below then has no "thermograph" group
|
|
|
|
|
# to resolve, which the classic (non-BuildKit) builder rejects outright).
|
|
|
|
|
RUN addgroup -S -g 10001 thermograph \
|
|
|
|
|
&& adduser -S -u 10001 -G thermograph -h /home/thermograph thermograph
|
|
|
|
|
|
|
|
|
|
COPY --from=builder /out/thermograph-frontend /usr/local/bin/thermograph-frontend
|
|
|
|
|
|
|
|
|
|
# The binary embeds its HTML templates (server/internal/render); static/ and
|
|
|
|
|
# content/ stay on disk, resolved relative to the working directory (see
|
|
|
|
|
# server/internal/config: StaticDir="static", ContentDir="content"), so /app
|
|
|
|
|
# mirrors the repo layout the config expects. Read-only at runtime -- the
|
|
|
|
|
# service is stateless and holds no data of its own.
|
|
|
|
|
#
|
|
|
|
|
# Numeric --chown, not the name: needs no /etc/passwd|group lookup at COPY
|
|
|
|
|
# time, so it works identically under BuildKit and the classic builder (the
|
|
|
|
|
# CI runner installs plain `docker.io`, no buildx plugin, so a build there
|
|
|
|
|
# silently uses the classic builder unless BuildKit is forced).
|
|
|
|
|
COPY --chown=10001:10001 static/ /app/static/
|
|
|
|
|
COPY --chown=10001:10001 content/ /app/content/
|
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.
2026-07-21 22:59:29 +00:00
|
|
|
|
|
|
|
|
USER thermograph
|
|
|
|
|
WORKDIR /app
|
|
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
# No WORKERS knob anymore: uvicorn needed a process count, the Go server
|
|
|
|
|
# handles concurrency in one process. (The stack/compose files never set it
|
|
|
|
|
# for frontend, so nothing references it.)
|
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.
2026-07-21 22:59:29 +00:00
|
|
|
ENV PORT=8080 \
|
2026-07-25 04:13:47 +00:00
|
|
|
THERMOGRAPH_BASE=/
|
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.
2026-07-21 22:59:29 +00:00
|
|
|
|
|
|
|
|
EXPOSE 8080
|
|
|
|
|
|
|
|
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
|
|
|
|
CMD curl -fsS http://127.0.0.1:${PORT}/healthz || exit 1
|
|
|
|
|
|
2026-07-25 04:13:47 +00:00
|
|
|
# Exec form, no shell wrapper: the Swarm shim receives this CMD as $@ and
|
|
|
|
|
# execs the binary directly; PID 1 gets SIGTERM and shuts down gracefully.
|
|
|
|
|
CMD ["/usr/local/bin/thermograph-frontend"]
|