Skip to content

Commit 4fe2175

Browse files
feat(engine): split copilot CLI install path by compile target (#584)
* feat(engine): split copilot CLI install path by compile target Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/eb129b79-de79-40cb-8d16-950d16644d40 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * refactor(engine): split non-1es latest vs pinned install steps Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/8ae2f8d4-e2d0-430b-98b6-5c63d8555544 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * refactor(engine): extract release URL constant and version tag helper Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/8ae2f8d4-e2d0-430b-98b6-5c63d8555544 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> * test(engine): implement rust-review follow-up suggestions Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/14275add-688b-4cc5-bfac-afc3db1a0567 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com>
1 parent e0ddc2c commit 4fe2175

2 files changed

Lines changed: 148 additions & 29 deletions

File tree

src/compile/common.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2961,7 +2961,7 @@ pub async fn compile_shared(
29612961
"/tmp/awf-tools/threat-analysis-prompt.md",
29622962
None,
29632963
)?;
2964-
let engine_install_steps = ctx.engine.install_steps(&front_matter.engine)?;
2964+
let engine_install_steps = ctx.engine.install_steps(&front_matter.engine, &front_matter.target)?;
29652965

29662966
// 5. Compute workspace, working directory, triggers
29672967
let effective_workspace = compute_effective_workspace(

src/engine.rs

Lines changed: 147 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use anyhow::Result;
22

33
use crate::compile::extensions::{CompilerExtension, Extension};
4-
use crate::compile::types::{EngineConfig, FrontMatter, McpConfig};
4+
use crate::compile::types::{CompileTarget, EngineConfig, FrontMatter, McpConfig};
55
use crate::validate::{
66
contains_ado_expression, contains_newline, contains_pipeline_command, is_valid_arg,
77
is_valid_command_path, is_valid_env_var_name, is_valid_hostname, is_valid_identifier,
@@ -40,9 +40,10 @@ pub const BLOCKED_ENV_KEYS: &[&str] = &[
4040
/// Default model used by the Copilot engine when no model is specified in front matter.
4141
pub const DEFAULT_COPILOT_MODEL: &str = "claude-opus-4.7";
4242

43-
/// Default pinned version of the Copilot CLI NuGet package.
43+
/// Default pinned version of the Copilot CLI.
4444
/// Override per-agent via `engine: { id: copilot, version: "1.0.35" }` in front matter.
4545
pub const COPILOT_CLI_VERSION: &str = "1.0.47";
46+
const COPILOT_CLI_RELEASES_BASE: &str = "https://github.com/github/copilot-cli/releases";
4647

4748
/// Resolved engine — enum dispatch over supported engine identifiers.
4849
///
@@ -134,9 +135,9 @@ impl Engine {
134135
/// Uses `engine_config.version()` if set in front matter, otherwise falls back
135136
/// to the pinned `COPILOT_CLI_VERSION` constant. Returns an empty string when
136137
/// `engine.command` is set (the user provides their own binary).
137-
pub fn install_steps(&self, engine_config: &EngineConfig) -> Result<String> {
138+
pub fn install_steps(&self, engine_config: &EngineConfig, target: &CompileTarget) -> Result<String> {
138139
match self {
139-
Engine::Copilot => copilot_install_steps(engine_config),
140+
Engine::Copilot => copilot_install_steps(engine_config, target),
140141
}
141142
}
142143

@@ -498,10 +499,12 @@ fn copilot_env(engine_config: &EngineConfig) -> Result<String> {
498499

499500
/// Generate Copilot CLI install steps for Azure DevOps pipelines.
500501
///
501-
/// Produces the YAML block that authenticates with NuGet, installs the
502-
/// `Microsoft.Copilot.CLI.linux-x64` package, copies the binary to
503-
/// `/tmp/awf-tools/copilot`, and verifies the install.
504-
fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
502+
/// Produces target-specific YAML:
503+
/// - 1ES: authenticate with NuGet and install `Microsoft.Copilot.CLI.linux-x64`.
504+
/// - Non-1ES: download Copilot CLI from GitHub Releases and verify SHA256.
505+
///
506+
/// Both paths stage the binary at `/tmp/awf-tools/copilot`.
507+
fn copilot_install_steps(engine_config: &EngineConfig, target: &CompileTarget) -> Result<String> {
505508
// Custom binary path → skip NuGet install entirely
506509
if engine_config.command().is_some() {
507510
return Ok(String::new());
@@ -511,8 +514,9 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
511514
.version()
512515
.unwrap_or(COPILOT_CLI_VERSION);
513516

514-
// Validate version to prevent NuGet argument injection — the version string
515-
// is embedded directly into NuGet command arguments.
517+
// Validate version to prevent injection — this value is used in NuGet
518+
// command arguments for 1ES and in GitHub Releases URL construction for
519+
// non-1ES targets.
516520
if !is_valid_version(version) {
517521
anyhow::bail!(
518522
"engine.version '{}' contains invalid characters. \
@@ -521,16 +525,17 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
521525
);
522526
}
523527

524-
// "latest" means "install the newest available version" — NuGet doesn't
525-
// recognise "latest" as a version string; omitting -Version installs the newest.
526-
let version_arg = if version == "latest" {
527-
String::new()
528-
} else {
529-
format!("-Version {version} ")
530-
};
528+
if *target == CompileTarget::OneES {
529+
// "latest" means "install the newest available version" — NuGet doesn't
530+
// recognise "latest" as a version string; omitting -Version installs the newest.
531+
let version_arg = if version == "latest" {
532+
String::new()
533+
} else {
534+
format!("-Version {version} ")
535+
};
531536

532-
Ok(format!(
533-
"\
537+
return Ok(format!(
538+
"\
534539
- task: NuGetAuthenticate@1
535540
displayName: \"Authenticate NuGet Feed\"
536541
@@ -551,6 +556,82 @@ fn copilot_install_steps(engine_config: &EngineConfig) -> Result<String> {
551556
chmod +x /tmp/awf-tools/copilot
552557
displayName: \"Add copilot to PATH\"
553558
559+
- bash: |
560+
copilot --version
561+
copilot -h
562+
displayName: \"Output copilot version\""
563+
));
564+
}
565+
566+
if version == "latest" {
567+
return copilot_install_from_github_release(
568+
&format!("{COPILOT_CLI_RELEASES_BASE}/latest/download"),
569+
"Install Copilot CLI (latest)",
570+
);
571+
}
572+
573+
let version_tag = normalize_version_tag(version);
574+
let base_url = format!("{COPILOT_CLI_RELEASES_BASE}/download/{version_tag}");
575+
copilot_install_from_github_release(
576+
&base_url,
577+
&format!("Install Copilot CLI ({version_tag})"),
578+
)
579+
}
580+
581+
fn normalize_version_tag(version: &str) -> String {
582+
if version.starts_with('v') {
583+
version.to_string()
584+
} else {
585+
format!("v{version}")
586+
}
587+
}
588+
589+
fn copilot_install_from_github_release(base_url: &str, display_name: &str) -> Result<String> {
590+
Ok(format!(
591+
"\
592+
- bash: |
593+
set -euo pipefail
594+
TARBALL_NAME=\"copilot-linux-x64.tar.gz\"
595+
BASE_URL=\"{base_url}\"
596+
TARBALL_URL=\"$BASE_URL/$TARBALL_NAME\"
597+
CHECKSUMS_URL=\"$BASE_URL/SHA256SUMS.txt\"
598+
TOOLS_DIR=\"$(Agent.TempDirectory)/tools\"
599+
TEMP_DIR=\"$(mktemp -d)\"
600+
trap 'rm -rf \"$TEMP_DIR\"' EXIT
601+
mkdir -p \"$TOOLS_DIR\" /tmp/awf-tools
602+
603+
curl -fsSL --retry 3 --retry-delay 5 -o \"$TEMP_DIR/SHA256SUMS.txt\" \"$CHECKSUMS_URL\"
604+
curl -fsSL --retry 3 --retry-delay 5 -o \"$TEMP_DIR/$TARBALL_NAME\" \"$TARBALL_URL\"
605+
606+
EXPECTED_CHECKSUM=$(awk -v fname=\"$TARBALL_NAME\" '$2 == fname {{print $1; exit}}' \"$TEMP_DIR/SHA256SUMS.txt\" | tr 'A-F' 'a-f')
607+
if [ -z \"$EXPECTED_CHECKSUM\" ]; then
608+
echo \"ERROR: failed to resolve expected checksum for $TARBALL_NAME\"
609+
exit 1
610+
fi
611+
612+
if command -v sha256sum > /dev/null 2>&1; then
613+
ACTUAL_CHECKSUM=$(sha256sum \"$TEMP_DIR/$TARBALL_NAME\" | awk '{{print $1}}' | tr 'A-F' 'a-f')
614+
elif command -v shasum > /dev/null 2>&1; then
615+
ACTUAL_CHECKSUM=$(shasum -a 256 \"$TEMP_DIR/$TARBALL_NAME\" | awk '{{print $1}}' | tr 'A-F' 'a-f')
616+
else
617+
echo \"ERROR: neither sha256sum nor shasum is available\"
618+
exit 1
619+
fi
620+
621+
if [ \"$EXPECTED_CHECKSUM\" != \"$ACTUAL_CHECKSUM\" ]; then
622+
echo \"ERROR: checksum verification failed\"
623+
echo \"Expected: $EXPECTED_CHECKSUM\"
624+
echo \"Actual: $ACTUAL_CHECKSUM\"
625+
exit 1
626+
fi
627+
628+
tar -xz -C \"$TOOLS_DIR\" -f \"$TEMP_DIR/$TARBALL_NAME\"
629+
ls -la \"$TOOLS_DIR\"
630+
echo \"##vso[task.prependpath]$TOOLS_DIR\"
631+
cp \"$TOOLS_DIR/copilot\" /tmp/awf-tools/copilot
632+
chmod +x /tmp/awf-tools/copilot
633+
displayName: \"{display_name}\"
634+
554635
- bash: |
555636
copilot --version
556637
copilot -h
@@ -585,7 +666,7 @@ fn copilot_invocation(
585666

586667
#[cfg(test)]
587668
mod tests {
588-
use super::{get_engine, Engine};
669+
use super::{get_engine, normalize_version_tag, Engine};
589670
use crate::compile::{extensions::collect_extensions, parse_markdown};
590671

591672
#[test]
@@ -922,7 +1003,7 @@ mod tests {
9221003
let (fm, _) = parse_markdown(
9231004
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: '1.0.0 -Source https://evil.com'\n---\n",
9241005
).unwrap();
925-
let result = Engine::Copilot.install_steps(&fm.engine);
1006+
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target);
9261007
assert!(result.is_err());
9271008
assert!(result.unwrap_err().to_string().contains("invalid characters"));
9281009
}
@@ -932,7 +1013,7 @@ mod tests {
9321013
let (fm, _) = parse_markdown(
9331014
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: \"1.0.0'\"\n---\n",
9341015
).unwrap();
935-
let result = Engine::Copilot.install_steps(&fm.engine);
1016+
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target);
9361017
assert!(result.is_err());
9371018
}
9381019

@@ -941,19 +1022,57 @@ mod tests {
9411022
let (fm, _) = parse_markdown(
9421023
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: '1.0.34'\n---\n",
9431024
).unwrap();
944-
let result = Engine::Copilot.install_steps(&fm.engine).unwrap();
945-
assert!(result.contains("-Version 1.0.34"));
1025+
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
1026+
assert!(result.contains("releases/download/v1.0.34"));
1027+
assert!(result.contains("Install Copilot CLI (v1.0.34)"));
1028+
}
1029+
1030+
#[test]
1031+
fn engine_version_accepts_valid_with_v_prefix() {
1032+
let (fm, _) = parse_markdown(
1033+
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: 'v1.0.34'\n---\n",
1034+
).unwrap();
1035+
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
1036+
assert!(result.contains("releases/download/v1.0.34"));
1037+
assert!(result.contains("Install Copilot CLI (v1.0.34)"));
9461038
}
9471039

9481040
#[test]
9491041
fn engine_version_accepts_latest() {
9501042
let (fm, _) = parse_markdown(
9511043
"---\nname: test\ndescription: test\nengine:\n id: copilot\n version: latest\n---\n",
9521044
).unwrap();
953-
let result = Engine::Copilot.install_steps(&fm.engine).unwrap();
954-
// "latest" omits -Version entirely so NuGet installs the newest available
955-
assert!(!result.contains("-Version"), "should not contain -Version flag for 'latest'");
956-
assert!(result.contains("-OutputDirectory"), "should still contain other NuGet args");
1045+
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
1046+
assert!(result.contains("releases/latest/download"), "latest should resolve via latest release URL");
1047+
assert!(result.contains("Install Copilot CLI (latest)"));
1048+
}
1049+
1050+
#[test]
1051+
fn engine_install_onees_latest_omits_version_argument() {
1052+
let (fm, _) = parse_markdown(
1053+
"---\nname: test\ndescription: test\ntarget: 1es\nengine:\n id: copilot\n version: latest\n---\n",
1054+
).unwrap();
1055+
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
1056+
assert!(result.contains("NuGetCommand@2"));
1057+
assert!(result.contains("Guardian1ESPTUpstreamOrgFeed"));
1058+
assert!(!result.contains("-Version latest"));
1059+
}
1060+
1061+
#[test]
1062+
fn engine_install_onees_uses_nuget_feed() {
1063+
let (fm, _) = parse_markdown(
1064+
"---\nname: test\ndescription: test\ntarget: 1es\nengine:\n id: copilot\n version: '1.0.34'\n---\n",
1065+
).unwrap();
1066+
let result = Engine::Copilot.install_steps(&fm.engine, &fm.target).unwrap();
1067+
assert!(result.contains("NuGetCommand@2"));
1068+
assert!(result.contains("Guardian1ESPTUpstreamOrgFeed"));
1069+
assert!(result.contains("-Version 1.0.34"));
1070+
}
1071+
1072+
#[test]
1073+
fn normalize_version_tag_does_not_double_prefix_v() {
1074+
assert_eq!(normalize_version_tag("v1.0.34"), "v1.0.34");
1075+
assert_eq!(normalize_version_tag("1.0.34"), "v1.0.34");
9571076
}
9581077

9591078
// ─── engine.env empty key test ────────────────────────────────────────

0 commit comments

Comments
 (0)