From ed40a83b649d7683bcc8df0af3c3f71264f2f4f7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:07:58 +0000 Subject: [PATCH 1/2] Initial plan From ffb958e1f406f32f7e6d3e2426582a801f11f945 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 10 Jun 2026 10:22:28 +0000 Subject: [PATCH 2/2] fix(compile): use macro form for same-job synthPr gate refs Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/filter_ir.rs | 51 +++++++++++++++++++------------- tests/compiler_tests.rs | 64 ++++++++++++++++++++++------------------ 2 files changed, 67 insertions(+), 48 deletions(-) diff --git a/src/compile/filter_ir.rs b/src/compile/filter_ir.rs index 0722f075..a43f6caa 100644 --- a/src/compile/filter_ir.rs +++ b/src/compile/filter_ir.rs @@ -1130,14 +1130,20 @@ pub fn build_gate_spec(ctx: GateContext, checks: &[FilterCheck]) -> anyhow::Resu /// **Same-job vs cross-job reference**: this gate step lives in the /// **Setup job** (`AdoScriptExtension::setup_steps` returns it), the /// same job as `synthPr`. Within the producing job, the cross-job form -/// `dependencies.Setup.outputs['synthPr.X']` is undefined (a job has -/// no entry for itself in `dependencies`), so we use the same-job -/// runtime expression `variables['synthPr.X']` instead, which resolves -/// step output variables added to the job's variable scope by prior -/// `isOutput=true` setvariable commands. See +/// `dependencies.Setup.outputs['synthPr.X']` is undefined (a job has no +/// entry for itself in `dependencies`), and — critically — the same-job +/// *runtime expression* form `$[ variables['synthPr.X'] ]` also resolves +/// to **empty**: step output variables are NOT exposed to runtime +/// expressions in the producing job. They are only reachable via the +/// **macro** form `$(synthPr.X)`, which expands them from the job's +/// output-variable namespace (empty when the producing step was skipped +/// or never set the output). We therefore reference synth outputs with +/// macros and rely on macro concatenation: real `System.PullRequest.*` +/// and synth `synthPr.*` are mutually exclusive (synthPr only runs on +/// non-PR builds; `System.PullRequest.*` is empty on non-PR builds), so +/// `$(System.PullRequest.X)$(synthPr.X)` yields exactly one non-empty +/// value. See /// . -/// Runtime expressions (`$[ ... ]`) are valid in step-level `env:` -/// blocks per the same docs. pub fn compile_gate_step_external( ctx: GateContext, checks: &[FilterCheck], @@ -1172,31 +1178,36 @@ pub fn compile_gate_step_external( // has been promoted to PR semantics, so the "not a PullRequest // build" bypass must not auto-pass. Always safe to emit (the gate // checks it strictly for the literal "true"), but only meaningful - // for PR gates. Same-job ref via `variables['synthPr.X']` — see - // function doc-comment for why this is NOT `dependencies.Setup...`. + // for PR gates. Same-job ref via the macro `$(synthPr.X)` — see + // function doc-comment for why this is NOT `variables['synthPr.X']` + // (resolves empty) nor `dependencies.Setup.outputs[...]` (undefined + // in the producing job). let pr_synth_active = synthetic_pr_active && matches!(ctx, GateContext::PullRequest); if pr_synth_active { - // YAML-quote runtime expressions whose value contains single quotes. - // Per ADO docs, `$[ ... ]` runtime expressions are valid in step - // `env:` blocks; wrapping in double quotes keeps the value - // strictly conformant to the YAML spec (which reserves `'` as a - // scalar indicator) and matches the form shown in ADO docs. - step.push_str( - " AW_SYNTHETIC_PR: \"$[ coalesce(variables['synthPr.AW_SYNTHETIC_PR'], '') ]\"\n", - ); + // Macro form: `$(synthPr.AW_SYNTHETIC_PR)` expands to "true" when + // the same-job `synthPr` step matched a PR, and to empty when it + // was skipped (real PR build) or found no PR — never the literal + // "true", so `gate/bypass.ts`'s strict `=== "true"` check stays + // correct in every case. + step.push_str(" AW_SYNTHETIC_PR: \"$(synthPr.AW_SYNTHETIC_PR)\"\n"); } for (env_var, ado_macro) in &exports { let macro_str = if pr_synth_active { + // Mutually-exclusive macro concatenation: on a real PR build + // `System.PullRequest.*` holds the value and `synthPr.*` is + // empty (step skipped); on a synth-promoted CI build + // `System.PullRequest.*` is empty and `synthPr.*` holds the + // value. Exactly one side is ever non-empty. match *env_var { "ADO_PR_ID" => { - "\"$[ coalesce(variables['System.PullRequest.PullRequestId'], variables['synthPr.AW_SYNTHETIC_PR_ID']) ]\"" + "\"$(System.PullRequest.PullRequestId)$(synthPr.AW_SYNTHETIC_PR_ID)\"" } "ADO_SOURCE_BRANCH" => { - "\"$[ coalesce(variables['System.PullRequest.SourceBranch'], variables['synthPr.AW_SYNTHETIC_PR_SOURCEBRANCH']) ]\"" + "\"$(System.PullRequest.SourceBranch)$(synthPr.AW_SYNTHETIC_PR_SOURCEBRANCH)\"" } "ADO_TARGET_BRANCH" => { - "\"$[ coalesce(variables['System.PullRequest.TargetBranch'], variables['synthPr.AW_SYNTHETIC_PR_TARGETBRANCH']) ]\"" + "\"$(System.PullRequest.TargetBranch)$(synthPr.AW_SYNTHETIC_PR_TARGETBRANCH)\"" } _ => ado_macro, } diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 6fa7e9bc..ea33df1e 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -4452,49 +4452,51 @@ fn test_pr_filter_synth_mode_agent_condition_enforces_gate() { /// Regression guard for the synth-mode gate-step same-job ref bug: the /// gate step lives in the **Setup** job (same job as `synthPr`), so its -/// env block must use `variables['synthPr.X']` (same-job runtime -/// expression) — `dependencies.Setup.outputs[...]` is undefined inside -/// the producing job and silently coalesces to empty, leaving -/// `AW_SYNTHETIC_PR` empty and causing the bypass to misfire. +/// env block must reference the synth outputs with the **macro** form +/// `$(synthPr.X)`. The same-job runtime-expression form +/// `$[ variables['synthPr.X'] ]` resolves to empty (step outputs are not +/// exposed to runtime expressions in the producing job), and the +/// cross-job form `dependencies.Setup.outputs[...]` is undefined inside +/// the producing job — both silently coalesce to empty, leaving +/// `AW_SYNTHETIC_PR` empty and causing the gate bypass to misfire. #[test] fn test_pr_filter_synth_mode_gate_step_uses_same_job_synth_ref() { let compiled = compile_fixture("pr-filter-tier1-agent.md"); assert!( - compiled.contains( - "AW_SYNTHETIC_PR: \"$[ coalesce(variables['synthPr.AW_SYNTHETIC_PR'], '') ]\"" - ), - "Gate step env must use same-job `variables['synthPr.X']` runtime expression — \ - `dependencies.Setup.outputs[...]` is undefined inside the producing Setup job" + compiled.contains("AW_SYNTHETIC_PR: \"$(synthPr.AW_SYNTHETIC_PR)\""), + "Gate step env must reference the same-job `synthPr` output via the macro \ + `$(synthPr.AW_SYNTHETIC_PR)` — the `$[ variables['synthPr.X'] ]` runtime \ + expression resolves to empty inside the producing Setup job" ); // The fixture exercises source-branch and target-branch filters, - // so the synth-coalesce treatment must appear on those env vars - // using the same-job `variables[...]` form. (ADO_PR_ID is not + // so the synth treatment must appear on those env vars using the + // mutually-exclusive macro-concatenation form. (ADO_PR_ID is not // exported by this fixture's filter set, so we don't assert it here.) assert!( compiled.contains( - "ADO_SOURCE_BRANCH: \"$[ coalesce(variables['System.PullRequest.SourceBranch'], variables['synthPr.AW_SYNTHETIC_PR_SOURCEBRANCH']) ]\"" + "ADO_SOURCE_BRANCH: \"$(System.PullRequest.SourceBranch)$(synthPr.AW_SYNTHETIC_PR_SOURCEBRANCH)\"" ), - "ADO_SOURCE_BRANCH coalesce must use same-job `variables[...]` form" + "ADO_SOURCE_BRANCH must concatenate the real `System.PullRequest.*` macro \ + with the same-job `synthPr.*` macro" ); assert!( compiled.contains( - "ADO_TARGET_BRANCH: \"$[ coalesce(variables['System.PullRequest.TargetBranch'], variables['synthPr.AW_SYNTHETIC_PR_TARGETBRANCH']) ]\"" + "ADO_TARGET_BRANCH: \"$(System.PullRequest.TargetBranch)$(synthPr.AW_SYNTHETIC_PR_TARGETBRANCH)\"" ), - "ADO_TARGET_BRANCH coalesce must use same-job `variables[...]` form" - ); - - // The same-job gate step MUST NOT use the cross-job - // `dependencies.Setup.outputs[...]` form for synthPr references. - // (It's fine elsewhere — e.g. the Agent-job dependsOn condition — but - // not inside the Setup job's own steps.) - // The same-job gate step MUST NOT use the cross-job - // `dependencies.Setup.outputs[...]` form for synthPr references. - // (It's fine elsewhere — e.g. the Agent-job dependsOn condition — but - // not inside the Setup job's own steps.) Bound the gate-step - // section by the start of the next top-level job (`\n - job: `), - // since extract_job_block's `\n- job: ` boundary doesn't match the - // 2-space-indented job list items produced for this target. + "ADO_TARGET_BRANCH must concatenate the real `System.PullRequest.*` macro \ + with the same-job `synthPr.*` macro" + ); + + // The same-job gate step MUST NOT use the broken same-job runtime + // expression form `variables['synthPr.X']` (resolves empty) nor the + // cross-job `dependencies.Setup.outputs[...]` form (undefined in the + // producing job) for synthPr references. (Both are fine elsewhere — + // e.g. the Agent-job dependsOn condition — but not inside the Setup + // job's own steps.) Bound the gate-step section by the start of the + // next top-level job (`\n - job: `), since extract_job_block's + // `\n- job: ` boundary doesn't match the 2-space-indented job list + // items produced for this target. let setup_block = extract_job_block(&compiled, "Setup").expect("Setup job present"); let gate_section = setup_block .split("name: prGate") @@ -4520,6 +4522,12 @@ fn test_pr_filter_synth_mode_gate_step_uses_same_job_synth_ref() { that is cross-job syntax and is undefined within the producing job. \ Gate section: {gate_section}" ); + assert!( + !gate_section.contains("variables['synthPr."), + "Gate step (inside Setup job) must NOT reference `variables['synthPr.X']` — \ + step outputs are not exposed to runtime expressions in the producing job \ + and resolve to empty. Gate section: {gate_section}" + ); } /// Native ADO PR trigger block is emitted for branch/path filters.