thermograph/.forgejo/workflows/build-push.yml

179 lines
8.1 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: git.thermograph.org's public DNS
# resolves to beta's public IP, whose Caddy ACL rejects /v2/ paths, so any
# runner host that pushes or pulls needs an /etc/hosts entry.
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 }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
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'
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. `${{ }}` 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."