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
7 changes: 0 additions & 7 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,6 @@
],
"rollForward": false
},
"dotnet-inspect": {
"version": "0.26.0",
"commands": [
"dotnet-inspect"
],
"rollForward": false
},
"purview.build": {
"version": "0.8.0",
"commands": [
Expand Down
55 changes: 43 additions & 12 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,52 @@ jobs:
- name: Test SampleApp
run: dotnet test samples/SampleApp/SampleApp.slnx --configuration Release --no-build

# Proving that each ForbiddenContent rule bites is cheap - pack the generator, then validate injected copies of it -
# so it runs in its own job rather than as the last step of `packed-consumers`. There it could only fail after the
# Native AOT and WebAssembly AOT publishes had already spent seven minutes.
#
# Like `samples`, it needs no feed wiring of its own: the shared `build` job registers the organisation's GitHub
# Packages source when a pinned version is not on nuget.org yet, and the pack below fails in seconds, naming the
# package and version, if one cannot be restored.
pack-validation:
name: Pack validation rule proof
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
fetch-tags: true

- uses: actions/setup-dotnet@v6
with:
global-json-file: global.json

# Restore before pack: a pwsh step that left `dotnet tool restore`'s non-zero exit code to the shell's
# trailing `exit $LASTEXITCODE` is what made this gate report a green run as a failure. Reaching a step at
# all must mean it succeeded, so the failure is raised here, with the message above it.
- name: Pack the generator and validate its content
shell: pwsh
run: |
dotnet tool restore
if ($LASTEXITCODE -ne 0) {
throw 'dotnet tool restore failed; the message above names the command and the package it is missing from.'
}
dotnet purview-build `
--Build:RunPack=true `
--Build:ValidatePack=true `
--Build:RunTests=false `
--Build:RunLint=false `
--Release:Mode=None

- name: Prove every pack-validation forbidden-content rule rejects a package
shell: pwsh
run: ./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts

packed-consumers:
name: Packed consumers (Native AOT, Blazor WebAssembly, Blazor Server)
runs-on: ubuntu-latest
timeout-minutes: 45
env:
# An extra feed for Purview.SourceGeneratorFramework and Purview.BuildSdk only, for versions
# that are not on nuget.org yet. Unset, the gate restores them from nuget.org alone.
TELEMETRY_PRERELEASE_FEED: ${{ vars.TELEMETRY_PRERELEASE_FEED }}
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -71,8 +109,7 @@ jobs:
# First, before any toolchain is installed: the generator is built against the
# Purview.SourceGeneratorFramework version Directory.Packages.props pins, with the
# Purview.BuildSdk version global.json pins, so nothing can be packed until both restore.
# This fails with what to do (publish them to nuget.org, or set the
# TELEMETRY_PRERELEASE_FEED repository variable) rather than skipping.
# This fails with what to do rather than skipping.
- name: Check the prerelease build dependencies restore
shell: pwsh
run: ./scripts/test-packed-consumers.ps1 -PreflightOnly
Expand All @@ -91,9 +128,3 @@ jobs:
run: >-
./scripts/test-packed-consumers.ps1 -RuntimeIdentifier linux-x64 -WasmAot Require
-PackOutput artifacts/packed

- name: Prove every pack-validation forbidden-content rule rejects a package
shell: pwsh
run: |
dotnet tool restore
./scripts/test-pack-validation-rules.ps1 -PackagesFolder artifacts/packed
10 changes: 5 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,11 +165,11 @@ Treat work as incomplete until every gate relevant to it passes. Before a releas
emitted activities, metrics and log entries, log-level gating and multi-target methods), a Blazor
WebAssembly app (trimmed and `RunAOTCompilation`; no generated member trimmed away, every generated
method AOT-compiled) and a Blazor Server host (page rendered on a `TestServer`, telemetry asserted).
Every consumer's bin and publish output is leak-scanned. Set `LOCAL_NUGET_FEED_PATH` (or
`TELEMETRY_PRERELEASE_FEED`) while Purview.SourceGeneratorFramework or Purview.BuildSdk is not on
nuget.org yet. It needs the Native AOT toolchain and, for the WebAssembly AOT step, the `wasm-tools`
workload.
- `just verify-pack-rules` after any change to `PackValidation` in `.config/purview-build.json`.
Every consumer's bin and publish output is leak-scanned. Set `LOCAL_NUGET_FEED_PATH` while
Purview.SourceGeneratorFramework or Purview.BuildSdk is not on nuget.org yet. It needs the Native AOT
toolchain and, for the WebAssembly AOT step, the `wasm-tools` workload.
- `just verify-pack-rules` after any change to `PackValidation` in `.config/purview-build.json` (it is also the last
step of `just pipeline-pack-validate`, so packing locally proves the rules without a second command).
- `just build-s && just test-s` — the samples, which reference the generator as an analyzer project.
- `just lint` — CSharpier reports no change (`just lint-fix` to apply it).

Expand Down
9 changes: 6 additions & 3 deletions Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ smoke-packed-consumers *args:

# Prove every PackValidation ForbiddenContent rule in .config/purview-build.json rejects a package: injects a matching
# file into scratch copies of the packed package and runs the real purview-build validation on them. Reads artifacts/,
# so pack first (`just pipeline-pack-validate`). `just pipeline-local-release` moves its packages to the local feed, so
# pass -PackagesFolder p:/_sync-projects/.local-nuget after it.
# so pack first (`just pipeline-pack-validate`, which runs this recipe itself). `just pipeline-local-release` moves its
# packages to the local feed, so pass -PackagesFolder p:/_sync-projects/.local-nuget after it.
[group('Pipeline')]
verify-pack-rules *args:
echo "Proving the pack-validation forbidden-content rules..."
Expand All @@ -83,12 +83,15 @@ pipeline-tests *args:
echo "Running tests pipeline..."
{{ pipeline_tool }} --Build:RunTests=true --Release:Mode=None {{ args }}

# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing
# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without
# publishing/releasing. Finishes by proving every ForbiddenContent rule rejects a package (`verify-pack-rules`, which
# reads artifacts/ - pass -Build:ArtifactsFolder to that recipe if args moved this run's packages somewhere else).
[group('Pipeline')]
pipeline-pack-validate *args:
just ensure-pipeline-tool
echo "Running pack + validate pipeline..."
{{ pipeline_tool }} --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }}
just verify-pack-rules

# -----------------------------------------------------------------------------
# Build and Test
Expand Down
21 changes: 12 additions & 9 deletions docs/wiki/Release-Flow.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,24 +49,27 @@ check fails rather than passing.
| Gate | What it proves | Local | CI |
| --- | --- | --- | --- |
| Build, lint and tests | The solution builds warning-free, CSharpier reports no change, and every test project passes. | `just build`, `just lint`, `just test` | `pr.yml` `build` |
| Pack and pack validation | The package packs; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` | `pr.yml` `build` |
| Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of the package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` | `pr.yml` `packed-consumers` |
| Pack and pack validation | The package packs; `purview-build.json` `PackValidation` finds exactly the declared content (`RequiredContent`, `RequireExplicitContent`) and none of the `ForbiddenContent`. | `just pipeline-pack-validate` (which also runs the rule proof) | `pr.yml` `build` |
| Forbidden-content rule proof | Every `ForbiddenContent` rule rejects a package: a matching file is injected into scratch copies of the package and the real `purview-build` validation must fail, per package and per rule. | `just verify-pack-rules` (and the end of `just pipeline-pack-validate`) | `pr.yml` `pack-validation`, in parallel with the consumer gate |
| Packed consumers | The package, packed to an isolated feed and cache, works for consumers that reference it as a package - each with a class library of its own generated telemetry. (a) A **Native AOT** console publishes with `-warnaserror` and no IL2xxx/IL3xxx warning, then runs with an in-process `ActivityListener`, `MeterListener` and capturing logger provider and checks the multi-target method (Activity, Information log and counter from one call), tags, baggage, events and the OpenTelemetry exception event, every metric measurement, v1 and v2 logging, log-level gating and the `TelemetryNames` aggregation. (b) A **Blazor WebAssembly** app, using the telemetry from a page and a service, publishes trimmed with `-warnaserror` and with `RunAOTCompilation=true`: the trimmed assemblies keep every generated member, and the WebAssembly AOT compiler compiles every generated method. (c) A **Blazor Server** host renders its page on an in-memory `TestServer`, and the telemetry the page emitted is checked as in (a). (d) Every bin and publish folder is leak-scanned. | `just smoke-packed-consumers` | `pr.yml` `packed-consumers` (linux-x64, `-WasmAot Require`) |
| Compiler-host leak scan | No generator, refactorings, Purview.SourceGeneratorFramework or Roslyn assembly reaches build or publish output, by file name or in a `deps.json`, `runtimeconfig.json` or boot manifest (`blazor.boot.json`, `dotnet.boot.js`, `dotnet.js`). Purview.SourceGeneratorFramework 1.2.1 put its DLL in every sample's `bin` while every build passed. | `just scan-compiler-host-leaks <dir>...` | the `samples` job (every sample's `bin`) and every consumer in `packed-consumers`; `ReleaseGateTests` fails a job that builds the samples or publishes without it |

`ReleaseGateTests` (in `src/tests/Repository.UnitTests`, so `just test` runs it) guards the gates themselves: the leak
scan must catch each kind of leak it claims to, every workflow job that builds the samples or publishes must scan its
output, the `packed-consumers` job must run the gate and the rule proof, the Justfile recipes must run their scripts,
the consumers must reference the generator as a package and never the repository's projects, and `ForbiddenContent`
must keep every rule.
output, the `packed-consumers` job must run the gate and the rule proof must have a cheap job of its own, the Justfile
recipes must run their scripts, every gate script must state its exit code rather than inherit a non-zero
`$LASTEXITCODE` (GitHub's `pwsh` shell exits with it, which reported a green proof as a failed step), the consumers
must reference the generator as a package and never the repository's projects, and `ForbiddenContent` must keep every
rule.

### Prerelease build dependencies

The generator is built against the Purview.SourceGeneratorFramework version `Directory.Packages.props` pins, with the
Purview.BuildSdk version `global.json` pins. Until those versions are on nuget.org, CI cannot restore them: the
`packed-consumers` job fails at its first step with what to do, rather than skipping. Publish them, or set the
`TELEMETRY_PRERELEASE_FEED` repository variable to a feed that has them. Locally, `just smoke-packed-consumers` also
reads `TELEMETRY_PRERELEASE_FEED` or `LOCAL_NUGET_FEED_PATH`, and uses that feed for those packages only.
Purview.BuildSdk version `global.json` pins. Until those versions are on nuget.org, nothing can be packed, so the
`packed-consumers` job fails at its first step with what to do, rather than skipping. Publish them to nuget.org, or
rely on the organisation's GitHub Packages source, which the shared `build` workflow registers when required. Locally,
`just smoke-packed-consumers` reads `LOCAL_NUGET_FEED_PATH` (or takes `-PrereleaseFeed`) and uses that feed for those
packages only.

The consumers target `net11.0` and take their ASP.NET Core package versions from the `Packed consumers` group in
`Directory.Packages.props`, so bump those with the .NET 11 SDK in `global.json`.
Expand Down
15 changes: 14 additions & 1 deletion scripts/test-pack-validation-rules.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@

The packages in -PackagesFolder are never modified.

Every validation run is expected to fail, so $LASTEXITCODE is left non-zero on the way out. The script therefore
exits explicitly: GitHub appends `if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }` to a
pwsh step, which would otherwise report a green proof as a failed step.

.PARAMETER PackagesFolder
Folder holding the packed .nupkg (and any .snupkg) files of the current version. Defaults to artifacts/, where
`just pipeline-pack-validate` and `just pack` write them. `just pipeline-local-release` moves them to the local feed,
Expand Down Expand Up @@ -93,7 +97,13 @@ function Invoke-Validation([string] $name, [System.IO.FileInfo[]] $nupkgs, [stri

Push-Location $root
try {
& dotnet tool restore | Out-Null
# Fatal, not ignored: a restore that failed silently would leave the runs below using whatever version the
# local tool store already holds, so a stale purview-build could report rules this repository no longer has.
$restore = & dotnet tool restore 2>&1 | ForEach-Object { "$_" }
if ($LASTEXITCODE -ne 0) {
throw "dotnet tool restore failed in '$root', so the pinned purview.build could not be installed:`n$($restore -join "`n")"
}

$output = & dotnet purview-build `
--Build:Solution=empty.slnx `
--Build:ArtifactsFolder=artifacts `
Expand Down Expand Up @@ -177,3 +187,6 @@ if ($failures.Count -gt 0) {
}

Write-Host "Every ForbiddenContent rule ($($cases.Count)) rejected its file, in every package ($($packages.Count))." -ForegroundColor Green

# See the header: every run above failed on purpose, so the non-zero $LASTEXITCODE must not become the exit code.
exit 0
14 changes: 9 additions & 5 deletions scripts/test-packed-consumers.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@
.PARAMETER PrereleaseFeed
A NuGet feed (folder or URL) to restore Purview.SourceGeneratorFramework and Purview.BuildSdk from, in addition to
nuget.org - for versions that are not on nuget.org yet. The feed is used for those packages only. Defaults to
$env:TELEMETRY_PRERELEASE_FEED, then $env:LOCAL_NUGET_FEED_PATH.
$env:LOCAL_NUGET_FEED_PATH.

.PARAMETER WasmAot
Auto (default) runs the WebAssembly AOT publish when the wasm-tools workload is installed and says so when it is
Expand All @@ -82,7 +82,7 @@
param (
[string] $Configuration = 'Release',
[string] $RuntimeIdentifier = [System.Runtime.InteropServices.RuntimeInformation]::RuntimeIdentifier,
[string] $PrereleaseFeed = ($env:TELEMETRY_PRERELEASE_FEED ? $env:TELEMETRY_PRERELEASE_FEED : $env:LOCAL_NUGET_FEED_PATH),
[string] $PrereleaseFeed = $env:LOCAL_NUGET_FEED_PATH,
[ValidateSet('Auto', 'Require', 'Skip')]
[string] $WasmAot = 'Auto',
[string] $PackOutput,
Expand Down Expand Up @@ -433,9 +433,9 @@ try {
"Purview.SourceGeneratorFramework $sgfVersion (Directory.Packages.props) or Purview.BuildSdk $buildSdkVersion " +
"(global.json) could not be restored from $from. The generator is built with both, so nothing can be " +
'packed. If a version is not on nuget.org yet, publish it there, or point this gate at a feed that has ' +
'it: -PrereleaseFeed <folder or URL>, or set TELEMETRY_PRERELEASE_FEED (LOCAL_NUGET_FEED_PATH is used ' +
'when set). Purview.BuildSdk is resolved by MSBuild through the normal NuGet configuration, so that feed ' +
'must also be a configured source for the pack itself.'
'it: -PrereleaseFeed <folder or URL>, or set LOCAL_NUGET_FEED_PATH. Purview.BuildSdk is resolved by ' +
'MSBuild through the normal NuGet configuration, so that feed must also be a configured source for the ' +
'pack itself.'
)
}
}
Expand Down Expand Up @@ -643,3 +643,7 @@ finally {
if ($exitCode -ne 0) { exit $exitCode }

Write-Host 'Packed-consumer gate passed.' -ForegroundColor Green

# The last native command was a restore in the finally block; state the exit code rather than inherit it, because
# GitHub's pwsh shell exits with $LASTEXITCODE when a script falls through.
exit 0
79 changes: 77 additions & 2 deletions src/tests/Repository.UnitTests/ReleaseGateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ await Assert
}

[Test]
public async Task PrWorkflow_GivenThePackedConsumersJob_RunsTheGateAndTheRuleProof(
public async Task PrWorkflow_GivenThePackedConsumersJob_RunsTheGateWithoutTheRuleProof(
CancellationToken cancellationToken
)
{
Expand All @@ -272,8 +272,83 @@ CancellationToken cancellationToken
// Assert
await Assert.That(job).Contains($"./{PackedConsumersScript} -PreflightOnly");
await Assert.That(job).Contains("-WasmAot Require");
await Assert.That(job).Contains($"./{PackRulesScript}");
await Assert.That(job).Contains("dotnet workload install wasm-tools");

// The proof is minutes of work against a package; this job is half an hour of Native AOT and WebAssembly AOT
// publishing. Behind it, a broken rule can only fail after all of that, so the proof has a job of its own.
await Assert
.That(job)
.DoesNotContain(PackRulesScript)
.Because("the rule proof must not sit behind the packed-consumer gate");
}

/// <summary>
/// The rule proof needs nothing a plain pack does not, so it packs its own package and runs in parallel with the
/// consumer gate. Watching it cost seven minutes of AOT publishing in the pull request that introduced it.
/// </summary>
[Test]
public async Task PrWorkflow_GivenThePackValidationJob_ProvesTheRulesFromItsOwnPack(
CancellationToken cancellationToken
)
{
// Arrange
var text = await File.ReadAllTextAsync(
System.IO.Path.Combine(RepositoryRoot.Value, ".github", "workflows", "pr.yml"),
cancellationToken
);

// Act
var job = Jobs(text).SingleOrDefault(static j => j.Name == "pack-validation").Body ?? string.Empty;

// Assert
await Assert.That(job).IsNotEmpty().Because("pr.yml has no 'pack-validation' job");
await Assert.That(job).Contains($"./{PackRulesScript}");
await Assert.That(job).Contains("--Build:RunPack=true").Because("the proof needs packages to inject into");
await Assert.That(job).Contains("--Build:ValidatePack=true");
await Assert
.That(job)
.DoesNotContain("dotnet workload install")
.Because("the proof must stay cheap: no Native AOT or WebAssembly toolchain");
await Assert
.That(job)
.DoesNotContain("dotnet publish")
.Because("the proof must stay cheap: it does not publish a consumer");

// A bare `dotnet tool restore` in a pwsh step leaves a non-zero $LASTEXITCODE for GitHub's trailing
// `exit $LASTEXITCODE`, which is how a green step was reported as a failure. Check the code, not the message.
await Assert
.That(job)
.Contains("if ($LASTEXITCODE -ne 0)")
.Because("a failed tool restore must raise, not be inherited by the step's exit code");
}

/// <summary>
/// GitHub's pwsh shell appends <c>if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE }</c>
/// to every step. These gates run commands that fail on purpose - the rule proof's validations must each be
/// rejected - so a script that falls through reports success as exit code 1.
/// </summary>
[Test]
[Arguments(PackedConsumersScript)]
[Arguments(PackRulesScript)]
public async Task GateScript_GivenCommandsThatFailByDesign_EndsWithAnExplicitExit(
string script,
CancellationToken cancellationToken
)
{
// Arrange
var lines = await File.ReadAllLinesAsync(
System.IO.Path.Combine(RepositoryRoot.Value, script),
cancellationToken
);

// Act
var last = lines.Reverse().FirstOrDefault(static line => !string.IsNullOrWhiteSpace(line))?.Trim();

// Assert
await Assert
.That(last)
.IsEqualTo("exit 0")
.Because($"{script} must state its exit code rather than leave $LASTEXITCODE to the shell");
}

[Test]
Expand Down
Loading