ado-script
ado-script is the umbrella name for the TypeScript workspace at
scripts/ado-script/.
It produces small, ncc-bundled Node programs that the compiler injects into emitted
pipelines as runtime helpers. Today it produces three bundles:
gate.js— trigger-filter gate evaluator (Setup job)import.js— runtime prompt resolver described in Runtime Imports (Agent job)exec-context-pr.js— PR execution-context precompute described in Execution Context (Agent job, PR builds only)
What gate.js does
Section titled “What gate.js does”gate.js is a single-shot Node program that runs as a step in the
pipeline’s Setup job and decides whether the downstream Agent /
SafeOutputs jobs should execute. It evaluates a declarative GateSpec
against runtime facts (PR title, labels, changed files, build reason,
etc.) and emits exactly one ##vso[task.setvariable] line:
##vso[task.setvariable variable=SHOULD_RUN;isOutput=true]true (or false)Downstream jobs gate themselves on that variable via a condition:
clause emitted by the compiler.
The gate is a data interpreter, not a code evaluator. The GateSpec
is a typed JSON document; predicates are dispatched via a switch on a
discriminated union. There is no eval, no Function, no vm — a
compromised compiler cannot use the spec to run arbitrary code on the
pipeline runner.
What import.js does
Section titled “What import.js does”import.js is a single-shot Node program. It reads the prompt file path
from argv[2] and resolves {{#runtime-import path}} markers in place.
The compiler runs it as a post-prepare-prompt step when
inlined-imports: false. See
Runtime Imports for the author-facing marker
syntax.
Env-var contract
Section titled “Env-var contract”import.js takes no environment variables. Relative-path markers
resolve against dirname(argv[2]); in pipeline use this is irrelevant
because the compiler always embeds an absolute marker path and
import.js is single-pass (nested markers inside the inlined body are
not re-expanded).
The bundle lives at import.js and ships in the same
ado-script.zip release asset as gate.js, so pipelines download it
through the same Setup-job asset flow. import.js uses only the Node
standard library, so the ncc bundle is small (~1.5 KB) and carries no
SDK dependency.
The Stage-2 threat-analysis prompt is not runtime-imported.
src/data/threat-analysis.md is include_str!’d into the ado-aw
binary and inlined into the emitted YAML at compile time, matching
gh-aw’s pattern (their threat_detection.md ships with the setup
action and is read directly from disk — no marker, no resolver).
What exec-context-pr.js does
Section titled “What exec-context-pr.js does”exec-context-pr.js is a single-shot Node program injected into the Agent
job’s prepare phase only on PR-triggered builds. It resolves the PR
merge-base and stages artefacts the agent can use to reason about the diff:
aw-context/└── pr/ ├── base.sha # target merge-base SHA (40-char hex) ├── head.sha # PR head SHA (40-char hex) └── error.txt # present only on failure; one-line reasonIt also appends a tailored prompt fragment to /tmp/awf-tools/agent-prompt.md
(the file assembled by the “Prepare agent prompt” base-YAML step). On success
the fragment tells the agent its PR number, project, repository, and provides
pre-filled example ADO MCP tool call arguments. On failure it surfaces a
graceful degradation message so the agent can still proceed without panicking.
Merge-base resolution
Section titled “Merge-base resolution”Two paths, both computing the same “merge-base of target tip and PR head”:
- Synthetic merge commit — ADO’s default PR checkout produces a merge
commit (
HEADhas ≥ 2 parents).HEAD^1is the target tip;HEAD^2is the PR head. The bundle runsgit merge-base HEAD^1 HEAD^2. - Progressive deepening — when
HEADis a normal commit (shallow clone without a merge commit), the bundle fetches the target branch at increasing depths (--depth=200,500,2000,--unshallow) untilgit merge-base origin/<target> HEADresolves.
Trust boundary
Section titled “Trust boundary”SYSTEM_ACCESSTOKEN is mapped only into this step’s env: block — never into
the agent step’s env. The bearer is passed to spawned git child processes via
GIT_CONFIG_COUNT / GIT_CONFIG_KEY_0 / GIT_CONFIG_VALUE_0 env vars (the
http.extraheader pattern), so it never appears in argv or in .git/config.
Env-var contract
Section titled “Env-var contract”| Env var | ADO source | Purpose |
|---|---|---|
SYSTEM_ACCESSTOKEN | $(System.AccessToken) | Bearer for git fetch of the target branch |
SYSTEM_PULLREQUEST_PULLREQUESTID | $(System.PullRequest.PullRequestId) | PR number (validated: digits only) |
SYSTEM_PULLREQUEST_TARGETBRANCH | $(System.PullRequest.TargetBranch) | Target branch ref (e.g. refs/heads/main) |
SYSTEM_TEAMPROJECT | $(System.TeamProject) | ADO project name (validated: alphanumeric + . _ -) |
BUILD_REPOSITORY_NAME | $(Build.Repository.Name) | Repository name (validated: alphanumeric + . _ -) |
BUILD_SOURCESDIRECTORY | $(Build.SourcesDirectory) | Workspace root — where aw-context/pr/ is staged |
All four identifier env vars are validated against strict allowlist regexes
before any value is interpolated into a git refspec or the agent prompt. A
validation failure writes aw-context/pr/error.txt and a failure-fragment to
the agent prompt, then exits 0 so the rest of the pipeline can continue.
End-to-end data flow
Section titled “End-to-end data flow” ┌──────────────────────┐ │ Rust compiler │ │ (filter_ir.rs) │ └──────────┬───────────┘ │ build_gate_spec(...) → GateSpec (JSON, base64) ▼ ┌──────────────────────┐ │ Generated pipeline │ │ Setup job: │ │ 1. UseNode@1 │ │ 2. curl + sha256 │ downloads ado-script.zip │ + unzip │ from the matching ado-aw release │ 3. node gate/index │ reads GATE_SPEC env var │ .js │ └──────────┬───────────┘ │ ##vso[task.setvariable variable=SHOULD_RUN;…] ▼ ┌──────────────────────┐ │ Agent / SafeOutputs │ conditioned on SHOULD_RUN=true │ jobs │ └──────────────────────┘The same GateSpec shape is generated as a JSON Schema by
cargo run -- export-gate-schema and converted to TypeScript by
json-schema-to-typescript into src/shared/types.gen.ts. The TS
gate evaluator imports from types.gen.ts, never from a hand-written
mirror of the IR — so the spec contract cannot drift between compiler
and evaluator. CI enforces this with a git diff --exit-code step on
the codegen output.
Runtime stages inside gate.js
Section titled “Runtime stages inside gate.js”gate.js’s entry point is src/gate/index.ts. It runs five stages,
all single-shot, all fail-closed on error:
- Decode + size-cap — base64-decode
GATE_SPEC, reject if the decoded JSON exceedsMAX_SPEC_DECODED_BYTES(256 KiB), thenJSON.parse. - Pre-flight validation — walk the predicate tree and throw on
any unknown
typediscriminant. This catches version drift between a newer compiler and an older bundledgate.jsbefore fact acquisition runs, so the failure mode is “loud” rather than “silent skip when the dependent fact is unavailable”. Deliberately runs beforerunBypassso a malformed spec fails fast regardless of build reason. - Bypass — if
ADO_BUILD_REASONdoes not matchspec.context.build_reason(e.g. spec is forPullRequestbut the build isManual), auto-pass: emitSHOULD_RUN=true, tag the build, completeSucceeded, exit. - Fact acquisition — for every
FactSpecin the spec, either read a pipeline env var (isPipelineVarFact) or call the ADO REST API (pr_metadata,pr_labels,changed_files, …). Each per-fact failure is recorded in thePolicyTrackerand dispatched via that fact’sfailure_policy(fail_closed/fail_open/skip_dependents). - Predicate evaluation — for each
CheckSpec, thePolicyTrackerdecides whether the check isevaluate,pass,skip, orfailbased on which referenced facts are still available. Evaluator dispatches the predicate via theswitchinevaluatePredicate. Failing checks emitaddBuildTagand the overallSHOULD_RUNistrueiff every check ispassorskip.
If SHOULD_RUN ends up false, selfCancelIfRequested issues a
best-effort BuildStatus.Cancelling PATCH so the pipeline run is
visibly cancelled in the ADO UI rather than just paused on a gated
job.
Runtime env-var contract
Section titled “Runtime env-var contract”The compiler injects these environment variables on the
bash: node gate.js step. gate.js reads them via
process.env:
| Env var | Source | Purpose |
|---|---|---|
GATE_SPEC | compiled inline (base64) | The full GateSpec JSON |
SYSTEM_ACCESSTOKEN | $(System.AccessToken) | ADO REST auth |
ADO_COLLECTION_URI | $(System.CollectionUri) | ADO org base URL |
ADO_BUILD_REASON | $(Build.Reason) | Used by the bypass branch |
ADO_BUILD_ID | $(Build.BuildId) | Used for selfCancelIfRequested |
ADO_PROJECT / ADO_REPO_ID / ADO_PR_ID | compiler-injected | PR-derived facts |
ADO_* (fact-specific) | Fact::ado_exports() in Rust | Per-fact pipeline-variable readers (e.g. ADO_PR_TITLE, ADO_SOURCE_BRANCH) |
ADO_API_TIMEOUT_MS | optional override | Per-attempt timeout for every ADO REST call. Default 30 000. On timeout, the call is retried once; if the retry also times out, the gate falls back to the per-fact FailurePolicy. |
The exact contract for pipeline-variable facts (which env var maps to
which FactKind) lives in two places that must stay in lockstep:
- Rust:
Fact::ado_exports()insrc/compile/filter_ir.rs - TS:
ENV_BY_FACTplus theFactKindunion inscripts/ado-script/src/shared/env-facts.ts
The codegen drift check only mirrors the GateSpec shape, not the
env-var mapping, so when adding a new pipeline-variable fact you must
update both sides by hand. Fact::ado_exports() carries a docstring
pointing at the TS mirror as a reminder.
Workspace layout
Section titled “Workspace layout”scripts/ado-script/├── package.json # type:module; dep: azure-devops-node-api (lazy-imported)├── tsconfig.json # strict; noUncheckedIndexedAccess; NodeNext├── src/│ ├── shared/ # Reusable across all bundles│ │ ├── types.gen.ts # AUTO-GENERATED from Rust IR — do not edit│ │ ├── auth.ts # WebApi factory; SDK is dynamic-imported here│ │ ├── ado-client.ts # azure-devops-node-api wrapper + retry + timeout + pagination│ │ ├── env-facts.ts # Pipeline-variable readers + ENV_BY_FACT + BRANCH_FACTS + ref-prefix stripping│ │ ├── policy.ts # PolicyTracker state machine│ │ └── vso-logger.ts # ##vso[…] emitters with property/message escaping; complete() is idempotent│ ├── gate/ # gate.js entry point + per-concern modules│ │ ├── index.ts # main(): decode → preflight → bypass → facts → eval → emit│ │ ├── bypass.ts # build-reason auto-pass│ │ ├── facts.ts # fact acquisition (env + REST)│ │ ├── predicates.ts # 11 predicate evaluators + validatePredicateTree + glob ReDoS hardening│ │ └── selfcancel.ts # best-effort build cancellation│ ├── import/ # import.js entry point + runtime prompt resolver│ │ ├── index.ts # main(): expand runtime-import markers in place│ │ └── __tests__/ # marker, path-resolution, and single-pass coverage│ └── exec-context-pr/ # exec-context-pr.js entry point + PR context modules│ ├── index.ts # main(): validate IDs → merge-base → stage artefacts → append prompt fragment│ ├── validate.ts # strict allowlist regexes for all 4 PR identifier env vars│ ├── merge-base.ts # synthetic-merge + progressive-deepening resolution│ ├── git.ts # git runner helpers + bearerEnv() for token isolation│ └── prompt.ts # successFragment() / failureFragment() for agent-prompt.md├── test/ # End-to-end smoke tests├── gate.js # ncc bundle output (gitignored)├── import.js # ncc bundle output (gitignored)└── exec-context-pr.js # ncc bundle output (gitignored)The release workflow (.github/workflows/release.yml) runs
npm ci && npm run build, then zips scripts/ado-script/gate.js,
scripts/ado-script/import.js, and scripts/ado-script/exec-context-pr.js into
the ado-script.zip release asset. Pipelines download that asset at
runtime by URL pinned to the compiler’s CARGO_PKG_VERSION, verify
its SHA-256 against the checksums.txt asset, then extract.
Schema codegen
Section titled “Schema codegen”types.gen.ts is derived from the Rust IR via
schemars →
json-schema-to-typescript:
┌──────────────────────────┐ schemars ┌──────────────────────────┐│ src/compile/filter_ir.rs │ ───────────► │ schema/gate-spec.schema ││ #[derive(JsonSchema)] │ │ .json │└──────────────────────────┘ └────────────┬─────────────┘ │ json2ts ▼ ┌──────────────────────────────┐ │ src/shared/types.gen.ts │ │ (consumed by gate/*.ts) │ └──────────────────────────────┘npm run codegen runs both stages. The CI workflow
(.github/workflows/ado-script.yml) regenerates the file and runs
git diff --exit-code to fail on drift, on both PRs and pushes to
main. If you change the IR shape in Rust, run
cd scripts/ado-script && npm run codegen and commit the regenerated
types.gen.ts.
The Rust subcommand that emits the schema is intentionally hidden:
cargo run -- export-gate-schema --output schema/gate-spec.schema.jsonHow the bundles are wired into emitted pipelines
Section titled “How the bundles are wired into emitted pipelines”AdoScriptExtension
(src/compile/extensions/ado_script.rs) is the always-on single
extension that owns all ado-script wiring. It has three independent
features, each emitted into the job that actually consumes the
bundle:
Setup job (gate evaluator)
Section titled “Setup job (gate evaluator)”When filters: lowers to non-empty checks, AdoScriptExtension::declarations()
returns three typed Declarations::setup_steps entries for the Setup job:
UseNode@1— installs Node 22.x LTS, capped attimeoutInMinutes: 5.curldownload + verify + extract — fetcheschecksums.txtandado-script.zipfrom thegithubnext/ado-awrelease matchingCARGO_PKG_VERSION, verifies the zip’s SHA-256, thenunzip -o /tmp/ado-aw-scripts/ado-script.zip -d /tmp/ado-aw-scripts/. Also capped attimeoutInMinutes: 5.bash: node '/tmp/ado-aw-scripts/ado-script/gate.js'— runs the gate withGATE_SPECand the env-var contract documented above.
Agent job (runtime-import resolver and PR context)
Section titled “Agent job (runtime-import resolver and PR context)”AdoScriptExtension::declarations() contributes Agent-job prepare steps when
either import.js or exec-context-pr.js is active. It returns install +
download first, then appends the relevant invocation steps.
import.js invocation — active when inlined-imports: false (the default):
UseNode@1— installs Node 22.x LTS, capped attimeoutInMinutes: 5.curldownload + verify + extract — same artefact and verification as the Setup job.bash: node '/tmp/ado-aw-scripts/ado-script/import.js'— expands{{#runtime-import …}}markers in/tmp/awf-tools/agent-prompt.mdin place. See Runtime Imports for marker syntax.
exec-context-pr.js invocation — emitted by ExecContextExtension
(src/compile/extensions/exec_context/pr.rs, Tool phase) when on.pr is
configured and execution-context.pr.enabled is not false:
bash: node '/tmp/ado-aw-scripts/ado-script/exec-context-pr.js'— resolves the merge-base and stagesaw-context/pr/{base,head}.sha, then appends a prompt fragment. Gated bycondition: eq(variables['Build.Reason'], 'PullRequest')so it is a no-op on non-PR builds.
Per-job download (NOT a duplication bug)
Section titled “Per-job download (NOT a duplication bug)”ADO jobs use isolated VMs — /tmp is not shared between jobs.
The ado-script.zip bundle therefore has to be downloaded once per
job that consumes it. When both the Setup gate and Agent resolver/PR-context
are active, install + download steps appear in both Setup and Agent.
That’s correct architecture given ADO’s topology, not waste.
What gets emitted, by case
Section titled “What gets emitted, by case”filters: | inlined-imports | on.pr w/ exec-context | Setup-job steps | Agent-job extra steps |
|---|---|---|---|---|
| inactive | true | no | (none) | (none) |
| inactive | true | yes | (none) | install + download + exec-context-pr |
| inactive | false | no | (no Setup job) | install + download + resolver |
| inactive | false | yes | (no Setup job) | install + download + resolver + exec-context-pr |
| active | true | no | install + download + gate | (none) |
| active | true | yes | install + download + gate | install + download + exec-context-pr |
| active | false | no | install + download + gate | install + download + resolver |
| active | false | yes | install + download + gate | install + download + resolver + exec-context-pr |
The IR-to-bash codegen that produces the gate step is
compile_gate_step_external in src/compile/filter_ir.rs.
Modifying ado-script
Section titled “Modifying ado-script”Add a new predicate
Section titled “Add a new predicate”- Add a
Predicate+PredicateSpecvariant insrc/compile/filter_ir.rs. Runcargo testand update spec tests. - In
scripts/ado-script/, runnpm run codegensotypes.gen.tspicks up the new variant. - Add a
caseto theswitchinsrc/gate/predicates.ts::evaluatePredicate. - Add the new type name to
KNOWN_PREDICATE_TYPES(right above thevalidatePredicateTreefunction). Both updates are required — the drift testKNOWN_PREDICATE_TYPES stays in sync with evaluatePredicate switchinpredicates.test.tswill fail if you forget either. - Add a vitest case under
src/gate/__tests__/ports/<new-predicate>.test.ts.
Add a new pipeline-variable fact
Section titled “Add a new pipeline-variable fact”- Add a
Factvariant insrc/compile/filter_ir.rsand updateFact::ado_exports(). (Its docstring reminds you about step 3.) npm run codegento regenerate types.- Add an entry to
ENV_BY_FACTand extend theFactKindunion inscripts/ado-script/src/shared/env-facts.ts. Without this step the gate silently treats the fact as missing. - If the fact value is ref-shaped (e.g. a branch name), add it to
the exported
BRANCH_FACTSset so the read-time strip is applied.
Add a new bundle (e.g. poll.js)
Section titled “Add a new bundle (e.g. poll.js)”The existing exec-context-pr.js bundle is a working example of this pattern — see
scripts/ado-script/src/exec-context-pr/ and src/compile/extensions/exec_context/pr.rs.
- Create
src/poll/index.tsand supporting modules underscripts/ado-script/src/poll/. Reuse anything insrc/shared/. - Add a build script to
package.json:and extend"build:poll": "ncc build src/poll/index.ts -o .ado-build/poll -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/poll/index.js','poll.js'); fs.rmSync('.ado-build/poll',{recursive:true,force:true});\""buildto also run it. - Add vitest tests under
src/poll/__tests__/. - Wire from a new
CompilerExtension(or extend an existing one) that downloadsado-script.zip(already a release asset) and invokesnode /tmp/ado-aw-scripts/ado-script/poll.jsas a runtime step. - Update release packaging to include
scripts/ado-script/poll.jsinado-script.zipalongside other bundles.
Local development loop
Section titled “Local development loop”From scripts/ado-script/:
npm ci # one-timenpm run codegen # regenerate types.gen.ts (compiles ado-aw first)npm test # vitest unit testsnpm run typecheck # strict tsc --noEmitnpm run build # ncc-bundle to gate.jsnpm run test:smoke # build + smoke test the bundle end-to-endThe Rust-side E2E gate test compiles a real agent, extracts the
emitted GATE_SPEC, and shells out to the bundled gate.js:
cargo test --test gate_e2e -- --ignored --nocaptureBundle-size budget
Section titled “Bundle-size budget”Each bundled artifact must stay under 5 MB. The entry-point
chunk for gate.js is ~78 KB; the lazy-imported
azure-devops-node-api SDK lives in a separate ~2.7 MB chunk loaded
only when an ADO REST call is needed. Pipelines that bypass or rely
only on pipeline-variable facts never load the SDK.
If a future bundle blows the budget:
- First, check ncc’s
--minifyand--targetflags. - If still too large, weigh dropping
azure-devops-node-apiin favor of hand-rolledfetchfor the hot endpoints. The retry / timeout / pagination helpers insrc/shared/ado-client.tsare written so they could wrap either approach.
Out of scope (explicitly)
Section titled “Out of scope (explicitly)”- A user-facing
ado-script:front-matter block. Letting authors run arbitrary TypeScript at pipeline runtime would bypass the safe-output trust boundary and require sandboxing the project does not have. - Migrating the safe-output executors (
src/safeoutputs/*.rs) to Node. Stage 3 keeps a Rust-only execution path. - Migrating the agent-stats parser. It runs in-pipeline as part of Stage 1 wrap-up and has no TypeScript dependency need.
- Bundling Node itself. Pipelines install Node via
UseNode@1.
See also
Section titled “See also”- Filter IR — the IR consumed by
gate.js. - Runtime Imports — author-facing marker syntax for
import.js. - Execution Context — user-facing configuration for
exec-context-pr.js. - Extending the Compiler — generic compiler-extension guide.