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/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..2aa39509f9f --- /dev/null +++ b/eng/scripts/compute-cli-channel.ps1 @@ -0,0 +1,105 @@ +# 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" +# 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/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() { diff --git a/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs b/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs new file mode 100644 index 00000000000..5cb95a7ce2e --- /dev/null +++ b/tests/Infrastructure.Tests/PowerShellScripts/ComputeCliChannelTests.cs @@ -0,0 +1,172 @@ +// 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); + // 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] + [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"); + } +}