diff --git a/.github/detect-go-changes.bash b/.github/detect-go-changes.bash new file mode 100755 index 00000000..b40114c7 --- /dev/null +++ b/.github/detect-go-changes.bash @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# Detect whether a PR or push includes Go-related changes. +# Prints "true" or "false" to stdout. +# +# Requires: GH_TOKEN, GITHUB_REPOSITORY +# Arguments: +set -euo pipefail + +event_name=$1 +pr_number=${2:-} +before_sha=${3:-} +head_sha=${4:-} + +if [ "$event_name" = "pull_request" ]; then + changed=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}/files" \ + --paginate --jq '.[].filename') +else + if [ "$before_sha" = "0000000000000000000000000000000000000000" ]; then + echo true + exit 0 + fi + changed=$(gh api "repos/${GITHUB_REPOSITORY}/compare/${before_sha}...${head_sha}" \ + --jq '.files[].filename') +fi + +if [ -z "$changed" ]; then + echo false + exit 0 +fi + +# For push events, the compare API caps at 300 files. +if [ "$event_name" != "pull_request" ]; then + file_count=$(echo "$changed" | wc -l) + if [ "$file_count" -ge 300 ]; then + echo "::warning::Compare API file cap hit ($file_count files), assuming Go changes" + echo true + exit 0 + fi +fi + +# Anything outside these paths is considered Go-related. +# Intentionally conservative: unknown paths trigger Go CI. +non_docs=$(echo "$changed" | grep -vE '^docs/|^images/|\.md$|^LICENSE|^\.github/workflows/pages\.yml$|^\.claude/' || true) +if [ -n "$non_docs" ]; then + echo true +else + echo false +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 028295ef..98aa1325 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,8 +17,34 @@ env: TZ: UTC jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + go: ${{ steps.detect.outputs.go }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + sparse-checkout: .github/detect-go-changes.bash + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Check for Go-related changes + id: detect + env: + GH_TOKEN: ${{ github.token }} + run: | + go=$(bash .github/detect-go-changes.bash \ + "${{ github.event_name }}" \ + "${{ github.event.pull_request.number }}" \ + "${{ github.event.before }}" \ + "${{ github.sha }}") + echo "go=$go" >> "$GITHUB_OUTPUT" + test: name: Build & Test (${{ matrix.os }}) + needs: changes + if: needs.changes.outputs.go == 'true' runs-on: ${{ matrix.os }} concurrency: group: ci-test-${{ matrix.os }}-${{ github.ref }} @@ -133,6 +159,8 @@ jobs: benchmarks: name: Benchmarks (smoke) + needs: changes + if: needs.changes.outputs.go == 'true' runs-on: ubuntu-latest concurrency: group: ci-benchmarks-${{ github.ref }} @@ -151,6 +179,8 @@ jobs: nix-build: name: Nix Build + needs: changes + if: needs.changes.outputs.go == 'true' runs-on: ubuntu-latest concurrency: group: ci-nix-build-${{ github.ref }} @@ -196,7 +226,12 @@ jobs: semantic-release: name: Semantic Release - needs: [test, nix-build, docs] + needs: [changes, test, nix-build, docs] + if: >- + always() + && !contains(needs.*.result, 'failure') + && !contains(needs.*.result, 'cancelled') + && (needs.changes.outputs.go != 'true' || (needs.test.result == 'success' && needs.nix-build.result == 'success')) runs-on: ubuntu-latest concurrency: group: semantic-release-${{ github.ref }} @@ -227,3 +262,17 @@ jobs: run: npx semantic-release ${{ github.event_name == 'pull_request' && '--dry-run' || '' }} env: GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} + + # --------------------------------------------------------------------------- + # Gate: single required check that rolls up all jobs above. + # After merging, update the repo ruleset to require only "CI / Result". + # --------------------------------------------------------------------------- + + result: + name: CI / Result + if: always() + needs: [changes, test, benchmarks, nix-build, docs, semantic-release] + runs-on: ubuntu-latest + steps: + - run: exit 1 + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3438133f..7ce9b084 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,8 +13,34 @@ permissions: contents: read jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + go: ${{ steps.detect.outputs.go }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + sparse-checkout: .github/detect-go-changes.bash + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Check for Go-related changes + id: detect + env: + GH_TOKEN: ${{ github.token }} + run: | + go=$(bash .github/detect-go-changes.bash \ + "${{ github.event_name }}" \ + "${{ github.event.pull_request.number }}" \ + "${{ github.event.before }}" \ + "${{ github.sha }}") + echo "go=$go" >> "$GITHUB_OUTPUT" + deadcode: name: Dead Code + needs: changes + if: needs.changes.outputs.go == 'true' runs-on: ubuntu-latest concurrency: group: lint-deadcode-${{ github.ref }} @@ -31,6 +57,8 @@ jobs: golangci-lint: name: Lint + needs: changes + if: needs.changes.outputs.go == 'true' runs-on: ubuntu-latest concurrency: group: lint-golangci-lint-${{ github.ref }} @@ -61,3 +89,12 @@ jobs: - name: Run pre-commit hooks run: nix run '.#pre-commit' -- --from-ref 'origin/${{ github.base_ref || 'main' }}' --to-ref HEAD + + result: + name: Lint / Result + if: always() + needs: [changes, deadcode, golangci-lint, pre-commit] + runs-on: ubuntu-latest + steps: + - run: exit 1 + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index c29cfeb0..b1676d3f 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -13,8 +13,34 @@ permissions: contents: read jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + go: ${{ steps.detect.outputs.go }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + sparse-checkout: .github/detect-go-changes.bash + sparse-checkout-cone-mode: false + persist-credentials: false + + - name: Check for Go-related changes + id: detect + env: + GH_TOKEN: ${{ github.token }} + run: | + go=$(bash .github/detect-go-changes.bash \ + "${{ github.event_name }}" \ + "${{ github.event.pull_request.number }}" \ + "${{ github.event.before }}" \ + "${{ github.sha }}") + echo "go=$go" >> "$GITHUB_OUTPUT" + govulncheck: name: Vulnerability Check + needs: changes + if: needs.changes.outputs.go == 'true' runs-on: ubuntu-latest concurrency: group: security-govulncheck-${{ github.ref }} @@ -31,6 +57,8 @@ jobs: osv-scanner: name: OSV Scan + needs: changes + if: needs.changes.outputs.go == 'true' runs-on: ubuntu-latest concurrency: group: security-osv-scanner-${{ github.ref }} @@ -63,6 +91,8 @@ jobs: codeql: name: CodeQL (Go) + needs: changes + if: needs.changes.outputs.go == 'true' runs-on: ubuntu-latest concurrency: group: security-codeql-${{ github.ref }} @@ -91,3 +121,12 @@ jobs: - name: Perform CodeQL analysis uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6 + + result: + name: Security / Result + if: always() + needs: [changes, govulncheck, osv-scanner, secrets, codeql] + runs-on: ubuntu-latest + steps: + - run: exit 1 + if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')