thermograph/.forgejo/workflows/build-push.yml
Emi Griffith 53e2eb7c84
All checks were successful
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-centralis (push) Has been skipped
Sync infra to hosts / sync-dev (push) Successful in 9s
secrets-guard / encrypted (push) Successful in 5s
Validate observability stack / validate (push) Successful in 12s
shell-lint / shellcheck (push) Successful in 8s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 26s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 1m10s
Deploy / deploy (backend) (push) Successful in 1m41s
Deploy / deploy (frontend) (push) Successful in 1m48s
secrets-guard / encrypted (pull_request) Successful in 8s
shell-lint / shellcheck (pull_request) Successful in 9s
PR build (required check) / changes (pull_request) Successful in 20s
PR build (required check) / validate-observability (pull_request) Successful in 15s
PR build (required check) / build-frontend (pull_request) Successful in 17s
PR build (required check) / build-backend (pull_request) Successful in 2m26s
PR build (required check) / gate (pull_request) Successful in 1s
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 16:06:22 -07:00

185 lines
8.4 KiB
YAML

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.
#
# Images stay separate and independently deployable -- jinemi/thermograph/backend
# and jinemi/thermograph/frontend -- exactly as before. Build-once,
# 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.
#
# 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".
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'
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
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.
#
# 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
# interpolated into the script text. An expression is substituted before bash
# 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 }}" \
--username admin_emi --password-stdin
- 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 }}/"
- 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."