2026-07-25 07:48:49 +00:00
|
|
|
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.
|
|
|
|
|
#
|
2026-07-26 06:56:38 +00:00
|
|
|
# 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.
|
2026-07-25 07:48:49 +00:00
|
|
|
#
|
|
|
|
|
# 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
|
2026-07-26 06:56:38 +00:00
|
|
|
# three environments therefore get three explicit, mutually exclusive steps.
|
2026-07-25 07:48:49 +00:00
|
|
|
#
|
|
|
|
|
# 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.
|
2026-07-26 06:56:38 +00:00
|
|
|
# - 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).
|
2026-07-25 07:48:49 +00:00
|
|
|
# - appleboy/ssh-action by full URL; it is not mirrored in Forgejo's default
|
|
|
|
|
# action registry.
|
|
|
|
|
|
|
|
|
|
on:
|
|
|
|
|
push:
|
2026-07-26 06:56:38 +00:00
|
|
|
branches: [dev, main, release]
|
2026-07-25 07:48:49 +00:00
|
|
|
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
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
# 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.
|
2026-07-25 07:48:49 +00:00
|
|
|
case "${{ github.ref_name }}" in
|
2026-07-26 06:56:38 +00:00
|
|
|
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 ;;
|
2026-07-25 07:48:49 +00:00
|
|
|
*) 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"
|
2026-07-26 06:56:38 +00:00
|
|
|
echo "script=$script"
|
2026-07-25 07:48:49 +00:00
|
|
|
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"
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
- 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)
|
2026-07-25 07:48:49 +00:00
|
|
|
if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'beta'
|
|
|
|
|
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
|
|
|
|
with:
|
2026-07-26 06:56:38 +00:00
|
|
|
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 }}
|
2026-07-25 07:48:49 +00:00
|
|
|
env:
|
|
|
|
|
SERVICE: ${{ matrix.service }}
|
2026-07-26 06:56:38 +00:00
|
|
|
# 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
|
2026-07-25 07:48:49 +00:00
|
|
|
# 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 || '' }}
|
|
|
|
|
|
2026-07-26 06:56:38 +00:00
|
|
|
- name: Deploy to prod (vps2)
|
2026-07-25 07:48:49 +00:00
|
|
|
if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'prod'
|
|
|
|
|
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
|
|
|
|
with:
|
2026-07-26 06:56:38 +00:00
|
|
|
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 }}
|
2026-07-25 07:48:49 +00:00
|
|
|
env:
|
|
|
|
|
SERVICE: ${{ matrix.service }}
|
2026-07-26 06:56:38 +00:00
|
|
|
THERMOGRAPH_ENV: prod
|
2026-07-25 07:48:49 +00:00
|
|
|
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."
|