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.
main took two direct PRs (#19 shell-lint, #21 the thermograph-daemon Go
service) while dev accumulated the ERA5 lake stack; both sides added compose/
stack services and touched the same seams. Resolutions keep both worlds:
- compose + Swarm stack: lake AND daemon are sibling services; backend env
carries THERMOGRAPH_LAKE_URL and THERMOGRAPH_INTERNAL_TOKEN.
- deploy.sh: backend deploys roll (backend lake daemon); all adds frontend.
- requirements.txt: websockets/apscheduler stay removed (moved to the Go
daemon), duckdb stays (the lake role's engine).
Merged tree: backend suite green, both compose configs validate.
The gateway bot and APScheduler were long-lived stateful I/O loops running
inside the async web app under a leader election. They move into a single Go
binary that owns ONLY that I/O -- websocket, RESUME, heartbeat, backoff, timers.
It owns no grading logic. Anything needing data calls back over a new
internal-only surface (/internal/discord/grade, /internal/jobs/*). Grading
depends on polars and the parquet cache; reimplementing it in Go would let the
bot's grades drift from the API's. The grade route returns gateway-ready JSON
and Go relays the bytes verbatim.
The binary ships in the backend image and runs as a second compose service off
the same tag, so the two ends of the /internal/* contract can never skew.
deploy.sh rolls daemon alongside backend -- without that the service would never
be created, since a single-service deploy uses --no-deps. It also probes the
image first and skips the daemon when rolling a tag that predates the binary:
infra tracks main while image tags are env-staged, so a host can legitimately be
asked to roll an older backend image, and creating the service anyway would
leave a container crash-looping on a missing binary.
replicas: 1 with order: stop-first replaces the leader election -- Discord
permits one gateway connection per bot token.
THERMOGRAPH_INTERNAL_TOKEN is optional: both ends derive it from
THERMOGRAPH_AUTH_SECRET via HMAC under a domain-separation label, so this needs
no new vault entry. The derivation is pinned to a shared cross-language test
vector asserted on both sides, so drift fails CI instead of 401ing every call.
Fail closed when neither secret is set.
Improvements over the Python: a close intended for RESUME uses 4000 rather than
1000 (Discord invalidates a session closed 1000, so the old default defeated its
own resume); MESSAGE_CREATE runs on a bounded worker pool; and a malformed HELLO
returns an error rather than a clean reconnect, which would otherwise reset
backoff and hot-loop against the gateway.
365 Python tests pass; Go build/vet/test -race clean; shellcheck 0 findings.