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 every emitted
pipeline as runtime helpers. Today it produces thirteen bundles:
gate.js— trigger-filter gate evaluator (Setup job).import.js— runtime prompt resolver described inruntime-imports.md(Agent job).exec-context-pr.js— PR-context precompute that resolves the merge-base, writesaw-context/pr/{base,head}.sha, and appends a prompt fragment to the agent prompt (Agent job, before the agent runs). Seeexecution-context.md.exec-context-pr-synth.js— Setup-job precompute that normalises PR-identifier variables into the stableAW_PR_*namespace, promoting CI builds with an open PR to PR semantics (Setup job, before any gate step).exec-context-manual.js— Manual-context precompute that stagesaw-context/manual/{requested-for, parameters.json}for manually-queued builds and appends a## Manual run contextfragment to the agent prompt (Agent job; seeexecution-context.md).exec-context-pipeline.js— Pipeline-completion precompute that fetches upstream-build metadata via the Build REST API and stagesaw-context/pipeline/upstream-*files plus a## Pipeline-completion contextprompt fragment (Agent job; seeexecution-context.md).exec-context-ci-push.js— CI/push precompute that stages "since last green build" diff context underaw-context/ci-push/(Agent job; seeexecution-context.md).exec-context-workitem.js— PR-linked work-item precompute that stages linked work-item metadata and descriptions underaw-context/workitem/(Agent job; seeexecution-context.md).exec-context-schedule.js— Scheduled-run precompute that stages "since last successful scheduled run" context underaw-context/schedule/(Agent job; seeexecution-context.md).exec-context-pr-checks.js— PR Build Validation checks precompute that stages succeeded/failing check results underaw-context/pr/checks/(Agent job; seeexecution-context.md).exec-context-repo.js— Repository identity precompute that stages branch, SHA, last release tag, and commits-since-tag facts underaw-context/repo/(Agent job; seeexecution-context.md).conclusion.js— Conclusion job work-item reporter: reads the safe-outputs execution manifest and upstream job results, files/comments ADO work items for pipeline failures and diagnostic signals (Conclusion job).approval-summary.js— Safe-outputs summary renderer that runs at the end of the Agent job (after proposals are collected). It reads the proposed safe outputs fromsafe_outputs.ndjson, renders a sanitized per-tool markdown summary (pending-approval proposals first when manual review is configured), and attaches it to the build'sado-aw-safe-outputssummary tab via##vso[task.uploadsummary]. Seesafe-outputs.md.github-app-token.js— GitHub App token minter that runs immediately before the Copilot invocation in the Agent and Detection jobs whenengine.github-app-tokenis configured. Builds an RS256 JWT from the App ID (argv) + private key (masked env secret), resolves the installation for the owner, exchanges it for an installation access token, and exposes it as a masked same-jobGITHUB_APP_TOKEN. Invoked again with arevokeargument after the Copilot run (best-effort) to delete the token viaDELETE /installation/token. Compiler-owned, non-secret inputs (--app-id,--owner,--output-var,--repositories,--api-url) are argv flags, not env vars, so a pipeline variable can't shadow them (only the private key / minted token ride in masked env). Runs outside AWF. Seeengine.md.prepare-pr-base.js— create-pull-request base-ref preparer that runs whencreate-pull-requestis configured, in both the Agent job (before the Copilot invocation, so the host-side SafeOutputs MCP server can compute a diff base — issue #1413) and the SafeOutputs job (beforeado-aw execute, so the Stage 3 executor'sgit worktree addresolvesorigin/<target>; each ADO job has an isolated checkout, so the ref must be re-fetched in the job that builds the worktree — issue #1453). For each allowed create-PR repo dir (self+ everycheckout:alias, passed as repeated--repo-dir <dir> --target-branch <branch>pairs in the same dir formmcp.rs::resolve_git_dir_for_patchresolves), it fetches and progressively deepens THAT repo's resolved target branch intorefs/remotes/origin/<target>and pointsrefs/remotes/origin/HEADat it, so a PR can be computed/opened on shallow-default agent pools without a full-historycheckout: self. In a multi-checkout ("meta repo") setup each dir may carry a different target (seecreate-pull-request'starget-branches/infer-target-from-checkout-ref). Reusesshared/merge-base.ts::ensureTargetRefFetched(the same fetch/deepen logic as the PR execution-context precompute). Each--repo-diris a double-quoted ADO-macro path; each--target-branchis a single-quoted literal; the ADO bearer (SYSTEM_ACCESSTOKEN) rides in masked env for the authenticated git fetch. Per-dir failures are isolated (logged + skipped). Runs outside AWF. Seesafe-outputs.md.
Internal-only.
ado-scriptis not a user-facing front-matter feature. Authors never write anado-script:block in their agent markdown. The compiler decides when anado-scriptbundle is needed and how to wire it. Seedocs/tools.mdfor what is user-facing.
Test-only, not shipped:
executor-e2e. The workspace also contains asrc/executor-e2e/harness that drives the deterministic Stage 3 executor E2E suite (seetests/executor-e2e/). It is not a runtime bundle: it is built to the non-roottest-bin/executor-e2e.jsbynpm run build:executor-e2e(kept out of the mainbuildchain and the releaseado-script/*.jsglob), andexecutor-e2eis listed inNON_BUNDLE_DIRSinsrc/__tests__/bundle-coverage.test.tsso the shipped-bundle guard ignores it. The compiler never references it.
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.
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.md for the author-facing marker
syntax.
import.js takes no environment variables — all inputs are explicit
CLI flags, so the resolver cannot be influenced by ambient pipeline state:
node import.js <prompt-file> --base "$(Build.SourcesDirectory)" \
--var "Build.SourcesDirectory=$(Build.SourcesDirectory)" \
--var "Build.Repository.Name=$(Build.Repository.Name)"
--base <path>— root that relative marker paths resolve against. The compiler always passes$(Build.SourcesDirectory)(ADO expands the macro before node runs), and the compiler-emitted marker for the agent body is a trigger-repo-relative path (e.g.agents/foo.md, or$(Build.Repository.Name)/agents/foo.mdunder multi-checkout) — not absolute.import.jsrejects absolute and..paths.--var name=value(repeatable) — a small, compiler-owned allowlist of ADO path-anchor variables (currentlyBuild.SourcesDirectoryandBuild.Repository.Name, defined byPROMPT_ADO_VARSinsrc/compile/extensions/ado_script.rs). ADO expands the$(...)macro into the bash arg at runtime, soimport.jsreceives the concrete value and literally substitutes every$(name)occurrence in the final prompt (author body + inlined snippets). Unknown$(...)macros are left untouched.import.jsnever reads these from the environment; the allowlist is the set of--varflags the compiler emits, so an untrusted agent body cannot introduce new variables. This makes path anchors behave the same whether imports are inlined at compile time (where ADO expands the macro in the heredoc) or resolved here at runtime.
Resolution is single-pass: nested markers inside an 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 and the ten
exec-context-*.js bundles listed in the workspace layout, so
pipelines download it through the same Agent-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).
exec-context-pr.js is a single-shot Node program that runs as the
precompute step of the PR contributor of the execution-context
extension. It runs in the Agent job before the agent step, inside
the AWF network-isolated sandbox's prepare phase.
It performs the work that used to live as ~190 lines of bash heredoc
inside src/compile/extensions/exec_context/pr.rs:
- Validate identifiers —
PR_ID,SYSTEM_TEAMPROJECT,BUILD_REPOSITORY_NAME,SYSTEM_PULLREQUEST_TARGETBRANCH, andSYSTEM_PULLREQUEST_SOURCEBRANCHare each matched against a strict allowlist regex (validate.ts) before any of them are interpolated into a git refspec or the agent prompt. On any failure the program writesaw-context/pr/error.txtand a### PR context (unavailable)fragment to the agent prompt, then exits 0 (soft fail: the agent still runs, but is told the context is missing). - Resolve merge-base — if the checkout is a synthetic
merge-commit (parent count ≥ 3 per ADO's PR-validation flow),
merge-base.ts::resolveMergeBasecomputesgit merge-baseover the two parents. If that cannot resolve in a shallow checkout, it fetches both target and source refs with progressive deepening (--depth=200/500/2000/--unshallow) and retries the parent merge-base. Otherwise it fetches the target branch with progressive deepening and then runsgit merge-baseagainstHEAD. SameBASE_SHAsemantics in both paths (git's true common ancestor). - Stage artefacts — writes
aw-context/pr/base.shaandaw-context/pr/head.shaso the agent cangit diff $(cat .../base.sha)..$(cat .../head.sha)itself. - Append prompt fragment — appends a
## PR contextsection to/tmp/awf-tools/agent-prompt.md(path overridable viaAW_AGENT_PROMPT_FILEfor tests).
The bearer (SYSTEM_ACCESSTOKEN) is mapped into the Node process's
env by the wrapper bash step, but is only propagated into the
spawned git child process via GIT_CONFIG_COUNT=1 / KEY_0 / VALUE_0 env vars (see git.ts::bearerEnv + runGit in
merge-base.ts). It never appears in argv, is never written to
.git/config, and is never visible to the agent process (which is
spawned later, in a separate AWF child). The
test_execution_context_pr_does_not_leak_system_accesstoken Rust
test walks the emitted YAML and asserts this scoping.
| Env var | Source | Purpose |
|---|---|---|
SYSTEM_ACCESSTOKEN |
$(System.AccessToken) |
ADO REST / git fetch bearer |
SYSTEM_PULLREQUEST_PULLREQUESTID |
$(System.PullRequest.PullRequestId) |
PR identifier (validated numeric) |
SYSTEM_PULLREQUEST_TARGETBRANCH |
$(System.PullRequest.TargetBranch) |
PR target branch for the fetch |
SYSTEM_TEAMPROJECT |
$(System.TeamProject) |
ADO project name (validated) |
BUILD_REPOSITORY_NAME |
$(Build.Repository.Name) |
Repository name (validated) |
BUILD_SOURCESDIRECTORY |
$(Build.SourcesDirectory) |
Workspace root for aw-context/ |
AW_AGENT_PROMPT_FILE |
(test override) | Override default /tmp/awf-tools/agent-prompt.md |
The bundle uses only node:child_process / node:fs / node:path
— no azure-devops-node-api, no fetch. The ncc'd bundle is ~8 KB.
exec-context-pr-synth.js is a single-shot Node program that runs as
the first step of the pipeline's Setup job, before any gate
step. It normalises the PR-identifier variables into the stable
AW_PR_* namespace so that every downstream consumer (the gate step
in the same job and the Agent job) can read a single set of names
regardless of whether the build is a real PR build or a CI build that
was synth-promoted to PR semantics.
Azure DevOps Services ignores the YAML pr: block unless a
per-branch Build Validation policy is registered server-side. Without
that policy, a push to a feature branch fires the pipeline as
Build.Reason = IndividualCI even when an open PR exists. The synth
path closes that gap: it looks up the active PR for the build's source
branch and, if exactly one matches the agent's on.pr branch/path
filters, promotes the CI build to PR semantics.
Doing the real-vs-synth merge here (in TypeScript) — rather than
coalescing $(System.PullRequest.X) with $(AW_SYNTHETIC_PR_X) inside
step env: — is deliberate: ADO only evaluates $[ ... ] runtime
expressions inside the variables: block and condition: fields, NOT
inside step env: values, so the coalesce form silently passed the
literal expression string to downstream steps. Every consumer now reads
plain $(AW_PR_*) macros instead.
Each variable is emitted as both a setOutput (isOutput=true,
for cross-job consumption via
dependencies.Setup.outputs['synthPr.<NAME>']) and a regular setVar
(for same-job consumption via $(<NAME>) macros). Both forms are
required because isOutput=true does not register the variable in the
producing job's regular namespace — see the setVar doc-comment in
scripts/ado-script/src/shared/vso-logger.ts.
| Variable | Meaning |
|---|---|
AW_PR_ID |
Resolved PR id (real or synth); empty if not a PR build |
AW_PR_TARGETBRANCH |
Resolved target ref (refs/heads/<name>) |
AW_PR_SOURCEBRANCH |
Resolved source ref |
AW_PR_IS_DRAFT |
"true" / "false" / "" (only meaningful on the synth path) |
AW_SYNTHETIC_PR |
"true" iff this build was synth-promoted (CI build + matched open PR); empty on real PR builds and non-promoted CI |
AW_SYNTHETIC_PR_SKIP |
"true" iff synth was attempted but no match was found (gates the Agent job to skip) |
All soft skips exit 0; only spec-decode and infrastructure errors exit non-zero:
- Real PR build — if
SYSTEM_PULLREQUEST_PULLREQUESTIDis non-empty (after stripping unsubstituted$(name)macro literals), copy theSYSTEM_PULLREQUEST_*env intoAW_PR_*and return. No API call needed. - GitHub-typed repo — if
BUILD_REPOSITORY_PROVIDERisGitHub, emit emptyAW_PR_*plusAW_SYNTHETIC_PR_SKIP=true(ADO routes GitHub PR webhooks natively, so a CI build on a GitHub repo has no associated PR). - Decode
PR_SYNTH_SPEC— base64-decode the compiler-emitted filter spec (build_pr_synth_specinsrc/compile/filter_ir.rs). Corruption is a hard failure (exit 1). - Fetch active PRs whose
sourceRefName == BUILD_SOURCEBRANCH. - Branch filter — keep PRs whose
targetRefNamematchesspec.branches.include/spec.branches.exclude. - Exactly-one rule — a count other than 1 emits empty
AW_PR_*+ skip. - Path filter — if the agent declared
on.pr.paths, fetch the latest PR iteration's changed files and skip unless at least one matches. - Match — emit the resolved
AW_PR_*plusAW_SYNTHETIC_PR=true.
The compiler injects only the non-auto-injected SYSTEM_ACCESSTOKEN bearer
(via the shared bundle-auth applier — see Bundle env contract)
plus the computed PR_SYNTH_SPEC on the node exec-context-pr-synth.js step.
Every predefined System.* / Build.* variable the bundle reads
(SYSTEM_TEAMPROJECT, BUILD_REPOSITORY_ID, BUILD_REASON,
BUILD_REPOSITORY_PROVIDER, BUILD_SOURCEBRANCH, SYSTEM_PULLREQUEST_*) is
auto-mapped into the process env by ADO at runtime, so it is not
re-projected on the step:
| Env var | Source | Purpose |
|---|---|---|
PR_SYNTH_SPEC |
compiled inline (base64) | The branch/path filter spec |
SYSTEM_ACCESSTOKEN |
$(System.AccessToken) (bundle-auth applier) |
ADO REST auth |
SYSTEM_COLLECTIONURI |
ADO auto-injected | ADO org base URL (read via getWebApi(); falls back to SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) |
SYSTEM_TEAMPROJECT |
ADO auto-injected | ADO project for the PR lookup |
BUILD_REPOSITORY_ID |
ADO auto-injected | Repository id for the PR lookup |
BUILD_REASON |
ADO auto-injected | Distinguishes CI from PR builds |
BUILD_REPOSITORY_PROVIDER |
ADO auto-injected | Detects GitHub-typed repos |
BUILD_SOURCEBRANCH |
ADO auto-injected | Source ref matched against active PRs |
SYSTEM_PULLREQUEST_* |
ADO auto-injected | Real-PR identifiers read verbatim |
The bundle lazy-imports azure-devops-node-api only when it needs to
call the PR REST endpoints (steps 4 and 7); real PR builds and
GitHub-typed repos return before any SDK load.
Every compiler-emitted step that runs an ado-script bundle has an implicit
environment contract — which process.env keys the bundle reads. That contract
is modelled in src/compile/ado_bundle.rs:
Bundleenumerates every bundle with its on-diskpath()and itsauth()requirement (BundleAuth::Bearerfor bundles that readSYSTEM_ACCESSTOKEN— for ADO REST viagetWebApi()and/or git bearer auth viabearerEnv—BundleAuth::Noneotherwise).apply_bundle_auth(step, bundle, token)is the single chokepoint that projectsSYSTEM_ACCESSTOKEN(from aTokenSource) into every bearer-requiring bundle step.SYSTEM_ACCESSTOKENis the one ADO predefined variable that is not auto-injected — ADO maps it only when a step explicitly references it — so it must be projected. This applier is why a step can no longer ship without a bearer (the regression behind #1307).token_source_for(write_service_connection)unifies theSystem.AccessTokenvsSC_WRITE_TOKENselection shared by the Conclusion job and the Stage 3 executor.
Every other ADO predefined variable (System.*, Build.*) is auto-injected
into every script step's env under its SCREAMING_SNAKE name, so bundle steps
must not re-project them (e.g. SYSTEM_TEAMPROJECT: $(System.TeamProject)
is a redundant mirror). is_redundant_ado_mirror identifies such entries and
the contract tests — plus the compiled-YAML churn guard in
tests/compiler_tests.rs — assert migrated steps do not emit them. The ADO
collection URI is likewise read from the auto-injected SYSTEM_COLLECTIONURI
(scripts/ado-script/src/shared/auth.ts::getWebApi, see #1307), so it
is never part of a step's env contract.
Not every bundle authenticates to Azure DevOps. The github-app-token bundle
(issue #1316) is BundleAuth::None: it authenticates to the GitHub API with
its own App JWT / minted installation token, so it carries no
SYSTEM_ACCESSTOKEN and no ADO predefined mirrors. Its only env var is the
masked private-key secret (GH_APP_PRIVATE_KEY, and GH_APP_TOKEN for revoke);
every other, non-secret input (App ID, owner, repositories, output-variable
name, api-url) is a single-quoted argv flag rather than an env var, so a
pipeline variable can never shadow it (ADO injects pipeline variables into a
step's env, but argv comes only from the compiler-authored script).
┌──────────────────────┐
│ 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.
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.
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 |
SYSTEM_COLLECTIONURI |
ADO auto-injected | ADO org base URL (read via getWebApi(); falls back to SYSTEM_TEAMFOUNDATIONCOLLECTIONURI) |
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.
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
│ │ ├── git.ts # execFile wrappers + bearerEnv helper (promoted from exec-context-pr/ in Stage 0)
│ │ ├── merge-base.ts # synthetic-merge detection + progressive-deepening fetch (promoted from exec-context-pr/)
│ │ ├── validate.ts # identifier regex guards (promoted from exec-context-pr/)
│ │ ├── prompt.ts # agent-prompt-file append helpers (promoted from exec-context-pr/)
│ │ └── build.ts # Build REST helpers (added in Stage 2; used by pipeline / ci-push / pr.checks)
│ ├── 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 precompute
│ │ ├── index.ts # main(): validate → resolve merge-base → stage SHAs → append prompt
│ │ │ # (imports validate/git/merge-base/prompt from ../shared/)
│ │ └── __tests__/ # end-to-end / integration tests live here; the
│ │ # per-module unit tests moved with their modules
│ │ # into ../shared/__tests__/
│ ├── exec-context-pr-synth/ # exec-context-pr-synth.js entry point + synthetic-PR resolver
│ │ ├── index.ts # main(): real-PR / GitHub / synth-promote branch resolution → emit AW_PR_*
│ │ ├── match.ts # branch/path include-exclude glob matching
│ │ ├── spec.ts # PR_SYNTH_SPEC base64 decode + validation
│ │ └── __tests__/ # unit tests across the three modules
│ ├── exec-context-manual/ # exec-context-manual.js entry point + manual-context precompute
│ │ ├── index.ts # main(): collect PARAM_* env vars → JSON snapshot → prompt fragment
│ │ └── __tests__/ # unit tests for success / failure / sanitisation paths
│ ├── exec-context-pipeline/ # exec-context-pipeline.js entry point + pipeline-completion precompute
│ │ ├── index.ts # main(): validate TriggeredBy ids → fetch upstream Build via REST → stage + prompt
│ │ └── __tests__/ # unit tests for validate / success / failure / sanitisation paths
│ ├── exec-context-ci-push/ # exec-context-ci-push.js entry point + CI/push diff context
│ │ ├── index.ts # main(): find last green build → stage changed files / commits → prompt
│ │ └── __tests__/ # unit tests for success / fallback / sanitisation paths
│ ├── exec-context-workitem/ # exec-context-workitem.js entry point + PR-linked work-item context
│ │ ├── index.ts # main(): fetch linked WIs → stage metadata + descriptions → prompt
│ │ └── __tests__/ # unit tests for fetch / truncation / sanitisation paths
│ ├── exec-context-schedule/ # exec-context-schedule.js entry point + scheduled-run diff context
│ │ ├── index.ts # main(): find prior scheduled success → stage changed files / commits → prompt
│ │ └── __tests__/ # unit tests for success / no-baseline / sanitisation paths
│ ├── exec-context-pr-checks/ # exec-context-pr-checks.js entry point + PR validation checks context
│ │ ├── index.ts # main(): fetch policy/build checks → stage failing/succeeded JSON → prompt
│ │ └── __tests__/ # unit tests for checks filtering / sanitisation paths
│ ├── exec-context-repo/ # exec-context-repo.js entry point + repository identity context
│ │ ├── index.ts # main(): stage branch/SHA/tag/commits-since-tag facts → prompt
│ │ └── __tests__/ # unit tests for identity / tag fallback / sanitisation paths
│ ├── conclusion/ # conclusion.js entry point + Conclusion-job reporter
│ │ ├── index.ts # main(): inspect upstream results + safe-outputs manifest → file/append work items
│ │ └── __tests__/ # unit tests for signal detection and work-item filing behaviour
│ ├── github-app-token/ # github-app-token.js entry point + GitHub App token minter
│ │ ├── index.ts # main(): RS256 JWT → resolve installation → mint installation token → masked GITHUB_APP_TOKEN
│ │ └── __tests__/ # unit tests for JWT signing / installation resolution / token minting
│ └── prepare-pr-base/ # prepare-pr-base.js entry point + create-pull-request base-ref fetch/deepen
│ ├── index.ts # main(): fetch/deepen target branch + set origin/HEAD so mcp.rs finds a diff base
│ └── __tests__/ # unit tests for fetch/deepen + origin/HEAD + benign-failure paths
├── test/ # End-to-end smoke tests (gate, import, exec-context-pr)
├── gate.js # ncc bundle output (gitignored)
├── import.js # ncc bundle output (gitignored)
├── exec-context-pr.js # ncc bundle output (gitignored)
├── exec-context-pr-synth.js # ncc bundle output (gitignored)
├── exec-context-manual.js # ncc bundle output (gitignored)
├── exec-context-pipeline.js # ncc bundle output (gitignored)
├── exec-context-ci-push.js # ncc bundle output (gitignored)
├── exec-context-workitem.js # ncc bundle output (gitignored)
├── exec-context-schedule.js # ncc bundle output (gitignored)
├── exec-context-pr-checks.js # ncc bundle output (gitignored)
├── exec-context-repo.js # ncc bundle output (gitignored)
├── conclusion.js # ncc bundle output (gitignored)
├── approval-summary.js # ncc bundle output (gitignored)
├── github-app-token.js # ncc bundle output (gitignored)
└── prepare-pr-base.js # ncc bundle output (gitignored)
The release workflow (.github/workflows/release.yml) runs
npm ci && npm run build, then globs scripts/ado-script/*.js — which
captures every bundle, including gate.js, import.js,
exec-context-pr.js, exec-context-pr-synth.js,
exec-context-manual.js, exec-context-pipeline.js,
exec-context-ci-push.js, exec-context-workitem.js,
exec-context-schedule.js, exec-context-pr-checks.js,
exec-context-repo.js, conclusion.js, approval-summary.js, and
github-app-token.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.
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.jsonAdoScriptExtension
(src/compile/extensions/ado_script.rs) is the always-on single
extension that owns all ado-script wiring. It has two independent
features, each emitted into the job that actually consumes the
bundle:
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.
When inlined-imports: false (the default) OR the execution-context
PR contributor activates (on.pr configured and not disabled),
AdoScriptExtension::declarations() returns the install + download pair in
Declarations::agent_prepare_steps for the Agent job:
UseNode@1— same shape as above.curldownload + verify + extract — same artefact, same verification.bash: node '/tmp/ado-aw-scripts/ado-script/import.js'— expands{{#runtime-import …}}markers in/tmp/awf-tools/agent-prompt.mdin place. Seeruntime-imports.mdfor marker syntax. Only emitted wheninlined-imports: false.
The PR-context precompute step (node exec-context-pr.js) is owned
by ExecContextExtension (not AdoScriptExtension) and emitted through
its own Tool-phase Declarations::agent_prepare_steps. Phase ordering
(AdoScriptExtension::phase() == System < ExecContextExtension::phase() == Tool)
guarantees the bundle is installed and on disk before the
exec-context invocation runs.
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 Setup and Agent need it, install +
download steps appear in both. That's correct architecture given
ADO's topology, not waste.
The rows below assume the synthetic-PR resolver is not active
(pr_trigger_for_synth = None):
| Setup consumer | Agent consumer | Setup-job steps | Agent-job extra steps |
|---|---|---|---|
| no gate | none | (none) | (none) |
| no gate | inlined-imports: false only |
(no Setup job) | install + download + resolver |
| no gate | execution-context contributor(s) only | (no Setup job) | install + download + exec-context bundle(s) |
| no gate | resolver + execution-context | (no Setup job) | install + download + resolver + exec-context bundle(s) |
| gate | none | install + download + gate | (none) |
| gate | any combination of resolver / exec-context | install + download + gate | install + download + (resolver?) + (exec-context bundle(s)?) |
When the synthetic-PR resolver is active
(pr_trigger_for_synth = Some(_), i.e. synthetic_pr_active() is
true) the Setup job gains the synthPr step (node exec-context-pr-synth.js) before any gate step — and the Setup job is
emitted even with no gate:
| Setup consumer | Setup-job steps | Agent-job extra steps |
|---|---|---|
| synth-PR (no gate) | install + download + synth-PR | (per Agent consumer above) |
| gate (no synth-PR) | install + download + gate | (per Agent consumer above) |
| synth-PR + gate | install + download + synth-PR + gate | (per Agent consumer above) |
The "Setup consumer" column is gated on filters: lowering to non-empty
checks or synthetic_pr_active() being true. The "Agent consumer"
columns are gated on inlined-imports: false (resolver) and the PR
contributor's activation predicate (exec-context-pr; see
pr_contributor_will_activate in
src/compile/extensions/exec_context/mod.rs).
The IR-to-bash codegen that produces the gate step is
compile_gate_step_external in src/compile/filter_ir.rs.
- 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
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.
- 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.
From scripts/ado-script/:
npm ci # one-time
npm run codegen # regenerate types.gen.ts (compiles ado-aw first)
npm test # vitest unit tests
npm run typecheck # strict tsc --noEmit
npm run build # ncc-bundle all ado-script JS helpers
npm run test:smoke # build + smoke test the bundles 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 --nocaptureEach 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.
- 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/safe_outputs/*.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.
filter-ir.md— the IR consumed bygate.js.extending.md— generic compiler-extension guide.