Skip to content

Decouple Aspire CLI channel identity from build-time AspireCliChannel baking (enable staging→stable promotion without rebuild) #17550

@joperezr

Description

@joperezr

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:

  1. Cut a release/13.x branch.
  2. Produce stabilizing builds (StabilizePackageVersion=trueDotNetFinalVersionKind=release) that are published to the staging feed and identified as channel=staging.
  3. Soak / dogfood / validate that exact build.
  4. 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)

  1. 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.
  2. Promotion without a rebuild. A validated staging artifact must be promotable to stable without recompiling the NAOT binary.
  3. 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.
  4. Diagnosability. aspire doctor must clearly report the effective channel and where it came from.
  5. 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

  1. 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).
  2. 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.

  3. 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.

  4. 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.

  5. 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.

Metadata

Metadata

Labels

Type

No type
No fields configured for issues without a type.

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions