diff --git a/.forgejo/workflows/backend-build-push.yml b/.forgejo/workflows/backend-build-push.yml deleted file mode 100644 index 514aaca..0000000 --- a/.forgejo/workflows/backend-build-push.yml +++ /dev/null @@ -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 }}" diff --git a/.forgejo/workflows/backend-deploy-dev.yml b/.forgejo/workflows/backend-deploy-dev.yml deleted file mode 100644 index 94b247e..0000000 --- a/.forgejo/workflows/backend-deploy-dev.yml +++ /dev/null @@ -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" diff --git a/.forgejo/workflows/backend-deploy-prod.yml b/.forgejo/workflows/backend-deploy-prod.yml deleted file mode 100644 index c4fba2c..0000000 --- a/.forgejo/workflows/backend-deploy-prod.yml +++ /dev/null @@ -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 }} diff --git a/.forgejo/workflows/backend-deploy.yml b/.forgejo/workflows/backend-deploy.yml deleted file mode 100644 index 0ad3999..0000000 --- a/.forgejo/workflows/backend-deploy.yml +++ /dev/null @@ -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 }} diff --git a/.forgejo/workflows/build-push.yml b/.forgejo/workflows/build-push.yml new file mode 100644 index 0000000..e9e1fc3 --- /dev/null +++ b/.forgejo/workflows/build-push.yml @@ -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." diff --git a/.forgejo/workflows/deploy.yml b/.forgejo/workflows/deploy.yml new file mode 100644 index 0000000..ceae65e --- /dev/null +++ b/.forgejo/workflows/deploy.yml @@ -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." diff --git a/.forgejo/workflows/frontend-build-push.yml b/.forgejo/workflows/frontend-build-push.yml deleted file mode 100644 index cf38190..0000000 --- a/.forgejo/workflows/frontend-build-push.yml +++ /dev/null @@ -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 }}" diff --git a/.forgejo/workflows/frontend-deploy-dev.yml b/.forgejo/workflows/frontend-deploy-dev.yml deleted file mode 100644 index 0269777..0000000 --- a/.forgejo/workflows/frontend-deploy-dev.yml +++ /dev/null @@ -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" diff --git a/.forgejo/workflows/frontend-deploy-prod.yml b/.forgejo/workflows/frontend-deploy-prod.yml deleted file mode 100644 index e483273..0000000 --- a/.forgejo/workflows/frontend-deploy-prod.yml +++ /dev/null @@ -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 }} diff --git a/.forgejo/workflows/frontend-deploy.yml b/.forgejo/workflows/frontend-deploy.yml deleted file mode 100644 index bbdad62..0000000 --- a/.forgejo/workflows/frontend-deploy.yml +++ /dev/null @@ -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 }} diff --git a/CLAUDE.md b/CLAUDE.md index 2395be1..2e5710a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,17 +15,24 @@ every change, so a stale one is a correctness bug, not a documentation bug. | Branch | Deploys to | Workflow | |---|---|---| | feature branch | nothing | PR into `dev` | -| `dev` | LAN dev | `*-deploy-dev.yml` — **currently inert**, see below | -| `main` | beta (beta.thermograph.org) | `*-deploy.yml` | -| `release` | prod (thermograph.org) | `*-deploy-prod.yml` | +| `dev` | nothing | integration branch only — see below | +| `main` | beta (beta.thermograph.org) | `deploy.yml` | +| `release` | prod (thermograph.org) | `deploy.yml` | `dev`, `main` and `release` are protected: **everything is a PR**, for humans and agents alike. Promotion is one PR per hop, `dev` → `main` → `release`. -The two `*-deploy-dev.yml` workflows are inert — the LAN box's -`~/thermograph-dev` is still a split-era `thermograph-infra` checkout, so the -monorepo path they call does not exist there. Use `infra/`'s `make dev-up` -locally instead. +**`dev` deploys nowhere.** It is purely the integration branch that feature PRs +land on before promotion to `main`. The two LAN-dev deploy workflows were deleted +rather than kept: they called a monorepo path that does not exist on the LAN box +(`~/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 diff --git a/CUTOVER-NOTES.md b/CUTOVER-NOTES.md index 845e21c..18743d5 100644 --- a/CUTOVER-NOTES.md +++ b/CUTOVER-NOTES.md @@ -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 — 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 build-push, path-filtered (`paths: ['backend/**']` etc.), image paths now **explicit**: `emi/thermograph/backend|frontend` (the old diff --git a/README.md b/README.md index e268e31..7db6473 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,8 @@ for: **per-domain images, per-domain deploys, and an async FE/BE contract**. | 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]` | -| `frontend/` | Public client: static JS/CSS + SSR pages | `frontend-*` mirrors of the above; image `emi/thermograph/frontend` | +| `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 | 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` | | `observability/` | Loki + Grafana + Alloy stack | `observability-validate` | diff --git a/backend/data/climate.py b/backend/data/climate.py index d7c1357..c08561e 100644 --- a/backend/data/climate.py +++ b/backend/data/climate.py @@ -936,29 +936,27 @@ def _fetch_recent_forecast(cell: dict) -> pl.DataFrame: .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: - """Fallback recent+forecast bundle from the Open-Meteo forecast API (the former - primary): recent past + forward days in one call. + """Primary recent+forecast bundle from the Open-Meteo forecast API: recent past + 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 - daily high/low for today aggregates only the hours elapsed so far, so grading it - reads a still-unfolding day as complete and produces spurious extremes (a cool - morning served as a record-low high, seen live at the 1st percentile). This is - the same failure the MET Norway path guards against with its diurnal-coverage - gate (see _metno_to_frame); the fallback needs the equivalent. Past days are - complete and future days are whole-day forecasts, so only today is excluded — - the day lands in the record once it is over.""" + The cell's in-progress *local* day is KEPT. An earlier revision dropped it on + the assumption that Open-Meteo's daily high/low for today aggregates only the + hours elapsed so far — true of the MET Norway series, which genuinely starts + mid-day and is gated on diurnal coverage (see _metno_to_frame), but NOT of this + endpoint: Open-Meteo backfills the rest of today from the model run, so today's + daily value spans the whole local day exactly as tomorrow's does. + + 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 = { "latitude": cell["center_lat"], "longitude": cell["center_lon"], @@ -971,12 +969,7 @@ def _fetch_recent_forecast_om(cell: dict) -> pl.DataFrame: "forecast_days": FORECAST_DAYS, } r = _request(FORECAST_URL, params, 60, phase="recent_forecast_fetch") - payload = r.json() - 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 + return _to_frame(r.json()["daily"]) def _load_recent_forecast(cell: dict) -> pl.DataFrame: diff --git a/backend/tests/data/test_climate.py b/backend/tests/data/test_climate.py index edaae3f..b09ff11 100644 --- a/backend/tests/data/test_climate.py +++ b/backend/tests/data/test_climate.py @@ -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)] -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) +def test_recent_forecast_om_keeps_the_in_progress_local_day(monkeypatch): + """Today must survive the Open-Meteo bundle. Unlike the MET Norway series (which + starts mid-day and is gated on diurnal coverage), this endpoint backfills the + rest of today from the model run, so today's high/low is a whole-day value of + the same kind as tomorrow's. Dropping it left a hole between a complete + 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 cell) 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], + "temperature_2m_max": [80.0, 82.0, 76.1, 84.0], + "temperature_2m_min": [60.0, 61.0, 55.9, 62.0], "precipitation_sum": [0.0, 0.0, 0.0, 0.0], } 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 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( {"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 + assert local_today not in got + assert got == [local_today - datetime.timedelta(days=1), + local_today + datetime.timedelta(days=1)] 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 - - 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).""" + """No UTC offset reported changes nothing: the bundle passes through with only + the usual null-day filter.""" payload = {"daily": _om_daily(3)} # no utc_offset_seconds class Resp: diff --git a/infra/deploy/forgejo/README.md b/infra/deploy/forgejo/README.md index 6f91009..ab7315d 100644 --- a/infra/deploy/forgejo/README.md +++ b/infra/deploy/forgejo/README.md @@ -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 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 -requirement documented in `backend-build-push.yml`): +requirement documented in `build-push.yml`): ```bash docker build -t git.thermograph.org/emi/thermograph/ci-runner:vN deploy/forgejo/ci-runner diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index 228dff2..1b62a06 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -258,7 +258,7 @@ services: restart: unless-stopped 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 # directly -- no THERMOGRAPH_SERVICE_ROLE process-picking). Independent # FRONTEND_IMAGE_TAG so a frontend deploy never disturbs the backend's tag.