Skip to content

CLI: bake channel into assembly metadata; drop global-channel writes#16820

Merged
radical merged 41 commits into
microsoft:mainfrom
radical:ankj/v3-pr1-channel
May 13, 2026
Merged

CLI: bake channel into assembly metadata; drop global-channel writes#16820
radical merged 41 commits into
microsoft:mainfrom
radical:ankj/v3-pr1-channel

Conversation

@radical
Copy link
Copy Markdown
Member

@radical radical commented May 6, 2026

Summary

The CLI's channel identity lives in build-time assembly metadata and is read at startup, eliminating cross-install drift where one acquisition route could silently overwrite the channel another route had selected. The "which channel is this CLI?" question is answerable from the binary itself.

Channel identity is a single MSBuild property, AspireCliChannel, emitted as [AssemblyMetadata("AspireCliChannel", ...)] on Aspire.Cli. CI sets it per build kind: stable for releases, staging for release branches, daily for main, pr-<N> for PR validation, and local for ./build.sh output. IdentityChannelReader reads the attribute at startup and populates CliExecutionContext.IdentityChannel / CliExecutionContext.Channel. CI bakes the full pr-<N> hive label directly, with a digit-only pre-check that fails the build if a build agent ever hands over an unresolved macro.

User- and maintainer-facing changes

CLI behavior

  • aspire update --self does not write ~/.aspire/aspire.config.json#channel; the chosen channel applies only to the self-update, and subsequent scaffolding resolves channel per project.
  • The new config schema has no channel key; the legacy file is preserved verbatim.
  • No production code path reads a global channel key — DotNetBasedAppHostServerProject, PrebuiltAppHostServer, and NewCommand resolve channel from per-project state or CLI flags.

Per-project channel

  • aspire new (and the Python / TypeScript starter scaffolders) seed the chosen channel into the new project's aspire.config.json#channel. Per-project channel preference is the single source of truth post-scaffolding.
  • TemplateNuGetConfigService: an explicit local channel override with no local hive on disk falls back to the implicit (ambient NuGet) channel rather than failing.
  • The local-hive label is local (PackageChannelNames.Local = "local"), uniform across all acquisition routes.

Packaging

  • PackageChannel enumerates flat-folder hives directly for local channels, since dotnet package search does not support local folder sources (this is the layout produced by ./build.sh --pack).
  • VersionHelper.IsLocalBuildChannel recognizes local, pr-*, and run-*.

Install scripts

  • get-aspire-cli-pr.{sh,ps1} and get-aspire-cli.{sh,ps1} share a single three-branch hive-label resolution: pr-<N> from a pr.<N>.g<sha> version suffix, else local.
  • --local-binary / -LocalBinary flag (paired with --local-dir / -LocalDir) installs a raw dotnet build / dotnet publish output tree directly, skipping the archive search-and-extract path. Useful for testing against an uncompressed build. --local-binary without --local-dir is rejected.

Testing

Aspire.Cli.Tests and Aspire.Acquisition.Tests cover assembly-metadata smoke, IdentityChannelReader, channel reseed, aspire update non-writes, config migration, flat-folder package enumeration, the PR/release install scripts' hive-label resolution, and aspire add cross-channel behavior. Test coverage asserts behavior directly on the constructors of the affected app-host project factories.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 6, 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 -- 16820

Or

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

@radical radical force-pushed the ankj/v3-pr1-channel branch from d043a14 to ed36ba2 Compare May 6, 2026 06:15
@radical

This comment was marked as outdated.

@radical

This comment was marked as outdated.

@radical radical changed the title feat(cli): channel binding — bake AspireCliChannel into assembly metadata [v3 acquisition] PR1: bake CLI channel into assembly metadata May 7, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 8, 2026

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.

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

radical and others added 16 commits May 12, 2026 01:25
Add an AspireCliChannel MSBuild property whose value (stable | staging | daily |
pr | local) is emitted as `[AssemblyMetadata("AspireCliChannel", ...)]` on the
CLI assembly. Locally-built CLIs default to "local" so they don't impersonate
the real "daily" channel — this matters for downstream PSM guards keyed on
identity == channel.

CI overrides the default via /p:AspireCliChannel=$(aspireCliChannel):
  - eng/clipack/Common.projitems propagates the value through the RID-specific
    pack pipeline.
  - eng/pipelines/templates/build_sign_native.yml threads it through the AzDO
    internal native-build template.
  - .github/workflows/build-cli-native-archives.yml does the same for GitHub
    Actions native archive builds.

PackageChannelNames.Local ("local") is added as a constant so the rest of the
CLI doesn't have to deal in raw strings.

Tests:
  - AssemblyMetadataChannelTests asserts the running CLI assembly carries the
    AssemblyMetadata attribute and that its value is one of the known channels.
  - CliMetadataPackagingTests verifies the metadata survives `dotnet pack`.
  - ClipackPropagationTests pins that clipack RID-specific packs honor the
    AspireCliChannel override.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wire the bootstrap so the running CLI's channel is sourced from the binary's
own [AssemblyMetadata("AspireCliChannel", ...)] value rather than from any
ambient configuration. This is the runtime half of the channel-baking story:
the build pins the channel into the assembly, and the CLI reads its own
identity at startup.

  - Acquisition/IdentityChannelReader.cs is the new component. It reads the
    AspireCliChannel metadata key from a caller-supplied Assembly. The ctor
    requires an explicit Assembly (no Assembly? = null default) so misuse is
    caught at construction time rather than via a cryptic
    "metadata missing on '?'" later. Production callers pass
    typeof(Aspire.Cli.Program).Assembly; tests pass a fake AssemblyBuilder
    assembly.
  - Program.BuildApplicationAsync registers IIdentityChannelReader in DI and
    threads its value into the new CliExecutionContext.Channel /
    .IdentityChannel / .PrNumber surface.
  - CliExecutionContext exposes:
      * Channel — the resolved hive label. For non-PR builds, identical to
        the identity channel verbatim. For PR builds (identity == "pr" with
        non-null PrNumber), resolves to "pr-<N>" — the directory name the
        packaging service uses, and the value reseed call sites write into a
        project's aspire.config.json#channel.
      * IdentityChannel — the raw build-time identity ("local"|"stable"|
        "staging"|"daily"|"pr"). Distinct from Channel because consumers that
        need the build-time taxonomy (PSM guards, version checks) should not
        see the per-PR refinement.
      * PrNumber — exposed verbatim. The constructor refuses
        channel="pr"+prNumber=null so a malformed PR build cannot construct.

Tests:
  - IdentityChannelReaderTests covers the metadata-key contract and the real
    GitHub Actions InformationalVersion shapes (-pr.<N>.g<SHA>+<sha>) the
    parser must accept.
  - CliBootstrapTests asserts the production wiring resolves a known channel
    value when invoked against the real Aspire.Cli assembly.
  - CliExecutionContextTests exercises the Channel/IdentityChannel/PrNumber
    surface, including the malformed-PR-build constructor guard.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The original name suggested the count was specific to PR build hives, but the
implementation has always returned the count of every subdirectory in the
hives root — including the local hive and any other channel-specific hives a
developer may have. The new name reflects what the method actually does.

This is a mechanical rename of the method on CliExecutionContext plus its five
call sites (AddCommand, IntegrationPackageSearchService, NewCommand,
UpdateCommand, TemplateNuGetConfigService). Comments and the doc string are
updated to match.

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

The app-host project plumbing was reading the channel through
IConfigurationService.GetConfigurationAsync("channel", ...) — a global lookup
that returned whatever happened to be in the user's ambient aspire config.
After the IdentityChannelReader work, the channel is available verbatim from
CliExecutionContext.Channel, so the global read is redundant and removes a
silent source of channel drift between sibling processes.

Changes:
  - AppHostServerProjectFactory: drop the IConfigurationService dependency.
    The factory now takes CliExecutionContext directly and forwards it.
  - DotNetBasedAppHostServerProject: drop the unused
    _configurationService field and its constructor parameter.
  - GuestAppHostProject: add the CliExecutionContext dependency so the
    generated guest-app-host can pass the channel through into its own
    DI graph (channel-aware paths that previously asked the global config
    now ask the execution context).

Tests:
  - AppHostServerProjectTests: drop the configurationService argument from
    the test factory; nothing else changes.
  - GuestAppHostProjectTests: pass a test CliExecutionContext through the
    constructor for parity with the production wiring.
  - CliTestHelper: factory now wires CliExecutionContext into the new
    constructor signatures.

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

When a prebuilt app-host runs against an existing project, the channel it
should use is the one the project was scaffolded against — stored in the
project's aspire.config.json#channel. The previous implementation consulted
global identity-channel state, which could disagree with the per-project
value when a developer ran multiple CLIs against the same project.

Changes:
  - PrebuiltAppHostServer.ResolveChannelName: read only the per-project
    aspire.config.json. Return null when the file is absent or sets no
    channel; let downstream consumers decide what to do with that.
  - Add a PSM (per-source mapping) guard: when the running CLI's identity
    channel is "local" and the requested channel is also "local", suppress
    the temporary NuGet config generation so the project picks up the user's
    ambient NuGet sources instead of being pinned to the local hive's PSM.
    For every other identity channel (stable, staging, daily, pr) the guard
    does not fire and a per-channel NuGet config is generated as before.

Tests:
  - PrebuiltAppHostServerChannelResolutionTests pins the per-project
    aspire.config.json#channel contract: null when absent, value when
    present.
  - PrebuiltAppHostServerTests adds the PSM-guard cross-product (identity
    channel × requested channel) so a regression that broadens the guard
    is caught.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ck; "local" override → implicit channel

TemplateNuGetConfigService had three channel-resolving entry points
(PromptToCreateOrUpdateNuGetConfigAsync, CreateOrUpdateNuGetConfigWithoutPromptAsync,
ResolveTemplatePackageAsync) and each one — when the caller did not supply an
explicit channel — fell back to reading ~/.aspire/aspire.config.json#channel
through IConfigurationService. That made template resolution silently depend
on whatever channel happened to be active for OTHER projects on the machine.

The fix is to drop the IConfigurationService dependency entirely: the only
channel inputs are now (1) the caller-supplied argument or (2) the implicit
channel. The three entry points short-circuit on null/whitespace input
instead of consulting a global source.

Side effect — local-channel override:
A locally-built CLI bakes channel="local" into its assembly metadata. On a
clean machine without ~/.aspire/hives/local, PackagingService produces no
"local" channel, and InitCommand forwards CliExecutionContext.Channel
("local") as the explicit ChannelOverride. Without resolver-level support
this throws ChannelNotFoundException and `aspire init` is unusable on a
clean machine. New behavior: a request for "local" with no matching channel
resolves to the implicit channel — semantically a CLI with no local hive
is just a CLI using the ambient NuGet configuration. The fallback is
narrowly scoped to "local"; any other unrecognized channel name still fails
loudly (typos like "stalbe" still surface as ChannelNotFoundException).

Tests:
  - TemplateNuGetConfigServiceTests covers null/whitespace short-circuits
    for the two prompt-style entry points, the local→implicit fallback,
    and the negative case that other unrecognized channels still throw.
  - DotNetTemplateFactoryTests drops the now-removed IConfigurationService
    argument from CreateTemplateFactory.

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

When `aspire init` or `aspire new` scaffolds a new project, write the
CLI's resolved CliExecutionContext.Channel value into the project's
aspire.config.json#channel field. This pins the per-project channel to
the CLI that scaffolded it, so subsequent runs against the project
resolve packages from the same hive — independent of any global channel
setting that may have drifted.

Changes:
  - ScaffoldingService gains channel-reseed entry points used by all
    starter-template factories.
  - CliTemplateFactory.{Go,Python,TypeScript}StarterTemplate forward
    CliExecutionContext.Channel into the reseed step after files are
    copied.
  - InitCommand and NewCommand pass CliExecutionContext.Channel into
    the scaffolding pipeline; they no longer derive the channel from
    global config.

Tests:
  - ChannelReseedTests pins the per-channel reseed contract: each
    identity channel (local | stable | staging | daily | pr-N) ends up
    written verbatim into aspire.config.json#channel, including the
    pr-N resolution for PR-channel CLIs.
  - InitCommandTests adds the reseed coverage and updates the existing
    init flow expectations to match the new wiring (drops the now-
    removed FakeConfigurationServiceWithChannel helper and the two
    tests that exercised the deleted global-channel-fallback path).

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

Two related additions that complete the local-channel runtime story.

PackageChannel — flat-folder enumeration:
  For local hive channels the Aspire* source is a flat folder of .nupkg
  files (the layout created by ./build.sh --pack). `dotnet package search`
  does not support local folder sources and returns no results, so
  GetIntegrationPackagesAsync would yield an empty set for the local hive
  even when packages are present. New behavior: when PinnedVersion != null
  and the Aspire-filtered mapping points at an existing local directory,
  enumerate *.nupkg files directly and project them to NuGetPackage
  identities. The HTTP/search path is unchanged for non-local channels.

VersionHelper.IsLocalBuildChannel — accept "local":
  The helper previously recognized only `pr-*` (PR hives) and `run-*`
  (workflow-run hives) as locally-built channels. With the baked
  AspireCliChannel value of "local" for ./build.sh builds, the helper now
  also returns true for the literal "local" channel, so version-check
  paths classify local-dev builds correctly alongside PR builds.

Tests:
  - PackagingServiceTests adds direct coverage for the flat-folder
    enumeration path, including the corrupted-state shape (hive dir
    exists but `packages/` is empty) where PinnedVersion is null and
    enumeration must safely yield nothing.
  - VersionHelperTests pins the new "local" → true case.

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

The PR-install scripts (get-aspire-cli-pr.{sh,ps1}) installed CLI artifacts
into a hive label that did not match what the running CLI's
CliExecutionContext.Channel resolved to. The mismatch surfaced when the
script ran against a local `--local-dir` source: artifacts landed under
~/.aspire/hives/<wrong-label>/, but the CLI looked under
~/.aspire/hives/pr-<N>/ (PR builds) or ~/.aspire/hives/local/ (local
dev builds), so the freshly-installed packages were invisible.

Resolution rule:
  - If the script's source nupkgs carry a PR-suffixed version
    (matching `pr.<N>.g<sha>`), the hive label is `pr-<N>`.
  - Otherwise the hive label is `local`.
  - Default falls back to `local` so `--local-dir` installs from local-dev
    builds (no PR suffix) work end-to-end without manual labeling.

The release-side scripts (get-aspire-cli.{sh,ps1}) had divergent hive-
labeling logic that the PR-script fix obsoletes; the release scripts now
share the same three-branch resolution, removing ~340 lines of duplicated
branch-handling.

The polyglot-validation setup-local-cli.sh helper learns the same rule so
CI's local-CLI smoke tests install into the same hive label the CLI will
read from.

Tests:
  - PRScriptShellTests / PRScriptPowerShellTests cover the three-branch
    resolution (pr-N from suffix, local fallback, explicit override).
  - ReleaseScriptShellTests / ReleaseScriptPowerShellTests mirror the
    pinning on the release-script side after the dedup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Self-update previously saved the chosen channel (or cleared it for "stable")
into ~/.aspire/aspire.config.json#channel so subsequent `aspire new` and
`aspire init` runs would pick it up via global config. With the per-project
channel reseed landed earlier in this branch, the global setting is no
longer a source of truth for anything — every scaffolded project carries
its own channel — so the global write is dead code and a source of drift
between projects on the same machine.

Changes:
  - UpdateCommand.ExecuteSelfUpdateAsync no longer calls
    SetConfigurationAsync / DeleteConfigurationAsync for "channel".
  - The accompanying comment is rewritten to describe the current contract:
    the channel choice applies to this self-update only; subsequent
    scaffolding resolves channel per-project.
  - The legacy → new config migration drops the channel key during
    migration (the migrated global config never carries channel), matching
    the new "channel is not stored globally" invariant. The legacy file
    is still preserved verbatim for backward compatibility with older CLIs.

Tests:
  - UpdateCommandTests asserts no global channel writes occur during
    self-update across all selectable channels.
  - ConfigMigrationTests (E2E) updates the migration assertion to expect
    the channel key absent in the migrated aspire.config.json and present
    in the preserved legacy file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pin the `aspire add` hive-resolution behavior across the new local channel
to match the existing PR-hive coverage:

  - AddCommand_WithLocalHive_PrefersCurrentCliVersion: with a populated
    ~/.aspire/hives/local/packages dir whose Aspire.Hosting pkg matches
    the running CLI's version, `aspire add` should pick that version
    without prompting (the local-hive equivalent of the existing
    WithPrHive coverage).

This complements the PackageChannel flat-folder enumeration and the
ChannelReseed coverage added earlier on this branch.

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

Previously the CLI carried two pieces of channel state:

 1. [AssemblyMetadata("AspireCliChannel", "pr")]      — the 5-value
    taxonomy (stable | staging | daily | pr | local).
 2. AssemblyInformationalVersionAttribute (0.0.0-pr.<N>.g<sha>) — the
    version stamp, which was *separately* parsed at runtime by
    IdentityChannelReader.ParsePrNumber to extract <N>.

CliExecutionContext then joined the two as `pr-<N>` for the hive label
exposed via .Channel. The split existed only inside the CLI itself —
every non-`pr` channel passed through verbatim, no external consumer
relied on the `pr` literal, and the only other src/ reader of the
raw taxonomy (PrebuiltAppHostServer.cs:423) only checked `== "local"`
which is unchanged in either scheme.

Collapse the two pieces. CI now bakes the resolved hive label
directly:

  * GH Actions (.github/workflows/build-cli-native-archives.yml):
    pull_request -> pr-<github.event.pull_request.number>
  * AzDO (eng/pipelines/templates/build_sign_native.yml):
    Build.Reason == PullRequest -> pr-<System.PullRequest.PullRequestNumber>,
    with a digit-only regex pre-check that throws if the agent ever
    hands us an unresolved macro or non-numeric value (clearer failure
    attribution than waiting for the runtime IsValidChannel check).

Runtime simplifications:
  * IdentityChannelReader.ParsePrNumber: deleted (~40 lines).
  * IdentityChannelReader.ResolveChannel: now validates shape via the
    new IsValidChannel helper. Accepts the four fixed strings
    (stable | staging | daily | local) or `pr-<digits>`. Rejects the
    legacy literal `pr` (no suffix) so a CI misconfiguration cannot
    silently mis-route packages.
  * CliExecutionContext: `prNumber` ctor parameter dropped;
    PrNumber and IdentityChannel properties dropped; Channel is now a
    plain auto-property. The `channel=='pr' && prNumber is null` throw
    guard goes away — the invariant is now structurally enforced by
    the channel string itself.
  * Program.cs: the `if (channel == "pr") { ParsePrNumber(...) }`
    block collapses to one line.

PrebuiltAppHostServer.cs: the one IdentityChannel reference becomes
Channel; behavior identical because the only check is `== "local"`
and `local` is unchanged.

Tests:
  * AssemblyMetadataChannelTests: smoke-tests the baked value against
    IsValidChannel (was: set-membership against the legacy 5 values).
  * IdentityChannelReaderTests: drops the ParsePrNumber theory entirely,
    adds the IsValidChannel truth table, and adds a Throws-based set
    covering invalid shapes (including the legacy `pr` literal as a
    regression guard for the CI change).
  * CliExecutionContextTests: rewritten as a thin getter test (the
    type is now a holder, not a join).
  * CliBootstrapTests: drops the IdentityChannel branch on the PrNumber
    assertion.
  * InitCommandTests, PrebuiltAppHostServerTests, ChannelReseedTests:
    builders drop the `prNumber:` argument; callers that previously
    passed `channel: "pr", prNumber: 12345` now pass
    `channel: "pr-12345"`.

All 2881 Aspire.Cli.Tests pass; src/Aspire.Cli and tests build clean
on net10.0/arm64.

Out of scope: the shell-script PR-suffix detection in
setup-local-cli.sh (commit 160801c) parses nupkg filenames stamped by
VersionSuffix, not assembly metadata, so it is not affected by this
change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds direct tests for behaviors the branch already implements but that were
only covered transitively, so a regression here would surface in unrelated
suites rather than naming the cause.

- CliExecutionContextTests: 4 GetHiveCount() tests (missing dir → 0, empty
  dir → 0, populated count, ignores stray files). GetHiveCount() gates the
  channel picker in update, the channel sub-menu in add, and explicit-channel
  inclusion in IntegrationPackageSearchService — so a bug here previously
  manifested as cross-command flake.

- UpdateCommandTests: three picker-contract tests.
  * WithHivesAndConfiguredChannel and WithHivesAndLocallyConfiguredChannel
    close the (hive-present × configured-channel) intersection — existing
    tests cover one axis at a time but not the intersection that PR-build
    users actually hit.
  * WithHives_PromptOffersChannelsInPackagingServiceOrder is the
    prompt-content contract: every existing prompt callback either flag-sets
    or .First()s the choices; nothing verified the names, order, or
    'Name (SourceDetails)' label format. Reorderings or label drops would
    otherwise be silently green.

- PrebuiltAppHostServerChannelResolutionTests: two migration-safety tests for
  the new aspire.config.json ?? legacy AspireJsonConfiguration fallback.
  Covers the legacy-only path (no new file present) and the precedence when
  both files exist with different channels — guards against an accidental
  ?? operand swap.

- TemplateNuGetConfigServiceTests: three IncludePrHives matrix tests. The
  existing three resolver tests all pass IncludePrHives: false; the true row
  (the aspire new path) and the (true × no-hives-on-disk) AND-gate were
  uncovered. Adds: IncludePrHivesTrue_WithHivesPresent_AllChannelsParticipate,
  IncludePrHivesTrue_NoHivesOnDisk_RestrictsToImplicit, and
  IncludePrHivesFalse_IgnoresHivesEvenWhenPresent (the aspire init policy).

Verified locally: 72/72 of the affected test classes pass; 56/56 unaffected
NewCommandTests still pass.

Three gaps from the audit are intentionally deferred:
- NewCommand local-build channel bias and global-config-fallback removal
  (the audit's T1/G8) target ResolveCliTemplateVersionAsync, which only
  fires for TemplateRuntime.Cli templates. Reaching it via aspire-starter
  is impossible — it's TemplateRuntime.DotNet and takes a different
  resolution path. The clean fix is to extract the channel-selection block
  to an internal helper that takes (channels, executionContextChannel,
  configuredChannelName) and tests it as a pure function across all rows;
  that refactor is out of scope for a pure test-add commit.
- A scaffold-then-resolve end-to-end round-trip (G5) is now reachable in
  principle via the new G6 read-side tests plus existing ChannelReseedTests
  on the write side, so the marginal value of a joint test is small.

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

Eleven targeted test simplifications across the new channel-resolution suite.
Net: 15 files touched, -284 lines, no coverage dropped (verified by
case-by-case audit: every assertion in every removed/folded test still has at
least one equivalent assertion remaining).

1. Delete CliMetadataPackagingTests.cs — its sole "metadata exists +
   non-empty" assertion is a strict subset of
   AssemblyMetadataChannelTests.AspireCliChannel_AssemblyMetadata_HasValidShape
   (IsValidChannel already rejects null/empty).

2. CliBootstrapTests: BuildApplication_LocallyBuiltCli_… and
   BuildApplication_CliExecutionContextChannel_… asserted the same thing.
   Keep one; extract GetBakedEntryAssemblyChannel() so the
   GetCustomAttributes<AssemblyMetadataAttribute>().Single(...) lookup isn't
   open-coded.

3. UpdateCommandTests: drop SelfUpdate_StableChannel_DoesNotDeleteGlobalChannel
   — the SelfUpdate_DoesNotWriteChannelToGlobalConfiguration theory's
   "update --self --channel stable" row already asserts no-delete (and no-set,
   so it's a strict superset).

4. AssemblyMetadataChannelTests: replace fragile
   CliAssembly_BakesChannel_AsLocal_MatchingCsprojDefault (would fail under
   /p:AspireCliChannel=stable CI builds) with
   Csproj_DeclaresAspireCliChannelDefault_AsLocal — inspects the csproj XML
   for the `<AspireCliChannel Condition="'$(AspireCliChannel)' == ''">local`
   element. Guards the same property (the declared default) without coupling
   to the build configuration of the test host.

5. AddCommandTests: extract RunAddRedisWithHiveScenarioAsync to share the
   workspace + prompter + project locator + AddPackage-capture scaffolding
   across the three hive tests (WithPrHive / WithLocalHive / WithLocalAndPrHives).
   Each test now declares only the hive layout and the SearchPackagesAsync mock.

6. ChannelReseedTests: NoExplicitChannel (4 InlineData rows) + ExplicitChannel
   (Fact) → one [Theory] with five rows over (contextChannel, explicitChannel,
   expected). Drop the CreateExecutionContext→BuildContext one-line wrapper
   and switch from Directory.CreateTempSubdirectory + try/finally to
   TemporaryWorkspace (project convention).

7. IdentityChannelReaderTests: the
   IsValidChannel_MatchesExpectedTruthTable theory exhaustively asserts the
   shape contract; the assembly-roundtrip tests only need to verify the wiring
   from ReadChannel through IsValidChannel. Reduce
   ReadChannel_AssemblyHasMetadataForValidChannel_ReturnsValue (6 rows) and
   ReadChannel_InvalidChannelValue_Throws (10 rows) to one representative
   [Fact] each; drop ReadChannel_AssemblyHasEmptyChannelMetadata_Throws
   (covered by the empty-string row in the truth table).

8. PrebuiltAppHostServer.ResolveChannelName: private → internal (the assembly
   already has [InternalsVisibleTo("Aspire.Cli.Tests")]). Drop reflection
   in PrebuiltAppHostServerChannelResolutionTests (4 sites) and
   PrebuiltAppHostServerTests (1 site).

9. CliExecutionContextTests: Channel_DefaultsToLocal_WhenNotSpecified now
   uses CreateContext(channel: null) instead of inlining the 5-line ctor
   that the helper already encapsulates.

10. Move Ctor_NullAssembly_ThrowsArgumentNullException from CliBootstrapTests
    to IdentityChannelReaderTests — it's about reader ctor validation, not
    bootstrap-wiring DI.

11. TemplateNuGetConfigServiceTests: fold three IncludePrHives Facts
    (TrueWithHives / TrueNoHives / FalseWithHives) into one [Theory] with
    three rows over (includePrHives, createHiveOnDisk, expectedVersion,
    expectImplicitChannel, expectedChannelName).

Verified: build clean; 180 tests across the affected classes pass locally.

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

The self-update process description listed a 7-step flow ending with
"Saves selected channel to global settings". That step was removed
across the v3 acquisition work — the CLI no longer writes the channel
globally (acquisition scripts dropped save_global_settings; aspire
update --self stopped persisting --channel). Identity is now baked
into the binary via AspireCliChannel assembly metadata; per-project
overrides live in aspire.config.json#channel. Drop step 7 and add a
short note explaining the new model so docs match reality.

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

Identity-channel is baked into the CLI via AspireCliChannel assembly
metadata; the acquisition scripts and aspire update --self no longer
seed a global "channel" field. The step-2 fallback in
ConfigurationService.GetConfigurationFromDirectoryAsync still reads
the global file, however, so aspire update's documented step-3
precedence ("global config 'channel'") is reachable.

That's intentional for now — users who deliberately ran
\`aspire config set -g channel <x>\` on the new CLI keep their
preference honored by aspire update — but it's worth flagging at both
the call site (UpdateCommand precedence comment) and the impl
(ConfigurationService) so a future reader doesn't assume the global
read is also dead. Add TODO markers calling out that the fallback can
be removed once telemetry confirms negligible global-channel usage.

No behavior change.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
radical and others added 15 commits May 12, 2026 15:28
The staging-channel gate in KnownFeatures.IsStagingChannelEnabled reads
configuration["channel"] (env vars + command-line + per-project
aspire.config.json#channel + global config), NOT
CliExecutionContext.IdentityChannel. A CLI baked with AspireCliChannel=staging
does NOT auto-enable staging in the packaging service unless the user has
also set the configuration value explicitly (e.g. via `aspire config set
channel staging` or `--channel staging`).

This is by design: identity selects which hive directory the CLI ships in;
feature gating goes through user-visible configuration. Audit flagged the
asymmetry as undocumented; adding a <remarks> block so future maintainers
do not read the gate as identity-driven.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GuestAppHostProject.RunAsync and ScaffoldingService.ScaffoldGuestLanguageAsync
each silently wrote config.Channel back to aspire.config.json after build /
scaffold prepare. Both writes reduced to one of two cases:

  - The project already had a channel pinned -> buildResult.ChannelName /
    prepareResult.ChannelName matched it (both come from
    AspireConfigFile.Load(...)?.Channel) -> no-op file write.
  - The project had no channel pinned -> buildResult.ChannelName was null,
    falling through to `?? _executionContext.IdentityChannel` -> silent
    identity pin on every `aspire run`.

Neither case is useful work. The C# apphost path (DotNetBasedAppHostServerProject)
never writes config.Channel and has worked fine in this regard, so the
asymmetry was branch-introduced (PR microsoft#16820's channel refactor), not
load-bearing.

After this commit:
  - `aspire run` is pure-read for config.Channel (guest + dotnet symmetric).
  - Scaffolding writes config.Channel exactly once, at seed time
    (ScaffoldingService:75) via the existing config.Save(...) at line 82.
  - `aspire update` writes config.Channel only when the user resolved an
    explicit channel (already pinned by the previous commit).

Three writes total across the CLI, each tied to a concrete user-driven
action. No silent pinning anywhere.

New regression test RunAsync_DoesNotMutateConfigChannel (Theory x2:
seededChannel="stable" and null) was verified red before applying the fix
(both cases produced Actual: "pr-99999"; after fix both stay at the seeded
value).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses PR microsoft#16820 reviewer feedback about inconsistent channel-name case sensitivity.

Uses StringComparison.Ordinal for CLI channel names, including the PSM local-channel guard, because these channel values are baked/configured as well-known lowercase identifiers rather than free-form case-insensitive labels.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses PR microsoft#16820 reviewer feedback asking whether the pinned local-hive package scan should use StartsWith instead of Contains.

The filter is intended to keep Aspire.Hosting packages, so matching the package-id prefix avoids accidental substring matches.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses PR microsoft#16820 reviewer feedback to use PackageChannelNames constants for baked identity-channel validation.

Keeps the fixed channel set centralized while preserving the existing pr-<N> prefix validation.

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

extract_version_suffix_from_packages (bash) / Get-VersionSuffixFromPackages (pwsh)
short-circuit in --dry-run / -WhatIf and previously returned the literal
'pr.1234.a1b2c3d4'. The new --local-dir auto-detect logic at the call site
feeds that value through the regex ^pr\.([0-9]+)\.[0-9a-g]+$ to derive a hive
label, so the mock unconditionally forced hive_label='pr-1234' regardless of
what was actually in --local-dir, making the intended 'local' fallback
unreachable in dry-run.

Return the non-PR-shaped sentinel 'local' instead, which the caller's regex
intentionally does not match, so the dry-run path now behaves the same as the
real-world 'no PR suffix detected' path: hive_label='local'.

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

The deleted tests asserted XML shape on eng/clipack/Common.projitems
(MSBuild idiom: <MSBuild Targets="Publish" Properties="…@(AdditionalProperties)" />).
That couples the test to a particular MSBuild construct: a behavior-preserving
refactor (e.g. forwarding through a helper target or hand-built Properties=
string) would fail the test, and an XML-valid but behaviorally-wrong refactor
would still pass it.

The downstream contract — "the baked CLI binary has AspireCliChannel in its
AssemblyMetadata" — is covered by Aspire.Cli.Tests.AssemblyMetadataChannelTests,
which reads the actual baked metadata and validates it against
IdentityChannelReader.IsValidChannel.

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

In ReleaseScript{Shell,PowerShell}Tests:

1. Drop Assert.DoesNotContain("save_global_settings" / "Save-GlobalSettings", …).
   Those are internal helper-function names; renaming the helper would break
   the test even though the contract still holds. The artifact-level check
   (File.Exists(globalConfig) == false) and the path-name check on dry-run
   output (DoesNotContain "aspire.config.json") already cover the real
   contract.

2. Replace "PR1 invariant" comments with statements of the invariant itself:
   install scripts must not write a global aspire.config.json — the channel
   is baked into the CLI binary at build time and read via
   IdentityChannelReader, so a global channel field would shadow the baked
   value. The string "PR1" encodes development-time PR sequencing and rots
   post-merge.

43/43 ReleaseScript{Shell,PowerShell} tests pass after the change.

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

CLI E2E tests intentionally run against multiple CliInstallStrategy modes
selected via CliInstallStrategy.Detect: LocalHive (local-built archive),
InstallScript (downloaded by get-aspire-cli{,-pr}.{sh,ps1}), and pre-existing
CLI on PATH. Only LocalHive returns a non-null channel from
PrepareLocalChannel; the other strategies rely on the CLI's baked channel
plus ambient NuGet feeds.

The old comment ("For LocalHive runs, point the freshly-created project at
the local channel…") read like a runtime env-sniff. Replace with a comment
that names the strategy explicitly and contrasts LocalHive against the
null-returning strategies, so readers see the if-branch as design intent
rather than runtime hedging.

Touched sites (no behavior change):
- ChannelUpdateWorkflowTests.cs (1 site, replaced existing comment)
- TypeScriptCodegenValidationTests.cs (1 site, added comment)
- TypeScriptPolyglotTests.cs (4 sites: two channelArgument ternaries and
  two WriteLocalChannelSettings if-blocks, comment adapted per site)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
In LocalDir_DryRun_WithGitHubRunIdEnvSet_UsesLocalHiveLabel and its PowerShell
sibling LocalDir_WhatIf_WithGitHubRunIdEnvSet_UsesLocalHiveLabel, the existing
assertion was too weak:

  Assert.Contains("local", result.Output, StringComparison.OrdinalIgnoreCase);

The substring "local" appears in the fixture path (local-artifacts) and in
neighboring log lines ("from local packages"), so the assertion passes even
when the actual hive label is wrong (e.g. pr-<N> from a dry-run mock that
collides with the auto-detect regex).

Tighten to the exact phrase the script emits at install_aspire_cli /
Resolve-HiveLabel:

  Assert.Contains("Using hive label: local", result.Output, StringComparison.Ordinal);

The existing Assert.DoesNotContain("run-99999", …) guard is preserved — it
protects a different regression (GITHUB_RUN_ID leaking into the hive label
when --local-dir / -LocalDir is used).

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

Without --pr-number / --hive-label, the script previously dropped packages into
hives/run-<workflowRunId>/packages. With the v3 design, the installed CLI's
CliExecutionContext.Channel is baked at build time from AspireCliChannel and is
one of pr-<N>/staging/daily/local — never run-<id>. Packages installed into a
run-<id> hive are therefore unreachable from the CLI.

Reject early with an actionable error directing the user to pass --pr-number
(preferred) or --hive-label matching the baked AspireCliChannel. This is the
Option B path from the v3-pr1 build-infra review; Option A (querying the
binary's baked channel) would require new CLI surface area.

Tests:
- PRScriptShellTests / PRScriptPowerShellTests: add regression guards
  asserting the rejection, plus tighten the --run-id-as-first-arg parsing
  tests to pair --run-id with --hive-label so they still verify positional
  parsing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Two coupled changes that close a silent-failure path on push events:

1. .github/workflows/tests.yml: gate the polyglot_validation job on
   github.event_name == 'pull_request'. Mirrors cli_starter_validation_windows.
   ci.yml triggers tests.yml on push to main/release/** as well as PRs, but
   the polyglot validation flow has no way to compute a hive label that the
   non-PR CLI archive (channel=daily/staging) will read from. Also exempt
   polyglot_validation.result == 'skipped' from the non-PR failure gate in
   test_summary.

2. .github/workflows/polyglot-validation/setup-local-cli.sh: remove the
   silent fall-back to HIVE_LABEL=local when no PR suffix is detected on the
   built nupkgs and fail loud instead. This is now a guard rather than a
   real code path — the workflow guard above ensures the script only runs on
   PR events where the suffix is always present — but the loud error is
   important defense in depth if the guard ever regresses.

Option A (deriving the hive label from the CLI binary's baked
AspireCliChannel) would require new CLI surface area and is not pursued
here.

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

Mirrors PrebuiltAppHostServerChannelResolutionTests to cover the remaining
two readers from which PR1 removed the global-channel read fallback
(IConfigurationService.GetConfigurationAsync("channel", ...)):

- DotNetBasedAppHostServerProject — direct-instantiation behavioral guards:
  per-project aspire.config.json is honored, legacy AspireJsonConfiguration
  is honored when the new file is absent, the new format wins when both
  exist, and a null channel is returned when no per-project state is set.
- NewCommand — tripwire IConfigurationService that throws on any
  GetConfigurationAsync/GetConfigurationFromDirectoryAsync call for the
  'channel' key (mirrors InitCommand_DoesNotConsultGlobalConfigurationService\
ForChannelKey).

Addresses the gap noted in PR1 review feedback that 'coverage relies on the
existing tests still passing after the fallback delete' for these two readers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PR1 added several channel-name comparisons using StringComparison.Ordinal,
which made user-supplied channel values (config, --channel, baked metadata
read paths) reject mixed-case inputs. Three CI tests caught it:

  - KnownFeaturesTests.IsStagingChannelEnabled_IsCaseInsensitive_ForChannelValue
  - KnownFeaturesTests.IsStagingChannelEnabled_IsCaseInsensitive_ForUppercaseChannelValue
  - VersionHelperTests.IsLocalBuildChannel_RecognizesAllLocalChannelForms("LOCAL")

Switch all channel-name matching to OrdinalIgnoreCase across the call sites
that compare values against PackageChannelNames.* literals or PackageChannel
records. IdentityChannelReader keeps Ordinal intentionally — it strictly
validates the lowercase value baked into our own assembly metadata and uses
case-sensitivity as a misconfiguration tripwire.

Also addresses PR microsoft#16820 review comment r3228883227.
@radical radical force-pushed the ankj/v3-pr1-channel branch from bff53cc to 4ecc899 Compare May 12, 2026 21:09
radical added a commit to radical/aspire that referenced this pull request May 12, 2026
- Merged decision inbox (livingston-runbook-e2e-feasibility + livingston-pr1-test-followups) into decisions.md
- Created orchestration logs for livingston (runbook analysis + test implementation) and basher (push + CI watch)
- Created session log summarizing PR microsoft#16820 final state: tests passing, ready for review
- Appended team updates to livingston and basher history files
- Summarized livingston history.md (overflow >15KB) into history-archive.md

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
radical and others added 2 commits May 12, 2026 18:42
Adds a Channel Identity subsection in docs/specs/bundle.md covering the
AspireCliChannel → [AssemblyMetadata] → IdentityChannelReader →
CliExecutionContext.IdentityChannel pipeline, plus tables for the supported
channel names and the hive types on disk.

Adds a user-facing channel-names callout to docs/dogfooding-pull-requests.md
so users know the valid values for aspire.config.json#channel and
`aspire update --channel`, including the `local` hive used by developer
builds.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Agent: livingston
Files created:
- .squad/decisions.md (merged inbox)
- .squad/orchestration-log/2026-05-12T22-39-42Z-livingston.md
- .squad/log/2026-05-12T22-39-42Z-channel-docs.md

Decision inbox (4 items) merged and cleared.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@radical radical merged commit 4a1725a into microsoft:main May 13, 2026
293 checks passed
@github-actions github-actions Bot added this to the 13.4 milestone May 13, 2026
@radical radical deleted the ankj/v3-pr1-channel branch May 13, 2026 00:09
radical added a commit to radical/aspire that referenced this pull request May 13, 2026
…icrosoft#16820)

* feat(cli): bake AspireCliChannel into assembly metadata

Add an AspireCliChannel MSBuild property whose value (stable | staging | daily |
pr | local) is emitted as `[AssemblyMetadata("AspireCliChannel", ...)]` on the
CLI assembly. Locally-built CLIs default to "local" so they don't impersonate
the real "daily" channel — this matters for downstream PSM guards keyed on
identity == channel.

CI overrides the default via /p:AspireCliChannel=$(aspireCliChannel):
  - eng/clipack/Common.projitems propagates the value through the RID-specific
    pack pipeline.
  - eng/pipelines/templates/build_sign_native.yml threads it through the AzDO
    internal native-build template.
  - .github/workflows/build-cli-native-archives.yml does the same for GitHub
    Actions native archive builds.

PackageChannelNames.Local ("local") is added as a constant so the rest of the
CLI doesn't have to deal in raw strings.

Tests:
  - AssemblyMetadataChannelTests asserts the running CLI assembly carries the
    AssemblyMetadata attribute and that its value is one of the known channels.
  - CliMetadataPackagingTests verifies the metadata survives `dotnet pack`.
  - ClipackPropagationTests pins that clipack RID-specific packs honor the
    AspireCliChannel override.

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

* feat(cli): IdentityChannelReader sources channel from assembly metadata

Wire the bootstrap so the running CLI's channel is sourced from the binary's
own [AssemblyMetadata("AspireCliChannel", ...)] value rather than from any
ambient configuration. This is the runtime half of the channel-baking story:
the build pins the channel into the assembly, and the CLI reads its own
identity at startup.

  - Acquisition/IdentityChannelReader.cs is the new component. It reads the
    AspireCliChannel metadata key from a caller-supplied Assembly. The ctor
    requires an explicit Assembly (no Assembly? = null default) so misuse is
    caught at construction time rather than via a cryptic
    "metadata missing on '?'" later. Production callers pass
    typeof(Aspire.Cli.Program).Assembly; tests pass a fake AssemblyBuilder
    assembly.
  - Program.BuildApplicationAsync registers IIdentityChannelReader in DI and
    threads its value into the new CliExecutionContext.Channel /
    .IdentityChannel / .PrNumber surface.
  - CliExecutionContext exposes:
      * Channel — the resolved hive label. For non-PR builds, identical to
        the identity channel verbatim. For PR builds (identity == "pr" with
        non-null PrNumber), resolves to "pr-<N>" — the directory name the
        packaging service uses, and the value reseed call sites write into a
        project's aspire.config.json#channel.
      * IdentityChannel — the raw build-time identity ("local"|"stable"|
        "staging"|"daily"|"pr"). Distinct from Channel because consumers that
        need the build-time taxonomy (PSM guards, version checks) should not
        see the per-PR refinement.
      * PrNumber — exposed verbatim. The constructor refuses
        channel="pr"+prNumber=null so a malformed PR build cannot construct.

Tests:
  - IdentityChannelReaderTests covers the metadata-key contract and the real
    GitHub Actions InformationalVersion shapes (-pr.<N>.g<SHA>+<sha>) the
    parser must accept.
  - CliBootstrapTests asserts the production wiring resolves a known channel
    value when invoked against the real Aspire.Cli assembly.
  - CliExecutionContextTests exercises the Channel/IdentityChannel/PrNumber
    surface, including the malformed-PR-build constructor guard.

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

* refactor(cli): rename GetPrHiveCount to GetHiveCount

The original name suggested the count was specific to PR build hives, but the
implementation has always returned the count of every subdirectory in the
hives root — including the local hive and any other channel-specific hives a
developer may have. The new name reflects what the method actually does.

This is a mechanical rename of the method on CliExecutionContext plus its five
call sites (AddCommand, IntegrationPackageSearchService, NewCommand,
UpdateCommand, TemplateNuGetConfigService). Comments and the doc string are
updated to match.

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

* refactor(cli): drop IConfigurationService from app-host project factories

The app-host project plumbing was reading the channel through
IConfigurationService.GetConfigurationAsync("channel", ...) — a global lookup
that returned whatever happened to be in the user's ambient aspire config.
After the IdentityChannelReader work, the channel is available verbatim from
CliExecutionContext.Channel, so the global read is redundant and removes a
silent source of channel drift between sibling processes.

Changes:
  - AppHostServerProjectFactory: drop the IConfigurationService dependency.
    The factory now takes CliExecutionContext directly and forwards it.
  - DotNetBasedAppHostServerProject: drop the unused
    _configurationService field and its constructor parameter.
  - GuestAppHostProject: add the CliExecutionContext dependency so the
    generated guest-app-host can pass the channel through into its own
    DI graph (channel-aware paths that previously asked the global config
    now ask the execution context).

Tests:
  - AppHostServerProjectTests: drop the configurationService argument from
    the test factory; nothing else changes.
  - GuestAppHostProjectTests: pass a test CliExecutionContext through the
    constructor for parity with the production wiring.
  - CliTestHelper: factory now wires CliExecutionContext into the new
    constructor signatures.

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

* refactor(cli): PrebuiltAppHostServer resolves channel from per-project aspire.config.json

When a prebuilt app-host runs against an existing project, the channel it
should use is the one the project was scaffolded against — stored in the
project's aspire.config.json#channel. The previous implementation consulted
global identity-channel state, which could disagree with the per-project
value when a developer ran multiple CLIs against the same project.

Changes:
  - PrebuiltAppHostServer.ResolveChannelName: read only the per-project
    aspire.config.json. Return null when the file is absent or sets no
    channel; let downstream consumers decide what to do with that.
  - Add a PSM (per-source mapping) guard: when the running CLI's identity
    channel is "local" and the requested channel is also "local", suppress
    the temporary NuGet config generation so the project picks up the user's
    ambient NuGet sources instead of being pinned to the local hive's PSM.
    For every other identity channel (stable, staging, daily, pr) the guard
    does not fire and a per-channel NuGet config is generated as before.

Tests:
  - PrebuiltAppHostServerChannelResolutionTests pins the per-project
    aspire.config.json#channel contract: null when absent, value when
    present.
  - PrebuiltAppHostServerTests adds the PSM-guard cross-product (identity
    channel × requested channel) so a regression that broadens the guard
    is caught.

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

* refactor(cli): TemplateNuGetConfigService drops global-channel fallback; "local" override → implicit channel

TemplateNuGetConfigService had three channel-resolving entry points
(PromptToCreateOrUpdateNuGetConfigAsync, CreateOrUpdateNuGetConfigWithoutPromptAsync,
ResolveTemplatePackageAsync) and each one — when the caller did not supply an
explicit channel — fell back to reading ~/.aspire/aspire.config.json#channel
through IConfigurationService. That made template resolution silently depend
on whatever channel happened to be active for OTHER projects on the machine.

The fix is to drop the IConfigurationService dependency entirely: the only
channel inputs are now (1) the caller-supplied argument or (2) the implicit
channel. The three entry points short-circuit on null/whitespace input
instead of consulting a global source.

Side effect — local-channel override:
A locally-built CLI bakes channel="local" into its assembly metadata. On a
clean machine without ~/.aspire/hives/local, PackagingService produces no
"local" channel, and InitCommand forwards CliExecutionContext.Channel
("local") as the explicit ChannelOverride. Without resolver-level support
this throws ChannelNotFoundException and `aspire init` is unusable on a
clean machine. New behavior: a request for "local" with no matching channel
resolves to the implicit channel — semantically a CLI with no local hive
is just a CLI using the ambient NuGet configuration. The fallback is
narrowly scoped to "local"; any other unrecognized channel name still fails
loudly (typos like "stalbe" still surface as ChannelNotFoundException).

Tests:
  - TemplateNuGetConfigServiceTests covers null/whitespace short-circuits
    for the two prompt-style entry points, the local→implicit fallback,
    and the negative case that other unrecognized channels still throw.
  - DotNetTemplateFactoryTests drops the now-removed IConfigurationService
    argument from CreateTemplateFactory.

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

* feat(cli): scaffolding reseeds channel into per-project aspire.config.json

When `aspire init` or `aspire new` scaffolds a new project, write the
CLI's resolved CliExecutionContext.Channel value into the project's
aspire.config.json#channel field. This pins the per-project channel to
the CLI that scaffolded it, so subsequent runs against the project
resolve packages from the same hive — independent of any global channel
setting that may have drifted.

Changes:
  - ScaffoldingService gains channel-reseed entry points used by all
    starter-template factories.
  - CliTemplateFactory.{Go,Python,TypeScript}StarterTemplate forward
    CliExecutionContext.Channel into the reseed step after files are
    copied.
  - InitCommand and NewCommand pass CliExecutionContext.Channel into
    the scaffolding pipeline; they no longer derive the channel from
    global config.

Tests:
  - ChannelReseedTests pins the per-channel reseed contract: each
    identity channel (local | stable | staging | daily | pr-N) ends up
    written verbatim into aspire.config.json#channel, including the
    pr-N resolution for PR-channel CLIs.
  - InitCommandTests adds the reseed coverage and updates the existing
    init flow expectations to match the new wiring (drops the now-
    removed FakeConfigurationServiceWithChannel helper and the two
    tests that exercised the deleted global-channel-fallback path).

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

* feat(cli): PackageChannel enumerates flat-folder hives; VersionHelper recognizes "local"

Two related additions that complete the local-channel runtime story.

PackageChannel — flat-folder enumeration:
  For local hive channels the Aspire* source is a flat folder of .nupkg
  files (the layout created by ./build.sh --pack). `dotnet package search`
  does not support local folder sources and returns no results, so
  GetIntegrationPackagesAsync would yield an empty set for the local hive
  even when packages are present. New behavior: when PinnedVersion != null
  and the Aspire-filtered mapping points at an existing local directory,
  enumerate *.nupkg files directly and project them to NuGetPackage
  identities. The HTTP/search path is unchanged for non-local channels.

VersionHelper.IsLocalBuildChannel — accept "local":
  The helper previously recognized only `pr-*` (PR hives) and `run-*`
  (workflow-run hives) as locally-built channels. With the baked
  AspireCliChannel value of "local" for ./build.sh builds, the helper now
  also returns true for the literal "local" channel, so version-check
  paths classify local-dev builds correctly alongside PR builds.

Tests:
  - PackagingServiceTests adds direct coverage for the flat-folder
    enumeration path, including the corrupted-state shape (hive dir
    exists but `packages/` is empty) where PinnedVersion is null and
    enumeration must safely yield nothing.
  - VersionHelperTests pins the new "local" → true case.

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

* fix(scripts): get-aspire-cli-pr picks the correct hive label for local-dir installs

The PR-install scripts (get-aspire-cli-pr.{sh,ps1}) installed CLI artifacts
into a hive label that did not match what the running CLI's
CliExecutionContext.Channel resolved to. The mismatch surfaced when the
script ran against a local `--local-dir` source: artifacts landed under
~/.aspire/hives/<wrong-label>/, but the CLI looked under
~/.aspire/hives/pr-<N>/ (PR builds) or ~/.aspire/hives/local/ (local
dev builds), so the freshly-installed packages were invisible.

Resolution rule:
  - If the script's source nupkgs carry a PR-suffixed version
    (matching `pr.<N>.g<sha>`), the hive label is `pr-<N>`.
  - Otherwise the hive label is `local`.
  - Default falls back to `local` so `--local-dir` installs from local-dev
    builds (no PR suffix) work end-to-end without manual labeling.

The release-side scripts (get-aspire-cli.{sh,ps1}) had divergent hive-
labeling logic that the PR-script fix obsoletes; the release scripts now
share the same three-branch resolution, removing ~340 lines of duplicated
branch-handling.

The polyglot-validation setup-local-cli.sh helper learns the same rule so
CI's local-CLI smoke tests install into the same hive label the CLI will
read from.

Tests:
  - PRScriptShellTests / PRScriptPowerShellTests cover the three-branch
    resolution (pr-N from suffix, local fallback, explicit override).
  - ReleaseScriptShellTests / ReleaseScriptPowerShellTests mirror the
    pinning on the release-script side after the dedup.

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

* refactor(cli): aspire update stops writing global channel setting

Self-update previously saved the chosen channel (or cleared it for "stable")
into ~/.aspire/aspire.config.json#channel so subsequent `aspire new` and
`aspire init` runs would pick it up via global config. With the per-project
channel reseed landed earlier in this branch, the global setting is no
longer a source of truth for anything — every scaffolded project carries
its own channel — so the global write is dead code and a source of drift
between projects on the same machine.

Changes:
  - UpdateCommand.ExecuteSelfUpdateAsync no longer calls
    SetConfigurationAsync / DeleteConfigurationAsync for "channel".
  - The accompanying comment is rewritten to describe the current contract:
    the channel choice applies to this self-update only; subsequent
    scaffolding resolves channel per-project.
  - The legacy → new config migration drops the channel key during
    migration (the migrated global config never carries channel), matching
    the new "channel is not stored globally" invariant. The legacy file
    is still preserved verbatim for backward compatibility with older CLIs.

Tests:
  - UpdateCommandTests asserts no global channel writes occur during
    self-update across all selectable channels.
  - ConfigMigrationTests (E2E) updates the migration assertion to expect
    the channel key absent in the migrated aspire.config.json and present
    in the preserved legacy file.

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

* test(cli): cross-channel coverage for aspire add (local hive)

Pin the `aspire add` hive-resolution behavior across the new local channel
to match the existing PR-hive coverage:

  - AddCommand_WithLocalHive_PrefersCurrentCliVersion: with a populated
    ~/.aspire/hives/local/packages dir whose Aspire.Hosting pkg matches
    the running CLI's version, `aspire add` should pick that version
    without prompting (the local-hive equivalent of the existing
    WithPrHive coverage).

This complements the PackageChannel flat-folder enumeration and the
ChannelReseed coverage added earlier on this branch.

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

* refactor(cli): bake `pr-<N>` into AspireCliChannel; drop runtime parse+join

Previously the CLI carried two pieces of channel state:

 1. [AssemblyMetadata("AspireCliChannel", "pr")]      — the 5-value
    taxonomy (stable | staging | daily | pr | local).
 2. AssemblyInformationalVersionAttribute (0.0.0-pr.<N>.g<sha>) — the
    version stamp, which was *separately* parsed at runtime by
    IdentityChannelReader.ParsePrNumber to extract <N>.

CliExecutionContext then joined the two as `pr-<N>` for the hive label
exposed via .Channel. The split existed only inside the CLI itself —
every non-`pr` channel passed through verbatim, no external consumer
relied on the `pr` literal, and the only other src/ reader of the
raw taxonomy (PrebuiltAppHostServer.cs:423) only checked `== "local"`
which is unchanged in either scheme.

Collapse the two pieces. CI now bakes the resolved hive label
directly:

  * GH Actions (.github/workflows/build-cli-native-archives.yml):
    pull_request -> pr-<github.event.pull_request.number>
  * AzDO (eng/pipelines/templates/build_sign_native.yml):
    Build.Reason == PullRequest -> pr-<System.PullRequest.PullRequestNumber>,
    with a digit-only regex pre-check that throws if the agent ever
    hands us an unresolved macro or non-numeric value (clearer failure
    attribution than waiting for the runtime IsValidChannel check).

Runtime simplifications:
  * IdentityChannelReader.ParsePrNumber: deleted (~40 lines).
  * IdentityChannelReader.ResolveChannel: now validates shape via the
    new IsValidChannel helper. Accepts the four fixed strings
    (stable | staging | daily | local) or `pr-<digits>`. Rejects the
    legacy literal `pr` (no suffix) so a CI misconfiguration cannot
    silently mis-route packages.
  * CliExecutionContext: `prNumber` ctor parameter dropped;
    PrNumber and IdentityChannel properties dropped; Channel is now a
    plain auto-property. The `channel=='pr' && prNumber is null` throw
    guard goes away — the invariant is now structurally enforced by
    the channel string itself.
  * Program.cs: the `if (channel == "pr") { ParsePrNumber(...) }`
    block collapses to one line.

PrebuiltAppHostServer.cs: the one IdentityChannel reference becomes
Channel; behavior identical because the only check is `== "local"`
and `local` is unchanged.

Tests:
  * AssemblyMetadataChannelTests: smoke-tests the baked value against
    IsValidChannel (was: set-membership against the legacy 5 values).
  * IdentityChannelReaderTests: drops the ParsePrNumber theory entirely,
    adds the IsValidChannel truth table, and adds a Throws-based set
    covering invalid shapes (including the legacy `pr` literal as a
    regression guard for the CI change).
  * CliExecutionContextTests: rewritten as a thin getter test (the
    type is now a holder, not a join).
  * CliBootstrapTests: drops the IdentityChannel branch on the PrNumber
    assertion.
  * InitCommandTests, PrebuiltAppHostServerTests, ChannelReseedTests:
    builders drop the `prNumber:` argument; callers that previously
    passed `channel: "pr", prNumber: 12345` now pass
    `channel: "pr-12345"`.

All 2881 Aspire.Cli.Tests pass; src/Aspire.Cli and tests build clean
on net10.0/arm64.

Out of scope: the shell-script PR-suffix detection in
setup-local-cli.sh (commit 160801c) parses nupkg filenames stamped by
VersionSuffix, not assembly metadata, so it is not affected by this
change.

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

* test(cli): cover picker & channel-resolution gaps on the channel branch

Adds direct tests for behaviors the branch already implements but that were
only covered transitively, so a regression here would surface in unrelated
suites rather than naming the cause.

- CliExecutionContextTests: 4 GetHiveCount() tests (missing dir → 0, empty
  dir → 0, populated count, ignores stray files). GetHiveCount() gates the
  channel picker in update, the channel sub-menu in add, and explicit-channel
  inclusion in IntegrationPackageSearchService — so a bug here previously
  manifested as cross-command flake.

- UpdateCommandTests: three picker-contract tests.
  * WithHivesAndConfiguredChannel and WithHivesAndLocallyConfiguredChannel
    close the (hive-present × configured-channel) intersection — existing
    tests cover one axis at a time but not the intersection that PR-build
    users actually hit.
  * WithHives_PromptOffersChannelsInPackagingServiceOrder is the
    prompt-content contract: every existing prompt callback either flag-sets
    or .First()s the choices; nothing verified the names, order, or
    'Name (SourceDetails)' label format. Reorderings or label drops would
    otherwise be silently green.

- PrebuiltAppHostServerChannelResolutionTests: two migration-safety tests for
  the new aspire.config.json ?? legacy AspireJsonConfiguration fallback.
  Covers the legacy-only path (no new file present) and the precedence when
  both files exist with different channels — guards against an accidental
  ?? operand swap.

- TemplateNuGetConfigServiceTests: three IncludePrHives matrix tests. The
  existing three resolver tests all pass IncludePrHives: false; the true row
  (the aspire new path) and the (true × no-hives-on-disk) AND-gate were
  uncovered. Adds: IncludePrHivesTrue_WithHivesPresent_AllChannelsParticipate,
  IncludePrHivesTrue_NoHivesOnDisk_RestrictsToImplicit, and
  IncludePrHivesFalse_IgnoresHivesEvenWhenPresent (the aspire init policy).

Verified locally: 72/72 of the affected test classes pass; 56/56 unaffected
NewCommandTests still pass.

Three gaps from the audit are intentionally deferred:
- NewCommand local-build channel bias and global-config-fallback removal
  (the audit's T1/G8) target ResolveCliTemplateVersionAsync, which only
  fires for TemplateRuntime.Cli templates. Reaching it via aspire-starter
  is impossible — it's TemplateRuntime.DotNet and takes a different
  resolution path. The clean fix is to extract the channel-selection block
  to an internal helper that takes (channels, executionContextChannel,
  configuredChannelName) and tests it as a pure function across all rows;
  that refactor is out of scope for a pure test-add commit.
- A scaffold-then-resolve end-to-end round-trip (G5) is now reachable in
  principle via the new G6 read-side tests plus existing ChannelReseedTests
  on the write side, so the marginal value of a joint test is small.

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

* test(cli): simplify channel-test suite — dedupe, fold theories, drop reflection

Eleven targeted test simplifications across the new channel-resolution suite.
Net: 15 files touched, -284 lines, no coverage dropped (verified by
case-by-case audit: every assertion in every removed/folded test still has at
least one equivalent assertion remaining).

1. Delete CliMetadataPackagingTests.cs — its sole "metadata exists +
   non-empty" assertion is a strict subset of
   AssemblyMetadataChannelTests.AspireCliChannel_AssemblyMetadata_HasValidShape
   (IsValidChannel already rejects null/empty).

2. CliBootstrapTests: BuildApplication_LocallyBuiltCli_… and
   BuildApplication_CliExecutionContextChannel_… asserted the same thing.
   Keep one; extract GetBakedEntryAssemblyChannel() so the
   GetCustomAttributes<AssemblyMetadataAttribute>().Single(...) lookup isn't
   open-coded.

3. UpdateCommandTests: drop SelfUpdate_StableChannel_DoesNotDeleteGlobalChannel
   — the SelfUpdate_DoesNotWriteChannelToGlobalConfiguration theory's
   "update --self --channel stable" row already asserts no-delete (and no-set,
   so it's a strict superset).

4. AssemblyMetadataChannelTests: replace fragile
   CliAssembly_BakesChannel_AsLocal_MatchingCsprojDefault (would fail under
   /p:AspireCliChannel=stable CI builds) with
   Csproj_DeclaresAspireCliChannelDefault_AsLocal — inspects the csproj XML
   for the `<AspireCliChannel Condition="'$(AspireCliChannel)' == ''">local`
   element. Guards the same property (the declared default) without coupling
   to the build configuration of the test host.

5. AddCommandTests: extract RunAddRedisWithHiveScenarioAsync to share the
   workspace + prompter + project locator + AddPackage-capture scaffolding
   across the three hive tests (WithPrHive / WithLocalHive / WithLocalAndPrHives).
   Each test now declares only the hive layout and the SearchPackagesAsync mock.

6. ChannelReseedTests: NoExplicitChannel (4 InlineData rows) + ExplicitChannel
   (Fact) → one [Theory] with five rows over (contextChannel, explicitChannel,
   expected). Drop the CreateExecutionContext→BuildContext one-line wrapper
   and switch from Directory.CreateTempSubdirectory + try/finally to
   TemporaryWorkspace (project convention).

7. IdentityChannelReaderTests: the
   IsValidChannel_MatchesExpectedTruthTable theory exhaustively asserts the
   shape contract; the assembly-roundtrip tests only need to verify the wiring
   from ReadChannel through IsValidChannel. Reduce
   ReadChannel_AssemblyHasMetadataForValidChannel_ReturnsValue (6 rows) and
   ReadChannel_InvalidChannelValue_Throws (10 rows) to one representative
   [Fact] each; drop ReadChannel_AssemblyHasEmptyChannelMetadata_Throws
   (covered by the empty-string row in the truth table).

8. PrebuiltAppHostServer.ResolveChannelName: private → internal (the assembly
   already has [InternalsVisibleTo("Aspire.Cli.Tests")]). Drop reflection
   in PrebuiltAppHostServerChannelResolutionTests (4 sites) and
   PrebuiltAppHostServerTests (1 site).

9. CliExecutionContextTests: Channel_DefaultsToLocal_WhenNotSpecified now
   uses CreateContext(channel: null) instead of inlining the 5-line ctor
   that the helper already encapsulates.

10. Move Ctor_NullAssembly_ThrowsArgumentNullException from CliBootstrapTests
    to IdentityChannelReaderTests — it's about reader ctor validation, not
    bootstrap-wiring DI.

11. TemplateNuGetConfigServiceTests: fold three IncludePrHives Facts
    (TrueWithHives / TrueNoHives / FalseWithHives) into one [Theory] with
    three rows over (includePrHives, createHiveOnDisk, expectedVersion,
    expectImplicitChannel, expectedChannelName).

Verified: build clean; 180 tests across the affected classes pass locally.

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

* docs(specs): correct bundle.md self-update flow after global-channel write removal

The self-update process description listed a 7-step flow ending with
"Saves selected channel to global settings". That step was removed
across the v3 acquisition work — the CLI no longer writes the channel
globally (acquisition scripts dropped save_global_settings; aspire
update --self stopped persisting --channel). Identity is now baked
into the binary via AspireCliChannel assembly metadata; per-project
overrides live in aspire.config.json#channel. Drop step 7 and add a
short note explaining the new model so docs match reality.

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

* chore(cli): mark global-channel fallback transitional in UpdateCommand precedence

Identity-channel is baked into the CLI via AspireCliChannel assembly
metadata; the acquisition scripts and aspire update --self no longer
seed a global "channel" field. The step-2 fallback in
ConfigurationService.GetConfigurationFromDirectoryAsync still reads
the global file, however, so aspire update's documented step-3
precedence ("global config 'channel'") is reachable.

That's intentional for now — users who deliberately ran
\`aspire config set -g channel <x>\` on the new CLI keep their
preference honored by aspire update — but it's worth flagging at both
the call site (UpdateCommand precedence comment) and the impl
(ConfigurationService) so a future reader doesn't assume the global
read is also dead. Add TODO markers calling out that the fallback can
be removed once telemetry confirms negligible global-channel usage.

No behavior change.

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

* feat(acquisition): auto-detect raw-build vs tarball in --local-dir flow

Cherry-picks the helper bodies from 721811c but reshapes the user
surface: instead of a separate --local-binary flag, install_from_local_dir
auto-detects from directory contents — tarball glob match → archive flow;
recursive aspire-exe match → raw-build flow.

Drops the LOCAL_BINARY=false variable, --local-binary arg parser, and
requires-LocalDir guard from the original commit (no longer needed with
auto-detect).

Preserves bash/pwsh parity. Existing LocalDir_* tests pass unmodified.

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

* test(acquisition): release scripts must not write global channel field

PR1 invariant: install.sh / install.ps1 no longer write
`~/.aspire/aspire.config.json#channel`. Adds Install_DryRun_DoesNotWriteGlobalChannelField
to the bash and pwsh test classes, asserting both that stdout under
--dry-run / -WhatIf contains no channel-write log line and that the
global config file isn't created under MockHome.

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

* refactor(cli): rename CLI identity & project-requested channel symbols

Disambiguate the two distinct channel concepts that PrebuiltAppHostServer
threads through restore:

  - CliExecutionContext.Channel  -> IdentityChannel
    The hive label baked into the CLI binary via [AssemblyMetadata("AspireCliChannel", ...)].
    Identity of the running CLI, not what any project asks for.

  - PrebuiltAppHostServer.ResolveChannelName()  -> ResolveRequestedChannel()
    Reads aspire.config.json#channel (with legacy fallback). The project's
    request to restore — independent of which CLI is running.

  - Parameter / local `channelName`  -> `requestedChannel`
    Across RestoreNuGetPackagesAsync, BuildIntegrationProjectAsync,
    TryCreateTemporaryNuGetConfigAsync, GetNuGetSourcesAsync.

XML doc on IdentityChannel makes the identity-vs-request distinction
explicit. No behaviour change.

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

* fix(cli): PSM guard keys on resolved channel, not CLI identity

PrebuiltAppHostServer.TryCreateTemporaryNuGetConfigAsync used to short-circuit
package source mapping (PSM) emission when the running CLI's IdentityChannel
was "local". That dropped PSM for legitimate scenarios:

  - locally-built CLI (IdentityChannel == "local") running an apphost whose
    aspire.config.json#channel asks for "pr-12345" / "daily" / "staging" —
    restore silently used the ambient NuGet config (PSM dropped), instead of
    the channel's package source mappings.

The guard now keys on the resolved channel.Name. The local hive has no real
mappings, so emitting PSM for it would constrain restore to nothing; for
every other channel (stable, staging, daily, pr-*) PSM must emit so restore
honours the channel's package source mappings — regardless of which CLI
identity is running.

Keying on channel.Name (rather than the input requestedChannel) is robust
to alias/normalization in the channel lookup above the guard.

Test matrix now covers the full identity x requested cross-product:
  - LocalIdentity_LocalRequested_ReturnsNull
  - LocalIdentity_PrRequested_EmitsConfig          (regression case)
  - StableIdentity_StableRequested_EmitsConfig
  - StableIdentity_LocalRequested_ReturnsNull
  - DailyIdentity_DailyRequested_EmitsConfig
  - PrIdentity_DifferentPrRequested_EmitsConfig
  - LocalIdentity_StagingRequested_EmitsConfigWithGlobalPackagesFolder
    (also pins that ConfigureGlobalPackagesFolder propagates to the emitted
    nuget.config; staging is the only channel today with that setting)
  - LocalRequested_ReturnsNull_RegardlessOfIdentity [Theory x4]

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

* refactor(cli): split TemplatePackageQuery, move init's local fallback to caller

TemplatePackageQuery.ChannelOverride carried two concepts:

  - aspire init forwarded _executionContext.IdentityChannel (CLI identity).
  - aspire new forwarded inputs.Channel (--channel / user request).

The resolver compensated with a "channelName == 'local' and not found -> fall
back to implicit" branch. That rule is only semantically correct for the
identity caller: an explicit `aspire new --channel local` on a machine
without a local hive should surface a ChannelNotFoundException, not silently
switch to ambient NuGet.

Rename the field to RequestedChannel and move the fallback contract into
InitCommand (the only caller that wants it). The resolver becomes a pure
single-concept lookup — drops the special-case branch entirely.

User-visible effects:
  - `aspire init` on a local-CLI without ~/.aspire/hives/local: still falls
    back to implicit (init catches ChannelNotFound and retries with
    RequestedChannel: null).
  - `aspire new --channel local` without the hive: now throws
    ChannelNotFoundException with the standard "Valid options are: ..."
    message instead of silently using ambient NuGet.

Test coverage:
  - ResolveTemplatePackage_RequestedChannel_NotFound_Throws
  - ResolveTemplatePackage_RequestedChannel_Matches_ReturnsThatChannel
  - existing InitCommand_OnLocalChannelCli_WithNoLocalHive_FallsBackToImplicitChannel
    still passes (now exercises the fallback in InitCommand, not the resolver).

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

* docs(cli): document dual-source contract on Template*NuGetConfigAsync

The string? channelName parameter on PromptToCreateOrUpdateNuGetConfigAsync
and CreateOrUpdateNuGetConfigWithoutPromptAsync is passed by:

  - EmptyTemplate / aspire new : inputs.Channel  (project request)
  - InitCommand                : CliExecutionContext.IdentityChannel
                                 (running CLI identity)

The lookup against IPackagingService.GetChannelsAsync is name-equivalent
for both, so the existing single-parameter shape stays. Update the XML
docs to document the dual-source contract explicitly so future readers
do not read the parameter name as request-only (the audit flagged this
as a maintainability trap).

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

* fix(cli): `aspire update` no longer pins identity when channel is implicit

GuestAppHostProject.UpdatePackagesAsync used to write
`config.Channel = _executionContext.IdentityChannel` whenever the update's
resolved PackageChannel was implicit. That silently propagated dev/PR-build
CLI identities into projects: a developer running `aspire update` with a
locally-built CLI would pin `channel: "local"` into the project's
aspire.config.json, surfacing identity into a file the user expected to
remain untouched.

`aspire update` is a no-pin path: the user is updating packages, not
initialising. Only write config.Channel when the update actually resolved
an explicit channel (--channel, per-project pin, prompt selection). The
scaffolding / build-time paths intentionally do auto-pin identity; those
are first-write contracts and unaffected.

The pre-existing UpdateCommand_WithoutHives_UsesImplicitChannelWithoutPrompting
test used a mock TestProjectUpdater that bypassed GuestAppHostProject
entirely, so this bug was unit-test-invisible. New regression test
UpdatePackagesAsync_ImplicitChannel_DoesNotPinIdentityIntoConfig exercises
the real path and was verified red before applying the fix
(Actual: "pr-99999" before fix, null after).

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

* docs(cli): clarify `IsStagingChannelEnabled` reads layered configuration

The staging-channel gate in KnownFeatures.IsStagingChannelEnabled reads
configuration["channel"] (env vars + command-line + per-project
aspire.config.json#channel + global config), NOT
CliExecutionContext.IdentityChannel. A CLI baked with AspireCliChannel=staging
does NOT auto-enable staging in the packaging service unless the user has
also set the configuration value explicitly (e.g. via `aspire config set
channel staging` or `--channel staging`).

This is by design: identity selects which hive directory the CLI ships in;
feature gating goes through user-visible configuration. Audit flagged the
asymmetry as undocumented; adding a <remarks> block so future maintainers
do not read the gate as identity-driven.

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

* refactor(cli): `aspire run` no longer mutates `config.Channel`

GuestAppHostProject.RunAsync and ScaffoldingService.ScaffoldGuestLanguageAsync
each silently wrote config.Channel back to aspire.config.json after build /
scaffold prepare. Both writes reduced to one of two cases:

  - The project already had a channel pinned -> buildResult.ChannelName /
    prepareResult.ChannelName matched it (both come from
    AspireConfigFile.Load(...)?.Channel) -> no-op file write.
  - The project had no channel pinned -> buildResult.ChannelName was null,
    falling through to `?? _executionContext.IdentityChannel` -> silent
    identity pin on every `aspire run`.

Neither case is useful work. The C# apphost path (DotNetBasedAppHostServerProject)
never writes config.Channel and has worked fine in this regard, so the
asymmetry was branch-introduced (PR microsoft#16820's channel refactor), not
load-bearing.

After this commit:
  - `aspire run` is pure-read for config.Channel (guest + dotnet symmetric).
  - Scaffolding writes config.Channel exactly once, at seed time
    (ScaffoldingService:75) via the existing config.Save(...) at line 82.
  - `aspire update` writes config.Channel only when the user resolved an
    explicit channel (already pinned by the previous commit).

Three writes total across the CLI, each tied to a concrete user-driven
action. No silent pinning anywhere.

New regression test RunAsync_DoesNotMutateConfigChannel (Theory x2:
seededChannel="stable" and null) was verified red before applying the fix
(both cases produced Actual: "pr-99999"; after fix both stay at the seeded
value).

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

* style(cli): standardize channel-name comparisons

Addresses PR microsoft#16820 reviewer feedback about inconsistent channel-name case sensitivity.

Uses StringComparison.Ordinal for CLI channel names, including the PSM local-channel guard, because these channel values are baked/configured as well-known lowercase identifiers rather than free-form case-insensitive labels.

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

* fix(cli): use StartsWith for hosting package match

Addresses PR microsoft#16820 reviewer feedback asking whether the pinned local-hive package scan should use StartsWith instead of Contains.

The filter is intended to keep Aspire.Hosting packages, so matching the package-id prefix avoids accidental substring matches.

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

* style(cli): use channel constants in identity reader

Addresses PR microsoft#16820 reviewer feedback to use PackageChannelNames constants for baked identity-channel validation.

Keeps the fixed channel set centralized while preserving the existing pr-<N> prefix validation.

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

* fix(acquisition): dry-run helpers return non-PR sentinel; fall back to local hive

extract_version_suffix_from_packages (bash) / Get-VersionSuffixFromPackages (pwsh)
short-circuit in --dry-run / -WhatIf and previously returned the literal
'pr.1234.a1b2c3d4'. The new --local-dir auto-detect logic at the call site
feeds that value through the regex ^pr\.([0-9]+)\.[0-9a-g]+$ to derive a hive
label, so the mock unconditionally forced hive_label='pr-1234' regardless of
what was actually in --local-dir, making the intended 'local' fallback
unreachable in dry-run.

Return the non-PR-shaped sentinel 'local' instead, which the caller's regex
intentionally does not match, so the dry-run path now behaves the same as the
real-world 'no PR suffix detected' path: hive_label='local'.

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

* test(cli): drop ClipackPropagationTests — coverage via AssemblyMetadataChannelTests

The deleted tests asserted XML shape on eng/clipack/Common.projitems
(MSBuild idiom: <MSBuild Targets="Publish" Properties="…@(AdditionalProperties)" />).
That couples the test to a particular MSBuild construct: a behavior-preserving
refactor (e.g. forwarding through a helper target or hand-built Properties=
string) would fail the test, and an XML-valid but behaviorally-wrong refactor
would still pass it.

The downstream contract — "the baked CLI binary has AspireCliChannel in its
AssemblyMetadata" — is covered by Aspire.Cli.Tests.AssemblyMetadataChannelTests,
which reads the actual baked metadata and validates it against
IdentityChannelReader.IsValidChannel.

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

* test(acquisition): drop helper-name asserts and scrub PR1 narrative comments

In ReleaseScript{Shell,PowerShell}Tests:

1. Drop Assert.DoesNotContain("save_global_settings" / "Save-GlobalSettings", …).
   Those are internal helper-function names; renaming the helper would break
   the test even though the contract still holds. The artifact-level check
   (File.Exists(globalConfig) == false) and the path-name check on dry-run
   output (DoesNotContain "aspire.config.json") already cover the real
   contract.

2. Replace "PR1 invariant" comments with statements of the invariant itself:
   install scripts must not write a global aspire.config.json — the channel
   is baked into the CLI binary at build time and read via
   IdentityChannelReader, so a global channel field would shadow the baked
   value. The string "PR1" encodes development-time PR sequencing and rots
   post-merge.

43/43 ReleaseScript{Shell,PowerShell} tests pass after the change.

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

* test(cli/e2e): sharpen LocalHive comments as strategy-parametric design intent

CLI E2E tests intentionally run against multiple CliInstallStrategy modes
selected via CliInstallStrategy.Detect: LocalHive (local-built archive),
InstallScript (downloaded by get-aspire-cli{,-pr}.{sh,ps1}), and pre-existing
CLI on PATH. Only LocalHive returns a non-null channel from
PrepareLocalChannel; the other strategies rely on the CLI's baked channel
plus ambient NuGet feeds.

The old comment ("For LocalHive runs, point the freshly-created project at
the local channel…") read like a runtime env-sniff. Replace with a comment
that names the strategy explicitly and contrasts LocalHive against the
null-returning strategies, so readers see the if-branch as design intent
rather than runtime hedging.

Touched sites (no behavior change):
- ChannelUpdateWorkflowTests.cs (1 site, replaced existing comment)
- TypeScriptCodegenValidationTests.cs (1 site, added comment)
- TypeScriptPolyglotTests.cs (4 sites: two channelArgument ternaries and
  two WriteLocalChannelSettings if-blocks, comment adapted per site)

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

* test(acquisition): tighten dry-run hive-label assertion to exact phrase

In LocalDir_DryRun_WithGitHubRunIdEnvSet_UsesLocalHiveLabel and its PowerShell
sibling LocalDir_WhatIf_WithGitHubRunIdEnvSet_UsesLocalHiveLabel, the existing
assertion was too weak:

  Assert.Contains("local", result.Output, StringComparison.OrdinalIgnoreCase);

The substring "local" appears in the fixture path (local-artifacts) and in
neighboring log lines ("from local packages"), so the assertion passes even
when the actual hive label is wrong (e.g. pr-<N> from a dry-run mock that
collides with the auto-detect regex).

Tighten to the exact phrase the script emits at install_aspire_cli /
Resolve-HiveLabel:

  Assert.Contains("Using hive label: local", result.Output, StringComparison.Ordinal);

The existing Assert.DoesNotContain("run-99999", …) guard is preserved — it
protects a different regression (GITHUB_RUN_ID leaking into the hive label
when --local-dir / -LocalDir is used).

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

* fix(acquisition): --run-id-only flow is rejected with actionable guidance

Without --pr-number / --hive-label, the script previously dropped packages into
hives/run-<workflowRunId>/packages. With the v3 design, the installed CLI's
CliExecutionContext.Channel is baked at build time from AspireCliChannel and is
one of pr-<N>/staging/daily/local — never run-<id>. Packages installed into a
run-<id> hive are therefore unreachable from the CLI.

Reject early with an actionable error directing the user to pass --pr-number
(preferred) or --hive-label matching the baked AspireCliChannel. This is the
Option B path from the v3-pr1 build-infra review; Option A (querying the
binary's baked channel) would require new CLI surface area.

Tests:
- PRScriptShellTests / PRScriptPowerShellTests: add regression guards
  asserting the rejection, plus tighten the --run-id-as-first-arg parsing
  tests to pair --run-id with --hive-label so they still verify positional
  parsing.

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

* fix(acquisition): polyglot-validation is PR-only and fails loud off-PR

Two coupled changes that close a silent-failure path on push events:

1. .github/workflows/tests.yml: gate the polyglot_validation job on
   github.event_name == 'pull_request'. Mirrors cli_starter_validation_windows.
   ci.yml triggers tests.yml on push to main/release/** as well as PRs, but
   the polyglot validation flow has no way to compute a hive label that the
   non-PR CLI archive (channel=daily/staging) will read from. Also exempt
   polyglot_validation.result == 'skipped' from the non-PR failure gate in
   test_summary.

2. .github/workflows/polyglot-validation/setup-local-cli.sh: remove the
   silent fall-back to HIVE_LABEL=local when no PR suffix is detected on the
   built nupkgs and fail loud instead. This is now a guard rather than a
   real code path — the workflow guard above ensures the script only runs on
   PR events where the suffix is always present — but the loud error is
   important defense in depth if the guard ever regresses.

Option A (deriving the hive label from the CLI binary's baked
AspireCliChannel) would require new CLI surface area and is not pursued
here.

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

* test(cli): add fallback-removal regression for DotNetBasedAppHostServerProject + NewCommand

Mirrors PrebuiltAppHostServerChannelResolutionTests to cover the remaining
two readers from which PR1 removed the global-channel read fallback
(IConfigurationService.GetConfigurationAsync("channel", ...)):

- DotNetBasedAppHostServerProject — direct-instantiation behavioral guards:
  per-project aspire.config.json is honored, legacy AspireJsonConfiguration
  is honored when the new file is absent, the new format wins when both
  exist, and a null channel is returned when no per-project state is set.
- NewCommand — tripwire IConfigurationService that throws on any
  GetConfigurationAsync/GetConfigurationFromDirectoryAsync call for the
  'channel' key (mirrors InitCommand_DoesNotConsultGlobalConfigurationService\
ForChannelKey).

Addresses the gap noted in PR1 review feedback that 'coverage relies on the
existing tests still passing after the fallback delete' for these two readers.

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

* fix(cli): channel-name comparisons are case-insensitive

PR1 added several channel-name comparisons using StringComparison.Ordinal,
which made user-supplied channel values (config, --channel, baked metadata
read paths) reject mixed-case inputs. Three CI tests caught it:

  - KnownFeaturesTests.IsStagingChannelEnabled_IsCaseInsensitive_ForChannelValue
  - KnownFeaturesTests.IsStagingChannelEnabled_IsCaseInsensitive_ForUppercaseChannelValue
  - VersionHelperTests.IsLocalBuildChannel_RecognizesAllLocalChannelForms("LOCAL")

Switch all channel-name matching to OrdinalIgnoreCase across the call sites
that compare values against PackageChannelNames.* literals or PackageChannel
records. IdentityChannelReader keeps Ordinal intentionally — it strictly
validates the lowercase value baked into our own assembly metadata and uses
case-sensitivity as a misconfiguration tripwire.

Also addresses PR microsoft#16820 review comment r3228883227.

* docs: document channel identity, supported names, and hives

Adds a Channel Identity subsection in docs/specs/bundle.md covering the
AspireCliChannel → [AssemblyMetadata] → IdentityChannelReader →
CliExecutionContext.IdentityChannel pipeline, plus tables for the supported
channel names and the hive types on disk.

Adds a user-facing channel-names callout to docs/dogfooding-pull-requests.md
so users know the valid values for aspire.config.json#channel and
`aspire update --channel`, including the `local` hive used by developer
builds.

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

* chore(squad): log channel-docs session (livingston)

Agent: livingston
Files created:
- .squad/decisions.md (merged inbox)
- .squad/orchestration-log/2026-05-12T22-39-42Z-livingston.md
- .squad/log/2026-05-12T22-39-42Z-channel-docs.md

Decision inbox (4 items) merged and cleared.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mitchdenny pushed a commit that referenced this pull request May 18, 2026
* Update no longer crashes when the AppHost's pinned Aspire.AppHost.Sdk
  version cannot be resolved (e.g. moving between PR builds after the
  hive was refreshed). UpdateCommand now calls the project locator in
  TrustConfiguredPath mode, which skips MSBuild validation of the
  configured AppHost path so ProjectUpdater can rewrite the SDK pin
  via its existing fallback parser.

* Update no longer defaults to the Implicit ("daily") channel when a
  PR-built CLI is run against an AppHost that has no per-project or
  global channel pin. UpdateCommand now consults
  ExecutionContext.IdentityChannel as a default before the prompt
  fallback, restoring the pre-#16820 behavior without re-introducing
  any global channel writes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mitchdenny added a commit that referenced this pull request May 18, 2026
* Fix two regressions in `aspire update`

* Update no longer crashes when the AppHost's pinned Aspire.AppHost.Sdk
  version cannot be resolved (e.g. moving between PR builds after the
  hive was refreshed). UpdateCommand now calls the project locator in
  TrustConfiguredPath mode, which skips MSBuild validation of the
  configured AppHost path so ProjectUpdater can rewrite the SDK pin
  via its existing fallback parser.

* Update no longer defaults to the Implicit ("daily") channel when a
  PR-built CLI is run against an AppHost that has no per-project or
  global channel pin. UpdateCommand now consults
  ExecutionContext.IdentityChannel as a default before the prompt
  fallback, restoring the pre-#16820 behavior without re-introducing
  any global channel writes.

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

* Strip orphaned PR-hive sources from <packageSources> on update

When 'aspire update' merges a project's NuGet.config to a new channel, any
~/.aspire/hives/pr-<N>/packages entry that lives only in <packageSources>
(no corresponding <packageSourceMapping> element) used to survive the merge.
RemoveEmptyPackageSourceElements only cleans up <packageSource> mapping
entries that become empty during the merge, so a source that was never
mapped — or whose mapping was rewritten by an earlier merge — would linger
forever. As soon as the hive directory is replaced on disk (which now
happens routinely for refreshed PR builds), 'dotnet restore' fails with
NU1301: The local source '...' doesn't exist.

Add a final pass over <packageSources> that removes any safe-to-remove
source (the existing IsSourceSafeToRemove heuristic — paths under
.aspire/hives) that is not in sourcesInUse and not in the new channel's
RequiredSources.

Add two regression tests covering both shapes: original config with a
PR-hive source mapped (already cleaned by the empty-element pass; locked
down) and original config with the same PR-hive source listed but with no
mapping element (the actual failure mode).

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

* Fix PR install PATH dedup and refuse PR-channel --self update

Two additional regressions that compounded the aspire update problems:

(D) get-aspire-cli-pr.{sh,ps1} appended a fresh PATH entry on every PR
    install because the install path embeds the PR number
    (/Users/midenn/.aspire/dogfood/pr-<N>/bin), so the literal-line dedup in
    add_to_path never matched any prior PR install. Detect any existing
    dogfood/pr-*/bin line and replace it in place when adding a new one;
    do the same for the Windows user PATH registry entry.

(E) aspire update --self prompts {Stable, Daily, Staging} and routes
    through _cliDownloader.DownloadLatestCliAsync. PR channels have no
    cliDownloadBaseUrl, so a PR-built CLI silently moved the user off
    the PR build whenever they ran --self. Refuse with a clear message
    pointing at the acquisition script (the supported refresh path for
    PR installs) when the running CLI's IdentityChannel starts with
    'pr-'. An explicit --channel still opts out.

Tests:
- AddToPath_PrInstall_ReplacesExistingDogfoodPrLine
- AddToPath_PrInstall_WithNoPriorDogfoodLine_AppendsAsBefore
- AddToPath_NonPrInstall_AppendsAndDoesNotMatchDogfoodHeuristic
- UpdateCommand_SelfUpdate_WhenIdentityChannelIsPr_RefusesWithAcquisitionScriptHint
- UpdateCommand_SelfUpdate_WhenIdentityChannelIsPrAndExplicitChannelGiven_AllowsDownload
- UpdateCommand_SelfUpdate_WhenIdentityChannelIsDaily_AllowsDownload

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

* Revert PR-channel refusal on aspire update --self

Per user feedback: 'aspire update --self' should remain a valid
ejection mechanism for PR builds. The hard-coded Stable/Daily/Staging
prompt already lets a PR-built CLI move to a real channel; refusing
with an acquisition-script hint was unhelpful gatekeeping.

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

* Persist resolved channel from `aspire new` (.NET scaffold)

The .NET starter and apphost templates went through DotNetTemplateFactory
without ever writing aspire.config.json#channel. The TypeScript starter
(CliTemplateFactory.TypeScriptStarterTemplate) and the empty-template /
init paths (ScaffoldingService) already mirrored the resolved channel
into the per-project config. The .NET path was an asymmetric gap.

Without a pin in the scaffolded project, `aspire update` skips its
local-config precedence step and falls through to either an interactive
prompt (when hives exist) or the Implicit/nuget.org channel — silently
moving a project scaffolded by a PR-built or daily CLI onto stable.

Mirror the TS pattern: when the resolved channel is Explicit (pr-<N>,
daily, staging, local), call AspireConfigFile.LoadOrCreate + Save with
the channel name. Implicit channels (stable/nuget.org) intentionally
stay unpinned so the user's ambient NuGet config governs.

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

* Extend channel pin to Python and Go starters

aspire-py-starter and aspire-go-starter previously declined to persist
the resolved channel into aspire.config.json, on the rationale that
PrebuiltAppHostServer aggregates package sources from every registered
channel when no pin exists. That rationale only held for a daily CLI —
on a PR-built CLI the next 'aspire update' falls through to a daily
prompt or the Implicit/nuget.org channel because the local-config step
in the channel-resolution precedence finds nothing.

Mirror the TypeScript starter / .NET starter pattern: when NewCommand
resolves an Explicit channel, write it to aspire.config.json#channel
before the SDK generation step. Implicit channels (stable/nuget.org)
remain unpinned.

Extend NewCommandTemplateConfigPersistenceTests to cover GoStarter and
PythonStarter across all three pin scenarios (identity unregistered,
identity matches, --channel overrides), and drop the now-obsolete
NonChannelPinningStarter_NeverPersistsChannel tripwire.

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

* Skip persistent profile/PATH writes for PR installs

PR installs land under <prefix>/dogfood/pr-<N>/bin, a per-PR path used
for session-scoped dogfooding. Writing it into ~/.zshrc / ~/.bashrc (Unix)
or HKCU\Environment (Windows) silently demoted a developer's daily/stable
install on every new shell until they hunted down the stale entry.

Treat PR installs as session-only: update the current session PATH and
print the activation hint so the user can opt into persistence manually,
but leave shell profiles and the Windows user PATH untouched. Non-PR
routes through these same scripts (release channels, --install-prefix
overrides) keep the previous persistent-PATH behavior.

The earlier dogfood/pr-* dedup pass in add_to_path / Update-PathEnvironment
is no longer needed and has been removed; the obsolete add_to_path PR
dedup tests are replaced with a single test pinning the simpler append
contract.

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

* Drop PR-N / date narrative from committed comments

Repo convention is that committed comments describe the current contract
as seen against origin/main; they should not reference internal PR-N
identifiers or branch-evolution narrative. Rewrite the affected block
headers in UpdateCommandTests.cs (and any matching narrative in
UpdateCommand.cs / ProjectLocator.cs found by audit) to describe what
the code / test group guards, in terms of contract rather than history.

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

---------

Co-authored-by: Mitch Denny <midenn@orangecake.localdomain>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: mitchdenny <mitchdenny@users.noreply.github.com>
Co-authored-by: Mitch Denny <midenn@Mac.localdomain>
nellshamrell pushed a commit to nellshamrell/aspire that referenced this pull request May 18, 2026
…icrosoft#16820)

* feat(cli): bake AspireCliChannel into assembly metadata

Add an AspireCliChannel MSBuild property whose value (stable | staging | daily |
pr | local) is emitted as `[AssemblyMetadata("AspireCliChannel", ...)]` on the
CLI assembly. Locally-built CLIs default to "local" so they don't impersonate
the real "daily" channel — this matters for downstream PSM guards keyed on
identity == channel.

CI overrides the default via /p:AspireCliChannel=$(aspireCliChannel):
  - eng/clipack/Common.projitems propagates the value through the RID-specific
    pack pipeline.
  - eng/pipelines/templates/build_sign_native.yml threads it through the AzDO
    internal native-build template.
  - .github/workflows/build-cli-native-archives.yml does the same for GitHub
    Actions native archive builds.

PackageChannelNames.Local ("local") is added as a constant so the rest of the
CLI doesn't have to deal in raw strings.

Tests:
  - AssemblyMetadataChannelTests asserts the running CLI assembly carries the
    AssemblyMetadata attribute and that its value is one of the known channels.
  - CliMetadataPackagingTests verifies the metadata survives `dotnet pack`.
  - ClipackPropagationTests pins that clipack RID-specific packs honor the
    AspireCliChannel override.

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

* feat(cli): IdentityChannelReader sources channel from assembly metadata

Wire the bootstrap so the running CLI's channel is sourced from the binary's
own [AssemblyMetadata("AspireCliChannel", ...)] value rather than from any
ambient configuration. This is the runtime half of the channel-baking story:
the build pins the channel into the assembly, and the CLI reads its own
identity at startup.

  - Acquisition/IdentityChannelReader.cs is the new component. It reads the
    AspireCliChannel metadata key from a caller-supplied Assembly. The ctor
    requires an explicit Assembly (no Assembly? = null default) so misuse is
    caught at construction time rather than via a cryptic
    "metadata missing on '?'" later. Production callers pass
    typeof(Aspire.Cli.Program).Assembly; tests pass a fake AssemblyBuilder
    assembly.
  - Program.BuildApplicationAsync registers IIdentityChannelReader in DI and
    threads its value into the new CliExecutionContext.Channel /
    .IdentityChannel / .PrNumber surface.
  - CliExecutionContext exposes:
      * Channel — the resolved hive label. For non-PR builds, identical to
        the identity channel verbatim. For PR builds (identity == "pr" with
        non-null PrNumber), resolves to "pr-<N>" — the directory name the
        packaging service uses, and the value reseed call sites write into a
        project's aspire.config.json#channel.
      * IdentityChannel — the raw build-time identity ("local"|"stable"|
        "staging"|"daily"|"pr"). Distinct from Channel because consumers that
        need the build-time taxonomy (PSM guards, version checks) should not
        see the per-PR refinement.
      * PrNumber — exposed verbatim. The constructor refuses
        channel="pr"+prNumber=null so a malformed PR build cannot construct.

Tests:
  - IdentityChannelReaderTests covers the metadata-key contract and the real
    GitHub Actions InformationalVersion shapes (-pr.<N>.g<SHA>+<sha>) the
    parser must accept.
  - CliBootstrapTests asserts the production wiring resolves a known channel
    value when invoked against the real Aspire.Cli assembly.
  - CliExecutionContextTests exercises the Channel/IdentityChannel/PrNumber
    surface, including the malformed-PR-build constructor guard.

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

* refactor(cli): rename GetPrHiveCount to GetHiveCount

The original name suggested the count was specific to PR build hives, but the
implementation has always returned the count of every subdirectory in the
hives root — including the local hive and any other channel-specific hives a
developer may have. The new name reflects what the method actually does.

This is a mechanical rename of the method on CliExecutionContext plus its five
call sites (AddCommand, IntegrationPackageSearchService, NewCommand,
UpdateCommand, TemplateNuGetConfigService). Comments and the doc string are
updated to match.

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

* refactor(cli): drop IConfigurationService from app-host project factories

The app-host project plumbing was reading the channel through
IConfigurationService.GetConfigurationAsync("channel", ...) — a global lookup
that returned whatever happened to be in the user's ambient aspire config.
After the IdentityChannelReader work, the channel is available verbatim from
CliExecutionContext.Channel, so the global read is redundant and removes a
silent source of channel drift between sibling processes.

Changes:
  - AppHostServerProjectFactory: drop the IConfigurationService dependency.
    The factory now takes CliExecutionContext directly and forwards it.
  - DotNetBasedAppHostServerProject: drop the unused
    _configurationService field and its constructor parameter.
  - GuestAppHostProject: add the CliExecutionContext dependency so the
    generated guest-app-host can pass the channel through into its own
    DI graph (channel-aware paths that previously asked the global config
    now ask the execution context).

Tests:
  - AppHostServerProjectTests: drop the configurationService argument from
    the test factory; nothing else changes.
  - GuestAppHostProjectTests: pass a test CliExecutionContext through the
    constructor for parity with the production wiring.
  - CliTestHelper: factory now wires CliExecutionContext into the new
    constructor signatures.

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

* refactor(cli): PrebuiltAppHostServer resolves channel from per-project aspire.config.json

When a prebuilt app-host runs against an existing project, the channel it
should use is the one the project was scaffolded against — stored in the
project's aspire.config.json#channel. The previous implementation consulted
global identity-channel state, which could disagree with the per-project
value when a developer ran multiple CLIs against the same project.

Changes:
  - PrebuiltAppHostServer.ResolveChannelName: read only the per-project
    aspire.config.json. Return null when the file is absent or sets no
    channel; let downstream consumers decide what to do with that.
  - Add a PSM (per-source mapping) guard: when the running CLI's identity
    channel is "local" and the requested channel is also "local", suppress
    the temporary NuGet config generation so the project picks up the user's
    ambient NuGet sources instead of being pinned to the local hive's PSM.
    For every other identity channel (stable, staging, daily, pr) the guard
    does not fire and a per-channel NuGet config is generated as before.

Tests:
  - PrebuiltAppHostServerChannelResolutionTests pins the per-project
    aspire.config.json#channel contract: null when absent, value when
    present.
  - PrebuiltAppHostServerTests adds the PSM-guard cross-product (identity
    channel × requested channel) so a regression that broadens the guard
    is caught.

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

* refactor(cli): TemplateNuGetConfigService drops global-channel fallback; "local" override → implicit channel

TemplateNuGetConfigService had three channel-resolving entry points
(PromptToCreateOrUpdateNuGetConfigAsync, CreateOrUpdateNuGetConfigWithoutPromptAsync,
ResolveTemplatePackageAsync) and each one — when the caller did not supply an
explicit channel — fell back to reading ~/.aspire/aspire.config.json#channel
through IConfigurationService. That made template resolution silently depend
on whatever channel happened to be active for OTHER projects on the machine.

The fix is to drop the IConfigurationService dependency entirely: the only
channel inputs are now (1) the caller-supplied argument or (2) the implicit
channel. The three entry points short-circuit on null/whitespace input
instead of consulting a global source.

Side effect — local-channel override:
A locally-built CLI bakes channel="local" into its assembly metadata. On a
clean machine without ~/.aspire/hives/local, PackagingService produces no
"local" channel, and InitCommand forwards CliExecutionContext.Channel
("local") as the explicit ChannelOverride. Without resolver-level support
this throws ChannelNotFoundException and `aspire init` is unusable on a
clean machine. New behavior: a request for "local" with no matching channel
resolves to the implicit channel — semantically a CLI with no local hive
is just a CLI using the ambient NuGet configuration. The fallback is
narrowly scoped to "local"; any other unrecognized channel name still fails
loudly (typos like "stalbe" still surface as ChannelNotFoundException).

Tests:
  - TemplateNuGetConfigServiceTests covers null/whitespace short-circuits
    for the two prompt-style entry points, the local→implicit fallback,
    and the negative case that other unrecognized channels still throw.
  - DotNetTemplateFactoryTests drops the now-removed IConfigurationService
    argument from CreateTemplateFactory.

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

* feat(cli): scaffolding reseeds channel into per-project aspire.config.json

When `aspire init` or `aspire new` scaffolds a new project, write the
CLI's resolved CliExecutionContext.Channel value into the project's
aspire.config.json#channel field. This pins the per-project channel to
the CLI that scaffolded it, so subsequent runs against the project
resolve packages from the same hive — independent of any global channel
setting that may have drifted.

Changes:
  - ScaffoldingService gains channel-reseed entry points used by all
    starter-template factories.
  - CliTemplateFactory.{Go,Python,TypeScript}StarterTemplate forward
    CliExecutionContext.Channel into the reseed step after files are
    copied.
  - InitCommand and NewCommand pass CliExecutionContext.Channel into
    the scaffolding pipeline; they no longer derive the channel from
    global config.

Tests:
  - ChannelReseedTests pins the per-channel reseed contract: each
    identity channel (local | stable | staging | daily | pr-N) ends up
    written verbatim into aspire.config.json#channel, including the
    pr-N resolution for PR-channel CLIs.
  - InitCommandTests adds the reseed coverage and updates the existing
    init flow expectations to match the new wiring (drops the now-
    removed FakeConfigurationServiceWithChannel helper and the two
    tests that exercised the deleted global-channel-fallback path).

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

* feat(cli): PackageChannel enumerates flat-folder hives; VersionHelper recognizes "local"

Two related additions that complete the local-channel runtime story.

PackageChannel — flat-folder enumeration:
  For local hive channels the Aspire* source is a flat folder of .nupkg
  files (the layout created by ./build.sh --pack). `dotnet package search`
  does not support local folder sources and returns no results, so
  GetIntegrationPackagesAsync would yield an empty set for the local hive
  even when packages are present. New behavior: when PinnedVersion != null
  and the Aspire-filtered mapping points at an existing local directory,
  enumerate *.nupkg files directly and project them to NuGetPackage
  identities. The HTTP/search path is unchanged for non-local channels.

VersionHelper.IsLocalBuildChannel — accept "local":
  The helper previously recognized only `pr-*` (PR hives) and `run-*`
  (workflow-run hives) as locally-built channels. With the baked
  AspireCliChannel value of "local" for ./build.sh builds, the helper now
  also returns true for the literal "local" channel, so version-check
  paths classify local-dev builds correctly alongside PR builds.

Tests:
  - PackagingServiceTests adds direct coverage for the flat-folder
    enumeration path, including the corrupted-state shape (hive dir
    exists but `packages/` is empty) where PinnedVersion is null and
    enumeration must safely yield nothing.
  - VersionHelperTests pins the new "local" → true case.

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

* fix(scripts): get-aspire-cli-pr picks the correct hive label for local-dir installs

The PR-install scripts (get-aspire-cli-pr.{sh,ps1}) installed CLI artifacts
into a hive label that did not match what the running CLI's
CliExecutionContext.Channel resolved to. The mismatch surfaced when the
script ran against a local `--local-dir` source: artifacts landed under
~/.aspire/hives/<wrong-label>/, but the CLI looked under
~/.aspire/hives/pr-<N>/ (PR builds) or ~/.aspire/hives/local/ (local
dev builds), so the freshly-installed packages were invisible.

Resolution rule:
  - If the script's source nupkgs carry a PR-suffixed version
    (matching `pr.<N>.g<sha>`), the hive label is `pr-<N>`.
  - Otherwise the hive label is `local`.
  - Default falls back to `local` so `--local-dir` installs from local-dev
    builds (no PR suffix) work end-to-end without manual labeling.

The release-side scripts (get-aspire-cli.{sh,ps1}) had divergent hive-
labeling logic that the PR-script fix obsoletes; the release scripts now
share the same three-branch resolution, removing ~340 lines of duplicated
branch-handling.

The polyglot-validation setup-local-cli.sh helper learns the same rule so
CI's local-CLI smoke tests install into the same hive label the CLI will
read from.

Tests:
  - PRScriptShellTests / PRScriptPowerShellTests cover the three-branch
    resolution (pr-N from suffix, local fallback, explicit override).
  - ReleaseScriptShellTests / ReleaseScriptPowerShellTests mirror the
    pinning on the release-script side after the dedup.

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

* refactor(cli): aspire update stops writing global channel setting

Self-update previously saved the chosen channel (or cleared it for "stable")
into ~/.aspire/aspire.config.json#channel so subsequent `aspire new` and
`aspire init` runs would pick it up via global config. With the per-project
channel reseed landed earlier in this branch, the global setting is no
longer a source of truth for anything — every scaffolded project carries
its own channel — so the global write is dead code and a source of drift
between projects on the same machine.

Changes:
  - UpdateCommand.ExecuteSelfUpdateAsync no longer calls
    SetConfigurationAsync / DeleteConfigurationAsync for "channel".
  - The accompanying comment is rewritten to describe the current contract:
    the channel choice applies to this self-update only; subsequent
    scaffolding resolves channel per-project.
  - The legacy → new config migration drops the channel key during
    migration (the migrated global config never carries channel), matching
    the new "channel is not stored globally" invariant. The legacy file
    is still preserved verbatim for backward compatibility with older CLIs.

Tests:
  - UpdateCommandTests asserts no global channel writes occur during
    self-update across all selectable channels.
  - ConfigMigrationTests (E2E) updates the migration assertion to expect
    the channel key absent in the migrated aspire.config.json and present
    in the preserved legacy file.

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

* test(cli): cross-channel coverage for aspire add (local hive)

Pin the `aspire add` hive-resolution behavior across the new local channel
to match the existing PR-hive coverage:

  - AddCommand_WithLocalHive_PrefersCurrentCliVersion: with a populated
    ~/.aspire/hives/local/packages dir whose Aspire.Hosting pkg matches
    the running CLI's version, `aspire add` should pick that version
    without prompting (the local-hive equivalent of the existing
    WithPrHive coverage).

This complements the PackageChannel flat-folder enumeration and the
ChannelReseed coverage added earlier on this branch.

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

* refactor(cli): bake `pr-<N>` into AspireCliChannel; drop runtime parse+join

Previously the CLI carried two pieces of channel state:

 1. [AssemblyMetadata("AspireCliChannel", "pr")]      — the 5-value
    taxonomy (stable | staging | daily | pr | local).
 2. AssemblyInformationalVersionAttribute (0.0.0-pr.<N>.g<sha>) — the
    version stamp, which was *separately* parsed at runtime by
    IdentityChannelReader.ParsePrNumber to extract <N>.

CliExecutionContext then joined the two as `pr-<N>` for the hive label
exposed via .Channel. The split existed only inside the CLI itself —
every non-`pr` channel passed through verbatim, no external consumer
relied on the `pr` literal, and the only other src/ reader of the
raw taxonomy (PrebuiltAppHostServer.cs:423) only checked `== "local"`
which is unchanged in either scheme.

Collapse the two pieces. CI now bakes the resolved hive label
directly:

  * GH Actions (.github/workflows/build-cli-native-archives.yml):
    pull_request -> pr-<github.event.pull_request.number>
  * AzDO (eng/pipelines/templates/build_sign_native.yml):
    Build.Reason == PullRequest -> pr-<System.PullRequest.PullRequestNumber>,
    with a digit-only regex pre-check that throws if the agent ever
    hands us an unresolved macro or non-numeric value (clearer failure
    attribution than waiting for the runtime IsValidChannel check).

Runtime simplifications:
  * IdentityChannelReader.ParsePrNumber: deleted (~40 lines).
  * IdentityChannelReader.ResolveChannel: now validates shape via the
    new IsValidChannel helper. Accepts the four fixed strings
    (stable | staging | daily | local) or `pr-<digits>`. Rejects the
    legacy literal `pr` (no suffix) so a CI misconfiguration cannot
    silently mis-route packages.
  * CliExecutionContext: `prNumber` ctor parameter dropped;
    PrNumber and IdentityChannel properties dropped; Channel is now a
    plain auto-property. The `channel=='pr' && prNumber is null` throw
    guard goes away — the invariant is now structurally enforced by
    the channel string itself.
  * Program.cs: the `if (channel == "pr") { ParsePrNumber(...) }`
    block collapses to one line.

PrebuiltAppHostServer.cs: the one IdentityChannel reference becomes
Channel; behavior identical because the only check is `== "local"`
and `local` is unchanged.

Tests:
  * AssemblyMetadataChannelTests: smoke-tests the baked value against
    IsValidChannel (was: set-membership against the legacy 5 values).
  * IdentityChannelReaderTests: drops the ParsePrNumber theory entirely,
    adds the IsValidChannel truth table, and adds a Throws-based set
    covering invalid shapes (including the legacy `pr` literal as a
    regression guard for the CI change).
  * CliExecutionContextTests: rewritten as a thin getter test (the
    type is now a holder, not a join).
  * CliBootstrapTests: drops the IdentityChannel branch on the PrNumber
    assertion.
  * InitCommandTests, PrebuiltAppHostServerTests, ChannelReseedTests:
    builders drop the `prNumber:` argument; callers that previously
    passed `channel: "pr", prNumber: 12345` now pass
    `channel: "pr-12345"`.

All 2881 Aspire.Cli.Tests pass; src/Aspire.Cli and tests build clean
on net10.0/arm64.

Out of scope: the shell-script PR-suffix detection in
setup-local-cli.sh (commit 160801c) parses nupkg filenames stamped by
VersionSuffix, not assembly metadata, so it is not affected by this
change.

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

* test(cli): cover picker & channel-resolution gaps on the channel branch

Adds direct tests for behaviors the branch already implements but that were
only covered transitively, so a regression here would surface in unrelated
suites rather than naming the cause.

- CliExecutionContextTests: 4 GetHiveCount() tests (missing dir → 0, empty
  dir → 0, populated count, ignores stray files). GetHiveCount() gates the
  channel picker in update, the channel sub-menu in add, and explicit-channel
  inclusion in IntegrationPackageSearchService — so a bug here previously
  manifested as cross-command flake.

- UpdateCommandTests: three picker-contract tests.
  * WithHivesAndConfiguredChannel and WithHivesAndLocallyConfiguredChannel
    close the (hive-present × configured-channel) intersection — existing
    tests cover one axis at a time but not the intersection that PR-build
    users actually hit.
  * WithHives_PromptOffersChannelsInPackagingServiceOrder is the
    prompt-content contract: every existing prompt callback either flag-sets
    or .First()s the choices; nothing verified the names, order, or
    'Name (SourceDetails)' label format. Reorderings or label drops would
    otherwise be silently green.

- PrebuiltAppHostServerChannelResolutionTests: two migration-safety tests for
  the new aspire.config.json ?? legacy AspireJsonConfiguration fallback.
  Covers the legacy-only path (no new file present) and the precedence when
  both files exist with different channels — guards against an accidental
  ?? operand swap.

- TemplateNuGetConfigServiceTests: three IncludePrHives matrix tests. The
  existing three resolver tests all pass IncludePrHives: false; the true row
  (the aspire new path) and the (true × no-hives-on-disk) AND-gate were
  uncovered. Adds: IncludePrHivesTrue_WithHivesPresent_AllChannelsParticipate,
  IncludePrHivesTrue_NoHivesOnDisk_RestrictsToImplicit, and
  IncludePrHivesFalse_IgnoresHivesEvenWhenPresent (the aspire init policy).

Verified locally: 72/72 of the affected test classes pass; 56/56 unaffected
NewCommandTests still pass.

Three gaps from the audit are intentionally deferred:
- NewCommand local-build channel bias and global-config-fallback removal
  (the audit's T1/G8) target ResolveCliTemplateVersionAsync, which only
  fires for TemplateRuntime.Cli templates. Reaching it via aspire-starter
  is impossible — it's TemplateRuntime.DotNet and takes a different
  resolution path. The clean fix is to extract the channel-selection block
  to an internal helper that takes (channels, executionContextChannel,
  configuredChannelName) and tests it as a pure function across all rows;
  that refactor is out of scope for a pure test-add commit.
- A scaffold-then-resolve end-to-end round-trip (G5) is now reachable in
  principle via the new G6 read-side tests plus existing ChannelReseedTests
  on the write side, so the marginal value of a joint test is small.

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

* test(cli): simplify channel-test suite — dedupe, fold theories, drop reflection

Eleven targeted test simplifications across the new channel-resolution suite.
Net: 15 files touched, -284 lines, no coverage dropped (verified by
case-by-case audit: every assertion in every removed/folded test still has at
least one equivalent assertion remaining).

1. Delete CliMetadataPackagingTests.cs — its sole "metadata exists +
   non-empty" assertion is a strict subset of
   AssemblyMetadataChannelTests.AspireCliChannel_AssemblyMetadata_HasValidShape
   (IsValidChannel already rejects null/empty).

2. CliBootstrapTests: BuildApplication_LocallyBuiltCli_… and
   BuildApplication_CliExecutionContextChannel_… asserted the same thing.
   Keep one; extract GetBakedEntryAssemblyChannel() so the
   GetCustomAttributes<AssemblyMetadataAttribute>().Single(...) lookup isn't
   open-coded.

3. UpdateCommandTests: drop SelfUpdate_StableChannel_DoesNotDeleteGlobalChannel
   — the SelfUpdate_DoesNotWriteChannelToGlobalConfiguration theory's
   "update --self --channel stable" row already asserts no-delete (and no-set,
   so it's a strict superset).

4. AssemblyMetadataChannelTests: replace fragile
   CliAssembly_BakesChannel_AsLocal_MatchingCsprojDefault (would fail under
   /p:AspireCliChannel=stable CI builds) with
   Csproj_DeclaresAspireCliChannelDefault_AsLocal — inspects the csproj XML
   for the `<AspireCliChannel Condition="'$(AspireCliChannel)' == ''">local`
   element. Guards the same property (the declared default) without coupling
   to the build configuration of the test host.

5. AddCommandTests: extract RunAddRedisWithHiveScenarioAsync to share the
   workspace + prompter + project locator + AddPackage-capture scaffolding
   across the three hive tests (WithPrHive / WithLocalHive / WithLocalAndPrHives).
   Each test now declares only the hive layout and the SearchPackagesAsync mock.

6. ChannelReseedTests: NoExplicitChannel (4 InlineData rows) + ExplicitChannel
   (Fact) → one [Theory] with five rows over (contextChannel, explicitChannel,
   expected). Drop the CreateExecutionContext→BuildContext one-line wrapper
   and switch from Directory.CreateTempSubdirectory + try/finally to
   TemporaryWorkspace (project convention).

7. IdentityChannelReaderTests: the
   IsValidChannel_MatchesExpectedTruthTable theory exhaustively asserts the
   shape contract; the assembly-roundtrip tests only need to verify the wiring
   from ReadChannel through IsValidChannel. Reduce
   ReadChannel_AssemblyHasMetadataForValidChannel_ReturnsValue (6 rows) and
   ReadChannel_InvalidChannelValue_Throws (10 rows) to one representative
   [Fact] each; drop ReadChannel_AssemblyHasEmptyChannelMetadata_Throws
   (covered by the empty-string row in the truth table).

8. PrebuiltAppHostServer.ResolveChannelName: private → internal (the assembly
   already has [InternalsVisibleTo("Aspire.Cli.Tests")]). Drop reflection
   in PrebuiltAppHostServerChannelResolutionTests (4 sites) and
   PrebuiltAppHostServerTests (1 site).

9. CliExecutionContextTests: Channel_DefaultsToLocal_WhenNotSpecified now
   uses CreateContext(channel: null) instead of inlining the 5-line ctor
   that the helper already encapsulates.

10. Move Ctor_NullAssembly_ThrowsArgumentNullException from CliBootstrapTests
    to IdentityChannelReaderTests — it's about reader ctor validation, not
    bootstrap-wiring DI.

11. TemplateNuGetConfigServiceTests: fold three IncludePrHives Facts
    (TrueWithHives / TrueNoHives / FalseWithHives) into one [Theory] with
    three rows over (includePrHives, createHiveOnDisk, expectedVersion,
    expectImplicitChannel, expectedChannelName).

Verified: build clean; 180 tests across the affected classes pass locally.

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

* docs(specs): correct bundle.md self-update flow after global-channel write removal

The self-update process description listed a 7-step flow ending with
"Saves selected channel to global settings". That step was removed
across the v3 acquisition work — the CLI no longer writes the channel
globally (acquisition scripts dropped save_global_settings; aspire
update --self stopped persisting --channel). Identity is now baked
into the binary via AspireCliChannel assembly metadata; per-project
overrides live in aspire.config.json#channel. Drop step 7 and add a
short note explaining the new model so docs match reality.

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

* chore(cli): mark global-channel fallback transitional in UpdateCommand precedence

Identity-channel is baked into the CLI via AspireCliChannel assembly
metadata; the acquisition scripts and aspire update --self no longer
seed a global "channel" field. The step-2 fallback in
ConfigurationService.GetConfigurationFromDirectoryAsync still reads
the global file, however, so aspire update's documented step-3
precedence ("global config 'channel'") is reachable.

That's intentional for now — users who deliberately ran
\`aspire config set -g channel <x>\` on the new CLI keep their
preference honored by aspire update — but it's worth flagging at both
the call site (UpdateCommand precedence comment) and the impl
(ConfigurationService) so a future reader doesn't assume the global
read is also dead. Add TODO markers calling out that the fallback can
be removed once telemetry confirms negligible global-channel usage.

No behavior change.

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

* feat(acquisition): auto-detect raw-build vs tarball in --local-dir flow

Cherry-picks the helper bodies from 721811c but reshapes the user
surface: instead of a separate --local-binary flag, install_from_local_dir
auto-detects from directory contents — tarball glob match → archive flow;
recursive aspire-exe match → raw-build flow.

Drops the LOCAL_BINARY=false variable, --local-binary arg parser, and
requires-LocalDir guard from the original commit (no longer needed with
auto-detect).

Preserves bash/pwsh parity. Existing LocalDir_* tests pass unmodified.

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

* test(acquisition): release scripts must not write global channel field

PR1 invariant: install.sh / install.ps1 no longer write
`~/.aspire/aspire.config.json#channel`. Adds Install_DryRun_DoesNotWriteGlobalChannelField
to the bash and pwsh test classes, asserting both that stdout under
--dry-run / -WhatIf contains no channel-write log line and that the
global config file isn't created under MockHome.

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

* refactor(cli): rename CLI identity & project-requested channel symbols

Disambiguate the two distinct channel concepts that PrebuiltAppHostServer
threads through restore:

  - CliExecutionContext.Channel  -> IdentityChannel
    The hive label baked into the CLI binary via [AssemblyMetadata("AspireCliChannel", ...)].
    Identity of the running CLI, not what any project asks for.

  - PrebuiltAppHostServer.ResolveChannelName()  -> ResolveRequestedChannel()
    Reads aspire.config.json#channel (with legacy fallback). The project's
    request to restore — independent of which CLI is running.

  - Parameter / local `channelName`  -> `requestedChannel`
    Across RestoreNuGetPackagesAsync, BuildIntegrationProjectAsync,
    TryCreateTemporaryNuGetConfigAsync, GetNuGetSourcesAsync.

XML doc on IdentityChannel makes the identity-vs-request distinction
explicit. No behaviour change.

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

* fix(cli): PSM guard keys on resolved channel, not CLI identity

PrebuiltAppHostServer.TryCreateTemporaryNuGetConfigAsync used to short-circuit
package source mapping (PSM) emission when the running CLI's IdentityChannel
was "local". That dropped PSM for legitimate scenarios:

  - locally-built CLI (IdentityChannel == "local") running an apphost whose
    aspire.config.json#channel asks for "pr-12345" / "daily" / "staging" —
    restore silently used the ambient NuGet config (PSM dropped), instead of
    the channel's package source mappings.

The guard now keys on the resolved channel.Name. The local hive has no real
mappings, so emitting PSM for it would constrain restore to nothing; for
every other channel (stable, staging, daily, pr-*) PSM must emit so restore
honours the channel's package source mappings — regardless of which CLI
identity is running.

Keying on channel.Name (rather than the input requestedChannel) is robust
to alias/normalization in the channel lookup above the guard.

Test matrix now covers the full identity x requested cross-product:
  - LocalIdentity_LocalRequested_ReturnsNull
  - LocalIdentity_PrRequested_EmitsConfig          (regression case)
  - StableIdentity_StableRequested_EmitsConfig
  - StableIdentity_LocalRequested_ReturnsNull
  - DailyIdentity_DailyRequested_EmitsConfig
  - PrIdentity_DifferentPrRequested_EmitsConfig
  - LocalIdentity_StagingRequested_EmitsConfigWithGlobalPackagesFolder
    (also pins that ConfigureGlobalPackagesFolder propagates to the emitted
    nuget.config; staging is the only channel today with that setting)
  - LocalRequested_ReturnsNull_RegardlessOfIdentity [Theory x4]

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

* refactor(cli): split TemplatePackageQuery, move init's local fallback to caller

TemplatePackageQuery.ChannelOverride carried two concepts:

  - aspire init forwarded _executionContext.IdentityChannel (CLI identity).
  - aspire new forwarded inputs.Channel (--channel / user request).

The resolver compensated with a "channelName == 'local' and not found -> fall
back to implicit" branch. That rule is only semantically correct for the
identity caller: an explicit `aspire new --channel local` on a machine
without a local hive should surface a ChannelNotFoundException, not silently
switch to ambient NuGet.

Rename the field to RequestedChannel and move the fallback contract into
InitCommand (the only caller that wants it). The resolver becomes a pure
single-concept lookup — drops the special-case branch entirely.

User-visible effects:
  - `aspire init` on a local-CLI without ~/.aspire/hives/local: still falls
    back to implicit (init catches ChannelNotFound and retries with
    RequestedChannel: null).
  - `aspire new --channel local` without the hive: now throws
    ChannelNotFoundException with the standard "Valid options are: ..."
    message instead of silently using ambient NuGet.

Test coverage:
  - ResolveTemplatePackage_RequestedChannel_NotFound_Throws
  - ResolveTemplatePackage_RequestedChannel_Matches_ReturnsThatChannel
  - existing InitCommand_OnLocalChannelCli_WithNoLocalHive_FallsBackToImplicitChannel
    still passes (now exercises the fallback in InitCommand, not the resolver).

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

* docs(cli): document dual-source contract on Template*NuGetConfigAsync

The string? channelName parameter on PromptToCreateOrUpdateNuGetConfigAsync
and CreateOrUpdateNuGetConfigWithoutPromptAsync is passed by:

  - EmptyTemplate / aspire new : inputs.Channel  (project request)
  - InitCommand                : CliExecutionContext.IdentityChannel
                                 (running CLI identity)

The lookup against IPackagingService.GetChannelsAsync is name-equivalent
for both, so the existing single-parameter shape stays. Update the XML
docs to document the dual-source contract explicitly so future readers
do not read the parameter name as request-only (the audit flagged this
as a maintainability trap).

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

* fix(cli): `aspire update` no longer pins identity when channel is implicit

GuestAppHostProject.UpdatePackagesAsync used to write
`config.Channel = _executionContext.IdentityChannel` whenever the update's
resolved PackageChannel was implicit. That silently propagated dev/PR-build
CLI identities into projects: a developer running `aspire update` with a
locally-built CLI would pin `channel: "local"` into the project's
aspire.config.json, surfacing identity into a file the user expected to
remain untouched.

`aspire update` is a no-pin path: the user is updating packages, not
initialising. Only write config.Channel when the update actually resolved
an explicit channel (--channel, per-project pin, prompt selection). The
scaffolding / build-time paths intentionally do auto-pin identity; those
are first-write contracts and unaffected.

The pre-existing UpdateCommand_WithoutHives_UsesImplicitChannelWithoutPrompting
test used a mock TestProjectUpdater that bypassed GuestAppHostProject
entirely, so this bug was unit-test-invisible. New regression test
UpdatePackagesAsync_ImplicitChannel_DoesNotPinIdentityIntoConfig exercises
the real path and was verified red before applying the fix
(Actual: "pr-99999" before fix, null after).

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

* docs(cli): clarify `IsStagingChannelEnabled` reads layered configuration

The staging-channel gate in KnownFeatures.IsStagingChannelEnabled reads
configuration["channel"] (env vars + command-line + per-project
aspire.config.json#channel + global config), NOT
CliExecutionContext.IdentityChannel. A CLI baked with AspireCliChannel=staging
does NOT auto-enable staging in the packaging service unless the user has
also set the configuration value explicitly (e.g. via `aspire config set
channel staging` or `--channel staging`).

This is by design: identity selects which hive directory the CLI ships in;
feature gating goes through user-visible configuration. Audit flagged the
asymmetry as undocumented; adding a <remarks> block so future maintainers
do not read the gate as identity-driven.

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

* refactor(cli): `aspire run` no longer mutates `config.Channel`

GuestAppHostProject.RunAsync and ScaffoldingService.ScaffoldGuestLanguageAsync
each silently wrote config.Channel back to aspire.config.json after build /
scaffold prepare. Both writes reduced to one of two cases:

  - The project already had a channel pinned -> buildResult.ChannelName /
    prepareResult.ChannelName matched it (both come from
    AspireConfigFile.Load(...)?.Channel) -> no-op file write.
  - The project had no channel pinned -> buildResult.ChannelName was null,
    falling through to `?? _executionContext.IdentityChannel` -> silent
    identity pin on every `aspire run`.

Neither case is useful work. The C# apphost path (DotNetBasedAppHostServerProject)
never writes config.Channel and has worked fine in this regard, so the
asymmetry was branch-introduced (PR microsoft#16820's channel refactor), not
load-bearing.

After this commit:
  - `aspire run` is pure-read for config.Channel (guest + dotnet symmetric).
  - Scaffolding writes config.Channel exactly once, at seed time
    (ScaffoldingService:75) via the existing config.Save(...) at line 82.
  - `aspire update` writes config.Channel only when the user resolved an
    explicit channel (already pinned by the previous commit).

Three writes total across the CLI, each tied to a concrete user-driven
action. No silent pinning anywhere.

New regression test RunAsync_DoesNotMutateConfigChannel (Theory x2:
seededChannel="stable" and null) was verified red before applying the fix
(both cases produced Actual: "pr-99999"; after fix both stay at the seeded
value).

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

* style(cli): standardize channel-name comparisons

Addresses PR microsoft#16820 reviewer feedback about inconsistent channel-name case sensitivity.

Uses StringComparison.Ordinal for CLI channel names, including the PSM local-channel guard, because these channel values are baked/configured as well-known lowercase identifiers rather than free-form case-insensitive labels.

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

* fix(cli): use StartsWith for hosting package match

Addresses PR microsoft#16820 reviewer feedback asking whether the pinned local-hive package scan should use StartsWith instead of Contains.

The filter is intended to keep Aspire.Hosting packages, so matching the package-id prefix avoids accidental substring matches.

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

* style(cli): use channel constants in identity reader

Addresses PR microsoft#16820 reviewer feedback to use PackageChannelNames constants for baked identity-channel validation.

Keeps the fixed channel set centralized while preserving the existing pr-<N> prefix validation.

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

* fix(acquisition): dry-run helpers return non-PR sentinel; fall back to local hive

extract_version_suffix_from_packages (bash) / Get-VersionSuffixFromPackages (pwsh)
short-circuit in --dry-run / -WhatIf and previously returned the literal
'pr.1234.a1b2c3d4'. The new --local-dir auto-detect logic at the call site
feeds that value through the regex ^pr\.([0-9]+)\.[0-9a-g]+$ to derive a hive
label, so the mock unconditionally forced hive_label='pr-1234' regardless of
what was actually in --local-dir, making the intended 'local' fallback
unreachable in dry-run.

Return the non-PR-shaped sentinel 'local' instead, which the caller's regex
intentionally does not match, so the dry-run path now behaves the same as the
real-world 'no PR suffix detected' path: hive_label='local'.

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

* test(cli): drop ClipackPropagationTests — coverage via AssemblyMetadataChannelTests

The deleted tests asserted XML shape on eng/clipack/Common.projitems
(MSBuild idiom: <MSBuild Targets="Publish" Properties="…@(AdditionalProperties)" />).
That couples the test to a particular MSBuild construct: a behavior-preserving
refactor (e.g. forwarding through a helper target or hand-built Properties=
string) would fail the test, and an XML-valid but behaviorally-wrong refactor
would still pass it.

The downstream contract — "the baked CLI binary has AspireCliChannel in its
AssemblyMetadata" — is covered by Aspire.Cli.Tests.AssemblyMetadataChannelTests,
which reads the actual baked metadata and validates it against
IdentityChannelReader.IsValidChannel.

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

* test(acquisition): drop helper-name asserts and scrub PR1 narrative comments

In ReleaseScript{Shell,PowerShell}Tests:

1. Drop Assert.DoesNotContain("save_global_settings" / "Save-GlobalSettings", …).
   Those are internal helper-function names; renaming the helper would break
   the test even though the contract still holds. The artifact-level check
   (File.Exists(globalConfig) == false) and the path-name check on dry-run
   output (DoesNotContain "aspire.config.json") already cover the real
   contract.

2. Replace "PR1 invariant" comments with statements of the invariant itself:
   install scripts must not write a global aspire.config.json — the channel
   is baked into the CLI binary at build time and read via
   IdentityChannelReader, so a global channel field would shadow the baked
   value. The string "PR1" encodes development-time PR sequencing and rots
   post-merge.

43/43 ReleaseScript{Shell,PowerShell} tests pass after the change.

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

* test(cli/e2e): sharpen LocalHive comments as strategy-parametric design intent

CLI E2E tests intentionally run against multiple CliInstallStrategy modes
selected via CliInstallStrategy.Detect: LocalHive (local-built archive),
InstallScript (downloaded by get-aspire-cli{,-pr}.{sh,ps1}), and pre-existing
CLI on PATH. Only LocalHive returns a non-null channel from
PrepareLocalChannel; the other strategies rely on the CLI's baked channel
plus ambient NuGet feeds.

The old comment ("For LocalHive runs, point the freshly-created project at
the local channel…") read like a runtime env-sniff. Replace with a comment
that names the strategy explicitly and contrasts LocalHive against the
null-returning strategies, so readers see the if-branch as design intent
rather than runtime hedging.

Touched sites (no behavior change):
- ChannelUpdateWorkflowTests.cs (1 site, replaced existing comment)
- TypeScriptCodegenValidationTests.cs (1 site, added comment)
- TypeScriptPolyglotTests.cs (4 sites: two channelArgument ternaries and
  two WriteLocalChannelSettings if-blocks, comment adapted per site)

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

* test(acquisition): tighten dry-run hive-label assertion to exact phrase

In LocalDir_DryRun_WithGitHubRunIdEnvSet_UsesLocalHiveLabel and its PowerShell
sibling LocalDir_WhatIf_WithGitHubRunIdEnvSet_UsesLocalHiveLabel, the existing
assertion was too weak:

  Assert.Contains("local", result.Output, StringComparison.OrdinalIgnoreCase);

The substring "local" appears in the fixture path (local-artifacts) and in
neighboring log lines ("from local packages"), so the assertion passes even
when the actual hive label is wrong (e.g. pr-<N> from a dry-run mock that
collides with the auto-detect regex).

Tighten to the exact phrase the script emits at install_aspire_cli /
Resolve-HiveLabel:

  Assert.Contains("Using hive label: local", result.Output, StringComparison.Ordinal);

The existing Assert.DoesNotContain("run-99999", …) guard is preserved — it
protects a different regression (GITHUB_RUN_ID leaking into the hive label
when --local-dir / -LocalDir is used).

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

* fix(acquisition): --run-id-only flow is rejected with actionable guidance

Without --pr-number / --hive-label, the script previously dropped packages into
hives/run-<workflowRunId>/packages. With the v3 design, the installed CLI's
CliExecutionContext.Channel is baked at build time from AspireCliChannel and is
one of pr-<N>/staging/daily/local — never run-<id>. Packages installed into a
run-<id> hive are therefore unreachable from the CLI.

Reject early with an actionable error directing the user to pass --pr-number
(preferred) or --hive-label matching the baked AspireCliChannel. This is the
Option B path from the v3-pr1 build-infra review; Option A (querying the
binary's baked channel) would require new CLI surface area.

Tests:
- PRScriptShellTests / PRScriptPowerShellTests: add regression guards
  asserting the rejection, plus tighten the --run-id-as-first-arg parsing
  tests to pair --run-id with --hive-label so they still verify positional
  parsing.

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

* fix(acquisition): polyglot-validation is PR-only and fails loud off-PR

Two coupled changes that close a silent-failure path on push events:

1. .github/workflows/tests.yml: gate the polyglot_validation job on
   github.event_name == 'pull_request'. Mirrors cli_starter_validation_windows.
   ci.yml triggers tests.yml on push to main/release/** as well as PRs, but
   the polyglot validation flow has no way to compute a hive label that the
   non-PR CLI archive (channel=daily/staging) will read from. Also exempt
   polyglot_validation.result == 'skipped' from the non-PR failure gate in
   test_summary.

2. .github/workflows/polyglot-validation/setup-local-cli.sh: remove the
   silent fall-back to HIVE_LABEL=local when no PR suffix is detected on the
   built nupkgs and fail loud instead. This is now a guard rather than a
   real code path — the workflow guard above ensures the script only runs on
   PR events where the suffix is always present — but the loud error is
   important defense in depth if the guard ever regresses.

Option A (deriving the hive label from the CLI binary's baked
AspireCliChannel) would require new CLI surface area and is not pursued
here.

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

* test(cli): add fallback-removal regression for DotNetBasedAppHostServerProject + NewCommand

Mirrors PrebuiltAppHostServerChannelResolutionTests to cover the remaining
two readers from which PR1 removed the global-channel read fallback
(IConfigurationService.GetConfigurationAsync("channel", ...)):

- DotNetBasedAppHostServerProject — direct-instantiation behavioral guards:
  per-project aspire.config.json is honored, legacy AspireJsonConfiguration
  is honored when the new file is absent, the new format wins when both
  exist, and a null channel is returned when no per-project state is set.
- NewCommand — tripwire IConfigurationService that throws on any
  GetConfigurationAsync/GetConfigurationFromDirectoryAsync call for the
  'channel' key (mirrors InitCommand_DoesNotConsultGlobalConfigurationService\
ForChannelKey).

Addresses the gap noted in PR1 review feedback that 'coverage relies on the
existing tests still passing after the fallback delete' for these two readers.

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

* fix(cli): channel-name comparisons are case-insensitive

PR1 added several channel-name comparisons using StringComparison.Ordinal,
which made user-supplied channel values (config, --channel, baked metadata
read paths) reject mixed-case inputs. Three CI tests caught it:

  - KnownFeaturesTests.IsStagingChannelEnabled_IsCaseInsensitive_ForChannelValue
  - KnownFeaturesTests.IsStagingChannelEnabled_IsCaseInsensitive_ForUppercaseChannelValue
  - VersionHelperTests.IsLocalBuildChannel_RecognizesAllLocalChannelForms("LOCAL")

Switch all channel-name matching to OrdinalIgnoreCase across the call sites
that compare values against PackageChannelNames.* literals or PackageChannel
records. IdentityChannelReader keeps Ordinal intentionally — it strictly
validates the lowercase value baked into our own assembly metadata and uses
case-sensitivity as a misconfiguration tripwire.

Also addresses PR microsoft#16820 review comment r3228883227.

* docs: document channel identity, supported names, and hives

Adds a Channel Identity subsection in docs/specs/bundle.md covering the
AspireCliChannel → [AssemblyMetadata] → IdentityChannelReader →
CliExecutionContext.IdentityChannel pipeline, plus tables for the supported
channel names and the hive types on disk.

Adds a user-facing channel-names callout to docs/dogfooding-pull-requests.md
so users know the valid values for aspire.config.json#channel and
`aspire update --channel`, including the `local` hive used by developer
builds.

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

* chore(squad): log channel-docs session (livingston)

Agent: livingston
Files created:
- .squad/decisions.md (merged inbox)
- .squad/orchestration-log/2026-05-12T22-39-42Z-livingston.md
- .squad/log/2026-05-12T22-39-42Z-channel-docs.md

Decision inbox (4 items) merged and cleared.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
nellshamrell pushed a commit to nellshamrell/aspire that referenced this pull request May 18, 2026
* Fix two regressions in `aspire update`

* Update no longer crashes when the AppHost's pinned Aspire.AppHost.Sdk
  version cannot be resolved (e.g. moving between PR builds after the
  hive was refreshed). UpdateCommand now calls the project locator in
  TrustConfiguredPath mode, which skips MSBuild validation of the
  configured AppHost path so ProjectUpdater can rewrite the SDK pin
  via its existing fallback parser.

* Update no longer defaults to the Implicit ("daily") channel when a
  PR-built CLI is run against an AppHost that has no per-project or
  global channel pin. UpdateCommand now consults
  ExecutionContext.IdentityChannel as a default before the prompt
  fallback, restoring the pre-microsoft#16820 behavior without re-introducing
  any global channel writes.

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

* Strip orphaned PR-hive sources from <packageSources> on update

When 'aspire update' merges a project's NuGet.config to a new channel, any
~/.aspire/hives/pr-<N>/packages entry that lives only in <packageSources>
(no corresponding <packageSourceMapping> element) used to survive the merge.
RemoveEmptyPackageSourceElements only cleans up <packageSource> mapping
entries that become empty during the merge, so a source that was never
mapped — or whose mapping was rewritten by an earlier merge — would linger
forever. As soon as the hive directory is replaced on disk (which now
happens routinely for refreshed PR builds), 'dotnet restore' fails with
NU1301: The local source '...' doesn't exist.

Add a final pass over <packageSources> that removes any safe-to-remove
source (the existing IsSourceSafeToRemove heuristic — paths under
.aspire/hives) that is not in sourcesInUse and not in the new channel's
RequiredSources.

Add two regression tests covering both shapes: original config with a
PR-hive source mapped (already cleaned by the empty-element pass; locked
down) and original config with the same PR-hive source listed but with no
mapping element (the actual failure mode).

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

* Fix PR install PATH dedup and refuse PR-channel --self update

Two additional regressions that compounded the aspire update problems:

(D) get-aspire-cli-pr.{sh,ps1} appended a fresh PATH entry on every PR
    install because the install path embeds the PR number
    (/Users/midenn/.aspire/dogfood/pr-<N>/bin), so the literal-line dedup in
    add_to_path never matched any prior PR install. Detect any existing
    dogfood/pr-*/bin line and replace it in place when adding a new one;
    do the same for the Windows user PATH registry entry.

(E) aspire update --self prompts {Stable, Daily, Staging} and routes
    through _cliDownloader.DownloadLatestCliAsync. PR channels have no
    cliDownloadBaseUrl, so a PR-built CLI silently moved the user off
    the PR build whenever they ran --self. Refuse with a clear message
    pointing at the acquisition script (the supported refresh path for
    PR installs) when the running CLI's IdentityChannel starts with
    'pr-'. An explicit --channel still opts out.

Tests:
- AddToPath_PrInstall_ReplacesExistingDogfoodPrLine
- AddToPath_PrInstall_WithNoPriorDogfoodLine_AppendsAsBefore
- AddToPath_NonPrInstall_AppendsAndDoesNotMatchDogfoodHeuristic
- UpdateCommand_SelfUpdate_WhenIdentityChannelIsPr_RefusesWithAcquisitionScriptHint
- UpdateCommand_SelfUpdate_WhenIdentityChannelIsPrAndExplicitChannelGiven_AllowsDownload
- UpdateCommand_SelfUpdate_WhenIdentityChannelIsDaily_AllowsDownload

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

* Revert PR-channel refusal on aspire update --self

Per user feedback: 'aspire update --self' should remain a valid
ejection mechanism for PR builds. The hard-coded Stable/Daily/Staging
prompt already lets a PR-built CLI move to a real channel; refusing
with an acquisition-script hint was unhelpful gatekeeping.

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

* Persist resolved channel from `aspire new` (.NET scaffold)

The .NET starter and apphost templates went through DotNetTemplateFactory
without ever writing aspire.config.json#channel. The TypeScript starter
(CliTemplateFactory.TypeScriptStarterTemplate) and the empty-template /
init paths (ScaffoldingService) already mirrored the resolved channel
into the per-project config. The .NET path was an asymmetric gap.

Without a pin in the scaffolded project, `aspire update` skips its
local-config precedence step and falls through to either an interactive
prompt (when hives exist) or the Implicit/nuget.org channel — silently
moving a project scaffolded by a PR-built or daily CLI onto stable.

Mirror the TS pattern: when the resolved channel is Explicit (pr-<N>,
daily, staging, local), call AspireConfigFile.LoadOrCreate + Save with
the channel name. Implicit channels (stable/nuget.org) intentionally
stay unpinned so the user's ambient NuGet config governs.

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

* Extend channel pin to Python and Go starters

aspire-py-starter and aspire-go-starter previously declined to persist
the resolved channel into aspire.config.json, on the rationale that
PrebuiltAppHostServer aggregates package sources from every registered
channel when no pin exists. That rationale only held for a daily CLI —
on a PR-built CLI the next 'aspire update' falls through to a daily
prompt or the Implicit/nuget.org channel because the local-config step
in the channel-resolution precedence finds nothing.

Mirror the TypeScript starter / .NET starter pattern: when NewCommand
resolves an Explicit channel, write it to aspire.config.json#channel
before the SDK generation step. Implicit channels (stable/nuget.org)
remain unpinned.

Extend NewCommandTemplateConfigPersistenceTests to cover GoStarter and
PythonStarter across all three pin scenarios (identity unregistered,
identity matches, --channel overrides), and drop the now-obsolete
NonChannelPinningStarter_NeverPersistsChannel tripwire.

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

* Skip persistent profile/PATH writes for PR installs

PR installs land under <prefix>/dogfood/pr-<N>/bin, a per-PR path used
for session-scoped dogfooding. Writing it into ~/.zshrc / ~/.bashrc (Unix)
or HKCU\Environment (Windows) silently demoted a developer's daily/stable
install on every new shell until they hunted down the stale entry.

Treat PR installs as session-only: update the current session PATH and
print the activation hint so the user can opt into persistence manually,
but leave shell profiles and the Windows user PATH untouched. Non-PR
routes through these same scripts (release channels, --install-prefix
overrides) keep the previous persistent-PATH behavior.

The earlier dogfood/pr-* dedup pass in add_to_path / Update-PathEnvironment
is no longer needed and has been removed; the obsolete add_to_path PR
dedup tests are replaced with a single test pinning the simpler append
contract.

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

* Drop PR-N / date narrative from committed comments

Repo convention is that committed comments describe the current contract
as seen against origin/main; they should not reference internal PR-N
identifiers or branch-evolution narrative. Rewrite the affected block
headers in UpdateCommandTests.cs (and any matching narrative in
UpdateCommand.cs / ProjectLocator.cs found by audit) to describe what
the code / test group guards, in terms of contract rather than history.

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

---------

Co-authored-by: Mitch Denny <midenn@orangecake.localdomain>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: mitchdenny <mitchdenny@users.noreply.github.com>
Co-authored-by: Mitch Denny <midenn@Mac.localdomain>
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