thermograph/.forgejo/workflows/deploy.yml
Emi Griffith fc455a0473
All checks were successful
PR build (required check) / changes (pull_request) Successful in 9s
PR build (required check) / build-backend (pull_request) Has been skipped
PR build (required check) / build-frontend (pull_request) Has been skipped
secrets-guard / encrypted (pull_request) Successful in 7s
shell-lint / shellcheck (pull_request) Successful in 9s
PR build (required check) / validate-observability (pull_request) Has been skipped
PR build (required check) / gate (pull_request) Successful in 2s
ci: collapse the eight deploy and build-push workflows into two
The six deploy workflows were one file written six times, differing only in a
branch name, a paths filter, a concurrency group, the service name, its
*_IMAGE_TAG variable and a secret prefix. The two build-push workflows were the
same file twice, differing only in the domain string. 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 eight
files to keep in step.

deploy.yml: branch selects the environment (main -> beta, release -> prod), a
matrix covers backend and frontend, and each leg decides whether this push
actually touched its domain before rolling anything. The workflow-level paths
filter only says backend OR frontend moved; without the per-leg refinement a
backend-only push would also roll the frontend and lose the independent-deploy
property the FE/BE split exists for.

build-push.yml: the same shape for images. Images stay separate and
independently deployable; nothing about the published artefacts changes.

The two *-deploy-dev.yml workflows are deleted rather than ported. They were
already documented as inert -- they call a monorepo path on the LAN box whose
~/thermograph-dev is still a split-era thermograph-infra checkout. LAN dev is a
local `make dev-up` concern, not a CI environment.

Deliberately boring expressions throughout. No dynamic matrix (fromJSON), and
the *_IMAGE_TAG selection is done in shell rather than with a
`matrix.service == 'x' && a || b` ternary. Those are GitHub idioms a Forgejo/act
runner may evaluate differently, and the failure mode is silent: an empty
*_IMAGE_TAG makes deploy.sh fall back to the tag already running, so the job
goes green having deployed nothing. Beta and prod get two explicit, mutually
exclusive steps rather than a ternary over secrets, where an empty host would be
worse still.

Everything load-bearing is preserved: fetch-depth 0 and the domain-keyed 12-hex
tag (the branch tip is often another domain's commit), per-service per-ref
concurrency with cancel-in-progress false, separate PROD_SSH_* credentials, the
v*.*.* both-images exception, and appleboy/ssh-action by full URL.

pr-build.yml is untouched. Its workflow name and `gate` job are the required
status check branch protection is configured against, and renaming that context
makes every PR unmergeable with fully green CI. secrets-guard.yml and
shell-lint.yml are likewise left alone: they are the only other consolidation
candidates that run on pull_request, and the branch-protection API needs
credentials this could not read, so merging them was not worth the risk for two
files.

Logic verified by replaying the plan step against real history: an infra-only
push rolls nothing, a backend-only push rolls backend and leaves frontend
alone, and a run with no usable before-sha defaults to deploying rather than
silently skipping. The computed frontend tag for a backend-only push is
sha-ca84e0ce95f0 -- exactly the tag live on beta -- so the derivation matches
what the old workflows produced.

15 workflows -> 9, 1365 lines -> 1019. Docs naming the deleted files are
updated in the same commit, per the rule the root CLAUDE.md now carries.
2026-07-25 00:47:41 -07:00

166 lines
7.3 KiB
YAML

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