From 4848238c1eed9fab123c7456732f875c7ecda05a Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sun, 8 Nov 2026 21:58:28 +0000 Subject: [PATCH] fix: build the example AppHosts without the Aspire CLI Both example AppHosts opted into the Aspire CLI bundle, which makes *building* an AppHost require a resolvable bundle. With none present, Aspire.Hosting.AppHost.targets runs `dnx aspire.cli@13.6.4 setup` once per AppHost, so one solution build restores and extracts the same CLI concurrently: the losing process dies on the locked NuGet cache file with exit code 1, which MSBuild reports only as ASPIRE009, "Automatic Aspire CLI bundle setup failed with exit code 1". That is what failed the release pipeline on sources the PR pipeline had just built - being a race, it fails some runs and not others. Neither shared pipeline can install the Aspire CLI before its own `dotnet build`, so the build must not need one. The examples now default AspireUseCliBundle to false, the Aspire-documented fallback: the AppHost SDK adds Aspire.Dashboard.Sdk. and Aspire.Hosting.Orchestration. references for the build machine's RID instead, and running the AppHost (`dotnet run`, `aspire run`, TUnit.Aspire) is unchanged. The ASPIRE010 that reports the choice is suppressed while the bundle stays off, and the opt-in remains available per build as -p:AspireUseCliBundle=true. ExampleAppHostTests keeps the opt-in out of the project files; AGENTS.md, the contributing page and the changelog record why. --- AGENTS.md | 8 ++ CHANGELOG.md | 13 +++ docs/wiki/Contributing.md | 11 ++- .../Example.AppHost/Example.AppHost.csproj | 25 ++++- .../Example.ManualAppHost.csproj | 11 ++- .../ExampleAppHostTests.cs | 97 +++++++++++++++++++ 6 files changed, 162 insertions(+), 3 deletions(-) create mode 100644 src/tests/Repository.UnitTests/ExampleAppHostTests.cs diff --git a/AGENTS.md b/AGENTS.md index 7bfb2bd..f20b8e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,14 @@ This is a .NET repository centered on Aspire and source generation. - Uses centrally-managed package versions (`Directory.Packages.props`). - Uses **TUnit** and **TUnit.Mocks** for testing. - Uses `Microsoft.Testing.Platform` via package-managed tooling. +- **No build needs the Aspire CLI installed.** The example AppHosts set + `AspireUseCliBundle=false`, so they build against the NuGet-delivered DCP and dashboard packages + (`Aspire.Dashboard.Sdk.` / `Aspire.Hosting.Orchestration.`, added by the AppHost SDK for the + build machine's RID). Do not put `AspireUseCliBundle=true` back in a project file: it makes a *build* + require a resolvable CLI bundle, and resolving one is a network install performed mid-build once per + AppHost — two of which race on the NuGet cache and fail each other with ASPIRE009. That is what broke + the release pipeline. `ExampleAppHostTests` guards the invariant; pass `-p:AspireUseCliBundle=true` for + a one-off build that wants the bundle path. The skills this repository **authors** live in the root `skills/` folder — read them there, at `skills//SKILL.md`. Skills a *package* ships are installed into `.agents/skills/` by the skills diff --git a/CHANGELOG.md b/CHANGELOG.md index e49fced..bb50cb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,19 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). `Home.md`, `_Sidebar.md`) are removed. The `docs/wiki` topic pages remain the source consumed by . +### Fixed + +- **The repository builds without the Aspire CLI installed.** `Example.AppHost` and + `Example.ManualAppHost` set `AspireUseCliBundle=true`, which makes a *build* require a resolvable CLI + bundle: with none present, `Aspire.Hosting.AppHost.targets` runs `dnx aspire.cli@13.6.4 setup` once per + AppHost, so one solution build restores and extracts the same CLI twice, and the loser dies on the + locked NuGet cache file with exit code 1. MSBuild reports that only as ASPIRE009 ("Automatic Aspire CLI + bundle setup failed with exit code 1"), which failed the release pipeline on sources the PR pipeline had + just built — a race, so it passed some runs and failed others. Both examples now build against the + NuGet-delivered DCP and dashboard packages (`AspireUseCliBundle=false`, ASPIRE010 suppressed as + deliberate), which is unchanged for running them; `ExampleAppHostTests` keeps the opt-in out of the + project files. + ## [1.0.0] - 2026-09-29 The first stable release of `Purview.Aspire.ResourceKit`. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 3311988..e126977 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -34,6 +34,14 @@ just scrub # remove bin/obj, clean, restore, stop build server just version # show the current version from package.json ``` +Nothing here needs the Aspire CLI installed. The example AppHosts build against the NuGet-delivered +DCP and dashboard packages (`AspireUseCliBundle=false`), which the AppHost SDK turns into +`Aspire.Dashboard.Sdk.` and `Aspire.Hosting.Orchestration.` references for the build machine's +RID. Opting them into the CLI bundle instead (`-p:AspireUseCliBundle=true`) makes a *build* need a +resolvable bundle, and resolving one is a network install performed mid-build, once per AppHost — two of +which then race on the NuGet cache and fail each other with ASPIRE009. `ExampleAppHostTests` keeps the +opt-in out of the project files for exactly that reason. + ## Testing conventions - Tests use **TUnit** (and TUnit.Mocks); do not introduce NUnit, xUnit, or MSTest patterns. @@ -53,7 +61,8 @@ Test projects: - `src/tests/SourceGeneration.IntegrationTests` — generated source content, caching, diagnostics, suppression, and the SG0020 code fix. - `src/tests/Repository.UnitTests` — asserts things about this repository itself: the conformance of the - authored skills in `skills/`, and the architecture policy in `.config/architecture-policy.json`. + authored skills in `skills/`, the architecture policy in `.config/architecture-policy.json`, and the + example AppHosts' build contract (no machine-global Aspire CLI). - `src/tests/SharedTestingInfra` — a shared-testing library, referenced rather than executed. Reports are written to `TestResults/`. diff --git a/src/src/Example.AppHost/Example.AppHost.csproj b/src/src/Example.AppHost/Example.AppHost.csproj index fb93571..fec1d82 100644 --- a/src/src/Example.AppHost/Example.AppHost.csproj +++ b/src/src/Example.AppHost/Example.AppHost.csproj @@ -1,8 +1,31 @@  true - true + + false $(NoWarn);CA1034;CA1062; + $(NoWarn);ASPIRE010 true - true + + false + $(NoWarn);ASPIRE010 diff --git a/src/tests/Repository.UnitTests/ExampleAppHostTests.cs b/src/tests/Repository.UnitTests/ExampleAppHostTests.cs new file mode 100644 index 0000000..fe6d129 --- /dev/null +++ b/src/tests/Repository.UnitTests/ExampleAppHostTests.cs @@ -0,0 +1,97 @@ +using System.Xml.Linq; + +namespace Purview.Aspire.ResourceKit.Repository; + +/// +/// Holds the example AppHosts to the one build contract the shared pipelines cannot check for +/// themselves: that dotnet build on this repository needs no machine-global Aspire CLI. +/// +/// +/// +/// Opting an AppHost into the Aspire CLI bundle (AspireUseCliBundle=true) makes a build +/// require a resolvable bundle. Where none exists, Aspire.Hosting.AppHost.targets runs +/// dnx aspire.cli@<version> -- setup mid-build - a network install inside MSBuild - and +/// reports only ASPIRE009 when it fails, which is how the release pipeline broke. Both pipelines invoke +/// dotnet build themselves and cannot install the CLI first, so the opt-in staying out of the +/// project files is the fix rather than an environment step. +/// +/// +/// A repository test rather than an assertion inside the AppHost, which would be asserting a property it +/// had just set. The cost of the choice - ASPIRE010 - is suppressed in the project for the same reason it +/// is made there, so this suite guards the decision, not the suppression. +/// +/// +public sealed class ExampleAppHostTests +{ + static readonly string RepositoryRoot = FindRepositoryRoot(); + + [Test] + [Arguments("src/src/Example.AppHost/Example.AppHost.csproj")] + [Arguments("src/src/Example.ManualAppHost/Example.ManualAppHost.csproj")] + public async Task AppHost_DoesNotOptIntoTheAspireCLIBundle( + string relativeProjectPath, + CancellationToken cancellationToken + ) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + var projectPath = Path.Combine(RepositoryRoot, relativeProjectPath.Replace('/', Path.DirectorySeparatorChar)); + + // Act + var optIns = XDocument + .Load(projectPath) + .Descendants() + .Where(element => element.Name.LocalName == "AspireUseCliBundle") + .Select(element => element.Value.Trim()) + .Where(value => string.Equals(value, "true", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + // Assert + await Assert + .That(optIns) + .IsEmpty() + .Because( + $"'{relativeProjectPath}' opts into the Aspire CLI bundle, which makes 'dotnet build' need a " + + "machine-global Aspire CLI: with no bundle, MSBuild runs 'dnx ... aspire setup' mid-build " + + "and fails with ASPIRE009 when that setup fails, which is what a clean Linux runner does. " + + "Pass -p:AspireUseCliBundle=true for a one-off build instead of pinning it in the project." + ); + } + + [Test] + public async Task RepositoryRoot_ResolvesToThisRepository(CancellationToken cancellationToken) + { + // Arrange + // Every sibling assertion is vacuous if the marker walk landed somewhere the AppHosts are not. + cancellationToken.ThrowIfCancellationRequested(); + + // Act + var solution = Path.Combine(RepositoryRoot, "src", "ResourceKit.slnx"); + + // Assert + await Assert + .That(File.Exists(solution)) + .IsTrue() + .Because($"the marker walk resolved '{RepositoryRoot}', which does not look like this repository"); + } + + static string FindRepositoryRoot() + { + // The same '.git/' marker walk Purview.BuildSdk uses to find the repository root. + for ( + var directory = new DirectoryInfo(AppContext.BaseDirectory); + directory is not null; + directory = directory.Parent + ) + { + if (Directory.Exists(Path.Combine(directory.FullName, ".git"))) + { + return directory.FullName; + } + } + + throw new InvalidOperationException( + $"No '.git' directory was found above '{AppContext.BaseDirectory}', so the repository root is unresolvable." + ); + } +}