From 46241f7b6cb1a9f5b85f513e94f37d454cf58e28 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 11:06:05 +0000 Subject: [PATCH 1/4] feat(runtimes): add .NET runtime extension Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/efab2e0c-8fcd-44b0-8af3-226fa6e8e2cc Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/front-matter.md | 4 + docs/runtimes.md | 45 ++++++++ src/compile/common.rs | 2 + src/compile/extensions/mod.rs | 7 ++ src/compile/extensions/tests.rs | 136 ++++++++++++++++++++++- src/compile/standalone.rs | 2 + src/compile/types.rs | 12 ++ src/runtimes/dotnet/extension.rs | 124 +++++++++++++++++++++ src/runtimes/dotnet/mod.rs | 185 +++++++++++++++++++++++++++++++ src/runtimes/mod.rs | 3 +- 10 files changed, 517 insertions(+), 3 deletions(-) create mode 100644 src/runtimes/dotnet/extension.rs create mode 100644 src/runtimes/dotnet/mod.rs diff --git a/docs/front-matter.md b/docs/front-matter.md index 02a98975..081b8d77 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -53,6 +53,10 @@ runtimes: # optional runtime configuration (language enviro # node: # Alternative object format (pin version, configure internal feed) # version: "22.x" # feed-url: "https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/" + # dotnet: true # .NET runtime — auto-installs via UseDotNet@2 (see docs/runtimes.md) + # dotnet: # Alternative object format (pin version, configure internal feed via nuget.config) + # version: "8.0.x" + # feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json" # env: # RESERVED: workflow-level environment variables (not yet implemented) # CUSTOM_VAR: "value" mcp-servers: diff --git a/docs/runtimes.md b/docs/runtimes.md index edaf6a5d..617fab26 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -103,6 +103,49 @@ When enabled, the compiler: - No AWF mounts or PATH prepends needed — `NodeTool@0` installs to `/opt/hostedtoolcache` (auto-mounted by AWF) and publishes PATH entries that AWF merges via `$GITHUB_PATH` - Note: AWF overlays `~/.npmrc` with `/dev/null` for credential security — the `NPM_CONFIG_REGISTRY` env var approach avoids conflicting with this overlay +### .NET (`dotnet:`) +.NET runtime. Auto-installs the .NET SDK via `UseDotNet@2`, emits `NuGetAuthenticate@1` for internal feed access, adds .NET ecosystem domains to the AWF network allowlist, and extends the bash command allow-list with `dotnet`. + +```yaml +# Simple enablement (installs default .NET SDK, currently 8.0.x) +runtimes: + dotnet: true + +# With options (pin version, configure internal feed) +runtimes: + dotnet: + version: "8.0.x" + feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json" + +# Or point at a checked-in nuget.config +runtimes: + dotnet: + version: "8.0.x" + config: "nuget.config" +``` + +**Fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `version` | string | .NET SDK version to install (e.g., `"8.0.x"`, `"9.0.x"`). Passed to `UseDotNet@2` `version` with `packageType: 'sdk'`. Defaults to `"8.0.x"`. | +| `feed-url` | string | Internal NuGet feed URL (typically the v3 `index.json` of an Azure Artifacts feed). When set, the compiler creates a minimal `nuget.config` if none exists and runs `NuGetAuthenticate@1`. | +| `config` | string | Path to a checked-in `nuget.config` in the repo. When set, the compiler runs `NuGetAuthenticate@1` (which auto-discovers `nuget.config` files in the workspace). Mutually exclusive with `feed-url`. | + +When enabled, the compiler: +- Injects `UseDotNet@2` into `{{ prepare_steps }}` (runs before AWF) +- If `feed-url` is set, injects an ensure-`nuget.config` step (writes a minimal `nuget.config` referencing the feed only when one doesn't already exist) and `NuGetAuthenticate@1` +- If `config` is set (and `feed-url` is not), injects `NuGetAuthenticate@1` only — the user-checked-in `nuget.config` is assumed to be present in the workspace +- Auto-adds `dotnet` to the bash command allow-list +- Adds .NET ecosystem domains to the network allowlist (nuget.org, dotnet.microsoft.com, pkgs.dev.azure.com, etc.) +- Appends a prompt supplement informing the agent about .NET availability +- No AWF mounts or PATH prepends needed — `UseDotNet@2` installs to `/opt/hostedtoolcache` (auto-mounted by AWF) and publishes PATH entries that AWF merges via `$GITHUB_PATH` + +**Differences from the Python and Node runtimes** (called out for clarity, since this runtime intentionally diverges): +- **No agent env var is injected for `feed-url`.** Unlike `pip` (`PIP_INDEX_URL`) and `npm` (`NPM_CONFIG_REGISTRY`), NuGet has no first-class environment-variable equivalent for selecting a package source. Feed configuration always goes through a `nuget.config` file. +- **`config:` is functional, not a deferred warning.** AWF only overlays files in `$HOME` (e.g., `~/.npmrc` → `/dev/null`); workspace files such as `nuget.config` are preserved inside the agent sandbox, so a checked-in `nuget.config` works today. +- **`NuGetAuthenticate@1` requires no `workingFile:` input.** It auto-discovers `nuget.config` files anywhere in the workspace, unlike `npmAuthenticate@0` which needs an explicit path. + ### Combining Runtimes Multiple runtimes can be enabled simultaneously: @@ -113,6 +156,8 @@ runtimes: version: "3.12" node: version: "22.x" + dotnet: + version: "8.0.x" lean: true ``` diff --git a/src/compile/common.rs b/src/compile/common.rs index 207c5d5f..032e9261 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2581,6 +2581,7 @@ mod tests { lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)), python: None, node: None, + dotnet: None, }); let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("shell(lean)"), "lean command should be allowed"); @@ -2603,6 +2604,7 @@ mod tests { lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)), python: None, node: None, + dotnet: None, }); let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap(); assert!(params.contains("--allow-all-tools"), "wildcard should use --allow-all-tools"); diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index 6ac8aba2..e7dbf2d5 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -560,6 +560,7 @@ pub(crate) mod trigger_filters; pub use crate::tools::azure_devops::AzureDevOpsExtension; pub use crate::tools::cache_memory::CacheMemoryExtension; pub use github::GitHubExtension; +pub use crate::runtimes::dotnet::DotnetExtension; pub use crate::runtimes::lean::LeanExtension; pub use crate::runtimes::node::NodeExtension; pub use crate::runtimes::python::PythonExtension; @@ -577,6 +578,7 @@ extension_enum! { Lean(LeanExtension), Python(PythonExtension), Node(NodeExtension), + Dotnet(DotnetExtension), AzureDevOps(AzureDevOpsExtension), CacheMemory(CacheMemoryExtension), TriggerFilters(TriggerFiltersExtension), @@ -621,6 +623,11 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec { extensions.push(Extension::Node(NodeExtension::new(node.clone()))); } } + if let Some(dotnet) = front_matter.runtimes.as_ref().and_then(|r| r.dotnet.as_ref()) { + if dotnet.is_enabled() { + extensions.push(Extension::Dotnet(DotnetExtension::new(dotnet.clone()))); + } + } // ── First-party tools (ExtensionPhase::Tool) ── if let Some(tools) = front_matter.tools.as_ref() { diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index e10920f2..32da867f 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -575,20 +575,152 @@ fn test_python_config_and_feed_url_mutually_exclusive() { assert!(result.unwrap_err().to_string().contains("mutually exclusive")); } +// ── DotnetExtension ──────────────────────────────────────────── + +#[test] +fn test_collect_extensions_dotnet_enabled() { + let (fm, _) = + parse_markdown("---\nname: test\ndescription: test\nruntimes:\n dotnet: true\n---\n") + .unwrap(); + let exts = collect_extensions(&fm); + assert!(exts.iter().any(|e| e.name() == "dotnet")); +} + +#[test] +fn test_collect_extensions_dotnet_disabled() { + let (fm, _) = + parse_markdown("---\nname: test\ndescription: test\nruntimes:\n dotnet: false\n---\n") + .unwrap(); + let exts = collect_extensions(&fm); + assert!(!exts.iter().any(|e| e.name() == "dotnet")); +} + +#[test] +fn test_collect_extensions_dotnet_with_version() { + let (fm, _) = + parse_markdown("---\nname: test\ndescription: test\nruntimes:\n dotnet:\n version: '8.0.x'\n---\n") + .unwrap(); + let exts = collect_extensions(&fm); + assert!(exts.iter().any(|e| e.name() == "dotnet")); +} + +#[test] +fn test_dotnet_required_hosts() { + let ext = crate::runtimes::dotnet::DotnetExtension::new( + crate::runtimes::dotnet::DotnetRuntimeConfig::Enabled(true), + ); + let hosts = ext.required_hosts(); + assert_eq!(hosts, vec!["dotnet".to_string()]); +} + +#[test] +fn test_dotnet_required_bash_commands() { + let ext = crate::runtimes::dotnet::DotnetExtension::new( + crate::runtimes::dotnet::DotnetRuntimeConfig::Enabled(true), + ); + assert_eq!(ext.required_bash_commands(), vec!["dotnet".to_string()]); +} + +#[test] +fn test_dotnet_prepare_steps() { + let ext = crate::runtimes::dotnet::DotnetExtension::new( + crate::runtimes::dotnet::DotnetRuntimeConfig::Enabled(true), + ); + let steps = ext.prepare_steps(); + assert_eq!(steps.len(), 1, "no auth steps without feed-url/config"); + assert!(steps[0].contains("UseDotNet@2")); + assert!(steps[0].contains("packageType: 'sdk'")); +} + +#[test] +fn test_dotnet_prepare_steps_with_feed_url() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n feed-url: 'https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let steps = ext.prepare_steps(); + assert_eq!(steps.len(), 3); + assert!(steps[0].contains("UseDotNet@2")); + assert!(steps[1].contains("Ensure nuget.config")); + assert!(steps[2].contains("NuGetAuthenticate@1")); +} + +#[test] +fn test_dotnet_prepare_steps_with_config_only() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n config: 'nuget.config'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let steps = ext.prepare_steps(); + // config: alone trusts the user-checked-in nuget.config — no shim, + // just the auth step. + assert_eq!(steps.len(), 2); + assert!(steps[0].contains("UseDotNet@2")); + assert!(steps[1].contains("NuGetAuthenticate@1")); +} + +#[test] +fn test_dotnet_agent_env_vars_no_feed() { + let ext = crate::runtimes::dotnet::DotnetExtension::new( + crate::runtimes::dotnet::DotnetRuntimeConfig::Enabled(true), + ); + assert!(ext.agent_env_vars().is_empty()); +} + +#[test] +fn test_dotnet_agent_env_vars_with_feed() { + // Unlike Python (PIP_INDEX_URL) and Node (NPM_CONFIG_REGISTRY), .NET + // does NOT inject any env var for feed configuration — it relies on + // nuget.config files. This test pins that contract. + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n version: '8.0.x'\n feed-url: 'https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + assert!(ext.agent_env_vars().is_empty()); +} + +#[test] +fn test_dotnet_config_and_feed_url_mutually_exclusive() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n config: 'nuget.config'\n feed-url: 'https://example.com/nuget/v3/index.json'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let ctx = ctx_from(&fm); + let result = ext.validate(&ctx); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("mutually exclusive")); +} + +#[test] +fn test_dotnet_invalid_feed_url_rejected() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n feed-url: 'https://example.com/$(SECRET)/nuget'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let ctx = ctx_from(&fm); + assert!(ext.validate(&ctx).is_err()); +} + // ── Multiple runtimes ────────────────────────────────────────── #[test] fn test_collect_extensions_all_runtimes_enabled() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nruntimes:\n lean: true\n python: true\n node: true\n---\n", + "---\nname: test\ndescription: test\nruntimes:\n lean: true\n python: true\n node: true\n dotnet: true\n---\n", ).unwrap(); let exts = collect_extensions(&fm); assert!(exts.iter().any(|e| e.name() == "Lean 4")); assert!(exts.iter().any(|e| e.name() == "Python")); assert!(exts.iter().any(|e| e.name() == "Node.js")); + assert!(exts.iter().any(|e| e.name() == "dotnet")); // All are Runtime phase let runtime_exts: Vec<_> = exts.iter().filter(|e| e.phase() == ExtensionPhase::Runtime).collect(); - assert_eq!(runtime_exts.len(), 3); + assert_eq!(runtime_exts.len(), 4); } #[test] diff --git a/src/compile/standalone.rs b/src/compile/standalone.rs index 5054a222..a683bd1c 100644 --- a/src/compile/standalone.rs +++ b/src/compile/standalone.rs @@ -180,6 +180,7 @@ mod tests { lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)), python: None, node: None, + dotnet: None, }); let exts = super::super::extensions::collect_extensions(&fm); let domains = generate_allowed_domains(&fm, &exts).unwrap(); @@ -195,6 +196,7 @@ mod tests { lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(false)), python: None, node: None, + dotnet: None, }); let exts = super::super::extensions::collect_extensions(&fm); let domains = generate_allowed_domains(&fm, &exts).unwrap(); diff --git a/src/compile/types.rs b/src/compile/types.rs index 2ba98a82..46ddb8c0 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -513,6 +513,15 @@ pub struct RuntimesConfig { /// the bash command allow-list, and optionally injects feed URL env vars. #[serde(default)] pub node: Option, + + /// .NET runtime. + /// Auto-installs the .NET SDK via UseDotNet@2, emits NuGetAuthenticate@1, + /// adds .NET ecosystem domains to the AWF network allowlist, and extends + /// the bash command allow-list. Feed configuration uses `nuget.config` + /// (generated or checked in) rather than env vars — NuGet has no env-var + /// equivalent for selecting a package source. + #[serde(default)] + pub dotnet: Option, } impl SanitizeConfigTrait for RuntimesConfig { @@ -526,6 +535,9 @@ impl SanitizeConfigTrait for RuntimesConfig { if let Some(ref mut node) = self.node { node.sanitize_config_fields(); } + if let Some(ref mut dotnet) = self.dotnet { + dotnet.sanitize_config_fields(); + } } } diff --git a/src/runtimes/dotnet/extension.rs b/src/runtimes/dotnet/extension.rs new file mode 100644 index 00000000..3ffbd406 --- /dev/null +++ b/src/runtimes/dotnet/extension.rs @@ -0,0 +1,124 @@ +// ─── .NET ────────────────────────────────────────────────────────── + +use crate::compile::extensions::{CompileContext, CompilerExtension, ExtensionPhase}; +use crate::validate; +use super::{ + DOTNET_BASH_COMMANDS, DotnetRuntimeConfig, generate_dotnet_install, + generate_ensure_nuget_config, generate_nuget_authenticate, +}; +use anyhow::Result; + +/// .NET runtime extension. +/// +/// Injects: ecosystem network hosts (dotnet), bash commands (dotnet), +/// install steps (UseDotNet@2), authenticate steps (NuGetAuthenticate@1), +/// optionally a `nuget.config` shim, and a prompt supplement. +/// +/// Unlike the Python and Node extensions, no agent env vars are emitted — +/// NuGet's package-source convention is the `nuget.config` file, not env +/// vars. See `runtimes/dotnet/mod.rs` for the rationale. +pub struct DotnetExtension { + config: DotnetRuntimeConfig, +} + +impl DotnetExtension { + pub fn new(config: DotnetRuntimeConfig) -> Self { + Self { config } + } +} + +impl CompilerExtension for DotnetExtension { + fn name(&self) -> &str { + "dotnet" + } + + fn phase(&self) -> ExtensionPhase { + ExtensionPhase::Runtime + } + + fn required_hosts(&self) -> Vec { + vec!["dotnet".to_string()] + } + + fn required_bash_commands(&self) -> Vec { + DOTNET_BASH_COMMANDS + .iter() + .map(|c| (*c).to_string()) + .collect() + } + + fn prompt_supplement(&self) -> Option { + Some( + "\n\ +---\n\ +\n\ +## .NET\n\ +\n\ +The .NET SDK is installed and available. Use `dotnet` to build, test, run, \ +and manage projects (e.g., `dotnet build`, `dotnet test`, `dotnet restore`, \ +`dotnet run`). NuGet package sources are configured via `nuget.config` files \ +in the repository.\n" + .to_string(), + ) + } + + fn prepare_steps(&self) -> Vec { + let mut steps = vec![generate_dotnet_install(&self.config)]; + // Emit ensure-nuget.config + NuGetAuthenticate when an internal feed + // is configured. When only `config:` is set, the user-checked-in + // nuget.config is assumed to exist — emit only the auth step. + if self.config.feed_url().is_some() { + steps.push(generate_ensure_nuget_config(&self.config)); + steps.push(generate_nuget_authenticate()); + } else if self.config.config().is_some() { + steps.push(generate_nuget_authenticate()); + } + steps + } + + fn validate(&self, ctx: &CompileContext) -> Result> { + let mut warnings = Vec::new(); + + // Warn if bash is disabled + let is_bash_disabled = ctx + .front_matter + .tools + .as_ref() + .and_then(|t| t.bash.as_ref()) + .is_some_and(|cmds| cmds.is_empty()); + + if is_bash_disabled { + warnings.push(format!( + "Agent '{}' has runtimes.dotnet enabled but tools.bash is empty. \ + .NET requires bash access (dotnet command).", + ctx.agent_name + )); + } + + // Mutual exclusivity: config + feed-url + if self.config.config().is_some() && self.config.feed_url().is_some() { + anyhow::bail!( + "runtimes.dotnet: 'config' and 'feed-url' are mutually exclusive. \ + Use one or the other." + ); + } + + // Validate feed URL + if let Some(feed_url) = self.config.feed_url() { + validate::validate_feed_url(feed_url, "runtimes.dotnet.feed-url")?; + } + + // Validate version string + if let Some(version) = self.config.version() { + validate::reject_pipeline_injection(version, "runtimes.dotnet.version")?; + } + + // Validate config path (just defend against pipeline injection — the + // path itself is user-supplied and ends up in displayName/log output) + if let Some(config) = self.config.config() { + validate::reject_pipeline_injection(config, "runtimes.dotnet.config")?; + } + + Ok(warnings) + } +} diff --git a/src/runtimes/dotnet/mod.rs b/src/runtimes/dotnet/mod.rs new file mode 100644 index 00000000..783a646b --- /dev/null +++ b/src/runtimes/dotnet/mod.rs @@ -0,0 +1,185 @@ +//! .NET runtime support for the ado-aw compiler. +//! +//! When enabled via `runtimes: dotnet:`, the compiler auto-installs a specific +//! .NET SDK version via `UseDotNet@2`, emits `NuGetAuthenticate@1` for internal +//! feed access, adds .NET ecosystem domains to the AWF network allowlist, +//! and extends the bash command allow-list with `dotnet`. +//! +//! No AWF mounts or PATH prepends are needed because `UseDotNet@2` installs +//! to `/opt/hostedtoolcache` (already mounted read-only by AWF) and publishes +//! `##vso[task.prependpath]` entries that AWF merges via `$GITHUB_PATH`. +//! +//! ## Difference from Python / Node runtimes +//! +//! Unlike `pip`/`npm`, NuGet has no first-class environment-variable +//! equivalent for selecting a package source — the convention is a +//! `nuget.config` file in the workspace. This runtime therefore configures +//! feeds via `nuget.config` (either generated or checked in) rather than +//! through `agent_env_vars()`. AWF preserves workspace files (it only +//! overlays things in `$HOME` such as `~/.npmrc`), so a checked-in or +//! generated `nuget.config` is fully usable inside the agent sandbox. + +pub mod extension; + +pub use extension::DotnetExtension; + +use ado_aw_derive::SanitizeConfig; +use serde::Deserialize; + +use crate::sanitize::SanitizeConfig as SanitizeConfigTrait; + +/// .NET runtime configuration — accepts both `true` and object formats. +/// +/// Examples: +/// ```yaml +/// # Simple enablement (installs default .NET SDK) +/// runtimes: +/// dotnet: true +/// +/// # With options (pin version, configure feed) +/// runtimes: +/// dotnet: +/// version: "8.0.x" +/// feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json" +/// ``` +#[derive(Debug, Deserialize, Clone)] +#[serde(untagged)] +pub enum DotnetRuntimeConfig { + /// Simple boolean enablement + Enabled(bool), + /// Full configuration with options + WithOptions(DotnetOptions), +} + +impl DotnetRuntimeConfig { + /// Whether .NET is enabled. + pub fn is_enabled(&self) -> bool { + match self { + DotnetRuntimeConfig::Enabled(enabled) => *enabled, + DotnetRuntimeConfig::WithOptions(_) => true, + } + } + + /// Get the .NET SDK version (None = use ADO default). + pub fn version(&self) -> Option<&str> { + match self { + DotnetRuntimeConfig::Enabled(_) => None, + DotnetRuntimeConfig::WithOptions(opts) => opts.version.as_deref(), + } + } + + /// Get the NuGet source URL (None = use public nuget.org / repo defaults). + pub fn feed_url(&self) -> Option<&str> { + match self { + DotnetRuntimeConfig::Enabled(_) => None, + DotnetRuntimeConfig::WithOptions(opts) => opts.feed_url.as_deref(), + } + } + + /// Get the path to a checked-in `nuget.config` (None = not set). + pub fn config(&self) -> Option<&str> { + match self { + DotnetRuntimeConfig::Enabled(_) => None, + DotnetRuntimeConfig::WithOptions(opts) => opts.config.as_deref(), + } + } +} + +impl SanitizeConfigTrait for DotnetRuntimeConfig { + fn sanitize_config_fields(&mut self) { + match self { + DotnetRuntimeConfig::Enabled(_) => {} + DotnetRuntimeConfig::WithOptions(opts) => opts.sanitize_config_fields(), + } + } +} + +/// .NET runtime options. +#[derive(Debug, Deserialize, Clone, Default, SanitizeConfig)] +pub struct DotnetOptions { + /// .NET SDK version to install (e.g., "8.0.x", "9.0.x"). + /// Passed to `UseDotNet@2` `version` with `packageType: 'sdk'`. + #[serde(default)] + pub version: Option, + + /// Internal NuGet feed URL (typically the v3 `index.json` of an Azure + /// Artifacts feed). When set, the compiler emits a step that creates a + /// minimal `nuget.config` referencing this source (only if the repo + /// doesn't already have one) and then runs `NuGetAuthenticate@1` so the + /// ADO build service identity can authenticate to the feed. + /// + /// Unlike Python (`PIP_INDEX_URL`) and Node (`NPM_CONFIG_REGISTRY`), + /// no env var is injected — NuGet does not have a first-class env-var + /// equivalent for selecting a package source. + #[serde(default, rename = "feed-url")] + pub feed_url: Option, + + /// Path to a checked-in `nuget.config` file in the repo. When set, the + /// compiler runs `NuGetAuthenticate@1` against the workspace (which + /// auto-discovers `nuget.config` files); the file is fully functional + /// inside the AWF agent environment because AWF preserves workspace + /// files. Mutually exclusive with `feed-url`. + #[serde(default)] + pub config: Option, +} + +/// Bash commands that the .NET runtime adds to the allow-list. +pub const DOTNET_BASH_COMMANDS: &[&str] = &["dotnet"]; + +/// Generate the `UseDotNet@2` pipeline step. +pub fn generate_dotnet_install(config: &DotnetRuntimeConfig) -> String { + let version = config.version().unwrap_or("8.0.x"); + format!( + "\ +- task: UseDotNet@2 + inputs: + packageType: 'sdk' + version: '{version}' + displayName: 'Install .NET SDK {version}'" + ) +} + +/// Generate the `NuGetAuthenticate@1` pipeline step. +/// +/// Emitted when `feed-url:` or `config:` is set, authenticating the ADO +/// build service identity against any Azure Artifacts feeds referenced by +/// `nuget.config` files in the workspace. `NuGetAuthenticate@1` auto- +/// discovers `nuget.config` files — no `workingFile:` input is required, +/// unlike `npmAuthenticate@0`. +pub fn generate_nuget_authenticate() -> String { + "\ +- task: NuGetAuthenticate@1 + displayName: 'Authenticate NuGet (build service identity)'" + .to_string() +} + +/// Generate a step that ensures a `nuget.config` exists before +/// `NuGetAuthenticate@1`. +/// +/// `NuGetAuthenticate@1` is a no-op without a `nuget.config` to authenticate +/// against. This step writes a minimal `nuget.config` (with the configured +/// feed URL added as a package source) only when one doesn't already exist +/// at the repo root, preserving any repo-checked-in `nuget.config`. +pub fn generate_ensure_nuget_config(config: &DotnetRuntimeConfig) -> String { + let feed_url = config.feed_url().unwrap_or("https://api.nuget.org/v3/index.json"); + + format!( + "\ +- bash: |\n\ + if [ ! -f nuget.config ] && [ ! -f NuGet.config ] && [ ! -f NuGet.Config ]; then\n\ + cat > nuget.config <<'EOF'\n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n\ + \n\ + EOF\n\ + echo \"Created nuget.config with source={feed_url}\"\n\ + else\n\ + echo 'nuget.config already exists, skipping creation'\n\ + fi\n\ + displayName: 'Ensure nuget.config exists'" + ) +} diff --git a/src/runtimes/mod.rs b/src/runtimes/mod.rs index 59099131..17b4e582 100644 --- a/src/runtimes/mod.rs +++ b/src/runtimes/mod.rs @@ -1,7 +1,7 @@ //! Runtime implementations for the ado-aw compiler. //! //! Runtimes are language toolchains installed before the agent runs -//! (e.g., Lean 4, and in future: Python, Node, Go, etc.). +//! (e.g., Lean 4, Python, Node.js, .NET, and in future: Go, etc.). //! //! Unlike `tools/` (agent capabilities like edit, bash, memory) or //! `safeoutputs/` (MCP tools that serialize to NDJSON), runtimes are @@ -9,6 +9,7 @@ //! //! Aligned with gh-aw's `runtimes:` front matter field. +pub mod dotnet; pub mod lean; pub mod node; pub mod python; From d85907b718cd6e6ba20784d9f64143b44fb08493 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 11:10:14 +0000 Subject: [PATCH 2/4] docs(runtimes): clarify nuget.config case-variation existence check Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/efab2e0c-8fcd-44b0-8af3-226fa6e8e2cc Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/runtimes/dotnet/mod.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/runtimes/dotnet/mod.rs b/src/runtimes/dotnet/mod.rs index 783a646b..f064b554 100644 --- a/src/runtimes/dotnet/mod.rs +++ b/src/runtimes/dotnet/mod.rs @@ -160,6 +160,11 @@ pub fn generate_nuget_authenticate() -> String { /// against. This step writes a minimal `nuget.config` (with the configured /// feed URL added as a package source) only when one doesn't already exist /// at the repo root, preserving any repo-checked-in `nuget.config`. +/// +/// The existence check covers the three case variations NuGet itself +/// recognises on case-sensitive filesystems (`nuget.config`, `NuGet.config`, +/// `NuGet.Config`); the file is always created with the lowercase form, +/// matching the cross-platform convention. pub fn generate_ensure_nuget_config(config: &DotnetRuntimeConfig) -> String { let feed_url = config.feed_url().unwrap_or("https://api.nuget.org/v3/index.json"); From 95888dffd630bc9d66023455a598b8b6366a5a6a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 11:25:11 +0000 Subject: [PATCH 3/4] feat(runtimes/dotnet): support global.json via useGlobalJson, error on conflict Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/2deb298d-0df3-45e4-92c4-e58d71e2990d Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/front-matter.md | 2 +- docs/runtimes.md | 21 +++++++- src/compile/extensions/mod.rs | 22 +++++++- src/compile/extensions/tests.rs | 89 ++++++++++++++++++++++++++++++++ src/runtimes/dotnet/extension.rs | 28 ++++++++-- src/runtimes/dotnet/mod.rs | 40 +++++++++++++- 6 files changed, 195 insertions(+), 7 deletions(-) diff --git a/docs/front-matter.md b/docs/front-matter.md index 081b8d77..cdcf9eb0 100644 --- a/docs/front-matter.md +++ b/docs/front-matter.md @@ -55,7 +55,7 @@ runtimes: # optional runtime configuration (language enviro # feed-url: "https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/" # dotnet: true # .NET runtime — auto-installs via UseDotNet@2 (see docs/runtimes.md) # dotnet: # Alternative object format (pin version, configure internal feed via nuget.config) - # version: "8.0.x" + # version: "8.0.x" # use "global.json" to pin from the repo's global.json # feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json" # env: # RESERVED: workflow-level environment variables (not yet implemented) # CUSTOM_VAR: "value" diff --git a/docs/runtimes.md b/docs/runtimes.md index 617fab26..e396ed5c 100644 --- a/docs/runtimes.md +++ b/docs/runtimes.md @@ -122,16 +122,35 @@ runtimes: dotnet: version: "8.0.x" config: "nuget.config" + +# Pin SDK from the repo's global.json (UseDotNet@2 useGlobalJson mode) +runtimes: + dotnet: + version: "global.json" ``` **Fields:** | Field | Type | Description | |-------|------|-------------| -| `version` | string | .NET SDK version to install (e.g., `"8.0.x"`, `"9.0.x"`). Passed to `UseDotNet@2` `version` with `packageType: 'sdk'`. Defaults to `"8.0.x"`. | +| `version` | string | .NET SDK version to install (e.g., `"8.0.x"`, `"9.0.x"`). Passed to `UseDotNet@2` `version` with `packageType: 'sdk'`. Defaults to `"8.0.x"`. The special value `"global.json"` (case-insensitive) emits `useGlobalJson: true` instead, which discovers and installs every SDK referenced by `global.json` files in the workspace. | | `feed-url` | string | Internal NuGet feed URL (typically the v3 `index.json` of an Azure Artifacts feed). When set, the compiler creates a minimal `nuget.config` if none exists and runs `NuGetAuthenticate@1`. | | `config` | string | Path to a checked-in `nuget.config` in the repo. When set, the compiler runs `NuGetAuthenticate@1` (which auto-discovers `nuget.config` files in the workspace). Mutually exclusive with `feed-url`. | +**`global.json` precedence.** A `global.json` file in the repo is the canonical +way to pin the .NET SDK. The compiler enforces a single source of truth: + +- If a `global.json` exists at the agent's compile directory **and** the front + matter sets a concrete `version`, compilation **errors out**. Either remove + the front-matter version or set it to the literal string `"global.json"` to + opt into `UseDotNet@2`'s `useGlobalJson: true` mode. +- If `version: "global.json"` is set, the compiler emits + `useGlobalJson: true` (no explicit `version:` input) so the install task + walks the workspace for `global.json` files itself. +- If no `version` is set and a `global.json` exists, the compiler does not + auto-promote — the default `"8.0.x"` is used. Opt in explicitly with the + sentinel. + When enabled, the compiler: - Injects `UseDotNet@2` into `{{ prepare_steps }}` (runs before AWF) - If `feed-url` is set, injects an ensure-`nuget.config` step (writes a minimal `nuget.config` referencing the feed only when one doesn't already exist) and `NuGetAuthenticate@1` diff --git a/src/compile/extensions/mod.rs b/src/compile/extensions/mod.rs index e7dbf2d5..7853fa87 100644 --- a/src/compile/extensions/mod.rs +++ b/src/compile/extensions/mod.rs @@ -104,6 +104,11 @@ pub struct CompileContext<'a> { pub ado_context: Option, /// Resolved engine based on the front matter `engine:` field. pub engine: Engine, + /// Directory containing the agent markdown being compiled (i.e. the + /// repo-relative dir against which paths like `global.json` / + /// `nuget.config` should be resolved). `None` for unit-test contexts + /// where no on-disk repo exists. + pub compile_dir: Option<&'a Path>, } impl<'a> CompileContext<'a> { @@ -112,7 +117,7 @@ impl<'a> CompileContext<'a> { /// Resolves the engine implementation from front matter and infers ADO /// context from the git remote in `compile_dir`. Returns an error if /// the engine identifier is unsupported. - pub async fn new(front_matter: &'a FrontMatter, compile_dir: &Path) -> Result { + pub async fn new(front_matter: &'a FrontMatter, compile_dir: &'a Path) -> Result { let engine = engine::get_engine(front_matter.engine.engine_id())?; let ado_context = Self::infer_ado_context(compile_dir).await; Ok(Self { @@ -120,6 +125,7 @@ impl<'a> CompileContext<'a> { front_matter, ado_context, engine, + compile_dir: Some(compile_dir), }) } @@ -168,6 +174,7 @@ impl<'a> CompileContext<'a> { front_matter, ado_context: None, engine: crate::engine::Engine::Copilot, + compile_dir: None, } } @@ -183,6 +190,19 @@ impl<'a> CompileContext<'a> { repo_name: "test-repo".to_string(), }), engine: crate::engine::Engine::Copilot, + compile_dir: None, + } + } + + /// Create a context for tests with a specific compile directory. + #[cfg(test)] + pub fn for_test_with_compile_dir(front_matter: &'a FrontMatter, compile_dir: &'a Path) -> Self { + Self { + agent_name: &front_matter.name, + front_matter, + ado_context: None, + engine: crate::engine::Engine::Copilot, + compile_dir: Some(compile_dir), } } } diff --git a/src/compile/extensions/tests.rs b/src/compile/extensions/tests.rs index 32da867f..a0901468 100644 --- a/src/compile/extensions/tests.rs +++ b/src/compile/extensions/tests.rs @@ -706,6 +706,95 @@ fn test_dotnet_invalid_feed_url_rejected() { assert!(ext.validate(&ctx).is_err()); } +#[test] +fn test_dotnet_global_json_sentinel_emits_use_global_json() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n version: 'global.json'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + assert!(dotnet.use_global_json()); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let steps = ext.prepare_steps(); + assert!(steps[0].contains("useGlobalJson: true")); + assert!(!steps[0].contains("version:"), "explicit version must be omitted in global.json mode"); + assert!(steps[0].contains("from global.json")); +} + +#[test] +fn test_dotnet_global_json_sentinel_case_insensitive() { + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n version: 'Global.JSON'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + assert!(dotnet.use_global_json()); +} + +#[test] +fn test_dotnet_global_json_sentinel_skips_injection_check() { + // The sentinel is a literal keyword, not a version — it must not be + // rejected by reject_pipeline_injection. + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n version: 'global.json'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let ctx = ctx_from(&fm); + assert!(ext.validate(&ctx).is_ok()); +} + +#[test] +fn test_dotnet_version_with_global_json_present_errors() { + use std::io::Write; + let tmp = tempfile::tempdir().unwrap(); + let mut f = std::fs::File::create(tmp.path().join("global.json")).unwrap(); + writeln!(f, r#"{{ "sdk": {{ "version": "8.0.100" }} }}"#).unwrap(); + + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n version: '9.0.x'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let ctx = CompileContext::for_test_with_compile_dir(&fm, tmp.path()); + let result = ext.validate(&ctx); + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("global.json"), "error must mention global.json: {msg}"); + assert!(msg.contains("useGlobalJson") || msg.contains("'global.json'"), "error must hint at the sentinel: {msg}"); +} + +#[test] +fn test_dotnet_global_json_sentinel_with_global_json_present_ok() { + // Using the sentinel alongside an on-disk global.json is the intended + // happy path — no error. + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("global.json"), r#"{"sdk":{"version":"8.0.100"}}"#).unwrap(); + + let (fm, _) = parse_markdown( + "---\nname: test\ndescription: test\nruntimes:\n dotnet:\n version: 'global.json'\n---\n", + ).unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let ctx = CompileContext::for_test_with_compile_dir(&fm, tmp.path()); + assert!(ext.validate(&ctx).is_ok()); +} + +#[test] +fn test_dotnet_no_version_with_global_json_present_ok() { + // Without an explicit version, no conflict — the user simply gets the + // compiler default. This intentionally does not auto-promote to + // useGlobalJson; users opt in with the sentinel. + let tmp = tempfile::tempdir().unwrap(); + std::fs::write(tmp.path().join("global.json"), r#"{"sdk":{"version":"8.0.100"}}"#).unwrap(); + + let (fm, _) = + parse_markdown("---\nname: test\ndescription: test\nruntimes:\n dotnet: true\n---\n") + .unwrap(); + let dotnet = fm.runtimes.as_ref().unwrap().dotnet.as_ref().unwrap(); + let ext = crate::runtimes::dotnet::DotnetExtension::new(dotnet.clone()); + let ctx = CompileContext::for_test_with_compile_dir(&fm, tmp.path()); + assert!(ext.validate(&ctx).is_ok()); +} + // ── Multiple runtimes ────────────────────────────────────────── #[test] diff --git a/src/runtimes/dotnet/extension.rs b/src/runtimes/dotnet/extension.rs index 3ffbd406..29273537 100644 --- a/src/runtimes/dotnet/extension.rs +++ b/src/runtimes/dotnet/extension.rs @@ -3,7 +3,7 @@ use crate::compile::extensions::{CompileContext, CompilerExtension, ExtensionPhase}; use crate::validate; use super::{ - DOTNET_BASH_COMMANDS, DotnetRuntimeConfig, generate_dotnet_install, + DOTNET_BASH_COMMANDS, DotnetRuntimeConfig, GLOBAL_JSON_SENTINEL, generate_dotnet_install, generate_ensure_nuget_config, generate_nuget_authenticate, }; use anyhow::Result; @@ -108,9 +108,31 @@ in the repository.\n" validate::validate_feed_url(feed_url, "runtimes.dotnet.feed-url")?; } - // Validate version string + // Validate version string. Skip the injection check for the + // `global.json` sentinel — it's a literal keyword, not a version. if let Some(version) = self.config.version() { - validate::reject_pipeline_injection(version, "runtimes.dotnet.version")?; + if !version.eq_ignore_ascii_case(GLOBAL_JSON_SENTINEL) { + validate::reject_pipeline_injection(version, "runtimes.dotnet.version")?; + } + } + + // global.json conflict detection: if the agent's compile directory + // contains a `global.json`, the SDK version is already pinned by + // that file and the front matter must not also specify an explicit + // version. Either drop the version or set `version: "global.json"`. + if let Some(compile_dir) = ctx.compile_dir { + if compile_dir.join("global.json").exists() + && self.config.version().is_some() + && !self.config.use_global_json() + { + anyhow::bail!( + "runtimes.dotnet.version: a 'global.json' file exists at '{}', \ + which already pins the .NET SDK version. Either remove \ + 'runtimes.dotnet.version' or set it to the literal string \ + 'global.json' to use UseDotNet@2's useGlobalJson mode.", + compile_dir.display() + ); + } } // Validate config path (just defend against pipeline injection — the diff --git a/src/runtimes/dotnet/mod.rs b/src/runtimes/dotnet/mod.rs index f064b554..175108b6 100644 --- a/src/runtimes/dotnet/mod.rs +++ b/src/runtimes/dotnet/mod.rs @@ -51,6 +51,11 @@ pub enum DotnetRuntimeConfig { WithOptions(DotnetOptions), } +/// The sentinel value users can set in `runtimes.dotnet.version` to opt +/// into `UseDotNet@2`'s `useGlobalJson: true` mode, which installs every +/// SDK referenced by `global.json` files in the workspace. +pub const GLOBAL_JSON_SENTINEL: &str = "global.json"; + impl DotnetRuntimeConfig { /// Whether .NET is enabled. pub fn is_enabled(&self) -> bool { @@ -68,6 +73,13 @@ impl DotnetRuntimeConfig { } } + /// Whether the user opted into `useGlobalJson: true` by setting + /// `version: "global.json"` (case-insensitive). + pub fn use_global_json(&self) -> bool { + self.version() + .is_some_and(|v| v.eq_ignore_ascii_case(GLOBAL_JSON_SENTINEL)) + } + /// Get the NuGet source URL (None = use public nuget.org / repo defaults). pub fn feed_url(&self) -> Option<&str> { match self { @@ -97,8 +109,18 @@ impl SanitizeConfigTrait for DotnetRuntimeConfig { /// .NET runtime options. #[derive(Debug, Deserialize, Clone, Default, SanitizeConfig)] pub struct DotnetOptions { - /// .NET SDK version to install (e.g., "8.0.x", "9.0.x"). + /// .NET SDK version to install (e.g., `"8.0.x"`, `"9.0.x"`). /// Passed to `UseDotNet@2` `version` with `packageType: 'sdk'`. + /// + /// The special value `"global.json"` (case-insensitive) opts into + /// `UseDotNet@2`'s `useGlobalJson: true` mode, which discovers and + /// installs every SDK version referenced by `global.json` files in + /// the workspace. When this sentinel is used the explicit `version` + /// input is omitted from the generated step. + /// + /// If a `global.json` exists at the agent's compile directory and a + /// concrete version is specified here, the compiler errors out — pick + /// one source of truth. #[serde(default)] pub version: Option, @@ -127,7 +149,23 @@ pub struct DotnetOptions { pub const DOTNET_BASH_COMMANDS: &[&str] = &["dotnet"]; /// Generate the `UseDotNet@2` pipeline step. +/// +/// Emits one of three shapes: +/// - `version: "global.json"` → `useGlobalJson: true` (discovers SDK +/// versions from `global.json` files in the workspace). +/// - explicit `version: "8.0.x"` → `version: '8.0.x'`. +/// - no version → `version: '8.0.x'` (compiler default). pub fn generate_dotnet_install(config: &DotnetRuntimeConfig) -> String { + if config.use_global_json() { + return "\ +- task: UseDotNet@2 + inputs: + packageType: 'sdk' + useGlobalJson: true + displayName: 'Install .NET SDK (from global.json)'" + .to_string(); + } + let version = config.version().unwrap_or("8.0.x"); format!( "\ From 91cbe6cf19dba76f1559d34e45e9af5a505685a6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 13:02:26 +0000 Subject: [PATCH 4/4] fix(runtimes/dotnet): single-quote echo + clarify config validate comment Agent-Logs-Url: https://github.com/githubnext/ado-aw/sessions/f0dd19ef-4d6c-4f0d-83f0-97947d886b69 Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/runtimes/dotnet/extension.rs | 7 +++++-- src/runtimes/dotnet/mod.rs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/runtimes/dotnet/extension.rs b/src/runtimes/dotnet/extension.rs index 29273537..35888181 100644 --- a/src/runtimes/dotnet/extension.rs +++ b/src/runtimes/dotnet/extension.rs @@ -135,8 +135,11 @@ in the repository.\n" } } - // Validate config path (just defend against pipeline injection — the - // path itself is user-supplied and ends up in displayName/log output) + // Validate config path (defend against pipeline injection). The value + // is not currently embedded in any generated YAML — `NuGetAuthenticate@1` + // auto-discovers `nuget.config` — but we still validate it as a + // defence-in-depth measure in case it is surfaced in displayName or + // logs in the future. if let Some(config) = self.config.config() { validate::reject_pipeline_injection(config, "runtimes.dotnet.config")?; } diff --git a/src/runtimes/dotnet/mod.rs b/src/runtimes/dotnet/mod.rs index 175108b6..04d58ec0 100644 --- a/src/runtimes/dotnet/mod.rs +++ b/src/runtimes/dotnet/mod.rs @@ -219,7 +219,7 @@ pub fn generate_ensure_nuget_config(config: &DotnetRuntimeConfig) -> String { \n\ \n\ EOF\n\ - echo \"Created nuget.config with source={feed_url}\"\n\ + echo 'Created nuget.config with source={feed_url}'\n\ else\n\ echo 'nuget.config already exists, skipping creation'\n\ fi\n\