Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/template-markers.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,19 @@ Generates a `timeoutInMinutes: <value>` job property for `Agent` when `engine.ti

If `timeout-minutes` is not configured, this is replaced with an empty string.

## {{ agent_job_variables }}

Generates the Agent job's `variables:` block. Currently emits content **only** when synthetic-PR-from-CI is active (`on.pr.mode == Synthetic`); replaced with an empty string otherwise.

When active, this hoists the relevant `synthPr` Setup-job step outputs into Agent-job-level variables using `$[ coalesce(dependencies.Setup.outputs['synthPr.X'], '') ]` runtime expressions:

- `AW_SYNTHETIC_PR`
- `AW_SYNTHETIC_PR_ID`
- `AW_SYNTHETIC_PR_TARGETBRANCH`
- `AW_SYNTHETIC_PR_SOURCEBRANCH`

The hoist exists because `dependencies.<job>.outputs[...]` references at step-level `env:` scope proved unreliable in practice (empirically observed in `msazuresphere/4x4` build #612290: the same reference resolved correctly at job-condition scope but returned the empty string at step-env scope, causing the `Stage PR execution context` step's bash guard to misfire and the agent to emit `noop` on a synth-promoted build). Job-level `variables:` is the documented safe location for cross-job output references; subsequent step `env:` blocks then consume the hoisted values via the `$(name)` macro or a `$[ coalesce(variables['name'], ...) ]` runtime expression.

## {{ working_directory }}

Should be replaced with the appropriate working directory based on the effective workspace setting.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,28 @@ describe("exec-context-pr-synth main", () => {
const { code, output } = await runMain(makeEnv({ PR_SYNTH_SPEC: spec }));
expect(code).toBe(0);
expect(output).not.toContain("AW_SYNTHETIC_PR_SKIP");
// Each AW_SYNTHETIC_PR* variable is emitted TWICE: once as an
// output (cross-job, consumed by the Agent job condition + the
// Agent-job-level `variables:` hoist) and once as a regular
// variable (same-job, consumed by the prGate step's env block
// via `$[ coalesce(variables['AW_SYNTHETIC_PR_X'], ...) ]`).
// See `setVar` in `shared/vso-logger.ts` for the rationale.
expect(output).toContain("AW_SYNTHETIC_PR;isOutput=true]true");
expect(output).toContain("AW_SYNTHETIC_PR_ID;isOutput=true]1234");
expect(output).toContain("AW_SYNTHETIC_PR_TARGETBRANCH;isOutput=true]refs/heads/main");
expect(output).toContain("AW_SYNTHETIC_PR_SOURCEBRANCH;isOutput=true]refs/heads/feature/x");
expect(output).toContain("AW_SYNTHETIC_PR_IS_DRAFT;isOutput=true]false");
// Regular-variable counterparts (no `isOutput`). Each line is a
// separate ##vso command terminated by `]value`.
expect(output).toContain("##vso[task.setvariable variable=AW_SYNTHETIC_PR]true");
expect(output).toContain("##vso[task.setvariable variable=AW_SYNTHETIC_PR_ID]1234");
expect(output).toContain(
"##vso[task.setvariable variable=AW_SYNTHETIC_PR_TARGETBRANCH]refs/heads/main",
);
expect(output).toContain(
"##vso[task.setvariable variable=AW_SYNTHETIC_PR_SOURCEBRANCH]refs/heads/feature/x",
);
expect(output).toContain("##vso[task.setvariable variable=AW_SYNTHETIC_PR_IS_DRAFT]false");
});

it("emits AW_SYNTHETIC_PR_IS_DRAFT=true when the PR is a draft", async () => {
Expand Down
30 changes: 24 additions & 6 deletions scripts/ado-script/src/exec-context-pr-synth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
getPullRequestIterations,
listActivePullRequestsBySourceRef,
} from "../shared/ado-client.js";
import { logError, logInfo, setOutput } from "../shared/vso-logger.js";
import { logError, logInfo, setOutput, setVar } from "../shared/vso-logger.js";

import { matchesIncludeExclude, normalisePath, pathMatchesIncludeExclude } from "./match.js";
import { decodeSpec, type PrSynthSpec } from "./spec.js";
Expand Down Expand Up @@ -189,11 +189,29 @@ export async function main(env: NodeJS.ProcessEnv = process.env): Promise<number
// promoted. (The unprefixed short form is `TargetBranchName` —
// a separate predefined variable we deliberately do not use here.)
// See <https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables>.
setOutput("AW_SYNTHETIC_PR", "true");
setOutput("AW_SYNTHETIC_PR_ID", String(prId));
setOutput("AW_SYNTHETIC_PR_TARGETBRANCH", pr.targetRefName ?? "");
setOutput("AW_SYNTHETIC_PR_SOURCEBRANCH", pr.sourceRefName ?? sourceBranch);
setOutput("AW_SYNTHETIC_PR_IS_DRAFT", pr.isDraft === true ? "true" : "false");
// Emit each AW_SYNTHETIC_PR* value TWICE: once as an output variable
// (visible cross-job via `dependencies.Setup.outputs['synthPr.X']` to
// the Agent job's condition + variables block) and once as a regular
// pipeline variable (visible same-job via `$(X)` macro and
// `$[ variables['X'] ]` runtime expression to the gate step that
// runs immediately after this one in the Setup job).
//
// The dual emission is required because `isOutput=true` alone does NOT
// register the variable in the producing job's regular variable
// namespace — same-job consumers (the `prGate` step in particular)
// would otherwise see empty values and the synth promotion would
// collapse silently. See `setVar` doc-comment in `shared/vso-logger.ts`
// for the underlying ADO contract.
const emitBoth = (name: string, value: string): void => {
setOutput(name, value);
setVar(name, value);
};

emitBoth("AW_SYNTHETIC_PR", "true");
emitBoth("AW_SYNTHETIC_PR_ID", String(prId));
emitBoth("AW_SYNTHETIC_PR_TARGETBRANCH", pr.targetRefName ?? "");
emitBoth("AW_SYNTHETIC_PR_SOURCEBRANCH", pr.sourceRefName ?? sourceBranch);
emitBoth("AW_SYNTHETIC_PR_IS_DRAFT", pr.isDraft === true ? "true" : "false");

logInfo(
`[synth-pr] matched PR #${prId} (source=${pr.sourceRefName} target=${pr.targetRefName})`,
Expand Down
20 changes: 20 additions & 0 deletions scripts/ado-script/src/shared/vso-logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,26 @@ export function setOutput(name: string, value: string): void {
emit(`##vso[task.setvariable variable=${safeName};isOutput=true]${safeValue}`);
}

/**
* Set a regular (non-output) pipeline variable, visible to subsequent
* steps in the **same job** via `$(name)` macro or
* `$[ variables['name'] ]` runtime expression.
*
* `isOutput=true` (used by `setOutput`) makes a variable available to
* downstream JOBS via `dependencies.<job>.outputs['<step>.<name>']`,
* but does NOT register it in the job's regular variable namespace —
* so `$(name)` and `$[ variables['name'] ]` resolve to empty in
* same-job consumers. Callers that need both same-job AND cross-job
* access must call BOTH `setVar` (same-job) and `setOutput` (cross-job).
*
* See <https://learn.microsoft.com/en-us/azure/devops/pipelines/process/variables#use-output-variables-from-tasks>.
*/
export function setVar(name: string, value: string): void {
const safeName = escapeProperty(name);
const safeValue = escapeMessage(value);
emit(`##vso[task.setvariable variable=${safeName}]${safeValue}`);
}

export function addBuildTag(tag: string): void {
emit(`##vso[build.addbuildtag]${escapeMessage(tag)}`);
}
Expand Down
87 changes: 87 additions & 0 deletions src/compile/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1111,6 +1111,53 @@ pub fn generate_job_timeout(front_matter: &FrontMatter) -> String {
}
}

/// Generate the Agent job's `variables:` block.
///
/// Currently emits content **only** when synthetic-PR-from-CI is active
/// (`on.pr.mode == Synthetic`). In that mode we need to surface the
/// `synthPr` Setup-job step outputs to consumers in the Agent job
/// (today: the `Stage PR execution context` bash step in
/// `exec_context/pr.rs`).
///
/// **Why job-level variables and not step-level env**: the canonical
/// ADO pattern for forwarding a cross-job step output is to declare a
/// job-level variable using a runtime expression `$[ ... ]`, then
/// consume that variable from step `env:` blocks via the `$(name)`
/// macro. Putting `$[ dependencies.<job>.outputs[...] ]` directly in
/// step-level `env:` is technically documented as supported but has
/// proven unreliable in practice — empirical evidence from
/// msazuresphere/4x4 build #612290 showed
/// `dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR']` resolving to
/// the empty string when referenced from a step's `env:` even though
/// the same expression worked in the Agent job's `condition:`. The
/// job-level form is the documented safe location:
/// <https://learn.microsoft.com/en-us/azure/devops/pipelines/process/variables#use-outputs-in-the-same-pipeline>.
///
/// When this hoist is empty (the agent isn't using synthetic-PR-from-CI),
/// the marker collapses cleanly: the surrounding template indents the
/// marker on its own line and an empty replacement leaves no stray
/// keys at job scope.
pub fn generate_agent_job_variables(synthetic_pr_active: bool) -> String {
if !synthetic_pr_active {
return String::new();
}
// The base indent on these continuation lines is just 2 spaces —
// `replace_with_indent` prepends the marker line's own indent to
// each subsequent line, so the keys here only need 2 extra spaces
// to land as proper children of `variables:` (which itself lands
// at the marker's column, the same column as `dependsOn:` /
// `pool:` on the Agent job). The same offset works for every
// base template (base.yml, 1es-base.yml, job-base.yml, stage-base.yml)
// because YAML child-indent is measured relative to the parent
// mapping key, not absolutely.
//
// `coalesce(..., '')` ensures the variable is the empty string
// rather than the unresolved literal `$[ ... ]` form if the
// dependency cannot be resolved (e.g. Setup was skipped or the
// synthPr step did not run).
"variables:\n AW_SYNTHETIC_PR: $[ coalesce(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR'], '') ]\n AW_SYNTHETIC_PR_ID: $[ coalesce(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_ID'], '') ]\n AW_SYNTHETIC_PR_TARGETBRANCH: $[ coalesce(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_TARGETBRANCH'], '') ]\n AW_SYNTHETIC_PR_SOURCEBRANCH: $[ coalesce(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_SOURCEBRANCH'], '') ]".to_string()
}

/// Format a single step's YAML string with proper indentation
#[allow(dead_code)]
pub fn format_step_yaml(step_yaml: &str) -> String {
Expand Down Expand Up @@ -3448,6 +3495,7 @@ pub async fn compile_shared(
synthetic_pr_active,
);
let job_timeout = generate_job_timeout(front_matter);
let agent_job_variables = generate_agent_job_variables(synthetic_pr_active);

// 9. Token acquisition and env vars
let acquire_read_token = generate_acquire_ado_token(
Expand Down Expand Up @@ -3622,6 +3670,7 @@ pub async fn compile_shared(
("{{ finalize_steps }}", &finalize_steps),
("{{ agentic_depends_on }}", &agentic_depends_on),
("{{ job_timeout }}", &job_timeout),
("{{ agent_job_variables }}", &agent_job_variables),
("{{ repositories }}", &repositories),
("{{ schedule }}", &schedule),
("{{ pipeline_resources }}", &pipeline_resources),
Expand Down Expand Up @@ -3774,6 +3823,44 @@ mod tests {
fm
}

// ─── generate_agent_job_variables ─────────────────────────────────

#[test]
fn test_generate_agent_job_variables_empty_when_synth_inactive() {
assert_eq!(generate_agent_job_variables(false), "");
}

#[test]
fn test_generate_agent_job_variables_emits_hoisted_synth_outputs() {
let out = generate_agent_job_variables(true);
// The hoist must declare a `variables:` mapping at the Agent
// job level (the `{{ agent_job_variables }}` marker sits at the
// job-keys indent).
assert!(
out.starts_with("variables:"),
"must declare a `variables:` block: {out}"
);
// Each AW_SYNTHETIC_PR* output that downstream consumers need
// must be hoisted via `$[ coalesce(dependencies.Setup.outputs[...], '') ]`.
// The `coalesce(..., '')` guarantees the variable is the empty
// string (rather than the literal `$[ ... ]` form) when the
// dependency is unresolved (e.g. Setup skipped).
for name in &[
"AW_SYNTHETIC_PR",
"AW_SYNTHETIC_PR_ID",
"AW_SYNTHETIC_PR_TARGETBRANCH",
"AW_SYNTHETIC_PR_SOURCEBRANCH",
] {
let needle = format!(
"{name}: $[ coalesce(dependencies.Setup.outputs['synthPr.{name}'], '') ]"
);
assert!(
out.contains(&needle),
"must hoist {name} from cross-job synth output: {out}"
);
}
}

// ─── normalize_yaml ───────────────────────────────────────────────────────

#[test]
Expand Down
Loading