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: # `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 # 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() 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 # 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 # 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; }