From 75371f2f8983dc2b91911f5473d56f9dee92b934 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Thu, 10 Sep 2026 20:27:14 -0600
Subject: [PATCH 01/59] Update dependency dotnet-symbol to v10.0.745401 (565)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 79a9fb762..9575f99e4 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -38,7 +38,7 @@
"rollForward": false
},
"dotnet-symbol": {
- "version": "10.0.731102",
+ "version": "10.0.745401",
"commands": [
"dotnet-symbol"
],
From 0fe44f9759bc1b317d9aa7f302b7381c4789e046 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 14 Sep 2026 09:18:01 -0600
Subject: [PATCH 02/59] Update dependency dotnet-coverage to v18.11.2 (566)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 9575f99e4..ac2120a2d 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -10,7 +10,7 @@
"rollForward": false
},
"dotnet-coverage": {
- "version": "18.11.0",
+ "version": "18.11.2",
"commands": [
"dotnet-coverage"
],
From a66bb7e95d8093fa349e23debdc32b9823bbfb71 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 14 Sep 2026 09:18:06 -0600
Subject: [PATCH 03/59] Update dependency
Microsoft.Testing.Extensions.CodeCoverage to 18.11.2 (567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bdf5637b8..2c5250e2a 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,7 +10,7 @@
-
+
From 30e9ed6e0c7341f8b66d8f17689a2e44156e7b8a Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 14 Sep 2026 09:18:11 -0600
Subject: [PATCH 04/59] Update mcr.microsoft.com/dotnet/sdk:10.0.401 Docker
digest to 2fa828c (568)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.devcontainer/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index c9af5a4bb..7fbd7f509 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,5 +1,5 @@
# Refer to https://hub.docker.com/_/microsoft-dotnet-sdk for available versions
-FROM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:4ea6fe75dd36706bb6d8c3c293d4c4315840f5d76ea28ac97def77e3ec487fa5
+FROM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:2fa828c68761b1b8c23d7662dc134421b9d3b59fe1425fdbc80804e390cdb24d
# Installing mono makes `dotnet test` work without errors even for net472.
# But installing it takes a long time, so it's excluded by default.
From 05c10db5ffbb89b053c245a4fdbc9e2438bcb7fb Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Mon, 14 Sep 2026 09:18:16 -0600
Subject: [PATCH 05/59] Update dependency xunit.v3.mtp-v2 to 4.0.1 (569)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2c5250e2a..fd61faae7 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -15,7 +15,7 @@
-
+
From c8e16228c60776f2c9dbc83dbb7ea48c77d3452a Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 15 Sep 2026 11:59:06 -0600
Subject: [PATCH 06/59] Migrate from `VSEngSS-MicroBuild2022-1ES` to
`VSEng-MicroBuildVSStable`
---
azure-pipelines/vs-insertion.yml | 2 +-
azure-pipelines/vs-validation.yml | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/azure-pipelines/vs-insertion.yml b/azure-pipelines/vs-insertion.yml
index 1b5313e6a..3f8a4d8c8 100644
--- a/azure-pipelines/vs-insertion.yml
+++ b/azure-pipelines/vs-insertion.yml
@@ -63,7 +63,7 @@ extends:
- job: insert
dependsOn: upload
displayName: VS insertion
- pool: VSEngSS-MicroBuild2022-1ES
+ pool: VSEng-MicroBuildVSStable
templateContext:
outputParentDirectory: $(Pipeline.Workspace)/CI
steps:
diff --git a/azure-pipelines/vs-validation.yml b/azure-pipelines/vs-validation.yml
index b4037eed6..70e9c45eb 100644
--- a/azure-pipelines/vs-validation.yml
+++ b/azure-pipelines/vs-validation.yml
@@ -101,7 +101,7 @@ extends:
- job: insertion
displayName: VS insertion
dependsOn: upload
- pool: VSEngSS-MicroBuild2022-1ES
+ pool: VSEng-MicroBuildVSStable
steps:
- checkout: self
clean: true
From 334f592be750b33564ea741b30b2fa76b4056e28 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Wed, 16 Sep 2026 11:44:45 -0600
Subject: [PATCH 07/59] Pin Microsoft.Testing.Platform
This is so that if xunit.v3.mtp-v2 wants an older version of it, we use the later version that's consistent with the MTP extension packages
---
Directory.Packages.props | 1 +
1 file changed, 1 insertion(+)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index fd61faae7..bf4055057 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -11,6 +11,7 @@
+
From 4a3a46fe6d0afb0ea7d80c11661de4c4306cbd41 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 17:52:39 +0000
Subject: [PATCH 08/59] Update Microsoft Testing Platform to 2.4.1 (571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index bf4055057..6f2124c10 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,7 @@
true
true
- 2.4.0
+ 2.4.1
From d691bea984b2684004d775ea2ffe77c7d9049fd8 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Wed, 16 Sep 2026 11:44:45 -0600
Subject: [PATCH 09/59] Pin Microsoft.Testing.Platform
This is so that if xunit.v3.mtp-v2 wants an older version of it, we use the later version that's consistent with the MTP extension packages
---
Directory.Packages.props | 1 +
1 file changed, 1 insertion(+)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index cc0261bf2..2cf21888c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,7 @@
+
From 6d1fa2256e4565ee4b6c0016ca1a3fa45a45e6e0 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Wed, 16 Sep 2026 17:52:39 +0000
Subject: [PATCH 10/59] Update Microsoft Testing Platform to 2.4.1 (571)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 2cf21888c..7fb901243 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,7 @@
true
true
- 2.4.0
+ 2.4.1
2.0.257
From 8a940a21814bdf2463b19c113088bda5ed15630e Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Wed, 16 Sep 2026 13:00:47 -0600
Subject: [PATCH 11/59] Downgrade `dotnet-symbol` to meet CFS quarantine
requirements
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index ac2120a2d..453666742 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -38,7 +38,7 @@
"rollForward": false
},
"dotnet-symbol": {
- "version": "10.0.745401",
+ "version": "10.0.731102",
"commands": [
"dotnet-symbol"
],
From 0868d932a56eeec779aa2124156111749c407aad Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 06:49:44 -0600
Subject: [PATCH 12/59] Update dependency docfx to v2.78.6 (#572)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index ac2120a2d..b65a25e69 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -24,7 +24,7 @@
"rollForward": false
},
"docfx": {
- "version": "2.78.5",
+ "version": "2.78.6",
"commands": [
"docfx"
],
From f27557cbeb247df8a06d81aa830c457717934e65 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:18:42 -0600
Subject: [PATCH 13/59] Update dependency ubuntu to v26 (574)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/release.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bbaf6be77..77ba156d1 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -13,7 +13,7 @@ run-name: ${{ github.ref_name }}
jobs:
release:
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
permissions:
actions: read # Required to download artifacts
contents: write # Upload artifacts to Release
From 7b5f5bfa226bc7c32aed79544eb54b2838668d4d Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 18 Sep 2026 07:18:58 -0600
Subject: [PATCH 14/59] Update dependency docfx to v2.80.1 (573)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index b65a25e69..aed56a8d5 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -24,7 +24,7 @@
"rollForward": false
},
"docfx": {
- "version": "2.78.6",
+ "version": "2.80.1",
"commands": [
"docfx"
],
From 8637b8889cb125bd944665743160b5cb17f75275 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 22 Sep 2026 08:23:08 -0600
Subject: [PATCH 15/59] Update mcr.microsoft.com/dotnet/sdk:10.0.401 Docker
digest to 35d4030 (575)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.devcontainer/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 7fbd7f509..e06f26ace 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,5 +1,5 @@
# Refer to https://hub.docker.com/_/microsoft-dotnet-sdk for available versions
-FROM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:2fa828c68761b1b8c23d7662dc134421b9d3b59fe1425fdbc80804e390cdb24d
+FROM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:35d40304542c8689331f8cab17c65926cdf48fe711e289321d71924b230a7d29
# Installing mono makes `dotnet test` work without errors even for net472.
# But installing it takes a long time, so it's excluded by default.
From d59c238d5246caa4d87230d6835451a843007c5a Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 22 Sep 2026 08:41:08 -0600
Subject: [PATCH 16/59] Avoid re-installing .NET SDK or runtimes
---
tools/Install-DotNetSdk.ps1 | 200 +++++++++++++++++++++++++-----------
1 file changed, 141 insertions(+), 59 deletions(-)
diff --git a/tools/Install-DotNetSdk.ps1 b/tools/Install-DotNetSdk.ps1
index 2621dda15..f4fcdaae6 100644
--- a/tools/Install-DotNetSdk.ps1
+++ b/tools/Install-DotNetSdk.ps1
@@ -198,6 +198,66 @@ Function Install-DotNet($Version, $Architecture, [ValidateSet('Sdk','Runtime','W
}
}
+Function Test-DotNetRuntimeInstalled(
+ $DotNetRoot,
+ [ValidateSet('Microsoft.NETCore.App','Microsoft.WindowsDesktop.App','Microsoft.AspNetCore.App')]
+ [string]$Runtime,
+ [Version]$Version
+) {
+ if (!$DotNetRoot) {
+ return $false
+ }
+
+ $runtimeRoot = Join-Path $DotNetRoot "shared\$Runtime"
+ if (!(Test-Path -LiteralPath $runtimeRoot)) {
+ return $false
+ }
+
+ foreach ($runtimeDirectory in Get-ChildItem -LiteralPath $runtimeRoot -Directory) {
+ $installedVersion = $null
+ if ([Version]::TryParse($runtimeDirectory.Name, [ref]$installedVersion) -and
+ $Version.Major -eq $installedVersion.Major -and
+ $Version.Minor -eq $installedVersion.Minor -and
+ (($Version.Build -eq -1) -or ($Version.Build -eq $installedVersion.Build)) -and
+ (($Version.Revision -eq -1) -or ($Version.Revision -eq $installedVersion.Revision))) {
+ return $true
+ }
+ }
+
+ return $false
+}
+
+Function Test-DotNetSdkInstalled($DotNetRoot, $Version, $Channel) {
+ if (!$DotNetRoot) {
+ return $false
+ }
+
+ $sdkRoot = Join-Path $DotNetRoot 'sdk'
+ if (!(Test-Path -LiteralPath $sdkRoot)) {
+ return $false
+ }
+
+ if ($Version) {
+ return Test-Path -LiteralPath (Join-Path $sdkRoot $Version) -PathType Container
+ }
+
+ $channelVersion = $null
+ if (![Version]::TryParse($Channel, [ref]$channelVersion)) {
+ return $false
+ }
+
+ foreach ($sdkDirectory in Get-ChildItem -LiteralPath $sdkRoot -Directory) {
+ $installedVersion = $null
+ if ([Version]::TryParse($sdkDirectory.Name, [ref]$installedVersion) -and
+ $channelVersion.Major -eq $installedVersion.Major -and
+ $channelVersion.Minor -eq $installedVersion.Minor) {
+ return $true
+ }
+ }
+
+ return $false
+}
+
$switches = @()
$envVars = @{
# For locally installed dotnet, skip first time experience which takes a long time
@@ -208,53 +268,67 @@ if ($InstallLocality -eq 'machine') {
if ($IsMacOS -or $IsLinux) {
$DotNetInstallDir = '/usr/share/dotnet'
} else {
+ $DotNetInstallDir = Join-Path $(if ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles }) 'dotnet'
+ $DotNetX86InstallDir = Join-Path ${env:ProgramFiles(x86)} 'dotnet'
$restartRequired = $false
$sdks |% {
if ($_.Version) { $version = $_.Version } else { $version = $_.Channel }
- if ($PSCmdlet.ShouldProcess(".NET SDK $version ($arch)", "Install")) {
+ if (!(Test-DotNetSdkInstalled -DotNetRoot $DotNetInstallDir -Version $_.Version -Channel $_.Channel) -and
+ $PSCmdlet.ShouldProcess(".NET SDK $version ($arch)", "Install")) {
Install-DotNet -Version $version -Architecture $arch
$restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
+ }
- if ($IncludeX86) {
- Install-DotNet -Version $version -Architecture x86
- $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
- }
+ if ($IncludeX86 -and
+ !(Test-DotNetSdkInstalled -DotNetRoot $DotNetX86InstallDir -Version $_.Version -Channel $_.Channel) -and
+ $PSCmdlet.ShouldProcess(".NET SDK $version (x86)", "Install")) {
+ Install-DotNet -Version $version -Architecture x86
+ $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
}
}
- $runtimeVersions | Sort-Object | Get-Unique |% {
- if ($PSCmdlet.ShouldProcess(".NET runtime $_", "Install")) {
+ $runtimeVersions | Sort-Object -Unique |% {
+ if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.NETCore.App -Version $_) -and
+ $PSCmdlet.ShouldProcess(".NET runtime $_ ($arch)", "Install")) {
Install-DotNet -Version $_ -sku Runtime -Architecture $arch
$restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
+ }
- if ($IncludeX86) {
- Install-DotNet -Version $_ -sku Runtime -Architecture x86
- $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
- }
+ if ($IncludeX86 -and
+ !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.NETCore.App -Version $_) -and
+ $PSCmdlet.ShouldProcess(".NET runtime $_ (x86)", "Install")) {
+ Install-DotNet -Version $_ -sku Runtime -Architecture x86
+ $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
}
}
- $windowsDesktopRuntimeVersions | Sort-Object | Get-Unique |% {
- if ($PSCmdlet.ShouldProcess(".NET Windows Desktop $_", "Install")) {
+ $windowsDesktopRuntimeVersions | Sort-Object -Unique |% {
+ if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.WindowsDesktop.App -Version $_) -and
+ $PSCmdlet.ShouldProcess(".NET Windows Desktop $_ ($arch)", "Install")) {
Install-DotNet -Version $_ -sku WindowsDesktop -Architecture $arch
$restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
+ }
- if ($IncludeX86) {
- Install-DotNet -Version $_ -sku WindowsDesktop -Architecture x86
- $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
- }
+ if ($IncludeX86 -and
+ !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.WindowsDesktop.App -Version $_) -and
+ $PSCmdlet.ShouldProcess(".NET Windows Desktop $_ (x86)", "Install")) {
+ Install-DotNet -Version $_ -sku WindowsDesktop -Architecture x86
+ $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
}
}
- $aspnetRuntimeVersions | Sort-Object | Get-Unique |% {
- if ($PSCmdlet.ShouldProcess("ASP.NET Core $_", "Install")) {
+ $aspnetRuntimeVersions | Sort-Object -Unique |% {
+ if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.AspNetCore.App -Version $_) -and
+ $PSCmdlet.ShouldProcess("ASP.NET Core $_ ($arch)", "Install")) {
Install-DotNet -Version $_ -sku AspNetCore -Architecture $arch
$restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
+ }
- if ($IncludeX86) {
- Install-DotNet -Version $_ -sku AspNetCore -Architecture x86
- $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
- }
+ if ($IncludeX86 -and
+ !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.AspNetCore.App -Version $_) -and
+ $PSCmdlet.ShouldProcess("ASP.NET Core $_ (x86)", "Install")) {
+ Install-DotNet -Version $_ -sku AspNetCore -Architecture x86
+ $restartRequired = $restartRequired -or ($LASTEXITCODE -eq 3010)
}
}
if ($restartRequired) {
@@ -317,19 +391,21 @@ $global:LASTEXITCODE = 0
$sdks |% {
if ($_.Version) { $parameters = '-Version', $_.Version } else { $parameters = '-Channel', $_.Channel }
- if ($PSCmdlet.ShouldProcess(".NET SDK $_ ($arch)", "Install")) {
- $anythingInstalled = $true
- Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture $arch -InstallDir $DotNetInstallDir $switches"
+ if (!(Test-DotNetSdkInstalled -DotNetRoot $DotNetInstallDir -Version $_.Version -Channel $_.Channel)) {
+ if ($PSCmdlet.ShouldProcess(".NET SDK $_ ($arch)", "Install")) {
+ $anythingInstalled = $true
+ Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture $arch -InstallDir $DotNetInstallDir $switches"
- if ($LASTEXITCODE -ne 0) {
- Write-Error ".NET SDK installation failure: $LASTEXITCODE"
- exit $LASTEXITCODE
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error ".NET SDK installation failure: $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
+ } else {
+ Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture $arch -InstallDir $DotNetInstallDir $switches -DryRun"
}
- } else {
- Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture $arch -InstallDir $DotNetInstallDir $switches -DryRun"
}
- if ($IncludeX86) {
+ if ($IncludeX86 -and !(Test-DotNetSdkInstalled -DotNetRoot $DotNetX86InstallDir -Version $_.Version -Channel $_.Channel)) {
if ($PSCmdlet.ShouldProcess(".NET x86 SDK $_", "Install")) {
$anythingInstalled = $true
Invoke-Expression -Command "$DotNetInstallScriptPathExpression $parameters -Architecture x86 -InstallDir $DotNetX86InstallDir $switches"
@@ -347,19 +423,21 @@ $sdks |% {
$dotnetRuntimeSwitches = $switches + '-Runtime','dotnet'
$runtimeVersions | Sort-Object -Unique |% {
- if ($PSCmdlet.ShouldProcess(".NET $Arch runtime $_", "Install")) {
- $anythingInstalled = $true
- Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $dotnetRuntimeSwitches"
+ if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.NETCore.App -Version $_)) {
+ if ($PSCmdlet.ShouldProcess(".NET $Arch runtime $_", "Install")) {
+ $anythingInstalled = $true
+ Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $dotnetRuntimeSwitches"
- if ($LASTEXITCODE -ne 0) {
- Write-Error ".NET SDK installation failure: $LASTEXITCODE"
- exit $LASTEXITCODE
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error ".NET SDK installation failure: $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
+ } else {
+ Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $dotnetRuntimeSwitches -DryRun"
}
- } else {
- Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $dotnetRuntimeSwitches -DryRun"
}
- if ($IncludeX86) {
+ if ($IncludeX86 -and !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.NETCore.App -Version $_)) {
if ($PSCmdlet.ShouldProcess(".NET x86 runtime $_", "Install")) {
$anythingInstalled = $true
Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture x86 -InstallDir $DotNetX86InstallDir $dotnetRuntimeSwitches"
@@ -377,19 +455,21 @@ $runtimeVersions | Sort-Object -Unique |% {
$windowsDesktopRuntimeSwitches = $switches + '-Runtime','windowsdesktop'
$windowsDesktopRuntimeVersions | Sort-Object -Unique |% {
- if ($PSCmdlet.ShouldProcess(".NET WindowsDesktop $arch runtime $_", "Install")) {
- $anythingInstalled = $true
- Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $windowsDesktopRuntimeSwitches"
+ if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.WindowsDesktop.App -Version $_)) {
+ if ($PSCmdlet.ShouldProcess(".NET WindowsDesktop $arch runtime $_", "Install")) {
+ $anythingInstalled = $true
+ Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $windowsDesktopRuntimeSwitches"
- if ($LASTEXITCODE -ne 0) {
- Write-Error ".NET SDK installation failure: $LASTEXITCODE"
- exit $LASTEXITCODE
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error ".NET SDK installation failure: $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
+ } else {
+ Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $windowsDesktopRuntimeSwitches -DryRun"
}
- } else {
- Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $windowsDesktopRuntimeSwitches -DryRun"
}
- if ($IncludeX86) {
+ if ($IncludeX86 -and !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.WindowsDesktop.App -Version $_)) {
if ($PSCmdlet.ShouldProcess(".NET WindowsDesktop x86 runtime $_", "Install")) {
$anythingInstalled = $true
Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture x86 -InstallDir $DotNetX86InstallDir $windowsDesktopRuntimeSwitches"
@@ -407,19 +487,21 @@ $windowsDesktopRuntimeVersions | Sort-Object -Unique |% {
$aspnetRuntimeSwitches = $switches + '-Runtime','aspnetcore'
$aspnetRuntimeVersions | Sort-Object -Unique |% {
- if ($PSCmdlet.ShouldProcess(".NET ASP.NET Core $arch runtime $_", "Install")) {
- $anythingInstalled = $true
- Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $aspnetRuntimeSwitches"
+ if (!(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetInstallDir -Runtime Microsoft.AspNetCore.App -Version $_)) {
+ if ($PSCmdlet.ShouldProcess(".NET ASP.NET Core $arch runtime $_", "Install")) {
+ $anythingInstalled = $true
+ Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $aspnetRuntimeSwitches"
- if ($LASTEXITCODE -ne 0) {
- Write-Error ".NET SDK installation failure: $LASTEXITCODE"
- exit $LASTEXITCODE
+ if ($LASTEXITCODE -ne 0) {
+ Write-Error ".NET SDK installation failure: $LASTEXITCODE"
+ exit $LASTEXITCODE
+ }
+ } else {
+ Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $aspnetRuntimeSwitches -DryRun"
}
- } else {
- Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture $arch -InstallDir $DotNetInstallDir $aspnetRuntimeSwitches -DryRun"
}
- if ($IncludeX86) {
+ if ($IncludeX86 -and !(Test-DotNetRuntimeInstalled -DotNetRoot $DotNetX86InstallDir -Runtime Microsoft.AspNetCore.App -Version $_)) {
if ($PSCmdlet.ShouldProcess(".NET ASP.NET Core x86 runtime $_", "Install")) {
$anythingInstalled = $true
Invoke-Expression -Command "$DotNetInstallScriptPathExpression -Channel $_ -Architecture x86 -InstallDir $DotNetX86InstallDir $aspnetRuntimeSwitches"
From 13d25ee93ad38e0f8c965a44f5c76545717e2d50 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 25 Sep 2026 09:00:59 -0600
Subject: [PATCH 17/59] Update dependency docfx to v2.81.0 (577)
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index aed56a8d5..160aded9d 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -24,7 +24,7 @@
"rollForward": false
},
"docfx": {
- "version": "2.80.1",
+ "version": "2.81.0",
"commands": [
"docfx"
],
From fc7087efd845885ae744a43faa382b07fd357352 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Mon, 28 Sep 2026 09:39:38 -0600
Subject: [PATCH 18/59] Revert "Downgrade `dotnet-symbol` to meet CFS
quarantine requirements"
This reverts commit 8a940a21814bdf2463b19c113088bda5ed15630e.
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 453666742..ac2120a2d 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -38,7 +38,7 @@
"rollForward": false
},
"dotnet-symbol": {
- "version": "10.0.731102",
+ "version": "10.0.745401",
"commands": [
"dotnet-symbol"
],
From f87898530c63a5ff6f5126cdd13a4873d87cb50a Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Mon, 28 Sep 2026 16:27:50 -0600
Subject: [PATCH 19/59] Update becheran/mlc action to v1.2.2
---
.github/workflows/docs_validate.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/docs_validate.yml b/.github/workflows/docs_validate.yml
index a2e22c5a7..7fe515706 100644
--- a/.github/workflows/docs_validate.yml
+++ b/.github/workflows/docs_validate.yml
@@ -17,7 +17,7 @@ jobs:
with:
fetch-depth: 0 # avoid shallow clone so nbgv can do its work.
- name: ๐ Markup Link Checker (mlc)
- uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0
+ uses: becheran/mlc@b0d0d353e04fcbcfd1d86716103d78a61e5b25dd # v1.2.2
with:
args: --do-not-warn-for-redirect-to https://learn.microsoft.com*,https://dotnet.microsoft.com/*,https://dev.azure.com/*,https://app.codecov.io/* -p docfx -i https://aka.ms/onboardsupport,https://aka.ms/spot,https://msrc.microsoft.com/*,https://www.microsoft.com/msrc*,https://microsoft.com/msrc*,https://www.npmjs.com/package/*,https://get.dot.net/
- name: โ Install prerequisites
From 95dd30b4ca796f74be3833140f136f496648a7c8 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 29 Sep 2026 07:48:35 -0600
Subject: [PATCH 20/59] Update becheran/mlc action to v1.2.2 (578)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.github/workflows/build.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 64a106f51..e210b36cf 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -76,6 +76,6 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: ๐ Markup Link Checker (mlc)
- uses: becheran/mlc@7ec24825cefe0c9c8c6bac48430e1f69e3ec356e # v1.2.0
+ uses: becheran/mlc@b0d0d353e04fcbcfd1d86716103d78a61e5b25dd # v1.2.2
with:
args: --do-not-warn-for-redirect-to https://learn.microsoft.com*,https://dotnet.microsoft.com/*,https://dev.azure.com/*,https://app.codecov.io/* -p docfx -i https://www.npmjs.com/package/*,https://get.dot.net/
From d4778325fe9cd707986b9a9eb3565107c66225a2 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Wed, 30 Sep 2026 11:50:55 -0600
Subject: [PATCH 21/59] Make docs workflow recoverable via re-run or manual
dispatch
Re-running a failed docs deployment failed because the prior attempt's
github-pages artifact remained, making deploy-pages find two artifacts.
Use a per-attempt artifact name, and allow workflow_dispatch.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.github/workflows/docs.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index c0492a90d..13dfd6d5c 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
+ workflow_dispatch:
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
@@ -37,7 +38,12 @@ jobs:
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: docfx/_site
+ # Artifacts from prior attempts remain visible on re-runs, so make the name unique per attempt
+ # to avoid deploy-pages failing due to multiple artifacts with the same name.
+ name: github-pages-${{ github.run_attempt }}
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
+ with:
+ artifact_name: github-pages-${{ github.run_attempt }}
From 57fc2b2ee08529340f110ddb5d4ef98a97a8f167 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Thu, 1 Oct 2026 10:29:38 -0600
Subject: [PATCH 22/59] Pin Ubuntu workflows to 26.04
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
.github/workflows/build.yml | 4 ++--
.github/workflows/copilot-setup-steps.yml | 2 +-
.github/workflows/docs.yml | 2 +-
.github/workflows/libtemplate-update.yml | 2 +-
azure-pipelines/build.yml | 2 +-
azure-pipelines/libtemplate-update.yml | 2 +-
azure-pipelines/release.yml | 2 +-
7 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index e210b36cf..db4b2e0f7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -23,7 +23,7 @@ jobs:
fail-fast: false
matrix:
os:
- - ubuntu-24.04
+ - ubuntu-26.04
- macOS-15
- windows-2025
@@ -72,7 +72,7 @@ jobs:
docs:
name: ๐ Docs
- runs-on: ubuntu-latest
+ runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: ๐ Markup Link Checker (mlc)
diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml
index 71adf9e73..74a625667 100644
--- a/.github/workflows/copilot-setup-steps.yml
+++ b/.github/workflows/copilot-setup-steps.yml
@@ -16,7 +16,7 @@ on:
jobs:
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
copilot-setup-steps:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-26.04
# Set the permissions to the lowest permissions possible needed for your steps.
# Copilot will be given its own token for its operations.
permissions:
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 13dfd6d5c..9616f6949 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -23,7 +23,7 @@ jobs:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
- runs-on: ubuntu-latest
+ runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
diff --git a/.github/workflows/libtemplate-update.yml b/.github/workflows/libtemplate-update.yml
index af1a9eef8..3de947a12 100644
--- a/.github/workflows/libtemplate-update.yml
+++ b/.github/workflows/libtemplate-update.yml
@@ -12,7 +12,7 @@ on:
jobs:
merge:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-26.04
permissions:
contents: write
pull-requests: write
diff --git a/azure-pipelines/build.yml b/azure-pipelines/build.yml
index 7d808edeb..3f154027a 100644
--- a/azure-pipelines/build.yml
+++ b/azure-pipelines/build.yml
@@ -28,7 +28,7 @@ jobs:
- job: Linux
pool:
- vmImage: ubuntu-24.04
+ vmImage: ubuntu-26.04
steps:
- checkout: self
fetchDepth: 0 # avoid shallow clone so nbgv can do its work.
diff --git a/azure-pipelines/libtemplate-update.yml b/azure-pipelines/libtemplate-update.yml
index fdfa1df54..84ab4caef 100644
--- a/azure-pipelines/libtemplate-update.yml
+++ b/azure-pipelines/libtemplate-update.yml
@@ -22,7 +22,7 @@ stages:
jobs:
- job: merge
pool:
- vmImage: ubuntu-latest
+ vmImage: ubuntu-26.04
steps:
- checkout: self
fetchDepth: 0
diff --git a/azure-pipelines/release.yml b/azure-pipelines/release.yml
index 4c9db860a..a737d0392 100644
--- a/azure-pipelines/release.yml
+++ b/azure-pipelines/release.yml
@@ -15,7 +15,7 @@ variables:
jobs:
- job: release
pool:
- vmImage: ubuntu-latest
+ vmImage: ubuntu-26.04
steps:
- checkout: none
- powershell: |
From 3f29bf73558e39312648235bd19a8f5e38b870f3 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 29 Sep 2026 20:58:54 -0600
Subject: [PATCH 23/59] Migrate from xUnit to TUnit
---
.../template-release-notes.md | 24 +++++++++++
.github/renovate.json | 5 +++
.github/workflows/build.yml | 4 +-
AGENTS.md | 32 ++++++---------
CONTRIBUTING.md | 16 ++++++++
Directory.Build.props | 17 ++++++++
Directory.Packages.props | 4 +-
azure-pipelines/dotnet.yml | 8 +++-
global.json | 3 ++
init.ps1 | 3 +-
src/Directory.Build.props | 1 +
src/dirs.proj | 5 +++
test/AOT.props | 38 +++++++++++++++++
test/Directory.Build.props | 7 ++++
test/Directory.Build.targets | 41 +++++++++++++++++--
test/Library.Tests/CalculatorTests.cs | 7 +---
test/Library.Tests/Library.Tests.csproj | 6 +--
test/Library.Tests/xunit.runner.json | 4 --
test/dirs.proj | 21 ++++++++++
tools/Get-NativeAOTTestProjects.ps1 | 25 +++++++++++
tools/dirs.proj | 6 +++
tools/dotnet-test-cloud.ps1 | 34 ++++++++++++++-
22 files changed, 268 insertions(+), 43 deletions(-)
create mode 100644 src/dirs.proj
create mode 100644 test/AOT.props
delete mode 100644 test/Library.Tests/xunit.runner.json
create mode 100644 test/dirs.proj
create mode 100644 tools/Get-NativeAOTTestProjects.ps1
create mode 100644 tools/dirs.proj
diff --git a/.agents/skills/update-library-template/template-release-notes.md b/.agents/skills/update-library-template/template-release-notes.md
index 975da315e..8bd620586 100644
--- a/.agents/skills/update-library-template/template-release-notes.md
+++ b/.agents/skills/update-library-template/template-release-notes.md
@@ -18,3 +18,27 @@ dotnet solution EXISTING.sln migrate
This will create an EXISTING.slnx file. `git add` that file, then `git rm` the old `.sln` file.
Sometimes a repo will reference the sln filename in a script or doc somewhere.
Search the repo for such references and update them to the slnx file.
+
+## Migrating from xUnit to TUnit
+
+The template now uses TUnit with Microsoft.Testing.Platform instead of the xUnit test runner.
+When merging this update into an existing repository, migrate all test projects and repo-specific test automation.
+Follow the [TUnit xUnit migration guide](https://tunit.dev/docs/migration/xunit), including its automated migration code fixes.
+
+* Replace xUnit runner/framework package references with `TUnit.Engine` and set `Exe` in each test project.
+ Remove obsolete runner dependencies such as `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, and `coverlet.collector`, along with `xunit.runner.json`.
+ Reconcile central package versions with the template's `Directory.Packages.props`.
+* Existing xUnit assertions can remain: the template retains `xunit.v3.assert`, using `xunit.v3.assert.aot` for .NET 9 and later.
+ Keep `Xunit` imports for assertions, but use TUnit for test attributes and lifecycle APIs.
+* Convert `[Fact]` and `[Theory]` to `[Test]`, and `[InlineData]` to `[Arguments]`.
+ Migrate member/class data sources, traits, fixtures, collection behavior, setup/teardown, and test output according to the migration guide.
+ Review tests that depend on xUnit's execution ordering or parallelization rules; do not assume those rules carry over.
+* Merge the `global.json` Microsoft.Testing.Platform runner setting, test directory build files, traversal projects, and updated PowerShell/YAML automation.
+ Keep every test project in the repository's solution for managed test runs.
+ The traversals discover `.csproj` files under `src` and `test`; adjust discovery if your projects live elsewhere or use another project language.
+* Update custom test commands and filters.
+ VSTest `--filter` expressions do not work with this runner; pass TUnit filters after `--`, for example `-- --treenode-filter "/*/*/ClassName/MethodName"`.
+ Replace any xUnit-specific CI result or coverage handling with the template's Microsoft.Testing.Platform integration.
+* Validate managed tests and coverage, then run `dotnet publish tools\dirs.proj -c Release` and `.\tools\dotnet-test-cloud.ps1 -Configuration Release -IncludeNativeAOT`.
+ Test projects targeting .NET 8 or later are eligible for NativeAOT publication by default.
+ If a project cannot support NativeAOT, set `false` in its project file; it will still run as managed tests.
diff --git a/.github/renovate.json b/.github/renovate.json
index 79dba1e54..786187da7 100644
--- a/.github/renovate.json
+++ b/.github/renovate.json
@@ -11,6 +11,11 @@
"matchPackageNames": ["xunit*"],
"groupName": "xunit"
},
+ {
+ "matchPackageNames": ["tunit*"],
+ "groupName": "tunit",
+ "schedule": ["on the first day of the month"]
+ },
{
"matchPackageNames": ["Microsoft.Testing.Extensions.*"],
"groupName": "Microsoft Testing Platform"
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index db4b2e0f7..bfe01efdc 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -45,9 +45,9 @@ jobs:
run: tools/variables/_define.ps1
shell: pwsh
- name: ๐ build
- run: dotnet build -t:build,pack --no-restore -c ${{ env.BUILDCONFIGURATION }} -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904 /bl:"${{ runner.temp }}/_artifacts/build_logs/build.binlog"
+ run: dotnet build tools/dirs.proj -t:build,pack,publish --no-restore -c ${{ env.BUILDCONFIGURATION }} -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904 /bl:"${{ runner.temp }}/_artifacts/build_logs/build.binlog"
- name: ๐งช test
- run: tools/dotnet-test-cloud.ps1 -Configuration ${{ env.BUILDCONFIGURATION }} -Agent ${{ runner.os }}
+ run: tools/dotnet-test-cloud.ps1 -Configuration ${{ env.BUILDCONFIGURATION }} -Agent ${{ runner.os }} -IncludeNativeAOT
shell: pwsh
- name: ๐
๐ป Verify formatted code
run: dotnet format --verify-no-changes --no-restore
diff --git a/AGENTS.md b/AGENTS.md
index 0187843a6..49f47191f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -13,11 +13,10 @@
## Testing
-**IMPORTANT**: This repository uses Microsoft.Testing.Platform (MTP v2) with xunit v3. Traditional `--filter` syntax does NOT work. Use the options below instead.
+**IMPORTANT**: This repository uses TUnit with Microsoft.Testing.Platform (MTP v2). Traditional `--filter` syntax does NOT work. Use the options below instead.
* There should generally be one test project (under the `test` directory) per shipping project (under the `src` directory). Test projects are named after the project being tested with a `.Tests` suffix.
-* Tests use xunit v3 with Microsoft.Testing.Platform (MTP v2). Traditional VSTest `--filter` syntax does NOT work.
-* Some tests are known to be unstable. When running tests, you should skip the unstable ones by using `-- --filter-not-trait "FailsInCloudTest=true"`.
+* Tests use TUnit with Microsoft.Testing.Platform (MTP v2), while retaining xUnit assertions. Traditional VSTest `--filter` syntax does NOT work.
### Running Tests
@@ -33,47 +32,40 @@ dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Rele
**Run a single test method**:
```bash
-dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --filter-method ClassName.MethodName
+dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --treenode-filter "/*/*/ClassName/MethodName"
```
**Run all tests in a test class**:
```bash
-dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --filter-class ClassName
+dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --treenode-filter "/*/*/ClassName/*"
```
**Run tests with wildcard matching** (supports wildcards at beginning and/or end):
```bash
-dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --filter-method "*Pattern*"
+dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --treenode-filter "/*/*/*/*Pattern*"
```
-**Run tests with a specific trait** (equivalent to category filtering):
+**Run tests with a specific property**:
```bash
-dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --filter-trait "TraitName=value"
-```
-
-**Exclude tests with a specific trait** (skip unstable tests):
-```bash
-dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --filter-not-trait "TestCategory=FailsInCloudTest"
+dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release -- --treenode-filter "/*/*/*/*[PropertyName=value]"
```
**Run tests for a specific framework only**:
```bash
-dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release --framework net9.0
+dotnet test --project test/Library.Tests/Library.Tests.csproj --no-build -c Release --framework net8.0
```
**List all available tests without running them**:
```bash
cd test/Library.Tests
-dotnet run --no-build -c Release --framework net9.0 -- --list-tests
+dotnet run --no-build -c Release --framework net8.0 -- --list-tests
```
-**Key points about test filtering with MTP v2 / xunit v3**:
+**Key points about test filtering with TUnit / MTP v2**:
- Options after `--` are passed to the test runner, not to `dotnet test`
-- Use `--filter-method`, `--filter-class`, `--filter-namespace` for simple filtering
-- Use `--filter-trait` and `--filter-not-trait` for trait-based filtering (replaces `--filter "TestCategory=..."`)
+- Use `--treenode-filter` to select tests by assembly, namespace, class, method, or property
- Traditional VSTest `--filter` expressions do NOT work
-- Wildcards `*` are supported at the beginning and/or end of filter values
-- Multiple simple filters of the same type use OR logic, different types combine with AND
+- Wildcards `*` are supported in tree node segments
- See `--help` for query filter language for advanced scenarios
## Coding style
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 2c566c0be..bf43aad42 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -45,6 +45,22 @@ You can use `dotnet test` to build and/or test the repo.
There may be tests that are known to be unstable or have special requirements. These can be avoided by running tests using the [dotnet-test-cloud.ps1](tools/dotnet-test-cloud.ps1) script *after* running `dotnet build`.
+To build both managed and NativeAOT tests, run `dotnet publish tools/dirs.proj -c Release`.
+Then run `./tools/dotnet-test-cloud.ps1 -Configuration Release -IncludeNativeAOT`.
+The traversal projects discover projects under `src` and `test`, and publish each eligible test framework targeting .NET 8 or later.
+Keep test projects in the solution as well: managed test runs still use the solution, while NativeAOT runs use the traversal's evaluated executable paths.
+Test projects can opt out of NativeAOT publishing with `false`.
+One restore includes all test target frameworks, runtime identifiers, and NativeAOT compiler dependencies.
+Managed builds are RID-neutral by default; NativeAOT builds use the SDK's runtime-specific output directories.
+For a specified RID, managed execution and native publishing can share the same build outputs.
+Test builds keep dynamic code, startup hooks, and event tracing enabled for managed code coverage.
+The `ConfigureNativeAOTTestFeatures` target disables those features only in the native compiler's publish-time inputs, without rewriting the managed runtime configuration.
+It preserves all other runtime feature options, including invariant globalization, so native compilation and linking use consistent settings.
+For an existing RID-specific build, `dotnet publish test/Library.Tests/Library.Tests.csproj -f net8.0 -r -p:NativeAOT=true --no-build` publishes native tests from the managed build.
+Use the same configuration, framework, and RID for the preceding build and the publish.
+Test builds use invariant globalization and retain only English satellite resources; RID-specific builds are self-contained.
+Root `Directory.Build.props` supplies project-reference defaults for both traversal and SDK projects that remove the `_IsPublishing` global property for managed dependencies, avoiding duplicate project instances that write to the same outputs during parallel publishing.
+
## Releases
Use `nbgv tag` to create a tag for a particular commit that you mean to release.
diff --git a/Directory.Build.props b/Directory.Build.props
index a3d5fb8e1..1a890354f 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -48,11 +48,28 @@
16.9
+
+ win
+ linux
+ osx
+
+ x64
+ arm64
+
+ $(RidOsPrefix)-$(RidOsArchitecture)
+ $(DefaultRuntimeIdentifier)
+
+
+
+
+ _IsPublishing
+ _IsPublishing
+
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 6f2124c10..11e38433c 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,7 +16,9 @@
-
+
+
+
diff --git a/azure-pipelines/dotnet.yml b/azure-pipelines/dotnet.yml
index 9a572afe1..d399b31bf 100644
--- a/azure-pipelines/dotnet.yml
+++ b/azure-pipelines/dotnet.yml
@@ -3,10 +3,14 @@ parameters:
steps:
-- script: dotnet build -t:build,pack --no-restore -c $(BuildConfiguration) -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904 /bl:"$(Build.ArtifactStagingDirectory)/build_logs/build.binlog"
+- script: dotnet build tools/dirs.proj -t:build,pack --no-restore -c $(BuildConfiguration) -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904 /bl:"$(Build.ArtifactStagingDirectory)/build_logs/build.binlog"
displayName: ๐ dotnet build
-- powershell: tools/dotnet-test-cloud.ps1 -Configuration $(BuildConfiguration) -Agent $(Agent.JobName) -PublishResults
+- script: dotnet publish tools/dirs.proj --no-restore -c $(BuildConfiguration)
+ displayName: ๐ publish NativeAOT tests
+ condition: and(succeeded(), ${{ parameters.RunTests }})
+
+- powershell: tools/dotnet-test-cloud.ps1 -Configuration $(BuildConfiguration) -Agent $(Agent.JobName) -PublishResults -IncludeNativeAOT
displayName: ๐งช dotnet test
condition: and(succeeded(), ${{ parameters.RunTests }})
diff --git a/global.json b/global.json
index 8c833e411..b925aabad 100644
--- a/global.json
+++ b/global.json
@@ -6,5 +6,8 @@
},
"test": {
"runner": "Microsoft.Testing.Platform"
+ },
+ "msbuild-sdks": {
+ "Microsoft.Build.Traversal": "4.1.82"
}
}
diff --git a/init.ps1 b/init.ps1
index b4004e792..3469e0b77 100755
--- a/init.ps1
+++ b/init.ps1
@@ -90,10 +90,11 @@ try {
if (!$NoRestore -and $PSCmdlet.ShouldProcess("NuGet packages", "Restore")) {
Write-Host "Restoring NuGet packages" -ForegroundColor $HeaderColor
- dotnet restore @RestoreArguments
+ dotnet restore "$PSScriptRoot/tools/dirs.proj" @RestoreArguments
if ($lastexitcode -ne 0) {
throw "Failure while restoring packages."
}
+
}
if (!$NoToolRestore -and $PSCmdlet.ShouldProcess("dotnet tool", "restore")) {
diff --git a/src/Directory.Build.props b/src/Directory.Build.props
index 9ba7818d4..b01ac125c 100644
--- a/src/Directory.Build.props
+++ b/src/Directory.Build.props
@@ -9,4 +9,5 @@
+
diff --git a/src/dirs.proj b/src/dirs.proj
new file mode 100644
index 000000000..b6fa81f93
--- /dev/null
+++ b/src/dirs.proj
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/test/AOT.props b/test/AOT.props
new file mode 100644
index 000000000..9a252b656
--- /dev/null
+++ b/test/AOT.props
@@ -0,0 +1,38 @@
+
+
+ true
+ $(AvailableRuntimeIdentifiers)
+
+
+
+ true
+ true
+ true
+ true
+ en
+
+
+ true
+
+
+
+ %(ProjectReference.GlobalPropertiesToRemove);RuntimeIdentifier;NativeAOT
+
+
+
+
+
+ false
+ false
+ false
+
+
+
+
+ false
+
+
+
+
diff --git a/test/Directory.Build.props b/test/Directory.Build.props
index 2b5b542d6..035e949cc 100644
--- a/test/Directory.Build.props
+++ b/test/Directory.Build.props
@@ -3,9 +3,16 @@
+ true
+ true
false
true
true
+ false
+
+
+
+ $(DefaultRuntimeIdentifier)
diff --git a/test/Directory.Build.targets b/test/Directory.Build.targets
index 3758bb8c9..95b0658bc 100644
--- a/test/Directory.Build.targets
+++ b/test/Directory.Build.targets
@@ -1,14 +1,47 @@
+
+
+
+ $(NoWarn);IL2104;IL3053
+
+
-
-
-
-
+
+
+
+ <_TestFramework Include="$(TargetFrameworks)" />
+
+
+
+
+
+
+
+
+
+
+
+ <_NativeExecutableExtension Condition="$([MSBuild]::IsOsPlatform('Windows'))">.exe
+
+
+ <_NativeAOTTestExecutable Include="$(MSBuildProjectFullPath)">
+ $(MSBuildProjectName)
+ $(TargetFramework)
+ $([MSBuild]::NormalizePath('$(MSBuildProjectDirectory)', '$(PublishDir)', '$(AssemblyName)$(_NativeExecutableExtension)'))
+
+
+
diff --git a/test/Library.Tests/CalculatorTests.cs b/test/Library.Tests/CalculatorTests.cs
index a35947bcd..fb1788185 100644
--- a/test/Library.Tests/CalculatorTests.cs
+++ b/test/Library.Tests/CalculatorTests.cs
@@ -2,15 +2,12 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
using Library;
+using TUnit.Core;
using Xunit;
public class CalculatorTests
{
- public CalculatorTests()
- {
- }
-
- [Fact]
+ [Test]
public void AddOrSubtract()
{
// This tests aggregation of code coverage across test runs.
diff --git a/test/Library.Tests/Library.Tests.csproj b/test/Library.Tests/Library.Tests.csproj
index e9e12e43a..29e319fde 100644
--- a/test/Library.Tests/Library.Tests.csproj
+++ b/test/Library.Tests/Library.Tests.csproj
@@ -1,18 +1,18 @@
-
net8.0
$(TargetFrameworks);net472
Exe
-
-
+
+
+
diff --git a/test/Library.Tests/xunit.runner.json b/test/Library.Tests/xunit.runner.json
deleted file mode 100644
index 8465a4543..000000000
--- a/test/Library.Tests/xunit.runner.json
+++ /dev/null
@@ -1,4 +0,0 @@
-{
- "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
- "shadowCopy": false
-}
diff --git a/test/dirs.proj b/test/dirs.proj
new file mode 100644
index 000000000..835c4a89a
--- /dev/null
+++ b/test/dirs.proj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+ <_NativeAOTTest Update="@(_NativeAOTTest)" UndefineProperties="" />
+
+
+
+
+
+
+
diff --git a/tools/Get-NativeAOTTestProjects.ps1 b/tools/Get-NativeAOTTestProjects.ps1
new file mode 100644
index 000000000..27a39ec39
--- /dev/null
+++ b/tools/Get-NativeAOTTestProjects.ps1
@@ -0,0 +1,25 @@
+#!/usr/bin/env pwsh
+
+<#
+.SYNOPSIS
+ Gets NativeAOT test executable paths evaluated by the test traversal project.
+.PARAMETER Configuration
+ The build configuration used to evaluate output paths.
+#>
+[CmdletBinding()]
+Param(
+ [string]$Configuration='Debug'
+)
+
+$result = & dotnet msbuild "$PSScriptRoot/../test/dirs.proj" `
+ -nologo `
+ -getTargetResult:GetNativeAOTTests `
+ "-property:Configuration=$Configuration"
+if ($LASTEXITCODE -ne 0) {
+ throw 'Failed to discover NativeAOT tests through MSBuild.'
+}
+
+$tests = ($result | Out-String | ConvertFrom-Json).TargetResults.GetNativeAOTTests.Items
+foreach ($test in $tests) {
+ $test
+}
diff --git a/tools/dirs.proj b/tools/dirs.proj
new file mode 100644
index 000000000..7b266e217
--- /dev/null
+++ b/tools/dirs.proj
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/tools/dotnet-test-cloud.ps1 b/tools/dotnet-test-cloud.ps1
index 3d8d08703..1a0007688 100644
--- a/tools/dotnet-test-cloud.ps1
+++ b/tools/dotnet-test-cloud.ps1
@@ -5,6 +5,8 @@
Runs tests as they are run in cloud test runs.
.PARAMETER Configuration
The configuration within which to run tests
+.PARAMETER IncludeNativeAOT
+ Runs the NativeAOT-compiled tests and fails if the expected image is missing.
.PARAMETER Agent
The name of the agent. This is used in preparing test run titles.
.PARAMETER PublishResults
@@ -19,6 +21,7 @@
[CmdletBinding()]
Param(
[string]$Configuration='Debug',
+ [switch]$IncludeNativeAOT,
[string]$Agent='Local',
[switch]$PublishResults,
[switch]$x86,
@@ -50,6 +53,9 @@ if ($x86) {
$testBinLog = Join-Path $ArtifactStagingFolder (Join-Path build_logs test.binlog)
$testLogs = Join-Path $ArtifactStagingFolder test_logs
+if (Test-Path -LiteralPath $testLogs) {
+ Remove-Item -LiteralPath $testLogs -Recurse -Force
+}
$globalJson = Get-Content $PSScriptRoot/../global.json | ConvertFrom-Json
$isMTP = $globalJson.test.runner -eq 'Microsoft.Testing.Platform'
@@ -95,12 +101,38 @@ if ($isMTP) {
-c $Configuration `
-bl:"$testBinLog" `
-- `
- --filter-not-trait 'TestCategory=FailsInCloudTest' `
@mtpArgs `
@dumpSwitches `
@extraArgs
if ($LASTEXITCODE -ne 0) { $failedTests += 1 }
+ if ($IncludeNativeAOT) {
+ $nativeAotTests = @(& "$PSScriptRoot/Get-NativeAOTTestProjects.ps1" -Configuration $Configuration)
+ foreach ($nativeAotTest in $nativeAotTests) {
+ $testExecutable = $nativeAotTest.ExecutablePath
+ if (-not (Test-Path -LiteralPath $testExecutable -PathType Leaf)) {
+ Write-Error "Expected NativeAOT TUnit test executable '$testExecutable' was not found."
+ $failedTests += 1
+ continue
+ }
+
+ $nativeAotArgs = @(
+ ,'--diagnostic'
+ ,'--diagnostic-output-directory',$testLogs
+ ,'--diagnostic-verbosity','Information'
+ ,'--results-directory',$testLogs
+ ,'--report-trx'
+ ,'--report-trx-filename',"$($nativeAotTest.ProjectName)_$($nativeAotTest.TargetFramework)_NativeAOT_{arch}.trx"
+ )
+ if ($IsWindows) {
+ $nativeAotArgs += $dumpSwitches
+ }
+ Write-Host "Running NativeAOT TUnit tests from '$testExecutable'." -ForegroundColor Cyan
+ & $testExecutable @nativeAotArgs @extraArgs
+ if ($LASTEXITCODE -ne 0) { $failedTests += 1 }
+ }
+ }
+
$trxFiles = Get-ChildItem -Recurse -Path $testLogs\*.trx
} else {
$testDiagLog = Join-Path $ArtifactStagingFolder (Join-Path test_logs diag.log)
From 302ccbeab00342f34c7e0299e39f7b22df2f1c07 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Thu, 1 Oct 2026 10:16:35 -0600
Subject: [PATCH 24/59] Build and test NativeAOT-safe libraries
---
.../template-release-notes.md | 9 +++++++++
CONTRIBUTING.md | 3 +++
Expand-Template.ps1 | 6 ++++++
Library.slnx | 1 +
README.md | 1 +
src/Library/Library.csproj | 1 +
test/AOT.props | 4 +++-
.../AotCompatibilityTest.csproj | 19 +++++++++++++++++++
test/AotCompatibilityTest/Program.cs | 4 ++++
test/dirs.proj | 2 ++
10 files changed, 49 insertions(+), 1 deletion(-)
create mode 100644 test/AotCompatibilityTest/AotCompatibilityTest.csproj
create mode 100644 test/AotCompatibilityTest/Program.cs
diff --git a/.agents/skills/update-library-template/template-release-notes.md b/.agents/skills/update-library-template/template-release-notes.md
index 8bd620586..7ce3f9c77 100644
--- a/.agents/skills/update-library-template/template-release-notes.md
+++ b/.agents/skills/update-library-template/template-release-notes.md
@@ -42,3 +42,12 @@ Follow the [TUnit xUnit migration guide](https://tunit.dev/docs/migration/xunit)
* Validate managed tests and coverage, then run `dotnet publish tools\dirs.proj -c Release` and `.\tools\dotnet-test-cloud.ps1 -Configuration Release -IncludeNativeAOT`.
Test projects targeting .NET 8 or later are eligible for NativeAOT publication by default.
If a project cannot support NativeAOT, set `false` in its project file; it will still run as managed tests.
+
+## NativeAOT compatibility validation
+
+Shipping projects now set `IsAotCompatible` for target frameworks compatible with .NET 8.
+Apply the same conditional property to each shipping project that is intended to support NativeAOT, then resolve all trim and AOT analyzer warnings rather than suppressing them broadly.
+
+The new `test/AotCompatibilityTest` project catches NativeAOT compiler warnings that library analyzers may not report.
+Keep it in the solution, add every NativeAOT-compatible shipping assembly as a `TrimmerRootAssembly`, and retain its publishable entry in `test/dirs.proj`.
+CI validates this project through the existing traversal publish on Windows, Linux, and macOS; it is intentionally marked `IsTestProject=false` because publishing successfully is the test.
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bf43aad42..5bda7fc9f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -59,6 +59,9 @@ It preserves all other runtime feature options, including invariant globalizatio
For an existing RID-specific build, `dotnet publish test/Library.Tests/Library.Tests.csproj -f net8.0 -r -p:NativeAOT=true --no-build` publishes native tests from the managed build.
Use the same configuration, framework, and RID for the preceding build and the publish.
Test builds use invariant globalization and retain only English satellite resources; RID-specific builds are self-contained.
+Shipping libraries targeting .NET 8 or later opt into NativeAOT compatibility analysis with `IsAotCompatible`.
+The `test/AotCompatibilityTest` project complements those analyzers by rooting the shipping assembly and passing it through the NativeAOT compiler during every traversal publish.
+Add each shipping assembly that must be validated as a `TrimmerRootAssembly`, and keep this project publishable in `test/dirs.proj`.
Root `Directory.Build.props` supplies project-reference defaults for both traversal and SDK projects that remove the `_IsPublishing` global property for managed dependencies, avoiding duplicate project instances that write to the same outputs during parallel publishing.
## Releases
diff --git a/Expand-Template.ps1 b/Expand-Template.ps1
index eb525325a..37e96b57c 100755
--- a/Expand-Template.ps1
+++ b/Expand-Template.ps1
@@ -140,6 +140,12 @@ try {
'Library'=$LibraryName
'COMPANY-PLACEHOLDER'=$Author
}
+ Replace-Placeholders -Path "test/AotCompatibilityTest/AotCompatibilityTest.csproj" -Replacements @{
+ 'Library'=$LibraryName
+ }
+ Replace-Placeholders -Path "test/AotCompatibilityTest/Program.cs" -Replacements @{
+ 'COMPANY-PLACEHOLDER'=$Author
+ }
Replace-Placeholders -Path "src/AssemblyInfo.cs" -Replacements @{
'COMPANY-PLACEHOLDER'=$Author
}
diff --git a/Library.slnx b/Library.slnx
index dc016d9e2..4d7254f12 100644
--- a/Library.slnx
+++ b/Library.slnx
@@ -22,5 +22,6 @@
+
diff --git a/README.md b/README.md
index 3f8c9764b..1f6ce9817 100644
--- a/README.md
+++ b/README.md
@@ -17,6 +17,7 @@
* Auto-versioning (via [Nerdbank.GitVersioning](https://github.com/dotnet/nerdbank.gitversioning))
* Builds with a "pinned" .NET SDK to ensure reproducible builds across machines and across time.
* Automatically pack the library and publish it as an artifact, and even push it to some NuGet feed for consumption.
+* Build NativeAOT-safe libraries with thorough compatibility testing
* Testing
* Testing on .NET Framework, multiple .NET versions
* Testing on Windows, Linux and OSX
diff --git a/src/Library/Library.csproj b/src/Library/Library.csproj
index fa79c05e0..e4cd98aa5 100644
--- a/src/Library/Library.csproj
+++ b/src/Library/Library.csproj
@@ -1,6 +1,7 @@
net8.0;netstandard2.0
+ true
diff --git a/test/AOT.props b/test/AOT.props
index 9a252b656..623ff24da 100644
--- a/test/AOT.props
+++ b/test/AOT.props
@@ -3,11 +3,13 @@
true
$(AvailableRuntimeIdentifiers)
-
+
true
true
true
+
+
true
en
diff --git a/test/AotCompatibilityTest/AotCompatibilityTest.csproj b/test/AotCompatibilityTest/AotCompatibilityTest.csproj
new file mode 100644
index 000000000..f8a37360d
--- /dev/null
+++ b/test/AotCompatibilityTest/AotCompatibilityTest.csproj
@@ -0,0 +1,19 @@
+
+
+ net10.0
+ Exe
+ false
+ $(DefaultRuntimeIdentifier)
+ true
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/test/AotCompatibilityTest/Program.cs b/test/AotCompatibilityTest/Program.cs
new file mode 100644
index 000000000..1467eff90
--- /dev/null
+++ b/test/AotCompatibilityTest/Program.cs
@@ -0,0 +1,4 @@
+// Copyright (c) COMPANY-PLACEHOLDER. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+Console.WriteLine("This project validates NativeAOT compatibility when it is published; the executable is not run.");
diff --git a/test/dirs.proj b/test/dirs.proj
index 835c4a89a..6f6def8c9 100644
--- a/test/dirs.proj
+++ b/test/dirs.proj
@@ -1,6 +1,8 @@
+
+
From a3c19c065e59510f19cec90f62cb75eef346a933 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Thu, 1 Oct 2026 18:42:20 -0600
Subject: [PATCH 25/59] Add opt-out instructions for the NativeAOT support
---
.../template-release-notes.md | 28 +++++++++++++++++++
1 file changed, 28 insertions(+)
diff --git a/.agents/skills/update-library-template/template-release-notes.md b/.agents/skills/update-library-template/template-release-notes.md
index 7ce3f9c77..1a731e63e 100644
--- a/.agents/skills/update-library-template/template-release-notes.md
+++ b/.agents/skills/update-library-template/template-release-notes.md
@@ -51,3 +51,31 @@ Apply the same conditional property to each shipping project that is intended to
The new `test/AotCompatibilityTest` project catches NativeAOT compiler warnings that library analyzers may not report.
Keep it in the solution, add every NativeAOT-compatible shipping assembly as a `TrimmerRootAssembly`, and retain its publishable entry in `test/dirs.proj`.
CI validates this project through the existing traversal publish on Windows, Linux, and macOS; it is intentionally marked `IsTestProject=false` because publishing successfully is the test.
+
+## NativeAOT opt-out and AI merge guidance
+
+Not every repo wants to take on NativeAOT readiness or NativeAOT-published test validation.
+If a receiving repo does not intend to support NativeAOT, opt out explicitly instead of inheriting the template's defaults.
+
+For test projects that should remain managed-only:
+
+* Set `false` in the project file or in a repo-level `Directory.Build.props`.
+* Leave the managed test project in the solution so usual IL-based testing continues to work.
+* Keep `dotnet test` and the managed coverage flow unchanged; only the NativeAOT publish step is disabled.
+
+For shipping libraries that do not want NativeAOT compatibility analysis:
+
+* Remove `IsAotCompatible` from the project, or set it only for specific target frameworks that truly support NativeAOT.
+* Do not add new AOT compatibility warnings to the build if the library is not intended to be AOT-compatible.
+* If the repo does not target .NET 8 or later, the property should be omitted entirely rather than applied unconditionally.
+
+For the compatibility project itself:
+
+* Remove the `test/AotCompatibilityTest` project from the solution if NativeAOT validation is out of scope.
+* Remove its entry from `test/dirs.proj` so the traversal publish no longer publishes it.
+* Keep the project only when the repo intends to validate shipping assemblies under NativeAOT in CI.
+
+When merging this template into a downstream repo, the AI assistant should ask whether NativeAOT is a relevant target for that repo before applying these changes if `IsAotCompatible` is not already set by any of the projects.
+If the answer is no, it should apply the opt-out steps above instead of blindly keeping the NativeAOT defaults.
+This is especially important for repos that are not shipping a NativeAOT-ready library or that cannot support the extra publish-time validation in CI.
+If the repo operator is unsure about NativeAOT support, you might offer to test the repo for NativeAOT readiness by attempting a NativeAOT publish of the relevant projects and observing whether any compatibility warnings or errors are produced.
From 5e9fca2e2173de7753112ea8447df56f06f17d87 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Thu, 1 Oct 2026 19:11:22 -0600
Subject: [PATCH 26/59] Improve release notes further
---
.../skills/update-library-template/template-release-notes.md | 5 ++++-
1 file changed, 4 insertions(+), 1 deletion(-)
diff --git a/.agents/skills/update-library-template/template-release-notes.md b/.agents/skills/update-library-template/template-release-notes.md
index 1a731e63e..0ef41c2c0 100644
--- a/.agents/skills/update-library-template/template-release-notes.md
+++ b/.agents/skills/update-library-template/template-release-notes.md
@@ -78,4 +78,7 @@ For the compatibility project itself:
When merging this template into a downstream repo, the AI assistant should ask whether NativeAOT is a relevant target for that repo before applying these changes if `IsAotCompatible` is not already set by any of the projects.
If the answer is no, it should apply the opt-out steps above instead of blindly keeping the NativeAOT defaults.
This is especially important for repos that are not shipping a NativeAOT-ready library or that cannot support the extra publish-time validation in CI.
-If the repo operator is unsure about NativeAOT support, you might offer to test the repo for NativeAOT readiness by attempting a NativeAOT publish of the relevant projects and observing whether any compatibility warnings or errors are produced.
+If the repo owner is unsure about NativeAOT support, you might offer to test the repo for NativeAOT readiness by attempting a NativeAOT publish of the relevant projects and observing whether any compatibility warnings or errors are produced.
+
+In the event the user elects for NativeAOT targeting, make efforts to get tests to work under NativeAOT as well so the shipping code can confidently advertise its NativeAOT compatibility.
+If aspects of the library or tests make successful builds, publish or test runs problematic, discuss this with the repo owner before making significant changes or giving up on NativeAOT support.
From 3eebddd0f5a3f5cd314c97a2acbed43000cc29e3 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Thu, 1 Oct 2026 19:16:07 -0600
Subject: [PATCH 27/59] Build and publish in one step in Azure Pipelines
---
azure-pipelines/dotnet.yml | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/azure-pipelines/dotnet.yml b/azure-pipelines/dotnet.yml
index d399b31bf..91568abb5 100644
--- a/azure-pipelines/dotnet.yml
+++ b/azure-pipelines/dotnet.yml
@@ -3,12 +3,13 @@ parameters:
steps:
-- script: dotnet build tools/dirs.proj -t:build,pack --no-restore -c $(BuildConfiguration) -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904 /bl:"$(Build.ArtifactStagingDirectory)/build_logs/build.binlog"
- displayName: ๐ dotnet build
+- ${{ if parameters.RunTests }}:
+ - script: dotnet build tools/dirs.proj -t:build,pack,publish --no-restore -c $(BuildConfiguration) -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904 /bl:"$(Build.ArtifactStagingDirectory)/build_logs/build.binlog"
+ displayName: ๐ dotnet build
-- script: dotnet publish tools/dirs.proj --no-restore -c $(BuildConfiguration)
- displayName: ๐ publish NativeAOT tests
- condition: and(succeeded(), ${{ parameters.RunTests }})
+- ${{ if not(parameters.RunTests) }}:
+ - script: dotnet build tools/dirs.proj -t:build,pack --no-restore -c $(BuildConfiguration) -warnAsError -warnNotAsError:NU1901,NU1902,NU1903,NU1904 /bl:"$(Build.ArtifactStagingDirectory)/build_logs/build.binlog"
+ displayName: ๐ dotnet build
- powershell: tools/dotnet-test-cloud.ps1 -Configuration $(BuildConfiguration) -Agent $(Agent.JobName) -PublishResults -IncludeNativeAOT
displayName: ๐งช dotnet test
From 8b0c0d02d2edcc103739fc044ff5d396af7c24fc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 2 Oct 2026 11:45:45 -0600
Subject: [PATCH 28/59] Update mcr.microsoft.com/dotnet/sdk:10.0.401 Docker
digest to 83e0db9 (582)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.devcontainer/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index e06f26ace..79c20cd8a 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,5 +1,5 @@
# Refer to https://hub.docker.com/_/microsoft-dotnet-sdk for available versions
-FROM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:35d40304542c8689331f8cab17c65926cdf48fe711e289321d71924b230a7d29
+FROM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:83e0db97c45d2e39b80123fe42940a23c423405a17f80b608a4b8768033d6392
# Installing mono makes `dotnet test` work without errors even for net472.
# But installing it takes a long time, so it's excluded by default.
From 84e1db7701eb938e8d4e30fdbc6a331bdc9726cc Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Fri, 2 Oct 2026 22:30:51 +0000
Subject: [PATCH 29/59] Update mcr.microsoft.com/dotnet/sdk:10.0.401 Docker
digest to e70cdb7 (583)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.devcontainer/Dockerfile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile
index 79c20cd8a..d2602ed43 100644
--- a/.devcontainer/Dockerfile
+++ b/.devcontainer/Dockerfile
@@ -1,5 +1,5 @@
# Refer to https://hub.docker.com/_/microsoft-dotnet-sdk for available versions
-FROM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:83e0db97c45d2e39b80123fe42940a23c423405a17f80b608a4b8768033d6392
+FROM mcr.microsoft.com/dotnet/sdk:10.0.401@sha256:e70cdb7f80b0348f5cb85f19a8f670fca061f033d57eed12fa003d58b0e06317
# Installing mono makes `dotnet test` work without errors even for net472.
# But installing it takes a long time, so it's excluded by default.
From 84911e43f17e1de8b4f469e2bbe7addb6545bad3 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Sat, 3 Oct 2026 09:41:33 -0600
Subject: [PATCH 30/59] Clarify API xml doc requirements
---
AGENTS.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/AGENTS.md b/AGENTS.md
index 49f47191f..397458e5f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -72,4 +72,4 @@ dotnet run --no-build -c Release --framework net8.0 -- --list-tests
* Honor StyleCop rules and fix any reported build warnings *after* getting tests to pass.
* In C# files, use namespace *statements* instead of namespace *blocks* for all new files.
-* Add API doc comments to all new public and internal members.
+* Add API doc comments to all new public and internal members in shipping code under `src`. Tests and samples do not require XML API documentation; do not add XML docs to test or sample members solely to satisfy this rule.
From 580ec8a253aab34763f6a80fa82ae48563028782 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Sun, 4 Oct 2026 12:16:08 -0600
Subject: [PATCH 31/59] Fix version.json schema URL
---
version.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/version.json b/version.json
index 39add9860..e75607ee3 100644
--- a/version.json
+++ b/version.json
@@ -1,5 +1,5 @@
{
- "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json",
+ "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/main/src/NerdBank.GitVersioning/version.schema.json",
"version": "0.1-beta",
"publicReleaseRefSpec": [
"^refs/heads/main$",
From 05c2761e0cc26d1b0917d7f03e36e9327c660862 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Mon, 5 Oct 2026 20:06:16 -0600
Subject: [PATCH 32/59] AOT.props: don't overwrite a test project's
InvariantGlobalization/SatelliteResourceLanguages (#585)
test/AOT.props is imported from test/Directory.Build.targets, which evaluates after
the project body. Unconditional assignments therefore clobbered a project's explicit
opt-out (e.g. InvariantGlobalization=false for culture-dependent tests).
---
test/AOT.props | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/test/AOT.props b/test/AOT.props
index 623ff24da..41b29eda6 100644
--- a/test/AOT.props
+++ b/test/AOT.props
@@ -10,8 +10,9 @@
true
- true
- en
+
+ true
+ en
true
From e798bd7f075778837c2b9b1006fc29e8fcede37a Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 6 Oct 2026 13:52:00 +0000
Subject: [PATCH 33/59] Update Microsoft Testing Platform to 2.5.0 (588)
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 11e38433c..b71f8db90 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -4,7 +4,7 @@
true
true
- 2.4.1
+ 2.5.0
From 6709bd53f98e69616c8e30c960ef6d404adf890c Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Mon, 5 Oct 2026 23:11:51 -0600
Subject: [PATCH 34/59] Add explanatory comment to GitVersionBaseDirectory
property
---
Directory.Build.props | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/Directory.Build.props b/Directory.Build.props
index 1a890354f..3e089f70b 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -23,7 +23,9 @@
true
-
+
$(MSBuildThisFileDirectory)
From d0851f1cd947f7020e83d592a1e0c65cac8ea43a Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 6 Oct 2026 08:23:25 -0600
Subject: [PATCH 35/59] Evaluate the test UseAppHost=false default in
Directory.Build.targets (#586)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
test/Directory.Build.props set UseAppHost=false whenever RuntimeIdentifier was empty.
Directory.Build.props is imported before the project body, so a test project that sets its own
RuntimeIdentifier (e.g. for native dependencies) still saw an empty RuntimeIdentifier and got
UseAppHost=false. If that project is also self-contained, the build fails with NETSDK1067.
Moving the property to test/Directory.Build.targets evaluates the condition after the project body.
RID-less test projects still get UseAppHost=false, and NativeAOT builds and RID-specific projects keep their app host.
Co-authored-by: ๐ค Copilot
---
test/Directory.Build.props | 1 -
test/Directory.Build.targets | 7 +++++++
2 files changed, 7 insertions(+), 1 deletion(-)
diff --git a/test/Directory.Build.props b/test/Directory.Build.props
index 035e949cc..8b3053753 100644
--- a/test/Directory.Build.props
+++ b/test/Directory.Build.props
@@ -8,7 +8,6 @@
false
true
true
- false
diff --git a/test/Directory.Build.targets b/test/Directory.Build.targets
index 95b0658bc..fe457cdf4 100644
--- a/test/Directory.Build.targets
+++ b/test/Directory.Build.targets
@@ -1,5 +1,12 @@
+
+
+ false
+
+
From d35dae1b887e74036c2bc6a8320f48906fd5a39c Mon Sep 17 00:00:00 2001
From: test user
Date: Tue, 6 Oct 2026 08:37:37 -0600
Subject: [PATCH 36/59] Test template expansion on github actions
---
.../template-release-notes.md | 8 ++++
.github/workflows/build.yml | 5 +++
Expand-Template.ps1 | 18 ++++++--
azure-pipelines/expand-template.yml | 25 +----------
tools/Validate-TemplateExpansion.ps1 | 44 +++++++++++++++++++
5 files changed, 74 insertions(+), 26 deletions(-)
create mode 100644 tools/Validate-TemplateExpansion.ps1
diff --git a/.agents/skills/update-library-template/template-release-notes.md b/.agents/skills/update-library-template/template-release-notes.md
index 0ef41c2c0..62fbddaf4 100644
--- a/.agents/skills/update-library-template/template-release-notes.md
+++ b/.agents/skills/update-library-template/template-release-notes.md
@@ -43,6 +43,14 @@ Follow the [TUnit xUnit migration guide](https://tunit.dev/docs/migration/xunit)
Test projects targeting .NET 8 or later are eligible for NativeAOT publication by default.
If a project cannot support NativeAOT, set `false` in its project file; it will still run as managed tests.
+## Template expansion validation
+
+The `tools/Validate-TemplateExpansion.ps1` script and the template-expansion validation steps in Azure Pipelines and GitHub Actions exist only to test Library.Template itself.
+They clean the working tree, run `Expand-Template.ps1`, and build the expanded result after the normal build and test sequence has completed.
+
+Do not bring `tools/Validate-TemplateExpansion.ps1`, `azure-pipelines/expand-template.yml`, or the block marked `TEMPLATE EXPANSION VALIDATION` in `.github/workflows/build.yml` into an existing repository.
+These files and steps are removed automatically when creating a repository through `Expand-Template.ps1`, and should likewise be discarded while merging template updates into an already-expanded repository.
+
## NativeAOT compatibility validation
Shipping projects now set `IsAotCompatible` for target frameworks compatible with .NET 8.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index bfe01efdc..e7763044f 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -69,6 +69,11 @@ jobs:
}
shell: pwsh
timeout-minutes: 3
+ # BEGIN TEMPLATE EXPANSION VALIDATION
+ - name: ๐งช Validate template expansion
+ run: ./tools/Validate-TemplateExpansion.ps1
+ shell: pwsh
+ # END TEMPLATE EXPANSION VALIDATION
docs:
name: ๐ Docs
diff --git a/Expand-Template.ps1 b/Expand-Template.ps1
index 37e96b57c..58051a125 100755
--- a/Expand-Template.ps1
+++ b/Expand-Template.ps1
@@ -178,6 +178,9 @@ try {
Replace-Placeholders -Path "azure-pipelines/build.yml" -Replacements @{
"(?m).*expand-template\.yml(?:\r)?\n" = ""
}
+ Replace-Placeholders -Path ".github/workflows/build.yml" -Replacements @{
+ "(?ms)^ # BEGIN TEMPLATE EXPANSION VALIDATION\r?\n.*?^ # END TEMPLATE EXPANSION VALIDATION\r?\n" = ""
+ }
$YmlReplacements = @{}
if ($CIFeed) {
@@ -193,13 +196,22 @@ try {
git rm :/azure-pipelines/expand-template.yml
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ git rm tools/Validate-TemplateExpansion.ps1
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
# Self-integrity check
- Get-ChildItem -Recurse -File -Exclude bin,obj,README.md,Expand-Template.* |? { -not $_.FullName.Contains("obj") } |% {
- $PLACEHOLDERS = Get-Content -LiteralPath $_.FullName |? { $_.Contains('PLACEHOLDER') }
+ $placeholdersFound = $false
+ $filesToCheck = git ls-files
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ $filesToCheck |? { $_ -notmatch '(^|/)README\.md$' -and $_ -notmatch '(^|/)SKILL\.md$' -and $_ -notmatch '(^|/)Expand-Template\.' } |% {
+ $file = $_
+ $PLACEHOLDERS = Get-Content -LiteralPath $file |? { $_.Contains('PLACEHOLDER') }
if ($PLACEHOLDERS) {
- Write-Error "PLACEHOLDER discovered in $($_.FullName)"
+ $placeholdersFound = $true
+ Write-Error "PLACEHOLDER discovered in $file"
}
}
+ if ($placeholdersFound) { exit 1 }
# Commit the changes
git commit -qm "Expanded template for $LibraryName" -m "This expansion done by the (now removed) Expand-Template.ps1 script."
diff --git a/azure-pipelines/expand-template.yml b/azure-pipelines/expand-template.yml
index 3f4897b8d..bd17051f7 100644
--- a/azure-pipelines/expand-template.yml
+++ b/azure-pipelines/expand-template.yml
@@ -1,26 +1,5 @@
steps:
-- script: |
- dotnet build-server shutdown
- git clean -fdx
- displayName: ๐งน Cleaning repo for template expansion
-- powershell: |
- git config user.name "test user"
- git config user.email "andrewarnott@gmail.com"
- if ($IsLinux) {
- Write-Host "##[group]strong-name-tool installation"
- Write-Host "##[command]sudo apt-get install strong-name-tool"
- sudo apt-get install strong-name-tool 2>&1
- Write-Host "##[endgroup]"
- }
- if ($IsMacOS) {
- Write-Host "##[group]mono installation"
- Write-Host "##[command]brew install mono"
- brew install mono 2>&1
- Write-Host "##[endgroup]"
- }
- ./Expand-Template.ps1 -LibraryName Calc -Author "Andrew Arnott"
- displayName: ๐งช Expanding template
+- powershell: tools/Validate-TemplateExpansion.ps1
+ displayName: ๐งช Validate template expansion
failOnStderr: true
# TODO: Verify that all changes are staged to the git index
-- script: dotnet build
- displayName: ๐ dotnet build (expanded template)
diff --git a/tools/Validate-TemplateExpansion.ps1 b/tools/Validate-TemplateExpansion.ps1
new file mode 100644
index 000000000..d27c49534
--- /dev/null
+++ b/tools/Validate-TemplateExpansion.ps1
@@ -0,0 +1,44 @@
+#!/usr/bin/env pwsh
+
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+
+Push-Location (Join-Path $PSScriptRoot '..')
+try {
+ dotnet build-server shutdown
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ git clean -fdx
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ git config user.name "test user"
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ git config user.email "andrewarnott@gmail.com"
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ if ($IsLinux) {
+ Write-Host "##[group]strong-name-tool installation"
+ Write-Host "##[command]sudo apt-get install strong-name-tool"
+ sudo apt-get install strong-name-tool 2>&1
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ Write-Host "##[endgroup]"
+ }
+
+ if ($IsMacOS) {
+ Write-Host "##[group]mono installation"
+ Write-Host "##[command]brew install mono"
+ brew install mono 2>&1
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+ Write-Host "##[endgroup]"
+ }
+
+ ./Expand-Template.ps1 -LibraryName Calc -Author "Andrew Arnott"
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+
+ dotnet build
+ if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
+} finally {
+ Pop-Location
+}
From 685810e2d485aa311b952e8497ea2c36d6084b81 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 6 Oct 2026 09:41:20 -0600
Subject: [PATCH 37/59] Update dependency dotnet-coverage to v18.12.0 (590)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 160aded9d..97c8b80be 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -10,7 +10,7 @@
"rollForward": false
},
"dotnet-coverage": {
- "version": "18.11.2",
+ "version": "18.12.0",
"commands": [
"dotnet-coverage"
],
From 2139c0c216b5dfe962f11ad52b588beedc794d73 Mon Sep 17 00:00:00 2001
From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com>
Date: Tue, 6 Oct 2026 09:41:30 -0600
Subject: [PATCH 38/59] Update dependency
Microsoft.Testing.Extensions.CodeCoverage to 18.12.0 (591)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
---
Directory.Packages.props | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Directory.Packages.props b/Directory.Packages.props
index b71f8db90..2e52d8e14 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -10,7 +10,7 @@
-
+
From 86208fdbf8fa640799f72b603eeccff7b48bca73 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 6 Oct 2026 09:58:28 -0600
Subject: [PATCH 39/59] update-library-template skill: keep framework-specific
docs/scripts (#584)
* docs(skill): keep framework-specific docs/scripts when consumer is not on TUnit
Library.Template AGENTS.md and test scripts assume TUnit. Tell mergers to retain
xunit/VSTest docs, FailsInCloudTest filters, bespoke dotnet-test-cloud.ps1 behavior,
unused-package hygiene, and repo-specific pipeline steps when those are not equivalent
to the template.
* docs(skill): traversal coverage, non-test projects under test/, placeholders, bespoke NativeAOT flows
Lessons from Copilot review of the 2026-10 template merge wave
(Nerdbank.Json#37, ShapeShift#17, Nerdbank.JsonRpc#114, Nerdbank.MessagePack#1091).
* docs(skill): never re-add GitVersionBaseDirectory in repos with nested version.json
* Avoid the literal placeholder token in the update-library-template skill
Expand-Template.ps1 fails if any remaining file contains the placeholder token, so describe it instead of quoting it.
* Fix expansion check
---------
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
---
.../skills/update-library-template/SKILL.md | 22 +++++++++++++++++++
1 file changed, 22 insertions(+)
diff --git a/.agents/skills/update-library-template/SKILL.md b/.agents/skills/update-library-template/SKILL.md
index a05a2c16e..87e633be4 100644
--- a/.agents/skills/update-library-template/SKILL.md
+++ b/.agents/skills/update-library-template/SKILL.md
@@ -30,6 +30,28 @@ Conflicts in the following files should always be resolved by keeping the curren
* README.md
+### Test framework docs, scripts, and packages
+
+Library.Template's `AGENTS.md`, test docs, and `tools/dotnet-test-cloud.ps1` assume **TUnit** on Microsoft.Testing.Platform.
+Many consumers still use **xunit v3 + MTP**, classic **VSTest xunit**, or hybrid setups (including xunit *extension* libraries such as Xunit.StaFact / Xunit.SkippableFact / Xunit.Combinatorial, whose own tests must stay on xunit).
+
+When merging:
+
+* Prefer the template only where the consumer's prior content was **equivalent**. Do not overwrite repo-specific behavior with template placeholders or TUnit-only defaults.
+* Keep framework-specific **AGENTS.md** (and similar agent/contributor docs): filter syntax (`--filter-method` / `--filter-not-trait` vs `--treenode-filter`), FailsInCloudTest / FailureExpected exclusions, real test project paths, and `--framework` TFMs that match the repo's test projects.
+* Keep bespoke **`tools/dotnet-test-cloud.ps1`** logic when it is not equivalent to the template (per-project loops, NonTUnit vs TUnit splits, MultiRID/NativeAOT discovery, hang/blame timeouts, coverage naming, custom filters). Graft genuine template improvements (for example `IncludeNativeAOT` + `Get-NativeAOTTestProjects.ps1`) onto the repo script only when they fit.
+* Do not add unused template **PackageVersion** entries (`TUnit.Engine`, `xunit.v3.assert.aot`, etc.) to `Directory.Packages.props` unless a project in the repo actually references them (or Central Package Management truly requires them).
+* Preserve repo-specific Azure Pipelines / GitHub Actions build steps that the template lacks equivalents for (for example full **MSBuild@1** on Windows for `net35`, `IncompleteBuild` as `warnNotAsError`).
+
+### Traversal coverage, placeholders, and non-test projects
+
+* `GitVersionBaseDirectory` in the template's `Directory.Build.props` assumes the repo has exactly one `version.json`. If the repo has nested `version.json` files (typically for analyzer or source generator projects that need `assemblyVersion` precision `revision`), the repo has deliberately removed or must not have that property. Never re-add it during a merge; keep the repo's first-parent state.
+* `init.ps1` and CI restore and build only `tools/dirs.proj`. If the solution contains projects outside `src` and `test` (for example `samples/` or `benchmark/`), add them to `tools/dirs.proj` (with `Pack="false"` and/or `Publish="false"` as appropriate) so they are still restored and compiled. Otherwise `dotnet format --no-restore` and sample regressions go unnoticed.
+* `test/Directory.Build.props` marks every project under `test` as a test project. Set `false` on non-test executables there (for example BenchmarkDotNet projects) so they are not run by `dotnet test` or picked up by NativeAOT test discovery.
+* After the merge, search `CONTRIBUTING.md`, `AGENTS.md`, and source headers for template placeholders such as `test/Library.Tests/Library.Tests.csproj`, the company-name placeholder token that `Expand-Template.ps1` replaces with the author, and the template's `net8.0` examples, and replace them with the repo's real project paths, metadata, and NativeAOT-enabled frameworks.
+* If the repo already publishes NativeAOT tests through its own mechanism (for example `MultiRIDProjectReference` items in `test/dirs.proj`), either keep that mechanism and disable the template's discovery (`PublishNativeAOTTests=false`, and drop or don't wire up `Get-NativeAOTTestProjects.ps1`), or replace it fully. Don't leave both half-wired, and update `CONTRIBUTING.md` to describe whichever one the repo actually uses.
+
+
### Deleted files
Very typically, when the incoming change is to a file that was deleted locally, the correct resolution is to re-delete the file.
From 5c5585167639572470a4da4d0c46d56a425eb07d Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 6 Oct 2026 10:18:48 -0600
Subject: [PATCH 40/59] Fix template expansion
---
Expand-Template.ps1 | 23 ++++++++++++++---------
1 file changed, 14 insertions(+), 9 deletions(-)
diff --git a/Expand-Template.ps1 b/Expand-Template.ps1
index c9d899a4f..a27925c0e 100755
--- a/Expand-Template.ps1
+++ b/Expand-Template.ps1
@@ -130,31 +130,34 @@ try {
# Replace placeholders in source files
Replace-Placeholders -Path "src/$LibraryName/Calculator.cs" -Replacements @{
'Library'=$LibraryName
+ 'COMPANY-PLACEHOLDER|Microsoft Corporation'=$Author
}
Replace-Placeholders -Path "test/$LibraryName.Tests/CalculatorTests.cs" -Replacements @{
'Library'=$LibraryName
- 'COMPANY-PLACEHOLDER'=$Author
+ 'COMPANY-PLACEHOLDER|Microsoft Corporation'=$Author
}
Replace-Placeholders -Path "test/AotCompatibilityTest/AotCompatibilityTest.csproj" -Replacements @{
'Library'=$LibraryName
}
Replace-Placeholders -Path "test/AotCompatibilityTest/Program.cs" -Replacements @{
- 'COMPANY-PLACEHOLDER'=$Author
+ 'COMPANY-PLACEHOLDER|Microsoft Corporation'=$Author
}
Replace-Placeholders -Path "src/AssemblyInfo.cs" -Replacements @{
- 'COMPANY-PLACEHOLDER'=$Author
+ 'COMPANY-PLACEHOLDER|Microsoft Corporation'=$Author
}
Replace-Placeholders -Path "src/AssemblyInfo.vb" -Replacements @{
- 'COMPANY-PLACEHOLDER'=$Author
+ 'COMPANY-PLACEHOLDER|Microsoft Corporation'=$Author
}
Replace-Placeholders -Path "LICENSE" -Replacements @{
- 'COMPANY-PLACEHOLDER'=$Author
+ 'COMPANY-PLACEHOLDER|Microsoft Corporation'=$Author
}
Replace-Placeholders -Path "stylecop.json" -Replacements @{
- 'COMPANY-PLACEHOLDER'=$Author
+ 'COMPANY-PLACEHOLDER|Microsoft Corporation'=$Author
}
Replace-Placeholders -Path "Directory.Build.props" -Replacements @{
- 'COMPANY-PLACEHOLDER'=$Author
+ '(?:COMPANY-PLACEHOLDER|Microsoft)'="$Author"
+ '(?:COMPANY-PLACEHOLDER|Microsoft)'="$Author"
+ 'COMPANY-PLACEHOLDER|Microsoft Corporation'=$Author
}
Replace-Placeholders -Path "README.md" -Replacements @{
"(?m)^.*\[NuGet package\][^`r`n]*"="[](https://nuget.org/packages/$LibraryName)"
@@ -173,8 +176,10 @@ try {
Replace-Placeholders -Path "azure-pipelines/build.yml" -Replacements @{
"(?m).*expand-template\.yml(?:\r)?\n" = ""
}
- Replace-Placeholders -Path ".github/workflows/build.yml" -Replacements @{
- "(?ms)^ # BEGIN TEMPLATE EXPANSION VALIDATION\r?\n.*?^ # END TEMPLATE EXPANSION VALIDATION\r?\n" = ""
+ if (Test-Path -LiteralPath ".github/workflows/build.yml") {
+ Replace-Placeholders -Path ".github/workflows/build.yml" -Replacements @{
+ "(?ms)^ # BEGIN TEMPLATE EXPANSION VALIDATION\r?\n.*?^ # END TEMPLATE EXPANSION VALIDATION\r?\n" = ""
+ }
}
$YmlReplacements = @{
From dddc1a3133552934bb9cc92e2fae8da573baf530 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 6 Oct 2026 14:09:29 -0600
Subject: [PATCH 41/59] Use ubuntu 26 agent
---
.github/workflows/docs_validate.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/docs_validate.yml b/.github/workflows/docs_validate.yml
index 7fe515706..af2e9bbe9 100644
--- a/.github/workflows/docs_validate.yml
+++ b/.github/workflows/docs_validate.yml
@@ -11,7 +11,7 @@ on:
jobs:
build:
name: ๐ Doc validation
- runs-on: ubuntu-24.04
+ runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
From 1f4a5a4598996510a937b8f0af17264ad03894a8 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 6 Oct 2026 14:30:52 -0600
Subject: [PATCH 42/59] Revert "Update dependency dotnet-coverage to v18.12.0
(590)"
This reverts commit 685810e2d485aa311b952e8497ea2c36d6084b81.
---
.config/dotnet-tools.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 97c8b80be..160aded9d 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -10,7 +10,7 @@
"rollForward": false
},
"dotnet-coverage": {
- "version": "18.12.0",
+ "version": "18.11.2",
"commands": [
"dotnet-coverage"
],
From 246b3603b15d3c22a7fb6572e8d205e7264513d3 Mon Sep 17 00:00:00 2001
From: Andrew Arnott
Date: Tue, 6 Oct 2026 16:03:59 -0600
Subject: [PATCH 43/59] Authenticate docs validation to Azure Artifacts with
GitHub OIDC (#592)
* Authenticate docs validation to Azure Artifacts with GitHub OIDC
Add immutable-subject federation setup with additive branch trust and readable credential names.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* Match Azure Artifacts credentials to configured NuGet source names
Derive environment credential keys from nuget.config so downstream feeds such as msft_consumption_public authenticate during tool restore.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* Skip docs feed authentication outside the Microsoft organization
Preserve anonymous restore for the template and other owners while retaining fork and Dependabot restrictions.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
* Authenticate same-repository Dependabot documentation checks
Remove the actor exclusion while preserving organization and fork restrictions.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
---
.github/workflows/docs_validate.yml | 33 ++++
CONTRIBUTING.md | 58 ++++++
tools/Configure-GitHubOidc.ps1 | 189 +++++++++++++++++++
tools/tests/Configure-GitHubOidc.Tests.ps1 | 200 +++++++++++++++++++++
tools/tests/DocsFeedCredentials.Tests.ps1 | 75 ++++++++
5 files changed, 555 insertions(+)
create mode 100644 tools/Configure-GitHubOidc.ps1
create mode 100644 tools/tests/Configure-GitHubOidc.Tests.ps1
create mode 100644 tools/tests/DocsFeedCredentials.Tests.ps1
diff --git a/.github/workflows/docs_validate.yml b/.github/workflows/docs_validate.yml
index af2e9bbe9..d99ff49c2 100644
--- a/.github/workflows/docs_validate.yml
+++ b/.github/workflows/docs_validate.yml
@@ -12,6 +12,9 @@ jobs:
build:
name: ๐ Doc validation
runs-on: ubuntu-26.04
+ permissions:
+ contents: read
+ id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
@@ -20,6 +23,36 @@ jobs:
uses: becheran/mlc@b0d0d353e04fcbcfd1d86716103d78a61e5b25dd # v1.2.2
with:
args: --do-not-warn-for-redirect-to https://learn.microsoft.com*,https://dotnet.microsoft.com/*,https://dev.azure.com/*,https://app.codecov.io/* -p docfx -i https://aka.ms/onboardsupport,https://aka.ms/spot,https://msrc.microsoft.com/*,https://www.microsoft.com/msrc*,https://microsoft.com/msrc*,https://www.npmjs.com/package/*,https://get.dot.net/
+ - name: Authenticate to Azure with GitHub OIDC
+ id: azure-login
+ # This tenant requires enterprise-issued assertions. Other owners restore anonymously.
+ # Same-repository dependency update PRs authenticate; fork PRs do not.
+ if: github.repository_owner == 'microsoft' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
+ uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
+ with:
+ client-id: 2799af29-63f3-404f-bdcf-67ff9c70abc9
+ tenant-id: 72f988bf-86f1-41af-91ab-2d7cd011db47
+ allow-no-subscriptions: true
+ audience: api://AzureADTokenExchange
+ - name: Configure Azure Artifacts credentials
+ if: steps.azure-login.outcome == 'success'
+ shell: pwsh
+ run: |
+ $accessToken = az account get-access-token --resource 499b84ac-1321-427f-aa17-267ca6975798 --query accessToken --output tsv
+ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($accessToken)) {
+ throw 'Failed to acquire an Azure DevOps access token.'
+ }
+ Write-Output "::add-mask::$accessToken"
+ $nugetConfig = [xml](Get-Content -LiteralPath nuget.config -Raw)
+ $sources = @($nugetConfig.configuration.packageSources.add | Where-Object {
+ $_.value -match '^https://pkgs\.dev\.azure\.com/' -or $_.value -match '^https://[\w-]+\.pkgs\.visualstudio\.com/'
+ })
+ if ($sources.Count -eq 0) {
+ throw 'No Azure Artifacts package sources were found in nuget.config.'
+ }
+ foreach ($source in $sources) {
+ Add-Content -LiteralPath $env:GITHUB_ENV -Encoding utf8 -Value "NuGetPackageSourceCredentials_$($source.key)=Username=ado;Password=$accessToken;ValidAuthenticationTypes=Basic"
+ }
- name: โ Install prerequisites
run: |
./init.ps1 -UpgradePrerequisites
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index f5cbe92fa..331c5fec1 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -85,6 +85,64 @@ After making a change, you can rebuild the docs site while the localhost server
The `.github/workflows/docs.yml` GitHub Actions workflow publishes the content of these docs to github.io if the workflow itself and [GitHub Pages is enabled for your repository](https://docs.github.com/en/pages/quickstart).
+### Documentation validation feed authentication
+
+The `.github/workflows/docs_validate.yml` workflow uses GitHub OIDC to authenticate
+as the **azure-public/vside package pull** Entra application before `init.ps1` restores packages.
+It requests an Azure DevOps access token and supplies it through
+`NuGetPackageSourceCredentials_` for each Azure Artifacts source in
+`nuget.config`, including repositories that use a different name such as
+`msft_consumption_public`.
+No client secret or PAT is required, and no credentials are written to `nuget.config`.
+
+Run `tools/Configure-GitHubOidc.ps1` from the repository to configure GitHub's immutable
+OIDC subject format and create matching Entra federated credentials.
+Sign in with `gh auth login` and
+`az login --tenant 72f988bf-86f1-41af-91ab-2d7cd011db47 --allow-no-subscriptions` first.
+The signed-in identities need repository administration and permission to manage the app's
+federated credentials. Use `-WhatIf` to preview without making changes.
+The script discovers the calling repository, trusts pull requests plus its default branch,
+and can be rerun without duplicating credentials.
+Use `-Branches microbuild,release` to add other trusted branches; the default branch
+and PR context are always included. The script reports each created or reused credential.
+Credential names identify the owner, repository, and PR or branch context, for example
+`github-AArnott-Library.Template-pull-request` and
+`github-AArnott-Library.Template-branch-main`. Names requiring punctuation replacement
+or truncation include a short hash suffix to preserve uniqueness within Entra's name limits.
+
+This changes OIDC subjects for **all workflows** in the repository. Update any other cloud
+trust policies (including environment subjects) before running it; existing Entra credentials
+are preserved. The script does not configure Azure DevOps feed permissions.
+
+The configuration uses:
+
+* Tenant ID: `72f988bf-86f1-41af-91ab-2d7cd011db47`.
+* Application (client) ID: `2799af29-63f3-404f-bdcf-67ff9c70abc9`.
+* App registration object ID: `1c99fd4b-8b5b-44bb-b214-52bdd958e339`.
+* Federated credentials must use issuer `https://token.actions.githubusercontent.com`
+ and audience `api://AzureADTokenExchange`, with separate subjects for
+ `repo:AArnott@3548/Library.Template@192191543:pull_request` and
+ `repo:AArnott@3548/Library.Template@192191543:ref:refs/heads/main` by default.
+ Owner and repository IDs prevent a recycled namespace from inheriting this trust.
+ Runs on other branches, including `microbuild`, require an additional matching branch
+ subject configured explicitly with `-Branches`.
+* Add the application's service principal to the `azure-public` Azure DevOps organization
+ and grant access to the `vside` project and **Feed and Upstream Reader (Collaborator)**
+ on `msft_consumption`. Reader alone cannot save new packages from upstream sources.
+ The service principal object ID from Entra **Enterprise applications** is distinct
+ from the app registration object ID above.
+
+Authentication is enabled only for repositories owned by the `microsoft` organization,
+because this Entra tenant requires enterprise-issued GitHub assertions.
+Same-repository dependency update PRs, including Renovate and Dependabot, authenticate
+using the job's explicit `id-token: write` permission.
+Repositories owned by other accounts (including this template) and fork PRs
+skip authentication and retain anonymous restore behavior;
+new upstream dependencies may still need to be ingested by a trusted run first.
+Do not switch this workflow to `pull_request_target` to give untrusted PR code credentials.
+Repositories based on this template must configure their own trusted subjects and, if necessary,
+update the application and tenant IDs in the workflow.
+
## Updating dependencies
This repo uses Renovate to keep dependencies current.
diff --git a/tools/Configure-GitHubOidc.ps1 b/tools/Configure-GitHubOidc.ps1
new file mode 100644
index 000000000..a48e739cc
--- /dev/null
+++ b/tools/Configure-GitHubOidc.ps1
@@ -0,0 +1,189 @@
+#!/usr/bin/env pwsh
+
+<#
+.SYNOPSIS
+ Configures immutable GitHub OIDC subjects and matching Entra federated credentials.
+.DESCRIPTION
+ Run from the repository to configure, after signing in with gh auth login and
+ az login --tenant 72f988bf-86f1-41af-91ab-2d7cd011db47 --allow-no-subscriptions.
+ Requires repository administration permission and permission to manage federated
+ credentials on the Entra application. This changes the subject format for ALL
+ OIDC workflows in the repository; migrate any other cloud trust policies first.
+ Existing federated credentials are preserved. Feed permissions are not changed.
+.PARAMETER ApplicationId
+ The Entra application (client) ID to configure.
+.PARAMETER TenantId
+ The tenant in which the application is registered. The active az account must
+ belong to this tenant.
+.PARAMETER Branches
+ Additional branches to trust. Pull requests and the repository's default
+ branch are always included.
+.EXAMPLE
+ ./tools/Configure-GitHubOidc.ps1 -WhatIf
+.EXAMPLE
+ ./tools/Configure-GitHubOidc.ps1 -Branches main,microbuild,release
+#>
+[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')]
+Param(
+ [guid]$ApplicationId = '2799af29-63f3-404f-bdcf-67ff9c70abc9',
+ [guid]$TenantId = '72f988bf-86f1-41af-91ab-2d7cd011db47',
+ [ValidateNotNullOrEmpty()]
+ [string[]]$Branches
+)
+
+$ErrorActionPreference = 'Stop'
+
+function Invoke-CheckedCommand {
+ Param(
+ [string]$Command,
+ [string[]]$Arguments
+ )
+
+ $output = & $Command @Arguments
+ if ($LASTEXITCODE -ne 0) {
+ throw "$Command $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
+ }
+
+ return $output
+}
+
+function Invoke-JsonCommand {
+ Param(
+ [string]$Command,
+ [string[]]$Arguments,
+ $Body
+ )
+
+ # JSON files avoid native-command quoting differences between Windows and Unix.
+ $path = [System.IO.Path]::GetTempFileName()
+ try {
+ $Body | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $path -Encoding utf8
+ Invoke-CheckedCommand $Command ($Arguments + $path)
+ }
+ finally {
+ Remove-Item -LiteralPath $path -Force
+ }
+}
+
+Get-Command gh, az -ErrorAction Stop | Out-Null
+$repoName = (Invoke-CheckedCommand gh @('repo', 'view', '--json', 'nameWithOwner') | ConvertFrom-Json).nameWithOwner
+if ([string]::IsNullOrWhiteSpace($repoName)) {
+ throw 'gh did not identify a repository in the current directory.'
+}
+
+$repo = Invoke-CheckedCommand gh @('api', "repos/$repoName") | ConvertFrom-Json
+if (!$repo.id -or !$repo.owner.id -or !$repo.owner.login -or !$repo.name -or !$repo.default_branch) {
+ throw 'GitHub returned incomplete repository identity information.'
+}
+
+$oidcEndpoint = "repos/$($repo.full_name)/actions/oidc/customization/sub"
+$currentSettings = Invoke-CheckedCommand gh @('api', $oidcEndpoint) | ConvertFrom-Json
+$account = Invoke-CheckedCommand az @('account', 'show', '--output', 'json') | ConvertFrom-Json
+if ($account.tenantId -ne $TenantId.ToString()) {
+ throw "Sign in to tenant $TenantId with az login --tenant $TenantId --allow-no-subscriptions before running this script."
+}
+
+$issuer = 'https://token.actions.githubusercontent.com'
+$audience = 'api://AzureADTokenExchange'
+$prefix = "repo:$($repo.owner.login)@$($repo.owner.id)/$($repo.name)@$($repo.id)"
+$trustedBranches = @($repo.default_branch)
+if ($PSBoundParameters.ContainsKey('Branches')) {
+ $trustedBranches += $Branches
+}
+
+$contexts = @('pull_request')
+foreach ($branch in $trustedBranches) {
+ if ([string]::IsNullOrWhiteSpace($branch)) {
+ throw 'Branch names must not be empty or whitespace.'
+ }
+
+ $contexts += "ref:refs/heads/$($branch.Replace(':', '%3A'))"
+}
+
+$existing = @(Invoke-CheckedCommand az @('ad', 'app', 'federated-credential', 'list', '--id', $ApplicationId.ToString(), '--output', 'json') | ConvertFrom-Json)
+$missing = @()
+foreach ($context in ($contexts | Select-Object -Unique)) {
+ $subject = "${prefix}:$context"
+ $matching = @($existing | Where-Object { $_.issuer -ceq $issuer -and $_.subject -ceq $subject })
+ if ($matching.Count -gt 0) {
+ if (!($matching | Where-Object { @($_.audiences).Count -eq 1 -and $_.audiences[0] -ceq $audience })) {
+ throw "An existing credential for '$subject' has an unexpected audience. Resolve it before continuing."
+ }
+
+ Write-Host "Reusing federated credential '$($matching[0].name)' for '$subject'."
+ continue
+ }
+
+ $contextName = if ($context -eq 'pull_request') {
+ 'pull-request'
+ } else {
+ "branch-$($context.Substring('ref:refs/heads/'.Length).Replace('%3A', ':'))"
+ }
+ $readableName = "github-$($repo.owner.login)-$($repo.name)-$contextName"
+ $name = $readableName -creplace '[^A-Za-z0-9._-]', '-'
+ # Entra names must be URL-friendly and at most 120 characters. Preserve uniqueness
+ # when replacing branch punctuation or shortening a long name.
+ if ($name -cne $readableName -or $name.Length -gt 120) {
+ $sha256 = [System.Security.Cryptography.SHA256]::Create()
+ try {
+ $hash = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($subject))
+ $suffix = ([System.BitConverter]::ToString($hash)).Replace('-', '').Substring(0, 16).ToLowerInvariant()
+ }
+ finally {
+ $sha256.Dispose()
+ }
+
+ $name = "$($name.Substring(0, [Math]::Min($name.Length, 103)))-$suffix"
+ }
+
+ if (@($existing) + @($missing) | Where-Object { $_.name -eq $name }) {
+ throw "Federated credential name '$name' is already used for a different trust. No existing credentials were modified."
+ }
+
+ $missing += @{
+ name = $name
+ issuer = $issuer
+ subject = $subject
+ audiences = @($audience)
+ description = "GitHub Actions for $($repo.full_name): $context"
+ }
+}
+
+$settingsMatch = $currentSettings.use_default -eq $true -and $currentSettings.use_immutable_subject -eq $true -and $currentSettings.sub_claim_prefix -ceq $prefix
+if ($missing.Count -eq 0 -and $settingsMatch) {
+ Write-Host "Immutable OIDC subjects and federated credentials are already configured for $($repo.full_name)."
+ return
+}
+
+Write-Warning 'Changing the repository OIDC subject format affects every workflow, including existing environment and other cloud trust policies.'
+if (!$PSCmdlet.ShouldProcess("$($repo.full_name) and Entra application $ApplicationId", "Ensure $($missing.Count) federated credentials and enable immutable default OIDC subjects")) {
+ return
+}
+
+foreach ($credential in $missing) {
+ Invoke-JsonCommand az @('ad', 'app', 'federated-credential', 'create', '--id', $ApplicationId.ToString(), '--output', 'none', '--parameters') $credential | Out-Null
+ Write-Host "Created federated credential '$($credential.name)' for '$($credential.subject)'."
+}
+
+$credentials = @(Invoke-CheckedCommand az @('ad', 'app', 'federated-credential', 'list', '--id', $ApplicationId.ToString(), '--output', 'json') | ConvertFrom-Json)
+foreach ($context in ($contexts | Select-Object -Unique)) {
+ $subject = "${prefix}:$context"
+ if (!($credentials | Where-Object { $_.issuer -ceq $issuer -and $_.subject -ceq $subject -and @($_.audiences).Count -eq 1 -and $_.audiences[0] -ceq $audience })) {
+ throw "Federated credential verification failed for '$subject'."
+ }
+}
+
+# Verify Azure trust before switching GitHub to the new subject format.
+if (!$settingsMatch) {
+ Invoke-JsonCommand gh @('api', '--method', 'PUT', $oidcEndpoint, '--input') @{
+ use_default = $true
+ use_immutable_subject = $true
+ } | Out-Null
+}
+
+$settings = Invoke-CheckedCommand gh @('api', $oidcEndpoint) | ConvertFrom-Json
+if ($settings.use_default -ne $true -or $settings.use_immutable_subject -ne $true -or $settings.sub_claim_prefix -cne $prefix) {
+ throw 'GitHub OIDC settings did not match the requested immutable default subject format.'
+}
+
+Write-Host "Configured immutable OIDC subjects and federated credentials for $($repo.full_name)."
diff --git a/tools/tests/Configure-GitHubOidc.Tests.ps1 b/tools/tests/Configure-GitHubOidc.Tests.ps1
new file mode 100644
index 000000000..e24a02560
--- /dev/null
+++ b/tools/tests/Configure-GitHubOidc.Tests.ps1
@@ -0,0 +1,200 @@
+#!/usr/bin/env pwsh
+
+# Run with pwsh -File tools/tests/Configure-GitHubOidc.Tests.ps1. No cloud changes are made.
+$ErrorActionPreference = 'Stop'
+$target = Join-Path $PSScriptRoot '..\Configure-GitHubOidc.ps1'
+$tenant = '72f988bf-86f1-41af-91ab-2d7cd011db47'
+$prefix = 'repo:ExampleOrg@123/ExampleRepo@456'
+
+function Assert-True($Condition, [string]$Message) {
+ if (!$Condition) {
+ throw $Message
+ }
+}
+
+function Reset-State {
+ $script:state = @{
+ Settings = @{ use_default = $true; use_immutable_subject = $false; sub_claim_prefix = 'repo:ExampleOrg/ExampleRepo' }
+ Credentials = @()
+ Writes = @()
+ Tenant = $tenant
+ Failure = ''
+ BadVerification = $false
+ MissingCreatedCredentials = $false
+ Prefix = $prefix
+ }
+ $global:ConfigureGitHubOidcTestState = $script:state
+}
+
+function gh {
+ $state = $global:ConfigureGitHubOidcTestState
+ $global:LASTEXITCODE = 0
+ if ($args[0] -eq 'repo') {
+ return '{"nameWithOwner":"ExampleOrg/ExampleRepo"}'
+ }
+
+ if ($args[1] -eq 'repos/ExampleOrg/ExampleRepo') {
+ return '{"id":456,"name":"ExampleRepo","full_name":"ExampleOrg/ExampleRepo","owner":{"id":123,"login":"ExampleOrg"},"default_branch":"trunk"}'
+ }
+
+ if ($args[1] -eq '--method') {
+ $state.Writes += 'gh-put'
+ if ($state.Failure -eq 'gh-put') {
+ $global:LASTEXITCODE = 1
+ return
+ }
+
+ $body = Get-Content -LiteralPath $args[-1] -Raw | ConvertFrom-Json
+ Assert-True ($body.use_default -eq $true -and $body.use_immutable_subject -eq $true) 'Incorrect GitHub settings payload.'
+ $state.Settings = @{ use_default = $true; use_immutable_subject = $true; sub_claim_prefix = $state.Prefix }
+ return
+ }
+
+ Assert-True ($args[1] -eq 'repos/ExampleOrg/ExampleRepo/actions/oidc/customization/sub') 'Unexpected GitHub API endpoint.'
+ if ($state.BadVerification -and $state.Writes -contains 'gh-put') {
+ return '{"use_default":true,"use_immutable_subject":true,"sub_claim_prefix":"wrong"}'
+ }
+
+ return ($state.Settings | ConvertTo-Json)
+}
+
+function az {
+ $state = $global:ConfigureGitHubOidcTestState
+ $global:LASTEXITCODE = 0
+ if ($args[0] -eq 'account') {
+ return (@{ tenantId = $state.Tenant } | ConvertTo-Json)
+ }
+
+ Assert-True ($args[0] -eq 'ad' -and $args[1] -eq 'app' -and $args[2] -eq 'federated-credential') 'Unexpected Azure command.'
+ Assert-True ($args[5] -eq '2799af29-63f3-404f-bdcf-67ff9c70abc9') 'Incorrect application ID.'
+ if ($args[3] -eq 'list') {
+ return (ConvertTo-Json -InputObject @($state.Credentials) -Depth 10)
+ }
+
+ Assert-True ($args[3] -eq 'create') 'Unexpected Azure mutation.'
+ $state.Writes += 'az-create'
+ if ($state.Failure -eq 'az-create') {
+ $global:LASTEXITCODE = 1
+ return
+ }
+
+ $body = Get-Content -LiteralPath $args[-1] -Raw | ConvertFrom-Json
+ Assert-True ($body.issuer -ceq 'https://token.actions.githubusercontent.com') 'Incorrect issuer.'
+ Assert-True ($body.audiences.Count -eq 1 -and $body.audiences[0] -ceq 'api://AzureADTokenExchange') 'Incorrect audience.'
+ if (!$state.MissingCreatedCredentials) {
+ $state.Credentials += $body
+ }
+}
+
+function Assert-Fails([string]$Pattern, [hashtable]$Parameters = @{}) {
+ $failed = $false
+ try {
+ & $target @Parameters -WarningAction SilentlyContinue
+ }
+ catch {
+ Assert-True ($_.Exception.Message -like $Pattern) "Unexpected error: $($_.Exception.Message)"
+ $failed = $true
+ }
+
+ Assert-True $failed 'Expected script to fail.'
+}
+
+Reset-State
+& $target -WarningAction SilentlyContinue
+$subjects = @($script:state.Credentials.subject)
+Assert-True ($subjects.Count -eq 2) 'Expected two federated credentials.'
+Assert-True ($subjects -cnotcontains "${prefix}:ref:refs/heads/microbuild") 'microbuild must not be trusted by default.'
+foreach ($context in @('pull_request', 'ref:refs/heads/trunk')) {
+ Assert-True ($subjects -ccontains "${prefix}:$context") "Missing subject: $context"
+}
+Assert-True (($script:state.Writes -join ',') -eq 'az-create,az-create,gh-put') 'Azure trust must be created before GitHub settings change.'
+Assert-True (@($script:state.Credentials.name | Select-Object -Unique).Count -eq 2) 'Credential names must be unique.'
+Assert-True ($script:state.Credentials.name -ccontains 'github-ExampleOrg-ExampleRepo-pull-request') 'PR credential name must be human-readable.'
+Assert-True ($script:state.Credentials.name -ccontains 'github-ExampleOrg-ExampleRepo-branch-trunk') 'Branch credential name must be human-readable.'
+$firstCredentialName = $script:state.Credentials[0].name
+
+$script:state.Writes = @()
+& $target -WarningAction SilentlyContinue
+Assert-True ($script:state.Writes.Count -eq 0) 'Rerun must not duplicate credentials or rewrite settings.'
+
+Reset-State
+& $target -WhatIf -WarningAction SilentlyContinue
+Assert-True ($script:state.Writes.Count -eq 0) 'WhatIf must not mutate either service.'
+
+Reset-State
+& $target -Branches @('release', 'release') -WarningAction SilentlyContinue
+Assert-True ($script:state.Credentials.Count -eq 3) 'Duplicate branches must be deduplicated while retaining the default branch.'
+Assert-True ($script:state.Credentials.subject -ccontains "${prefix}:ref:refs/heads/release") 'Custom branch subject missing.'
+Assert-True ($script:state.Credentials.subject -ccontains "${prefix}:ref:refs/heads/trunk") 'Explicit branches must not replace the default branch.'
+
+Reset-State
+& $target -Branches microbuild -WarningAction SilentlyContinue
+Assert-True ($script:state.Credentials.Count -eq 3) 'A single additional branch must produce PR, default branch, and additional branch credentials.'
+Assert-True ($script:state.Credentials.subject -ccontains "${prefix}:ref:refs/heads/microbuild") 'Single-string branch argument must be trusted.'
+Assert-True ($script:state.Credentials.subject -ccontains "${prefix}:ref:refs/heads/trunk") 'Default branch must be retained with a single-string argument.'
+
+Reset-State
+& $target -Branches trunk -WarningAction SilentlyContinue
+Assert-True ($script:state.Credentials.Count -eq 2) 'Explicit default branch must not duplicate default trust.'
+
+Reset-State
+& $target -Branches @('feature/widget', 'feature-widget', ('long-' + ('x' * 150))) -WarningAction SilentlyContinue
+$names = @($script:state.Credentials.name)
+Assert-True (($names | Select-Object -Unique).Count -eq 5) 'Sanitized branch names must remain unique.'
+foreach ($name in $names) {
+ Assert-True ($name.Length -le 120 -and $name -cmatch '^[A-Za-z0-9._-]+$') 'Credential names must satisfy Entra limits.'
+ Assert-True ($name.StartsWith('github-ExampleOrg-ExampleRepo-')) 'Sanitized names must retain repository names.'
+}
+Assert-True ($names -ccontains 'github-ExampleOrg-ExampleRepo-branch-feature-widget') 'URL-friendly branch names should not have a hash suffix.'
+$script:state.Writes = @()
+& $target -Branches @('feature/widget', 'feature-widget', ('long-' + ('x' * 150))) -WarningAction SilentlyContinue
+Assert-True ($script:state.Writes.Count -eq 0) 'Sanitized names must remain idempotent.'
+
+Reset-State
+$script:state.Credentials = @(@{ name = 'unrelated'; issuer = 'other'; subject = 'other'; audiences = @('other') })
+& $target -WarningAction SilentlyContinue
+Assert-True ($script:state.Credentials[0].name -eq 'unrelated' -and $script:state.Credentials.Count -eq 3) 'Unrelated trust must be preserved.'
+
+Reset-State
+$script:state.Tenant = 'wrong-tenant'
+Assert-Fails 'Sign in to tenant*'
+Assert-True ($script:state.Writes.Count -eq 0) 'Wrong tenant must fail before mutations.'
+
+Reset-State
+$script:state.Failure = 'az-create'
+Assert-Fails 'az *failed with exit code 1.'
+Assert-True ($script:state.Writes -notcontains 'gh-put') 'Azure failure must prevent GitHub subject changes.'
+
+Reset-State
+$script:state.Failure = 'gh-put'
+Assert-Fails 'gh *failed with exit code 1.'
+$script:state.Failure = ''
+$script:state.Writes = @()
+& $target -WarningAction SilentlyContinue
+Assert-True (($script:state.Writes -join ',') -eq 'gh-put') 'Retry after GitHub failure must reuse newly created Azure credentials.'
+
+Reset-State
+$script:state.Credentials = @(@{ name = 'wrong-audience'; issuer = 'https://token.actions.githubusercontent.com'; subject = "${prefix}:pull_request"; audiences = @('wrong') })
+Assert-Fails '*unexpected audience*'
+Assert-True ($script:state.Writes.Count -eq 0) 'Conflicting audience must fail before mutations.'
+
+Reset-State
+$script:state.Credentials = @(@{ name = $firstCredentialName; issuer = 'other'; subject = 'other'; audiences = @('other') })
+Assert-Fails '*already used for a different trust*'
+Assert-True ($script:state.Writes.Count -eq 0) 'Credential name collisions must fail before mutations.'
+
+Reset-State
+Assert-Fails 'Branch names must not be empty*' @{ Branches = @(' ') }
+Assert-True ($script:state.Writes.Count -eq 0) 'Invalid branches must fail before mutations.'
+
+Reset-State
+$script:state.BadVerification = $true
+Assert-Fails 'GitHub OIDC settings did not match*'
+
+Reset-State
+$script:state.MissingCreatedCredentials = $true
+Assert-Fails 'Federated credential verification failed*'
+Assert-True ($script:state.Writes -notcontains 'gh-put') 'Unverified Azure trust must prevent GitHub changes.'
+
+Write-Host 'Configure-GitHubOidc tests passed.'
+Remove-Variable ConfigureGitHubOidcTestState -Scope Global
diff --git a/tools/tests/DocsFeedCredentials.Tests.ps1 b/tools/tests/DocsFeedCredentials.Tests.ps1
new file mode 100644
index 000000000..2de06dd4b
--- /dev/null
+++ b/tools/tests/DocsFeedCredentials.Tests.ps1
@@ -0,0 +1,75 @@
+#!/usr/bin/env pwsh
+
+$ErrorActionPreference = 'Stop'
+$workflow = Get-Content -LiteralPath (Join-Path $PSScriptRoot '..\..\.github\workflows\docs_validate.yml') -Raw
+$match = [regex]::Match($workflow, '(?ms) - name: Configure Azure Artifacts credentials\r?\n.*? run: \|\r?\n(?