PR Validation
July 10, 2026 ยท View on GitHub
The PR-validation gate is the merge guard for main. It runs a set of
offline (and one OIDC-authenticated) checks against every pull request so
that broken content, templates, or a stale dependency manifest cannot land.
Two implementations exist and share the same underlying scripts:
| CI system | File | Shape |
|---|---|---|
| GitHub Actions | .github/workflows/pr-validation.yml | Five independent jobs, each its own required status check |
| Azure DevOps | Pipelines/Sentinel-PR-Validation.yml | One stage, one job, two gates (Pester + dependency manifest) |
The two are deliberately asymmetric. The GitHub workflow is the richer of the pair; the ADO pipeline covers the two gates that need no Azure authentication. The GitHub vs Azure DevOps section below spells out exactly what each covers.
This page documents pipeline mechanics only. For what the invoked scripts
actually do, see Pester Tests (the test
suite validate runs) and Dependency Manifest
(the drift gate). The one-off setup for the OIDC-authenticated arm-validate
job is in PR Validation Gate Setup.
GitHub Actions: pr-validation.yml
Triggers
| Event | Detail |
|---|---|
pull_request | Target branch main; activity types opened, synchronize, reopened, ready_for_review |
push | Branch main (CI baseline; catches admin-override, force-push and squash-merge edge cases where a direct push reaches main) |
workflow_dispatch | Manual run against any branch |
There is no paths / paths-ignore filter. Every job runs on every
pull request and every push to main, regardless of which files changed.
Concurrency
concurrency:
group: pr-validation-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
A new commit on a PR cancels the in-flight run for that PR (grouped by PR
number), so only the latest commit's status counts. Push-to-main runs fall
back to github.ref so they group per ref rather than colliding with PR runs.
Workflow-level environment (version pins)
| Variable | Value | Used by |
|---|---|---|
PESTER_VERSION | 5.7.1 | validate (via the setup composite action) |
YAML_VERSION | 0.4.12 | validate, dependency-manifest |
The kql-validate job additionally pins KUSTO_LANGUAGE_VERSION (12.2.4)
at job scope. The pins exist because this gate is a hard merge requirement:
a breaking release from PSGallery or NuGet must not be able to silently fail
the gate on an unrelated change. Bumping a pin is a one-line PR that re-runs
the gate against the new version before merging.
Jobs
All five jobs run in parallel on ubuntu-latest. Each surfaces as its own
status check that the branch-protection ruleset can require independently.
1. validate (Pester)
- Auth: none (offline).
- Timeout: 15 minutes.
- Permissions:
contents: read,checks: write,pull-requests: write.checks: writeis required by the results-publishing action to post per-test results to the check-run UI; without it the publish step 403s and marks the job red even on a clean test run. - Steps:
- Checkout (
fetch-depth: 1). ./.github/actions/setup-pwsh-moduleswith the pinned Pester + powershell-yaml versions.- Run
Tools/Invoke-PRValidation.ps1with-InstallModules:$false(the composite action already installed the modules). This is the single cross-platform entrypoint that both CI systems call, so test discovery, report emission and exit-code logic live in one place. - Publish the results to the PR check UI (runs
if: always(), so failed tests are visible with per-test granularity rather than a bare "validate failed"). - Upload the report directory as a run artefact (
if: always(), 30-day retention).
- Checkout (
- Output: an NUnit 2.5 XML report at
test-results/pester-results.xml. (The workflow's inline comments say "JUnit XML" loosely;Invoke-PRValidation.ps1sets Pester'sOutputFormat = 'NUnitXml', and the publish action auto-detects the NUnit schema.)
2. bicep-build
- Auth: none (
az bicep buildis fully offline). - Timeout: 5 minutes.
- What it does: ensures the Bicep CLI is current (
az bicep install), then compiles everyInfra/**/*.bicepfile to ARM JSON viaaz bicep build --stdout. Catches syntax errors, undefined parameter references, type mismatches and lint violations before they reach the deploy pipeline. Fails the job if any file fails to build.
3. arm-validate
- Auth: federated OIDC against the deploy service principal (the only job that needs Azure). One-off setup: PR Validation Gate Setup.
- Timeout: 20 minutes.
- Permissions:
id-token: write(for OIDC),contents: read. - Skip conditions (evaluated in the first step, which sets a
should_skipoutput):- Fork PRs cannot obtain an OIDC token (the federated subject mismatches), so the job no-ops with a warning telling maintainers to validate ARM templates manually.
- If any of the OIDC secrets (
AZURE_CLIENT_ID,AZURE_TENANT_ID,AZURE_SUBSCRIPTION_ID) is empty, the job skips with a warning rather than failing the gate on missing configuration. Once the secrets are populated the next run exercises the job.
- Steps (when not skipped):
- Checkout.
./.github/actions/azure-login-oidcwith the three OIDC secrets.- For every
Content/Playbooks/**/*.jsontemplate, callTest-AzResourceGroupDeploymentagainst the resource group named in thePR_VALIDATION_RESOURCE_GROUPrepository variable, passing a set of stub parameter values (only the parameters each template actually declares are forwarded). This is a template-validation call against the ARM deployment-validation API -Test-AzResourceGroupDeploymenthas no-WhatIfparameter and creates nothing. It catches malformed ARM templates, missing required parameters and schema-version mismatches. Any validation error fails the job.
- Required repo variable:
PR_VALIDATION_RESOURCE_GROUP(the job errors out with a pointer to the setup runbook if it is unset).
4. kql-validate
-
Auth: none (offline).
-
Timeout: 10 minutes.
-
What it does: builds a throwaway .NET probe project that pulls the
Microsoft.Azure.Kusto.LanguageNuGet package (pinned viaKUSTO_LANGUAGE_VERSION, cached by version + OS key), loadsKusto.Language.dll, then parses the KQL from:Content/AnalyticalRules/**,Content/HuntingQueries/**,Content/Parsers/**(YAMLqueryfield);Content/SummaryRules/**(JSONqueryfield);Content/DefenderCustomDetections/**(YAMLqueryCondition.queryText).
Each query is parsed with
KustoCode.Parseand checked forGetSyntaxDiagnostics(). This is a syntax-level check only (no workspace schema), so it catches unmatched parentheses, unknown keywords, missing pipe operators and malformed literals, but not column-existence errors. Any syntax diagnostic fails the job.
5. dependency-manifest
- Auth: none (offline; reads YAML/JSON only).
- Timeout: 5 minutes.
- Steps:
- Checkout.
setup-pwsh-moduleswithinstall-pester: 'false'(this gate consumes only powershell-yaml).Tools/Build-DependencyManifest.ps1 -Mode Verify, which walks the content, re-runs the discovery extractors, and compares the result against the on-diskdependencies.json. Any drift fails the job with a fix message.
- Author fix for drift: run
./Tools/Build-DependencyManifest.ps1 -Mode Generatelocally and commit the regenerated manifest, or wait for the daily dependency-update workflow to open an auto-PR. See Dependency Manifest.
Composite actions consumed
| Action | Purpose |
|---|---|
.github/actions/setup-pwsh-modules | Cache + pin-install Pester and powershell-yaml (exact versions); install-pester: 'false' skips Pester for YAML-only jobs |
.github/actions/azure-login-oidc | Federated Azure login via Azure/login@v3; used only by arm-validate |
Secrets and repository variables
| Kind | Name | Consumed by |
|---|---|---|
| Secret | AZURE_CLIENT_ID | arm-validate (OIDC) |
| Secret | AZURE_TENANT_ID | arm-validate (OIDC) |
| Secret | AZURE_SUBSCRIPTION_ID | arm-validate (OIDC) |
| Repo variable | PR_VALIDATION_RESOURCE_GROUP | arm-validate (target RG for template validation) |
The other four jobs consume no secrets or variables.
Artefacts and outputs
validatepublishes the NUnit 2.5 report to the PR check UI and uploadstest-results/as run artefactpester-results-<run_id>(30-day retention).- No other job produces an artefact; their outputs are the pass/fail status check and the annotated log.
Wiring as a required gate
After each job has run at least once, add the checks under
Repo Settings -> Rules -> Rulesets -> Main Branch Protection -> Require
status checks to pass: validate, bicep-build, kql-validate,
dependency-manifest, and arm-validate (the last only after the OIDC
setup in PR Validation Gate Setup is complete).
Once required, the merge button stays disabled until every required check
reports success against the PR's latest commit.
Azure DevOps: Sentinel-PR-Validation.yml
Triggers
Both a pr trigger and a CI trigger, each scoped to main with the same
path filter:
paths:
include:
- Content/AnalyticalRules
- Content/HuntingQueries
- Modules
- Deploy
- Tools
- Tests
- dependencies.json
- Pipelines/Sentinel-PR-Validation.yml
prruns the pipeline automatically against PR commits (even before the branch policy is wired up).triggerprovides a green baseline on push tomain.
Unlike the GitHub workflow, the ADO pipeline does filter by path: it only runs when one of the listed paths changes.
Variables
| Variable | Value |
|---|---|
testResultsFile | $(Build.SourcesDirectory)/test-results/pester-results.xml |
Stage and job
One stage (Validate, display name "PR Validation") with one job
(RunPRValidation) on vmImage: ubuntu-latest, timeout 15 minutes. No
Azure authentication - every gate here is offline.
Steps, in order:
- Checkout
selfwithfetchDepth: 1. - Install Pester and powershell-yaml (inline
pwsh): installs Pester (minimum5.0.0) if a suitable version is not already present, plus powershell-yaml if missing. Note this is a minimum-version install, not the exact pin the GitHub composite action enforces. - Run PR validation: calls
Tools/Invoke-PRValidation.ps1with-RepoPath,-TestResultsPath "$(testResultsFile)"and-InstallModules:$false- the same entrypoint the GitHubvalidatejob uses. - Verify dependency manifest:
Tools/Build-DependencyManifest.ps1 -Mode Verify; logs an error and exits non-zero on drift. This is the ADO equivalent of the GitHubdependency-manifestjob. - Publish Pester results (
condition: always()):PublishTestResults@2withtestResultsFormat: NUnitagainst$(testResultsFile).failTaskOnFailedTests: falsebecause the Pester step has already exited non-zero on failure. - Upload Pester report (
condition: always()):PublishPipelineArtifact@1uploadstest-results/aspester-results-$(Build.BuildId).
Wiring as a required gate
Configure a build-validation branch policy under Project Settings -> Repos
-> Repositories -> <repo> -> Policies -> Branch policies for main:
Build pipeline Sentinel-PR-Validation, trigger Automatic, requirement
Required, expiration "Immediately when main is updated". Once required, the
PR's Complete button stays disabled until the pipeline reports success against
the latest commit.
GitHub vs Azure DevOps
| Aspect | GitHub pr-validation.yml | ADO Sentinel-PR-Validation.yml |
|---|---|---|
| Gates | 5 jobs: validate, bicep-build, arm-validate, kql-validate, dependency-manifest | 2 gates in one job: Pester (Invoke-PRValidation.ps1) + dependency manifest |
| Bicep / ARM / KQL checks | Yes (bicep-build, arm-validate, kql-validate) | Not present |
| Azure auth | arm-validate uses OIDC; rest offline | None (all gates offline) |
| Path filter | None (runs on every PR / push to main) | Path-filtered on both pr and trigger |
| PR trigger scope | opened, synchronize, reopened, ready_for_review | pr branches main |
| Module install | Exact pins via setup-pwsh-modules (Pester 5.7.1, powershell-yaml 0.4.12) | Inline; Pester minimum 5.0.0, powershell-yaml latest |
| Test report | NUnit 2.5 XML (published to check UI + artefact) | NUnit 2.5 XML (published via PublishTestResults@2 + artefact) |
| Concurrency | Cancels superseded PR runs | Handled by ADO's own PR-run behaviour |
The headline asymmetry: the ADO pipeline covers only the two authentication-free
gates. The Bicep-build, ARM-template and KQL syntax checks exist on GitHub only.
Both pipelines share Tools/Invoke-PRValidation.ps1 and
Tools/Build-DependencyManifest.ps1, so the Pester and dependency-manifest
gates behave identically across the two.
Related documentation
- Pipelines overview - the full set of pipelines and their GitHub parity.
- PR Validation Gate Setup - one-off OIDC + required-check setup for
arm-validate. - Pester Tests - the test suite the
validategate runs. - Dependency Manifest - what the dependency-manifest drift gate checks.