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." + ); + } +}