2026-07-25 07:48:49 +00:00
|
|
|
name: Build + push images (Forgejo registry)
|
|
|
|
|
|
|
|
|
|
# backend-build-push.yml and frontend-build-push.yml, collapsed into one.
|
|
|
|
|
# They were identical apart from the domain string: the paths filter, the
|
|
|
|
|
# concurrency group, IMAGE_PATH, the tag key and the build context.
|
|
|
|
|
#
|
2026-07-31 03:53:26 +00:00
|
|
|
# Images stay separate and independently deployable -- jinemi/thermograph/backend
|
|
|
|
|
# and jinemi/thermograph/frontend -- exactly as before. Build-once,
|
2026-07-25 07:48:49 +00:00
|
|
|
# deploy-everywhere; nothing about the published artefacts changes here.
|
|
|
|
|
#
|
|
|
|
|
# SEMVER EXCEPTION, preserved: a v*.*.* tag push carries no paths context, so a
|
|
|
|
|
# release tag builds BOTH domain images. That is intended -- a release names the
|
|
|
|
|
# whole set, not whichever domain happened to move last.
|
|
|
|
|
#
|
|
|
|
|
# Runs on the `docker` label. The LAN runner's 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, hence the install step.
|
|
|
|
|
#
|
registry: move the registry host to dev.jinemi.com, MCP to mcp.jinemi.com
Completes the Forgejo domain migration. ROOT_URL moved to dev.jinemi.com
earlier; the registry half was deliberately deferred. Every image name,
REGISTRY_HOST default, runner label and --add-host pin now names
dev.jinemi.com, so the registry host and the bearer-token realm agree again.
Both names address the same Forgejo, so no image needs re-pushing and a
rollback to a tag pushed under the old prefix still resolves.
git.thermograph.org therefore stays served off the same Caddy site block --
one block, so the /v2/* mesh-only matcher keeps covering both names -- for
pre-migration tags and for runners holding it as their registered instance
URL.
Mesh clients now pin both names in /etc/hosts: the new one as registry host
and token realm, the old one for pre-migration tags. runner-vps2/config.yaml
carries both --add-host entries for the same reason.
Also renames Centralis' endpoint to mcp.jinemi.com in the two places this
repo names it; Centralis itself is provisioned outside this repo.
Host-side steps this cannot do (documented in deploy/forgejo/README.md,
"Host-side steps"): the Forgejo Actions variable REGISTRY_HOST, docker login
against the new host, and the /etc/hosts pins.
2026-08-01 23:06:22 +00:00
|
|
|
# The registry is deliberately mesh-only: dev.jinemi.com's public DNS resolves
|
|
|
|
|
# to vps1's public IP, whose Caddy ACL rejects /v2/ paths, so any runner host
|
|
|
|
|
# that pushes or pulls needs an /etc/hosts entry pinning it to 10.10.0.2.
|
|
|
|
|
#
|
|
|
|
|
# The host itself comes from the Forgejo Actions variable REGISTRY_HOST, not
|
|
|
|
|
# from this file — a repo-side rename (git.thermograph.org -> dev.jinemi.com)
|
|
|
|
|
# does nothing until that variable is updated too. See
|
|
|
|
|
# infra/deploy/forgejo/README.md, "Migrating the domain".
|
2026-07-25 07:48:49 +00:00
|
|
|
|
|
|
|
|
on:
|
|
|
|
|
push:
|
|
|
|
|
branches: [dev, main, release]
|
|
|
|
|
paths:
|
|
|
|
|
- 'backend/**'
|
|
|
|
|
- 'frontend/**'
|
|
|
|
|
tags: ['v*.*.*']
|
|
|
|
|
workflow_dispatch: {}
|
|
|
|
|
|
|
|
|
|
jobs:
|
|
|
|
|
build-push:
|
|
|
|
|
strategy:
|
|
|
|
|
fail-fast: false
|
|
|
|
|
# capacity: 1 physical runner. Serialising keeps a two-domain push from
|
|
|
|
|
# queueing two whole image builds against each other.
|
|
|
|
|
max-parallel: 1
|
|
|
|
|
matrix:
|
|
|
|
|
domain: [backend, frontend]
|
|
|
|
|
|
|
|
|
|
runs-on: docker
|
|
|
|
|
|
|
|
|
|
# Per-domain, per-ref: only a superseded build of the SAME domain on the
|
|
|
|
|
# SAME ref is cancelled, and a frontend build never cancels a backend one.
|
|
|
|
|
concurrency:
|
|
|
|
|
group: build-push-${{ matrix.domain }}-${{ github.ref }}
|
|
|
|
|
cancel-in-progress: true
|
|
|
|
|
|
|
|
|
|
env:
|
|
|
|
|
REGISTRY: https://${{ vars.REGISTRY_HOST }}
|
|
|
|
|
|
|
|
|
|
steps:
|
|
|
|
|
- uses: actions/checkout@v4
|
|
|
|
|
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
|
|
|
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
|
|
|
# often an unrelated domain's commit, and a depth-1 clone can't see past
|
|
|
|
|
# it to find the real key.
|
|
|
|
|
with:
|
|
|
|
|
fetch-depth: 0
|
|
|
|
|
|
|
|
|
|
- name: Compute image ref + tags
|
|
|
|
|
id: image
|
|
|
|
|
run: |
|
|
|
|
|
set -euo pipefail
|
|
|
|
|
|
|
|
|
|
host="${REGISTRY#https://}"; host="${host#http://}"
|
|
|
|
|
path="$(printf '%s/%s' "${{ github.repository }}" "${{ matrix.domain }}" | tr '[:upper:]' '[:lower:]')"
|
|
|
|
|
ref="$host/$path"
|
|
|
|
|
|
|
|
|
|
# Does this push touch this domain? The workflow-level paths filter
|
|
|
|
|
# only says backend OR frontend moved; without this a backend-only
|
|
|
|
|
# push would also rebuild and republish the frontend image.
|
|
|
|
|
before="${{ github.event.before }}"
|
|
|
|
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
|
|
|
|
changed=true # semver tag: build the whole set, see header
|
|
|
|
|
elif [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] \
|
|
|
|
|
|| ! git cat-file -e "$before^{commit}" 2>/dev/null; then
|
|
|
|
|
changed=true # no usable base; build rather than silently skip
|
|
|
|
|
elif git diff --name-only "$before" "${{ github.sha }}" | grep -q "^${{ matrix.domain }}/"; then
|
|
|
|
|
changed=true
|
|
|
|
|
else
|
|
|
|
|
changed=false
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
# Key the tag to the last commit that touched this domain -- the SAME
|
|
|
|
|
# key every deploy computes -- so build and deploy always agree even
|
|
|
|
|
# when the branch tip is another domain's commit. (A tag keyed to the
|
|
|
|
|
# push tip breaks the moment an infra-only commit lands: deploys go
|
|
|
|
|
# looking for an image no build ever produced.)
|
|
|
|
|
domain_sha="$(git log -1 --format=%H -- "${{ matrix.domain }}/")"
|
|
|
|
|
|
|
|
|
|
{
|
|
|
|
|
echo "changed=$changed"
|
|
|
|
|
echo "host=$host"
|
|
|
|
|
echo "sha_tag=$ref:sha-${domain_sha:0:12}"
|
|
|
|
|
} >> "$GITHUB_OUTPUT"
|
|
|
|
|
|
|
|
|
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
|
|
|
|
echo "semver_tag=$ref:${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
echo "==> ${{ matrix.domain }} | changed=$changed | $ref:sha-${domain_sha:0:12}"
|
|
|
|
|
|
|
|
|
|
- name: Install Docker CLI
|
|
|
|
|
if: steps.image.outputs.changed == 'true'
|
|
|
|
|
run: |
|
|
|
|
|
apt-get update -qq
|
|
|
|
|
apt-get install -y -qq docker.io
|
|
|
|
|
|
|
|
|
|
- name: Log in to the Forgejo registry
|
|
|
|
|
if: steps.image.outputs.changed == 'true'
|
build-push: stop a whitespace-tainted REGISTRY_TOKEN failing as "denied"
`docker login --password-stdin` strips exactly ONE trailing newline. `echo
"$T"` adds one. So a secret pasted WITH a trailing newline -- which is what
copying from a terminal, or an editor that terminates files with one, produces
-- arrives at the registry as "<token>\n" and gets back:
Error response from daemon: Get "https://.../v2/": denied:
with no further detail. That is indistinguishable from a revoked or wrong
token. On 2026-08-01 it stopped every build and deploy in the estate and cost
an afternoon of diagnosis on a credential that was in fact valid: the same
token, tested by hand, returned 200 on /v2/ and was issued a pull,push-scoped
registry token for jinemi/thermograph/backend.
So: strip leading/trailing whitespace and CR/LF before the pipe, and never
`echo` a credential into stdin.
The token now travels through the ENVIRONMENT rather than being interpolated
into the script text. `${{ }}` is substituted before bash parses the line, so a
value containing a quote or a newline changes the shape of the command itself,
not merely its arguments.
Two diagnostics, deliberately asymmetric:
* empty/unset -> HARD FAILURE naming where to set it. There is no case where
proceeding helps.
* wrong shape -> WARNING only (length, and whether it is outside [0-9a-f]),
then attempt the login anyway. A hard assertion on token format would block
every build the day Forgejo changes that format, which is a worse failure
than the one being prevented. The warning is enough to turn the registry's
opaque "denied:" into a diagnosis.
Neither diagnostic prints the value; only its length and character class.
Note the username is not a factor: tested against the live registry, all of
admin_emi, emi and jinemi authenticate identically with a valid token and are
each issued a push-scoped token. Forgejo's container registry authenticates on
the token, not the username.
2026-08-01 17:16:32 +00:00
|
|
|
env:
|
|
|
|
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
2026-07-25 07:48:49 +00:00
|
|
|
run: |
|
|
|
|
|
# NOT secrets.GITHUB_TOKEN -- Forgejo's per-job auto-injected token can
|
|
|
|
|
# never push to the container registry (a known Forgejo limitation,
|
|
|
|
|
# independent of any permission setting). Needs a manually provisioned
|
|
|
|
|
# PAT with write:package scope, stored as REGISTRY_TOKEN.
|
build-push: stop a whitespace-tainted REGISTRY_TOKEN failing as "denied"
`docker login --password-stdin` strips exactly ONE trailing newline. `echo
"$T"` adds one. So a secret pasted WITH a trailing newline -- which is what
copying from a terminal, or an editor that terminates files with one, produces
-- arrives at the registry as "<token>\n" and gets back:
Error response from daemon: Get "https://.../v2/": denied:
with no further detail. That is indistinguishable from a revoked or wrong
token. On 2026-08-01 it stopped every build and deploy in the estate and cost
an afternoon of diagnosis on a credential that was in fact valid: the same
token, tested by hand, returned 200 on /v2/ and was issued a pull,push-scoped
registry token for jinemi/thermograph/backend.
So: strip leading/trailing whitespace and CR/LF before the pipe, and never
`echo` a credential into stdin.
The token now travels through the ENVIRONMENT rather than being interpolated
into the script text. `${{ }}` is substituted before bash parses the line, so a
value containing a quote or a newline changes the shape of the command itself,
not merely its arguments.
Two diagnostics, deliberately asymmetric:
* empty/unset -> HARD FAILURE naming where to set it. There is no case where
proceeding helps.
* wrong shape -> WARNING only (length, and whether it is outside [0-9a-f]),
then attempt the login anyway. A hard assertion on token format would block
every build the day Forgejo changes that format, which is a worse failure
than the one being prevented. The warning is enough to turn the registry's
opaque "denied:" into a diagnosis.
Neither diagnostic prints the value; only its length and character class.
Note the username is not a factor: tested against the live registry, all of
admin_emi, emi and jinemi authenticate identically with a valid token and are
each issued a push-scoped token. Forgejo's container registry authenticates on
the token, not the username.
2026-08-01 17:16:32 +00:00
|
|
|
#
|
|
|
|
|
# WHITESPACE IS THE FAILURE THIS GUARDS AGAINST, and it is a nasty one.
|
|
|
|
|
# `docker login --password-stdin` strips exactly ONE trailing newline.
|
|
|
|
|
# `echo "$T"` adds one. So a secret pasted WITH a trailing newline --
|
|
|
|
|
# which is what happens if you copy from a terminal or an editor that
|
|
|
|
|
# ends files with one -- arrives as "<token>\n" and the registry answers
|
|
|
|
|
#
|
|
|
|
|
# Error response from daemon: Get "https://.../v2/": denied:
|
|
|
|
|
#
|
|
|
|
|
# with no further detail. That is indistinguishable from a revoked or
|
|
|
|
|
# wrong token, and it cost an afternoon on 2026-08-01 chasing a
|
|
|
|
|
# credential that was in fact valid. Strip first, echo never.
|
|
|
|
|
#
|
|
|
|
|
# The token also goes through the ENVIRONMENT rather than being
|
build-push: drop the empty ${{ }} that silenced the registry login
Forgejo evaluates ${{ }} expressions inside a step's `run:` script, comments
included. An EMPTY expression is a parse error, and the runner's response is to
drop the step -- no log line, no error, no failed status. The login step simply
never ran, so every subsequent `docker push` went out anonymous.
The registry then answers `unauthorized: reqPackageAccess` (or, depending on
the client path, `no basic auth credentials`), which reads exactly like a
revoked token or a missing scope. It is neither. Both are downstream of a
comment.
Isolated on one branch, one variable, back to back:
* empty expression present -> both legs fail, no `Login Succeeded` in the log
* empty expression removed -> both legs pass, `Login Succeeded` present,
sha-df409f88b3fd published for backend and frontend
Nothing was wrong with the credential. The token, its scope and whether it sat
at repo or organization level were all ruled out first: pushes to
jinemi/thermograph/* succeed by hand from vps1 and from the runner host, with
matching and mismatched usernames, and the run log shows REGISTRY_TOKEN
arriving in the job environment.
Do not write a bare ${{ }} in a run block, in a comment or otherwise.
2026-08-01 18:17:48 +00:00
|
|
|
# interpolated into the script text. An expression is substituted before bash
|
build-push: stop a whitespace-tainted REGISTRY_TOKEN failing as "denied"
`docker login --password-stdin` strips exactly ONE trailing newline. `echo
"$T"` adds one. So a secret pasted WITH a trailing newline -- which is what
copying from a terminal, or an editor that terminates files with one, produces
-- arrives at the registry as "<token>\n" and gets back:
Error response from daemon: Get "https://.../v2/": denied:
with no further detail. That is indistinguishable from a revoked or wrong
token. On 2026-08-01 it stopped every build and deploy in the estate and cost
an afternoon of diagnosis on a credential that was in fact valid: the same
token, tested by hand, returned 200 on /v2/ and was issued a pull,push-scoped
registry token for jinemi/thermograph/backend.
So: strip leading/trailing whitespace and CR/LF before the pipe, and never
`echo` a credential into stdin.
The token now travels through the ENVIRONMENT rather than being interpolated
into the script text. `${{ }}` is substituted before bash parses the line, so a
value containing a quote or a newline changes the shape of the command itself,
not merely its arguments.
Two diagnostics, deliberately asymmetric:
* empty/unset -> HARD FAILURE naming where to set it. There is no case where
proceeding helps.
* wrong shape -> WARNING only (length, and whether it is outside [0-9a-f]),
then attempt the login anyway. A hard assertion on token format would block
every build the day Forgejo changes that format, which is a worse failure
than the one being prevented. The warning is enough to turn the registry's
opaque "denied:" into a diagnosis.
Neither diagnostic prints the value; only its length and character class.
Note the username is not a factor: tested against the live registry, all of
admin_emi, emi and jinemi authenticate identically with a valid token and are
each issued a push-scoped token. Forgejo's container registry authenticates on
the token, not the username.
2026-08-01 17:16:32 +00:00
|
|
|
# parses the line, so a value containing a quote or newline would change
|
|
|
|
|
# the shape of the command itself rather than just its arguments.
|
|
|
|
|
tok=$(printf '%s' "$REGISTRY_TOKEN" | tr -d ' \t\r\n')
|
|
|
|
|
if [ -z "$tok" ]; then
|
|
|
|
|
echo "::error::REGISTRY_TOKEN is empty or unset. Set it at" \
|
|
|
|
|
"Settings -> Actions -> Secrets on this repository."
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
# Shape check is a WARNING, not a failure: a hard assertion on the token
|
|
|
|
|
# format would block every build the day Forgejo changes it. But saying
|
|
|
|
|
# so up front turns the registry's opaque "denied:" into a diagnosis.
|
|
|
|
|
# Length and character class only -- the value is never printed.
|
|
|
|
|
case "$tok" in
|
|
|
|
|
*[!0-9a-f]*) echo "::warning::REGISTRY_TOKEN contains characters outside" \
|
|
|
|
|
"[0-9a-f]; a Forgejo PAT is 40 hex characters. If the" \
|
|
|
|
|
"login below fails, re-paste the secret." ;;
|
|
|
|
|
esac
|
|
|
|
|
[ "${#tok}" -eq 40 ] || echo "::warning::REGISTRY_TOKEN is ${#tok} characters," \
|
|
|
|
|
"expected 40. If the login below fails, re-paste the secret."
|
|
|
|
|
printf '%s' "$tok" | docker login "${{ steps.image.outputs.host }}" \
|
2026-07-31 03:53:26 +00:00
|
|
|
--username admin_emi --password-stdin
|
2026-07-25 07:48:49 +00:00
|
|
|
|
|
|
|
|
- name: Build
|
|
|
|
|
if: steps.image.outputs.changed == 'true'
|
|
|
|
|
run: |
|
|
|
|
|
# One build, both tags -- the semver tag (tag pushes only) rides along
|
|
|
|
|
# with the SHA tag instead of a second tag+push round trip. Context is
|
|
|
|
|
# the DOMAIN dir, so the Dockerfile sees the same tree it saw as a repo
|
|
|
|
|
# root in the split era.
|
|
|
|
|
tag_args=(-t "${{ steps.image.outputs.sha_tag }}")
|
|
|
|
|
if [[ -n "${{ steps.image.outputs.semver_tag }}" ]]; then
|
|
|
|
|
tag_args+=(-t "${{ steps.image.outputs.semver_tag }}")
|
|
|
|
|
fi
|
|
|
|
|
docker build "${tag_args[@]}" "${{ matrix.domain }}/"
|
|
|
|
|
|
ci: gate the prod path, fold the repo-wide checks into gate, run the daemon tests
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.
2026-07-25 08:36:31 +00:00
|
|
|
# Build -> TEST -> push. Without this the only pytest run in CI was
|
|
|
|
|
# pr-build's, so anything reaching a branch by another route (a direct
|
|
|
|
|
# push, a workflow_dispatch, a v*.*.* tag) published an image that CI had
|
|
|
|
|
# never tested — and deploy.yml then rolls that exact tag onto beta/prod.
|
|
|
|
|
# Gating the artifact rather than the branch is what makes the deploy safe,
|
|
|
|
|
# since deploy.yml consumes a tag, not a commit status.
|
|
|
|
|
#
|
|
|
|
|
# Same invocation as build.yml's, deliberately: -u 0 because the image's
|
|
|
|
|
# default user cannot write to site-packages, --entrypoint sh because the
|
|
|
|
|
# real entrypoint is alembic-migrate-then-serve and would swallow the
|
|
|
|
|
# command, and `unset THERMOGRAPH_BASE` because the image bakes the prod
|
|
|
|
|
# root mount (/) while the tests are written against the /thermograph
|
|
|
|
|
# prefix. The Go daemon and Go frontend suites need no step here: both run
|
|
|
|
|
# inside their Dockerfiles, so `docker build` above already gated them.
|
|
|
|
|
- name: Test the built image (backend only)
|
|
|
|
|
if: steps.image.outputs.changed == 'true' && matrix.domain == 'backend'
|
|
|
|
|
run: |
|
|
|
|
|
docker run --rm -u 0 --entrypoint sh "${{ steps.image.outputs.sha_tag }}" \
|
|
|
|
|
-c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest tests -q"
|
|
|
|
|
|
2026-07-25 07:48:49 +00:00
|
|
|
- name: Push (SHA tag, every push)
|
|
|
|
|
if: steps.image.outputs.changed == 'true'
|
|
|
|
|
run: docker push "${{ steps.image.outputs.sha_tag }}"
|
|
|
|
|
|
|
|
|
|
- name: Push (semver tag, tag pushes only)
|
|
|
|
|
if: steps.image.outputs.changed == 'true' && startsWith(github.ref, 'refs/tags/v')
|
|
|
|
|
run: docker push "${{ steps.image.outputs.semver_tag }}"
|
|
|
|
|
|
|
|
|
|
- name: Skipped
|
|
|
|
|
if: steps.image.outputs.changed != 'true'
|
|
|
|
|
run: echo "${{ matrix.domain }} unchanged in this push — no image built."
|