Pull Request Check Policies
One of the most effective uses of OPA in CI/CD is using Rego policies to control
which checks run on a pull request based on the files that changed. This pattern
replaces brittle paths-filter YAML configurations or shell-scripted if
conditions with testable, readable policy logic.
While the Rego policy itself is platform-agnostic, the examples on this page use GitHub Actions to demonstrate the workflow integration. The same approach can be adapted to other CI platforms by replacing the platform-specific parts (fetching changed files, setting job outputs).
Why Use Rego for PR Checks?
Traditional approaches to conditional CI checks have significant drawbacks:
| Approach | Drawback |
|---|---|
paths-filter actions | Limited to glob patterns, no complex logic, hard to test in isolation |
Shell scripts with grep/find | Fragile, hard to read, impossible to unit test |
Hard coded if: conditions | Duplicated across jobs, no single source of truth |
Using Rego provides:
- Testability - Unit test your check logic with
opa testbefore it hits CI - Readability - Declarative rules are easier to review than imperative scripts
- Re-usability - Share policies across repositories as bundles
- Audit trail - Policy changes are versioned and reviewed like any other code
The Pattern
The pattern has three components:
- A Rego policy that takes the list of changed files as input and outputs which checks should run
- A "check-changes" job that evaluates the policy and sets workflow outputs
- Conditional jobs that only run when the policy says they should
Optionally, a summary job can use OPA to validate that all required jobs passed, providing a single required status check for branch protection.
Example: Test Selection (GitHub Actions)
Consider a repository with a frontend, backend, and shared documentation:
my-project/
├── frontend/
├── backend/
├── docs/
├── .github/
│ └── workflows/
│ └── pull-request.yaml
└── policy/
└── pr-check/
├── pr_check.rego
└── pr_check_test.rego
The Policy
package pr_check
# Shared config files that affect all packages
shared_build_files := [
"package.json",
"tsconfig.base.json",
"Makefile",
]
changes["frontend"] if {
some file in input
startswith(file.filename, "frontend/")
} else if {
some file in input
file.filename in shared_build_files
}
changes["backend"] if {
some file in input
startswith(file.filename, "backend/")
} else if {
some file in input
file.filename in shared_build_files
}
changes["docs"] if {
some file in input
startswith(file.filename, "docs/")
}
Each rule in changes evaluates to true if any changed file matches the
conditions. The else if clauses ensure that modifications to shared build
files trigger all relevant checks.
Testing the Policy
package pr_check_test
import data.pr_check
test_frontend_change_triggers_frontend if {
pr_check.changes.frontend with input as [
{"filename": "frontend/src/App.tsx"},
]
}
test_frontend_change_does_not_trigger_backend if {
not pr_check.changes.backend with input as [
{"filename": "frontend/src/App.tsx"},
]
}
test_shared_file_triggers_all_packages if {
pr_check.changes.frontend with input as [
{"filename": "package.json"},
]
pr_check.changes.backend with input as [
{"filename": "package.json"},
]
}
test_docs_change_only_triggers_docs if {
pr_check.changes.docs with input as [
{"filename": "docs/getting-started.md"},
]
not pr_check.changes.frontend with input as [
{"filename": "docs/getting-started.md"},
]
not pr_check.changes.backend with input as [
{"filename": "docs/getting-started.md"},
]
}
Run the tests locally with:
opa test policy/pr-check/
The Workflow (GitHub Actions)
The workflow uses the GitHub API to fetch changed files and passes them to OPA for evaluation. Job outputs propagate the policy decision to downstream jobs.
name: Pull Request Checks
on: [pull_request]
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
frontend: ${{ steps.check.outputs.frontend }}
backend: ${{ steps.check.outputs.backend }}
docs: ${{ steps.check.outputs.docs }}
steps:
- uses: actions/checkout@v4
- name: Download OPA
uses: open-policy-agent/setup-opa@v2
with:
version: latest
- name: Test PR check policies
run: opa test policy/pr-check/
- name: Get changed files
id: changes
env:
GH_TOKEN: ${{ github.token }}
run: |
gh api repos/${{ github.repository }}/pulls/${{ github.event.number }}/files \
--paginate > changed_files.json
- name: Evaluate policy
id: check
run: |
# Default all checks to true (safe fallback)
frontend=true
backend=true
docs=true
# Override with policy evaluation results
for check in frontend backend docs; do
result=$(opa eval \
--input changed_files.json \
--data policy/pr-check/pr_check.rego \
--format raw \
"data.pr_check.changes.$check" 2>/dev/null || echo "")
if [ "$result" = "true" ]; then
declare "$check=true"
elif [ -z "$result" ]; then
declare "$check=false"
fi
done
echo "frontend=$frontend" >> "$GITHUB_OUTPUT"
echo "backend=$backend" >> "$GITHUB_OUTPUT"
echo "docs=$docs" >> "$GITHUB_OUTPUT"
test-frontend:
needs: check-changes
if: needs.check-changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test --workspace=frontend
test-backend:
needs: check-changes
if: needs.check-changes.outputs.backend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm test --workspace=backend
build-docs:
needs: check-changes
if: needs.check-changes.outputs.docs == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run build --workspace=docs
pr-check-summary:
runs-on: ubuntu-latest
if: always()
needs: [check-changes, test-frontend, test-backend, build-docs]
steps:
- name: Download OPA
uses: open-policy-agent/setup-opa@v2
with:
version: latest
- name: Check job results
env:
NEEDS: ${{ toJson(needs) }}
run: |
echo "$NEEDS" | opa eval --fail \
--stdin-input \
--format raw \
'every value in input { value.result != "failure" }'
The Summary Job Pattern
The pr-check-summary job deserves special attention. It runs if: always(),
meaning it executes regardless of whether other jobs were skipped or failed. It
then uses OPA to check whether any required job reported a failure.
This approach lets you use a single required status check
(pr-check-summary) in your branch protection rules, rather than listing every
individual job. When you add or remove conditional jobs, you only need to update
the needs list — the branch protection rules remain unchanged.
Adapting the Pattern
To adapt this pattern to your repository:
- Identify your check categories - What logical groupings of files exist? (packages, services, languages, etc.)
- Define the file-to-check mapping in a Rego policy - Which file patterns trigger which checks?
- Write tests - Cover edge cases like shared files, root config changes, and files that should not trigger any check.
- Wire it into your workflow - Use the
check-changesjob pattern to set outputs, andif:conditions on downstream jobs.
Real-World Examples
This pattern is used in production by the OPA project itself:
- OPA's pull-request workflow - Selectively runs Go, Wasm, docs, Rego, and YAML checks based on changed files
- OPA's PR check policy - The Rego policy that drives the check selection
- Java OPA SDK PR checks - Applies the same pattern to a Gradle mono-repo with multiple subprojects