Skip to content

Commit 0845490

Browse files
Copilotjamesadevinecopilot
authored
fix(compile): anchor source/pipeline paths to trigger repo, not workspace (#342)
Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/97c1330d-1888-4fce-ab93-54662ec5bac8 Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> Co-authored-by: copilot <copilot@github.com>
1 parent d263d13 commit 0845490

2 files changed

Lines changed: 126 additions & 35 deletions

File tree

AGENTS.md

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -797,23 +797,31 @@ This is used for the `workingDirectory` property of the copilot task.
797797

798798
## {{ source_path }}
799799

800-
Should be replaced with the path to the agent markdown source file for Stage 3 execution. The path is relative to the workspace and depends on the effective workspace setting (see `{{ working_directory }}` for resolution logic):
801-
- `root`: `$(Build.SourcesDirectory)/<filename>.md`
802-
- `repo`: `$(Build.SourcesDirectory)/$(Build.Repository.Name)/<filename>.md`
800+
Should be replaced with the path to the agent markdown source file for Stage 3 execution. The path is anchored at the **trigger ("self") repository** via `{{ trigger_repo_directory }}` (see below), independent of the user's `workspace:` setting, and mirrors the relative path used at compile time:
801+
- No additional checkouts: `$(Build.SourcesDirectory)/<relative-path>.md`
802+
- Additional checkouts present: `$(Build.SourcesDirectory)/$(Build.Repository.Name)/<relative-path>.md`
803803

804-
The path mirrors the relative path used at compile time — if compiled as `agents/my-agent.md`, the runtime path is `$(Build.SourcesDirectory)/agents/my-agent.md` (or the equivalent under `$(Build.Repository.Name)` for the `repo` workspace).
804+
For example, compiling `agents/my-agent.md` produces a runtime path of `$(Build.SourcesDirectory)/agents/my-agent.md` (or the equivalent under `$(Build.Repository.Name)` when additional repositories are checked out).
805805

806-
Used by the execute command's --source parameter.
806+
Used by the execute command's --source parameter. The agent markdown only ever lives in the trigger repo, so this is intentionally not affected by `workspace:` pointing at a non-self alias.
807807

808808
## {{ pipeline_path }}
809809

810-
Should be replaced with the path to the compiled pipeline YAML file for runtime integrity checking. The path is derived from the output path (preserving any directory structure) and uses `{{ working_directory }}` as the base (which gets resolved before this placeholder):
811-
- `root`: `$(Build.SourcesDirectory)/<relative-path>.yml`
812-
- `repo`: `$(Build.SourcesDirectory)/$(Build.Repository.Name)/<relative-path>.yml`
810+
Should be replaced with the path to the compiled pipeline YAML file for runtime integrity checking. The path is derived from the output path (preserving any directory structure) and is anchored at the **trigger ("self") repository** via `{{ trigger_repo_directory }}` (see below), independent of the user's `workspace:` setting:
811+
- No additional checkouts: `$(Build.SourcesDirectory)/<relative-path>.yml`
812+
- Additional checkouts present: `$(Build.SourcesDirectory)/$(Build.Repository.Name)/<relative-path>.yml`
813813

814-
For example, an output path of `pipelines/production/review.lock.yml` resolves to `$(Build.SourcesDirectory)/pipelines/production/review.lock.yml` under the `root` workspace.
814+
For example, an output path of `pipelines/production/review.lock.yml` resolves to `$(Build.SourcesDirectory)/pipelines/production/review.lock.yml` when no additional repositories are checked out.
815815

816-
Used by the pipeline's integrity check step to verify the pipeline hasn't been modified outside the compilation process.
816+
Used by the pipeline's integrity check step to verify the pipeline hasn't been modified outside the compilation process. The compiled yaml only ever lives in the trigger repo, so this is intentionally not affected by `workspace:` pointing at a non-self alias.
817+
818+
## {{ trigger_repo_directory }}
819+
820+
Should be replaced with the directory where the trigger ("self") repository is checked out. This is independent of the `workspace:` setting and depends only on whether any additional repositories are listed in `checkout:`:
821+
- No additional checkouts → `$(Build.SourcesDirectory)` (ADO checks `self` into the root)
822+
- One or more additional checkouts → `$(Build.SourcesDirectory)/$(Build.Repository.Name)` (ADO puts each checked-out repo, including `self`, into a subfolder named after the repository)
823+
824+
Use this marker (rather than `{{ working_directory }}` / `{{ workspace }}`) for any path that refers to a file shipped in the trigger repo (e.g. the agent markdown source and the compiled pipeline yaml itself).
817825

818826
## {{ integrity_check }}
819827

src/compile/common.rs

Lines changed: 108 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,27 @@ pub fn compute_effective_workspace(
426426
}
427427
}
428428

429+
/// Generate the directory where the trigger ("self") repository is checked out.
430+
///
431+
/// This is independent of `workspace:` — it depends only on whether any
432+
/// additional repositories are checked out:
433+
/// - No additional checkouts → `$(Build.SourcesDirectory)` (ADO checks `self`
434+
/// into the root).
435+
/// - One or more additional checkouts → `$(Build.SourcesDirectory)/$(Build.Repository.Name)`
436+
/// (ADO puts each checked-out repo, including `self`, into a subfolder named
437+
/// after the repository).
438+
///
439+
/// Used to anchor paths to files that ship in the trigger repo (e.g. the agent
440+
/// markdown source and the compiled pipeline yaml itself), regardless of where
441+
/// `workspace:` points the agent.
442+
pub fn generate_trigger_repo_directory(checkout: &[String]) -> String {
443+
if checkout.is_empty() {
444+
"$(Build.SourcesDirectory)".to_string()
445+
} else {
446+
"$(Build.SourcesDirectory)/$(Build.Repository.Name)".to_string()
447+
}
448+
}
449+
429450
/// Generate working directory based on workspace setting
430451
pub fn generate_working_directory(effective_workspace: &str) -> String {
431452
if let Some(alias) = effective_workspace.strip_prefix(WORKSPACE_ALIAS_PREFIX) {
@@ -596,15 +617,16 @@ pub const ADO_MCP_PACKAGE: &str = "@azure-devops/mcp";
596617
/// Reserved MCPG server name for the auto-configured ADO MCP.
597618
pub const ADO_MCP_SERVER_NAME: &str = "azure-devops";
598619

599-
/// Generate source path for the execute command.
620+
/// Generate the agent markdown source path for Stage 3 execution.
600621
///
601-
/// Returns a path using `{{ workspace }}` as the base, which gets resolved
602-
/// to the correct ADO working directory before this placeholder is replaced.
622+
/// Returns a path using `{{ trigger_repo_directory }}` as the base. The agent
623+
/// markdown lives in the trigger ("self") repo, so this anchor is independent
624+
/// of the user's `workspace:` setting (which may point at a different
625+
/// checked-out repo where the agent runs).
603626
///
604627
/// The full relative path of the input file is preserved so that agents compiled
605628
/// from subdirectories (e.g. `ado-aw compile agents/ctf.md`) produce a correct
606-
/// runtime path (`$(Build.SourcesDirectory)/agents/ctf.md`) rather than a path
607-
/// that drops the directory component.
629+
/// runtime path rather than one that drops the directory component.
608630
///
609631
/// Absolute paths fall back to using only the filename to avoid embedding
610632
/// machine-specific paths in the generated pipeline.
@@ -617,7 +639,7 @@ pub fn generate_source_path(input_path: &std::path::Path) -> String {
617639
.to_string()
618640
});
619641

620-
format!("{{{{ workspace }}}}/{}", relative)
642+
format!("{{{{ trigger_repo_directory }}}}/{}", relative)
621643
}
622644

623645
/// Generate the "Verify pipeline integrity" step for the pipeline YAML.
@@ -733,13 +755,13 @@ pub fn generate_debug_pipeline_replacements(debug: bool) -> Vec<(String, String)
733755

734756
/// Generate the pipeline YAML path for integrity checking at ADO runtime.
735757
///
736-
/// Returns a path using `{{ workspace }}` as the base, derived from the
737-
/// output path so it matches whatever `-o` was specified during compilation.
758+
/// Returns a path using `{{ trigger_repo_directory }}` as the base. The
759+
/// compiled pipeline yaml ships in the trigger ("self") repo, so this anchor
760+
/// is independent of the user's `workspace:` setting.
738761
///
739762
/// The full relative path is preserved so that pipelines compiled into
740763
/// subdirectories (e.g. `agents/ctf.yml`) produce a correct runtime path
741-
/// (`$(Build.SourcesDirectory)/agents/ctf.yml`) rather than a path that
742-
/// drops the directory component.
764+
/// rather than one that drops the directory component.
743765
///
744766
/// Absolute paths fall back to using only the filename to avoid embedding
745767
/// machine-specific paths in the generated pipeline.
@@ -752,7 +774,7 @@ pub fn generate_pipeline_path(output_path: &std::path::Path) -> String {
752774
.to_string()
753775
});
754776

755-
format!("{{{{ workspace }}}}/{}", relative)
777+
format!("{{{{ trigger_repo_directory }}}}/{}", relative)
756778
}
757779

758780
/// Normalize a path for embedding in a generated pipeline.
@@ -1726,6 +1748,7 @@ pub async fn compile_shared(
17261748
&front_matter.name,
17271749
)?;
17281750
let working_directory = generate_working_directory(&effective_workspace);
1751+
let trigger_repo_directory = generate_trigger_repo_directory(&front_matter.checkout);
17291752
let pipeline_resources = generate_pipeline_resources(&front_matter.triggers)?;
17301753
let has_schedule = front_matter.schedule.is_some();
17311754
let pr_trigger = generate_pr_trigger(&front_matter.triggers, has_schedule);
@@ -1844,6 +1867,9 @@ pub async fn compile_shared(
18441867
// integrity step content itself contains {{ pipeline_path }}.
18451868
("{{ integrity_check }}", &integrity_check),
18461869
("{{ pipeline_path }}", &pipeline_path),
1870+
// trigger_repo_directory must come after source_path / pipeline_path
1871+
// because those expansions embed the placeholder.
1872+
("{{ trigger_repo_directory }}", &trigger_repo_directory),
18471873
("{{ working_directory }}", &working_directory),
18481874
("{{ workspace }}", &working_directory),
18491875
("{{ agent_content }}", markdown_body),
@@ -2582,32 +2608,36 @@ mod tests {
25822608

25832609
#[test]
25842610
fn test_generate_source_path_preserves_directory() {
2585-
// Compiling agents/ctf.md should produce {{ workspace }}/agents/ctf.md,
2586-
// not {{ workspace }}/agents/ctf.md with a hardcoded agents/ prefix.
2611+
// Compiling agents/ctf.md should produce the trigger-repo-anchored
2612+
// path so the integrity check / Stage 3 executor find the file in the
2613+
// self repo regardless of the user's workspace setting.
25872614
let path = std::path::Path::new("agents/ctf.md");
25882615
let result = generate_source_path(path);
2589-
assert_eq!(result, "{{ workspace }}/agents/ctf.md");
2616+
assert_eq!(result, "{{ trigger_repo_directory }}/agents/ctf.md");
25902617
}
25912618

25922619
#[test]
25932620
fn test_generate_source_path_nested_directory() {
25942621
let path = std::path::Path::new("pipelines/production/review.md");
25952622
let result = generate_source_path(path);
2596-
assert_eq!(result, "{{ workspace }}/pipelines/production/review.md");
2623+
assert_eq!(
2624+
result,
2625+
"{{ trigger_repo_directory }}/pipelines/production/review.md"
2626+
);
25972627
}
25982628

25992629
#[test]
26002630
fn test_generate_source_path_strips_dot_slash() {
26012631
let path = std::path::Path::new("./agents/my-agent.md");
26022632
let result = generate_source_path(path);
2603-
assert_eq!(result, "{{ workspace }}/agents/my-agent.md");
2633+
assert_eq!(result, "{{ trigger_repo_directory }}/agents/my-agent.md");
26042634
}
26052635

26062636
#[test]
26072637
fn test_generate_source_path_filename_only() {
26082638
let path = std::path::Path::new("my-agent.md");
26092639
let result = generate_source_path(path);
2610-
assert_eq!(result, "{{ workspace }}/my-agent.md");
2640+
assert_eq!(result, "{{ trigger_repo_directory }}/my-agent.md");
26112641
}
26122642

26132643
// ─── generate_pipeline_path ──────────────────────────────────────────────
@@ -2618,28 +2648,31 @@ mod tests {
26182648
// output, but the embedded path was only ctf.yml (missing agents/).
26192649
let path = std::path::Path::new("agents/ctf.yml");
26202650
let result = generate_pipeline_path(path);
2621-
assert_eq!(result, "{{ workspace }}/agents/ctf.yml");
2651+
assert_eq!(result, "{{ trigger_repo_directory }}/agents/ctf.yml");
26222652
}
26232653

26242654
#[test]
26252655
fn test_generate_pipeline_path_nested_directory() {
26262656
let path = std::path::Path::new("pipelines/production/review.yml");
26272657
let result = generate_pipeline_path(path);
2628-
assert_eq!(result, "{{ workspace }}/pipelines/production/review.yml");
2658+
assert_eq!(
2659+
result,
2660+
"{{ trigger_repo_directory }}/pipelines/production/review.yml"
2661+
);
26292662
}
26302663

26312664
#[test]
26322665
fn test_generate_pipeline_path_strips_dot_slash() {
26332666
let path = std::path::Path::new("./agents/my-agent.yml");
26342667
let result = generate_pipeline_path(path);
2635-
assert_eq!(result, "{{ workspace }}/agents/my-agent.yml");
2668+
assert_eq!(result, "{{ trigger_repo_directory }}/agents/my-agent.yml");
26362669
}
26372670

26382671
#[test]
26392672
fn test_generate_pipeline_path_filename_only() {
26402673
let path = std::path::Path::new("pipeline.yml");
26412674
let result = generate_pipeline_path(path);
2642-
assert_eq!(result, "{{ workspace }}/pipeline.yml");
2675+
assert_eq!(result, "{{ trigger_repo_directory }}/pipeline.yml");
26432676
}
26442677

26452678
#[test]
@@ -2652,15 +2685,15 @@ mod tests {
26522685
// No .git marker — find_git_root will walk up and find nothing
26532686
// (temp dirs are outside any repo).
26542687
let result = generate_source_path(&abs_path);
2655-
assert_eq!(result, "{{ workspace }}/ctf.md");
2688+
assert_eq!(result, "{{ trigger_repo_directory }}/ctf.md");
26562689
}
26572690

26582691
#[test]
26592692
fn test_generate_pipeline_path_absolute_falls_back_to_filename() {
26602693
let tmp = tempfile::TempDir::new().unwrap();
26612694
let abs_path = tmp.path().join("agents").join("ctf.yml");
26622695
let result = generate_pipeline_path(&abs_path);
2663-
assert_eq!(result, "{{ workspace }}/ctf.yml");
2696+
assert_eq!(result, "{{ trigger_repo_directory }}/ctf.yml");
26642697
}
26652698

26662699
#[test]
@@ -2676,7 +2709,7 @@ mod tests {
26762709
fs::write(tmp.path().join(".git"), "gitdir: fake").unwrap();
26772710
let abs_path = agents_dir.join("ctf.md");
26782711
let result = generate_source_path(&abs_path);
2679-
assert_eq!(result, "{{ workspace }}/agents/ctf.md");
2712+
assert_eq!(result, "{{ trigger_repo_directory }}/agents/ctf.md");
26802713
}
26812714

26822715
#[test]
@@ -2688,7 +2721,57 @@ mod tests {
26882721
fs::write(tmp.path().join(".git"), "gitdir: fake").unwrap();
26892722
let abs_path = agents_dir.join("ctf.yml");
26902723
let result = generate_pipeline_path(&abs_path);
2691-
assert_eq!(result, "{{ workspace }}/agents/ctf.yml");
2724+
assert_eq!(result, "{{ trigger_repo_directory }}/agents/ctf.yml");
2725+
}
2726+
2727+
// ─── generate_trigger_repo_directory ─────────────────────────────────────
2728+
2729+
#[test]
2730+
fn test_generate_trigger_repo_directory_no_additional_checkouts() {
2731+
// With only `self` checked out, ADO places the repository content
2732+
// directly into $(Build.SourcesDirectory).
2733+
let result = generate_trigger_repo_directory(&[]);
2734+
assert_eq!(result, "$(Build.SourcesDirectory)");
2735+
}
2736+
2737+
#[test]
2738+
fn test_generate_trigger_repo_directory_with_additional_checkouts() {
2739+
// As soon as any additional repo is checked out, ADO places every
2740+
// checked-out repo (including `self`) into a subdirectory named
2741+
// after the repository.
2742+
let result =
2743+
generate_trigger_repo_directory(&["exp23-a7-nw".to_string()]);
2744+
assert_eq!(
2745+
result,
2746+
"$(Build.SourcesDirectory)/$(Build.Repository.Name)"
2747+
);
2748+
}
2749+
2750+
#[test]
2751+
fn test_trigger_repo_directory_independent_of_workspace_alias() {
2752+
// Regression: when workspace points at a checked-out alias, the
2753+
// trigger-repo directory must still anchor at the self repo, NOT at
2754+
// the alias subfolder. This is what makes the integrity check
2755+
// (and Stage 3 --source) find the pipeline yaml / agent markdown.
2756+
let checkout = vec!["exp23-a7-nw".to_string()];
2757+
let trigger = generate_trigger_repo_directory(&checkout);
2758+
let workspace = compute_effective_workspace(
2759+
&Some("exp23-a7-nw".to_string()),
2760+
&checkout,
2761+
"ctf",
2762+
)
2763+
.unwrap();
2764+
let working_dir = generate_working_directory(&workspace);
2765+
2766+
assert_eq!(
2767+
trigger,
2768+
"$(Build.SourcesDirectory)/$(Build.Repository.Name)"
2769+
);
2770+
assert_eq!(working_dir, "$(Build.SourcesDirectory)/exp23-a7-nw");
2771+
assert_ne!(
2772+
trigger, working_dir,
2773+
"trigger repo dir must differ from working dir when workspace points at an alias"
2774+
);
26922775
}
26932776

26942777
// ─── generate_integrity_check ────────────────────────────────────────────

0 commit comments

Comments
 (0)