The six deploy workflows were one file written six times, differing only in a branch name, a paths filter, a concurrency group, the service name, its *_IMAGE_TAG variable and a secret prefix. The two build-push workflows were the same file twice, differing only in the domain string. The contract into infra/deploy/deploy.sh (SERVICE + BACKEND_IMAGE_TAG/FRONTEND_IMAGE_TAG) was already fully parameterised, so the duplication bought nothing and cost eight files to keep in step. deploy.yml: branch selects the environment (main -> beta, release -> prod), a matrix covers backend and frontend, and each leg decides whether this push actually touched its domain before rolling anything. The workflow-level paths filter only says backend OR frontend moved; without the per-leg refinement a backend-only push would also roll the frontend and lose the independent-deploy property the FE/BE split exists for. build-push.yml: the same shape for images. Images stay separate and independently deployable; nothing about the published artefacts changes. The two *-deploy-dev.yml workflows are deleted rather than ported. They were already documented as inert -- they call a monorepo path on the LAN box whose ~/thermograph-dev is still a split-era thermograph-infra checkout. LAN dev is a local `make dev-up` concern, not a CI environment. Deliberately boring expressions throughout. No dynamic matrix (fromJSON), and the *_IMAGE_TAG selection is done in shell rather than with a `matrix.service == 'x' && a || b` ternary. Those are GitHub idioms a Forgejo/act runner may evaluate differently, and the failure mode is silent: an empty *_IMAGE_TAG makes deploy.sh fall back to the tag already running, so the job goes green having deployed nothing. Beta and prod get two explicit, mutually exclusive steps rather than a ternary over secrets, where an empty host would be worse still. Everything load-bearing is preserved: fetch-depth 0 and the domain-keyed 12-hex tag (the branch tip is often another domain's commit), per-service per-ref concurrency with cancel-in-progress false, separate PROD_SSH_* credentials, the v*.*.* both-images exception, and appleboy/ssh-action by full URL. pr-build.yml is untouched. Its workflow name and `gate` job are the required status check branch protection is configured against, and renaming that context makes every PR unmergeable with fully green CI. secrets-guard.yml and shell-lint.yml are likewise left alone: they are the only other consolidation candidates that run on pull_request, and the branch-protection API needs credentials this could not read, so merging them was not worth the risk for two files. Logic verified by replaying the plan step against real history: an infra-only push rolls nothing, a backend-only push rolls backend and leaves frontend alone, and a run with no usable before-sha defaults to deploying rather than silently skipping. The computed frontend tag for a backend-only push is sha-ca84e0ce95f0 -- exactly the tag live on beta -- so the derivation matches what the old workflows produced. 15 workflows -> 9, 1365 lines -> 1019. Docs naming the deleted files are updated in the same commit, per the rule the root CLAUDE.md now carries.
4.4 KiB
thermograph monorepo — agent instructions
One repo, four domains: backend/, frontend/, infra/, observability/.
Reunified 2026-07-22 from four split repos, with history, via subtree merges.
thermograph-docs is deliberately a separate repo — cross-cutting decision
records and operator runbooks go there, not here.
Rule for this file and every domain CLAUDE.md: only statements that would
break CI if they became false, or that name a file that exists. Background,
history and rationale belong in thermograph-docs. These files are read before
every change, so a stale one is a correctness bug, not a documentation bug.
Branches and environments
| Branch | Deploys to | Workflow |
|---|---|---|
| feature branch | nothing | PR into dev |
dev |
nothing | integration branch only — see below |
main |
beta (beta.thermograph.org) | deploy.yml |
release |
prod (thermograph.org) | deploy.yml |
dev, main and release are protected: everything is a PR, for humans and
agents alike. Promotion is one PR per hop, dev → main → release.
dev deploys nowhere. It is purely the integration branch that feature PRs
land on before promotion to main. The two LAN-dev deploy workflows were deleted
rather than kept: they called a monorepo path that does not exist on the LAN box
(~/thermograph-dev is still a split-era thermograph-infra checkout), so they
had been inert since cutover. Run LAN dev locally with infra/'s make dev-up.
One workflow deploys everything. deploy.yml handles both services and both
environments: the branch selects the environment (main → beta, release →
prod), a matrix covers backend and frontend, and each leg checks whether this
push actually touched its domain before rolling. build-push.yml is the same
shape for images. They replaced six and two near-identical files respectively.
The deploy contract
One entry point, two modes, one contract:
SERVICE=backend|frontend|all BACKEND_IMAGE_TAG=sha-<12hex> FRONTEND_IMAGE_TAG=sha-<12hex> \
/opt/thermograph/infra/deploy/deploy.sh
deploy.sh resets the host checkout, renders secrets from the SOPS vault, then
either rolls compose services or — if /etc/thermograph/deploy-mode contains
stack — execs infra/deploy/stack/deploy-stack.sh.
- prod runs Swarm. Its stack is
infra/deploy/stack/thermograph-stack.yml(db, web, worker, lake, daemon, frontend, autoscaler, autoscaler-lake).deploy-stack.shalso offersSTACK_TEST=1: a full parallel rehearsal on throwaway volumes and ports that cannot touch live data. - beta and LAN dev run compose, from
infra/docker-compose.yml(db, backend, lake, daemon, frontend). - Each service's live tag is persisted host-side, so a single-service roll never disturbs the sibling's running tag.
Images are emi/thermograph/backend and emi/thermograph/frontend, tagged
sha-<12hex> on every push and by semver on v*.*.* tags. They build and deploy
independently — a backend change ships without a frontend deploy and vice
versa. That independence is the point of the FE/BE split and survived
reunification.
Rules that bind across domains
- CI lives only in root
.forgejo/workflows/, path-filtered per domain. Never add workflows under a domain's own.forgejo/— they are inert there and become a trap. - Secrets only via the SOPS vault (
infra/deploy/secrets/). Never hand-edit/etc/thermograph.envon a host — it is a rendered artifact.secrets-guardCI rejects plaintext.seed-from-live.shreads production secrets and is explicitly not for an agent to run. - FE and BE ship out of lockstep, so the
/api/versioncontract andPAYLOAD_VERdiscipline are load-bearing — seebackend/CLAUDE.md. - Anything named "prefetch" must never spend the Open-Meteo quota. Nominatim ≤ 1 req/s.
- The compose project name is pinned (
name: thermographininfra/docker-compose.yml); LAN dev overrides withCOMPOSE_PROJECT_NAME=thermograph-dev. Don't remove either half — the pinned name is what makes the Swarm stack's external volume names line up. CUTOVER-NOTES.mdis the source of truth for what is and isn't live yet.
Commits & PRs
Describe only the substance of the change; concise and technical. Never mention AI, Claude, assistants or automated authorship anywhere — no trailers, co-authors, emoji, or "as requested" narration.