Skip to content

Commit bdfb21c

Browse files
feat(runtimes): add dotnet runtime extension (#435)
* 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(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> * 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> * 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> --------- 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 0dc67b7 commit bdfb21c

10 files changed

Lines changed: 714 additions & 4 deletions

File tree

docs/front-matter.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ runtimes: # optional runtime configuration (language enviro
5353
# node: # Alternative object format (pin version, configure internal feed)
5454
# version: "22.x"
5555
# feed-url: "https://pkgs.dev.azure.com/ORG/PROJECT/_packaging/FEED/npm/registry/"
56+
# dotnet: true # .NET runtime — auto-installs via UseDotNet@2 (see docs/runtimes.md)
57+
# dotnet: # Alternative object format (pin version, configure internal feed via nuget.config)
58+
# version: "8.0.x" # use "global.json" to pin from the repo's global.json
59+
# feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"
5660
# env: # RESERVED: workflow-level environment variables (not yet implemented)
5761
# CUSTOM_VAR: "value"
5862
mcp-servers:

docs/runtimes.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,68 @@ When enabled, the compiler:
103103
- 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`
104104
- Note: AWF overlays `~/.npmrc` with `/dev/null` for credential security — the `NPM_CONFIG_REGISTRY` env var approach avoids conflicting with this overlay
105105

106+
### .NET (`dotnet:`)
107+
.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`.
108+
109+
```yaml
110+
# Simple enablement (installs default .NET SDK, currently 8.0.x)
111+
runtimes:
112+
dotnet: true
113+
114+
# With options (pin version, configure internal feed)
115+
runtimes:
116+
dotnet:
117+
version: "8.0.x"
118+
feed-url: "https://pkgs.dev.azure.com/myorg/_packaging/myfeed/nuget/v3/index.json"
119+
120+
# Or point at a checked-in nuget.config
121+
runtimes:
122+
dotnet:
123+
version: "8.0.x"
124+
config: "nuget.config"
125+
126+
# Pin SDK from the repo's global.json (UseDotNet@2 useGlobalJson mode)
127+
runtimes:
128+
dotnet:
129+
version: "global.json"
130+
```
131+
132+
**Fields:**
133+
134+
| Field | Type | Description |
135+
|-------|------|-------------|
136+
| `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. |
137+
| `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`. |
138+
| `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`. |
139+
140+
**`global.json` precedence.** A `global.json` file in the repo is the canonical
141+
way to pin the .NET SDK. The compiler enforces a single source of truth:
142+
143+
- If a `global.json` exists at the agent's compile directory **and** the front
144+
matter sets a concrete `version`, compilation **errors out**. Either remove
145+
the front-matter version or set it to the literal string `"global.json"` to
146+
opt into `UseDotNet@2`'s `useGlobalJson: true` mode.
147+
- If `version: "global.json"` is set, the compiler emits
148+
`useGlobalJson: true` (no explicit `version:` input) so the install task
149+
walks the workspace for `global.json` files itself.
150+
- If no `version` is set and a `global.json` exists, the compiler does not
151+
auto-promote — the default `"8.0.x"` is used. Opt in explicitly with the
152+
sentinel.
153+
154+
When enabled, the compiler:
155+
- Injects `UseDotNet@2` into `{{ prepare_steps }}` (runs before AWF)
156+
- 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`
157+
- 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
158+
- Auto-adds `dotnet` to the bash command allow-list
159+
- Adds .NET ecosystem domains to the network allowlist (nuget.org, dotnet.microsoft.com, pkgs.dev.azure.com, etc.)
160+
- Appends a prompt supplement informing the agent about .NET availability
161+
- 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`
162+
163+
**Differences from the Python and Node runtimes** (called out for clarity, since this runtime intentionally diverges):
164+
- **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.
165+
- **`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.
166+
- **`NuGetAuthenticate@1` requires no `workingFile:` input.** It auto-discovers `nuget.config` files anywhere in the workspace, unlike `npmAuthenticate@0` which needs an explicit path.
167+
106168
### Combining Runtimes
107169

108170
Multiple runtimes can be enabled simultaneously:
@@ -113,6 +175,8 @@ runtimes:
113175
version: "3.12"
114176
node:
115177
version: "22.x"
178+
dotnet:
179+
version: "8.0.x"
116180
lean: true
117181
```
118182

src/compile/common.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2581,6 +2581,7 @@ mod tests {
25812581
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
25822582
python: None,
25832583
node: None,
2584+
dotnet: None,
25842585
});
25852586
let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap();
25862587
assert!(params.contains("shell(lean)"), "lean command should be allowed");
@@ -2603,6 +2604,7 @@ mod tests {
26032604
lean: Some(crate::runtimes::lean::LeanRuntimeConfig::Enabled(true)),
26042605
python: None,
26052606
node: None,
2607+
dotnet: None,
26062608
});
26072609
let params = CompileContext::for_test(&fm).engine.args(&fm, &crate::compile::extensions::collect_extensions(&fm)).unwrap();
26082610
assert!(params.contains("--allow-all-tools"), "wildcard should use --allow-all-tools");

src/compile/extensions/mod.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ pub struct CompileContext<'a> {
104104
pub ado_context: Option<AdoContext>,
105105
/// Resolved engine based on the front matter `engine:` field.
106106
pub engine: Engine,
107+
/// Directory containing the agent markdown being compiled (i.e. the
108+
/// repo-relative dir against which paths like `global.json` /
109+
/// `nuget.config` should be resolved). `None` for unit-test contexts
110+
/// where no on-disk repo exists.
111+
pub compile_dir: Option<&'a Path>,
107112
}
108113

109114
impl<'a> CompileContext<'a> {
@@ -112,14 +117,15 @@ impl<'a> CompileContext<'a> {
112117
/// Resolves the engine implementation from front matter and infers ADO
113118
/// context from the git remote in `compile_dir`. Returns an error if
114119
/// the engine identifier is unsupported.
115-
pub async fn new(front_matter: &'a FrontMatter, compile_dir: &Path) -> Result<Self> {
120+
pub async fn new(front_matter: &'a FrontMatter, compile_dir: &'a Path) -> Result<Self> {
116121
let engine = engine::get_engine(front_matter.engine.engine_id())?;
117122
let ado_context = Self::infer_ado_context(compile_dir).await;
118123
Ok(Self {
119124
agent_name: &front_matter.name,
120125
front_matter,
121126
ado_context,
122127
engine,
128+
compile_dir: Some(compile_dir),
123129
})
124130
}
125131

@@ -168,6 +174,7 @@ impl<'a> CompileContext<'a> {
168174
front_matter,
169175
ado_context: None,
170176
engine: crate::engine::Engine::Copilot,
177+
compile_dir: None,
171178
}
172179
}
173180

@@ -183,6 +190,19 @@ impl<'a> CompileContext<'a> {
183190
repo_name: "test-repo".to_string(),
184191
}),
185192
engine: crate::engine::Engine::Copilot,
193+
compile_dir: None,
194+
}
195+
}
196+
197+
/// Create a context for tests with a specific compile directory.
198+
#[cfg(test)]
199+
pub fn for_test_with_compile_dir(front_matter: &'a FrontMatter, compile_dir: &'a Path) -> Self {
200+
Self {
201+
agent_name: &front_matter.name,
202+
front_matter,
203+
ado_context: None,
204+
engine: crate::engine::Engine::Copilot,
205+
compile_dir: Some(compile_dir),
186206
}
187207
}
188208
}
@@ -560,6 +580,7 @@ pub(crate) mod trigger_filters;
560580
pub use crate::tools::azure_devops::AzureDevOpsExtension;
561581
pub use crate::tools::cache_memory::CacheMemoryExtension;
562582
pub use github::GitHubExtension;
583+
pub use crate::runtimes::dotnet::DotnetExtension;
563584
pub use crate::runtimes::lean::LeanExtension;
564585
pub use crate::runtimes::node::NodeExtension;
565586
pub use crate::runtimes::python::PythonExtension;
@@ -577,6 +598,7 @@ extension_enum! {
577598
Lean(LeanExtension),
578599
Python(PythonExtension),
579600
Node(NodeExtension),
601+
Dotnet(DotnetExtension),
580602
AzureDevOps(AzureDevOpsExtension),
581603
CacheMemory(CacheMemoryExtension),
582604
TriggerFilters(TriggerFiltersExtension),
@@ -621,6 +643,11 @@ pub fn collect_extensions(front_matter: &FrontMatter) -> Vec<Extension> {
621643
extensions.push(Extension::Node(NodeExtension::new(node.clone())));
622644
}
623645
}
646+
if let Some(dotnet) = front_matter.runtimes.as_ref().and_then(|r| r.dotnet.as_ref()) {
647+
if dotnet.is_enabled() {
648+
extensions.push(Extension::Dotnet(DotnetExtension::new(dotnet.clone())));
649+
}
650+
}
624651

625652
// ── First-party tools (ExtensionPhase::Tool) ──
626653
if let Some(tools) = front_matter.tools.as_ref() {

0 commit comments

Comments
 (0)