Skip to content

Filter IR specification

This document specifies the intermediate representation (IR) used by the ado-aw compiler to translate trigger filter configurations (YAML front matter) into bash gate steps that run inside Azure DevOps pipelines.

Source: src/compile/filter_ir.rs

When an agent file declares runtime trigger filters under on.pr.filters or on.pipeline.filters, the compiler generates a gate step — a bash script injected into the Setup job that evaluates each filter at pipeline runtime and self-cancels the build if any filter fails.

The IR formalises this compilation as a three-pass pipeline:

on.pr.filters / on.pipeline.filters (YAML front matter)
┌──────────────┐
│ 1. Lower │ Filters -> Vec<FilterCheck>
└──────┬───────┘
┌──────────────┐
│ 2. Validate │ Vec<FilterCheck> -> Vec<Diagnostic>
└──────┬───────┘
┌──────────────┐
│ 3. Codegen │ GateContext + Vec<FilterCheck> -> bash string
└──────────────┘

A Fact is a typed runtime value that can be acquired during pipeline execution. Each fact has:

PropertyTypePurpose
dependencies()&[Fact]Facts that must be acquired first
kind()&strUnique identifier used in the serialized spec
ado_exports()Vec<(&str, &str)>ADO macro -> env var mappings for the bash shim
failure_policy()FailurePolicyWhat happens if acquisition fails
is_pipeline_var()boolWhether this is a free ADO pipeline variable

Facts are organised into four tiers by acquisition cost:

These are always available via ADO macro expansion — no I/O required.

FactADO VariableShell VarApplies To
PrTitle$(System.PullRequest.Title)TITLEPR
AuthorEmail$(Build.RequestedForEmail)AUTHORPR
SourceBranch$(System.PullRequest.SourceBranch)SOURCE_BRANCHPR
TargetBranch$(System.PullRequest.TargetBranch)TARGET_BRANCHPR
CommitMessage$(Build.SourceVersionMessage)COMMIT_MSGPR, CI
BuildReason$(Build.Reason)REASONAll
TriggeredByPipeline$(Build.TriggeredBy.DefinitionName)SOURCE_PIPELINEPipeline
TriggeringBranch$(Build.SourceBranch)TRIGGER_BRANCHPipeline, CI

Require a curl call to the ADO REST API. PrIsDraft and PrLabels depend on PrMetadata being acquired first.

FactSourceShell VarDepends On
PrMetadataGET pullRequests/{id}PR_DATA
PrIsDraftjson .isDraft from PR_DATAIS_DRAFTPrMetadata
PrLabelsjson .labels[].name from PR_DATAPR_LABELSPrMetadata

Require a separate API call to the PR iterations endpoint.

FactSourceShell VarDepends On
ChangedFilesGET pullRequests/{id}/iterations/{last}/changesCHANGED_FILES
ChangedFileCountgrep -c on CHANGED_FILESFILE_COUNT

Derived from runtime computation (no API calls).

FactSourceShell Var
CurrentUtcMinutesdate -u -> minutes since midnightCURRENT_MINUTES

Each fact declares what happens if it cannot be acquired at runtime:

PolicyBehaviourUsed By
FailClosedCheck fails -> SHOULD_RUN=falsePipeline vars, PrIsDraft, CurrentUtcMinutes
FailOpenCheck passes -> assume OKPrLabels, ChangedFiles, ChangedFileCount
SkipDependentsLog warning, skip dependent predicatesPrMetadata

A Predicate is a pure boolean test over one or more acquired facts. The IR supports these predicate types:

PredicateBash ShapeExample
GlobMatch { fact, pattern }fnmatch(value, pattern)Title matches *[review]*
Equality { fact, value }[ "$VAR" = "value" ]Draft is false
ValueInSet { fact, values, case_insensitive }echo "$VAR" | grep -q[i]E '^(a|b)$'Author in allow-list
ValueNotInSet { fact, values, case_insensitive }Inverse of ValueInSetAuthor not in block-list
NumericRange { fact, min, max }[ "$VAR" -ge N ] && [ "$VAR" -le M ]Changed file count in range
TimeWindow { start, end }Arithmetic on CURRENT_MINUTESOnly during business hours
LabelSetMatch { any_of, all_of, none_of }grep -qiF per labelPR labels match criteria
FileGlobMatch { include, exclude }python3 fnmatchChanged files match globs
And(Vec<Predicate>)All must pass(reserved for compound filters)
Or(Vec<Predicate>)At least one must pass(reserved)
Not(Box<Predicate>)Inner must fail(reserved)

And, Or, and Not are reserved for future compound filter expressions. Currently all filter checks at the top level use AND semantics implicitly (all must pass).

Each predicate can report the set of facts it requires via required_facts() -> BTreeSet<Fact>. This drives fact acquisition planning in the codegen pass.

A FilterCheck pairs a predicate with metadata used for diagnostics and bash codegen:

struct FilterCheck {
name: &'static str, // "title", "author include", "labels", etc.
predicate: Predicate, // The boolean test
build_tag_suffix: &'static str, // "title-mismatch" -> "{prefix}:title-mismatch"
}

all_required_facts() returns the transitive closure of all facts needed by the check, including dependencies (e.g. a draft check needs both PrIsDraft and its dependency PrMetadata).

A GateContext determines the trigger-type-specific behaviour of the gate step:

Contextbuild_reason()tag_prefix()step_name()Bypass Condition
PullRequestPullRequestpr-gateprGateBuild.Reason != PullRequest
PipelineCompletionResourceTriggerpipeline-gatepipelineGateBuild.Reason != ResourceTrigger

Non-matching builds bypass the gate automatically and set SHOULD_RUN=true.

lower_pr_filters(filters: &PrFilters) -> Vec<FilterCheck>

Section titled “lower_pr_filters(filters: &PrFilters) -> Vec<FilterCheck>”

Maps each field of PrFilters to a FilterCheck:

FieldPredicateFact(s)Tag Suffix
titleGlobMatchPrTitletitle-mismatch
author.includeValueInSet (case-insensitive)AuthorEmailauthor-mismatch
author.excludeValueNotInSet (case-insensitive)AuthorEmailauthor-excluded
source_branchGlobMatchSourceBranchsource-branch-mismatch
target_branchGlobMatchTargetBranchtarget-branch-mismatch
commit_messageGlobMatchCommitMessagecommit-message-mismatch
labelsLabelSetMatchPrLabels (-> PrMetadata)labels-mismatch
draftEqualityPrIsDraft (-> PrMetadata)draft-mismatch
changed_filesFileGlobMatchChangedFileschanged-files-mismatch
time_windowTimeWindowCurrentUtcMinutestime-window-mismatch
min/max_changesNumericRangeChangedFileCountchanges-mismatch
build_reason.includeValueInSet (case-insensitive)BuildReasonbuild-reason-mismatch
build_reason.excludeValueNotInSet (case-insensitive)BuildReasonbuild-reason-excluded

lower_pipeline_filters(filters: &PipelineFilters) -> Vec<FilterCheck>

Section titled “lower_pipeline_filters(filters: &PipelineFilters) -> Vec<FilterCheck>”
FieldPredicateFact(s)Tag Suffix
source_pipelineGlobMatchTriggeredByPipelinesource-pipeline-mismatch
branchGlobMatchTriggeringBranchbranch-mismatch
time_windowTimeWindowCurrentUtcMinutestime-window-mismatch
build_reason.includeValueInSetBuildReasonbuild-reason-mismatch
build_reason.excludeValueNotInSetBuildReasonbuild-reason-excluded

The expression field on both PrFilters and PipelineFilters is not part of the IR. It is a raw ADO condition string applied directly to the Agent job’s condition: field (not the bash gate step). It is handled by generate_agentic_depends_on() in common.rs.

validate_pr_filters(filters: &PrFilters) -> Vec<Diagnostic>

Section titled “validate_pr_filters(filters: &PrFilters) -> Vec<Diagnostic>”

Compile-time checks for impossible or conflicting configurations:

CheckSeverityCondition
Min exceeds maxErrormin_changes > max_changes
Zero-width time windowErrortime_window.start == time_window.end
Author include/exclude overlapErrorauthor.include ∩ author.exclude ≠ ∅ (case-insensitive)
Build reason include/exclude overlapErrorbuild_reason.include ∩ build_reason.exclude ≠ ∅
Labels any-of ∩ none-of overlapErrorlabels.any_of ∩ labels.none_of ≠ ∅
Labels all-of ∩ none-of overlapErrorlabels.all_of ∩ labels.none_of ≠ ∅
Empty labels filterWarningAll of any_of, all_of, none_of are empty

validate_pipeline_filters(filters: &PipelineFilters) -> Vec<Diagnostic>

Section titled “validate_pipeline_filters(filters: &PipelineFilters) -> Vec<Diagnostic>”
CheckSeverityCondition
Zero-width time windowErrortime_window.start == time_window.end
Build reason include/exclude overlapErrorbuild_reason.include ∩ build_reason.exclude ≠ ∅

Error diagnostics cause compilation to fail with an actionable message. Warning diagnostics are emitted to stderr but compilation continues.

Regex and glob pattern overlap is intentionally not validated — it would require heuristic analysis and could produce false positives.

compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String

Section titled “compile_gate_step(ctx: GateContext, checks: &[FilterCheck]) -> String”

Produces a complete ADO pipeline step (- bash: |) with a data-driven architecture: bash is a thin ADO-macro shim, all filter logic lives in a generic Python evaluator that reads a JSON gate spec.

- bash: |
# 1. ADO macro exports (fact-specific, minimal set)
export ADO_BUILD_REASON="$(Build.Reason)"
export ADO_COLLECTION_URI="$(System.CollectionUri)"
export ADO_PROJECT="$(System.TeamProject)"
export ADO_BUILD_ID="$(Build.BuildId)"
export ADO_PR_TITLE="$(System.PullRequest.Title)"
# ... only the macros needed by this spec's facts ...
# 2. Base64-encoded gate spec (safe from ADO macro expansion)
export GATE_SPEC="eyJjb250ZXh0Ijp7Li4ufX0="
# 3. Access token passthrough
export ADO_SYSTEM_ACCESS_TOKEN="$SYSTEM_ACCESSTOKEN"
# 4. Embedded Python evaluator (heredoc -- never modified)
python3 << 'GATE_EVAL_EOF'
...evaluator source...
GATE_EVAL_EOF
name: prGate
displayName: "Evaluate PR filters"
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)

The spec is base64-encoded to prevent ADO macro expansion and heredoc quoting issues. Decoded, it contains:

{
"context": {
"build_reason": "PullRequest",
"tag_prefix": "pr-gate",
"step_name": "prGate",
"bypass_label": "PR"
},
"facts": [
{"id": "pr_title", "kind": "pr_title", "failure_policy": "fail_closed"},
{"id": "pr_metadata", "kind": "pr_metadata", "failure_policy": "skip_dependents"},
{"id": "pr_is_draft", "kind": "pr_is_draft", "failure_policy": "fail_closed"}
],
"checks": [
{
"name": "title",
"predicate": {"type": "glob_match", "fact": "pr_title", "pattern": "*[review]*"},
"tag_suffix": "title-mismatch"
},
{
"name": "draft",
"predicate": {"type": "equals", "fact": "pr_is_draft", "value": "false"},
"tag_suffix": "draft-mismatch"
}
]
}

The spec is declarative — it uses fact kinds (e.g., "pr_title", "pr_metadata") not raw REST endpoints. The Python evaluator owns acquisition logic.

Python Gate Evaluator (scripts/gate-eval.py)

Section titled “Python Gate Evaluator (scripts/gate-eval.py)”

The evaluator is a self-contained Python script embedded via include_str!(). It handles:

  1. Bypass logic — reads ADO_BUILD_REASON and exits early for non-matching trigger types
  2. Fact acquisition — maps fact kinds to acquisition methods:
    • Pipeline variables -> os.environ.get("ADO_*")
    • PR metadata -> urllib call to ADO REST API
    • Changed files -> iteration API calls
    • UTC time -> datetime.now(timezone.utc)
  3. Failure policiesfail_closed, fail_open, skip_dependents
  4. Predicate evaluation — recursive evaluator supporting all predicate types
  5. Result reporting##vso[...] logging commands, build tags, self-cancel

The evaluator never changes per-pipeline — all variation is in the spec.

The bash shim exports only the ADO macros needed by the spec’s facts:

  • Always exported: ADO_BUILD_REASON, ADO_COLLECTION_URI, ADO_PROJECT, ADO_BUILD_ID (needed for bypass and self-cancel)
  • PR API facts: ADO_REPO_ID, ADO_PR_ID (only when pr_metadata, pr_is_draft, pr_labels, or changed_files facts are required)
  • Fact-specific: each Fact variant declares its ADO exports via ado_exports() (e.g., PrTitle -> ADO_PR_TITLE)
typeFieldsDescription
glob_matchfact, patternGlob match (* any chars, ? single char)
equalsfact, valueExact string equality
value_in_setfact, values, case_insensitiveValue membership
value_not_in_setfact, values, case_insensitiveInverse membership
numeric_rangefact, min?, max?Integer range check
time_windowstart, endUTC HH:MM window (overnight-aware)
label_set_matchfact, any_of?, all_of?, none_of?Label set predicates
file_glob_matchfact, include?, exclude?Python fnmatch globs
andoperandsAll must pass
oroperandsAt least one must pass
notoperandInner must fail

When Tier 2/3 filters are configured, the TriggerFiltersExtension (src/compile/extensions/trigger_filters.rs) activates via collect_extensions(). It implements CompilerExtension and controls:

  1. Download step — downloads scripts.zip from the ado-aw release artifacts, verifies its SHA256 checksum via checksums.txt, then extracts gate-eval.py to /tmp/ado-aw-scripts/gate-eval.py
  2. Gate step — calls compile_gate_step_external() to generate a step that references the downloaded script (no inline heredoc)
  3. Validation — runs validate_pr_filters() / validate_pipeline_filters() during compilation via the validate() trait method

The extension uses the setup_steps() trait method (not prepare_steps()) because the gate must run in the Setup job (before the SafeOutputs job).

When only Tier 1 filters are configured (pipeline variables — title, author, branch, commit-message, build-reason), the extension is NOT activated. generate_pr_gate_step() generates an inline bash gate step directly, with no Python evaluator and no download step.

Gate steps are injected into the Setup job by generate_setup_job() in common.rs. When the TriggerFiltersExtension is active, its setup_steps() are collected and injected first (download + gate). When only Tier 1 filters are present, the inline gate step is injected directly.

User setup steps are conditioned on the gate output: condition: eq(variables['{stepName}.SHOULD_RUN'], 'true')

generate_agentic_depends_on() in common.rs generates the Agent job’s dependsOn and condition clauses:

dependsOn: Setup
condition: |
and(
succeeded(),
or(
ne(variables['Build.Reason'], 'PullRequest'),
eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')
)
)

When both PR and pipeline filters are active, both or() clauses are ANDed. The expression escape hatch is also ANDed if present.

gate-eval.py lives at scripts/gate-eval.py in the repository and is shipped inside a scripts.zip archive alongside the ado-aw binary. The download URL is deterministic based on the ado-aw version: https://github.com/githubnext/ado-aw/releases/download/v{VERSION}/scripts.zip

A checksums.txt file is also published at the same URL base and used to verify the SHA256 integrity of scripts.zip before extraction.

See Extending the Compiler for the step-by-step guide. In summary:

  1. Add a Fact variant if a new data source is needed (with kind(), ado_exports(), dependencies(), failure_policy())
  2. Add a Predicate variant if a new test shape is needed
  3. Add a PredicateSpec variant for serialization
  4. Add an evaluator handler in scripts/gate-eval.py for the new predicate type
  5. Extend the lowering function (lower_pr_filters or lower_pipeline_filters)
  6. Add validation rules if the new filter can conflict with existing ones
  7. Write tests: lowering, validation, spec serialization, and evaluator