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 -- emi/thermograph/backend
# and emi/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'
        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.
          echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ steps.image.outputs.host }}" \
            --username 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."
