Background
In 13.4 we moved the CLI's acquisition channel identity (stable/staging/daily/local/pr-<N>) from a config file to a value baked into the NAOT binary via [AssemblyMetadata(""AspireCliChannel"", ""..."")] (see src/Aspire.Cli/Aspire.Cli.csproj and src/Aspire.Cli/Acquisition/IdentityChannelReader.cs). The motivation was correct: a config-file-based channel was global state, so two CLI installations on the same machine would stomp on each other's channel and aspire init / aspire add would resolve packages from the wrong feed.
Burning the channel into the assembly solved that, but it introduced a new structural problem that we hit during the 13.4 staging cycle: the channel can no longer change after the binary is built.
The problem this is creating
Today, the release flow goes:
- Cut a
release/13.x branch.
- Produce stabilizing builds (
StabilizePackageVersion=true → DotNetFinalVersionKind=release) that are published to the staging feed and identified as channel=staging.
- Soak / dogfood / validate that exact build.
- Promote the validated build to GA — the same packages, the same SHA, but now available on nuget.org. The CLI binary should now identify as
channel=stable so aspire init writes a nuget.org-only nuget.config.
Because the channel is baked at compile time, step 4 currently requires a rebuild with /p:AspireCliChannel=stable. That:
- Defeats the point of validating a specific signed artifact (the validated bits are not the shipped bits).
- Requires the release operator to remember a queue-time parameter (see #17528 (https://github.com/microsoft/aspire/pull/17528)
aspireCliChannelOverride).
- Adds re-spin risk inside the ship window.
#17528 patches the symptom for 13.4 (so stabilizing builds correctly identify as staging rather than wrongly as stable) but does not address the underlying promote-without-rebuild requirement.
Related: #17527 (https://github.com/microsoft/aspire/issues/17527) is the bug that surfaced this. #16652 (https://github.com/microsoft/aspire/issues/16652) is the earlier staging-channel refusal work that established the current identity-based routing.
Goals (what any solution must do)
- Per-installation isolation. Two CLIs installed side-by-side (e.g., a stable global tool + a PR dogfood archive) must each resolve their own channel. This is what the baked-in approach got right and what the pre-13.4 config-file approach got wrong.
- Promotion without a rebuild. A validated staging artifact must be promotable to
stable without recompiling the NAOT binary.
- Coverage of all install paths. The acquisition surface today includes
get-aspire-cli.{sh,ps1} (curlable), the per-PR variant get-aspire-cli-pr.{sh,ps1}, the aspire NuGet global tool (dotnet tool install -g aspire), and developer-built binaries via ./build.sh. Any solution needs a story for each.
- Diagnosability.
aspire doctor must clearly report the effective channel and where it came from.
- Safe fallback. A binary invoked from outside any supported install layout must not crash or silently misroute; it should land on a safe identity (
local).
Non-goals
- Cryptographic non-repudiation. The threat model is ""user owns their install directory."" Anyone who can edit a channel sidecar can also hand-edit a
nuget.config, so signing the channel data is not in scope unless we discover a concrete attack vector.
- Changing the package-routing logic in
PackagingService.cs. The channel→feed mapping stays the same; only the channel source of truth changes.
Design options considered
Option A — Sidecar manifest written at install time (lead candidate)
Acquisition scripts write a small file next to the binary at install:
<installRoot>/bin/aspire
<installRoot>/aspire.install.json
Contents (illustrative):
{
""channel"": ""staging"",
""version"": ""13.4.0+0f51445"",
""sourceUrl"": ""https://aka.ms/dotnet/9/aspire/staging"",
""installedAt"": ""2026-05-27T..."",
""schemaVersion"": 1
}
IdentityChannelReader resolves the channel in this order: sidecar → assembly metadata → local fallback.
Promotion without rebuild: the same NAOT artifact is published behind two acquisition URLs (e.g., /aspire/staging and /aspire/ga). The acquisition script writes channel: ""stable"" when it was downloaded from the GA URL. Promotion = repoint the GA URL at the validated artifact.
Why this is the lead:
- Cleanly satisfies goals 1 + 2.
- The install location is already a stable, per-installation thing — there's existing precedent in
PackagingService.TryResolvePrInstallPackagesDirectory for inferring identity from Environment.ProcessPath's ancestor.
- Sidecar gives room for provenance metadata that
aspire doctor can surface.
- Falls back gracefully when sidecar is missing.
Open questions:
- JSON vs. plain text. JSON allows extension; plain text is harder to corrupt. Recommendation: JSON, with a schemaVersion.
- Exact location relative to the binary. Sibling file? Parent directory? Decision needs to survive symlinking and the install layouts used by each acquisition path.
- Tampering. Acceptable per the non-goals, but
aspire doctor should display the source ('baked-in' vs 'sidecar').
Option B — Channel inferred from install path
Standardize the install layout so the channel is encoded in a path segment (e.g., <prefix>/aspire/<channel>/bin/aspire). IdentityChannelReader reads Environment.ProcessPath and parses the parent.
Pros:
- No new file format.
- The install location is the metadata; can't go out of sync with itself.
- Precedent:
TryResolvePrInstallPackagesDirectory already infers PR identity from layout.
Cons:
- Breaks for users who symlink or copy the binary to
/usr/local/bin/aspire.
- Less flexible than a sidecar (no room for version provenance).
aspire.tool global tool layout is controlled by NuGet, not by us — we can't impose a <channel>/ segment.
Option C — Hybrid: baked default + sidecar override
Keep the current [AssemblyMetadata] baking as a safe default, but let a sidecar override it.
Pros:
- Pure superset of today; existing installs continue to work unchanged.
- Smallest behavioral delta.
- Promotion = drop a sidecar.
Cons:
- Two sources of truth. Needs a clear precedence rule and clear
aspire doctor output explaining why baked says X but effective is Y.
Option D — Signed promotion manifest
Like A, but the sidecar is signed and the CLI verifies the signature against a public key embedded in the binary.
Pros:
- Tamper-resistant promotion.
- Stronger audit trail.
Cons:
- Signing infrastructure, key management, revocation story.
- Overkill given the threat model (see non-goals).
Option E — Acquisition URL → identity at startup
Sidecar records the download URL (sourceUrl); the CLI maps known URLs → channels at startup.
Pros:
- Self-documenting provenance.
- Promotion is automatic (the GA URL always means
stable).
Cons:
- Couples runtime to URL conventions; URL changes require CLI changes.
- Probably worth doing in addition to A for diagnostics, not instead of it.
Recommendation
Option A (sidecar) with provenance fields from Option E baked into the JSON for diagnostics. Keep assembly metadata as a fallback to handle the aspire.tool global-tool path that NuGet controls (see aspire.tool gap below).
Hard problems to solve before implementation
-
aspire.tool NuGet global tool path. dotnet tool install -g aspire has no post-install hook to write a sidecar. The global tool would have to keep falling back to assembly metadata, which means promotion of the global tool still requires a re-pack of the NuGet package. Possible answers:
- Accept the asymmetry:
aspire.tool continues to bake its channel; only archive installs get promotion-without-rebuild.
- Ship two
aspire.tool packages (aspire.tool and aspire.tool.staging) and let users pick.
- Build a small first-run init that writes a sidecar based on the package source the tool was installed from (fragile).
-
Migration / existing installs. Already-installed 13.4 CLIs will not have a sidecar; resolver must transparently fall through to assembly metadata. This is easy, but we need an explicit test for it.
-
Symlinks and copies. If a user does cp ~/.aspire/bin/aspire /usr/local/bin/aspire, the sidecar is left behind. Resolver behavior should be: walk from Environment.ProcessPath, then Path.GetFullPath to dereference symlinks, then look for the sidecar; missing → fall back to assembly metadata.
-
Release runbook. The ship process changes from ""queue an official build with aspireCliChannelOverride=stable"" to ""re-publish the validated staging artifact under the GA URL with the install script writing channel: stable."" Needs documentation, rehearsal, and explicit operator sign-off.
-
E2E test coverage. Today's acquisition tests (Aspire.Cli.AcquisitionTests) need new cases for sidecar present, sidecar absent, sidecar corrupt, sidecar disagrees with assembly metadata, and aspire.tool fallback path.
Out of scope for this issue (but related)
Asks
@mitchdenny @radical @davidfowl — would love your input on:
- Whether Option A + assembly-fallback is the right shape, or whether one of the other options (B, C, E) better fits acquisition flows you have in mind.
- The
aspire.tool gap. Is the ""accept the asymmetry"" answer acceptable, or do we need a unified story across archive and global-tool installs?
- Whether this should target 13.5 GA or be earlier (e.g., a 13.5 preview) to validate the promotion flow on a real release cycle before depending on it.
Background
In 13.4 we moved the CLI's acquisition channel identity (
stable/staging/daily/local/pr-<N>) from a config file to a value baked into the NAOT binary via[AssemblyMetadata(""AspireCliChannel"", ""..."")](seesrc/Aspire.Cli/Aspire.Cli.csprojandsrc/Aspire.Cli/Acquisition/IdentityChannelReader.cs). The motivation was correct: a config-file-based channel was global state, so two CLI installations on the same machine would stomp on each other's channel andaspire init/aspire addwould resolve packages from the wrong feed.Burning the channel into the assembly solved that, but it introduced a new structural problem that we hit during the 13.4 staging cycle: the channel can no longer change after the binary is built.
The problem this is creating
Today, the release flow goes:
release/13.xbranch.StabilizePackageVersion=true→DotNetFinalVersionKind=release) that are published to the staging feed and identified aschannel=staging.channel=stablesoaspire initwrites a nuget.org-onlynuget.config.Because the channel is baked at compile time, step 4 currently requires a rebuild with
/p:AspireCliChannel=stable. That:aspireCliChannelOverride).#17528 patches the symptom for 13.4 (so stabilizing builds correctly identify as
stagingrather than wrongly asstable) but does not address the underlyingpromote-without-rebuildrequirement.Related: #17527 (https://github.com/microsoft/aspire/issues/17527) is the bug that surfaced this. #16652 (https://github.com/microsoft/aspire/issues/16652) is the earlier staging-channel refusal work that established the current identity-based routing.
Goals (what any solution must do)
stablewithout recompiling the NAOT binary.get-aspire-cli.{sh,ps1}(curlable), the per-PR variantget-aspire-cli-pr.{sh,ps1}, theaspireNuGet global tool (dotnet tool install -g aspire), and developer-built binaries via./build.sh. Any solution needs a story for each.aspire doctormust clearly report the effective channel and where it came from.local).Non-goals
nuget.config, so signing the channel data is not in scope unless we discover a concrete attack vector.PackagingService.cs. The channel→feed mapping stays the same; only the channel source of truth changes.Design options considered
Option A — Sidecar manifest written at install time (lead candidate)
Acquisition scripts write a small file next to the binary at install:
Contents (illustrative):
{ ""channel"": ""staging"", ""version"": ""13.4.0+0f51445"", ""sourceUrl"": ""https://aka.ms/dotnet/9/aspire/staging"", ""installedAt"": ""2026-05-27T..."", ""schemaVersion"": 1 }IdentityChannelReaderresolves the channel in this order: sidecar → assembly metadata →localfallback.Promotion without rebuild: the same NAOT artifact is published behind two acquisition URLs (e.g.,
/aspire/stagingand/aspire/ga). The acquisition script writeschannel: ""stable""when it was downloaded from the GA URL. Promotion = repoint the GA URL at the validated artifact.Why this is the lead:
PackagingService.TryResolvePrInstallPackagesDirectoryfor inferring identity fromEnvironment.ProcessPath's ancestor.aspire doctorcan surface.Open questions:
aspire doctorshould display the source ('baked-in' vs 'sidecar').Option B — Channel inferred from install path
Standardize the install layout so the channel is encoded in a path segment (e.g.,
<prefix>/aspire/<channel>/bin/aspire).IdentityChannelReaderreadsEnvironment.ProcessPathand parses the parent.Pros:
TryResolvePrInstallPackagesDirectoryalready infers PR identity from layout.Cons:
/usr/local/bin/aspire.aspire.toolglobal tool layout is controlled by NuGet, not by us — we can't impose a<channel>/segment.Option C — Hybrid: baked default + sidecar override
Keep the current
[AssemblyMetadata]baking as a safe default, but let a sidecar override it.Pros:
Cons:
aspire doctoroutput explaining why baked says X but effective is Y.Option D — Signed promotion manifest
Like A, but the sidecar is signed and the CLI verifies the signature against a public key embedded in the binary.
Pros:
Cons:
Option E — Acquisition URL → identity at startup
Sidecar records the download URL (
sourceUrl); the CLI maps known URLs → channels at startup.Pros:
stable).Cons:
Recommendation
Option A (sidecar) with provenance fields from Option E baked into the JSON for diagnostics. Keep assembly metadata as a fallback to handle the
aspire.toolglobal-tool path that NuGet controls (seeaspire.toolgap below).Hard problems to solve before implementation
aspire.toolNuGet global tool path.dotnet tool install -g aspirehas no post-install hook to write a sidecar. The global tool would have to keep falling back to assembly metadata, which means promotion of the global tool still requires a re-pack of the NuGet package. Possible answers:aspire.toolcontinues to bake its channel; only archive installs get promotion-without-rebuild.aspire.toolpackages (aspire.toolandaspire.tool.staging) and let users pick.Migration / existing installs. Already-installed 13.4 CLIs will not have a sidecar; resolver must transparently fall through to assembly metadata. This is easy, but we need an explicit test for it.
Symlinks and copies. If a user does
cp ~/.aspire/bin/aspire /usr/local/bin/aspire, the sidecar is left behind. Resolver behavior should be: walk fromEnvironment.ProcessPath, thenPath.GetFullPathto dereference symlinks, then look for the sidecar; missing → fall back to assembly metadata.Release runbook. The ship process changes from ""queue an official build with
aspireCliChannelOverride=stable"" to ""re-publish the validated staging artifact under the GA URL with the install script writingchannel: stable."" Needs documentation, rehearsal, and explicit operator sign-off.E2E test coverage. Today's acquisition tests (
Aspire.Cli.AcquisitionTests) need new cases for sidecar present, sidecar absent, sidecar corrupt, sidecar disagrees with assembly metadata, andaspire.toolfallback path.Out of scope for this issue (but related)
aspireCliChannelOverrideparameter introduced in Fix 13.4 staging CLI dropping nuget.config without Aspire package source mapping #17528 — that can be removed once promotion-without-rebuild lands.PackagingService's identity-staging quality default should keep its version-shape inference (also from Fix 13.4 staging CLI dropping nuget.config without Aspire package source mapping #17528). Once the channel can change post-build, astagingbuild promoted tostablecould have its sidecar rewritten and not need the version-shape inference at all.Asks
@mitchdenny @radical @davidfowl — would love your input on:
aspire.toolgap. Is the ""accept the asymmetry"" answer acceptable, or do we need a unified story across archive and global-tool installs?