All checks were successful
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / validate-observability (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 16s
PR build (required check) / lint-shell (pull_request) Successful in 23s
PR build (required check) / guard-secrets (pull_request) Successful in 26s
PR build (required check) / build-frontend (pull_request) Successful in 1m41s
PR build (required check) / build-backend (pull_request) Successful in 2m13s
PR build (required check) / gate (pull_request) Successful in 2s
Four holes, all of which let untested or unchecked code reach an environment. - PRs into `release` ran no build and no test. release is the branch that deploys to prod, so the intended promotion path was the least-checked path in the repo: the last seven release PRs ran shellcheck and the secrets check, nothing else. pr-build now triggers on it. - shell-lint and secrets-guard reported as standalone statuses outside `gate`, and pr-build's own setup notes tell the operator to require only `gate`. Taken literally that means a commit adding a plaintext SOPS file was mergeable. Both are now called from pr-build and reduced into gate. They are deliberately not domain-gated and not `needs: changes`: they are repo-wide invariants that must hold on every PR, including one touching no app domain -- exactly the case where every domain job skips and gate used to pass vacuously. For the same reason `skipped` counts as a failure for these two, while it stays a pass for the domain jobs it legitimately describes. They keep their standalone pull_request trigger, so they will run twice on a PR until branch protection is confirmed to require only `gate`. Cheap, and it avoids breaking a rule that may still require them by name. - build-push.yml built and pushed with no test step, and deploy.yml consumes a TAG rather than a commit status -- so an image reaching the registry by any route other than a dev/main PR (a direct push, a dispatch, a v*.*.* tag) was never tested by CI, and beta/prod then rolled it. The test now sits between build and push, so the artifact that deploys is the artifact that was tested. Gating the artifact is what makes this work; gating the branch would not. - backend/Dockerfile ran `go build` on daemon/ but never its tests. All 48 of them (gateway, cron, apiclient, config) ran nowhere: `grep -rn "go test" .forgejo/workflows/` found nothing, while the daemon owns the prod Discord gateway websocket and every recurring-job timer. It now runs gofmt + vet + test before the build, which is the pattern frontend/Dockerfile already uses -- and being inside `docker build` it gates build-push too. Verified clean: gofmt reports nothing, vet passes, all four packages pass.
85 lines
4.1 KiB
Docker
85 lines
4.1 KiB
Docker
# Thermograph backend: FastAPI API + accounts/notifications + the SSR content
|
|
# JSON API frontend consumes. Split from the monorepo (repo-split Stage 7).
|
|
|
|
# thermograph-daemon (daemon/): the Go process that owns the Discord gateway
|
|
# websocket and the recurring-job timers, calling back into this app's
|
|
# /internal/* routes for anything that needs data. It is built INTO this image
|
|
# on purpose: daemon and backend share the internal API contract, so shipping
|
|
# one image (compose picks the process per-service) makes version skew between
|
|
# them impossible. CGO_ENABLED=0 gives a fully static binary that drops into
|
|
# the python:3.12-slim final stage with no runtime deps; go.mod/go.sum are
|
|
# copied and downloaded before the sources so the Go dep layer caches across
|
|
# daemon code-only changes, same reasoning as the pip layer below.
|
|
FROM golang:1.26 AS daemon-builder
|
|
WORKDIR /src
|
|
COPY daemon/go.mod daemon/go.sum ./
|
|
RUN go mod download
|
|
COPY daemon/ ./
|
|
# gofmt + vet + the full daemon test suite BEFORE the build, so no published
|
|
# image can contain a daemon that failed its own tests — the same guarantee
|
|
# frontend/Dockerfile already gives the Go frontend, and the reason the tests
|
|
# live here rather than in a workflow step: build-push.yml builds this image on
|
|
# every push to dev/main/release with no test job of its own, so a check that is
|
|
# not part of `docker build` does not gate the artifact that actually deploys.
|
|
#
|
|
# These 48 tests (gateway, cron, apiclient, config) previously ran nowhere:
|
|
# `grep -rn "go test" .forgejo/workflows/` found nothing. The daemon owns the
|
|
# prod Discord gateway websocket and every recurring-job timer.
|
|
RUN test -z "$(gofmt -l .)" && go vet ./... && go test ./...
|
|
# -trimpath keeps the build reproducible (identical sources -> identical
|
|
# binary), which is what lets the final stage's COPY layer cache-hit when only
|
|
# Python code changed.
|
|
RUN CGO_ENABLED=0 go build -trimpath -o /out/thermograph-daemon .
|
|
|
|
FROM python:3.12-slim
|
|
|
|
# curl is only for the container HEALTHCHECK below. Everything Python needs
|
|
# ships as manylinux wheels (asyncpg, psycopg[binary], polars, numpy,
|
|
# cryptography via pywebpush, PyNaCl), so no compiler/build toolchain is
|
|
# required.
|
|
RUN apt-get update \
|
|
&& apt-get install -y --no-install-recommends curl \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
# Install deps first so this layer caches across code-only changes.
|
|
COPY requirements.txt /tmp/requirements.txt
|
|
RUN pip install --no-cache-dir -r /tmp/requirements.txt
|
|
|
|
# The daemon binary lands before the app tree: it changes far less often than
|
|
# the Python code, so this layer usually cache-hits and only the COPY below
|
|
# rebuilds. NOT the entrypoint — deploy/entrypoint.sh stays that; compose
|
|
# selects this binary per-service for the daemon container.
|
|
COPY --from=daemon-builder /out/thermograph-daemon /usr/local/bin/thermograph-daemon
|
|
|
|
COPY . /app/
|
|
RUN chmod +x /app/deploy/entrypoint.sh
|
|
|
|
# Non-root runtime user. Create the writable state dirs and own the whole tree
|
|
# so the parquet cache, logs, notifier.lock, homepage.json, vapid.json can be
|
|
# written. When the named volumes first mount over /app/data and /app/logs,
|
|
# Docker seeds them from this image content -- including this ownership --
|
|
# so they stay writable.
|
|
RUN useradd --system --create-home --uid 10001 thermograph \
|
|
&& mkdir -p /app/data /app/logs /state \
|
|
&& chown -R thermograph:thermograph /app /state
|
|
|
|
USER thermograph
|
|
# Bake DuckDB's httpfs + iceberg extensions AS THE RUNTIME USER — extensions
|
|
# install to the invoking user's ~/.duckdb, and a root-time bake leaves uid
|
|
# 10001 unable to LOAD them (seen live: prod lake /query 500'd on exactly
|
|
# this). iceberg powers the era5_daily view via table metadata instead of a
|
|
# bucket-wide glob LIST.
|
|
RUN python -c "import duckdb; c = duckdb.connect(); c.execute('INSTALL httpfs'); c.execute('INSTALL iceberg')"
|
|
WORKDIR /app
|
|
|
|
ENV PORT=8137 \
|
|
WORKERS=4 \
|
|
THERMOGRAPH_BASE=/ \
|
|
PYTHONUNBUFFERED=1
|
|
|
|
EXPOSE 8137
|
|
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
|
|
CMD curl -fsS http://127.0.0.1:${PORT}/healthz || exit 1
|
|
|
|
ENTRYPOINT ["/app/deploy/entrypoint.sh"]
|