Merge pull request 'promote: dev → main (CI consolidation, 15 workflows → 9)' (#90) from dev into main
All checks were successful
shell-lint / shellcheck (pull_request) Successful in 9s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 6s
Deploy / deploy (frontend) (push) Successful in 12s
Sync infra to hosts / sync-beta (push) Successful in 12s
Sync infra to hosts / sync-prod (push) Successful in 9s
secrets-guard / encrypted (push) Successful in 13s
Deploy / deploy (backend) (push) Successful in 50s
shell-lint / shellcheck (push) Successful in 14s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 46s
secrets-guard / encrypted (pull_request) Successful in 7s
All checks were successful
shell-lint / shellcheck (pull_request) Successful in 9s
Build + push images (Forgejo registry) / build-push (frontend) (push) Successful in 6s
Deploy / deploy (frontend) (push) Successful in 12s
Sync infra to hosts / sync-beta (push) Successful in 12s
Sync infra to hosts / sync-prod (push) Successful in 9s
secrets-guard / encrypted (push) Successful in 13s
Deploy / deploy (backend) (push) Successful in 50s
shell-lint / shellcheck (push) Successful in 14s
Build + push images (Forgejo registry) / build-push (backend) (push) Successful in 46s
secrets-guard / encrypted (pull_request) Successful in 7s
This commit is contained in:
commit
aa4ee7f4c4
17 changed files with 398 additions and 752 deletions
|
|
@ -1,126 +0,0 @@
|
||||||
name: Build + push backend image (Forgejo registry)
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
#
|
|
||||||
# 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 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.
|
|
||||||
#
|
|
||||||
# The registry is deliberately mesh-only: git.thermograph.org's public DNS
|
|
||||||
# resolves to beta's public IP, which Caddy's ACL rejects for /v2/ paths, so
|
|
||||||
# any runner host that pushes/pulls needs a /etc/hosts entry pointing
|
|
||||||
# git.thermograph.org at beta's WireGuard IP (10.10.0.2). The docker
|
|
||||||
# build/push commands run against the automounted HOST daemon, so it's the
|
|
||||||
# runner HOST's own resolver that has to route over the mesh, not anything
|
|
||||||
# settable from inside the job container.
|
|
||||||
#
|
|
||||||
# REGISTRY is the vars.REGISTRY_HOST Actions variable (git.thermograph.org),
|
|
||||||
# NOT github.server_url -- that context resolves to the bare mesh IP:port
|
|
||||||
# (http://10.10.0.2:3080, no TLS), which docker CLI refuses ("server gave HTTP
|
|
||||||
# response to HTTPS client"). git.thermograph.org gets real TLS via Caddy and
|
|
||||||
# routes over the mesh once /etc/hosts is set, so it's the only correct value.
|
|
||||||
# deploy.sh resolves the SHA tag at deploy time and `docker compose pull`s it.
|
|
||||||
|
|
||||||
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 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: backend-build-push-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
runs-on: docker
|
|
||||||
env:
|
|
||||||
REGISTRY: https://${{ vars.REGISTRY_HOST }}
|
|
||||||
IMAGE_PATH: ${{ github.repository }}/backend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
||||||
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
||||||
# often an unrelated domain's commit, and a depth-1 clone can't see
|
|
||||||
# past it to find the real key.
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Docker CLI
|
|
||||||
run: |
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq docker.io
|
|
||||||
|
|
||||||
- name: Compute image ref + tags
|
|
||||||
id: image
|
|
||||||
run: |
|
|
||||||
host="${REGISTRY#https://}"; host="${host#http://}"
|
|
||||||
path="$(printf '%s' "$IMAGE_PATH" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
ref="$host/$path"
|
|
||||||
# Key the tag to the last commit that touched backend/ -- the SAME key
|
|
||||||
# every backend deploy workflow computes -- so build and deploy always
|
|
||||||
# agree even when the branch tip is another domain's commit. (A tag
|
|
||||||
# keyed to the push tip breaks the moment an infra-only commit lands:
|
|
||||||
# deploys go looking for an image no build ever produced.)
|
|
||||||
domain_sha="$(git log -1 --format=%H -- backend/)"
|
|
||||||
sha_tag="$ref:sha-${domain_sha:0:12}"
|
|
||||||
echo "host=$host" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "sha_tag=$sha_tag" >> "$GITHUB_OUTPUT"
|
|
||||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
|
||||||
echo "semver_tag=$ref:${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to the Forgejo registry
|
|
||||||
run: |
|
|
||||||
# NOT secrets.GITHUB_TOKEN -- Forgejo's per-job auto-injected token
|
|
||||||
# can never push to the container registry (a known Forgejo
|
|
||||||
# limitation independent of any permission setting). Needs a
|
|
||||||
# manually provisioned PAT with write:package scope, stored as the
|
|
||||||
# REGISTRY_TOKEN secret.
|
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ steps.image.outputs.host }}" \
|
|
||||||
--username emi --password-stdin
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
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[@]}" backend/
|
|
||||||
|
|
||||||
- name: Push (SHA tag, every push)
|
|
||||||
run: docker push "${{ steps.image.outputs.sha_tag }}"
|
|
||||||
|
|
||||||
- name: Push (semver tag, tag pushes only)
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
run: docker push "${{ steps.image.outputs.semver_tag }}"
|
|
||||||
|
|
@ -1,79 +0,0 @@
|
||||||
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
|
|
||||||
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
||||||
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
||||||
# often an unrelated domain's commit, and a depth-1 clone can't see
|
|
||||||
# past it to find the real key.
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Compute image tag
|
|
||||||
id: tag
|
|
||||||
# Keyed to the last commit that touched backend/ -- must match
|
|
||||||
# backend-build-push.yml's tag key exactly (see its comment).
|
|
||||||
run: |
|
|
||||||
domain_sha="$(git log -1 --format=%H -- backend/)"
|
|
||||||
echo "tag=sha-${domain_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.
|
|
||||||
# The lake creds ride the Actions S3 secrets: dev renders no vault
|
|
||||||
# (deploy-dev.sh's no-op secrets path), and compose interpolates
|
|
||||||
# ${THERMOGRAPH_LAKE_S3_*} straight from the deploy environment.
|
|
||||||
env:
|
|
||||||
THERMOGRAPH_LAKE_S3_ACCESS_KEY: ${{ secrets.S3_ACCESS_KEY }}
|
|
||||||
THERMOGRAPH_LAKE_S3_SECRET_KEY: ${{ secrets.S3_SECRET_KEY }}
|
|
||||||
run: SERVICE=backend BACKEND_IMAGE_TAG=${{ steps.tag.outputs.tag }} bash "$HOME/thermograph-dev/infra/deploy/deploy-dev.sh"
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
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 last-backend-commit key (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:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
||||||
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
||||||
# often an unrelated domain's commit, and a depth-1 clone can't see
|
|
||||||
# past it to find the real key.
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Compute image tag (last backend-touching commit)
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
domain_sha="$(git log -1 --format=%H -- backend/)"
|
|
||||||
echo "tag=sha-${domain_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 }}
|
|
||||||
|
|
@ -1,65 +0,0 @@
|
||||||
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:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
||||||
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
||||||
# often an unrelated domain's commit, and a depth-1 clone can't see
|
|
||||||
# past it to find the real key.
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Compute image tag (last backend-touching commit)
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
domain_sha="$(git log -1 --format=%H -- backend/)"
|
|
||||||
echo "tag=sha-${domain_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 }}
|
|
||||||
144
.forgejo/workflows/build-push.yml
Normal file
144
.forgejo/workflows/build-push.yml
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
name: Build + push images (Forgejo registry)
|
||||||
|
|
||||||
|
# backend-build-push.yml and frontend-build-push.yml, collapsed into one.
|
||||||
|
# They were identical apart from the domain string: the paths filter, the
|
||||||
|
# concurrency group, IMAGE_PATH, the tag key and the build context.
|
||||||
|
#
|
||||||
|
# Images stay separate and independently deployable -- emi/thermograph/backend
|
||||||
|
# and emi/thermograph/frontend -- exactly as before. Build-once,
|
||||||
|
# deploy-everywhere; nothing about the published artefacts changes here.
|
||||||
|
#
|
||||||
|
# SEMVER EXCEPTION, preserved: a v*.*.* tag push carries no paths context, so a
|
||||||
|
# release tag builds BOTH domain images. That is intended -- a release names the
|
||||||
|
# whole set, not whichever domain happened to move last.
|
||||||
|
#
|
||||||
|
# Runs on the `docker` label. The LAN runner's job containers get the host's
|
||||||
|
# docker.sock automounted (Docker-outside-of-Docker, no privileged mode); the
|
||||||
|
# job image (node:20-bookworm) ships no docker CLI, hence the install step.
|
||||||
|
#
|
||||||
|
# The registry is deliberately mesh-only: git.thermograph.org's public DNS
|
||||||
|
# resolves to beta's public IP, whose Caddy ACL rejects /v2/ paths, so any
|
||||||
|
# runner host that pushes or pulls needs an /etc/hosts entry.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev, main, release]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
tags: ['v*.*.*']
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-push:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
# capacity: 1 physical runner. Serialising keeps a two-domain push from
|
||||||
|
# queueing two whole image builds against each other.
|
||||||
|
max-parallel: 1
|
||||||
|
matrix:
|
||||||
|
domain: [backend, frontend]
|
||||||
|
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
# Per-domain, per-ref: only a superseded build of the SAME domain on the
|
||||||
|
# SAME ref is cancelled, and a frontend build never cancels a backend one.
|
||||||
|
concurrency:
|
||||||
|
group: build-push-${{ matrix.domain }}-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: https://${{ vars.REGISTRY_HOST }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
||||||
|
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
||||||
|
# often an unrelated domain's commit, and a depth-1 clone can't see past
|
||||||
|
# it to find the real key.
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Compute image ref + tags
|
||||||
|
id: image
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
host="${REGISTRY#https://}"; host="${host#http://}"
|
||||||
|
path="$(printf '%s/%s' "${{ github.repository }}" "${{ matrix.domain }}" | tr '[:upper:]' '[:lower:]')"
|
||||||
|
ref="$host/$path"
|
||||||
|
|
||||||
|
# Does this push touch this domain? The workflow-level paths filter
|
||||||
|
# only says backend OR frontend moved; without this a backend-only
|
||||||
|
# push would also rebuild and republish the frontend image.
|
||||||
|
before="${{ github.event.before }}"
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
changed=true # semver tag: build the whole set, see header
|
||||||
|
elif [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] \
|
||||||
|
|| ! git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||||
|
changed=true # no usable base; build rather than silently skip
|
||||||
|
elif git diff --name-only "$before" "${{ github.sha }}" | grep -q "^${{ matrix.domain }}/"; then
|
||||||
|
changed=true
|
||||||
|
else
|
||||||
|
changed=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Key the tag to the last commit that touched this domain -- the SAME
|
||||||
|
# key every deploy computes -- so build and deploy always agree even
|
||||||
|
# when the branch tip is another domain's commit. (A tag keyed to the
|
||||||
|
# push tip breaks the moment an infra-only commit lands: deploys go
|
||||||
|
# looking for an image no build ever produced.)
|
||||||
|
domain_sha="$(git log -1 --format=%H -- "${{ matrix.domain }}/")"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "changed=$changed"
|
||||||
|
echo "host=$host"
|
||||||
|
echo "sha_tag=$ref:sha-${domain_sha:0:12}"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
|
echo "semver_tag=$ref:${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> ${{ matrix.domain }} | changed=$changed | $ref:sha-${domain_sha:0:12}"
|
||||||
|
|
||||||
|
- name: Install Docker CLI
|
||||||
|
if: steps.image.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
apt-get update -qq
|
||||||
|
apt-get install -y -qq docker.io
|
||||||
|
|
||||||
|
- name: Log in to the Forgejo registry
|
||||||
|
if: steps.image.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
# NOT secrets.GITHUB_TOKEN -- Forgejo's per-job auto-injected token can
|
||||||
|
# never push to the container registry (a known Forgejo limitation,
|
||||||
|
# independent of any permission setting). Needs a manually provisioned
|
||||||
|
# PAT with write:package scope, stored as REGISTRY_TOKEN.
|
||||||
|
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ steps.image.outputs.host }}" \
|
||||||
|
--username emi --password-stdin
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
if: steps.image.outputs.changed == 'true'
|
||||||
|
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 the 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[@]}" "${{ matrix.domain }}/"
|
||||||
|
|
||||||
|
- name: Push (SHA tag, every push)
|
||||||
|
if: steps.image.outputs.changed == 'true'
|
||||||
|
run: docker push "${{ steps.image.outputs.sha_tag }}"
|
||||||
|
|
||||||
|
- name: Push (semver tag, tag pushes only)
|
||||||
|
if: steps.image.outputs.changed == 'true' && startsWith(github.ref, 'refs/tags/v')
|
||||||
|
run: docker push "${{ steps.image.outputs.semver_tag }}"
|
||||||
|
|
||||||
|
- name: Skipped
|
||||||
|
if: steps.image.outputs.changed != 'true'
|
||||||
|
run: echo "${{ matrix.domain }} unchanged in this push — no image built."
|
||||||
166
.forgejo/workflows/deploy.yml
Normal file
166
.forgejo/workflows/deploy.yml
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
name: Deploy
|
||||||
|
|
||||||
|
# The six per-domain-per-environment deploy workflows, collapsed into one.
|
||||||
|
#
|
||||||
|
# backend-deploy{,-dev,-prod}.yml and frontend-deploy{,-dev,-prod}.yml were the
|
||||||
|
# same file written six times: they differed only in a branch name, a paths
|
||||||
|
# filter, a concurrency group, the service name, its *_IMAGE_TAG variable and a
|
||||||
|
# secret prefix. The contract into infra/deploy/deploy.sh
|
||||||
|
# (SERVICE + BACKEND_IMAGE_TAG/FRONTEND_IMAGE_TAG) was already fully
|
||||||
|
# parameterised, so the duplication bought nothing and cost six files to keep in
|
||||||
|
# step.
|
||||||
|
#
|
||||||
|
# The two *-deploy-dev.yml workflows are NOT ported. They were documented as
|
||||||
|
# inert: they call the monorepo layout at ~/thermograph-dev on the LAN box,
|
||||||
|
# which is still a split-era thermograph-infra checkout, so that path does not
|
||||||
|
# exist there. LAN dev is a local `make dev-up` concern, not a CI environment.
|
||||||
|
#
|
||||||
|
# DELIBERATELY BORING EXPRESSIONS. No dynamic matrix (fromJSON), no
|
||||||
|
# `cond && secrets.A || secrets.B` ternary. Those are GitHub idioms that a
|
||||||
|
# Forgejo/act runner may evaluate differently, and the failure mode here is
|
||||||
|
# "production does not deploy" or, worse, "deploys with an empty SSH host". The
|
||||||
|
# two environments therefore get two explicit, mutually exclusive steps.
|
||||||
|
#
|
||||||
|
# What is preserved from the originals, all of it load-bearing:
|
||||||
|
# - fetch-depth: 0, because the image tag is keyed to the LAST COMMIT THAT
|
||||||
|
# TOUCHED THAT DOMAIN, not the branch tip. In a path-filtered monorepo the
|
||||||
|
# tip is often an unrelated domain's commit and a depth-1 clone cannot see
|
||||||
|
# past it.
|
||||||
|
# - The 12-hex truncation, matching build-push exactly.
|
||||||
|
# - Per-service, per-environment concurrency with cancel-in-progress: false --
|
||||||
|
# a half-finished deploy must never be cancelled by a newer one.
|
||||||
|
# - Separate PROD_SSH_* credentials, so a beta credential leak cannot reach
|
||||||
|
# prod.
|
||||||
|
# - appleboy/ssh-action by full URL; it is not mirrored in Forgejo's default
|
||||||
|
# action registry.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, release]
|
||||||
|
paths:
|
||||||
|
- 'backend/**'
|
||||||
|
- 'frontend/**'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
# One physical runner, and deploy.sh serialises host-side with flock
|
||||||
|
# anyway. Rolling one service at a time keeps the logs readable and
|
||||||
|
# matches what the six separate workflows effectively did.
|
||||||
|
max-parallel: 1
|
||||||
|
matrix:
|
||||||
|
service: [backend, frontend]
|
||||||
|
|
||||||
|
runs-on: docker
|
||||||
|
|
||||||
|
# Keyed by ref AND service, reproducing the old per-file groups
|
||||||
|
# (beta-deploy-backend, prod-deploy-frontend, ...).
|
||||||
|
concurrency:
|
||||||
|
group: deploy-${{ github.ref_name }}-${{ matrix.service }}
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Plan this leg
|
||||||
|
id: plan
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Branch selects the environment. main -> beta, release -> prod.
|
||||||
|
case "${{ github.ref_name }}" in
|
||||||
|
main) environment=beta ;;
|
||||||
|
release) environment=prod ;;
|
||||||
|
*) echo "::error::Deploy triggered on unexpected ref '${{ github.ref_name }}'"; exit 1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
# Did THIS push touch THIS domain? The workflow-level paths filter only
|
||||||
|
# tells us backend OR frontend moved; without this refinement a
|
||||||
|
# backend-only push would also roll the frontend, losing the
|
||||||
|
# independent-deploy property the FE/BE split exists for.
|
||||||
|
before="${{ github.event.before }}"
|
||||||
|
if [ -z "$before" ] || [ "$before" = "0000000000000000000000000000000000000000" ] \
|
||||||
|
|| ! git cat-file -e "$before^{commit}" 2>/dev/null; then
|
||||||
|
# No usable base (first push, force push, or workflow_dispatch).
|
||||||
|
# Deploy rather than skip: rolling onto the tag already running is a
|
||||||
|
# no-op for deploy.sh, whereas skipping silently strands a change.
|
||||||
|
changed=true
|
||||||
|
echo "no usable before-sha; defaulting to deploy"
|
||||||
|
elif git diff --name-only "$before" "${{ github.sha }}" | grep -q "^${{ matrix.service }}/"; then
|
||||||
|
changed=true
|
||||||
|
else
|
||||||
|
changed=false
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The tag is the last commit that touched this domain, truncated to 12
|
||||||
|
# hex to match build-push.yml exactly. Actions expressions have no
|
||||||
|
# substring function, which is why this is computed in shell.
|
||||||
|
domain_sha="$(git log -1 --format=%H -- "${{ matrix.service }}/")"
|
||||||
|
|
||||||
|
tag="sha-${domain_sha:0:12}"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "environment=$environment"
|
||||||
|
echo "changed=$changed"
|
||||||
|
echo "tag=$tag"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# Export the deploy.sh contract as real environment variables, chosen
|
||||||
|
# in shell rather than with a `matrix.service == 'x' && a || b`
|
||||||
|
# expression. That idiom is a GitHub convention a Forgejo/act runner
|
||||||
|
# may evaluate differently, and the failure here would be silent: an
|
||||||
|
# empty *_IMAGE_TAG makes deploy.sh fall back to the tag already
|
||||||
|
# running, so the job goes green having deployed nothing.
|
||||||
|
{
|
||||||
|
echo "SERVICE=${{ matrix.service }}"
|
||||||
|
if [ "${{ matrix.service }}" = "backend" ]; then
|
||||||
|
echo "BACKEND_IMAGE_TAG=$tag"
|
||||||
|
else
|
||||||
|
echo "FRONTEND_IMAGE_TAG=$tag"
|
||||||
|
fi
|
||||||
|
} >> "$GITHUB_ENV"
|
||||||
|
|
||||||
|
echo "==> ${{ matrix.service }} -> $environment | changed=$changed | tag=$tag"
|
||||||
|
|
||||||
|
- name: Deploy to beta
|
||||||
|
if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'beta'
|
||||||
|
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST }}
|
||||||
|
username: ${{ secrets.SSH_USER }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: ${{ secrets.SSH_PORT }}
|
||||||
|
envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG
|
||||||
|
script: /opt/thermograph/infra/deploy/deploy.sh
|
||||||
|
env:
|
||||||
|
SERVICE: ${{ matrix.service }}
|
||||||
|
# Only the matching one is read by deploy.sh for a single-service roll;
|
||||||
|
# the other stays empty and the persisted .image-tags.env supplies the
|
||||||
|
# sibling's live tag, so this roll never disturbs it.
|
||||||
|
BACKEND_IMAGE_TAG: ${{ matrix.service == 'backend' && steps.plan.outputs.tag || '' }}
|
||||||
|
FRONTEND_IMAGE_TAG: ${{ matrix.service == 'frontend' && steps.plan.outputs.tag || '' }}
|
||||||
|
|
||||||
|
- name: Deploy to prod
|
||||||
|
if: steps.plan.outputs.changed == 'true' && steps.plan.outputs.environment == 'prod'
|
||||||
|
uses: https://github.com/appleboy/ssh-action@v1.2.0
|
||||||
|
with:
|
||||||
|
# A completely separate secret set from beta's, deliberately: a beta
|
||||||
|
# credential leak must not reach prod.
|
||||||
|
host: ${{ secrets.PROD_SSH_HOST }}
|
||||||
|
username: ${{ secrets.PROD_SSH_USER }}
|
||||||
|
key: ${{ secrets.PROD_SSH_KEY }}
|
||||||
|
port: ${{ secrets.PROD_SSH_PORT }}
|
||||||
|
envs: SERVICE,BACKEND_IMAGE_TAG,FRONTEND_IMAGE_TAG
|
||||||
|
script: /opt/thermograph/infra/deploy/deploy.sh
|
||||||
|
env:
|
||||||
|
SERVICE: ${{ matrix.service }}
|
||||||
|
BACKEND_IMAGE_TAG: ${{ matrix.service == 'backend' && steps.plan.outputs.tag || '' }}
|
||||||
|
FRONTEND_IMAGE_TAG: ${{ matrix.service == 'frontend' && steps.plan.outputs.tag || '' }}
|
||||||
|
|
||||||
|
- name: Skipped
|
||||||
|
if: steps.plan.outputs.changed != 'true'
|
||||||
|
run: echo "${{ matrix.service }} unchanged in this push — nothing to roll."
|
||||||
|
|
@ -1,126 +0,0 @@
|
||||||
name: Build + push frontend image (Forgejo registry)
|
|
||||||
|
|
||||||
# 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.
|
|
||||||
#
|
|
||||||
# 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 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.
|
|
||||||
#
|
|
||||||
# The registry is deliberately mesh-only: git.thermograph.org's public DNS
|
|
||||||
# resolves to beta's public IP, which Caddy's ACL rejects for /v2/ paths, so
|
|
||||||
# any runner host that pushes/pulls needs a /etc/hosts entry pointing
|
|
||||||
# git.thermograph.org at beta's WireGuard IP (10.10.0.2). The docker
|
|
||||||
# build/push commands run against the automounted HOST daemon, so it's the
|
|
||||||
# runner HOST's own resolver that has to route over the mesh, not anything
|
|
||||||
# settable from inside the job container.
|
|
||||||
#
|
|
||||||
# REGISTRY is the vars.REGISTRY_HOST Actions variable (git.thermograph.org),
|
|
||||||
# NOT github.server_url -- that context resolves to the bare mesh IP:port
|
|
||||||
# (http://10.10.0.2:3080, no TLS), which docker CLI refuses ("server gave HTTP
|
|
||||||
# response to HTTPS client"). git.thermograph.org gets real TLS via Caddy and
|
|
||||||
# routes over the mesh once /etc/hosts is set, so it's the only correct value.
|
|
||||||
# deploy.sh resolves the SHA tag at deploy time and `docker compose pull`s it.
|
|
||||||
|
|
||||||
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 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: frontend-build-push-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-push:
|
|
||||||
runs-on: docker
|
|
||||||
env:
|
|
||||||
REGISTRY: https://${{ vars.REGISTRY_HOST }}
|
|
||||||
IMAGE_PATH: ${{ github.repository }}/frontend
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
||||||
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
||||||
# often an unrelated domain's commit, and a depth-1 clone can't see
|
|
||||||
# past it to find the real key.
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Install Docker CLI
|
|
||||||
run: |
|
|
||||||
apt-get update -qq
|
|
||||||
apt-get install -y -qq docker.io
|
|
||||||
|
|
||||||
- name: Compute image ref + tags
|
|
||||||
id: image
|
|
||||||
run: |
|
|
||||||
host="${REGISTRY#https://}"; host="${host#http://}"
|
|
||||||
path="$(printf '%s' "$IMAGE_PATH" | tr '[:upper:]' '[:lower:]')"
|
|
||||||
ref="$host/$path"
|
|
||||||
# Key the tag to the last commit that touched frontend/ -- the SAME key
|
|
||||||
# every frontend deploy workflow computes -- so build and deploy always
|
|
||||||
# agree even when the branch tip is another domain's commit. (A tag
|
|
||||||
# keyed to the push tip breaks the moment an infra-only commit lands:
|
|
||||||
# deploys go looking for an image no build ever produced.)
|
|
||||||
domain_sha="$(git log -1 --format=%H -- frontend/)"
|
|
||||||
sha_tag="$ref:sha-${domain_sha:0:12}"
|
|
||||||
echo "host=$host" >> "$GITHUB_OUTPUT"
|
|
||||||
echo "sha_tag=$sha_tag" >> "$GITHUB_OUTPUT"
|
|
||||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
|
||||||
echo "semver_tag=$ref:${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Log in to the Forgejo registry
|
|
||||||
run: |
|
|
||||||
# NOT secrets.GITHUB_TOKEN -- Forgejo's per-job auto-injected token
|
|
||||||
# can never push to the container registry (a known Forgejo
|
|
||||||
# limitation independent of any permission setting). Needs a
|
|
||||||
# manually provisioned PAT with write:package scope, stored as the
|
|
||||||
# REGISTRY_TOKEN secret.
|
|
||||||
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login "${{ steps.image.outputs.host }}" \
|
|
||||||
--username emi --password-stdin
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
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[@]}" frontend/
|
|
||||||
|
|
||||||
- name: Push (SHA tag, every push)
|
|
||||||
run: docker push "${{ steps.image.outputs.sha_tag }}"
|
|
||||||
|
|
||||||
- name: Push (semver tag, tag pushes only)
|
|
||||||
if: startsWith(github.ref, 'refs/tags/v')
|
|
||||||
run: docker push "${{ steps.image.outputs.semver_tag }}"
|
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
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
|
|
||||||
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
||||||
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
||||||
# often an unrelated domain's commit, and a depth-1 clone can't see
|
|
||||||
# past it to find the real key.
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Compute image tag
|
|
||||||
id: tag
|
|
||||||
# Keyed to the last commit that touched frontend/ -- must match
|
|
||||||
# frontend-build-push.yml's tag key exactly (see its comment).
|
|
||||||
run: |
|
|
||||||
domain_sha="$(git log -1 --format=%H -- frontend/)"
|
|
||||||
echo "tag=sha-${domain_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"
|
|
||||||
|
|
@ -1,61 +0,0 @@
|
||||||
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 last-frontend-commit key (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:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
||||||
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
||||||
# often an unrelated domain's commit, and a depth-1 clone can't see
|
|
||||||
# past it to find the real key.
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Compute image tag (last frontend-touching commit)
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
domain_sha="$(git log -1 --format=%H -- frontend/)"
|
|
||||||
echo "tag=sha-${domain_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 }}
|
|
||||||
|
|
@ -1,70 +0,0 @@
|
||||||
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 last-frontend-commit key (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:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
# fetch-depth 0: the tag is keyed to the LAST COMMIT THAT TOUCHED THIS
|
|
||||||
# DOMAIN, not the branch tip -- in a path-filtered monorepo the tip is
|
|
||||||
# often an unrelated domain's commit, and a depth-1 clone can't see
|
|
||||||
# past it to find the real key.
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Compute image tag (last frontend-touching commit)
|
|
||||||
id: tag
|
|
||||||
run: |
|
|
||||||
domain_sha="$(git log -1 --format=%H -- frontend/)"
|
|
||||||
echo "tag=sha-${domain_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 }}
|
|
||||||
21
CLAUDE.md
21
CLAUDE.md
|
|
@ -15,17 +15,24 @@ every change, so a stale one is a correctness bug, not a documentation bug.
|
||||||
| Branch | Deploys to | Workflow |
|
| Branch | Deploys to | Workflow |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| feature branch | nothing | PR into `dev` |
|
| feature branch | nothing | PR into `dev` |
|
||||||
| `dev` | LAN dev | `*-deploy-dev.yml` — **currently inert**, see below |
|
| `dev` | nothing | integration branch only — see below |
|
||||||
| `main` | beta (beta.thermograph.org) | `*-deploy.yml` |
|
| `main` | beta (beta.thermograph.org) | `deploy.yml` |
|
||||||
| `release` | prod (thermograph.org) | `*-deploy-prod.yml` |
|
| `release` | prod (thermograph.org) | `deploy.yml` |
|
||||||
|
|
||||||
`dev`, `main` and `release` are protected: **everything is a PR**, for humans and
|
`dev`, `main` and `release` are protected: **everything is a PR**, for humans and
|
||||||
agents alike. Promotion is one PR per hop, `dev` → `main` → `release`.
|
agents alike. Promotion is one PR per hop, `dev` → `main` → `release`.
|
||||||
|
|
||||||
The two `*-deploy-dev.yml` workflows are inert — the LAN box's
|
**`dev` deploys nowhere.** It is purely the integration branch that feature PRs
|
||||||
`~/thermograph-dev` is still a split-era `thermograph-infra` checkout, so the
|
land on before promotion to `main`. The two LAN-dev deploy workflows were deleted
|
||||||
monorepo path they call does not exist there. Use `infra/`'s `make dev-up`
|
rather than kept: they called a monorepo path that does not exist on the LAN box
|
||||||
locally instead.
|
(`~/thermograph-dev` is still a split-era `thermograph-infra` checkout), so they
|
||||||
|
had been inert since cutover. Run LAN dev locally with `infra/`'s `make dev-up`.
|
||||||
|
|
||||||
|
**One workflow deploys everything.** `deploy.yml` handles both services and both
|
||||||
|
environments: the branch selects the environment (`main` → beta, `release` →
|
||||||
|
prod), a matrix covers backend and frontend, and each leg checks whether this
|
||||||
|
push actually touched its domain before rolling. `build-push.yml` is the same
|
||||||
|
shape for images. They replaced six and two near-identical files respectively.
|
||||||
|
|
||||||
## The deploy contract
|
## The deploy contract
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,16 @@ subtree-merging each split repo's `origin/main` (full history, 380 commits):
|
||||||
**CI (all in root `.forgejo/workflows/`; the subtree copies were deleted —
|
**CI (all in root `.forgejo/workflows/`; the subtree copies were deleted —
|
||||||
Forgejo only reads root workflows, but dead copies are a trap):**
|
Forgejo only reads root workflows, but dead copies are a trap):**
|
||||||
|
|
||||||
|
> **Superseded 2026-07-25.** The per-domain workflow names below describe the
|
||||||
|
> cutover as it landed, and are kept for that history. They no longer exist:
|
||||||
|
> the six `*-deploy*.yml` files were collapsed into a single `deploy.yml`
|
||||||
|
> (branch selects environment, matrix covers the services) and the two
|
||||||
|
> `*-build-push.yml` into a single `build-push.yml`. The two LAN-dev deploy
|
||||||
|
> workflows were deleted outright rather than ported, having been inert since
|
||||||
|
> cutover. Everything described below about *behaviour* — path filtering, the
|
||||||
|
> domain-keyed 12-hex tag, per-domain concurrency, the `v*.*.*` both-images
|
||||||
|
> exception, separate `SSH_*`/`PROD_SSH_*` secret sets — is unchanged.
|
||||||
|
|
||||||
- `backend-build-push.yml` / `frontend-build-push.yml` — the old per-repo
|
- `backend-build-push.yml` / `frontend-build-push.yml` — the old per-repo
|
||||||
build-push, path-filtered (`paths: ['backend/**']` etc.), image paths now
|
build-push, path-filtered (`paths: ['backend/**']` etc.), image paths now
|
||||||
**explicit**: `emi/thermograph/backend|frontend` (the old
|
**explicit**: `emi/thermograph/backend|frontend` (the old
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ for: **per-domain images, per-domain deploys, and an async FE/BE contract**.
|
||||||
|
|
||||||
| Dir | What | CI |
|
| Dir | What | CI |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `backend/` | FastAPI graded-climate API, accounts, notifications (Discord bot, push, mail), data pipeline | `backend-build-push` → image `emi/thermograph/backend`; `backend-deploy[-prod\|-dev]` |
|
| `backend/` | FastAPI graded-climate API, accounts, notifications (Discord bot, push, mail), data pipeline | `build-push` → image `emi/thermograph/backend`; `deploy` |
|
||||||
| `frontend/` | Public client: static JS/CSS + SSR pages | `frontend-*` mirrors of the above; image `emi/thermograph/frontend` |
|
| `frontend/` | Public client: static JS/CSS + SSR pages | same `build-push` / `deploy` workflows, matrixed by domain; image `emi/thermograph/frontend` |
|
||||||
| `infra/` | Compose (beta, LAN dev) + the Swarm stack (prod), deploy scripts, terraform, SOPS secrets vault, ops cron | `infra-sync` (host checkout + secrets render), `secrets-guard`, `ops-cron` |
|
| `infra/` | Compose (beta, LAN dev) + the Swarm stack (prod), deploy scripts, terraform, SOPS secrets vault, ops cron | `infra-sync` (host checkout + secrets render), `secrets-guard`, `ops-cron` |
|
||||||
| `observability/` | Loki + Grafana + Alloy stack | `observability-validate` |
|
| `observability/` | Loki + Grafana + Alloy stack | `observability-validate` |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -936,29 +936,27 @@ def _fetch_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
.sort("date"))
|
.sort("date"))
|
||||||
|
|
||||||
|
|
||||||
def _om_local_today(payload: dict) -> "datetime.date | None":
|
|
||||||
"""The calendar date it is *now* at the cell, from the UTC offset Open-Meteo
|
|
||||||
reports for a ``timezone=auto`` request. None when the offset is absent, which
|
|
||||||
tells the caller to skip the in-progress-day guard rather than guess a date."""
|
|
||||||
offset = payload.get("utc_offset_seconds")
|
|
||||||
if offset is None:
|
|
||||||
return None
|
|
||||||
return (datetime.datetime.now(datetime.timezone.utc)
|
|
||||||
+ datetime.timedelta(seconds=int(offset))).date()
|
|
||||||
|
|
||||||
|
|
||||||
def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
||||||
"""Fallback recent+forecast bundle from the Open-Meteo forecast API (the former
|
"""Primary recent+forecast bundle from the Open-Meteo forecast API: recent past
|
||||||
primary): recent past + forward days in one call.
|
and forward days in one call, covering today + 7 (see FORECAST_DAYS).
|
||||||
|
|
||||||
The cell's in-progress *local* day is dropped from the bundle. Open-Meteo's
|
The cell's in-progress *local* day is KEPT. An earlier revision dropped it on
|
||||||
daily high/low for today aggregates only the hours elapsed so far, so grading it
|
the assumption that Open-Meteo's daily high/low for today aggregates only the
|
||||||
reads a still-unfolding day as complete and produces spurious extremes (a cool
|
hours elapsed so far — true of the MET Norway series, which genuinely starts
|
||||||
morning served as a record-low high, seen live at the 1st percentile). This is
|
mid-day and is gated on diurnal coverage (see _metno_to_frame), but NOT of this
|
||||||
the same failure the MET Norway path guards against with its diurnal-coverage
|
endpoint: Open-Meteo backfills the rest of today from the model run, so today's
|
||||||
gate (see _metno_to_frame); the fallback needs the equivalent. Past days are
|
daily value spans the whole local day exactly as tomorrow's does.
|
||||||
complete and future days are whole-day forecasts, so only today is excluded —
|
|
||||||
the day lands in the record once it is over."""
|
Verified against the live API at four cells spanning 01:00-19:00 local: the
|
||||||
|
reported daily max equals the max over all 24 hourly values, and diverges from
|
||||||
|
the elapsed-hours-only max wherever the day still has hours left (Los Angeles
|
||||||
|
at 09:16 local reported 93.9F, the whole-day figure, where the hours elapsed
|
||||||
|
topped out at 76.1F). Re-check it that way before reintroducing any exclusion.
|
||||||
|
|
||||||
|
Dropping today cost every freshly-synced cell its current day — a hole between
|
||||||
|
a complete yesterday and a forecast tomorrow, which the UI renders as a missing
|
||||||
|
column. A day that genuinely cannot be graded, because it came back with no
|
||||||
|
high/low, is already dropped upstream by _to_frame."""
|
||||||
params = {
|
params = {
|
||||||
"latitude": cell["center_lat"],
|
"latitude": cell["center_lat"],
|
||||||
"longitude": cell["center_lon"],
|
"longitude": cell["center_lon"],
|
||||||
|
|
@ -971,12 +969,7 @@ def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame:
|
||||||
"forecast_days": FORECAST_DAYS,
|
"forecast_days": FORECAST_DAYS,
|
||||||
}
|
}
|
||||||
r = _request(FORECAST_URL, params, 60, phase="recent_forecast_fetch")
|
r = _request(FORECAST_URL, params, 60, phase="recent_forecast_fetch")
|
||||||
payload = r.json()
|
return _to_frame(r.json()["daily"])
|
||||||
df = _to_frame(payload["daily"])
|
|
||||||
local_today = _om_local_today(payload)
|
|
||||||
if local_today is not None:
|
|
||||||
df = df.filter(pl.col("date") != local_today)
|
|
||||||
return df
|
|
||||||
|
|
||||||
|
|
||||||
def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
def _load_recent_forecast(cell: dict) -> pl.DataFrame:
|
||||||
|
|
|
||||||
|
|
@ -215,20 +215,20 @@ def test_recent_forecast_fallback_merges_archive_past_and_metno_forward(monkeypa
|
||||||
datetime.date(2026, 7, 16), datetime.date(2026, 7, 17)]
|
datetime.date(2026, 7, 16), datetime.date(2026, 7, 17)]
|
||||||
|
|
||||||
|
|
||||||
def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch):
|
def test_recent_forecast_om_keeps_the_in_progress_local_day(monkeypatch):
|
||||||
"""The Open-Meteo fallback must not grade the cell's in-progress local day: its
|
"""Today must survive the Open-Meteo bundle. Unlike the MET Norway series (which
|
||||||
daily high/low is only a partial aggregate of the hours elapsed so far (a cool
|
starts mid-day and is gated on diurnal coverage), this endpoint backfills the
|
||||||
morning would read as a record-low high). Past days and future forecast days
|
rest of today from the model run, so today's high/low is a whole-day value of
|
||||||
survive; today — per the UTC offset Open-Meteo reports for a timezone=auto
|
the same kind as tomorrow's. Dropping it left a hole between a complete
|
||||||
request — is dropped, matching the MET path's diurnal-coverage gate."""
|
yesterday and a forecast tomorrow, which the UI renders as a missing column."""
|
||||||
offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai incident)
|
offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai cell)
|
||||||
local_today = (datetime.datetime.now(datetime.timezone.utc)
|
local_today = (datetime.datetime.now(datetime.timezone.utc)
|
||||||
+ datetime.timedelta(seconds=offset)).date()
|
+ datetime.timedelta(seconds=offset)).date()
|
||||||
days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)]
|
days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)]
|
||||||
daily = {
|
daily = {
|
||||||
"time": [d.isoformat() for d in days],
|
"time": [d.isoformat() for d in days],
|
||||||
"temperature_2m_max": [80.0, 82.0, 61.0, 84.0], # today's 61 is the partial value
|
"temperature_2m_max": [80.0, 82.0, 76.1, 84.0],
|
||||||
"temperature_2m_min": [60.0, 61.0, 57.0, 62.0],
|
"temperature_2m_min": [60.0, 61.0, 55.9, 62.0],
|
||||||
"precipitation_sum": [0.0, 0.0, 0.0, 0.0],
|
"precipitation_sum": [0.0, 0.0, 0.0, 0.0],
|
||||||
}
|
}
|
||||||
payload = {"utc_offset_seconds": offset, "daily": daily}
|
payload = {"utc_offset_seconds": offset, "daily": daily}
|
||||||
|
|
@ -237,60 +237,42 @@ def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch):
|
||||||
def json(self): return payload
|
def json(self): return payload
|
||||||
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
||||||
|
|
||||||
|
got = climate._fetch_recent_forecast_om({"center_lat": 54.9, "center_lon": 23.8})
|
||||||
|
assert got["date"].to_list() == days # every day present, no gap at today
|
||||||
|
row = got.filter(pl.col("date") == local_today)
|
||||||
|
assert row["tmax"].item() == 76.1 # today's whole-day high, ungraded-down
|
||||||
|
assert row["tmin"].item() == 55.9
|
||||||
|
|
||||||
|
|
||||||
|
def test_recent_forecast_om_still_drops_a_today_with_no_high_low(monkeypatch):
|
||||||
|
"""The one case today is excluded: it came back with no high/low and so cannot be
|
||||||
|
graded at all. _to_frame enforces that for every day, today included."""
|
||||||
|
offset = 3 * 3600
|
||||||
|
local_today = (datetime.datetime.now(datetime.timezone.utc)
|
||||||
|
+ datetime.timedelta(seconds=offset)).date()
|
||||||
|
days = [local_today + datetime.timedelta(days=n) for n in (-1, 0, 1)]
|
||||||
|
daily = {
|
||||||
|
"time": [d.isoformat() for d in days],
|
||||||
|
"temperature_2m_max": [82.0, None, 84.0],
|
||||||
|
"temperature_2m_min": [61.0, None, 62.0],
|
||||||
|
"precipitation_sum": [0.0, 0.0, 0.0],
|
||||||
|
}
|
||||||
|
payload = {"utc_offset_seconds": offset, "daily": daily}
|
||||||
|
|
||||||
|
class Resp:
|
||||||
|
def json(self): return payload
|
||||||
|
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
||||||
|
|
||||||
got = climate._fetch_recent_forecast_om(
|
got = climate._fetch_recent_forecast_om(
|
||||||
{"center_lat": 54.9, "center_lon": 23.8})["date"].to_list()
|
{"center_lat": 54.9, "center_lon": 23.8})["date"].to_list()
|
||||||
assert local_today not in got # partial today dropped
|
assert local_today not in got
|
||||||
assert local_today - datetime.timedelta(days=1) in got # yesterday kept
|
assert got == [local_today - datetime.timedelta(days=1),
|
||||||
assert local_today + datetime.timedelta(days=1) in got # tomorrow's forecast kept
|
local_today + datetime.timedelta(days=1)]
|
||||||
assert len(got) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch):
|
def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch):
|
||||||
"""No UTC offset reported -> skip the in-progress-day guard rather than guess a
|
"""No UTC offset reported changes nothing: the bundle passes through with only
|
||||||
date, so the bundle passes through as before (only the usual null-day filter)."""
|
the usual null-day filter."""
|
||||||
payload = {"daily": _om_daily(3)} # no utc_offset_seconds
|
|
||||||
|
|
||||||
class Resp:
|
|
||||||
def json(self): return payload
|
|
||||||
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
|
||||||
|
|
||||||
df = climate._fetch_recent_forecast_om({"center_lat": 1.0, "center_lon": 2.0})
|
|
||||||
assert df.height == climate._to_frame(_om_daily(3)).height
|
|
||||||
|
|
||||||
|
|
||||||
def test_recent_forecast_om_drops_the_in_progress_local_day(monkeypatch):
|
|
||||||
"""The Open-Meteo fallback must not grade the cell's in-progress local day: its
|
|
||||||
daily high/low is only a partial aggregate of the hours elapsed so far (a cool
|
|
||||||
morning would read as a record-low high). Past days and future forecast days
|
|
||||||
survive; today — per the UTC offset Open-Meteo reports for a timezone=auto
|
|
||||||
request — is dropped, matching the MET path's diurnal-coverage gate."""
|
|
||||||
offset = 3 * 3600 # UTC+3, e.g. Europe/Vilnius (the reported Ringaudai incident)
|
|
||||||
local_today = (datetime.datetime.now(datetime.timezone.utc)
|
|
||||||
+ datetime.timedelta(seconds=offset)).date()
|
|
||||||
days = [local_today + datetime.timedelta(days=n) for n in (-2, -1, 0, 1)]
|
|
||||||
daily = {
|
|
||||||
"time": [d.isoformat() for d in days],
|
|
||||||
"temperature_2m_max": [80.0, 82.0, 61.0, 84.0], # today's 61 is the partial value
|
|
||||||
"temperature_2m_min": [60.0, 61.0, 57.0, 62.0],
|
|
||||||
"precipitation_sum": [0.0, 0.0, 0.0, 0.0],
|
|
||||||
}
|
|
||||||
payload = {"utc_offset_seconds": offset, "daily": daily}
|
|
||||||
|
|
||||||
class Resp:
|
|
||||||
def json(self): return payload
|
|
||||||
monkeypatch.setattr(climate, "_request", lambda *a, **k: Resp())
|
|
||||||
|
|
||||||
got = climate._fetch_recent_forecast_om(
|
|
||||||
{"center_lat": 54.9, "center_lon": 23.8})["date"].to_list()
|
|
||||||
assert local_today not in got # partial today dropped
|
|
||||||
assert local_today - datetime.timedelta(days=1) in got # yesterday kept
|
|
||||||
assert local_today + datetime.timedelta(days=1) in got # tomorrow's forecast kept
|
|
||||||
assert len(got) == 3
|
|
||||||
|
|
||||||
|
|
||||||
def test_recent_forecast_om_keeps_all_days_without_a_utc_offset(monkeypatch):
|
|
||||||
"""No UTC offset reported -> skip the in-progress-day guard rather than guess a
|
|
||||||
date, so the bundle passes through as before (only the usual null-day filter)."""
|
|
||||||
payload = {"daily": _om_daily(3)} # no utc_offset_seconds
|
payload = {"daily": _om_daily(3)} # no utc_offset_seconds
|
||||||
|
|
||||||
class Resp:
|
class Resp:
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ bug hit during the frontend Go rewrite). `ci-runner` adds `docker-ce-cli` +
|
||||||
Current tag: `git.thermograph.org/emi/thermograph/ci-runner:v2` (`v1` is
|
Current tag: `git.thermograph.org/emi/thermograph/ci-runner:v2` (`v1` is
|
||||||
broken — do not register any runner against it). Rebuild/push (requires a PAT
|
broken — do not register any runner against it). Rebuild/push (requires a PAT
|
||||||
with `write:package` scope — the embedded git-remote token lacks it, same
|
with `write:package` scope — the embedded git-remote token lacks it, same
|
||||||
requirement documented in `backend-build-push.yml`):
|
requirement documented in `build-push.yml`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN deploy/forgejo/ci-runner
|
docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN deploy/forgejo/ci-runner
|
||||||
|
|
|
||||||
|
|
@ -258,7 +258,7 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
# Frontend's OWN image, published by frontend-build-push.yml from
|
# Frontend's OWN image, published by build-push.yml (frontend leg) from
|
||||||
# frontend/Dockerfile (which starts the thermograph-frontend Go binary
|
# frontend/Dockerfile (which starts the thermograph-frontend Go binary
|
||||||
# directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent
|
# directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent
|
||||||
# FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag.
|
# FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue