diff --git a/backend/.forgejo/workflows/build-push.yml b/.forgejo/workflows/backend-build-push.yml similarity index 67% rename from backend/.forgejo/workflows/build-push.yml rename to .forgejo/workflows/backend-build-push.yml index 2a86cd7..b502978 100644 --- a/backend/.forgejo/workflows/build-push.yml +++ b/.forgejo/workflows/backend-build-push.yml @@ -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 }}" diff --git a/.forgejo/workflows/backend-deploy-dev.yml b/.forgejo/workflows/backend-deploy-dev.yml new file mode 100644 index 0000000..cee669a --- /dev/null +++ b/.forgejo/workflows/backend-deploy-dev.yml @@ -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" diff --git a/.forgejo/workflows/backend-deploy-prod.yml b/.forgejo/workflows/backend-deploy-prod.yml new file mode 100644 index 0000000..9df7155 --- /dev/null +++ b/.forgejo/workflows/backend-deploy-prod.yml @@ -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 }} diff --git a/.forgejo/workflows/backend-deploy.yml b/.forgejo/workflows/backend-deploy.yml new file mode 100644 index 0000000..d2cbbf7 --- /dev/null +++ b/.forgejo/workflows/backend-deploy.yml @@ -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 }} diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml new file mode 100644 index 0000000..10da8c3 --- /dev/null +++ b/.forgejo/workflows/build.yml @@ -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 }}/ diff --git a/frontend/.forgejo/workflows/build-push.yml b/.forgejo/workflows/frontend-build-push.yml similarity index 67% rename from frontend/.forgejo/workflows/build-push.yml rename to .forgejo/workflows/frontend-build-push.yml index 0728d71..df6f568 100644 --- a/frontend/.forgejo/workflows/build-push.yml +++ b/.forgejo/workflows/frontend-build-push.yml @@ -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 }}" diff --git a/.forgejo/workflows/frontend-deploy-dev.yml b/.forgejo/workflows/frontend-deploy-dev.yml new file mode 100644 index 0000000..2b745a8 --- /dev/null +++ b/.forgejo/workflows/frontend-deploy-dev.yml @@ -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" diff --git a/.forgejo/workflows/frontend-deploy-prod.yml b/.forgejo/workflows/frontend-deploy-prod.yml new file mode 100644 index 0000000..7a707d2 --- /dev/null +++ b/.forgejo/workflows/frontend-deploy-prod.yml @@ -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 }} diff --git a/.forgejo/workflows/frontend-deploy.yml b/.forgejo/workflows/frontend-deploy.yml new file mode 100644 index 0000000..75c511a --- /dev/null +++ b/.forgejo/workflows/frontend-deploy.yml @@ -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 }} diff --git a/.forgejo/workflows/infra-sync.yml b/.forgejo/workflows/infra-sync.yml new file mode 100644 index 0000000..5c6acfa --- /dev/null +++ b/.forgejo/workflows/infra-sync.yml @@ -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)" diff --git a/.forgejo/workflows/observability-validate.yml b/.forgejo/workflows/observability-validate.yml new file mode 100644 index 0000000..7b6fcc0 --- /dev/null +++ b/.forgejo/workflows/observability-validate.yml @@ -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 diff --git a/infra/.forgejo/workflows/ops-cron.yml b/.forgejo/workflows/ops-cron.yml similarity index 89% rename from infra/.forgejo/workflows/ops-cron.yml rename to .forgejo/workflows/ops-cron.yml index 76f8947..3bed373 100644 --- a/infra/.forgejo/workflows/ops-cron.yml +++ b/.forgejo/workflows/ops-cron.yml @@ -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}" diff --git a/.forgejo/workflows/pr-build.yml b/.forgejo/workflows/pr-build.yml new file mode 100644 index 0000000..578aa9d --- /dev/null +++ b/.forgejo/workflows/pr-build.yml @@ -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; } diff --git a/.forgejo/workflows/secrets-guard.yml b/.forgejo/workflows/secrets-guard.yml new file mode 100644 index 0000000..09b2049 --- /dev/null +++ b/.forgejo/workflows/secrets-guard.yml @@ -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 diff --git a/backend/.forgejo/workflows/build.yml b/backend/.forgejo/workflows/build.yml deleted file mode 100644 index 6701686..0000000 --- a/backend/.forgejo/workflows/build.yml +++ /dev/null @@ -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 . diff --git a/backend/.forgejo/workflows/deploy-dev.yml b/backend/.forgejo/workflows/deploy-dev.yml deleted file mode 100644 index 933f194..0000000 --- a/backend/.forgejo/workflows/deploy-dev.yml +++ /dev/null @@ -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" diff --git a/backend/.forgejo/workflows/deploy-prod.yml b/backend/.forgejo/workflows/deploy-prod.yml deleted file mode 100644 index 0b43338..0000000 --- a/backend/.forgejo/workflows/deploy-prod.yml +++ /dev/null @@ -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 }} diff --git a/backend/.forgejo/workflows/deploy.yml b/backend/.forgejo/workflows/deploy.yml deleted file mode 100644 index 217734f..0000000 --- a/backend/.forgejo/workflows/deploy.yml +++ /dev/null @@ -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 }} diff --git a/backend/.forgejo/workflows/pr-build.yml b/backend/.forgejo/workflows/pr-build.yml deleted file mode 100644 index 0a47eff..0000000 --- a/backend/.forgejo/workflows/pr-build.yml +++ /dev/null @@ -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 diff --git a/frontend/.forgejo/workflows/build.yml b/frontend/.forgejo/workflows/build.yml deleted file mode 100644 index 99b9adf..0000000 --- a/frontend/.forgejo/workflows/build.yml +++ /dev/null @@ -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 . diff --git a/frontend/.forgejo/workflows/deploy-dev.yml b/frontend/.forgejo/workflows/deploy-dev.yml deleted file mode 100644 index fe19fa2..0000000 --- a/frontend/.forgejo/workflows/deploy-dev.yml +++ /dev/null @@ -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" diff --git a/frontend/.forgejo/workflows/deploy-prod.yml b/frontend/.forgejo/workflows/deploy-prod.yml deleted file mode 100644 index de5c176..0000000 --- a/frontend/.forgejo/workflows/deploy-prod.yml +++ /dev/null @@ -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 }} diff --git a/frontend/.forgejo/workflows/deploy.yml b/frontend/.forgejo/workflows/deploy.yml deleted file mode 100644 index 6b8e37b..0000000 --- a/frontend/.forgejo/workflows/deploy.yml +++ /dev/null @@ -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 }} diff --git a/frontend/.forgejo/workflows/pr-build.yml b/frontend/.forgejo/workflows/pr-build.yml deleted file mode 100644 index 435b8e9..0000000 --- a/frontend/.forgejo/workflows/pr-build.yml +++ /dev/null @@ -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 diff --git a/infra/.forgejo/workflows/secrets-guard.yml b/infra/.forgejo/workflows/secrets-guard.yml deleted file mode 100644 index c57907a..0000000 --- a/infra/.forgejo/workflows/secrets-guard.yml +++ /dev/null @@ -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 diff --git a/observability/.forgejo/workflows/validate.yml b/observability/.forgejo/workflows/validate.yml deleted file mode 100644 index 084a861..0000000 --- a/observability/.forgejo/workflows/validate.yml +++ /dev/null @@ -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