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 }} 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 "\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."