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

This commit is contained in:
emi 2026-07-25 16:58:11 +00:00
commit aa4ee7f4c4
17 changed files with 398 additions and 752 deletions

View file

@ -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 }}"

View file

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

View file

@ -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 }}

View file

@ -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 }}

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

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

View file

@ -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 }}"

View file

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

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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` |

View file

@ -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:

View file

@ -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:

View file

@ -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

View file

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