2026-07-23 05:11:33 +00:00
|
|
|
# thermograph monorepo — agent instructions
|
|
|
|
|
|
2026-07-25 07:08:54 +00:00
|
|
|
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` |
|
ci: collapse the eight deploy and build-push workflows into two
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.
2026-07-25 07:47:41 +00:00
|
|
|
| `dev` | nothing | integration branch only — see below |
|
|
|
|
|
| `main` | beta (beta.thermograph.org) | `deploy.yml` |
|
|
|
|
|
| `release` | prod (thermograph.org) | `deploy.yml` |
|
2026-07-25 07:08:54 +00:00
|
|
|
|
|
|
|
|
`dev`, `main` and `release` are protected: **everything is a PR**, for humans and
|
|
|
|
|
agents alike. Promotion is one PR per hop, `dev` → `main` → `release`.
|
|
|
|
|
|
ci: collapse the eight deploy and build-push workflows into two
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.
2026-07-25 07:47:41 +00:00
|
|
|
**`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.
|
2026-07-25 07:08:54 +00:00
|
|
|
|
|
|
|
|
## 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.sh` also offers `STACK_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.env` on a host — it is a rendered artifact. `secrets-guard`
|
|
|
|
|
CI rejects plaintext. `seed-from-live.sh` reads production secrets and is
|
|
|
|
|
explicitly **not** for an agent to run.
|
|
|
|
|
- FE and BE ship out of lockstep, so the `/api/version` contract and
|
|
|
|
|
`PAYLOAD_VER` discipline are load-bearing — see `backend/CLAUDE.md`.
|
|
|
|
|
- Anything named "prefetch" must never spend the Open-Meteo quota.
|
|
|
|
|
Nominatim ≤ 1 req/s.
|
2026-07-23 05:11:33 +00:00
|
|
|
- The compose project name is pinned (`name: thermograph` in
|
2026-07-25 07:08:54 +00:00
|
|
|
`infra/docker-compose.yml`); LAN dev overrides with
|
|
|
|
|
`COMPOSE_PROJECT_NAME=thermograph-dev`. Don't remove either half — the pinned
|
|
|
|
|
name is what makes the Swarm stack's external volume names line up.
|
2026-07-23 05:11:33 +00:00
|
|
|
- `CUTOVER-NOTES.md` is the source of truth for what is and isn't live yet.
|
2026-07-25 07:08:54 +00:00
|
|
|
|
|
|
|
|
## 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.
|