Skip to content

fix(cli): persist channel in aspire.config.json on init and update#17452

Open
mitchdenny wants to merge 5 commits into
mainfrom
mitchdenny/aspire-init-channel
Open

fix(cli): persist channel in aspire.config.json on init and update#17452
mitchdenny wants to merge 5 commits into
mainfrom
mitchdenny/aspire-init-channel

Conversation

@mitchdenny
Copy link
Copy Markdown
Member

@mitchdenny mitchdenny commented May 24, 2026

Description

When a non-stable Aspire CLI (daily / staging / pr-<N> / local) ran aspire init, the scaffolded aspire.config.json was written without a top-level channel key. Downstream commands (aspire add, aspire integration list, aspire integration search) then had no channel context for the project and defaulted to the implicit nuget.org channel. The result: a daily-CLI user who ran aspire init followed by aspire add orleans would be offered the stable Orleans package version rather than the daily one that matches the CLI build they are actually dogfooding.

A parallel gap existed on the update path: aspire update --channel <x> against a C# AppHost rewrote NuGet.config and bumped packages, but left aspire.config.json#channel at its create-time value, so subsequent aspire add / aspire update runs against the same project kept resolving against the stale channel. The polyglot (TypeScript) update path in GuestAppHostProject already handled this; ProjectUpdater did not.

This PR fixes both:

Init path

Passes CliExecutionContext.IdentityChannel through to:

  • the single-file C# init path's DropAspireConfig helper, which now writes settings["channel"] when it is not already present
  • the polyglot init path's ScaffoldContext.Channel, which the existing ScaffoldingService already persists into the polyglot template's aspire.config.json (covers TypeScript apphosts as well as any future polyglot languages)

Pre-existing channel values are preserved (the write is gated on settings["channel"] is null for C# and a pre-check in the polyglot path) so user-edited or migrated configs are not clobbered. Only channels that are registered as Explicit in the IPackagingService are persisted, mirroring NewCommand's existing resolution logic — so implicit defaults (e.g. plain stable) are not pinned. Project-mode C# init is unchanged because the aspire-apphost template owns its own aspire.config.json and that mode already produces no top-level config file from init.

Update path

Adds a new ChannelUpdateStep in ProjectUpdater that mirrors GuestAppHostProject.UpdatePackagesInternalAsync: after analysis, when the selected channel is Explicit and differs from the persisted value, the step re-loads aspire.config.json on apply, sets Channel, and saves. Implicit channels are intentionally left alone. The pre-confirm summary lists the channel change alongside any package updates so users see it in the prompt.

Fixes #17295.

User-facing usage

C# single-file apphost

Running a daily CLI build:

aspire init
cat aspire.config.json

Before this change:

{
  "appHost": {
    "path": "apphost.cs"
  },
  "profiles": {
    "https": { /* ... */ },
    "http":  { /* ... */ }
  }
}

After this change:

{
  "appHost": {
    "path": "apphost.cs"
  },
  "channel": "daily",
  "profiles": {
    "https": { /* ... */ },
    "http":  { /* ... */ }
  }
}

TypeScript polyglot apphost

Running a daily CLI build with --language typescript:

aspire init --language typescript
cat aspire.config.json

Before this change:

{
  "appHost": {
    "path": "apphost.mts"
  }
}

After this change:

{
  "appHost": {
    "path": "apphost.mts"
  },
  "channel": "daily"
}

aspire update --channel <x> now rewrites aspire.config.json#channel

For any AppHost layout — C# single-file, C# aspire-empty project mode, TypeScript single-file, TypeScript project mode — running aspire update --channel stable (or --channel daily) against a project pinned to a different Explicit channel now updates aspire.config.json#channel in place, alongside the SDK/package updates.

aspire add orleans from any of these workspaces now resolves Orleans packages against the channel the user just switched to rather than the stale create-time channel.

Tests

  • Existing init tests (C# and polyglot)

  • ProjectUpdaterTests: added unit tests for ChannelUpdateStep.GetFormattedDisplayText (with and without an existing channel value), and extended UpdateProjectFileAsync_CanUpdateFromStableToDaily to assert that aspire.config.json#channel is rewritten from "stable" to "daily"

  • ChannelUpdateWorkflowTests: added three E2E regression tests so the create-path × language matrix is fully covered:

    • UpdateProjectChannelToStable_CSharpSingleFileInit_RewritesAspireConfigChannel
    • UpdateProjectChannelToStable_CSharpEmptyAppHost_RewritesAspireConfigChannel
    • UpdateProjectChannelToStable_TypeScriptSingleFileInit_RewritesAspireConfigChannel

    The existing UpdateProjectChannelToStable_TypeScript_PicksUpStablePackages covers the TS aspire new cell with package-version assertions.

Checklist

  • Is this feature complete?
    • Yes. Ready to ship.
    • No. Follow-up changes expected.
  • Are you including unit tests for the changes and scenario tests if relevant?
    • Yes
    • No
  • Did you add public API?
    • Yes
    • No
  • Does the change make any security assumptions or guarantees?
    • Yes
    • No

`aspire init` was not writing the top-level `channel` key into the
scaffolded `aspire.config.json`. As a result, when a non-stable CLI
build (daily / staging / pr-<N> / local) ran `aspire init`, subsequent
`aspire add` / `integration list` / `integration search` calls had no
channel context and defaulted to implicit nuget.org versions that did
not line up with the CLI build the user was dogfooding.

This change passes `CliExecutionContext.IdentityChannel` through to:

- the single-file C# init path's `DropAspireConfig` helper, which now
  writes `settings["channel"]` when absent
- the polyglot init path's `ScaffoldContext.Channel`, which the
  existing `ScaffoldingService` already persists into the polyglot
  template's `aspire.config.json`

Pre-existing `channel` values are preserved (the write is gated on
`settings["channel"] is null`) so user-edited or migrated configs are
not clobbered.

Related to #17295.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings May 24, 2026 23:15
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 24, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.sh | bash -s -- 17452

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/microsoft/aspire/main/eng/scripts/get-aspire-cli-pr.ps1) } 17452"

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates aspire init to persist the running CLI’s identity channel (e.g., daily, staging, pr-<N>, local) into the generated aspire.config.json, so subsequent package/integration commands resolve against the intended channel instead of falling back to implicit defaults.

Changes:

  • Single-file C# init now passes CliExecutionContext.IdentityChannel into DropAspireConfig and writes channel when it’s missing.
  • Polyglot init now passes CliExecutionContext.IdentityChannel into ScaffoldContext.Channel so scaffolding persists it.
  • Adds unit tests covering channel persistence + preservation for single-file init.
Show a summary per file
File Description
src/Aspire.Cli/Commands/InitCommand.cs Threads identity channel into init scaffolding/config generation and conditionally writes aspire.config.json#channel.
tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs Adds regression coverage for writing and preserving aspire.config.json#channel in single-file init mode.

Copilot's findings

  • Files reviewed: 2/2 changed files
  • Comments generated: 2

Comment thread src/Aspire.Cli/Commands/InitCommand.cs Outdated
Comment thread tests/Aspire.Cli.Tests/Commands/InitCommandTests.cs
Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 comment: stale architectural comment in ScaffoldingService contradicts the new polyglot init behavior.

Comment thread src/Aspire.Cli/Commands/InitCommand.cs Outdated
The first pass blindly persisted CliExecutionContext.IdentityChannel into
aspire.config.json#channel. That violates the documented invariant in
ScaffoldingService.cs:84-92 and creates two real regressions:

* Persisting 'local' (or any unregistered identity like a stale 'pr-<N>')
  pins a name no package source mapping can satisfy, zeroing out polyglot
  'aspire add' discovery via IntegrationPackageSearchService's name filter
  (line 28-30).
* Persisting an implicit channel pins the default fallback into the
  project file — exact regression scope of #17295.

Mirror NewCommand.cs:316-402: resolve the identity through
IPackagingService.GetChannelsAsync, match by StringComparisons.ChannelName,
and only persist when the matched channel is PackageChannelType.Explicit.

For the polyglot path also pre-check aspire.config.json#channel before
scaffolding — ScaffoldingService writes context.Channel unconditionally
when non-empty, so without this guard a user-edited value would be
silently overwritten on subsequent 'aspire init' runs.

Adds coverage for: unregistered identity (single-file + polyglot),
implicit-channel identity (single-file), and polyglot channel
pass-through + preserve-existing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mitchdenny
Copy link
Copy Markdown
Member Author

Self-review post-mortem (commit 15add1be4)

Caught three issues in the first pass that warranted a follow-up commit. Recording the analysis here so the rationale lives with the PR.

1. 🔴 Polyglot path blindly persisted IdentityChannel, violating a documented invariant

InitCommand.cs was passing _executionContext.IdentityChannel straight into ScaffoldContext.Channel, which ScaffoldingService.cs:95 then wrote verbatim to aspire.config.json#channel. But ScaffoldingService.cs:84–92 explicitly warns against exactly that:

"Do NOT fall back to CliExecutionContext.IdentityChannel: an identity that isn't a registered channel … would otherwise pin a channel name that no PSM rule can satisfy."

NewCommand.cs:316–402 handles this correctly — enumerate channels via PackagingService.GetChannelsAsync, match identity against registered channels, and only persist when the match is Explicit. The first pass skipped all of that.

2. 🔴 Persisting "local" (or any unregistered identity) breaks polyglot aspire add entirely

CliExecutionContext.IdentityChannel defaults to "local". IntegrationPackageSearchService.cs:28–30 filters allChannels to those whose Name equals the configured channel. "local" isn't a registered channel name in most setups → filter returns zero packages → polyglot aspire add shows nothing. Same failure mode for a stale pr-<N> identity on a machine without the matching hive.

3. 🟡 Polyglot path silently overwrote user-edited channel

The first pass added a settings["channel"] is null guard inside DropAspireConfig, but the polyglot path goes through ScaffoldingService.cs:93–95 which writes config.Channel = context.Channel unconditionally when non-empty. A user who hand-edited a polyglot aspire.config.json#channel would lose their value on the next aspire init.

Fix (commit 15add1be4)

  • Inject IPackagingService into InitCommand.
  • New helper ResolvePersistableChannelNameAsync mirrors NewCommand.cs:316–402: matches identity against GetChannelsAsync results via StringComparisons.ChannelName, returns the name only when the match is Explicit, returns null for unregistered or Implicit matches.
  • Single-file C# path: route the resolved value (not raw IdentityChannel) into DropAspireConfig.
  • Polyglot path: pre-check AspireConfigFile.Load(...)?.Channel; if a value already exists, pass null into ScaffoldContext.Channel so ScaffoldingService leaves the user's value alone.
  • Added 5 tests:
    • …DoesNotPersistChannelWhenIdentityUnregistered (single-file)
    • …DoesNotPersistChannelWhenIdentityMatchesImplicit (single-file)
    • …PolyglotMode_PassesResolvedChannelToScaffolder
    • …PolyglotMode_PreservesExistingChannelInAspireConfig
    • …PolyglotMode_DoesNotPassChannelWhenIdentityUnregistered

54/54 InitCommandTests pass; 237/237 across Init / New / Add / Scaffolding / channel-resolution / template-persistence test classes.

@mitchdenny
Copy link
Copy Markdown
Member Author

/deployment-test

@github-actions
Copy link
Copy Markdown
Contributor

🚀 Deployment tests starting on PR #17452...

This will deploy to real Azure infrastructure. Results will be posted here when complete.

View workflow run

@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot had a problem deploying to deployment-testing May 25, 2026 01:02 Failure
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot had a problem deploying to deployment-testing May 25, 2026 01:02 Failure
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot had a problem deploying to deployment-testing May 25, 2026 01:02 Failure
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot had a problem deploying to deployment-testing May 25, 2026 01:02 Failure
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions github-actions Bot temporarily deployed to deployment-testing May 25, 2026 01:02 Inactive
@github-actions
Copy link
Copy Markdown
Contributor

Deployment E2E Tests failed — 36 passed, 4 failed, 0 cancelled

View test results and recordings

View workflow run

Test Result Recording
Deployment.EndToEnd-AzureKeyVaultDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureServiceBusDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaCompactNamingDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-KubernetesHelmChartDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-TypeScriptVnetSqlServerInfraDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureContainerRegistryDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetSqlServerInfraDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureAppConfigDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-TypeScriptExpressDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureStorageDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureLogAnalyticsDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetSqlServerConnectivityDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-KubernetesGatewayTlsDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AuthenticationTests ✅ Passed
Deployment.EndToEnd-VnetKeyVaultConnectivityDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AzureEventHubsDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetKeyVaultInfraDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-NspStorageKeyVaultDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaStarterDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaCustomRegistryDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksVnetInfraDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksWithAzureResourcesDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksAzureKubernetesEnvironmentCertManagerDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaExistingRegistryDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksMultipleNodePoolsDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksStarterDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksStarterWithRedisHelmDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetStorageBlobInfraDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-VnetStorageBlobConnectivityDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksVnetWithAzureResourcesDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksWithHelmChartDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AppServiceReactDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaDeploymentErrorOutputTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AcaManagedRedisDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksAzureKubernetesEnvironmentGatewayDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-AksBlazorRedisDeploymentTests ✅ Passed ▶️ View Recording
Deployment.EndToEnd-FrontDoorDeploymentTests ❌ Failed ▶️ View Recording
Deployment.EndToEnd-TypeScriptAzureContainerAppJobDeploymentTests ❌ Failed ▶️ View Recording
Deployment.EndToEnd-TypeScriptJavaScriptHostingDeploymentTests ❌ Failed ▶️ View Recording
Deployment.EndToEnd-AksAzureKubernetesEnvironmentCertManagerTypeScriptDeploymentTests ❌ Failed ▶️ View Recording

@mitchdenny
Copy link
Copy Markdown
Member Author

Deployment test failures — pre-existing on main

The 4 deployment test failures in run 26377750311 are not caused by this PR. The same 4 tests fail on main in the scheduled deployment runs:

Test This PR main (26351886139, ~21h ago) main (26323258106, ~1d ago)
FrontDoorDeploymentTests.DeployReactTemplateWithFrontDoor
TypeScriptJavaScriptHostingDeploymentTests
TypeScriptAzureContainerAppJobDeploymentTests
AksAzureKubernetesEnvironmentCertManagerTypeScriptDeploymentTests

Cross-checked the changes wouldn't be implicated anyway:

  • FrontDoorDeploymentTests uses aspire new (not aspire init); this PR touches only InitCommand.
  • AksAzureKubernetesEnvironmentCertManagerTypeScript uses aspire new with the Express/React template and fails reading apphost.ts — the template now produces .mts, but again, unrelated to this PR.
  • The TS hosting/jobs tests time out on later aspire add polyglot scaffolding steps, well after aspire init would have run.

This PR is good to go from a deployment-test perspective — the failures are tracked separately as ongoing TypeScript/template drift issues on main.

…Command

Mention InitCommand's polyglot path alongside NewCommand in the channel-persistence
invariant comment in ScaffoldingService.cs. Both call sites resolve the identity
channel through IPackagingService and only pass an Explicit channel name, so the
invariant the comment guards still holds; the comment was just stale on the list
of callers that satisfy it.

Addresses review feedback from @JamesNK on PR #17452.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mitchdenny
Copy link
Copy Markdown
Member Author

Thanks for the review!

@JamesNK — fixed in fb4772a. The ScaffoldingService invariant still holds (callers must validate against registered IPackagingService channels before passing ScaffoldContext.Channel); InitCommand's polyglot path does that via the new ResolvePersistableChannelNameAsync helper, which only returns Explicit channel names. Updated the comment to list InitCommand alongside NewCommand so the rationale doesn't read as a contradiction.

@copilot-pull-request-reviewer:

  1. The overwrite concern was addressed in 15add1bDropPolyglotSkeletonAsync now pre-checks the existing aspire.config.json#channel via TryLoadExistingChannel and nullifies ScaffoldContext.Channel when a value is already present, mirroring the single-file path's settings["channel"] is null guard.
  2. Polyglot coverage was added in 15add1b: InitCommand_PolyglotMode_PassesResolvedChannelToScaffolder, InitCommand_PolyglotMode_PreservesExistingChannelInAspireConfig, and InitCommand_PolyglotMode_DoesNotPassChannelWhenIdentityUnregistered.

When the user runs 'aspire update --channel <x>' against a C# AppHost
(single-file apphost.cs or aspire-empty .csproj), the resolved Explicit
channel was only being written into NuGet.config — the project's
aspire.config.json#channel pin was left at whatever the create-time
value was. That meant subsequent 'aspire add' / 'aspire update' commands
on the same project kept resolving against the stale channel.

The polyglot (TypeScript) update path in GuestAppHostProject already
handled this; ProjectUpdater did not. This commit mirrors that logic in
ProjectUpdater: after analysis, when the selected channel is Explicit
and differs from the persisted value, enqueue a new ChannelUpdateStep
that re-loads aspire.config.json on apply, sets Channel, and saves.
Implicit channels are intentionally left alone.

Also adds 3 E2E regression tests covering the remaining cells of the
create-path × language matrix (C# init, C# new aspire-empty, TS init)
to complement the existing TS new-empty deep test.

Fixes part of #17295 (extension to update path).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mitchdenny mitchdenny changed the title fix(cli): persist CLI identity channel during aspire init fix(cli): persist channel in aspire.config.json on init and update May 25, 2026
@mitchdenny
Copy link
Copy Markdown
Member Author

Expanded the PR to also fix aspire update --channel <x> not rewriting aspire.config.json#channel on C# AppHosts (the polyglot path already handled this). Per offline feedback from @JamesNK.

What changed in this push (04aa66b):

  • ProjectUpdater.cs: new ChannelUpdateStep record mirroring GuestAppHostProject.UpdatePackagesInternalAsync. After analysis, when the resolved channel is Explicit and differs from the persisted value in aspire.config.json, we enqueue a step that re-loads the file on apply, sets Channel, and saves. Implicit channels are intentionally left alone. The pre-confirm summary now lists the channel change alongside any package updates.

  • UpdateCommandStrings: two new strings (UpdateChannelStepDescriptionFormat, ChannelNonePlaceholder) + XLF updates.

  • Unit tests (ProjectUpdaterTests): coverage for ChannelUpdateStep.GetFormattedDisplayText (both with and without an existing channel) and an assertion appended to the existing UpdateProjectFileAsync_CanUpdateFromStableToDaily test that confirms aspire.config.json#channel is rewritten from stable to daily.

  • E2E tests (ChannelUpdateWorkflowTests): three new regression tests so the create-path × language matrix is fully covered for aspire update --channel stable:

    • C# aspire init (single-file)
    • C# aspire new aspire-empty (project mode)
    • TS aspire init --language typescript --non-interactive

    The existing TS-empty-AppHost test already covers the fourth cell with deeper package-version assertions.

PR description + title updated to reflect the expanded scope. ./dotnet.sh build clean; 167 ProjectUpdater/UpdateCommand/GuestAppHostProject unit tests all green locally.

… tests

Each E2E test runs in its own Docker container, so the global feature
flag set inside the container never leaks to other tests. Drop the
try/finally cleanup dance.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Member

@JamesNK JamesNK left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — well-thought-out fix for the channel persistence gap on both init and update paths. Good test coverage across the create-path × language matrix.

2 comments: 1 correctness issue (missing null guard in ProjectUpdater when aspire.config.json is absent), 1 misleading comment about channel type classification.

// If no aspire.config.json is present (legacy split layouts), the rewrite is skipped.
if (channel.Type == PackageChannelType.Explicit && projectFile.Directory is { } projectDirectory)
{
var existingConfig = AspireConfigFile.Load(projectDirectory.FullName);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment above says "If no aspire.config.json is present (legacy split layouts), the rewrite is skipped" — but the code doesn't enforce that. When AspireConfigFile.Load() returns null (no file), existingChannel becomes null, and !string.Equals(null, channel.Name, ...) evaluates to true, so a ChannelUpdateStep is enqueued. The callback's LoadOrCreate then creates a new aspire.config.json for projects that never had one.

Consider guarding with a null check:

var existingConfig = AspireConfigFile.Load(projectDirectory.FullName);
if (existingConfig is not null)
{
    var existingChannel = existingConfig.Channel;
    if (!string.Equals(existingChannel, channel.Name, StringComparisons.CliInputOrOutput))
    {
        // ...enqueue step...
    }
}

// so subsequent commands like `aspire add` resolve packages against the matching
// channel. Resolve through PackagingService and only persist when the identity
// matches a registered Explicit channel — mirrors `NewCommand.cs:316-402`. This
// avoids pinning `stable` (Implicit → restricts polyglot discovery, see
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this comment (and the matching <item> in the ResolvePersistableChannelNameAsync XML doc) says "stable" is an example of an Implicit channel, but in production PackagingService.GetChannelsAsync creates "stable" via CreateExplicitChannel(PackageChannelNames.Stable, ...) — so "stable" IS Explicit and WILL be persisted by this code (confirmed by the [InlineData("stable")] test case).

The only Implicit channel is "default" (from CreateImplicitChannel), and no CLI ever has that as its identity. The real channels filtered out are unregistered identities like "local" or stale pr-<N>. Consider updating the comment to avoid confusing future maintainers about which channels are actually skipped.

@github-actions
Copy link
Copy Markdown
Contributor

CLI E2E Tests unknown — 98 passed, 0 failed, 6 unknown (commit f58a141)

View all recordings
Status Test Recording
AddPackageInteractiveWhileAppHostRunningDetached ▶️ View recording
AddPackageWhileAppHostRunningDetached ▶️ View recording
AgentCommands_AllHelpOutputs_AreCorrect ▶️ View recording
AgentInitCommand_DefaultSelection_InstallsDefaultSkills ▶️ View recording
AgentInitCommand_MigratesDeprecatedConfig ▶️ View recording
AgentMcpListStructuredLogsFromStarterAppCore ▶️ View recording
AllPublishMethodsBuildDockerImages ▶️ View recording
AspireAddPackageVersionToDirectoryPackagesProps ▶️ View recording
AspireInitSingleFileAppHostRunsViaDotnetRunAppHost ▶️ View recording
AspireInitWithExistingAppHostDirRecreatesMissingNuGetConfigAndPreservesFiles ▶️ View recording
AspireInitWithSolutionFileGeneratesAppHostThatBuildsAgainstChannelHive ▶️ View recording
AspireStartUpdatesStaleTypeScriptAppHostPath ▶️ View recording
AspireUpdateRemovesAppHostPackageVersionFromDirectoryPackagesProps ▶️ View recording
AspireUpdateRemovesOrphanAppHostPackageVersionWhenSdkAlreadyCurrent ▶️ View recording
Banner_DisplayedOnFirstRun ▶️ View recording
Banner_DisplayedWithExplicitFlag ▶️ View recording
Banner_NotDisplayedWithNoLogoFlag ▶️ View recording
CertificatesClean_RemovesCertificates ▶️ View recording
CertificatesTrust_WithNoCert_CreatesAndTrustsCertificate ▶️ View recording
CertificatesTrust_WithUntrustedCert_TrustsCertificate ▶️ View recording
ConfigSetGet_CreatesNestedJsonFormat ▶️ View recording
CreateAndRunAspireStarterProject ▶️ View recording
CreateAndRunAspireStarterProjectWithBundle ▶️ View recording
CreateAndRunEmptyAppHostProject ▶️ View recording
CreateAndRunJavaEmptyAppHostProject ▶️ View recording
CreateAndRunJsReactProject ▶️ View recording
CreateAndRunPythonReactProject ▶️ View recording
CreateAndRunTypeScriptEmptyAppHostProject ▶️ View recording
CreateAndRunTypeScriptStarterProject ▶️ View recording
CreateJavaAppHostWithViteApp ▶️ View recording
CreateTypeScriptAppHostWithViteApp_UsesConfiguredToolchain ▶️ View recording
DashboardRunWithAgentMcpCore ▶️ View recording
DashboardRunWithOtelTracesReturnsNoTracesCore ▶️ View recording
DeployK8sBasicApiService ▶️ View recording
DeployK8sWithExternalHelmChart ▶️ View recording
DeployK8sWithGarnet ▶️ View recording
DeployK8sWithMongoDB ▶️ View recording
DeployK8sWithMySql ▶️ View recording
DeployK8sWithPostgres ▶️ View recording
DeployK8sWithRabbitMQ ▶️ View recording
DeployK8sWithRedis ▶️ View recording
DeployK8sWithSqlServer ▶️ View recording
DeployK8sWithValkey ▶️ View recording
DeployTypeScriptAppToKubernetes ▶️ View recording
DescribeCommandResolvesReplicaNames ▶️ View recording
DescribeCommandShowsRunningResources ▶️ View recording
DetachFormatJsonProducesValidJson ▶️ View recording
DetachFormatJsonProducesValidJsonWhenRestartingExistingInstance ▶️ View recording
DoListStepsShowsPipelineSteps ▶️ View recording
DocsCommand_RendersInteractiveMarkdownFromLocalSource ▶️ View recording
DoctorCommand_DetectsDeprecatedAgentConfig ▶️ View recording
DoctorCommand_TypeScriptAppHostReportsMissingConfiguredToolchain ▶️ View recording
DoctorCommand_WithSslCertDir_ShowsTrusted ▶️ View recording
DoctorCommand_WithoutSslCertDir_ShowsPartiallyTrusted ▶️ View recording
GeneratedAspireDevScript_StartsWatchMode_WithConfiguredToolchain ▶️ View recording
GlobalMigration_HandlesCommentsAndTrailingCommas ▶️ View recording
GlobalMigration_HandlesMalformedLegacyJson ▶️ View recording
GlobalMigration_PreservesAllValueTypes ▶️ View recording
GlobalMigration_SkipsWhenNewConfigExists ▶️ View recording
GlobalSettings_MigratedFromLegacyFormat ▶️ View recording
InitTypeScriptAppHost_AugmentsExistingViteRepoAtRoot ▶️ View recording
InteractiveCSharpInitCreatesExpectedFiles ▶️ View recording
InvalidAppHostPathWithComments_IsHealedOnRun ▶️ View recording
JavaScriptHostingApisRunFromTypeScriptAppHost ▶️ View recording
LatestCliCanStartStableChannelAppHost ▶️ View recording
LatestCliCanStartStableChannelTypeScriptAppHost ▶️ View recording
LegacySettingsMigration_AdjustsRelativeAppHostPath ▶️ View recording
LogLevelTrace_ProducesTraceEntriesInCliLogFile ▶️ View recording
LogsCommandShowsResourceLogs ▶️ View recording
OtelLogsReturnsStructuredLogsFromStarterApp ▶️ View recording
OtelLogsReturnsStructuredLogsFromStarterAppIsolated ▶️ View recording
PsCommandListsRunningAppHost ▶️ View recording
PsFormatJsonOutputsOnlyJsonToStdout ▶️ View recording
PublishJavaScriptPatternsGeneratesExpectedDockerComposeArtifacts ▶️ View recording
PublishWithConfigureEnvFileUpdatesEnvOutput ▶️ View recording
PublishWithDockerComposeServiceCallbackSucceeds ▶️ View recording
PublishWithoutOutputPathUsesAppHostDirectoryDefault ▶️ View recording
ResourceCommand_FailedExecution_DisplaysAppHostLogPathAndLogContainsEntries ▶️ View recording
ResourceCommand_FailsWhenInteractionServiceIsRequired ▶️ View recording
ResourceCommand_SetAndDeleteParameterUpdatesDescribeOutput ▶️ View recording
RestoreGeneratesSdkFiles ▶️ View recording
RestoreGeneratesSdkFiles_WithConfiguredToolchain ▶️ View recording
RestoreRefreshesGeneratedSdkAfterAddingIntegration ▶️ View recording
RestoreSupportsConfigOnlyHelperPackageAndCrossPackageTypes ▶️ View recording
RunFromParentDirectory_UsesExistingConfigNearAppHost ▶️ View recording
RunPublishFailureScenarioAsync ▶️ View recording
RunReportsSyntaxErrorsForDotNetAppHost ▶️ View recording
RunReportsSyntaxErrorsForTypeScriptAppHost ▶️ View recording
SecretCrudOnDotNetAppHost ▶️ View recording
SecretCrudOnTypeScriptAppHost ▶️ View recording
StagingChannel_ConfigureAndVerifySettings_ThenSwitchChannels ▶️ View recording
StartAndWaitForTypeScriptSqlServerAppHostWithNativeAssets ▶️ View recording
StartReportsSyntaxErrorsForDotNetAppHost ▶️ View recording
StartReportsSyntaxErrorsForTypeScriptAppHost ▶️ View recording
StopAllAppHostsFromAppHostDirectory ▶️ View recording
StopJavaPolyglotAppHostUsingApphostDirectory ▶️ View recording
StopNonInteractiveSingleAppHost ▶️ View recording
StopTypeScriptPolyglotAppHostUsingApphostDirectory ▶️ View recording
StopWithNoRunningAppHostExitsSuccessfully ▶️ View recording
UnAwaitedChainsCompileWithAutoResolvePromises ▶️ View recording
UpdateProjectChannelToStable_CSharpEmptyAppHost_RewritesAspireConfigChannel ▶️ View recording
UpdateProjectChannelToStable_CSharpSingleFileInit_RewritesAspireConfigChannel ▶️ View recording
UpdateProjectChannelToStable_TypeScriptSingleFileInit_RewritesAspireConfigChannel ▶️ View recording
UpdateProjectChannelToStable_TypeScript_PicksUpStablePackages ▶️ View recording

📹 Recordings uploaded automatically from CI run #26382537317

@radical
Copy link
Copy Markdown
Member

radical commented May 25, 2026

F1: stable polyglot + private NuGet sources

After this PR, stable CLI's aspire init --language typescript writes channel: "stable" in aspire.config.json (Explicit stable channel matches by name). aspire add then filters to that channel only — synthetic nuget.config with just nuget.org. A user whose ambient nuget.config has a private feed (corporate Artifactory, internal Azure DevOps, GitHub Packages — Contoso.Aspire.Hosting.X style) loses their internal packages from the picker. Same shape as #17295, now opt-out instead of opt-in.

Vanilla stable users (nuget.org only) are unaffected — actually slightly better off (5 CommunityToolkit packages downgraded from -beta/-preview to stable; 19 preview-only Aspire.* packages correctly hidden).

Does this sound like a case we want to fix in this PR, or accept and let #17295's eventual fix paper over it?

Repro

get-aspire-cli-pr.sh 17452 installs the PR with identity pr-17452 — same match.Type is Explicit predicate that hits stable, so the channel-write + picker-narrowing mechanism reproduces identically (just with "pr-17452" instead of "stable"):

./eng/scripts/get-aspire-cli-pr.sh 17452 \
    --install-path /tmp/aspire-pr17452 --skip-extension --skip-path
PR_CLI=/tmp/aspire-pr17452/dogfood/pr-17452/bin/aspire

# Compare init output: current installed CLI (pre-PR, no channel) vs this PR's CLI:
mkdir -p /tmp/before /tmp/after
(cd /tmp/before && aspire init --language typescript --suppress-agent-init --non-interactive --nologo)
(cd /tmp/after  && $PR_CLI init --language typescript --suppress-agent-init --non-interactive --nologo)
diff /tmp/before/aspire.config.json /tmp/after/aspire.config.json
# /tmp/after gets   "channel": "pr-17452"   added.

# Version-aware diff of what `aspire add` will see:
for v in before after; do
  $PR_CLI integration list --apphost /tmp/$v/apphost.mts --format json --non-interactive --nologo \
    | jq -r '.[] | "\(.package) @ \(.version)"' | sort > /tmp/$v.txt
done
diff /tmp/before.txt /tmp/after.txt

For a stable user the diff shape is: 19 Aspire.Hosting.*-preview.* disappear (no stable on nuget.org), 5 CommunityToolkit packages downgrade from -beta/-preview to stable (e.g. CommunityToolkit.Aspire.Hosting.Dapr @ 13.3.0-preview.1.260514-0647@ 13.0.0), 96 unchanged. For a corporate-feed user, every Contoso.Aspire.Hosting.* package on that feed disappears too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

4 participants