thermograph/.forgejo/workflows/pr-build.yml

130 lines
4.8 KiB
YAML
Raw Normal View History

name: PR build (required check)
# Monorepo port of the app repos' pr-build.yml: ONE workflow, ONE stable
# required-check name (`gate`), any number of domains. A `changes` job diffs
# the PR against its base; per-domain jobs run only for domains the PR
# touches; `gate` reduces their results into the single status branch
# protection requires.
#
# Deliberately NOT path-filtered at the workflow level: a path-filtered
# required check that never fires leaves the PR stuck "expected -- waiting for
# status" forever, so auto-merge never proceeds. An always-running gate whose
# domain jobs skip cleanly gives the same compute savings without the
# deadlock.
#
# One-time web UI setup (Settings -> Branches, rules for `dev` and `main`):
# -> enable "Enable Status Check", list this workflow's "gate" job
# -> merge style: squash
# Then on a ready PR: "Auto merge when checks succeed".
on:
pull_request:
ci: gate the prod path, fold the repo-wide checks into gate, run the daemon tests 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.
2026-07-25 08:36:31 +00:00
# `release` included: it is the branch that deploys to PROD, and it used to
# be the least-checked branch in the repo. A PR into release ran neither a
# build nor a test — only the two standalone lint workflows — so the intended
# promotion path was the one with no gate on it.
branches: [dev, main, release]
concurrency:
group: pr-build-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
changes:
runs-on: docker
outputs:
backend: ${{ steps.diff.outputs.backend }}
frontend: ${{ steps.diff.outputs.frontend }}
observability: ${{ steps.diff.outputs.observability }}
steps:
- uses: actions/checkout@v4
with:
# The PR base commit isn't reachable from a depth-1 clone; full
# history keeps the diff computable however far the branch diverged.
fetch-depth: 0
- name: Detect changed domains
id: diff
run: |
set -euo pipefail
base="${{ github.event.pull_request.base.sha }}"
changed=$(git diff --name-only "$base" "$GITHUB_SHA")
echo "changed files:"; printf '%s\n' "$changed"
for d in backend frontend observability; do
if printf '%s\n' "$changed" | grep -q "^$d/"; then
echo "$d=true" >> "$GITHUB_OUTPUT"
else
echo "$d=false" >> "$GITHUB_OUTPUT"
fi
done
build-backend:
needs: changes
if: needs.changes.outputs.backend == 'true'
uses: ./.forgejo/workflows/build.yml
with:
domain: backend
build-frontend:
needs: changes
if: needs.changes.outputs.frontend == 'true'
uses: ./.forgejo/workflows/build.yml
with:
domain: frontend
validate-observability:
needs: changes
if: needs.changes.outputs.observability == 'true'
uses: ./.forgejo/workflows/observability-validate.yml
ci: gate the prod path, fold the repo-wide checks into gate, run the daemon tests 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.
2026-07-25 08:36:31 +00:00
# NOT domain-gated and NOT `needs: changes`: both are repo-wide invariants that
# must hold on every PR regardless of which directories it touches. A plaintext
# secret or a broken script can arrive in a commit that changes no app domain at
# all — precisely the case where every domain job skips and `gate` used to pass
# vacuously.
lint-shell:
uses: ./.forgejo/workflows/shell-lint.yml
guard-secrets:
uses: ./.forgejo/workflows/secrets-guard.yml
gate:
name: gate
# always(): skipped domain jobs (nothing changed there) must not skip the
# gate itself -- the required check has to report on EVERY PR.
if: always()
ci: gate the prod path, fold the repo-wide checks into gate, run the daemon tests 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.
2026-07-25 08:36:31 +00:00
needs: [changes, build-backend, build-frontend, validate-observability,
lint-shell, guard-secrets]
runs-on: docker
steps:
- name: Reduce domain results
run: |
set -euo pipefail
ok=1
ci: gate the prod path, fold the repo-wide checks into gate, run the daemon tests 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.
2026-07-25 08:36:31 +00:00
# Domain jobs: `skipped` is a pass — it means the PR did not touch that
# domain, which is the whole point of the path filtering.
for pair in \
"backend=${{ needs.build-backend.result }}" \
"frontend=${{ needs.build-frontend.result }}" \
"observability=${{ needs.validate-observability.result }}"; do
echo "$pair"
case "${pair#*=}" in
success|skipped) ;;
*) ok=0 ;;
esac
done
ci: gate the prod path, fold the repo-wide checks into gate, run the daemon tests 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.
2026-07-25 08:36:31 +00:00
# Repo-wide invariants: `skipped` is a FAILURE. These have no `if:` and
# no `needs:`, so they run on every PR; a skip means the job did not
# execute and the invariant is therefore unverified, which must not be
# reported as a pass.
for pair in \
"shellcheck=${{ needs.lint-shell.result }}" \
"secrets-encrypted=${{ needs.guard-secrets.result }}"; do
echo "$pair"
case "${pair#*=}" in
success) ;;
*) ok=0 ;;
esac
done
[ "$ok" = 1 ] || { echo "a required check failed"; exit 1; }