Twelve documents under docs/onboarding/ covering orientation, local setup, the repo map, per-domain deep dives, the cross-service contracts, CI and the release flow, infra and secrets, observability, task recipes, and a list of which docs in this tree are currently stale. Every command in the setup guide was run against this checkout: the backend suite (429 passed, 8 skipped), the frontend Go suite, a venv boot of the backend, and a build+boot of the Go frontend. Two findings recorded along the way: - static/units.js's F_REGIONS is guarded by no test, despite three source comments claiming "a test asserts all three stay identical". The Go test only cross-checks the Go copy against the backend's Python. All four copies are currently identical. - backend/ and frontend/docker-compose.test.yml still default to the retired emi/thermograph-backend/app image path, and the frontend harness pins the split-era v0.0.2-split-ci tag. Claude-Session: https://claude.ai/code/session_01AfXqHrxCJLs2D7hpQkiUiJ
11 KiB
7. CI and release
The branch model
PR ──(required check: `gate`)──▶ dev ──▶ LAN dev box
│
promotion PR
▼
main ──▶ beta.thermograph.org
│
promotion PR ← the owner's call, always
▼
release ──▶ thermograph.org
dev, main and release are protected — direct pushes are blocked for
everyone. Forgejo has no auto-merge-on-green, so merges are explicit: either
click "Auto merge when checks succeed" on a PR, or merge via the API once CI is
green.
Promotion is continuous and per-decision. There is no release calendar. Size a promotion to whatever has actually soaked on the environment below.
Who may merge what
| Hop | Whose call | Norm |
|---|---|---|
anything → dev |
yours | Merge it. An open PR into dev is unfinished work, not caution. |
dev → main (beta) |
yours, batched | Promote when a batch is coherent enough to be worth testing. Beta is the test environment; not promoting is not testing. |
main → release (prod) |
the owner's | Prepare it, recommend for or against, and ask. Never unilaterally, never by another route. |
Centralis's promote(from, to) implements exactly this, and forge_pr_merge
enforces it with no back door: it merges into dev and main, refuses
main → release under any flag, and requires confirm_protected_base: true
for the separate hotfix path.
Two things about branch divergence
Commit counts are not deliverable content. Before promoting, compare the two
tips' trees. Identical trees mean the same changes are already on the target
under different SHAs — promoting there merges nothing while still firing the
target's deploy workflows, including infra-sync re-rendering
/etc/thermograph.env on prod and beta. The ahead/behind numbers will look like
real work. promote refuses this outright.
Expect dev, main and release to be mutually divergent, and expect
--ff-only to fail. Every promotion merges the source into the target,
creating a merge commit on the target that the source never receives — so each
promotion widens the gap it just closed. That is accumulated design, not drift.
(Reconciling merges back into dev do happen; be4aa94 in the history is one.)
The nine workflows
All in root .forgejo/workflows/. Never add a workflow under a domain's own
.forgejo/ — Forgejo only reads the root ones, so a copy there is inert and
becomes a trap for whoever edits it next.
pr-build.yml — the required check
One workflow, one stable required-check name (gate), any number of
domains:
- a
changesjob diffs the PR against its base (needsfetch-depth: 0— the base commit isn't reachable from a depth-1 clone); - per-domain jobs run only for domains the PR touches;
gatereduces their results into the single status branch protection requires.
Deliberately not path-filtered at the workflow level. A path-filtered required check that never fires leaves a PR stuck on "expected — waiting for status" forever, so auto-merge never proceeds. An always-running gate whose domain jobs skip cleanly gives the same compute savings without the deadlock. This is the one intentionally coupled piece of the CI design.
build.yml — the reusable build check
Called via uses: with a domain input. The build context is that domain's
subdirectory, so each Dockerfile sees exactly the tree it saw when its domain
was a repo root.
For backend it then runs the hermetic pytest suite inside the image it just
built — the exact interpreter and dependencies that ship. Three flags there are
load-bearing: -u 0 (the image's default user can't write to site-packages),
--entrypoint sh (otherwise the test command is swallowed as entrypoint args
and the container just boots the server forever), and unset THERMOGRAPH_BASE
(the image bakes /, which moves every route off the /thermograph prefix the
tests are written against).
For frontend there's no separate test step: it's a Go binary with no
interpreter in the runtime image, and gofmt/vet/go test already run in the
Dockerfile's builder stage — so a failing Go test fails the Build step above.
It is deliberately not a live boot+healthz check. That was tried in the split
repos and repeatedly hit the runner's own environment quirks (bridge-IP
reachability, non-unique $$, squatted host ports) without ever going reliably
green. Image boot is verified by hand instead — make smoke, or docker run +
alembic + /healthz 200 + a real /api/v2/place 200.
build-push.yml — images
Triggers on pushes to dev/main/release touching backend/** or
frontend/**, and on v*.*.* tags. Publishes
git.thermograph.org/emi/thermograph/{backend,frontend}:sha-<12hex>.
- Serialised (
max-parallel: 1): one physical runner, and two whole image builds racing each other buys nothing. - Semver exception: a
v*.*.*tag push carries no paths context, so a release tag builds both images. That's intended — a release names the whole set, not whichever domain moved last. - The registry is mesh-only, so the runner host needs
10.10.0.2 git.thermograph.orgin/etc/hosts.
deploy.yml — one file, two environments, two services
Formerly six near-identical files. Branch selects the environment
(main → beta, release → prod); a matrix covers the services.
The style is deliberately boring: no dynamic fromJSON matrix, no
cond && secrets.A || secrets.B ternary. Those are GitHub idioms a Forgejo/act
runner may evaluate differently, and the failure mode here is "production does
not deploy" or, worse, "deploys with an empty SSH host". So the two environments
get two explicit, mutually exclusive steps and the tag is computed in shell.
Preserved from the originals, all load-bearing:
fetch-depth: 0— the tag is keyed to the last commit that touched that domain, not the branch tip.- 12-hex truncation matching
build-push.ymlexactly. - Per-service, per-environment concurrency with
cancel-in-progress: false— a half-finished deploy must never be cancelled by a newer one. - Separate
PROD_SSH_*credentials, so a beta credential leak cannot reach prod. appleboy/ssh-actionreferenced by full URL; it isn't mirrored in Forgejo's default action registry.
There's also a per-leg refinement: the workflow-level paths filter only says
backend or frontend moved, so each leg re-checks whether this push touched
this domain. Without it a backend-only push would also roll the frontend,
losing the independent-deploy property the whole split exists for. When there's
no usable base SHA (first push, force push, workflow_dispatch) it deploys
rather than skips — rolling onto the tag already running is a no-op, whereas
skipping silently strands a change.
infra-sync.yml — infra's own pipeline
Push to main touching infra/** → SSH to beta and prod, fast-forward
/opt/thermograph, re-render /etc/thermograph.env from the vault.
Rolls no service. Image tags are the app domains' axis, not infra's. A
compose change that must recreate containers takes effect on the next app
deploy, or on a by-hand SERVICE=all … infra/deploy/deploy.sh.
Note the asymmetry: app code is environment-staged (dev→main→release
maps to LAN→beta→prod via image tags); infra is not — both hosts track infra
via main. Prod's app images are staged by release, but its checkout
follows main.
observability-validate.yml
Parses every artifact that ships to a node: both compose files, all dashboard
JSON, the Loki and provisioning YAML, and the Alloy config — checked with the
pinned alloy binary (v1.9.1, matching what the fleet runs), downloaded
from its GitHub release, no Docker CLI needed.
Alerting gets a stricter third step: every rule's condition must name a refId
that exists, every policy must route to a receiver that exists, and a literal
Discord webhook URL in the repo is a hard failure. A rule pointing at a
missing refId is valid YAML, provisions cleanly, and then never fires — that
silent no-op is exactly what this check exists to prevent.
ops-cron.yml — the prod backup
Daily at 03:00 UTC: pg_dump on prod, plus an IndexNow ping. Both SSH into prod
and run inside the already-running stack.
It uses PROD_SSH_*, not SSH_*. An earlier revision reused SSH_* — so
the "prod" backup was silently dumping beta, and prod had no backup at all. If
you touch this file, verify a dump actually lands in
agent@prod:~/thermograph-backups/.
secrets-guard.yml and shell-lint.yml — the backstops
Both run on every PR and push, deliberately un-path-filtered: a backstop that only runs when you expect it to isn't a backstop.
secrets-guardfails if anyinfra/deploy/secrets/*.yamllacks both asops:block andENC[AES256_GCMvalues. ~1 second.shell-lintruns shellcheck over every*.shfound byfind(not a list — so a new script is covered the moment it lands). Pinned to v0.11.0 plus a sha256, installed from the official static release rather thanapt-get: the distro version drifts with the job image, and a drifted shellcheck grows new warnings that fail CI on an unrelated push. Bump the version and the hash together, and expect to fix new findings in the same PR.
Keep the tree at zero shellcheck findings. These scripts run as root over SSH against live hosts with no test suite in front of them; this is the only guard.
Runner facts
- Jobs run on the
dockerlabel — the LAN box's Forgejo Actions runner. - Job containers get the host's docker.sock automounted
(Docker-outside-of-Docker, no privileged mode). The job image
(
node:20-bookworm) ships no docker CLI, which is why several workflowsapt-get install docker.iofirst. - Forgejo's runner has only the labels it was explicitly registered with.
GitHub implicitly tags every self-hosted runner
self-hosted; Forgejo does not. Aruns-on: [self-hosted, thermograph-lan]array is permanently unschedulable there — a real bug that was found and fixed once, and worth recognising if a job ever silently stops appearing in the Actions history.
Opening a PR
git switch -c fix/short-description # branch off dev
# ... work ...
cd backend && make test # or: cd frontend/server && go test ./...
git commit # concise, technical, no AI mentions
git push -u origin fix/short-description
Then open the PR into dev (Centralis's forge_pr_open, or the Forgejo web
UI). Wait for gate. Merge — squash is the configured style.
If your change touches shell, expect shell-lint to have an opinion. If it
touches infra/deploy/secrets/, secrets-guard will too.
Next: Infra and secrets.