Skip to content

fix(cli): aspire new fails to scaffold aspire-ts-starter on daily / staging CLI#17120

Merged
radical merged 5 commits into
microsoft:mainfrom
radical:ankj/fix-ts-starter-stable-sdk
May 15, 2026
Merged

fix(cli): aspire new fails to scaffold aspire-ts-starter on daily / staging CLI#17120
radical merged 5 commits into
microsoft:mainfrom
radical:ankj/fix-ts-starter-stable-sdk

Conversation

@radical
Copy link
Copy Markdown
Member

@radical radical commented May 15, 2026

What users see

aspire new aspire-ts-starter fails with a daily CLI:

$ aspire --version
13.4.0-preview.1.26264.16+642f3dfc

$ aspire new aspire-ts-starter --name TsApp --output . --non-interactive
...
ERROR: Unable to find a stable package Aspire.Hosting with version (>= 13.3.2)
  - Found 2793 version(s) in https://pkgs.dev.azure.com/dnceng/.../dotnet9/...
    [ Nearest version: 13.4.0-preview.1.26229.5 ]
  - Versions from https://api.nuget.org/v3/index.json were not considered
❌ Automatic 'aspire restore' failed for the new TypeScript starter project.

Core issue

The generated aspire.config.json pins two things that disagree:

{ "sdk": { "version": "13.3.2" }, "channel": "daily" }
  • channel: "daily" routes Aspire.* to the dnceng daily feed (prereleases only)
  • sdk.version: "13.3.2" is stable, which only exists on nuget.org

Restore can't satisfy both pins, so it fails.

Why the two values disagreed: NewCommand picked the template version from the Implicit (nuget.org) channel — because its identity-channel preference was gated on IsLocalBuildChannel, which excludes daily / staging / stable — while the TypeScript template factory wrote channel from IdentityChannel regardless.

Rule this PR enforces

The channel pinned into a new project matches the CLI's source identity.

  • NewCommand prefers any registered channel whose name matches CliExecutionContext.IdentityChannel (stable, staging, daily, local, pr-<N>) over the Implicit channel when --channel is omitted. Falls back to Implicit when the identity isn't a registered channel.
  • The TypeScript factory only persists channel when an Explicit channel was resolved. Implicit selections leave the pin unwritten so aspire add / aspire restore use the user's ambient NuGet config.
  • The Go and Python factories no longer touch the project's config file. Their previous channel-write code was a no-op (it wrote to .aspire/settings.json, which those templates don't ship) — PrebuiltAppHostServer already aggregates sources from every registered channel when no pin is present, so daily-feed packages stay reachable.

Result: SDK version and channel pin are always satisfiable by the same feed.

Notes

  • A staging-identity CLI still falls back to Implicit because PackagingService only registers the staging channel behind a feature flag — tracked separately in aspire new: staging-identity CLI should auto-register the staging channel #17121.
  • The TS starter E2E test is renamed to *SmokeTests so the daily smoke workflow picks it up — that's the regression coverage that catches identity-channel bugs invisible on PR builds.

…taging CLI

`aspire new aspire-ts-starter` on a daily-channel CLI (and likewise on
staging / stable identities) fails the automatic restore that follows
project creation, leaving the user with a half-scaffolded project that
cannot build:

    $ aspire --version
    13.4.0-preview.1.26264.16+642f3dfc

    $ aspire new aspire-ts-starter --name TsApp --output . --non-interactive
    ...
    Package source mapping matches found for package ID 'Aspire.Hosting' are:
    'https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json'.
    ...
    ERROR: Unable to find a stable package Aspire.Hosting with version (>= 13.3.2)
      - Found 2793 version(s) in https://pkgs.dev.azure.com/dnceng/.../dotnet9/...
        [ Nearest version: 13.4.0-preview.1.26229.5 ]
      - Versions from https://api.nuget.org/v3/index.json were not considered
    ...
    ❌ Automatic 'aspire restore' failed for the new TypeScript starter project.

The generated `aspire.config.json` shows the underlying mismatch:

    {
      "sdk":     { "version": "13.3.2" },
      "channel": "daily",
      "packages": { "Aspire.Hosting.JavaScript": "13.3.2" }
    }

The channel is pinned to `daily` (so PSM routes `Aspire.*` to the dnceng
daily feed, which only hosts prereleases), but the SDK / package
versions are stable `13.3.2` — which only exists on nuget.org. The two
pins are not mutually satisfiable, so restore fails.

The two halves of TypeScript-starter scaffolding were resolving the
channel inconsistently:

* `NewCommand` only consulted `CliExecutionContext.IdentityChannel` to
  pick the resolution channel when the identity was a local-build name
  (`pr-*`, `run-*`, `local`) — gated on `VersionHelper.IsLocalBuildChannel`.
  For a daily binary the identity is `daily`, which the gate excluded,
  so resolution fell through to the Implicit (nuget.org) channel and
  yielded the latest stable template package version.

* The TypeScript starter factory then pinned `aspire.config.json`'s
  `channel` to `IdentityChannel` regardless of what `NewCommand` had
  actually selected. Subsequent `aspire restore` routed `Aspire.*` to
  the channel-specific feed via Package Source Mapping, but the version
  had already been chosen against Implicit, so the daily feed (which
  only contains the prerelease) rejected the stable version that landed
  in the project.

PR-built CLIs hid the bug because `pr-<N>` is a local-build channel
and the gate fired — so the resolved channel and the pinned channel
happened to agree. Daily and staging never took that branch, so the
gate's omission only surfaced through the daily smoke workflow.

The Go and Python starters were *not* affected in practice: their
factories tried to persist the channel into `.aspire/settings.json`
via `AspireJsonConfiguration`, but those templates ship
`aspire.config.json` instead and never carried a `.aspire/settings.json`
— so `AspireJsonConfiguration.Load` returned `null` and the channel
was never written. They worked by accident because the resulting
project had no channel pin at all, leaving restore to use ambient
NuGet sources via `PrebuiltAppHostServer`'s channel aggregation.

The fix aligns NewCommand and the TypeScript factory on a single
resolution policy, and drops the dead channel-write code from the Go
and Python factories:

* `NewCommand` prefers any channel whose name matches
  `ExecutionContext.IdentityChannel` (stable, staging, daily, local,
  `pr-<N>`) over the Implicit channel when `--channel` is not passed.
  Falls back to Implicit when the identity name isn't a registered
  channel (typo, future name).
* The TypeScript factory only persists `config.Channel` when an
  Explicit channel was resolved (either `--channel` or a registered
  match for the running CLI's identity). Implicit (nuget.org)
  selections leave the channel unwritten so `aspire add` /
  `aspire restore` use the user's ambient NuGet config without a
  stale per-project pin.
* The Go and Python factories no longer touch the project's config
  file. `PrebuiltAppHostServer` aggregates package sources from every
  registered channel when the project doesn't pin one, so daily-feed
  packages remain reachable on a daily CLI without each project
  carrying a channel name across machines.

This makes the build → execution context → feed → restore pipeline
mutually satisfiable on every channel: a daily CLI scaffolds a
daily-channel project whose prerelease SDK version is reachable
through the daily feed's Package Source Mapping; a stable CLI
scaffolds a stable project whose version is reachable through
nuget.org; a PR CLI scaffolds a `pr-<N>`-channel project against the
PR hive; and so on.

Note on `staging`: `PackagingService.GetChannelsAsync` only registers
a `staging` channel when `KnownFeatures.IsStagingChannelEnabled` is
true (feature flag or `configuration["channel"]="staging"`). A
default staging-identity CLI therefore still falls back to Implicit
here — auto-registering staging from the identity is tracked
separately.

Tests:

* New `NewCommandChannelResolutionTests` cover the resolution matrix:
  identity matches a registered channel (daily / stable / staging-
  when-enabled / pr-<N>) / identity not registered / staging not
  registered → fallback to Implicit / explicit `--channel` overrides
  identity.
* `TypeScriptStarterTemplateTests` renamed to
  `TypeScriptStarterSmokeTests` so the daily smoke workflow picks it
  up via its `*SmokeTests` filter — this is the regression coverage
  that catches identity-channel resolution bugs invisible on PR
  builds. Expanded to assert `aspire.config.json` has no channel pin
  when invoked without `--channel`, and is pinned when invoked with
  one. The pin ↔ SDK-version invariant skips the `staging` channel,
  whose feed can legitimately host stable versions.
* `ChannelReseedTests` doc-comments updated for the new "persist
  only on Explicit" semantic in the TypeScript factory.

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

github-actions Bot commented May 15, 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 -- 17120

Or

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

@radical radical marked this pull request as ready for review May 15, 2026 08:14
Copilot AI review requested due to automatic review settings May 15, 2026 08:14
@radical radical requested review from danegsta and joperezr May 15, 2026 08:14
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

Fixes a regression where aspire new aspire-ts-starter can scaffold a project whose pinned sdk.version and pinned channel are mutually unsatisfiable (notably on daily/staging identities), by making channel resolution and channel persistence consistently follow the running CLI’s identity and explicit channel selection rules.

Changes:

  • Update NewCommand channel selection to prefer a registered channel matching CliExecutionContext.IdentityChannel when --channel is omitted, falling back to Implicit when there’s no match.
  • Persist aspire.config.json#channel for the TypeScript starter only when an Explicit channel was actually resolved (leave Implicit unpinned).
  • Remove no-op channel seeding from Python/Go starter template factories and adjust/rename tests so daily smoke runs cover the TS starter path.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/Aspire.Cli.Tests/Scaffolding/ChannelReseedTests.cs Updates unit-test documentation to clarify where channel persistence is/ isn’t expected to happen.
tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs Adds coverage for identity-channel matching vs Implicit fallback, plus PR/staging edge cases.
tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterTemplateTests.cs Removes the prior TS starter E2E test in favor of a smoke-test-class target.
tests/Aspire.Cli.EndToEnd.Tests/TypeScriptStarterSmokeTests.cs Adds a daily-smoke-pickup test validating scaffold + restore/run + channel/sdk consistency.
src/Aspire.Cli/Templating/CliTemplateFactory.TypeScriptStarterTemplate.cs Only writes channel into aspire.config.json when TemplateInputs.Channel is explicitly set.
src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs Removes per-project channel writing behavior (previously attempted legacy settings write).
src/Aspire.Cli/Templating/CliTemplateFactory.GoStarterTemplate.cs Removes per-project channel writing behavior (previously attempted legacy settings write).
src/Aspire.Cli/Commands/NewCommand.cs Changes template-version resolution to prefer identity-matching registered channel over Implicit when --channel is omitted.

Comment thread tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs
Comment thread tests/Aspire.Cli.Tests/Commands/NewCommandChannelResolutionTests.cs
@radical
Copy link
Copy Markdown
Member Author

radical commented May 15, 2026

PR Testing Report — dogfood run

Result:Verified end-to-end

aspire new aspire-ts-starter from a PR-identity CLI now writes consistent sdk.version + channel pins (both pr-17120), and follow-up aspire add redis resolves packages cleanly — the original Automatic 'aspire restore' failed mode is gone.

Environment

  • CLI installed via: eng/scripts/get-aspire-cli-pr.sh 17120
  • Execution: repo container runner (eng/scripts/aspire-pr-container/, Ubuntu 24.04, linux-arm64)
  • CLI version reported: 13.4.0-pr.17120.g8c4ad6b4 — short SHA matches head 8c4ad6b4…

Scenarios

# Scenario Result Evidence
1 aspire new aspire-ts-starter (natural channel resolution, no --source / --version) ✅ Pass Generated aspire.config.json has sdk.version: 13.4.0-pr.17120.g8c4ad6b4 and channel: pr-17120 — both resolvable from the same hive
1B aspire add redis on the TS app (reproduces the original failure path) ✅ Pass Aspire.Hosting.Redis::13.4.0-pr.17120.g8c4ad6b4 was added successfully
2 aspire-go-starter ⚠️ Filtered at runtime in this container (no go toolchain → IsTemplateAvailable returns false). Verified via diff instead: factory no longer calls AspireJsonConfiguration.Load/Save.
3 aspire new aspire-py-starter ✅ Pass Generated aspire.config.json has no channel pin and no sdk.version — confirms Python factory no longer touches the config

Key evidence — TS-starter aspire.config.json

{
  "appHost": { "path": "apphost.ts", "language": "typescript/nodejs" },
  "sdk":     { "version": "13.4.0-pr.17120.g8c4ad6b4" },
  "channel": "pr-17120",
  "packages": {
    "Aspire.Hosting.JavaScript": "13.4.0-pr.17120.g8c4ad6b4"
  }
}

Both pins are satisfied by the same feed/version — the invariant this PR enforces. Compare with the bug shape from the PR body (sdk.version: 13.3.2 (stable) + channel: daily → unresolvable).

Notes on the Copilot review

Took a look at the two inline comments from copilot-pull-request-reviewer:

  • using Aspire.Cli.Projects; is unused → CS8019 — looks like a false positive. KnownLanguageId.TypeScript is referenced at NewCommandChannelResolutionTests.cs:259, and KnownLanguageId is declared in Aspire.Cli.Projects (src/Aspire.Cli/Projects/KnownLanguageId.cs:9). The using is required.
  • Path.Combine(Path.GetTempPath(), "aspire-test-sdks") for sdksDirectory — points at a valid AGENTS.md guideline, but this is the established house pattern for the placeholder sdksDirectory in CliExecutionContext test fixtures (same string in tests/Aspire.Cli.Tests/Utils/CliTestHelper.cs:285, ListAppHostsToolTests.cs:171, RunCommandTests.cs ×7). The new test is following local convention — worth fixing as a follow-up across all the call sites, not just here, since sdksDirectory is never actually populated on disk.

Other notes

  • npm is not installed warnings appeared during TS/Python new — expected in the minimal Ubuntu container; non-fatal and unrelated to the NuGet / channel logic this PR changes.
  • Go starter needs a Go toolchain to appear in the template list at all, so a dogfood-run against the bare container can't exercise it; relied on the diff (which is short and clear) for that path.
  • One transient install hiccup worth noting: the dogfood install briefly hit no artifact matches any of the names or patterns provided against run 25907294112 while CI was still mid-flight. Retrying once the cli-native-archives-linux-arm64 artifact had been published worked cleanly.

🤖 Tested with GitHub Copilot CLI using the pr-testing skill.

…e the test workspace

The other three directories produced by BuildExecutionContextWithIdentity
(hivesDirectory / cacheDirectory / logsDirectory) all live under the
caller's TemporaryWorkspace.WorkspaceRoot. Only sdksDirectory escaped to
Path.GetTempPath(), which is inconsistent within the same method and goes
against the AGENTS.md guidance to use the test workspace or
Directory.CreateTempSubdirectory() rather than a deterministic name
under Path.GetTempPath() that can collide across concurrent test runs.

Mirror the shape used by CreateExecutionContextWithHives in
TemplateNuGetConfigServiceTests (and CreateExecutionContext in
InitCommandTests) and keep the placeholder sdks directory under the
workspace root.

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

Re-running the failed jobs in the CI workflow for this pull request because 1 job was identified as retry-safe transient failures in the CI run attempt.
GitHub was asked to rerun all failed jobs for that attempt, and the rerun is being tracked in the rerun attempt.
The job links below point to the failed attempt jobs that matched the retry-safe transient failure rules.

@davidfowl
Copy link
Copy Markdown
Contributor

I tested #17120 with the dogfood CLI against both TypeScript templates.

aspire-ts-starter looks fixed: when --channel is omitted, it no longer writes a channel to aspire.config.json, and aspire new, npm run build, and aspire add Aspire.Hosting.Redis all passed.

aspire-ts-empty is still incomplete. It creates and builds, but it still writes the CLI identity channel into aspire.config.json even though no channel was specified:

"channel": "pr-17120"

That causes follow-up package discovery to fail in a clean HOME. Repro from the generated aspire-ts-empty app:

aspire add Aspire.Hosting.Redis

Result:

❌ No integration packages were found. Please check your internet connection or NuGet source configuration.

The remaining fix should be in the empty/scaffolding path: ScaffoldingService should persist ScaffoldContext.Channel only when the user explicitly provided a channel, and should not fall back to CliExecutionContext.IdentityChannel. The identity channel can still be used for resolving which template/package version to install during aspire new, but it should not be written into aspire.config.json unless the user asked to pin that channel.

radical and others added 3 commits May 15, 2026 15:10
… registered channel

Follow-up to 8c4ad6b. The TypeScript starter factory was patched to persist
`aspire.config.json#channel` only when NewCommand resolved an Explicit channel
(`--channel`, or a registered identity-match). `ScaffoldingService` — the writer
used by every empty template (TS / Python / Go / Java / Rust) and by
`aspire init` polyglot — still had the original identity-fallback shape:

    var seedChannel = string.IsNullOrWhiteSpace(context.Channel)
        ? _cliExecutionContext.IdentityChannel
        : context.Channel;

When NewCommand's resolution found no registered channel matching the running
CLI's identity (e.g. a CLI whose `CliExecutionContext.IdentityChannel` is
`pr-17120` on a machine where the matching hive isn't present, or a
staging-identity CLI without the staging feature flag), it correctly fell back
to the Implicit channel and passed `context.Channel = null` down to the
scaffolder. `ScaffoldingService` then silently substituted the CLI's identity
name — so the generated `aspire.config.json` carried a channel pin that no
Package Source Mapping rule could satisfy:

    $ aspire new aspire-ts-empty --name TsEmpty
    ...
    $ cat TsEmpty/aspire.config.json
    {
      "channel": "pr-17120",          // identity-fallback wrote this
      "sdk":     { "version": "13.3.0" }   // stable, from Implicit
    }
    $ aspire add Aspire.Hosting.Redis
    ❌ No integration packages were found.

Drop the identity-fallback so `ScaffoldingService` persists
`ScaffoldContext.Channel` verbatim and only when set. When unset,
`PrebuiltAppHostServer` aggregates package sources from every registered
channel — daily-feed packages stay reachable on a daily CLI without each
project carrying a stale channel name. This brings the empty-template +
polyglot-init path in line with the TS / Python / Go starter factories, all of
which already had this shape.

The now-unused `CliExecutionContext` ctor parameter is removed; the test
fixture (`CliTestHelper.ScaffoldingServiceFactory`) is updated to match.

`ChannelReseedTests` is rewritten so its scenarios assert the new contract:
`ScaffoldContext.Channel` flows through verbatim, and a deliberately
non-matching `IdentityChannel` is used as a tripwire that fails if anyone
re-introduces the identity-fallback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…late that emits aspire.config.json

The starter-side bug (8c4ad6b) had a `*SmokeTests` E2E test on the daily smoke
workflow only — no PR-CI coverage. The scaffolding-side follow-up had unit-
level coverage on `ScaffoldingService` only. Neither caught the symmetric bug
on the empty-template path before it had to be flagged manually.

Close the gap with `NewCommandTemplateConfigPersistenceTests`, a single
integration test that drives `NewCommand` end-to-end through the real
`CliTemplateFactory` and the real writer for each template. Only
`IAppHostServerProjectFactory` is swapped for a fake whose `PrepareAsync`
returns failure, so the early on-disk channel write is captured without
touching the network, the dotnet CLI, npm, or RPC.

Five parameterised methods cover 21 cases across every CLI template that
ships an `aspire.config.json` writer:

* `aspire-ts-empty`, `aspire-py-empty`, `aspire-go-empty`, `aspire-java-empty`
  (writer: `ScaffoldingService`) — identity-not-registered → no pin;
  identity-matches → pin; `--channel` → pin; staging-without-flag → no pin.
* `aspire-ts-starter` (writer:
  `CliTemplateFactory.TypeScriptStarterTemplate`) — same matrix; this is the
  PR-CI counterpart to the existing daily smoke test.
* `aspire-go-starter`, `aspire-py-starter` (writer: none) — tripwires
  asserting the persisted channel stays null regardless of identity or
  `--channel`, so future code that re-introduces the dead
  `config.Channel = inputs.Channel` line is caught.

Locally validated by simulating the pre-fix shape against the new tests: a
`ScaffoldingService`-side identity-fallback fails the four empty-template
"identity not registered" cases plus the staging variant; a starter-side
identity-fallback fails the two ts-starter cases. Each failure points at the
writer code path that drifted.

`FakeFailingAppHostServerProject` is promoted from `GuestAppHostProjectTests`
into `TestServices/` so both test classes share it.

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

radical commented May 15, 2026

PR Testing Report

Dogfooded the PR CLI on macOS arm64.

CLI install verifiedaspire --version13.4.0-pr.17120.g0e9f3af2 (matches PR head 0e9f3af2). Hive resolved to ~/.aspire/hives/pr-17120/packages with all NuGets tagged *-pr.17120.g0e9f3af2.

Note: initial install at the default mktemp HOME on macOS made the AppHost RPC socket path exceed the 104-char Unix domain socket limit. Worked around by reinstalling with a short HOME (/tmp/aspire17120). Unrelated to this PR but worth flagging.

Template sweep — identity-channel path (no --source/--version):

Template rc channel in aspire.config.json sdk.version Result
aspire-starter 0 <absent> <absent> (lives in .csproj Sdk="Aspire.AppHost.Sdk/13.4.0-pr.17120.g0e9f3af2") ✅ C# pins via .csproj
aspire-empty --language csharp 0 <absent> <absent> (in .csproj) ✅ same
aspire-empty --language typescript 0 pr-17120 13.4.0-pr.17120.g0e9f3af2 ✅ identity-channel pin
aspire-ts-empty 0 pr-17120 13.4.0-pr.17120.g0e9f3af2 ✅ identity-channel pin
aspire-ts-starter (headline) 0 pr-17120 13.4.0-pr.17120.g0e9f3af2 ✅ headline bug fixed
aspire-ts-cs-starter 0 <absent> <absent> (.csproj pins Aspire.AppHost.Sdk/...g0e9f3af2 + Aspire.Hosting.JavaScript 13.4.0-pr.17120.g0e9f3af2) ✅ C# AppHost path
aspire-py-starter (tripwire) 0 <absent> <absent> (packages block carries PR-feed versions) ✅ tripwire intact — no dead config.Channel = inputs.Channel line

For every template that persists (channel, sdk.version), both values reference the PR feed — exactly satisfiable. The pre-fix bug shape (channel: daily + sdk.version: 13.3.2) cannot occur on this CLI.

Headline scenario — aspire restore on the scaffolded TS starter:

The bug report quoted:

❌ Automatic 'aspire restore' failed for the new TypeScript starter project.

On the PR CLI against the scaffolded aspire-ts-starter:

$ aspire restore --non-interactive
⚙️ Restoring SDK code...
✅ SDK code restored successfully for apphost.ts.
rc=0

Coverage gapaspire-py-empty, aspire-go-empty, aspire-java-empty, aspire-apphost, aspire-go-starter are template IDs in KnownTemplateId.cs and parameterized in the new NewCommandTemplateConfigPersistenceTests (in-process), but aren't reachable via aspire new <id> in this build — they appear to come in through aspire init polyglot rather than aspire new. Not a regression of this PR; the unit tests do cover them. Flagging only so we know dogfooding via aspire new doesn't reach those code paths end-to-end.

Result

✅ Verified. TS starter scaffolds cleanly, aspire restore passes on the generated project, persisted (channel, sdk.version) pairs are consistently from the PR feed, and the python-starter tripwire stays absent (dead writer line confirmed not re-introduced).

@radical radical enabled auto-merge (squash) May 15, 2026 22:53
config.Channel = !string.IsNullOrEmpty(inputs.Channel)
? inputs.Channel
: _executionContext.IdentityChannel;
if (!string.IsNullOrEmpty(inputs.Channel))
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.

IMO it is odd that TypeScript is special behavior here. It doesn't do the same thing as the Go or Python abovel

@radical radical merged commit 822a2ee into microsoft:main May 15, 2026
875 of 882 checks passed
@microsoft-github-policy-service microsoft-github-policy-service Bot added this to the 13.4 milestone May 15, 2026
@davidfowl
Copy link
Copy Markdown
Contributor

Retested the latest dogfood CLI for #17120 in the repo container runner.

CLI version verified:

13.4.0-pr.17120.g0e9f3af2

matches PR head 0e9f3af2.

Scenarios covered:

Scenario aspire new aspire restore aspire add Aspire.Hosting.Redis npm install && npm run build Channel result
PR-hive aspire-ts-starter pr-17120
PR-hive aspire-ts-empty pr-17120
Clean-HOME aspire-ts-starter <absent>
Clean-HOME aspire-ts-empty <absent>

The clean-HOME aspire-ts-empty regression is fixed now: it no longer persists a stale pr-17120 channel when the matching hive/channel is not registered, and follow-up aspire add Aspire.Hosting.Redis succeeds.

Artifacts from the run are under:

/Users/davidfowler/.copilot/workspaces/fa7bcc48-647b-4c93-b34a-60a16d9169c3/artifacts/pr-17120-container-retest

// or NewCommand's identity-match against a registered Explicit channel — see
// `CliTemplateFactory.EmptyTemplate.cs` for how `ScaffoldContext.Channel` is sourced).
// Do NOT fall back to `CliExecutionContext.IdentityChannel`: an identity that isn't a
// registered channel (e.g. `daily` on a CLI without the staging feature flag, or `pr-<N>`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This example should be staging, not daily. The daily channel is registered unconditionally in PackagingService, while staging is the one gated by the staging feature flag. As written, the comment contradicts the actual channel registration behavior.

@radical radical deleted the ankj/fix-ts-starter-stable-sdk branch May 15, 2026 23:31
nellshamrell pushed a commit to nellshamrell/aspire that referenced this pull request May 18, 2026
…taging CLI (microsoft#17120)

* fix(cli): aspire new fails to scaffold aspire-ts-starter on daily / staging CLI

`aspire new aspire-ts-starter` on a daily-channel CLI (and likewise on
staging / stable identities) fails the automatic restore that follows
project creation, leaving the user with a half-scaffolded project that
cannot build:

    $ aspire --version
    13.4.0-preview.1.26264.16+642f3dfc

    $ aspire new aspire-ts-starter --name TsApp --output . --non-interactive
    ...
    Package source mapping matches found for package ID 'Aspire.Hosting' are:
    'https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet9/nuget/v3/index.json'.
    ...
    ERROR: Unable to find a stable package Aspire.Hosting with version (>= 13.3.2)
      - Found 2793 version(s) in https://pkgs.dev.azure.com/dnceng/.../dotnet9/...
        [ Nearest version: 13.4.0-preview.1.26229.5 ]
      - Versions from https://api.nuget.org/v3/index.json were not considered
    ...
    ❌ Automatic 'aspire restore' failed for the new TypeScript starter project.

The generated `aspire.config.json` shows the underlying mismatch:

    {
      "sdk":     { "version": "13.3.2" },
      "channel": "daily",
      "packages": { "Aspire.Hosting.JavaScript": "13.3.2" }
    }

The channel is pinned to `daily` (so PSM routes `Aspire.*` to the dnceng
daily feed, which only hosts prereleases), but the SDK / package
versions are stable `13.3.2` — which only exists on nuget.org. The two
pins are not mutually satisfiable, so restore fails.

The two halves of TypeScript-starter scaffolding were resolving the
channel inconsistently:

* `NewCommand` only consulted `CliExecutionContext.IdentityChannel` to
  pick the resolution channel when the identity was a local-build name
  (`pr-*`, `run-*`, `local`) — gated on `VersionHelper.IsLocalBuildChannel`.
  For a daily binary the identity is `daily`, which the gate excluded,
  so resolution fell through to the Implicit (nuget.org) channel and
  yielded the latest stable template package version.

* The TypeScript starter factory then pinned `aspire.config.json`'s
  `channel` to `IdentityChannel` regardless of what `NewCommand` had
  actually selected. Subsequent `aspire restore` routed `Aspire.*` to
  the channel-specific feed via Package Source Mapping, but the version
  had already been chosen against Implicit, so the daily feed (which
  only contains the prerelease) rejected the stable version that landed
  in the project.

PR-built CLIs hid the bug because `pr-<N>` is a local-build channel
and the gate fired — so the resolved channel and the pinned channel
happened to agree. Daily and staging never took that branch, so the
gate's omission only surfaced through the daily smoke workflow.

The Go and Python starters were *not* affected in practice: their
factories tried to persist the channel into `.aspire/settings.json`
via `AspireJsonConfiguration`, but those templates ship
`aspire.config.json` instead and never carried a `.aspire/settings.json`
— so `AspireJsonConfiguration.Load` returned `null` and the channel
was never written. They worked by accident because the resulting
project had no channel pin at all, leaving restore to use ambient
NuGet sources via `PrebuiltAppHostServer`'s channel aggregation.

The fix aligns NewCommand and the TypeScript factory on a single
resolution policy, and drops the dead channel-write code from the Go
and Python factories:

* `NewCommand` prefers any channel whose name matches
  `ExecutionContext.IdentityChannel` (stable, staging, daily, local,
  `pr-<N>`) over the Implicit channel when `--channel` is not passed.
  Falls back to Implicit when the identity name isn't a registered
  channel (typo, future name).
* The TypeScript factory only persists `config.Channel` when an
  Explicit channel was resolved (either `--channel` or a registered
  match for the running CLI's identity). Implicit (nuget.org)
  selections leave the channel unwritten so `aspire add` /
  `aspire restore` use the user's ambient NuGet config without a
  stale per-project pin.
* The Go and Python factories no longer touch the project's config
  file. `PrebuiltAppHostServer` aggregates package sources from every
  registered channel when the project doesn't pin one, so daily-feed
  packages remain reachable on a daily CLI without each project
  carrying a channel name across machines.

This makes the build → execution context → feed → restore pipeline
mutually satisfiable on every channel: a daily CLI scaffolds a
daily-channel project whose prerelease SDK version is reachable
through the daily feed's Package Source Mapping; a stable CLI
scaffolds a stable project whose version is reachable through
nuget.org; a PR CLI scaffolds a `pr-<N>`-channel project against the
PR hive; and so on.

Note on `staging`: `PackagingService.GetChannelsAsync` only registers
a `staging` channel when `KnownFeatures.IsStagingChannelEnabled` is
true (feature flag or `configuration["channel"]="staging"`). A
default staging-identity CLI therefore still falls back to Implicit
here — auto-registering staging from the identity is tracked
separately.

Tests:

* New `NewCommandChannelResolutionTests` cover the resolution matrix:
  identity matches a registered channel (daily / stable / staging-
  when-enabled / pr-<N>) / identity not registered / staging not
  registered → fallback to Implicit / explicit `--channel` overrides
  identity.
* `TypeScriptStarterTemplateTests` renamed to
  `TypeScriptStarterSmokeTests` so the daily smoke workflow picks it
  up via its `*SmokeTests` filter — this is the regression coverage
  that catches identity-channel resolution bugs invisible on PR
  builds. Expanded to assert `aspire.config.json` has no channel pin
  when invoked without `--channel`, and is pinned when invoked with
  one. The pin ↔ SDK-version invariant skips the `staging` channel,
  whose feed can legitimately host stable versions.
* `ChannelReseedTests` doc-comments updated for the new "persist
  only on Explicit" semantic in the TypeScript factory.

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

* test(cli): keep BuildExecutionContextWithIdentity sdksDirectory inside the test workspace

The other three directories produced by BuildExecutionContextWithIdentity
(hivesDirectory / cacheDirectory / logsDirectory) all live under the
caller's TemporaryWorkspace.WorkspaceRoot. Only sdksDirectory escaped to
Path.GetTempPath(), which is inconsistent within the same method and goes
against the AGENTS.md guidance to use the test workspace or
Directory.CreateTempSubdirectory() rather than a deterministic name
under Path.GetTempPath() that can collide across concurrent test runs.

Mirror the shape used by CreateExecutionContextWithHives in
TemplateNuGetConfigServiceTests (and CreateExecutionContext in
InitCommandTests) and keep the placeholder sdks directory under the
workspace root.

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

* fix(cli): aspire new aspire-ts-empty pins a channel that may not be a registered channel

Follow-up to 8c4ad6b. The TypeScript starter factory was patched to persist
`aspire.config.json#channel` only when NewCommand resolved an Explicit channel
(`--channel`, or a registered identity-match). `ScaffoldingService` — the writer
used by every empty template (TS / Python / Go / Java / Rust) and by
`aspire init` polyglot — still had the original identity-fallback shape:

    var seedChannel = string.IsNullOrWhiteSpace(context.Channel)
        ? _cliExecutionContext.IdentityChannel
        : context.Channel;

When NewCommand's resolution found no registered channel matching the running
CLI's identity (e.g. a CLI whose `CliExecutionContext.IdentityChannel` is
`pr-17120` on a machine where the matching hive isn't present, or a
staging-identity CLI without the staging feature flag), it correctly fell back
to the Implicit channel and passed `context.Channel = null` down to the
scaffolder. `ScaffoldingService` then silently substituted the CLI's identity
name — so the generated `aspire.config.json` carried a channel pin that no
Package Source Mapping rule could satisfy:

    $ aspire new aspire-ts-empty --name TsEmpty
    ...
    $ cat TsEmpty/aspire.config.json
    {
      "channel": "pr-17120",          // identity-fallback wrote this
      "sdk":     { "version": "13.3.0" }   // stable, from Implicit
    }
    $ aspire add Aspire.Hosting.Redis
    ❌ No integration packages were found.

Drop the identity-fallback so `ScaffoldingService` persists
`ScaffoldContext.Channel` verbatim and only when set. When unset,
`PrebuiltAppHostServer` aggregates package sources from every registered
channel — daily-feed packages stay reachable on a daily CLI without each
project carrying a stale channel name. This brings the empty-template +
polyglot-init path in line with the TS / Python / Go starter factories, all of
which already had this shape.

The now-unused `CliExecutionContext` ctor parameter is removed; the test
fixture (`CliTestHelper.ScaffoldingServiceFactory`) is updated to match.

`ChannelReseedTests` is rewritten so its scenarios assert the new contract:
`ScaffoldContext.Channel` flows through verbatim, and a deliberately
non-matching `IdentityChannel` is used as a tripwire that fails if anyone
re-introduces the identity-fallback.

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

* test(cli): add channel-persistence coverage for every aspire-new template that emits aspire.config.json

The starter-side bug (8c4ad6b) had a `*SmokeTests` E2E test on the daily smoke
workflow only — no PR-CI coverage. The scaffolding-side follow-up had unit-
level coverage on `ScaffoldingService` only. Neither caught the symmetric bug
on the empty-template path before it had to be flagged manually.

Close the gap with `NewCommandTemplateConfigPersistenceTests`, a single
integration test that drives `NewCommand` end-to-end through the real
`CliTemplateFactory` and the real writer for each template. Only
`IAppHostServerProjectFactory` is swapped for a fake whose `PrepareAsync`
returns failure, so the early on-disk channel write is captured without
touching the network, the dotnet CLI, npm, or RPC.

Five parameterised methods cover 21 cases across every CLI template that
ships an `aspire.config.json` writer:

* `aspire-ts-empty`, `aspire-py-empty`, `aspire-go-empty`, `aspire-java-empty`
  (writer: `ScaffoldingService`) — identity-not-registered → no pin;
  identity-matches → pin; `--channel` → pin; staging-without-flag → no pin.
* `aspire-ts-starter` (writer:
  `CliTemplateFactory.TypeScriptStarterTemplate`) — same matrix; this is the
  PR-CI counterpart to the existing daily smoke test.
* `aspire-go-starter`, `aspire-py-starter` (writer: none) — tripwires
  asserting the persisted channel stays null regardless of identity or
  `--channel`, so future code that re-introduces the dead
  `config.Channel = inputs.Channel` line is caught.

Locally validated by simulating the pre-fix shape against the new tests: a
`ScaffoldingService`-side identity-fallback fails the four empty-template
"identity not registered" cases plus the staging variant; a starter-side
identity-fallback fails the two ts-starter cases. Each failure points at the
writer code path that drifted.

`FakeFailingAppHostServerProject` is promoted from `GuestAppHostProjectTests`
into `TestServices/` so both test classes share it.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants