All checks were successful
PR build (required check) / changes (pull_request) Successful in 7s
secrets-guard / encrypted (pull_request) Successful in 6s
PR build (required check) / validate-observability (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 16s
PR build (required check) / lint-shell (pull_request) Successful in 23s
PR build (required check) / guard-secrets (pull_request) Successful in 26s
PR build (required check) / build-frontend (pull_request) Successful in 1m41s
PR build (required check) / build-backend (pull_request) Successful in 2m13s
PR build (required check) / gate (pull_request) Successful in 2s
Four holes, all of which let untested or unchecked code reach an environment. - PRs into `release` ran no build and no test. release is the branch that deploys to prod, so the intended promotion path was the least-checked path in the repo: the last seven release PRs ran shellcheck and the secrets check, nothing else. pr-build now triggers on it. - shell-lint and secrets-guard reported as standalone statuses outside `gate`, and pr-build's own setup notes tell the operator to require only `gate`. Taken literally that means a commit adding a plaintext SOPS file was mergeable. Both are now called from pr-build and reduced into gate. They are deliberately not domain-gated and not `needs: changes`: they are repo-wide invariants that must hold on every PR, including one touching no app domain -- exactly the case where every domain job skips and gate used to pass vacuously. For the same reason `skipped` counts as a failure for these two, while it stays a pass for the domain jobs it legitimately describes. They keep their standalone pull_request trigger, so they will run twice on a PR until branch protection is confirmed to require only `gate`. Cheap, and it avoids breaking a rule that may still require them by name. - build-push.yml built and pushed with no test step, and deploy.yml consumes a TAG rather than a commit status -- so an image reaching the registry by any route other than a dev/main PR (a direct push, a dispatch, a v*.*.* tag) was never tested by CI, and beta/prod then rolled it. The test now sits between build and push, so the artifact that deploys is the artifact that was tested. Gating the artifact is what makes this work; gating the branch would not. - backend/Dockerfile ran `go build` on daemon/ but never its tests. All 48 of them (gateway, cron, apiclient, config) ran nowhere: `grep -rn "go test" .forgejo/workflows/` found nothing, while the daemon owns the prod Discord gateway websocket and every recurring-job timer. It now runs gofmt + vet + test before the build, which is the pattern frontend/Dockerfile already uses -- and being inside `docker build` it gates build-push too. Verified clean: gofmt reports nothing, vet passes, all four packages pass.
164 lines
7.3 KiB
YAML
164 lines
7.3 KiB
YAML
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 }}/"
|
|
|
|
# Build -> TEST -> push. Without this the only pytest run in CI was
|
|
# pr-build's, so anything reaching a branch by another route (a direct
|
|
# push, a workflow_dispatch, a v*.*.* tag) published an image that CI had
|
|
# never tested — and deploy.yml then rolls that exact tag onto beta/prod.
|
|
# Gating the artifact rather than the branch is what makes the deploy safe,
|
|
# since deploy.yml consumes a tag, not a commit status.
|
|
#
|
|
# Same invocation as build.yml's, deliberately: -u 0 because the image's
|
|
# default user cannot write to site-packages, --entrypoint sh because the
|
|
# real entrypoint is alembic-migrate-then-serve and would swallow the
|
|
# command, and `unset THERMOGRAPH_BASE` because the image bakes the prod
|
|
# root mount (/) while the tests are written against the /thermograph
|
|
# prefix. The Go daemon and Go frontend suites need no step here: both run
|
|
# inside their Dockerfiles, so `docker build` above already gated them.
|
|
- name: Test the built image (backend only)
|
|
if: steps.image.outputs.changed == 'true' && matrix.domain == 'backend'
|
|
run: |
|
|
docker run --rm -u 0 --entrypoint sh "${{ steps.image.outputs.sha_tag }}" \
|
|
-c "unset THERMOGRAPH_BASE; pip install -q --no-cache-dir pytest==8.4.1 && python -m pytest tests -q"
|
|
|
|
- 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."
|