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