All checks were successful
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 4s
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / validate-observability (pull_request) Successful in 20s
PR build (required check) / build-frontend (pull_request) Successful in 37s
PR build (required check) / build-backend (pull_request) Successful in 51s
PR build (required check) / gate (pull_request) Successful in 1s
Repos moved to the Jinemi org; the container packages did not follow, since
Forgejo does not transfer packages with a repo. The deploy path still resolved
`emi/thermograph/*` — a user_redirect to admin_emi — while build-push.yml
derives its push path from ${github.repository}, now jinemi/thermograph. The
next backend or frontend build would have published somewhere no deploy looks.
Point the image paths at jinemi/thermograph/* (lowercase: OCI references admit
no uppercase, which is why build-push.yml already pipes through tr), the clone
URLs at Jinemi/thermograph, and the registry logins at admin_emi — the account
that actually owns the tokens, rather than the redirect.
The live tags and both ci-runner tags were copied into the Jinemi namespace
first, so the switch has something to pull. thermograph-infra,
thermograph-observability and the retired */app packages stay under admin_emi;
they did not move.
262 lines
13 KiB
Markdown
262 lines
13 KiB
Markdown
# 7. CI and release
|
|
|
|
## The branch model
|
|
|
|
```
|
|
PR ──(required check: `gate`)──▶ dev ──▶ dev (vps1, mesh-only)
|
|
│
|
|
promotion PR
|
|
▼
|
|
main ──▶ beta.thermograph.org (vps2)
|
|
│
|
|
promotion PR ← the owner's call, always
|
|
▼
|
|
release ──▶ thermograph.org (vps2)
|
|
```
|
|
|
|
`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 prod's and
|
|
beta's env files (`/etc/thermograph.env` and `/etc/thermograph-beta.env`, both
|
|
on vps2). 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:
|
|
|
|
1. a `changes` job diffs the PR against its base (needs `fetch-depth: 0` — the
|
|
base commit isn't reachable from a depth-1 clone);
|
|
2. per-domain jobs run only for domains the PR touches;
|
|
3. `gate` reduces 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/Jinemi/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.org` in `/etc/hosts`.
|
|
|
|
### `deploy.yml` — one file, three environments, two services
|
|
|
|
Formerly six near-identical files. Branch selects the environment
|
|
(`dev → dev`, `main → beta`, `release → prod`) — **three legs now, not two** —
|
|
and 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 each environment
|
|
gets its own explicit, mutually exclusive step 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.yml` exactly.
|
|
- Per-service, per-environment concurrency with **`cancel-in-progress: false`** —
|
|
a half-finished deploy must never be cancelled by a newer one.
|
|
- `appleboy/ssh-action` referenced by full URL; it isn't mirrored in Forgejo's
|
|
default action registry.
|
|
|
|
**SSH credentials are keyed by HOST, not by environment**: `VPS1_SSH_*` and
|
|
`VPS2_SSH_*`, replacing the old `SSH_*` (which actually meant beta) and
|
|
`PROD_SSH_*` (which actually meant prod). That old naming used to read as a
|
|
security boundary — "separate `PROD_SSH_*` credentials, so a beta credential
|
|
leak cannot reach prod" — and on the old topology it happened to be true,
|
|
because beta and prod were also on separate hosts. They no longer are: beta and
|
|
prod are now two co-resident Swarm stacks on vps2, so a `VPS2_SSH_*` credential
|
|
reaches both by construction, and there is no host-level SSH boundary between
|
|
them to preserve. The boundary that replaces it lives at two other levels
|
|
instead: the **database** (beta connects as its own `NOSUPERUSER` role to its
|
|
own database, `CONNECT` revoked from `PUBLIC` on prod's) and the **filesystem**
|
|
(separate checkouts, separate rendered env files, separate deploy locks —
|
|
`env-topology.sh` derives all of it so nothing is shared by accident).
|
|
|
|
The credential boundary that *is* still real, and still matters, is
|
|
**vps1-vs-vps2**: vps1 runs Forgejo, its own CI, and whatever's currently on
|
|
`dev` — unreviewed by definition, since `dev` is exactly the branch PRs land on
|
|
before review gates them into `main`. That is precisely why the `dev`
|
|
environment renders `dev.yaml` alone and never layers `common.yaml` (the
|
|
fleet's shared production credentials, including a read-write S3 keypair and
|
|
the VAPID push-signing key) — see [Infra and secrets](08-infra-secrets.md). A
|
|
`VPS1_SSH_*` leak should never be able to reach a production credential, which
|
|
is a property `common.yaml`'s dev exclusion buys independently of which SSH key
|
|
was used to get there.
|
|
|
|
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 vps2, fast-forward **both**
|
|
checkouts there (`/opt/thermograph` for prod, `/opt/thermograph-beta` for
|
|
beta), re-render each one's own env file from the vault.
|
|
|
|
**Rolls no service.** Image tags are the app domains' axis, not infra's. A
|
|
compose or stack change that must recreate containers takes effect on the next
|
|
app deploy, or on a by-hand `SERVICE=all … infra/deploy/deploy.sh` (or
|
|
`deploy-dev.sh` for dev).
|
|
|
|
Note the asymmetry: app code *is* environment-staged (`dev`→`main`→`release`
|
|
maps to dev(vps1)→beta→prod(vps2) via image tags); **infra is not** — beta's and
|
|
prod's checkouts both track infra via `main`, while dev's checkout tracks `dev`
|
|
itself. 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
|
|
vps2 and run inside prod's already-running stack — specifically prod's, not
|
|
beta's, even though both now live on the same box.
|
|
|
|
**It uses `VPS2_SSH_*`, not a beta-flavoured secret.** An earlier revision (on
|
|
the old topology) reused the credential meant for beta's host — so the "prod"
|
|
backup was silently dumping *beta*, and prod had no backup at all. On vps2's
|
|
co-residency, the host-keyed secret alone doesn't disambiguate prod from beta
|
|
the way it used to; the script also needs to target prod's checkout and `db`
|
|
specifically. If you touch this file, verify a dump actually lands in
|
|
`agent@vps2:~/thermograph-backups/` and that it's prod's data, not beta's.
|
|
|
|
### `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-guard` fails if any `infra/deploy/secrets/*.yaml` lacks both a
|
|
`sops:` block and `ENC[AES256_GCM` values. ~1 second.
|
|
- `shell-lint` runs shellcheck over every `*.sh` found by `find` (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 than `apt-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 `docker` label — vps1's Forgejo Actions runner (vps1 is where
|
|
Forgejo itself lives; the desktop no longer runs a 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 workflows
|
|
`apt-get install docker.io` first.
|
|
- Forgejo's runner has **only the labels it was explicitly registered with**.
|
|
GitHub implicitly tags every self-hosted runner `self-hosted`; Forgejo does
|
|
not. A `runs-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
|
|
|
|
```bash
|
|
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](08-infra-secrets.md).
|