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.
#
# The two *-deploy-dev.yml workflows are NOT ported. They were documented as
# inert: they call the monorepo layout at ~/thermograph-dev on the LAN box,
# which is still a split-era thermograph-infra checkout, so that path does not
# exist there. LAN dev is a local `make dev-up` concern, not a CI environment.
#
# 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
# two environments therefore get two 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 PROD_SSH_* credentials, so a beta credential leak cannot reach
#     prod.
#   - appleboy/ssh-action by full URL; it is not mirrored in Forgejo's default
#     action registry.

on:
  push:
    branches: [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. main -> beta, release -> prod.
          case "${{ github.ref_name }}" in
            main)    environment=beta ;;
            release) environment=prod ;;
            *) 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 "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 beta
        if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'beta'
        uses: https://github.com/appleboy/ssh-action@v1.2.0
        with:
          host: ${{ secrets.SSH_HOST }}
          username: ${{ secrets.SSH_USER }}
          key: ${{ secrets.SSH_KEY }}
          port: ${{ secrets.SSH_PORT }}
          envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG
          script: /opt/thermograph/infra/deploy/deploy.sh
        env:
          SERVICE: ${{ matrix.service }}
          # 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
        if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'prod'
        uses: https://github.com/appleboy/ssh-action@v1.2.0
        with:
          # A completely separate secret set from beta's, deliberately: a beta
          # credential leak must not reach prod.
          host: ${{ secrets.PROD_SSH_HOST }}
          username: ${{ secrets.PROD_SSH_USER }}
          key: ${{ secrets.PROD_SSH_KEY }}
          port: ${{ secrets.PROD_SSH_PORT }}
          envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG
          script: /opt/thermograph/infra/deploy/deploy.sh
        env:
          SERVICE: ${{ matrix.service }}
          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."
