From d99c407a4b09c49f5ad9194ba522409c3468079d Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 27 May 2026 15:35:44 -0400 Subject: [PATCH 1/3] refactor(cli): extract Aspire CLI channel cascade to compute-cli-channel.ps1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #17528 fixed https://github.com/microsoft/aspire/issues/17527 by reordering the channel-detect cascade in `eng/pipelines/templates/build_sign_native.yml` so a release-branch build with `DotNetFinalVersionKind=release` (the steady state during stabilization) resolves to `staging` rather than `stable`. That reorder is the single most load-bearing line in the fix, and it lived as inline `pwsh:` with zero test coverage. A future maintainer reordering the arms back to "obvious" order would re-introduce the staging-misroute and only the next stabilizing build would notice. Extract the cascade into `eng/scripts/compute-cli-channel.ps1` with the same parameters the YAML step previously bound (`-Reason`, `-SourceBranch`, `-PrNumber`, `-Override`, `-VersionKind`). The YAML step keeps the `DotNetFinalVersionKind` resolution (which needs the in-tree `dotnet` + `Versions.props`) and forwards everything else to the script. The script preserves the existing `Write-Host` diagnostics and the `##vso[task.setvariable variable=aspireCliChannel]` emission so the rest of `build_sign_native.yml` is unaffected; the resolved channel additionally shows up on stdout via `Write-Output` for non-AzDO callers. Add `tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs` with a Theory-based set of cases that reuses the existing `PowerShellCommand` helper. Cascade cases covered: - release-branch + versionKind=release → `staging` (the #17527 fix-pin) - release-branch + versionKind=prerelease → `staging` (early stabilization) - internal/release branch → `staging` - main + versionKind=release → `stable` (unchanged) - main + versionKind=prerelease → `daily` (unchanged) - override `stable` beats the release-branch arm (the GA ship-build path) - override `staging` and `daily` route as requested - override `auto` falls through to the cascade - PullRequest with numeric `prNumber` → `pr-` Negative cases: - PullRequest with the unresolved `$(System.PullRequest.PullRequestNumber)` macro string → throws (defense-in-depth that was previously untested) - override `garbage` → throws (the `-notin` validation arm) - override `Stable` (mixed case) → normalized to `stable` (pins the `.ToLowerInvariant()` workaround for PowerShell's case-insensitive `-notin`; without it, the binary would build but `IdentityChannelReader.IsValidChannel` would reject it at CLI startup) No behavior change for the source build itself: the script's output (both the `##vso[task.setvariable]` and the human-readable diagnostics) matches the previous inline block byte-for-byte. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- eng/pipelines/templates/build_sign_native.yml | 75 ++------ eng/scripts/compute-cli-channel.ps1 | 97 ++++++++++ .../ComputeCliChannelTests.cs | 166 ++++++++++++++++++ 3 files changed, 277 insertions(+), 61 deletions(-) create mode 100644 eng/scripts/compute-cli-channel.ps1 create mode 100644 tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs diff --git a/eng/pipelines/templates/build_sign_native.yml b/eng/pipelines/templates/build_sign_native.yml index ac72bbde30d..45d58ee195c 100644 --- a/eng/pipelines/templates/build_sign_native.yml +++ b/eng/pipelines/templates/build_sign_native.yml @@ -92,71 +92,24 @@ jobs: # be visible here, and there is no other AzDO consumer of this value. # The variable is job-scoped (no isOutput=true) since it is consumed by # a later step in the same job. + # + # The cascade itself lives in eng/scripts/compute-cli-channel.ps1 so it + # can be unit-tested by ComputeCliChannelTests; this step only resolves + # DotNetFinalVersionKind (which needs the in-tree dotnet + Versions.props) + # and forwards AzDO build context to the script. - pwsh: | $ErrorActionPreference = 'Stop' - $reason = '$(Build.Reason)' - $sourceBranch = '$(Build.SourceBranch)' - $prNumber = '$(System.PullRequest.PullRequestNumber)' - # Template-time substitution: the value is the resolved - # aspireCliChannelOverride parameter literal, never a runtime - # variable. Quoting protects an empty/default value. - $override = '${{ parameters.aspireCliChannelOverride }}' - Write-Host "Build.Reason: '$reason'" - Write-Host "Build.SourceBranch: '$sourceBranch'" - Write-Host "System.PullRequest.PullRequestNumber: '$prNumber'" - Write-Host "aspireCliChannelOverride: '$override'" - $versionKind = & "$(Build.SourcesDirectory)/$(dotnetScript)" msbuild "$(Build.SourcesDirectory)/eng/Versions.props" -getProperty:DotNetFinalVersionKind $versionKind = $versionKind.Trim() - Write-Host "DotNetFinalVersionKind: '$versionKind'" - - if ($override -and $override -ne 'auto') { - # Operator override path. Validate against the same accepted set - # that IdentityChannelReader.IsValidChannel enforces at CLI startup - # so a typo here fails the pipeline step rather than producing a - # binary that refuses to boot. pr- is intentionally excluded - # from the override set — PR builds always come from the - # PullRequest reason arm below. - if ($override -notin @('stable', 'staging', 'daily')) { - throw "aspireCliChannelOverride='$override' is not one of: auto, stable, staging, daily." - } - $channel = $override.ToLowerInvariant() - } - elseif ($reason -eq 'PullRequest') { - # Defense in depth: validate digit-only PR number rather than just - # non-emptiness. If the agent ever returns the literal macro string - # (e.g. '$(System.PullRequest.PullRequestNumber)' unresolved) this - # catches it at compute time rather than letting an invalid - # AspireCliChannel value reach the build and be rejected later by - # IdentityChannelReader.IsValidChannel — clearer failure attribution. - if ($prNumber -notmatch '^\d+$') { - throw "Build.Reason is 'PullRequest' but System.PullRequest.PullRequestNumber was not a numeric PR number: '$prNumber'." - } - # Bake the resolved hive label directly into AspireCliChannel. The CLI - # consumes this verbatim and avoids the legacy "pr" + parsed-PrNumber join. - $channel = "pr-$prNumber" - } elseif ($sourceBranch -match '^refs/heads/(release|internal/release)/') { - # Release/internal-release branches always produce staging artifacts — - # they are published to the staging feed for dogfooding and only later - # promoted to nuget.org. This must be checked BEFORE the - # `versionKind == release` arm, because a release-branch build also sets - # StabilizePackageVersion=true (→ DotNetFinalVersionKind=release) once - # we are stabilizing for ship. Without this ordering, the stabilized - # staging build would bake AspireCliChannel=stable and `aspire init` - # would drop a nuget.config with no staging feed mapping, causing - # `aspire add` to resolve Aspire.* packages from nuget.org (older - # versions) or fail to resolve the +sha-pinned Aspire.AppHost.Sdk. - # See https://github.com/microsoft/aspire/issues/17527. - $channel = 'staging' - } elseif ($versionKind -eq 'release') { - $channel = 'stable' - } else { - # main and any other branch fall through to daily - $channel = 'daily' - } - - Write-Host "Aspire CLI channel: $channel" - Write-Host "##vso[task.setvariable variable=aspireCliChannel]$channel" + # Template-time substitution for $Override: the value is the resolved + # aspireCliChannelOverride parameter literal, never a runtime variable. + # Quoting protects an empty/default value. + & "$(Build.SourcesDirectory)/eng/scripts/compute-cli-channel.ps1" ` + -Reason '$(Build.Reason)' ` + -SourceBranch '$(Build.SourceBranch)' ` + -PrNumber '$(System.PullRequest.PullRequestNumber)' ` + -Override '${{ parameters.aspireCliChannelOverride }}' ` + -VersionKind $versionKind name: computeCliChannel displayName: 🟣Determine Aspire CLI channel diff --git a/eng/scripts/compute-cli-channel.ps1 b/eng/scripts/compute-cli-channel.ps1 new file mode 100644 index 00000000000..238bedc26e0 --- /dev/null +++ b/eng/scripts/compute-cli-channel.ps1 @@ -0,0 +1,97 @@ +# Computes the Aspire CLI channel that gets baked into the native binary as +# the AspireCliChannel MSBuild property. The resolved value drives the staging +# vs stable feed-resolution behavior in PackagingService at CLI startup, so the +# same accepted set (stable | staging | daily | pr-) that +# IdentityChannelReader.IsValidChannel enforces at runtime is enforced here. +# +# Consumed by eng/pipelines/templates/build_sign_native.yml: the YAML step +# resolves DotNetFinalVersionKind from eng/Versions.props, then invokes this +# script. AzDO callers pick up the resolved channel via the +# `##vso[task.setvariable variable=aspireCliChannel]` logging command; other +# callers (unit tests, ad-hoc dev runs) consume the final `Write-Output` line. +# +# See https://github.com/microsoft/aspire/issues/17527 for the bug whose fix +# required this cascade reorder, and the accompanying ComputeCliChannelTests +# for the cases this script must continue to satisfy. + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$Reason, + + [Parameter(Mandatory = $true)] + [string]$SourceBranch, + + [string]$PrNumber = '', + + [string]$Override = 'auto', + + [Parameter(Mandatory = $true)] + [string]$VersionKind +) + +$ErrorActionPreference = 'Stop' + +Write-Host "Build.Reason: '$Reason'" +Write-Host "Build.SourceBranch: '$SourceBranch'" +Write-Host "System.PullRequest.PullRequestNumber: '$PrNumber'" +Write-Host "aspireCliChannelOverride: '$Override'" +Write-Host "DotNetFinalVersionKind: '$VersionKind'" + +if ($Override -and $Override -ne 'auto') { + # Operator override path. Validate against the same accepted set that + # IdentityChannelReader.IsValidChannel enforces at CLI startup so a typo + # here fails the pipeline step rather than producing a binary that refuses + # to boot. pr- is intentionally excluded from the override set — + # PR builds always come from the PullRequest reason arm below. + if ($Override -notin @('stable', 'staging', 'daily')) { + throw "aspireCliChannelOverride='$Override' is not one of: auto, stable, staging, daily." + } + # Normalize after validation: PowerShell's `-notin` above is case-insensitive + # by default, but the runtime `IdentityChannelReader.IsValidChannel` is + # case-sensitive — without this, a capitalized override would build cleanly + # but produce a binary that throws at startup with + # "Assembly metadata 'AspireCliChannel' has invalid value 'Stable'". + $channel = $Override.ToLowerInvariant() +} +elseif ($Reason -eq 'PullRequest') { + # Defense in depth: validate digit-only PR number rather than just + # non-emptiness. If the agent ever returns the literal macro string + # (e.g. '$(System.PullRequest.PullRequestNumber)' unresolved) this catches + # it at compute time rather than letting an invalid AspireCliChannel value + # reach the build and be rejected later by IdentityChannelReader.IsValidChannel + # — clearer failure attribution. + if ($PrNumber -notmatch '^\d+$') { + throw "Build.Reason is 'PullRequest' but System.PullRequest.PullRequestNumber was not a numeric PR number: '$PrNumber'." + } + # Bake the resolved hive label directly into AspireCliChannel. The CLI + # consumes this verbatim and avoids the legacy "pr" + parsed-PrNumber join. + $channel = "pr-$PrNumber" +} elseif ($SourceBranch -match '^refs/heads/(release|internal/release)/') { + # Release/internal-release branches always produce staging artifacts — + # they are published to the staging feed for dogfooding and only later + # promoted to nuget.org. This must be checked BEFORE the + # `versionKind == release` arm, because a release-branch build also sets + # StabilizePackageVersion=true (→ DotNetFinalVersionKind=release) once we + # are stabilizing for ship. Without this ordering, the stabilized staging + # build would bake AspireCliChannel=stable and `aspire init` would drop a + # nuget.config with no staging feed mapping, causing `aspire add` to + # resolve Aspire.* packages from nuget.org (older versions) or fail to + # resolve the +sha-pinned Aspire.AppHost.Sdk. + # See https://github.com/microsoft/aspire/issues/17527. + $channel = 'staging' +} elseif ($VersionKind -eq 'release') { + $channel = 'stable' +} else { + # main and any other branch fall through to daily + $channel = 'daily' +} + +Write-Host "Aspire CLI channel: $channel" +# AzDO logging command for build_sign_native.yml: subsequent steps in the +# same job read the resolved value via $(aspireCliChannel). Non-AzDO callers +# (tests, ad-hoc dev runs) ignore the prefix. +Write-Host "##vso[task.setvariable variable=aspireCliChannel]$channel" +# Emit the channel on stdout so callers can capture it via $(pwsh -File ...) +# without parsing Write-Host diagnostics or AzDO logging-command prefixes. +Write-Output $channel diff --git a/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs new file mode 100644 index 00000000000..066b124e5bc --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs @@ -0,0 +1,166 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.TestUtilities; +using Xunit; + +namespace Infrastructure.Tests; + +/// +/// Tests for eng/scripts/compute-cli-channel.ps1, which resolves the +/// AspireCliChannel MSBuild property baked into the native CLI binary +/// from AzDO build context (Build.Reason, Build.SourceBranch, +/// System.PullRequest.PullRequestNumber, the queue-time +/// aspireCliChannelOverride parameter, and DotNetFinalVersionKind). +/// +/// The script is the canonical computation invoked by +/// eng/pipelines/templates/build_sign_native.yml. These tests pin the +/// cascade ordering — specifically that release/* and +/// internal/release/* source branches resolve to staging even +/// when DotNetFinalVersionKind=release (which is the steady state +/// during stabilization). A regression of that ordering reintroduces +/// https://github.com/microsoft/aspire/issues/17527. +/// +public sealed class ComputeCliChannelTests +{ + private readonly string _scriptPath; + private readonly ITestOutputHelper _output; + + public ComputeCliChannelTests(ITestOutputHelper output) + { + _output = output; + _scriptPath = Path.Combine(FindRepoRoot(), "eng", "scripts", "compute-cli-channel.ps1"); + } + + public static TheoryData CascadeCases() => new() + { + // The bug fix from https://github.com/microsoft/aspire/issues/17527: a + // stabilizing release-branch build sets DotNetFinalVersionKind=release + // (StabilizePackageVersion=true), but the resulting binary must still + // identify as staging because the packages have not yet been promoted + // to nuget.org. Without the release-branch arm running before the + // versionKind=release arm, the staging dogfood build would bake + // AspireCliChannel=stable and aspire init would drop a nuget.config + // with no staging feed mapping. + { "release branch + versionKind=release", "IndividualCI", "refs/heads/release/13.4", "", "auto", "release", "staging" }, + { "release branch + versionKind=prerelease (early stabilization)", "IndividualCI", "refs/heads/release/13.4", "", "auto", "prerelease", "staging" }, + { "main + versionKind=release", "IndividualCI", "refs/heads/main", "", "auto", "release", "stable" }, + { "main + versionKind=prerelease", "IndividualCI", "refs/heads/main", "", "auto", "prerelease", "daily" }, + { "internal/release branch", "IndividualCI", "refs/heads/internal/release/13.4", "", "auto", "release", "staging" }, + { "override=stable beats release-branch arm", "Manual", "refs/heads/release/13.4", "", "stable", "release", "stable" }, + { "override=staging on main", "Manual", "refs/heads/main", "", "staging", "release", "staging" }, + { "override=daily on release branch", "Manual", "refs/heads/release/13.4", "", "daily", "release", "daily" }, + { "override=auto falls through cascade", "IndividualCI", "refs/heads/main", "", "auto", "release", "stable" }, + { "PullRequest with numeric prNumber", "PullRequest", "refs/pull/17528/merge", "17528", "auto", "prerelease", "pr-17528" }, + }; + + [Theory] + [MemberData(nameof(CascadeCases))] + [RequiresTools(["pwsh"])] + public async Task ResolvesExpectedChannel(string description, string reason, string sourceBranch, string prNumber, string @override, string versionKind, string expectedChannel) + { + _output.WriteLine($"Case: {description}"); + + var result = await RunScript(reason, sourceBranch, prNumber, @override, versionKind); + + result.EnsureSuccessful("compute-cli-channel.ps1 failed"); + Assert.Contains($"Aspire CLI channel: {expectedChannel}", result.Output); + // The AzDO logging command is the consumer contract for + // build_sign_native.yml; a refactor that drops it would silently break + // every later step that reads $(aspireCliChannel). Pin it explicitly. + Assert.Contains($"##vso[task.setvariable variable=aspireCliChannel]{expectedChannel}", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task ThrowsWhenPullRequestPrNumberIsNotNumeric() + { + // Catches the case where the AzDO agent leaks the unresolved + // System.PullRequest.PullRequestNumber macro string into the build — + // failing here gives clearer attribution than letting + // IdentityChannelReader.IsValidChannel reject the baked value at CLI + // startup time. + var result = await RunScript( + reason: "PullRequest", + sourceBranch: "refs/pull/17528/merge", + prNumber: "$(System.PullRequest.PullRequestNumber)", + @override: "auto", + versionKind: "prerelease"); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("was not a numeric PR number", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task ThrowsWhenOverrideIsInvalid() + { + // The AzDO `values:` enum in azure-pipelines.yml constrains the + // top-level queue-time UI to the accepted set, but a direct + // template-caller could still pass an arbitrary string — this is the + // defense-in-depth that catches that path. + var result = await RunScript( + reason: "Manual", + sourceBranch: "refs/heads/main", + prNumber: "", + @override: "garbage", + versionKind: "prerelease"); + + Assert.NotEqual(0, result.ExitCode); + // The PowerShell exception formatter wraps long messages at terminal width; match + // on a substring guaranteed to live on a single output line. + Assert.Contains("aspireCliChannelOverride='garbage'", result.Output); + } + + [Fact] + [RequiresTools(["pwsh"])] + public async Task CaseInsensitiveOverrideIsNormalizedToLowercase() + { + // PowerShell's `-notin` operator used for validation is + // case-insensitive by default, but the runtime + // IdentityChannelReader.IsValidChannel is case-sensitive. Without + // explicit normalization, a capitalized override would pass validation + // and produce a binary that throws at startup. Pin the normalization + // so that defensive behavior doesn't regress. + var result = await RunScript( + reason: "Manual", + sourceBranch: "refs/heads/release/13.4", + prNumber: "", + @override: "Stable", + versionKind: "release"); + + result.EnsureSuccessful("compute-cli-channel.ps1 failed"); + Assert.Contains("Aspire CLI channel: stable", result.Output); + } + + private async Task RunScript(string reason, string sourceBranch, string prNumber, string @override, string versionKind) + { + using var cmd = new PowerShellCommand(_scriptPath, _output) + .WithTimeout(TimeSpan.FromMinutes(1)); + + var args = new List + { + "-Reason", $"\"{reason}\"", + "-SourceBranch", $"\"{sourceBranch}\"", + "-PrNumber", $"\"{prNumber}\"", + "-Override", $"\"{@override}\"", + "-VersionKind", $"\"{versionKind}\"" + }; + + return await cmd.ExecuteAsync([.. args]); + } + + private static string FindRepoRoot() + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null) + { + if (File.Exists(Path.Combine(dir.FullName, "Aspire.slnx"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Could not find repository root"); + } +} From e4c219b10c586b9782287aadeff16ad6db763fb1 Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 27 May 2026 15:36:03 -0400 Subject: [PATCH 2/3] test(cli): pin PackagingService identity+requested ordering against #17527 regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fix that landed in PR #17528 reorders the `if/else` in `PackagingService.GetChannelsAsync` so the `stagingIdentityChannel` arm runs before `stagingChannelConfigured || stagingChannelRequested`. The new test added in that PR — `GetChannelsAsync_WhenIdentityChannelIsStagingOnStableShapedCli_DefaultsToStableQuality` — calls `GetChannelsAsync()` with no `requestedChannelName`, so a future refactor that put `stagingChannelRequested` first (and forced `Both`) would still pass that test, silently re-introducing the #17527 misroute on the combinations that actually occur in production: - `InitCommand.cs:671` — `GetChannelsAsync(ct, identityChannel)` on a staging CLI running `aspire init`. - `IntegrationPackageSearchService.cs:26` (used by `aspire add`) and `DotNetBasedAppHostServerProject.cs:334` once a project's aspire.config.json pins `channel: staging`. Add three tests that exercise both signals together: - identity=staging + requested=staging + stable-shaped → `Stable`. This is the explicit ordering pin: if `stagingChannelRequested` ran first the result would be `Both` and the test fails. - identity=staging + requested=staging + prerelease-shaped → `Both`. The companion case asserts the identity arm itself consults `_isStableShapedCliVersion`; a refactor that dropped the predicate from the identity arm would flip this prerelease case from `Both` to `Stable` and silently misroute Aspire.* away from the shared daily feed where prerelease-shaped staging packages actually live. - identity=staging + `configuration["channel"]=staging` + stable-shaped → `Stable`. Same shape but for the configured-arm path, which is what `aspire add` against a staging-pinned project hits. No production code changes. Verified by running just the new tests: `dotnet test --project tests/Aspire.Cli.Tests/Aspire.Cli.Tests.csproj --no-launch-profile -- --filter-class "*.PackagingServiceTests" --filter-not-trait "quarantined=true" --filter-not-trait "outerloop=true"` → 53/53 pass (50 existing + 3 new). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Packaging/PackagingServiceTests.cs | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs index 1780f14cf3e..62b490a26e9 100644 --- a/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs +++ b/tests/Aspire.Cli.Tests/Packaging/PackagingServiceTests.cs @@ -111,6 +111,98 @@ public async Task GetChannelsAsync_WhenIdentityChannelIsStagingOnStableShapedCli Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); } + [Fact] + public async Task GetChannelsAsync_WhenIdentityIsStagingAndRequestedIsStagingOnStableShapedCli_DefaultsToStableQuality() + { + // Regression-pin for the if/else ordering in PackagingService.GetChannelsAsync: the + // `stagingIdentityChannel` arm MUST run before `stagingChannelConfigured || stagingChannelRequested`. + // The combination identity=staging + requested=staging is the steady state for + // `aspire init` on a staging CLI (InitCommand passes `requestedChannelName: staging` + // when identity is staging), so a refactor that reordered the if/else would silently + // re-introduce the https://github.com/microsoft/aspire/issues/17527 misroute even + // though the existing identity-only test would still pass. Lock both arms together + // by asserting that requested=staging does NOT override the identity-driven quality. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Staging); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance, isStableShapedCliVersion: () => true); + + var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityIsStagingAndRequestedIsStagingOnPrereleaseShapedCli_DefaultsToBothQuality() + { + // Companion to the stable-shaped test above: when both identity and requested name + // are staging, the synthesized quality must be driven by the CLI version shape — the + // identity arm itself consults `_isStableShapedCliVersion`. A refactor that dropped + // that predicate from the identity arm (or routed all requested=staging callers + // through the explicit-opt-in arm) would change this prerelease case from + // Both → Stable and silently misroute Aspire.* away from the shared daily feed where + // prerelease-shaped staging packages actually live. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Staging); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance, isStableShapedCliVersion: () => false); + + var channels = await packagingService.GetChannelsAsync(requestedChannelName: PackageChannelNames.Staging).DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Both, stagingChannel.Quality); + } + + [Fact] + public async Task GetChannelsAsync_WhenIdentityIsStagingAndConfigurationChannelIsStagingOnStableShapedCli_DefaultsToStableQuality() + { + // Same shape as the `requested=staging` companion, but for the configuration arm — + // `stagingChannelConfigured` (reading `_configuration["channel"]`) is the path + // exercised by `aspire add` / `IntegrationPackageSearchService` and + // `DotNetBasedAppHostServerProject` once a project's aspire.config.json pins + // `channel: staging`. A refactor that ran `stagingChannelConfigured` before + // `stagingIdentityChannel` would silently re-introduce the #17527 misroute for + // every staging-CLI invocation against a staging-pinned project. + using var workspace = TemporaryWorkspace.Create(outputHelper); + var tempDir = workspace.WorkspaceRoot; + var hivesDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "hives")); + var cacheDir = new DirectoryInfo(Path.Combine(tempDir.FullName, ".aspire", "cache")); + var executionContext = new CliExecutionContext(tempDir, hivesDir, cacheDir, new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-runtimes")), new DirectoryInfo(Path.Combine(Path.GetTempPath(), "aspire-test-logs")), "test.log", identityChannel: PackageChannelNames.Staging); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["channel"] = PackageChannelNames.Staging, + [PackagingService.OverrideStagingFeedConfigKey] = "https://example.com/nuget/v3/index.json" + }) + .Build(); + var packagingService = new PackagingService(executionContext, new FakeNuGetPackageCache(), new TestFeatures(), configuration, NullLogger.Instance, isStableShapedCliVersion: () => true); + + var channels = await packagingService.GetChannelsAsync().DefaultTimeout(); + + var stagingChannel = channels.First(c => c.Name == PackageChannelNames.Staging); + Assert.Equal(PackageChannelQuality.Stable, stagingChannel.Quality); + } + [Fact] public async Task GetChannelsAsync_WhenRequestedChannelIsStaging_IncludesStagingChannel() { From 0053459f1fecbc52e3a2ca2413bf2e37cbca829d Mon Sep 17 00:00:00 2001 From: Ankit Jain Date: Wed, 27 May 2026 15:39:22 -0400 Subject: [PATCH 3/3] ci(release): guard GA ship build against AspireCliChannel != stable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After PR #17528 reordered the channel-detect cascade so release-branch source builds default to `AspireCliChannel=staging`, the GA ship path silently depends on the release manager remembering to queue `microsoft-aspire` (definition 1602) with `aspireCliChannelOverride=stable`. Following the release docs at face value still selects "any successful signed build", so a single missed override at queue time publishes a `staging`-baked CLI binary to nuget.org — `aspire init` then writes a nuget.config that maps Aspire.* to the staging feed for end users, and `aspire add` 404s once the SHA-derived darc-pub feed for that build expires. https://github.com/microsoft/aspire/issues/17527 is the underlying issue. Add a release-pipeline guard that fails fast if the selected source build is not stable-channel. Mechanism, three pieces: - `eng/scripts/compute-cli-channel.ps1` additionally emits `##vso[build.addbuildtag]aspire-cli-channel - ` so the source build's tag set carries the resolved channel. The tag shape mirrors the existing `release-version - X.Y.Z` and `BAR ID - ` tags so the consumer can reuse the same tag-fetch REST call. `build.addbuildtag` is idempotent across jobs in the same build, so per-RID `build_sign_native` invocations all setting the same tag is safe — AzDO dedupes them on the build. - `eng/pipelines/release-publish-nuget.yml` adds a `Validate Source Build CLI Channel` step right after the BAR ID extraction. It fetches the source build's tags, finds the `aspire-cli-channel - *` tag, and fails the pipeline unless the resolved channel is `stable`. The step is gated by a new `AllowNonStableCliChannel` advanced parameter (default `false`) — the documented escape hatch for an intentional non-stable ship. `DryRun` also bypasses the guard so the pipeline remains testable against any source build. Error messages name the cause (missing tag vs. non-stable channel), point at the `microsoft-aspire` definition and the `aspireCliChannelOverride=stable` queue-time parameter, and tell the operator how to bypass. - `docs/release-process.md` updates: the Signed Build prerequisite highlights the override requirement; the source-build selection step tells the release manager to verify the `aspire-cli-channel - stable` tag at pick time; the Advanced parameters table documents `AllowNonStableCliChannel`. Test coverage in `ComputeCliChannelTests.cs` extends the Theory-based output assertions to also pin the `##vso[build.addbuildtag]aspire-cli-channel - ` emission, so a future refactor that drops it would break the test suite rather than silently disabling the pipeline guard. 13.5+ planning: this guard is a 13.4 safety net for the same risk that https://github.com/microsoft/aspire/issues/17550 is scoped to remove entirely by decoupling channel identity from build-time baking via a sidecar manifest written at acquisition time. Once #17550 lands, the queue-time override and this guard become moot and can be removed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/release-process.md | 18 +++ eng/pipelines/release-publish-nuget.yml | 118 ++++++++++++++++++ eng/scripts/compute-cli-channel.ps1 | 8 ++ .../ComputeCliChannelTests.cs | 6 + 4 files changed, 150 insertions(+) diff --git a/docs/release-process.md b/docs/release-process.md index a3c8cb00926..c1ff6a7022a 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -45,6 +45,15 @@ Before starting a release: 1. **Signed Build**: Have a successful signed build from the official [`microsoft-aspire`](https://dev.azure.com/dnceng/internal/_build?definitionId=1602) pipeline - The build will be selected from a dropdown when running the release pipeline - The build should have a `BAR ID - NNNNNN` tag (auto-extracted by the pipeline) + - **For GA ships**: queue the source build with `aspireCliChannelOverride: stable`. + The default `auto` resolution bakes `AspireCliChannel=staging` for every + `release/*` source build (correct for dogfood/staging), so a stable ship + requires the explicit override. The release pipeline enforces this with a + `Validate Source Build CLI Channel` guard that fetches the source build's + tags and fails if `aspire-cli-channel - stable` is not present. + See [microsoft/aspire#17527](https://github.com/microsoft/aspire/issues/17527) + for the bug this guard prevents, and the `AllowNonStableCliChannel` + parameter below for the documented escape hatch. 2. **Release Branch**: Ensure the release branch exists (e.g., `release/9.2`) @@ -97,6 +106,7 @@ Before starting a release: | `SkipGitHubTasks` | Set `true` to skip dispatching the GH workflow | `false` | | `SkipReleaseAssets` | Set `true` to skip uploading aspire-cli-* assets to the GitHub release | `false` | | `SkipHomebrewValidation` | Set `true` if re-running after a successful Homebrew cask validation (validates against the live GH release) | `false` | + | `AllowNonStableCliChannel` | **Escape hatch**: bypass the `Validate Source Build CLI Channel` guard. Leave `false` for any GA ship — the guard catches a source build queued without `aspireCliChannelOverride=stable`. Setting this to `true` lets a source build with `AspireCliChannel=staging` (or any other value) publish to nuget.org. There is currently no known use case; the parameter exists so a real emergency override is documented rather than ad-hoc. | `false` | | `GitHubTasksWorkflowRef` | Ref to load `release-github-tasks.yml` from when dispatching. Only affects the workflow source — the release branch/commit are passed via inputs. Override only when testing pipeline changes on a topic branch. | `main` | 4. Select the **Resources** button in the bottom right, then select the source build from the `aspire-build` dropdown @@ -109,6 +119,14 @@ Before starting a release: source build (after the tag-emitting change in `azure-pipelines.yml` is on that release branch) or pass an explicit `ReleaseVersion` override below. + - **For GA ships**: also verify the source build has the + `aspire-cli-channel - stable` tag. If it shows `aspire-cli-channel - staging` + (or any other value), the build was queued without + `aspireCliChannelOverride=stable` — pick a different source build, or + queue a new one. The `Validate Source Build CLI Channel` step will fail + the pipeline if you proceed without a `stable`-tagged build (unless + `AllowNonStableCliChannel` is set, which you almost certainly do not + want for a GA ship). 5. Click "Run" and monitor the pipeline. The final stage (`GitHubTasks`) dispatches `release-github-tasks.yml`, waits for it to complete, and then uploads the `aspire-cli-*` archives from the source build's diff --git a/eng/pipelines/release-publish-nuget.yml b/eng/pipelines/release-publish-nuget.yml index fc6193373e7..41c9896eca2 100644 --- a/eng/pipelines/release-publish-nuget.yml +++ b/eng/pipelines/release-publish-nuget.yml @@ -77,6 +77,24 @@ parameters: type: boolean default: false + # Source builds from `release/*` branches bake `AspireCliChannel=staging` by + # default (see eng/pipelines/templates/build_sign_native.yml). A GA ship build + # must explicitly queue `microsoft-aspire` with `aspireCliChannelOverride=stable` + # so the CLI binary identifies as `stable` and `aspire init` drops the + # nuget.org-only nuget.config that GA users expect. To enforce this, the + # `Validate Source Build CLI Channel` step below fetches the source build's + # tags and fails the pipeline unless the `aspire-cli-channel - stable` tag is + # present. Set this parameter to `true` only in the rare case of intentionally + # publishing a non-stable-channel build to nuget.org (currently no known + # use case — exists as a documented escape hatch). + # See: https://github.com/microsoft/aspire/pull/17528 (#17527) and + # https://github.com/microsoft/aspire/issues/17550 (longer-term 13.5+ work + # to decouple channel identity from build-time baking). + - name: AllowNonStableCliChannel + displayName: '[Advanced] Allow source build with AspireCliChannel != stable (escape hatch; leave false for GA ships)' + type: boolean + default: false + # Ref used when invoking `workflow_dispatch` against release-github-tasks.yml. # GitHub loads the workflow file *from this ref*, so it controls which version # of the workflow runs. The branch/commit being released is passed separately @@ -427,6 +445,106 @@ extends: env: SYSTEM_ACCESSTOKEN: $(System.AccessToken) + # ===== VALIDATE SOURCE BUILD CLI CHANNEL ===== + # Source builds from `release/*` branches bake `AspireCliChannel=staging` + # by default (see eng/pipelines/templates/build_sign_native.yml and + # eng/scripts/compute-cli-channel.ps1). To ship a build as GA, the + # source build must have been queued with `aspireCliChannelOverride=stable`, + # which causes compute-cli-channel.ps1 to also tag the source build + # with `aspire-cli-channel - stable`. + # + # Without this guard, forgetting the queue-time override on the GA + # ship build silently publishes a `staging`-baked CLI binary to + # nuget.org; `aspire init` then drops a nuget.config that maps + # Aspire.* to the staging feed, which 404s for GA users once the + # darc-pub feed for that SHA expires. See + # https://github.com/microsoft/aspire/issues/17527 for the bug this + # guard is the safety net against. + # + # Builds from before the channel-tag emission landed will not have + # this tag; those cannot be shipped as GA without queueing a fresh + # source build. The `AllowNonStableCliChannel` parameter is the + # documented escape hatch. + - ${{ if and(eq(parameters.DryRun, false), eq(parameters.AllowNonStableCliChannel, false)) }}: + - powershell: | + $buildId = "$(resources.pipeline.aspire-build.runID)" + $org = "$(System.CollectionUri)" + $project = "internal" + + Write-Host "Fetching build tags for build: $buildId" + + $uri = "${org}${project}/_apis/build/builds/${buildId}/tags?api-version=7.0" + Write-Host "API URI: $uri" + + try { + $response = Invoke-RestMethod -Uri $uri -Headers @{ + Authorization = "Bearer $(System.AccessToken)" + } -Method Get + + Write-Host "Build tags found: $($response.value -join ', ')" + + # Tags look like 'aspire-cli-channel - stable'. The ' - ' separator + # matches the existing 'BAR ID - ' and 'release-version - X.Y.Z' + # tag styles emitted elsewhere in the source build. + $channelTags = @($response.value | Where-Object { $_ -match '^aspire-cli-channel - (.+)$' }) + + if ($channelTags.Count -eq 0) { + Write-Error @" + Source build $buildId has no 'aspire-cli-channel - *' tag. + + This means either: + (a) The build predates the channel-tag emission in + eng/scripts/compute-cli-channel.ps1 (anything before that + landed on the source branch). Queue a fresh source build + from the current release branch and re-run this pipeline + against it. + (b) The compute-cli-channel.ps1 step was modified to drop + the `##vso[build.addbuildtag]` emission. Restore it. + + To override this guard for an intentional non-stable ship, + re-run with -AllowNonStableCliChannel `$true. + "@ + exit 1 + } + + if ($channelTags.Count -gt 1) { + Write-Error "Source build $buildId has multiple 'aspire-cli-channel - *' tags ($($channelTags -join ', ')). Refusing to guess." + exit 1 + } + + $resolvedChannel = ($channelTags[0] -replace '^aspire-cli-channel - ', '').Trim() + Write-Host "Source build CLI channel: '$resolvedChannel'" + + if ($resolvedChannel -ne 'stable') { + Write-Error @" + Source build $buildId has aspire-cli-channel='$resolvedChannel', not 'stable'. + + The selected microsoft-aspire build was queued with the default + AspireCliChannel resolution, which bakes 'staging' for release/* + branches. Publishing this build to nuget.org would ship a CLI + binary that identifies as '$resolvedChannel' and writes the + corresponding nuget.config from ``aspire init`` for end users — + see https://github.com/microsoft/aspire/issues/17527. + + To produce a GA ship build, re-queue microsoft-aspire + (definition 1602) with -aspireCliChannelOverride stable, then + re-run this pipeline against the new source build. + + To override this guard for an intentional non-stable ship, + re-run with -AllowNonStableCliChannel `$true. + "@ + exit 1 + } + + Write-Host "✓ Source build $buildId has AspireCliChannel=stable; GA ship may proceed." + } catch { + Write-Error "Failed to fetch build tags: $_" + exit 1 + } + displayName: 'Validate Source Build CLI Channel' + env: + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + # ===== DOWNLOAD PACKAGES ===== # Artifacts are downloaded automatically via templateContext.inputs above diff --git a/eng/scripts/compute-cli-channel.ps1 b/eng/scripts/compute-cli-channel.ps1 index 238bedc26e0..2aa39509f9f 100644 --- a/eng/scripts/compute-cli-channel.ps1 +++ b/eng/scripts/compute-cli-channel.ps1 @@ -92,6 +92,14 @@ Write-Host "Aspire CLI channel: $channel" # same job read the resolved value via $(aspireCliChannel). Non-AzDO callers # (tests, ad-hoc dev runs) ignore the prefix. Write-Host "##vso[task.setvariable variable=aspireCliChannel]$channel" +# Tag the source build with the resolved channel so release-publish-nuget +# can verify the channel of the build it's about to ship without inspecting +# binary metadata. The tag shape mirrors the existing `release-version - X.Y.Z` +# tag emitted by azure-pipelines.yml so the consumer can use the same +# tag-fetch REST API call. `build.addbuildtag` is idempotent across jobs +# in the same build, so the per-RID `build_sign_native` invocations all +# setting the same tag is safe — AzDO dedupes them on the build. +Write-Host "##vso[build.addbuildtag]aspire-cli-channel - $channel" # Emit the channel on stdout so callers can capture it via $(pwsh -File ...) # without parsing Write-Host diagnostics or AzDO logging-command prefixes. Write-Output $channel diff --git a/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs index 066b124e5bc..5cb95a7ce2e 100644 --- a/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs +++ b/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs @@ -69,6 +69,12 @@ public async Task ResolvesExpectedChannel(string description, string reason, str // build_sign_native.yml; a refactor that drops it would silently break // every later step that reads $(aspireCliChannel). Pin it explicitly. Assert.Contains($"##vso[task.setvariable variable=aspireCliChannel]{expectedChannel}", result.Output); + // The build tag is the consumer contract for release-publish-nuget.yml's + // GA ship-build guard: it asserts the selected source build's tags + // include `aspire-cli-channel - stable` before publishing to nuget.org. + // A refactor that drops this tag emission would silently disable the + // guard and re-introduce the risk that PR #17528 / issue #17527 closed. + Assert.Contains($"##vso[build.addbuildtag]aspire-cli-channel - {expectedChannel}", result.Output); } [Fact]