PR-CI #250 #1163
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI check | |
| run-name: >- | |
| ${{ github.event_name == 'pull_request' && format('PR-CI #{0}', github.event.pull_request.number) | |
| || github.event_name == 'merge_group' && 'MergeQueue-CI' | |
| || github.event_name == 'push' && 'Main-CI' | |
| || 'CI' }} | |
| on: | |
| push: | |
| branches: | |
| - main | |
| pull_request: | |
| types: | |
| - opened | |
| - reopened | |
| - synchronize | |
| - ready_for_review | |
| merge_group: | |
| # Least privilege: jobs only read the repo. Any job needing more must opt in | |
| # explicitly at the job level. | |
| permissions: | |
| contents: read | |
| # Cancel superseded runs for the same PR to save runner minutes. Never cancel | |
| # in-progress runs for `push` (main) or `merge_group` — those must complete so | |
| # the default branch and merge queue always have a definitive result. | |
| concurrency: | |
| group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: ${{ github.event_name == 'pull_request' }} | |
| jobs: | |
| build-and-test: | |
| name: Build and Test | |
| if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| # This job executes untrusted PR code (make build/test). Don't leave | |
| # the GITHUB_TOKEN in the workspace git config while it runs. | |
| persist-credentials: false | |
| - name: Build all targets | |
| run: make build | |
| - name: Run all tests | |
| run: make test | |
| - name: Display test logs on failure | |
| if: failure() | |
| run: | | |
| echo "=== Test logs ===" | |
| find . -name "test.log" -exec echo "--- {} ---" \; -exec cat {} \; || echo "No test logs found" | |
| integration: | |
| name: Integration Tests | |
| if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 30 | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| persist-credentials: false | |
| fetch-depth: 0 | |
| # Persist Bazel's disk cache (see .bazelrc: --disk_cache) across runs. The | |
| # integration test drives nested `bazel query` against the repo itself; a | |
| # cold cache makes the first GetChangedTargets pay the full analysis cost | |
| # and pushes the run against its deadline. Warming it removes that spike | |
| # and speeds every run. restore-keys lets a stale cache seed a new one. | |
| - name: Cache Bazel disk cache | |
| uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0 | |
| with: | |
| path: ~/.cache/bazel-disk-cache | |
| key: bazel-disk-integration-${{ hashFiles('MODULE.bazel', 'go.mod', '.bazelrc') }} | |
| restore-keys: | | |
| bazel-disk-integration- | |
| - name: Run integration tests | |
| run: make test-integration | |
| - name: Display test logs on failure | |
| if: failure() | |
| run: | | |
| echo "=== Integration test logs ===" | |
| find . -name "test.log" -exec echo "--- {} ---" \; -exec cat {} \; || echo "No test logs found" | |
| coverage: | |
| name: Coverage | |
| if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| persist-credentials: false | |
| - name: Set up Go | |
| uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| with: | |
| go-version-file: go.mod | |
| - name: Run tests with coverage | |
| run: make cover | |
| - name: Upload coverage to codecov.io | |
| uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v6 | |
| with: | |
| file: cover.out | |
| verbose: true | |
| dependencies: | |
| name: Dependencies | |
| if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| # This job executes untrusted PR code (go mod tidy, bazel/gazelle). | |
| persist-credentials: false | |
| - name: Set up Go | |
| uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| with: | |
| go-version-file: go.mod | |
| - name: Run go mod tidy and bazel mod tidy | |
| run: | | |
| go mod tidy | |
| ./tools/bazel mod tidy | |
| - name: Verify no uncommitted changes from mod tidy | |
| run: | | |
| if [ -n "$(git status --porcelain)" ]; then | |
| echo "::error::Dependencies are out of date. Please run 'go mod tidy' and 'bazel mod tidy' locally and commit the results." | |
| git diff | |
| exit 1 | |
| fi | |
| - name: Run gazelle | |
| run: make gazelle | |
| - name: Verify no uncommitted changes from gazelle | |
| run: | | |
| if [ -n "$(git status --porcelain)" ]; then | |
| echo "::error::BUILD.bazel files are out of date. Please run 'make gazelle' locally and commit the results." | |
| git diff | |
| exit 1 | |
| fi | |
| lint: | |
| name: Lint | |
| if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - name: Checkout code | |
| uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| # This job executes untrusted PR code (gazelle, gofmt, goimports). | |
| persist-credentials: false | |
| - name: Set up Go | |
| uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 | |
| with: | |
| go-version-file: go.mod | |
| - name: Run gazelle | |
| run: make gazelle | |
| - name: Run linters | |
| run: | | |
| gofmt -w . | |
| go install golang.org/x/tools/cmd/goimports@latest | |
| goimports -w . | |
| - name: Verify no uncommitted changes from linters | |
| run: | | |
| if [ -n "$(git status --porcelain)" ]; then | |
| echo "::error::Code is not formatted. Please run 'make gazelle', 'gofmt -w .', and 'goimports -w .' locally and commit the results." | |
| git diff | |
| exit 1 | |
| fi | |
| # --------------------------------------------------------------------------- | |
| # WORKFLOW SECURITY LINT | |
| # | |
| # Guards against regressions in the workflows themselves: actionlint checks | |
| # general validity; zizmor audits for GitHub Actions security smells | |
| # (dangerous triggers, unpinned `uses:`, credential persistence, template | |
| # injection). Keeps the SHA-pinning / persist-credentials hardening from | |
| # silently eroding in future edits. | |
| # --------------------------------------------------------------------------- | |
| workflow-security: | |
| name: Workflow Security Lint | |
| if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} | |
| runs-on: ubuntu-latest | |
| steps: | |
| - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | |
| with: | |
| persist-credentials: false | |
| - name: actionlint | |
| uses: raven-actions/actionlint@205b530c5d9fa8f44ae9ed59f341a0db994aa6f8 # v2.1.2 | |
| - name: zizmor | |
| uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 | |
| with: | |
| # Pin the zizmor TOOL version (distinct from the action tag above) for | |
| # reproducible audits matching local validation. | |
| version: "1.25.2" | |
| # Fail the job on findings without requiring GitHub Advanced Security | |
| # / SARIF upload (which needs security-events: write and degrades on | |
| # fork PRs). Keeps the gate self-contained. | |
| advanced-security: false | |
| # ci.yml runs untrusted PR code under the `pull_request` trigger. A | |
| # repository secret referenced anywhere on that path is reachable by a | |
| # malicious PR and can be exfiltrated on the PR run (before any review), | |
| # so this path must stay secret-free — route any secret-bearing step | |
| # through a SEPARATE trusted workflow (workflow_run / pull_request_target) | |
| # that does not execute PR code. | |
| # | |
| # This guard catches ACCIDENTAL reintroduction by honest contributors; a | |
| # malicious actor controlling ci.yml could delete the guard itself, so the | |
| # real defense remains CODEOWNERS review on .github/. GITHUB_TOKEN | |
| # (least-privilege, read-only here) is allowlisted. | |
| - name: Guard — no repository secrets on the untrusted-code path | |
| run: | | |
| hits="$(grep -rnE '\$\{\{[^}]*secrets\.' \ | |
| .github/workflows/ci.yml \ | |
| | grep -vE 'secrets\.GITHUB_TOKEN' || true)" | |
| if [ -n "$hits" ]; then | |
| echo "::error::Repository secret referenced on the untrusted-code CI path (ci.yml):" >&2 | |
| echo "$hits" >&2 | |
| echo "Move secret-bearing steps to a separate trusted workflow that does not run PR code." >&2 | |
| exit 1 | |
| fi | |
| echo "OK: no repository secrets referenced in ci.yml." | |
| # --------------------------------------------------------------------------- | |
| # REQUIRED CHECKS GATE | |
| # | |
| # Fan-in aggregator that must turn RED when any required job fails, is | |
| # cancelled, or is skipped. `if: always()` is critical: without it, this job | |
| # is skipped when any `needs` dependency fails, and GitHub treats a skipped | |
| # required status check as "not failed" — which would let a PR merge through | |
| # the merge queue despite failing checks. | |
| # --------------------------------------------------------------------------- | |
| required-checks: | |
| name: Required Checks | |
| if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.draft == false) }} | |
| runs-on: ubuntu-latest | |
| needs: | |
| - build-and-test | |
| - integration | |
| - coverage | |
| - dependencies | |
| - lint | |
| - workflow-security | |
| steps: | |
| - name: Fail if any required check did not succeed | |
| if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'skipped') }} | |
| # Pass the job-results context via env (not inline ${{ }} in the script) | |
| # so there is no template expansion inside the run block. | |
| env: | |
| NEEDS_JSON: ${{ toJSON(needs) }} | |
| run: | | |
| echo "One or more required checks did not succeed:" >&2 | |
| echo "$NEEDS_JSON" >&2 | |
| exit 1 | |
| - name: All required checks passed | |
| run: echo "All required checks passed!" |