name: Deploy

# The six per-domain-per-environment deploy workflows, collapsed into one.
#
# backend-deploy{,-dev,-prod}.yml and frontend-deploy{,-dev,-prod}.yml were the
# same file written six times: they differed only in a branch name, a paths
# filter, a concurrency group, the service name, its *_IMAGE_TAG variable and a
# secret prefix. The contract into infra/deploy/deploy.sh
# (SERVICE + BACKEND_IMAGE_TAG/FRONTEND_IMAGE_TAG) was already fully
# parameterised, so the duplication bought nothing and cost six files to keep in
# step.
#
# DEV IS A REAL DEPLOY TARGET AGAIN. It previously was not: dev meant a
# sudo-free compose stack on the operator's desktop, and the two
# *-deploy-dev.yml workflows that drove it were inert. Dev now lives on vps1 at
# /opt/thermograph-dev — a normal fleet host, reached over SSH exactly like beta
# and prod — so it gets a leg here.
#
# SECRETS ARE KEYED BY HOST, NOT BY ENVIRONMENT. `SSH_*` used to mean "beta" and
# `PROD_SSH_*` "prod", which worked only while each environment owned a box. It
# stopped being true in two directions at once: vps2 now runs beta AND prod, and
# the box `SSH_*` pointed at is now vps1, which runs dev, Forgejo and Grafana.
# The old names would have made "the beta secret" and "the Forgejo box secret"
# the same value by accident — the ops-cron file already records one incident
# caused by exactly that conflation. So: VPS1_SSH_* and VPS2_SSH_*, named for
# the machine, and the environment is passed separately as THERMOGRAPH_ENV.
#
# BETA AND PROD DEPLOYING TO ONE BOX DO NOT RACE. They have separate checkouts
# (/opt/thermograph-beta and /opt/thermograph), so the `git reset --hard` in one
# cannot pull the tree out from under the other, and deploy.sh's flock is per
# checkout. The concurrency groups below stay keyed by ref+service, which keeps
# two pushes to the SAME branch serialised — the property that actually matters.
#
# DELIBERATELY BORING EXPRESSIONS. No dynamic matrix (fromJSON), no
# `cond && secrets.A || secrets.B` ternary. Those are GitHub idioms that a
# Forgejo/act runner may evaluate differently, and the failure mode here is
# "production does not deploy" or, worse, "deploys with an empty SSH host". The
# three environments therefore get three explicit, mutually exclusive steps.
#
# What is preserved from the originals, all of it load-bearing:
#   - fetch-depth: 0, because the image tag is keyed to the LAST COMMIT THAT
#     TOUCHED THAT DOMAIN, not the branch tip. In a path-filtered monorepo the
#     tip is often an unrelated domain's commit and a depth-1 clone cannot see
#     past it.
#   - The 12-hex truncation, matching build-push exactly.
#   - Per-service, per-environment concurrency with cancel-in-progress: false --
#     a half-finished deploy must never be cancelled by a newer one.
#   - Separate credentials per host. Note what this does and does not buy now:
#     it keeps vps1 (Forgejo, its CI, and whatever unreviewed branch dev is
#     running) away from vps2 entirely. It no longer puts a host boundary
#     between beta and prod, because they share vps2 by design — that boundary
#     is now at the database (separate roles and databases) and the filesystem
#     (separate checkouts and rendered env files).
#   - appleboy/ssh-action by full URL; it is not mirrored in Forgejo's default
#     action registry.

on:
  push:
    branches: [dev, main, release]
    paths:
      - 'backend/**'
      - 'frontend/**'
  workflow_dispatch: {}

jobs:
  deploy:
    strategy:
      fail-fast: false
      # One physical runner, and deploy.sh serialises host-side with flock
      # anyway. Rolling one service at a time keeps the logs readable and
      # matches what the six separate workflows effectively did.
      max-parallel: 1
      matrix:
        service: [backend, frontend]

    runs-on: docker

    # Keyed by ref AND service, reproducing the old per-file groups
    # (beta-deploy-backend, prod-deploy-frontend, ...).
    concurrency:
      group: deploy-${{ github.ref_name }}-${{ matrix.service }}
      cancel-in-progress: false

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Plan this leg
        id: plan
        run: |
          set -euo pipefail

          # Branch selects the environment; the environment selects the host and
          # the script path. dev -> dev (vps1), main -> beta (vps2),
          # release -> prod (vps2). The paths differ because each environment
          # has its own checkout — two environments on vps2 must never share
          # one — and dev has its own entry point for its secrets policy.
          case "${{ github.ref_name }}" in
            dev)     environment=dev  ; script=/opt/thermograph-dev/infra/deploy/deploy-dev.sh ;;
            main)    environment=beta ; script=/opt/thermograph-beta/infra/deploy/deploy.sh ;;
            release) environment=prod ; script=/opt/thermograph/infra/deploy/deploy.sh ;;
            *) echo "::error::Deploy triggered on unexpected ref '${{ github.ref_name }}'"; exit 1 ;;
          esac

          # Did THIS push touch THIS domain? The workflow-level paths filter only
          # tells us backend OR frontend moved; without this refinement a
          # backend-only push would also roll the frontend, losing the
          # independent-deploy property the FE/BE split exists for.
          before="${{ github.event.before }}"
          if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] \
             || ! git cat-file -e "$before^{commit}" 2>/dev/null; then
            # No usable base (first push, force push, or workflow_dispatch).
            # Deploy rather than skip: rolling onto the tag already running is a
            # no-op for deploy.sh, whereas skipping silently strands a change.
            changed=true
            echo "no usable before-sha; defaulting to deploy"
          elif git diff --name-only "$before" "${{ github.sha }}" | grep -q "^${{ matrix.service }}/"; then
            changed=true
          else
            changed=false
          fi

          # The tag is the last commit that touched this domain, truncated to 12
          # hex to match build-push.yml exactly. Actions expressions have no
          # substring function, which is why this is computed in shell.
          domain_sha="$(git log -1 --format=%H -- "${{ matrix.service }}/")"

          tag="sha-${domain_sha:0:12}"

          {
            echo "environment=$environment"
            echo "script=$script"
            echo "changed=$changed"
            echo "tag=$tag"
          } >> "$GITHUB_OUTPUT"

          # Export the deploy.sh contract as real environment variables, chosen
          # in shell rather than with a `matrix.service == 'x' && a || b`
          # expression. That idiom is a GitHub convention a Forgejo/act runner
          # may evaluate differently, and the failure here would be silent: an
          # empty *_IMAGE_TAG makes deploy.sh fall back to the tag already
          # running, so the job goes green having deployed nothing.
          {
            echo "SERVICE=${{ matrix.service }}"
            if [ "${{ matrix.service }}" = "backend" ]; then
              echo "BACKEND_IMAGE_TAG=$tag"
            else
              echo "FRONTEND_IMAGE_TAG=$tag"
            fi
          } >> "$GITHUB_ENV"

          echo "==> ${{ matrix.service }} -> $environment | changed=$changed | tag=$tag"

      - name: Deploy to dev (vps1)
        if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'dev'
        uses: https://github.com/appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.VPS1_SSH_HOST }}
          username: ${{ secrets.VPS1_SSH_USER }}
          key: ${{ secrets.VPS1_SSH_KEY }}
          port: ${{ secrets.VPS1_SSH_PORT }}
          envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG,THERMOGRAPH_ENV
          script: ${{ steps.plan.outputs.script }}
        env:
          SERVICE: ${{ matrix.service }}
          THERMOGRAPH_ENV: dev
          BACKEND_IMAGE_TAG: ${{ matrix.service == 'backend' && steps.plan.outputs.tag || '' }}
          FRONTEND_IMAGE_TAG: ${{ matrix.service == 'frontend' && steps.plan.outputs.tag || '' }}

      - name: Deploy to beta (vps2)
        if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'beta'
        uses: https://github.com/appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.VPS2_SSH_HOST }}
          username: ${{ secrets.VPS2_SSH_USER }}
          key: ${{ secrets.VPS2_SSH_KEY }}
          port: ${{ secrets.VPS2_SSH_PORT }}
          envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG,THERMOGRAPH_ENV
          script: ${{ steps.plan.outputs.script }}
        env:
          SERVICE: ${{ matrix.service }}
          # THERMOGRAPH_ENV is what makes this a BETA deploy rather than a prod
          # one: same host, same credentials, same script — the environment is
          # the only thing that differs, and deploy.sh refuses to run if it
          # disagrees with the checkout it was invoked from.
          THERMOGRAPH_ENV: beta
          # Only the matching one is read by deploy.sh for a single-service roll;
          # the other stays empty and the persisted .image-tags.env supplies the
          # sibling's live tag, so this roll never disturbs it.
          BACKEND_IMAGE_TAG: ${{ matrix.service == 'backend' && steps.plan.outputs.tag || '' }}
          FRONTEND_IMAGE_TAG: ${{ matrix.service == 'frontend' && steps.plan.outputs.tag || '' }}

      - name: Deploy to prod (vps2)
        if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'prod'
        uses: https://github.com/appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.VPS2_SSH_HOST }}
          username: ${{ secrets.VPS2_SSH_USER }}
          key: ${{ secrets.VPS2_SSH_KEY }}
          port: ${{ secrets.VPS2_SSH_PORT }}
          envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG,THERMOGRAPH_ENV
          script: ${{ steps.plan.outputs.script }}
        env:
          SERVICE: ${{ matrix.service }}
          THERMOGRAPH_ENV: prod
          BACKEND_IMAGE_TAG: ${{ matrix.service == 'backend' && steps.plan.outputs.tag || '' }}
          FRONTEND_IMAGE_TAG: ${{ matrix.service == 'frontend' && steps.plan.outputs.tag || '' }}

      - name: Skipped
        if: steps.plan.outputs.changed != 'true'
        run: echo "${{ matrix.service }} unchanged in this push — nothing to roll."
