CI: port the split repos' workflows to per-domain path-filtered monorepo pipelines

One root workflow set replaces the four repos' copies (deleted -- root-only
is where Forgejo reads them, and dead copies are a trap): per-domain
build-push with explicit image paths (emi/thermograph/backend|frontend; the
old github.repository-derived path collides in a monorepo), path-filtered
per-domain beta/prod/dev deploys, a domain-input reusable build check, a
single always-reporting PR gate (path-filtered required checks deadlock
auto-merge), a new infra-sync pipeline (host checkout + secrets render on
infra/** pushes), and ports of secrets-guard / ops-cron /
observability-validate to monorepo paths.
This commit is contained in:
Emi Griffith 2026-07-22 22:11:33 -07:00
parent f2fd8f6835
commit 7b2db07722
26 changed files with 727 additions and 648 deletions

View file

@ -1,21 +1,26 @@
name: Build + push image (Forgejo registry)
name: Build + push backend image (Forgejo registry)
# Builds THIS repo's image (backend/Dockerfile) and pushes it to Forgejo's
# built-in container registry, tagged by git SHA (every push) and semver
# (version tags) -- build-once, deploy-everywhere.
# Builds the BACKEND domain's image (backend/Dockerfile, context backend/) and
# pushes it to Forgejo's built-in container registry, tagged by git SHA (every
# push) and semver (version tags) -- build-once, deploy-everywhere.
#
# Repo-split "finish separating" (was Stage 6/7): the frontend and backend no
# longer share the single `emi/thermograph/app` image the old monorepo
# build-push.yml produced. Each app repo now publishes its OWN image from its
# OWN Dockerfile -- this one is emi/thermograph-backend/app -- and
# thermograph-infra's compose references the two paths independently
# (BACKEND_IMAGE_PATH / FRONTEND_IMAGE_PATH). IMAGE_PATH is derived from
# github.repository, so this file is byte-identical in the frontend repo; the
# repo it lives in is what makes the image distinct.
# Monorepo port (reunification): the split repos each derived IMAGE_PATH from
# github.repository, which now collides for every domain -- so each domain's
# build-push names its image explicitly: this one is emi/thermograph/backend
# (frontend-build-push.yml publishes emi/thermograph/frontend). infra's
# compose/stack files reference the two paths independently
# (BACKEND_IMAGE_PATH / FRONTEND_IMAGE_PATH -- their defaults were renamed to
# match in the same cutover).
#
# Path-filtered: only a push that touches backend/** builds this image. A
# frontend-only push builds nothing here; deploy.sh's persisted
# .image-tags.env keeps the backend's live tag for the sibling's deploy.
# EXCEPTION: version-tag pushes (v*.*.*) carry no paths context, so a semver
# tag builds BOTH domain images -- intended, a release names the whole set.
#
# Runs on the `docker` label -- the LAN runner's job containers get the host's
# docker.sock automounted in (forgejo-runner config: container.docker_host:
# automount; see thermograph-infra/deploy/forgejo/register-lan-runner.sh),
# automount; see infra/deploy/forgejo/register-lan-runner.sh),
# Docker-outside-of-Docker rather than DinD -- no privileged mode needed.
# The job image itself (node:20-bookworm) has no docker CLI, hence the
# "Install Docker CLI" step.
@ -38,15 +43,18 @@ name: Build + push image (Forgejo registry)
on:
push:
branches: [dev, main, release]
paths: ['backend/**']
tags: ['v*.*.*']
workflow_dispatch: {}
# The runner has capacity: 1 (one physical LAN box) -- a burst of pushes to the
# same ref would otherwise queue whole builds back to back even though only the
# last one still matters. Keying on github.ref means dev/main/release/each tag
# get their own group, so this only cancels a superseded build of the SAME ref.
# last one still matters. Keying on domain + github.ref means each domain's
# dev/main/release/tag builds get their own group, so this only cancels a
# superseded build of the SAME domain on the SAME ref -- and a frontend build
# never cancels a backend one.
concurrency:
group: build-push-${{ github.ref }}
group: backend-build-push-${{ github.ref }}
cancel-in-progress: true
jobs:
@ -54,7 +62,7 @@ jobs:
runs-on: docker
env:
REGISTRY: https://${{ vars.REGISTRY_HOST }}
IMAGE_PATH: ${{ github.repository }}/app
IMAGE_PATH: ${{ github.repository }}/backend
steps:
- uses: actions/checkout@v4
@ -90,11 +98,13 @@ jobs:
run: |
# One build, both tags -- the semver tag (tag pushes only) rides
# along with the SHA tag instead of a second tag+push round trip.
# Context is the DOMAIN dir, so backend/Dockerfile sees the same
# tree it saw as a repo root in the split era.
tag_args=(-t "${{ steps.image.outputs.sha_tag }}")
if [[ -n "${{ steps.image.outputs.semver_tag }}" ]]; then
tag_args+=(-t "${{ steps.image.outputs.semver_tag }}")
fi
docker build "${tag_args[@]}" -f Dockerfile .
docker build "${tag_args[@]}" backend/
- name: Push (SHA tag, every push)
run: docker push "${{ steps.image.outputs.sha_tag }}"

View file

@ -0,0 +1,65 @@
name: Deploy backend to LAN dev server
# Fires on a push to `dev` touching backend/** -- a direct push, or the real
# merge commit a native "auto merge when checks succeed" produces (see
# pr-build.yml). A `build` job (the reusable build.yml sanity check) gates a
# `deploy` job that rolls the LAN dev box.
#
# CUTOVER NOTE (monorepo reunification): the LAN box's ~/thermograph-dev is a
# thermograph-infra checkout from the split era. This job calls
# ~/thermograph-dev/infra/deploy/deploy-dev.sh -- the MONOREPO layout -- so it
# is INERT until ~/thermograph-dev is re-pointed at the monorepo (a fresh
# clone; deploy-dev.sh then self-updates via deploy.sh's git reset like the
# beta/prod paths).
#
# SERVICE=backend + BACKEND_IMAGE_TAG is the same env-var contract the
# beta/prod deploy workflows use against infra/deploy/deploy.sh, so a dev-only
# rollout never touches the frontend's running container or tag.
on:
push:
branches: [dev]
paths: ['backend/**']
workflow_dispatch: {}
jobs:
build:
name: build
# Fixed (unparameterized) group: if two backend pushes to dev land close
# together, the newer push's build cancels the older's still-running one
# on the single physical runner -- the older result would be thrown away
# once needs:build gates its deploy anyway. Safe to cancel mid-run:
# build.yml only runs a throwaway `docker build`, nothing persisted.
concurrency:
group: dev-push-build-backend
cancel-in-progress: true
uses: ./.forgejo/workflows/build.yml
with:
domain: backend
deploy:
needs: build
# NOT [self-hosted, thermograph-lan] -- GitHub implicitly tags every
# self-hosted runner with "self-hosted"; Forgejo's runner has only the
# labels it was registered with ("docker" + "thermograph-lan"). The array
# form silently makes this job unschedulable on every push.
runs-on: thermograph-lan
permissions:
contents: read
concurrency:
group: dev-lan-deploy-backend
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Compute image tag
id: tag
# Same 12-hex truncation the beta/prod deploys use -- must match
# backend-build-push.yml's `sha-${GITHUB_SHA:0:12}` tag exactly.
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy to the LAN dev server
# thermograph-lan is host-native (the runner job runs directly on the
# LAN box, not over SSH like beta/prod), so plain env vars on the
# command reach deploy-dev.sh directly -- no ssh-action needed here.
run: SERVICE=backend BACKEND_IMAGE_TAG=${{ steps.tag.outputs.tag }} bash "$HOME/thermograph-dev/infra/deploy/deploy-dev.sh"

View file

@ -0,0 +1,46 @@
name: Deploy backend to prod VPS
# On a push to `release` that touches backend/**, SSH to prod and roll ONLY
# the backend service onto the image backend-build-push.yml published for this
# commit. Same shape as backend-deploy.yml (the `main`->beta path) against a
# completely separate secret set (PROD_SSH_HOST/USER/KEY/PORT) so a beta
# credential leak can't reach prod. Frontend deploys itself independently via
# frontend-deploy-prod.yml -- a backend change ships to prod without touching
# frontend and vice versa, exactly as in the split era.
#
# The tag MUST match backend-build-push.yml's `sha-${GITHUB_SHA:0:12}` (12
# hex) -- see backend-deploy.yml for why it's truncated here. deploy.sh
# retries the pull because the build for this push may still be in flight.
on:
push:
branches: [release]
paths: ['backend/**']
workflow_dispatch: {}
concurrency:
group: prod-deploy-backend
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy backend over SSH
uses: https://github.com/appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: ${{ secrets.PROD_SSH_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
port: ${{ secrets.PROD_SSH_PORT }}
# Forward the target service + the image tag to run. deploy.sh reads
# BACKEND_IMAGE_TAG and rolls just `backend`.
envs: SERVICE,BACKEND_IMAGE_TAG
script: /opt/thermograph/infra/deploy/deploy.sh
env:
SERVICE: backend
BACKEND_IMAGE_TAG: ${{ steps.tag.outputs.tag }}

View file

@ -0,0 +1,55 @@
name: Deploy backend to beta VPS
# On a push to `main` that touches backend/**, SSH to beta and roll ONLY the
# backend service onto the image backend-build-push.yml just published for
# this commit. Frontend is deployed independently by frontend-deploy.yml --
# the FE/BE CI-CD split survives reunification intact: a backend change ships
# without touching frontend and vice versa. infra/deploy/deploy.sh persists
# each service's live tag host-side, so a single-service roll never disturbs
# the other. An infra-only push rolls nothing (infra-sync.yml syncs the
# host checkouts instead); a mixed backend+frontend push fires both deploy
# workflows, serialized host-side by deploy.sh's flock.
#
# The SSH_HOST/USER/KEY/PORT secrets point at beta. appleboy/ssh-action is
# referenced by full GitHub URL because it isn't mirrored in Forgejo's default
# action registry.
#
# The tag MUST match backend-build-push.yml's `sha-${GITHUB_SHA:0:12}` (12
# hex), not the full 40-char ${{ github.sha }} -- default Actions expressions
# have no substring function, so the compute step below truncates it.
# deploy.sh also retries the pull for ~5 min because Forgejo `needs:` can't
# gate across the separate build-push workflow, so this push's build may still
# be in flight.
on:
push:
branches: [main]
paths: ['backend/**']
workflow_dispatch: {}
concurrency:
group: beta-deploy-backend
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy backend over SSH
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 }}
# Forward the target service + the image tag to run. deploy.sh reads
# BACKEND_IMAGE_TAG and rolls just `backend`.
envs: SERVICE,BACKEND_IMAGE_TAG
script: /opt/thermograph/infra/deploy/deploy.sh
env:
SERVICE: backend
BACKEND_IMAGE_TAG: ${{ steps.tag.outputs.tag }}

View file

@ -0,0 +1,36 @@
name: Build check (reusable)
# Build-only `docker build` sanity check for ONE app domain, reused via
# `uses:` by pr-build.yml and the deploy-dev workflows -- the monorepo port of
# each app repo's build.yml. Still deliberately NOT a live boot+healthz check:
# that was tried in the split repos and repeatedly hit this runner's own
# environment quirks (bridge-IP reachability, non-unique $$, squatted host
# ports) without ever going reliably green. Image boot is verified by hand
# instead (docker run + alembic + /healthz 200 + a real /api/v2/place 200).
#
# Monorepo port: the domain (backend|frontend) arrives as a workflow_call
# input, and the build CONTEXT is that domain's subdirectory -- so each
# Dockerfile sees exactly the tree it saw when its domain was a repo root,
# byte-for-byte the same build as the split era.
on:
workflow_call:
inputs:
domain:
description: "App domain to build: backend | frontend"
required: true
type: string
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install Docker CLI
run: |
apt-get update -qq
apt-get install -y -qq docker.io
- name: Build
run: docker build -t thermograph-${{ inputs.domain }}:ci ${{ inputs.domain }}/

View file

@ -1,21 +1,26 @@
name: Build + push image (Forgejo registry)
name: Build + push frontend image (Forgejo registry)
# Builds THIS repo's image (frontend/Dockerfile) and pushes it to Forgejo's
# built-in container registry, tagged by git SHA (every push) and semver
# (version tags) -- build-once, deploy-everywhere.
# Builds the FRONTEND domain's image (frontend/Dockerfile, context frontend/) and
# pushes it to Forgejo's built-in container registry, tagged by git SHA (every
# push) and semver (version tags) -- build-once, deploy-everywhere.
#
# Repo-split "finish separating" (was Stage 6/7): the frontend and backend no
# longer share the single `emi/thermograph/app` image the old monorepo
# build-push.yml produced. Each app repo now publishes its OWN image from its
# OWN Dockerfile -- this one is emi/thermograph-frontend/app -- and
# thermograph-infra's compose references the two paths independently
# (BACKEND_IMAGE_PATH / FRONTEND_IMAGE_PATH). IMAGE_PATH is derived from
# github.repository, so this file is byte-identical in the frontend repo; the
# repo it lives in is what makes the image distinct.
# Monorepo port (reunification): the split repos each derived IMAGE_PATH from
# github.repository, which now collides for every domain -- so each domain's
# build-push names its image explicitly: this one is emi/thermograph/frontend
# (backend-build-push.yml publishes emi/thermograph/backend). infra's
# compose/stack files reference the two paths independently
# (FRONTEND_IMAGE_PATH / BACKEND_IMAGE_PATH -- their defaults were renamed to
# match in the same cutover).
#
# Path-filtered: only a push that touches frontend/** builds this image. A
# backend-only push builds nothing here; deploy.sh's persisted
# .image-tags.env keeps the frontend's live tag for the sibling's deploy.
# EXCEPTION: version-tag pushes (v*.*.*) carry no paths context, so a semver
# tag builds BOTH domain images -- intended, a release names the whole set.
#
# Runs on the `docker` label -- the LAN runner's job containers get the host's
# docker.sock automounted in (forgejo-runner config: container.docker_host:
# automount; see thermograph-infra/deploy/forgejo/register-lan-runner.sh),
# automount; see infra/deploy/forgejo/register-lan-runner.sh),
# Docker-outside-of-Docker rather than DinD -- no privileged mode needed.
# The job image itself (node:20-bookworm) has no docker CLI, hence the
# "Install Docker CLI" step.
@ -38,15 +43,18 @@ name: Build + push image (Forgejo registry)
on:
push:
branches: [dev, main, release]
paths: ['frontend/**']
tags: ['v*.*.*']
workflow_dispatch: {}
# The runner has capacity: 1 (one physical LAN box) -- a burst of pushes to the
# same ref would otherwise queue whole builds back to back even though only the
# last one still matters. Keying on github.ref means dev/main/release/each tag
# get their own group, so this only cancels a superseded build of the SAME ref.
# last one still matters. Keying on domain + github.ref means each domain's
# dev/main/release/tag builds get their own group, so this only cancels a
# superseded build of the SAME domain on the SAME ref -- and a backend build
# never cancels a frontend one.
concurrency:
group: build-push-${{ github.ref }}
group: frontend-build-push-${{ github.ref }}
cancel-in-progress: true
jobs:
@ -54,7 +62,7 @@ jobs:
runs-on: docker
env:
REGISTRY: https://${{ vars.REGISTRY_HOST }}
IMAGE_PATH: ${{ github.repository }}/app
IMAGE_PATH: ${{ github.repository }}/frontend
steps:
- uses: actions/checkout@v4
@ -90,11 +98,13 @@ jobs:
run: |
# One build, both tags -- the semver tag (tag pushes only) rides
# along with the SHA tag instead of a second tag+push round trip.
# Context is the DOMAIN dir, so frontend/Dockerfile sees the same
# tree it saw as a repo root in the split era.
tag_args=(-t "${{ steps.image.outputs.sha_tag }}")
if [[ -n "${{ steps.image.outputs.semver_tag }}" ]]; then
tag_args+=(-t "${{ steps.image.outputs.semver_tag }}")
fi
docker build "${tag_args[@]}" -f Dockerfile .
docker build "${tag_args[@]}" frontend/
- name: Push (SHA tag, every push)
run: docker push "${{ steps.image.outputs.sha_tag }}"

View file

@ -0,0 +1,65 @@
name: Deploy frontend to LAN dev server
# Fires on a push to `dev` touching frontend/** -- a direct push, or the real
# merge commit a native "auto merge when checks succeed" produces (see
# pr-build.yml). A `build` job (the reusable build.yml sanity check) gates a
# `deploy` job that rolls the LAN dev box.
#
# CUTOVER NOTE (monorepo reunification): the LAN box's ~/thermograph-dev is a
# thermograph-infra checkout from the split era. This job calls
# ~/thermograph-dev/infra/deploy/deploy-dev.sh -- the MONOREPO layout -- so it
# is INERT until ~/thermograph-dev is re-pointed at the monorepo (a fresh
# clone; deploy-dev.sh then self-updates via deploy.sh's git reset like the
# beta/prod paths).
#
# SERVICE=frontend + FRONTEND_IMAGE_TAG is the same env-var contract the
# beta/prod deploy workflows use against infra/deploy/deploy.sh, so a dev-only
# rollout never touches the backend's running container or tag.
on:
push:
branches: [dev]
paths: ['frontend/**']
workflow_dispatch: {}
jobs:
build:
name: build
# Fixed (unparameterized) group: if two frontend pushes to dev land close
# together, the newer push's build cancels the older's still-running one
# on the single physical runner -- the older result would be thrown away
# once needs:build gates its deploy anyway. Safe to cancel mid-run:
# build.yml only runs a throwaway `docker build`, nothing persisted.
concurrency:
group: dev-push-build-frontend
cancel-in-progress: true
uses: ./.forgejo/workflows/build.yml
with:
domain: frontend
deploy:
needs: build
# NOT [self-hosted, thermograph-lan] -- GitHub implicitly tags every
# self-hosted runner with "self-hosted"; Forgejo's runner has only the
# labels it was registered with ("docker" + "thermograph-lan"). The array
# form silently makes this job unschedulable on every push.
runs-on: thermograph-lan
permissions:
contents: read
concurrency:
group: dev-lan-deploy-frontend
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Compute image tag
id: tag
# Same 12-hex truncation the beta/prod deploys use -- must match
# frontend-build-push.yml's `sha-${GITHUB_SHA:0:12}` tag exactly.
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy to the LAN dev server
# thermograph-lan is host-native (the runner job runs directly on the
# LAN box, not over SSH like beta/prod), so plain env vars on the
# command reach deploy-dev.sh directly -- no ssh-action needed here.
run: SERVICE=frontend FRONTEND_IMAGE_TAG=${{ steps.tag.outputs.tag }} bash "$HOME/thermograph-dev/infra/deploy/deploy-dev.sh"

View file

@ -0,0 +1,51 @@
name: Deploy frontend to prod VPS
# On a push to `release` that touches frontend/**, SSH to prod and roll ONLY
# the frontend service onto the image frontend-build-push.yml published for this
# commit. Same shape as frontend-deploy.yml (the `main`->beta path) against a
# completely separate secret set (PROD_SSH_HOST/USER/KEY/PORT) so a beta
# credential leak can't reach prod. Backend deploys itself independently via
# backend-deploy-prod.yml -- a frontend change ships to prod without touching
# backend and vice versa, exactly as in the split era.
#
# The tag MUST match frontend-build-push.yml's `sha-${GITHUB_SHA:0:12}` (12
# hex) -- see frontend-deploy.yml for why it's truncated here. deploy.sh
# retries the pull because the build for this push may still be in flight.
#
# Frontend's own register() fetches the IndexNow key from backend at boot, so
# deploy.sh waits on a healthy backend before declaring the frontend roll OK
# (its compose depends_on already encodes that ordering).
on:
push:
branches: [release]
paths: ['frontend/**']
workflow_dispatch: {}
concurrency:
group: prod-deploy-frontend
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy frontend over SSH
uses: https://github.com/appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: ${{ secrets.PROD_SSH_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
port: ${{ secrets.PROD_SSH_PORT }}
# Forward the target service + the image tag to run. deploy.sh reads
# FRONTEND_IMAGE_TAG and rolls just `frontend`.
envs: SERVICE,FRONTEND_IMAGE_TAG
script: /opt/thermograph/infra/deploy/deploy.sh
env:
SERVICE: frontend
FRONTEND_IMAGE_TAG: ${{ steps.tag.outputs.tag }}

View file

@ -0,0 +1,60 @@
name: Deploy frontend to beta VPS
# On a push to `main` that touches frontend/**, SSH to beta and roll ONLY the
# frontend service onto the image frontend-build-push.yml just published for
# this commit. Backend is deployed independently by backend-deploy.yml --
# the FE/BE CI-CD split survives reunification intact: a frontend change ships
# without touching backend and vice versa. infra/deploy/deploy.sh persists
# each service's live tag host-side, so a single-service roll never disturbs
# the other. An infra-only push rolls nothing (infra-sync.yml syncs the
# host checkouts instead); a mixed frontend+backend push fires both deploy
# workflows, serialized host-side by deploy.sh's flock.
#
# The SSH_HOST/USER/KEY/PORT secrets point at beta. appleboy/ssh-action is
# referenced by full GitHub URL because it isn't mirrored in Forgejo's default
# action registry.
#
# The tag MUST match frontend-build-push.yml's `sha-${GITHUB_SHA:0:12}` (12
# hex), not the full 40-char ${{ github.sha }} -- default Actions expressions
# have no substring function, so the compute step below truncates it.
# deploy.sh also retries the pull for ~5 min because Forgejo `needs:` can't
# gate across the separate build-push workflow, so this push's build may still
# be in flight.
#
# Frontend's own register() fetches the IndexNow key from backend at boot, so
# deploy.sh waits on a healthy backend before declaring the frontend roll OK
# (its compose depends_on already encodes that ordering).
on:
push:
branches: [main]
paths: ['frontend/**']
workflow_dispatch: {}
concurrency:
group: beta-deploy-frontend
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy frontend over SSH
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 }}
# Forward the target service + the image tag to run. deploy.sh reads
# FRONTEND_IMAGE_TAG and rolls just `frontend`.
envs: SERVICE,FRONTEND_IMAGE_TAG
script: /opt/thermograph/infra/deploy/deploy.sh
env:
SERVICE: frontend
FRONTEND_IMAGE_TAG: ${{ steps.tag.outputs.tag }}

View file

@ -0,0 +1,73 @@
name: Sync infra to hosts
# The infra DOMAIN's own pipeline (new with the monorepo -- the split era had
# no infra deploy workflow at all; infra changes rode along lazily with the
# next app deploy's `git reset`). On a push to `main` touching infra/**, SSH
# to beta AND prod, fast-forward the host's /opt/thermograph monorepo checkout
# and re-render /etc/thermograph.env from the SOPS vault -- so a compose edit
# or a secret rotation lands push-button instead of waiting for the next app
# deploy.
#
# Deliberately does NOT roll any service: image tags are the app domains'
# axis, not infra's. A compose change that must recreate containers takes
# effect on the next app deploy, or a by-hand
# `SERVICE=all ... infra/deploy/deploy.sh` run (see that script's header).
#
# Both hosts track infra via `main` (the split-era model, unchanged): prod's
# *app images* are staged by the `release` branch, but its checkout follows
# main -- deploy.sh's own BRANCH default encodes the same thing.
on:
push:
branches: [main]
paths: ['infra/**']
workflow_dispatch: {}
jobs:
sync-beta:
runs-on: docker
concurrency:
group: infra-sync-beta
cancel-in-progress: false
steps:
- name: Sync beta checkout + re-render secrets
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 }}
script: |
set -euo pipefail
cd /opt/thermograph
git fetch --prune origin main
git reset --hard origin/main
if [ -f infra/deploy/render-secrets.sh ]; then
. infra/deploy/render-secrets.sh
render_thermograph_secrets /opt/thermograph/infra
fi
echo "synced to $(git log --oneline -1)"
sync-prod:
runs-on: docker
concurrency:
group: infra-sync-prod
cancel-in-progress: false
steps:
- name: Sync prod checkout + re-render secrets
uses: https://github.com/appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: ${{ secrets.PROD_SSH_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
port: ${{ secrets.PROD_SSH_PORT }}
script: |
set -euo pipefail
cd /opt/thermograph
git fetch --prune origin main
git reset --hard origin/main
if [ -f infra/deploy/render-secrets.sh ]; then
. infra/deploy/render-secrets.sh
render_thermograph_secrets /opt/thermograph/infra
fi
echo "synced to $(git log --oneline -1)"

View file

@ -0,0 +1,72 @@
name: Validate observability stack
# The observability DOMAIN deploys by hand (`docker compose up -d` on beta +
# the Alloy agent on each node) with no build step, so nothing caught a
# malformed compose file, a broken dashboard JSON, or an unparseable config
# until it failed live on the host. This is that missing guard: it parses
# every YAML/JSON artifact that ships to a node. It does NOT deploy, and
# deliberately needs no docker CLI (the node:20-bookworm job image doesn't
# ship one) -- a YAML parse catches the real breakage without it.
#
# Monorepo port: paths are prefixed observability/, the push trigger is
# path-filtered to the domain, and workflow_call lets pr-build.yml reuse this
# as the domain's PR check under its single `gate` required status.
#
# Not validated here: the Alloy config (observability/alloy/config.alloy, an
# HCL-like format, needs the `alloy` binary) and docker-compose *schema*
# (unknown-key checks, which would need the docker CLI). Add either if the
# churn warrants the extra tooling.
on:
workflow_call: {}
push:
branches: [main, dev]
paths: ['observability/**']
jobs:
validate:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Dashboards are valid JSON
run: |
set -euo pipefail
python3 - <<'PY'
import json, pathlib, sys
bad = False
files = sorted(pathlib.Path("observability/grafana/dashboards").glob("*.json"))
if not files:
print("no dashboards found"); sys.exit(1)
for f in files:
try:
json.loads(f.read_text()); print("OK", f)
except Exception as e: # noqa: BLE001
print("INVALID JSON", f, "-", e); bad = True
sys.exit(1 if bad else 0)
PY
- name: YAML artifacts parse (compose + loki + grafana provisioning)
run: |
set -euo pipefail
apt-get update -qq && apt-get install -y -qq python3-yaml >/dev/null
python3 - <<'PY'
import pathlib, sys
import yaml
bad = False
root = pathlib.Path("observability")
targets = [
root / "docker-compose.yml",
root / "alloy/docker-compose.agent.yml",
root / "loki/config.yml",
*sorted((root / "grafana/provisioning").rglob("*.yml")),
]
for f in targets:
if not f.exists():
print("MISSING", f); bad = True; continue
try:
yaml.safe_load(f.read_text()); print("OK", f)
except Exception as e: # noqa: BLE001
print("INVALID YAML", f, "-", e); bad = True
sys.exit(1 if bad else 0)
PY

View file

@ -20,6 +20,11 @@ name: Ops cron (backup + IndexNow)
# beta; prod itself had no backup at all. The prod database is the one that must
# be backed up, so both jobs use PROD_SSH_*.
# Monorepo port: the compose file now lives under infra/ of the host's
# /opt/thermograph monorepo checkout, so every docker-compose invocation cd's
# into /opt/thermograph/infra (compose also pins `name: thermograph` so the
# project name no longer depends on the directory). Everything else unchanged.
on:
schedule:
# 03:00 UTC daily -- a low-traffic window for both jobs.
@ -48,7 +53,7 @@ jobs:
port: ${{ secrets.PROD_SSH_PORT }}
script: |
set -euo pipefail
cd /opt/thermograph
cd /opt/thermograph/infra
# Source the env so `docker compose` can interpolate POSTGRES_PASSWORD;
# without it compose fails to parse docker-compose.yml, the redirect
# still creates the target, and the job leaves a 0-byte .dump and exits
@ -63,7 +68,7 @@ jobs:
# (prod post-cutover); resolve the container either way so the
# backup survives the deploy-mode switch.
dbc=$(docker ps -q --filter "label=com.docker.swarm.service.name=thermograph_db" | head -1)
[ -z "$dbc" ] && dbc=$(cd /opt/thermograph && docker compose ps -q db 2>/dev/null | head -1)
[ -z "$dbc" ] && dbc=$(cd /opt/thermograph/infra && docker compose ps -q db 2>/dev/null | head -1)
[ -n "$dbc" ] || { echo "!! no db container found (compose or stack)"; exit 1; }
# Write to a .partial and rename on success so a mid-dump failure can
# never leave a truncated file that looks like a good backup.
@ -95,10 +100,10 @@ jobs:
port: ${{ secrets.PROD_SSH_PORT }}
script: |
set -euo pipefail
cd /opt/thermograph
cd /opt/thermograph/infra
set -a; . /etc/thermograph.env 2>/dev/null || true; set +a
bec=$(docker ps -q --filter "label=com.docker.swarm.service.name=thermograph_web" | head -1)
[ -z "$bec" ] && bec=$(cd /opt/thermograph && docker compose ps -q backend 2>/dev/null | head -1)
[ -z "$bec" ] && bec=$(cd /opt/thermograph/infra && docker compose ps -q backend 2>/dev/null | head -1)
[ -n "$bec" ] || { echo "!! no backend/web container found"; exit 1; }
docker exec "$bec" python indexnow.py --if-changed \
"${THERMOGRAPH_BASE_URL:-https://thermograph.org}"

View file

@ -0,0 +1,98 @@
name: PR build (required check)
# Monorepo port of the app repos' pr-build.yml: ONE workflow, ONE stable
# required-check name (`gate`), any number of domains. A `changes` job diffs
# the PR against its base; per-domain jobs run only for domains the PR
# touches; `gate` reduces their results into the single status branch
# protection requires.
#
# Deliberately NOT path-filtered at the workflow level: a path-filtered
# required check that never fires leaves the PR stuck "expected -- waiting for
# status" forever, so auto-merge never proceeds. An always-running gate whose
# domain jobs skip cleanly gives the same compute savings without the
# deadlock.
#
# One-time web UI setup (Settings -> Branches, rules for `dev` and `main`):
# -> enable "Enable Status Check", list this workflow's "gate" job
# -> merge style: squash
# Then on a ready PR: "Auto merge when checks succeed".
on:
pull_request:
branches: [dev, main]
concurrency:
group: pr-build-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
changes:
runs-on: docker
outputs:
backend: ${{ steps.diff.outputs.backend }}
frontend: ${{ steps.diff.outputs.frontend }}
observability: ${{ steps.diff.outputs.observability }}
steps:
- uses: actions/checkout@v4
with:
# The PR base commit isn't reachable from a depth-1 clone; full
# history keeps the diff computable however far the branch diverged.
fetch-depth: 0
- name: Detect changed domains
id: diff
run: |
set -euo pipefail
base="${{ github.event.pull_request.base.sha }}"
changed=$(git diff --name-only "$base" "$GITHUB_SHA")
echo "changed files:"; printf '%s\n' "$changed"
for d in backend frontend observability; do
if printf '%s\n' "$changed" | grep -q "^$d/"; then
echo "$d=true" >> "$GITHUB_OUTPUT"
else
echo "$d=false" >> "$GITHUB_OUTPUT"
fi
done
build-backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
uses: ./.forgejo/workflows/build.yml
with:
domain: backend
build-frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
uses: ./.forgejo/workflows/build.yml
with:
domain: frontend
validate-observability:
needs: changes
if: needs.changes.outputs.observability == 'true'
uses: ./.forgejo/workflows/observability-validate.yml
gate:
name: gate
# always(): skipped domain jobs (nothing changed there) must not skip the
# gate itself -- the required check has to report on EVERY PR.
if: always()
needs: [changes, build-backend, build-frontend, validate-observability]
runs-on: docker
steps:
- name: Reduce domain results
run: |
set -euo pipefail
ok=1
for pair in \
"backend=${{ needs.build-backend.result }}" \
"frontend=${{ needs.build-frontend.result }}" \
"observability=${{ needs.validate-observability.result }}"; do
echo "$pair"
case "${pair#*=}" in
success|skipped) ;;
*) ok=0 ;;
esac
done
[ "$ok" = 1 ] || { echo "a changed domain's check failed"; exit 1; }

View file

@ -0,0 +1,41 @@
name: secrets-guard
# Fail the build if any infra/deploy/secrets/*.yaml is committed UNENCRYPTED.
# SOPS stores a `sops:` metadata block and wraps every value in ENC[...]; a
# plaintext leak has neither. This is the backstop against the classic
# "committed the decrypted file by accident" incident. .sops.yaml (the
# plaintext config) and README.md are not *.yaml under infra/deploy/secrets/,
# so they're correctly ignored.
#
# Deliberately NOT path-filtered: it's a ~1s grep, and a backstop that only
# runs when you expect it to isn't a backstop.
on:
pull_request:
push:
branches: [dev, main, release]
jobs:
encrypted:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Assert infra/deploy/secrets/*.yaml are SOPS-encrypted
run: |
set -euo pipefail
shopt -s nullglob
files=(infra/deploy/secrets/*.yaml)
if [ ${#files[@]} -eq 0 ]; then
echo "no infra/deploy/secrets/*.yaml yet — nothing to check"
exit 0
fi
fail=0
for f in "${files[@]}"; do
if grep -q '^sops:' "$f" && grep -q 'ENC\[AES256_GCM' "$f"; then
echo "ok: $f is SOPS-encrypted"
else
echo "::error file=$f::NOT SOPS-encrypted — refusing to accept a plaintext secrets file"
fail=1
fi
done
exit $fail

View file

@ -1,40 +0,0 @@
name: Build check
# Proves the split Dockerfile actually builds (repo-split Stage 7b) -- NOT a
# live boot+healthz check anymore. That was tried and repeatedly hit this
# specific runner's own environment quirks (a sibling container's bridge IP
# is unreachable from the job container; $$ isn't unique across job
# containers; a fixed host port gets permanently squatted by a leftover
# the AppArmor bug won't let anything remove; boot time varies a lot under
# the runner's shared capacity=2 contention) without ever settling into a
# reliably green check. The image DOES boot correctly standalone --
# independently verified by hand: `docker run` + real alembic migrations +
# `/healthz` returning 200 + a real `/api/v2/place` 200. Full pytest-suite
# migration is separate, deferred follow-up work too, same category as
# thermograph-copy's deferred vendoring.
on:
# Reusable (uses: ./.forgejo/workflows/build.yml) by pr-build.yml and
# deploy-dev.yml for the dev-branch flow, AND still directly triggered on
# push/PR to main (unchanged pre-existing behavior) plus dev (new -- so a
# direct push to dev or a PR into dev gets this same check without relying
# solely on the reusable call from the other two files).
workflow_call: {}
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install Docker CLI
run: |
apt-get update -qq
apt-get install -y -qq docker.io
- name: Build
run: docker build -t thermograph-backend:ci .

View file

@ -1,83 +0,0 @@
name: Deploy to LAN dev server
# Fires on every push to `dev` -- a direct push, or the real merge commit a
# native "auto merge when checks succeed" produces (see pr-build.yml). Mirrors
# the monorepo's deploy-dev.yml shape: a `build` job (the build.yml sanity
# check, reused) followed by a `deploy` job that rolls the LAN dev box.
#
# Unlike the monorepo (a single repo, so deploy-dev.sh lived in the same
# checkout this workflow ran `actions/checkout` on), this repo publishes only
# an image (build-push.yml, already triggers on push to dev and tags it
# sha-<12hex> in the Forgejo registry) -- deploy-dev.sh itself now lives in
# the INFRA repo's checkout on the runner host, at ~/thermograph-dev (it
# self-updates there via its own `git reset` against thermograph-infra, same
# pattern deploy.sh/deploy-prod.sh already use for main/release). That means
# this job is INERT until the thermograph-lan box is reprovisioned from its
# current monorepo checkout to an infra checkout -- there is no
# ~/thermograph-dev/deploy/deploy-dev.sh on the box yet for this step to run.
#
# SERVICE=backend + BACKEND_IMAGE_TAG is the same env-var contract deploy.yml/
# deploy-prod.yml use against thermograph-infra's deploy.sh, so a dev-only
# rollout never touches the frontend's running container or tag, exactly like
# the beta/prod paths.
on:
push:
branches: [dev]
workflow_dispatch: {}
jobs:
build:
name: build
# Fixed (unparameterized) group, same convention as the deploy job's
# below: if two pushes to dev land close together, the newer push's
# build cancels the older's still-running one rather than letting both
# run to completion on the single physical runner -- the older push's
# result would just be thrown away once needs:build gates its deploy
# against a stale build anyway. Safe to cancel mid-run: build.yml only
# runs a throwaway `docker build` sanity check, nothing persisted.
concurrency:
group: dev-push-build
cancel-in-progress: true
uses: ./.forgejo/workflows/build.yml
deploy:
needs: build
# NOT [self-hosted, thermograph-lan] -- that's what the original GitHub
# version used, but GitHub implicitly tags every self-hosted runner with
# "self-hosted" automatically; Forgejo's runner has only the labels it
# was explicitly registered with ("docker" + "thermograph-lan", no
# "self-hosted"). The array form silently makes this job unschedulable on
# every push -- see the monorepo's deploy-dev.yml, which documents this
# same bug against its own history.
runs-on: thermograph-lan
# Only needs to read the repo to fetch it into the checkout that computes
# the tag below -- deploy-dev.sh itself lives in the separate INFRA
# checkout on this host, not this repo's checkout.
permissions:
contents: read
concurrency:
group: dev-lan-deploy
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Compute image tag
id: tag
# Same 12-hex truncation deploy.yml/deploy-prod.yml use -- must match
# build-push.yml's `sha-${GITHUB_SHA:0:12}` tag exactly (Actions
# expressions have no substring function for the full 40-char SHA).
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy to the LAN dev server
# thermograph-lan is host-native (the runner job runs directly on the
# LAN box, not over SSH like beta/prod), so plain env vars on the
# command reach deploy-dev.sh directly -- no ssh-action needed here.
#
# INERT until thermograph-lan is reprovisioned: deploy-dev.sh doesn't
# exist yet at this path there (the box is currently a monorepo
# checkout, not an infra checkout) -- see the file-level comment
# above. Once reprovisioned, deploy-dev.sh self-updates via its own
# `git reset` against thermograph-infra before running, the same way
# deploy.sh/deploy-prod.sh already do for the beta/prod paths.
run: SERVICE=backend BACKEND_IMAGE_TAG=${{ steps.tag.outputs.tag }} bash "$HOME/thermograph-dev/deploy/deploy-dev.sh"

View file

@ -1,55 +0,0 @@
name: Deploy backend to prod VPS
# On a push to `release`, SSH to prod and roll ONLY the backend service onto
# the image build-push.yml just published for this commit. This is the
# release->prod path, distinct from deploy.yml's main->beta path -- same
# shape, different branch, different target host, and a completely separate
# secret set (PROD_SSH_HOST/USER/KEY/PORT), so a beta credential leak can't
# touch prod and vice versa. Frontend is deployed independently by its own
# repo's deploy-prod.yml -- that is the whole point of the FE/BE CI-CD split:
# a backend change ships without touching frontend and vice versa.
# thermograph-infra/deploy/deploy.sh persists each service's live tag
# host-side, so a single-service roll never disturbs the other.
#
# The prod SSH user (agent) has full sudo because the SOPS secrets render
# sudo-reads the age key on that box, but that's handled inside deploy.sh --
# nothing extra is needed here. appleboy/ssh-action is referenced by full
# GitHub URL because it isn't mirrored in Forgejo's default action registry.
#
# The tag MUST match build-push.yml's `sha-${GITHUB_SHA:0:12}` (12 hex), not
# the full 40-char ${{ github.sha }} -- default Actions expressions have no
# substring function, so the compute step below truncates it. deploy.sh also
# retries the pull for ~5 min because Forgejo `needs:` can't gate across the
# separate build-push.yml, so this push's build may still be in flight.
on:
push:
branches: [release]
workflow_dispatch: {}
concurrency:
group: prod-deploy-backend
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy backend over SSH
uses: https://github.com/appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: ${{ secrets.PROD_SSH_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
port: ${{ secrets.PROD_SSH_PORT }}
# Forward the target service + the image tag to run. deploy.sh reads
# BACKEND_IMAGE_TAG and rolls just `backend`.
envs: SERVICE,BACKEND_IMAGE_TAG
script: /opt/thermograph/deploy/deploy.sh
env:
SERVICE: backend
BACKEND_IMAGE_TAG: ${{ steps.tag.outputs.tag }}

View file

@ -1,52 +0,0 @@
name: Deploy backend to beta VPS
# On a push to `main`, SSH to beta and roll ONLY the backend service onto the
# image build-push.yml just published for this commit. Frontend is deployed
# independently by its own repo's deploy.yml -- that is the whole point of the
# FE/BE CI-CD split: a backend change ships without touching frontend and vice
# versa. thermograph-infra/deploy/deploy.sh persists each service's live tag
# host-side, so a single-service roll never disturbs the other.
#
# The SSH_HOST/USER/KEY/PORT secrets point at beta. Prod is NOT deployed by a
# workflow -- it's deployed with `terraform apply` on the `release` branch, so
# there is deliberately no release-triggered workflow. appleboy/ssh-action is
# referenced by full GitHub URL because it isn't mirrored in Forgejo's default
# action registry.
#
# The tag MUST match build-push.yml's `sha-${GITHUB_SHA:0:12}` (12 hex), not
# the full 40-char ${{ github.sha }} -- default Actions expressions have no
# substring function, so the compute step below truncates it. deploy.sh also
# retries the pull for ~5 min because Forgejo `needs:` can't gate across the
# separate build-push.yml, so this push's build may still be in flight.
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: beta-deploy-backend
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy backend over SSH
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 }}
# Forward the target service + the image tag to run. deploy.sh reads
# BACKEND_IMAGE_TAG and rolls just `backend`.
envs: SERVICE,BACKEND_IMAGE_TAG
script: /opt/thermograph/deploy/deploy.sh
env:
SERVICE: backend
BACKEND_IMAGE_TAG: ${{ steps.tag.outputs.tag }}

View file

@ -1,30 +0,0 @@
name: PR build (required check)
# Every PR into `dev` runs the build check (build.yml's build-only `docker
# build` sanity check, reused via `uses:`). Mirrors the monorepo's
# pr-build.yml: no custom merge step here -- Forgejo has native branch
# protection + auto-merge, so this workflow's only job is to BE the required
# status check on PRs targeting `dev`.
#
# One-time web UI setup (not expressible in this file):
# Settings -> Branches -> add a protected-branch rule for `dev`
# -> enable "Enable Status Check", list this job's name ("build")
# -> merge style: squash
# Then on a ready PR: "Auto merge when checks succeed".
#
# A native auto-merge is a real git merge, so it fires deploy-dev.yml's push
# trigger naturally -- no separate deploy job needed here, and no "direct push
# vs PR merge" double-trigger to avoid.
on:
pull_request:
branches: [dev]
concurrency:
group: dev-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build:
name: build
uses: ./.forgejo/workflows/build.yml

View file

@ -1,41 +0,0 @@
name: Build check
# Proves the split Dockerfile actually builds (repo-split Stage 7b) -- NOT a
# live boot+healthz check. content.register() fetches the IndexNow key from
# backend at import time with no retry (documented Stage 4 behavior,
# unchanged here); without a real, reachable backend this process crashes
# immediately on import, by design -- so a standalone boot check would either
# be fake (skip the real import path) or require checking out and booting
# thermograph-backend too. That's the real fix (a genuine cross-repo
# contract-test job, same pattern the original repo-split plan already
# scoped for the /cell + ETag contract), not yet built -- flagged as a real
# gap, not silently skipped. Full pytest-suite migration is separate,
# deferred follow-up work too, same category as thermograph-copy's deferred
# vendoring.
#
# workflow_call added alongside `dev` on both push/pull_request filters
# (dev-branch flow, mirroring the monorepo's build.yml): pr-build.yml (PRs
# into dev) and deploy-dev.yml (pushes to dev) both `uses:` this file as
# their shared build gate, same build-only behavior either way -- direct
# push/PR-to-main triggers are unchanged.
on:
push:
branches: [main, dev]
pull_request:
branches: [main, dev]
workflow_call: {}
jobs:
build:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Install Docker CLI
run: |
apt-get update -qq
apt-get install -y -qq docker.io
- name: Build
run: docker build -t thermograph-frontend:ci .

View file

@ -1,62 +0,0 @@
name: Deploy to LAN dev server
# Fires on every push to `dev` -- a direct push, or the real merge commit a
# native "auto merge when checks succeed" produces (see pr-build.yml). Mirrors
# the monorepo's .forgejo/workflows/deploy-dev.yml, split to roll only the
# frontend service.
#
# INERT until the LAN dev box is reprovisioned: deploy-dev.sh does not live in
# this repo's checkout -- it lives in the INFRA checkout at
# $HOME/thermograph-dev on that box (and self-updates there via its own `git
# reset` against thermograph-infra), the same way the monorepo's deploy-dev.sh
# is invoked. That box is currently provisioned as a thermograph (monorepo)
# checkout, not a thermograph-infra one, so until it's reprovisioned this
# job's deploy step has nothing to run against and will fail at the shell
# call below -- expected, not a bug in this workflow.
on:
push:
branches: [dev]
workflow_dispatch: {}
jobs:
build:
name: build
# Fixed (unparameterized) group, same convention as the deploy job's
# below: if two pushes to dev land close together, the newer push's
# build cancels the older's still-running one rather than letting both
# run to completion on the single physical runner -- the older push's
# result would just be thrown away once needs:build gates its deploy
# against a stale build anyway. Safe to cancel mid-run: build.yml only
# proves the Dockerfile builds, nothing persisted.
concurrency:
group: dev-push-build
cancel-in-progress: true
uses: ./.forgejo/workflows/build.yml
deploy:
needs: build
# NOT [self-hosted, thermograph-lan] -- GitHub implicitly tags every
# self-hosted runner with "self-hosted" automatically; Forgejo's runner
# has only the labels it was explicitly registered with ("docker" +
# "thermograph-lan", no "self-hosted"). The array form would silently
# make this job unschedulable on every push, same gotcha the monorepo's
# deploy-dev.yml documents and fixes the same way.
runs-on: thermograph-lan
# Only needs to read the repo to compute the SHA tag.
permissions:
contents: read
concurrency:
group: dev-lan-deploy
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy to the LAN dev server
run: |
SERVICE=frontend FRONTEND_IMAGE_TAG=${{ steps.tag.outputs.tag }} \
bash "$HOME/thermograph-dev/deploy/deploy-dev.sh"

View file

@ -1,59 +0,0 @@
name: Deploy frontend to prod VPS
# On a push to `release`, SSH to prod and roll ONLY the frontend service onto
# the image build-push.yml just published for this commit. This is the
# release->prod path, distinct from deploy.yml's main->beta path -- same
# shape, different branch, different target host, and a completely separate
# secret set (PROD_SSH_HOST/USER/KEY/PORT), so a beta credential leak can't
# touch prod and vice versa. Backend is deployed independently by its own
# repo's deploy-prod.yml -- that is the whole point of the FE/BE CI-CD split:
# a frontend change ships without touching backend and vice versa.
# thermograph-infra/deploy/deploy.sh persists each service's live tag
# host-side, so a single-service roll never disturbs the other.
#
# The prod SSH user (agent) has full sudo because the SOPS secrets render
# sudo-reads the age key on that box, but that's handled inside deploy.sh --
# nothing extra is needed here. appleboy/ssh-action is referenced by full
# GitHub URL because it isn't mirrored in Forgejo's default action registry.
#
# The tag MUST match build-push.yml's `sha-${GITHUB_SHA:0:12}` (12 hex), not
# the full 40-char ${{ github.sha }} -- default Actions expressions have no
# substring function, so the compute step below truncates it. deploy.sh also
# retries the pull for ~5 min because Forgejo `needs:` can't gate across the
# separate build-push.yml, so this push's build may still be in flight.
#
# Frontend's own register() fetches the IndexNow key from backend at boot, so
# deploy.sh waits on a healthy backend before declaring the frontend roll OK
# (its compose depends_on already encodes that ordering).
on:
push:
branches: [release]
workflow_dispatch: {}
concurrency:
group: prod-deploy-frontend
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy frontend over SSH
uses: https://github.com/appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.PROD_SSH_HOST }}
username: ${{ secrets.PROD_SSH_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
port: ${{ secrets.PROD_SSH_PORT }}
# Forward the target service + the image tag to run. deploy.sh reads
# FRONTEND_IMAGE_TAG and rolls just `frontend`.
envs: SERVICE,FRONTEND_IMAGE_TAG
script: /opt/thermograph/deploy/deploy.sh
env:
SERVICE: frontend
FRONTEND_IMAGE_TAG: ${{ steps.tag.outputs.tag }}

View file

@ -1,56 +0,0 @@
name: Deploy frontend to beta VPS
# On a push to `main`, SSH to beta and roll ONLY the frontend service onto the
# image build-push.yml just published for this commit. Backend is deployed
# independently by its own repo's deploy.yml -- that is the whole point of the
# FE/BE CI-CD split: a frontend change ships without touching backend and vice
# versa. thermograph-infra/deploy/deploy.sh persists each service's live tag
# host-side, so a single-service roll never disturbs the other.
#
# The SSH_HOST/USER/KEY/PORT secrets point at beta. Prod is NOT deployed by a
# workflow -- it's deployed with `terraform apply` on the `release` branch, so
# there is deliberately no release-triggered workflow. appleboy/ssh-action is
# referenced by full GitHub URL because it isn't mirrored in Forgejo's default
# action registry.
#
# The tag MUST match build-push.yml's `sha-${GITHUB_SHA:0:12}` (12 hex), not
# the full 40-char ${{ github.sha }} -- default Actions expressions have no
# substring function, so the compute step below truncates it. deploy.sh also
# retries the pull for ~5 min because Forgejo `needs:` can't gate across the
# separate build-push.yml, so this push's build may still be in flight.
#
# Frontend's own register() fetches the IndexNow key from backend at boot, so
# deploy.sh waits on a healthy backend before declaring the frontend roll OK
# (its compose depends_on already encodes that ordering).
on:
push:
branches: [main]
workflow_dispatch: {}
concurrency:
group: beta-deploy-frontend
cancel-in-progress: false
jobs:
deploy:
runs-on: docker
steps:
- name: Compute image tag
id: tag
run: echo "tag=sha-${GITHUB_SHA:0:12}" >> "$GITHUB_OUTPUT"
- name: Deploy frontend over SSH
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 }}
# Forward the target service + the image tag to run. deploy.sh reads
# FRONTEND_IMAGE_TAG and rolls just `frontend`.
envs: SERVICE,FRONTEND_IMAGE_TAG
script: /opt/thermograph/deploy/deploy.sh
env:
SERVICE: frontend
FRONTEND_IMAGE_TAG: ${{ steps.tag.outputs.tag }}

View file

@ -1,28 +0,0 @@
name: PR build (required check)
# Every PR into `dev` runs the build check. Mirrors the monorepo's
# .forgejo/workflows/pr-build.yml -- Forgejo has native branch protection +
# auto-merge, so this workflow's only job is to BE the required status check;
# there is no custom merge step here.
#
# One-time web UI setup (not expressible in this file):
# Settings -> Branches -> add a protected-branch rule for `dev`
# -> enable "Enable Status Check", list this job's name ("build")
# -> merge style: squash
# Then on a ready PR: "Auto merge when checks succeed".
#
# A native auto-merge is a real git merge, so it fires deploy-dev.yml's push
# trigger naturally -- no separate deploy job needed here.
on:
pull_request:
branches: [dev]
concurrency:
group: dev-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
build:
name: build
uses: ./.forgejo/workflows/build.yml

View file

@ -1,36 +0,0 @@
name: secrets-guard
# Fail the build if any deploy/secrets/*.yaml is committed UNENCRYPTED. SOPS stores a
# `sops:` metadata block and wraps every value in ENC[...]; a plaintext leak has
# neither. This is the backstop against the classic "committed the decrypted file by
# accident" incident. .sops.yaml (the plaintext config) and README.md are not *.yaml
# under deploy/secrets/, so they're correctly ignored.
on:
pull_request:
push:
branches: [dev, main, release]
jobs:
encrypted:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Assert deploy/secrets/*.yaml are SOPS-encrypted
run: |
set -euo pipefail
shopt -s nullglob
files=(deploy/secrets/*.yaml)
if [ ${#files[@]} -eq 0 ]; then
echo "no deploy/secrets/*.yaml yet — nothing to check"
exit 0
fi
fail=0
for f in "${files[@]}"; do
if grep -q '^sops:' "$f" && grep -q 'ENC\[AES256_GCM' "$f"; then
echo "ok: $f is SOPS-encrypted"
else
echo "::error file=$f::NOT SOPS-encrypted — refusing to accept a plaintext secrets file"
fail=1
fi
done
exit $fail

View file

@ -1,66 +0,0 @@
name: Validate observability stack
# This repo deploys by hand (`docker compose up -d` on beta + the Alloy agent on
# each node) with no build step, so nothing caught a malformed compose file, a
# broken dashboard JSON, or an unparseable config until it failed live on the
# host. This is that missing guard: it parses every YAML/JSON artifact that ships
# to a node. It does NOT deploy, and deliberately needs no docker CLI (the
# node:20-bookworm job image doesn't ship one) — a YAML parse catches the real
# breakage (bad indent / unparseable file) without it.
#
# Not validated here: the Alloy config (alloy/config.alloy, an HCL-like format,
# needs the `alloy` binary) and docker-compose *schema* (unknown-key checks, which
# would need the docker CLI). Add either if the churn warrants the extra tooling.
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
validate:
runs-on: docker
steps:
- uses: actions/checkout@v4
- name: Dashboards are valid JSON
run: |
set -euo pipefail
python3 - <<'PY'
import json, pathlib, sys
bad = False
files = sorted(pathlib.Path("grafana/dashboards").glob("*.json"))
if not files:
print("no dashboards found"); sys.exit(1)
for f in files:
try:
json.loads(f.read_text()); print("OK", f)
except Exception as e: # noqa: BLE001
print("INVALID JSON", f, "-", e); bad = True
sys.exit(1 if bad else 0)
PY
- name: YAML artifacts parse (compose + loki + grafana provisioning)
run: |
set -euo pipefail
apt-get update -qq && apt-get install -y -qq python3-yaml >/dev/null
python3 - <<'PY'
import pathlib, sys
import yaml
bad = False
targets = [
pathlib.Path("docker-compose.yml"),
pathlib.Path("alloy/docker-compose.agent.yml"),
pathlib.Path("loki/config.yml"),
*sorted(pathlib.Path("grafana/provisioning").rglob("*.yml")),
]
for f in targets:
if not f.exists():
print("MISSING", f); bad = True; continue
try:
yaml.safe_load(f.read_text()); print("OK", f)
except Exception as e: # noqa: BLE001
print("INVALID YAML", f, "-", e); bad = True
sys.exit(1 if bad else 0)
PY