From f472f391b91fbac5239123a5c19cfc6b47883f20 Mon Sep 17 00:00:00 2001 From: Don Browne Date: Wed, 28 Jan 2026 15:34:10 +0000 Subject: [PATCH 1/3] Create Github action to monitor all required build gates --- .github/workflows/PR-GATE-README.md | 145 +++++++++++++++++++++ .github/workflows/pr-gate.yml | 190 ++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+) create mode 100644 .github/workflows/PR-GATE-README.md create mode 100644 .github/workflows/pr-gate.yml diff --git a/.github/workflows/PR-GATE-README.md b/.github/workflows/PR-GATE-README.md new file mode 100644 index 0000000000..1b05ddd52b --- /dev/null +++ b/.github/workflows/PR-GATE-README.md @@ -0,0 +1,145 @@ +# PR Gate Workflow + +This document explains the PR Gate workflow and how to configure branch protection rules. + +## Problem + +ToolHive has multiple quality gate workflows that run conditionally based on which files are changed: +- `run-on-pr.yml` - Runs when Go code changes (excludes `deploy/charts/**`) +- `run-on-pr-charts.yml` - Runs when Helm charts change (only `deploy/charts/**`) + +This creates a problem for branch protection: +- If you require BOTH workflows, no PR can pass (one will never trigger) +- If you require neither, you can't enforce quality gates + +## Solution + +The `pr-gate.yml` workflow acts as a sentinel that: +1. Detects which files changed using `dorny/paths-filter` +2. Conditionally runs the appropriate quality checks +3. Provides a single "All Checks Pass" job that reports the combined status + +This is a well-established pattern used by many large open-source projects. + +## Configuration + +### Branch Protection Rules + +Configure your branch protection to require only the **"All Checks Pass"** job: + +1. Go to Repository Settings → Branches +2. Add or edit a branch protection rule for `main` +3. Enable "Require status checks to pass before merging" +4. Search for and select: **"All Checks Pass"** +5. Remove any other workflow requirements (the gate handles them all) + +### How It Works + +```mermaid +graph TD + A[PR Created] --> B[changes job] + B -->|code files| C[Go code checks] + B -->|chart files| D[Chart checks] + B -->|both| E[Both sets of checks] + C --> F[all-checks sentinel] + D --> F + E --> F + F -->|all passed/skipped| G[✅ PR can merge] + F -->|any failed| H[❌ PR blocked] +``` + +The `all-checks` job: +- Runs with `if: always()` so it executes even if some jobs were skipped +- Checks the status of all quality gate jobs +- Succeeds if all jobs are either `success` or `skipped` +- Fails if any job has status `failure` or `cancelled` + +## Deprecating Old Workflows + +Once the PR gate is enabled and tested, you can consider deprecating: +- `run-on-pr.yml` +- `run-on-pr-charts.yml` + +These workflows are now replaced by `pr-gate.yml`. However, you may want to keep them temporarily for: +- Existing PRs that reference them +- Gradual migration period +- Rollback capability if issues arise + +## Modifying the Gate + +To add or remove checks from the gate: + +1. **Add a new check**: + - Add the workflow call as a new job under the appropriate section (code or charts) + - Add the job name to the `needs:` list in the `all-checks` job + - Ensure the job has proper conditionals (`if: needs.changes.outputs.code == 'true'`) + +2. **Remove a check**: + - Remove the job definition + - Remove the job name from the `needs:` list in `all-checks` + +3. **Modify path filters**: + - Edit the `filters:` section in the `changes` job + - The `code` filter matches everything except charts + - The `charts` filter matches only `deploy/charts/**` + +## Testing + +To test the gate workflow: + +1. **Test with code changes**: + ```bash + # Make a small Go file change + echo "// test" >> cmd/thv/main.go + git add cmd/thv/main.go + git commit -m "test: verify PR gate with code change" + git push origin feature-branch + ``` + - Verify: Code checks run, chart checks skip, "All Checks Pass" succeeds + +2. **Test with chart changes**: + ```bash + # Make a chart change + echo "# test" >> deploy/charts/toolhive/README.md + git add deploy/charts/toolhive/README.md + git commit -m "test: verify PR gate with chart change" + git push origin feature-branch + ``` + - Verify: Chart checks run, code checks skip, "All Checks Pass" succeeds + +3. **Test with both**: + ```bash + # Change both code and charts + echo "// test" >> cmd/thv/main.go + echo "# test" >> deploy/charts/toolhive/README.md + git add . + git commit -m "test: verify PR gate with mixed changes" + git push origin feature-branch + ``` + - Verify: ALL checks run, "All Checks Pass" succeeds + +## Troubleshooting + +### Jobs not running as expected + +Check the `changes` job output to see which filters matched: +- Go to Actions → PR Gate workflow → changes job +- Look at the outputs: `code: true/false` and `charts: true/false` + +### All checks skip + +This can happen if: +- The path filters don't match any changed files +- Review the `filters:` configuration in the `changes` job + +### Sentinel reports failure even though all checks passed + +Check the status of each job in the `all-checks` step output: +- Jobs should be `success` or `skipped`, not `failure` or `cancelled` +- If a job shows `cancelled`, check if it has proper `if: !cancelled()` conditions + +## References + +- [GitHub Actions: Using jobs in a workflow](https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobs) +- [dorny/paths-filter action](https://github.com/dorny/paths-filter) +- [Branch protection rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches) diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml new file mode 100644 index 0000000000..a0d36bcd87 --- /dev/null +++ b/.github/workflows/pr-gate.yml @@ -0,0 +1,190 @@ +# SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. +# SPDX-License-Identifier: Apache-2.0 + +# PR Gate - Sentinel workflow that ensures all quality checks pass +# +# This workflow acts as a single entry point for branch protection rules. +# It waits for all triggered quality checks to complete and reports their +# combined status. This solves the problem of conditional workflows: +# - Some workflows only run when certain files are touched +# - Branch protection can't require workflows that don't always run +# - This gate checks all jobs that DID run and reports success/failure +# +# Configure branch protection to require only the "All Checks Pass" job. + +name: PR Gate + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + # Detect which files changed to determine which checks to run + changes: + name: Detect Changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + charts: ${{ steps.filter.outputs.charts }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + charts: + - 'deploy/charts/**' + + # ============================================================================ + # PR Checks (Go code) - from run-on-pr.yml + # These run when NOT touching deploy/charts/** + # ============================================================================ + + pr-spellcheck: + name: Spellcheck + needs: changes + if: needs.changes.outputs.code == 'true' + uses: ./.github/workflows/spellcheck.yml + + pr-license-headers: + name: License Headers + needs: changes + if: needs.changes.outputs.code == 'true' + uses: ./.github/workflows/license-headers.yml + + pr-linting: + name: Linting + needs: changes + if: needs.changes.outputs.code == 'true' + uses: ./.github/workflows/lint.yml + + pr-security-scan: + name: Security Scan + needs: changes + if: needs.changes.outputs.code == 'true' + permissions: + contents: read + security-events: write + uses: ./.github/workflows/security-scan.yml + + pr-tests: + name: Tests + needs: changes + if: needs.changes.outputs.code == 'true' + uses: ./.github/workflows/test.yml + secrets: inherit + + pr-docs: + name: Docs + needs: changes + if: needs.changes.outputs.code == 'true' + uses: ./.github/workflows/verify-docgen.yml + + pr-codegen: + name: Codegen + needs: changes + if: needs.changes.outputs.code == 'true' + uses: ./.github/workflows/verify-gen.yml + + pr-e2e-tests: + name: E2E Tests + needs: [changes, pr-linting, pr-tests, pr-docs, pr-codegen] + if: | + !cancelled() && + needs.changes.outputs.code == 'true' + uses: ./.github/workflows/e2e-tests.yml + + pr-operator-ci: + name: Operator CI + needs: [changes, pr-linting, pr-tests, pr-docs, pr-codegen] + if: | + !cancelled() && + needs.changes.outputs.code == 'true' + permissions: + contents: read + uses: ./.github/workflows/operator-ci.yml + + # ============================================================================ + # Chart Checks - from run-on-pr-charts.yml + # These run when ONLY touching deploy/charts/** + # ============================================================================ + + charts-spellcheck: + name: Charts Spellcheck + needs: changes + if: needs.changes.outputs.charts == 'true' + uses: ./.github/workflows/spellcheck.yml + + charts-linting: + name: Charts Linting + needs: changes + if: needs.changes.outputs.charts == 'true' + uses: ./.github/workflows/lint-helm-charts.yml + + charts-tests: + name: Charts Tests + needs: changes + if: needs.changes.outputs.charts == 'true' + uses: ./.github/workflows/test-helm-charts.yml + + # ============================================================================ + # SENTINEL JOB - This is the job to require in branch protection + # ============================================================================ + + all-checks: + name: All Checks Pass + runs-on: ubuntu-latest + needs: + - changes + # PR checks (Go code) + - pr-spellcheck + - pr-license-headers + - pr-linting + - pr-security-scan + - pr-tests + - pr-docs + - pr-codegen + - pr-e2e-tests + - pr-operator-ci + # Chart checks + - charts-spellcheck + - charts-linting + - charts-tests + + # CRITICAL: Always run this job, even if some dependencies were skipped + if: always() + + steps: + - name: Check all job statuses + run: | + echo "Checking status of all quality gate jobs..." + echo "" + + # Convert needs context to JSON for easier parsing + cat << 'EOF' | jq -r 'to_entries[] | "\(.key): \(.value.result)"' + ${{ toJSON(needs) }} + EOF + + echo "" + + # Check if any job failed or was cancelled + # Jobs can be: success, failure, cancelled, skipped + # We want to fail if ANY job failed or was cancelled + # We allow skipped (means the job didn't need to run) + + if echo '${{ toJSON(needs) }}' | jq -e 'to_entries[] | select(.value.result == "failure")' > /dev/null; then + echo "❌ One or more jobs failed" + exit 1 + fi + + if echo '${{ toJSON(needs) }}' | jq -e 'to_entries[] | select(.value.result == "cancelled")' > /dev/null; then + echo "❌ One or more jobs were cancelled" + exit 1 + fi + + echo "✅ All required checks passed or were skipped appropriately" From 610ec63c27f8806bb117b608442da17e158e3805 Mon Sep 17 00:00:00 2001 From: Don Browne Date: Wed, 28 Jan 2026 15:48:42 +0000 Subject: [PATCH 2/3] Take II --- .github/workflows/PR-GATE-README.md | 35 ++++---- .github/workflows/pr-gate.yml | 135 ++++++++++++++++++---------- 2 files changed, 110 insertions(+), 60 deletions(-) diff --git a/.github/workflows/PR-GATE-README.md b/.github/workflows/PR-GATE-README.md index 1b05ddd52b..865b19c773 100644 --- a/.github/workflows/PR-GATE-README.md +++ b/.github/workflows/PR-GATE-README.md @@ -17,7 +17,8 @@ This creates a problem for branch protection: The `pr-gate.yml` workflow acts as a sentinel that: 1. Detects which files changed using `dorny/paths-filter` 2. Conditionally runs the appropriate quality checks -3. Provides a single "All Checks Pass" job that reports the combined status +3. Provides a single "All Checks Pass" job that dynamically discovers and validates all workflow runs +4. Uses the GitHub API to query workflow statuses (no static list maintenance required) This is a well-established pattern used by many large open-source projects. @@ -49,10 +50,12 @@ graph TD ``` The `all-checks` job: -- Runs with `if: always()` so it executes even if some jobs were skipped -- Checks the status of all quality gate jobs -- Succeeds if all jobs are either `success` or `skipped` -- Fails if any job has status `failure` or `cancelled` +- Runs with `if: always()` so it executes unconditionally +- Uses GitHub API to dynamically discover all workflow runs for the PR +- Polls until all workflows complete (max 60 minutes timeout) +- Succeeds if all workflows are either `success` or `skipped` +- Fails if any workflow has status `failure` or `cancelled` +- **No manual maintenance** - automatically detects new/removed workflows ## Deprecating Old Workflows @@ -67,21 +70,23 @@ These workflows are now replaced by `pr-gate.yml`. However, you may want to keep ## Modifying the Gate -To add or remove checks from the gate: +The gate automatically discovers all workflows that run on PRs - no manual maintenance needed! + +To add or remove checks: 1. **Add a new check**: - - Add the workflow call as a new job under the appropriate section (code or charts) - - Add the job name to the `needs:` list in the `all-checks` job - - Ensure the job has proper conditionals (`if: needs.changes.outputs.code == 'true'`) + - Simply add the workflow with `on: pull_request` trigger + - The gate will automatically detect and wait for it + - No need to update the gate workflow itself 2. **Remove a check**: - - Remove the job definition - - Remove the job name from the `needs:` list in `all-checks` + - Remove or disable the workflow + - The gate will stop waiting for it automatically -3. **Modify path filters**: - - Edit the `filters:` section in the `changes` job - - The `code` filter matches everything except charts - - The `charts` filter matches only `deploy/charts/**` +3. **Modify path filters** (optional, for conditional workflows): + - You can use the `changes` job outputs in your workflow's `if:` condition + - Or implement your own path filtering in the workflow + - The gate respects workflows that choose not to run (treats as skipped) ## Testing diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index a0d36bcd87..c5525ec844 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -139,52 +139,97 @@ jobs: all-checks: name: All Checks Pass runs-on: ubuntu-latest - needs: - - changes - # PR checks (Go code) - - pr-spellcheck - - pr-license-headers - - pr-linting - - pr-security-scan - - pr-tests - - pr-docs - - pr-codegen - - pr-e2e-tests - - pr-operator-ci - # Chart checks - - charts-spellcheck - - charts-linting - - charts-tests - # CRITICAL: Always run this job, even if some dependencies were skipped if: always() steps: - - name: Check all job statuses - run: | - echo "Checking status of all quality gate jobs..." - echo "" - - # Convert needs context to JSON for easier parsing - cat << 'EOF' | jq -r 'to_entries[] | "\(.key): \(.value.result)"' - ${{ toJSON(needs) }} - EOF - - echo "" - - # Check if any job failed or was cancelled - # Jobs can be: success, failure, cancelled, skipped - # We want to fail if ANY job failed or was cancelled - # We allow skipped (means the job didn't need to run) - - if echo '${{ toJSON(needs) }}' | jq -e 'to_entries[] | select(.value.result == "failure")' > /dev/null; then - echo "❌ One or more jobs failed" - exit 1 - fi - - if echo '${{ toJSON(needs) }}' | jq -e 'to_entries[] | select(.value.result == "cancelled")' > /dev/null; then - echo "❌ One or more jobs were cancelled" - exit 1 - fi - - echo "✅ All required checks passed or were skipped appropriately" + - name: Wait for all checks to complete + uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7.0.1 + with: + script: | + const prNumber = context.payload.pull_request?.number; + if (!prNumber) { + core.setFailed('No PR number found'); + return; + } + + const sha = context.payload.pull_request.head.sha; + + console.log(`Checking status of all workflow runs for PR #${prNumber} (SHA: ${sha})`); + console.log(''); + + // Wait for all workflows to complete (max 60 minutes) + const maxWaitTime = 60 * 60 * 1000; // 60 minutes + const pollInterval = 10 * 1000; // 10 seconds + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime) { + // Get all workflow runs for this commit + const { data: workflowRuns } = await github.rest.actions.listWorkflowRunsForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + head_sha: sha, + per_page: 100 + }); + + // Filter to runs from this PR (exclude the pr-gate workflow itself) + const prWorkflows = workflowRuns.workflow_runs.filter(run => + run.name !== 'PR Gate' && + run.event === 'pull_request' + ); + + console.log(`Found ${prWorkflows.length} workflow runs for this PR`); + + // Check if all are complete + const pending = prWorkflows.filter(run => + run.status === 'queued' || run.status === 'in_progress' + ); + + if (pending.length === 0) { + console.log('All workflows completed'); + console.log(''); + + // Print status of all workflows + console.log('Workflow Status Summary:'); + console.log('========================'); + prWorkflows.forEach(run => { + const status = run.conclusion || run.status; + const icon = status === 'success' ? '✅' : + status === 'skipped' ? '⏭️' : + status === 'cancelled' ? '🚫' : + '❌'; + console.log(`${icon} ${run.name}: ${status}`); + }); + console.log(''); + + // Check for failures or cancellations + const failed = prWorkflows.filter(run => + run.conclusion === 'failure' || run.conclusion === 'cancelled' + ); + + if (failed.length > 0) { + console.log('❌ The following workflows failed or were cancelled:'); + failed.forEach(run => { + console.log(` - ${run.name}: ${run.conclusion}`); + }); + core.setFailed('One or more quality checks failed'); + return; + } + + console.log('✅ All quality checks passed or were skipped appropriately'); + return; + } + + console.log(`Waiting for ${pending.length} workflows to complete...`); + pending.forEach(run => { + console.log(` - ${run.name}: ${run.status}`); + }); + console.log(''); + + // Wait before next poll + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + core.setFailed('Timeout waiting for all workflows to complete'); + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From ff2e930a1784f51f89d79d3a5628462021d90571 Mon Sep 17 00:00:00 2001 From: Don Browne Date: Thu, 29 Jan 2026 11:36:27 +0000 Subject: [PATCH 3/3] testing... --- .github/workflows/e2e-tests.yml | 29 +++++- .github/workflows/license-headers.yml | 23 ++++- .github/workflows/lint-helm-charts.yml | 23 ++++- .github/workflows/lint.yml | 23 ++++- .github/workflows/operator-ci.yml | 44 +++++++- .github/workflows/pr-gate.yml | 134 ++++--------------------- .github/workflows/run-on-pr-charts.yml | 21 ---- .github/workflows/run-on-pr.yml | 49 --------- .github/workflows/spellcheck.yml | 23 ++++- .github/workflows/test-helm-charts.yml | 23 ++++- .github/workflows/test.yml | 23 ++++- .github/workflows/verify-docgen.yml | 23 ++++- .github/workflows/verify-gen.yml | 23 ++++- 13 files changed, 262 insertions(+), 199 deletions(-) delete mode 100644 .github/workflows/run-on-pr-charts.yml delete mode 100644 .github/workflows/run-on-pr.yml diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 7001d662b7..1743fa57a7 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -1,15 +1,36 @@ name: E2E Tests on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs permissions: contents: read jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + build-binary: name: Build ToolHive Binary runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 @@ -42,7 +63,11 @@ jobs: e2e-tests-core: name: E2E Tests Core (${{ matrix.title }}) runs-on: ubuntu-latest - needs: build-binary + needs: [changes, build-binary] + if: | + always() && + needs.build-binary.result == 'success' && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') strategy: fail-fast: false matrix: diff --git a/.github/workflows/license-headers.yml b/.github/workflows/license-headers.yml index 39ee79b385..069a5107f0 100644 --- a/.github/workflows/license-headers.yml +++ b/.github/workflows/license-headers.yml @@ -4,15 +4,36 @@ name: License Headers on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs permissions: contents: read jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + check-license-headers: name: Check License Headers runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 diff --git a/.github/workflows/lint-helm-charts.yml b/.github/workflows/lint-helm-charts.yml index b5f4c95831..15dbed17fd 100644 --- a/.github/workflows/lint-helm-charts.yml +++ b/.github/workflows/lint-helm-charts.yml @@ -4,12 +4,33 @@ permissions: contents: read on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + charts: ${{ steps.filter.outputs.charts }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + charts: + - 'deploy/charts/**' + lint-helm-charts: name: Lint Helm Charts runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.charts == 'true') env: GO111MODULE: on steps: diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index e53708278b..e10af81bd2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,15 +1,36 @@ name: Linting on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs permissions: contents: read jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + lint-go-code: name: Lint Go Code runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 diff --git a/.github/workflows/operator-ci.yml b/.github/workflows/operator-ci.yml index 92a84ae2ae..dba92ef2c4 100644 --- a/.github/workflows/operator-ci.yml +++ b/.github/workflows/operator-ci.yml @@ -1,16 +1,36 @@ name: Operator CI on: - workflow_call: - workflow_dispatch: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs permissions: contents: read jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + operator-tests: name: Operator Tests runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 @@ -32,6 +52,10 @@ jobs: operator-tests-integration: name: Operator Tests Integration runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 @@ -53,6 +77,10 @@ jobs: build-operator: name: Build Operator runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 @@ -74,6 +102,10 @@ jobs: generate-crds: name: Generate CRDs runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 @@ -107,6 +139,10 @@ jobs: generate-crd-docs: name: Generate CRD Docs runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 @@ -140,6 +176,10 @@ jobs: e2e-tests-operator: name: E2E Tests Operator runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') timeout-minutes: 30 defaults: run: diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index c5525ec844..5505472842 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -1,14 +1,20 @@ # SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc. # SPDX-License-Identifier: Apache-2.0 -# PR Gate - Sentinel workflow that ensures all quality checks pass +# PR Gate - Dynamic sentinel workflow that ensures all quality checks pass # # This workflow acts as a single entry point for branch protection rules. -# It waits for all triggered quality checks to complete and reports their -# combined status. This solves the problem of conditional workflows: -# - Some workflows only run when certain files are touched -# - Branch protection can't require workflows that don't always run -# - This gate checks all jobs that DID run and reports success/failure +# It dynamically discovers and waits for all quality checks to complete, +# then reports their combined status. +# +# How it works: +# - Individual quality check workflows run independently on pull_request events +# - Each workflow has path filtering to skip when files aren't relevant +# - This sentinel discovers all workflows that ran via the GitHub API +# - Reports success if all workflows pass (or skip appropriately) +# +# Adding new checks: Just create a workflow with 'on: pull_request' +# No need to update this file - the sentinel discovers workflows automatically # # Configure branch protection to require only the "All Checks Pass" job. @@ -22,119 +28,13 @@ permissions: contents: read jobs: - # Detect which files changed to determine which checks to run - changes: - name: Detect Changes - runs-on: ubuntu-latest - outputs: - code: ${{ steps.filter.outputs.code }} - charts: ${{ steps.filter.outputs.charts }} - steps: - - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 - id: filter - with: - filters: | - code: - - '!deploy/charts/**' - charts: - - 'deploy/charts/**' - - # ============================================================================ - # PR Checks (Go code) - from run-on-pr.yml - # These run when NOT touching deploy/charts/** - # ============================================================================ - - pr-spellcheck: - name: Spellcheck - needs: changes - if: needs.changes.outputs.code == 'true' - uses: ./.github/workflows/spellcheck.yml - - pr-license-headers: - name: License Headers - needs: changes - if: needs.changes.outputs.code == 'true' - uses: ./.github/workflows/license-headers.yml - - pr-linting: - name: Linting - needs: changes - if: needs.changes.outputs.code == 'true' - uses: ./.github/workflows/lint.yml - - pr-security-scan: - name: Security Scan - needs: changes - if: needs.changes.outputs.code == 'true' - permissions: - contents: read - security-events: write - uses: ./.github/workflows/security-scan.yml - - pr-tests: - name: Tests - needs: changes - if: needs.changes.outputs.code == 'true' - uses: ./.github/workflows/test.yml - secrets: inherit - - pr-docs: - name: Docs - needs: changes - if: needs.changes.outputs.code == 'true' - uses: ./.github/workflows/verify-docgen.yml - - pr-codegen: - name: Codegen - needs: changes - if: needs.changes.outputs.code == 'true' - uses: ./.github/workflows/verify-gen.yml - - pr-e2e-tests: - name: E2E Tests - needs: [changes, pr-linting, pr-tests, pr-docs, pr-codegen] - if: | - !cancelled() && - needs.changes.outputs.code == 'true' - uses: ./.github/workflows/e2e-tests.yml - - pr-operator-ci: - name: Operator CI - needs: [changes, pr-linting, pr-tests, pr-docs, pr-codegen] - if: | - !cancelled() && - needs.changes.outputs.code == 'true' - permissions: - contents: read - uses: ./.github/workflows/operator-ci.yml - - # ============================================================================ - # Chart Checks - from run-on-pr-charts.yml - # These run when ONLY touching deploy/charts/** - # ============================================================================ - - charts-spellcheck: - name: Charts Spellcheck - needs: changes - if: needs.changes.outputs.charts == 'true' - uses: ./.github/workflows/spellcheck.yml - - charts-linting: - name: Charts Linting - needs: changes - if: needs.changes.outputs.charts == 'true' - uses: ./.github/workflows/lint-helm-charts.yml - - charts-tests: - name: Charts Tests - needs: changes - if: needs.changes.outputs.charts == 'true' - uses: ./.github/workflows/test-helm-charts.yml - # ============================================================================ - # SENTINEL JOB - This is the job to require in branch protection + # SENTINEL JOB - This is the only job to require in branch protection # ============================================================================ + # This job dynamically discovers all workflows that ran on this PR and + # validates that they all passed (or skipped appropriately). + # + # Individual workflows run independently with path filtering at the job level. all-checks: name: All Checks Pass diff --git a/.github/workflows/run-on-pr-charts.yml b/.github/workflows/run-on-pr-charts.yml deleted file mode 100644 index 7c6c522f69..0000000000 --- a/.github/workflows/run-on-pr-charts.yml +++ /dev/null @@ -1,21 +0,0 @@ -# These set of workflows run on every push to the main branch -name: PR Checks Helm Charts -permissions: - contents: read - -on: - workflow_dispatch: - pull_request: - paths: - - deploy/charts/** - -jobs: - spellcheck: - name: Spellcheck - uses: ./.github/workflows/spellcheck.yml - linting: - name: Linting - uses: ./.github/workflows/lint-helm-charts.yml - tests: - name: Tests - uses: ./.github/workflows/test-helm-charts.yml diff --git a/.github/workflows/run-on-pr.yml b/.github/workflows/run-on-pr.yml deleted file mode 100644 index 8f2971d16c..0000000000 --- a/.github/workflows/run-on-pr.yml +++ /dev/null @@ -1,49 +0,0 @@ -# These set of workflows run on every push to the main branch -name: PR Checks - -on: - workflow_dispatch: - pull_request: - paths-ignore: - - deploy/charts/** - -permissions: - contents: read - -jobs: - spellcheck: - name: Spellcheck - uses: ./.github/workflows/spellcheck.yml - license-headers: - name: License Headers - uses: ./.github/workflows/license-headers.yml - linting: - name: Linting - uses: ./.github/workflows/lint.yml - security-scan: - name: Security Scan - permissions: - contents: read - security-events: write - uses: ./.github/workflows/security-scan.yml - tests: - name: Tests - uses: ./.github/workflows/test.yml - secrets: inherit - docs: - name: Docs - uses: ./.github/workflows/verify-docgen.yml - codegen: - name: Codegen - uses: ./.github/workflows/verify-gen.yml - # Tier 2: Expensive integration tests - only run after all fast checks pass - e2e-tests: - name: E2E Tests - needs: [linting, tests, docs, codegen] - uses: ./.github/workflows/e2e-tests.yml - operator-ci: - name: Operator CI - needs: [linting, tests, docs, codegen] - permissions: - contents: read - uses: ./.github/workflows/operator-ci.yml diff --git a/.github/workflows/spellcheck.yml b/.github/workflows/spellcheck.yml index be32b62204..f2799e868e 100644 --- a/.github/workflows/spellcheck.yml +++ b/.github/workflows/spellcheck.yml @@ -4,12 +4,33 @@ permissions: contents: read on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + codespell: name: Codespell runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - name: Checkout Code uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 diff --git a/.github/workflows/test-helm-charts.yml b/.github/workflows/test-helm-charts.yml index 6d48f84acf..5911d0dbb6 100644 --- a/.github/workflows/test-helm-charts.yml +++ b/.github/workflows/test-helm-charts.yml @@ -4,12 +4,33 @@ permissions: contents: read on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + charts: ${{ steps.filter.outputs.charts }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + charts: + - 'deploy/charts/**' + test-helm-charts: name: Test Helm Charts runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.charts == 'true') steps: - name: Checkout uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e873139a90..324fa3c820 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,15 +1,36 @@ name: Tests on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs permissions: contents: read jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + test-go-code: name: Test Go Code runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 diff --git a/.github/workflows/verify-docgen.yml b/.github/workflows/verify-docgen.yml index a7f68b54ff..244230af54 100644 --- a/.github/workflows/verify-docgen.yml +++ b/.github/workflows/verify-docgen.yml @@ -1,14 +1,35 @@ name: Docgen on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + verify-swagger-docs: name: Verify Swagger Documentation runs-on: ubuntu-latest + needs: changes permissions: contents: read + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 diff --git a/.github/workflows/verify-gen.yml b/.github/workflows/verify-gen.yml index 0bad5702bb..d7d90110be 100644 --- a/.github/workflows/verify-gen.yml +++ b/.github/workflows/verify-gen.yml @@ -1,15 +1,36 @@ name: Codegen on: - workflow_call: + workflow_call: # Keep for run-on-main.yml + workflow_dispatch: # Manual trigger + pull_request: # Run independently on PRs permissions: contents: read jobs: + changes: + name: Detect Changes + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + outputs: + code: ${{ steps.filter.outputs.code }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2 + id: filter + with: + filters: | + code: + - '!deploy/charts/**' + verify-code-generation: name: Verify Code Generation runs-on: ubuntu-latest + needs: changes + if: | + always() && + (needs.changes.result == 'skipped' || needs.changes.outputs.code == 'true') steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6