Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<rid>` / `Aspire.Hosting.Orchestration.<rid>`, 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/<name>/SKILL.md`. Skills a *package* ships are installed into `.agents/skills/` by the skills
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<https://purview.dev/docs/aspire-resourcekit/>.

### 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`.
Expand Down
11 changes: 10 additions & 1 deletion docs/wiki/Contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<rid>` and `Aspire.Hosting.Orchestration.<rid>` 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.
Expand All @@ -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/`.
Expand Down
25 changes: 24 additions & 1 deletion src/src/Example.AppHost/Example.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -1,8 +1,31 @@
<Project Sdk="Aspire.AppHost.Sdk">
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<AspireUseCliBundle>true</AspireUseCliBundle>
<!--
The examples build against the NuGet-delivered DCP and dashboard packages, so `dotnet build`
works on a machine that has never installed the Aspire CLI.

AspireUseCliBundle=true makes *building* an AppHost require a resolvable CLI bundle. With no
bundle present, Aspire.Hosting.AppHost.targets runs `dnx aspire.cli@<version> setup` once per
AppHost, so a single solution build restores and extracts the same CLI concurrently. Reproduced
against the paired CLI version, 13.6.4: the losing process dies with "The process cannot access
the file '...aspire.cli.13.6.4.nupkg' because it is being used by another process" and exit
code 1, which MSBuild reports only as ASPIRE009, "Automatic Aspire CLI bundle setup failed with
exit code 1". Being a race, it fails some runs and not others: that is how the release pipeline
failed a 'dotnet build' the PR pipeline had just passed on the same sources. Neither shared
pipeline can install the CLI before its build, so the build must not need one.

false is the Aspire-documented fallback: the AppHost SDK adds Aspire.Dashboard.Sdk.<rid> and
Aspire.Hosting.Orchestration.<rid> references for the build machine's RID instead, and running
the AppHost (`dotnet run`, `aspire run`, TUnit.Aspire) still works. Choosing it is reported as
ASPIRE010, suppressed below because it is deliberate rather than noise. Opt back in per build
with:

dotnet build -p:AspireUseCliBundle=true
-->
<AspireUseCliBundle Condition="'$(AspireUseCliBundle)' == ''">false</AspireUseCliBundle>
<NoWarn>$(NoWarn);CA1034;CA1062;</NoWarn>
<NoWarn Condition="'$(AspireUseCliBundle)' != 'true'">$(NoWarn);ASPIRE010</NoWarn>
<!--
Declares the two suppressions above as deliberate so Purview.BuildSdk's style policy
(PRSGD0008) can tell them apart from an accidental one. An Aspire AppHost nests the public
Expand Down
11 changes: 10 additions & 1 deletion src/src/Example.ManualAppHost/Example.ManualAppHost.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,16 @@
without the source generator.
-->
<DisablePurviewAspireResourceKitSourceGenerator>true</DisablePurviewAspireResourceKitSourceGenerator>
<AspireUseCliBundle>true</AspireUseCliBundle>
<!--
Same choice as Example.AppHost, for the same reason: opting in to the Aspire CLI bundle makes a
*build* require one, and both AppHosts then run `dnx aspire.cli@<version> setup` at the same
time, which is what made the release pipeline fail with ASPIRE009. The examples use the
NuGet-delivered DCP and dashboard packages instead, which is reported as ASPIRE010 and
suppressed here because it is deliberate. Opt back in with:
dotnet build -p:AspireUseCliBundle=true
-->
<AspireUseCliBundle Condition="'$(AspireUseCliBundle)' == ''">false</AspireUseCliBundle>
<NoWarn Condition="'$(AspireUseCliBundle)' != 'true'">$(NoWarn);ASPIRE010</NoWarn>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Example.Service\Example.Service.csproj" />
Expand Down
97 changes: 97 additions & 0 deletions src/tests/Repository.UnitTests/ExampleAppHostTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
using System.Xml.Linq;

namespace Purview.Aspire.ResourceKit.Repository;

/// <summary>
/// Holds the example AppHosts to the one build contract the shared pipelines cannot check for
/// themselves: that <c>dotnet build</c> on this repository needs no machine-global Aspire CLI.
/// </summary>
/// <remarks>
/// <para>
/// Opting an AppHost into the Aspire CLI bundle (<c>AspireUseCliBundle=true</c>) makes a <em>build</em>
/// require a resolvable bundle. Where none exists, <c>Aspire.Hosting.AppHost.targets</c> runs
/// <c>dnx aspire.cli@&lt;version&gt; -- setup</c> mid-build - a network install inside MSBuild - and
/// reports only ASPIRE009 when it fails, which is how the release pipeline broke. Both pipelines invoke
/// <c>dotnet build</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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."
);
}
}
Loading