From 7db91c9034aac0c236971c5572425aca83ad3142 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 29 Oct 2026 21:22:52 +0000 Subject: [PATCH 1/2] fix(ci): stop a green pack-validation proof failing the step The rule proof passed all 29 validations and the step still reported exit 1. Every validation run has to fail, so $LASTEXITCODE was non-zero when the script fell through, and GitHub's pwsh shell appends `exit $LASTEXITCODE` to every step. The script now states exit 0, and the tool restore it depends on is fatal instead of piped to Out-Null: an ignored failure there leaves a stale purview-build proving rules this repository no longer has. The proof moves out of packed-consumers into its own job. It needs nothing the Native AOT and WebAssembly gates do, and as the last step of that job it could only fail after seven minutes of them. packed-consumers states its own exit code explicitly for the same reason. Drops the TELEMETRY_PRERELEASE_FEED variable. It was never set, and the shared build workflow already registers the organisation's GitHub Packages source when a pinned Purview.SourceGeneratorFramework or Purview.BuildSdk version is not on nuget.org. `-PrereleaseFeed` and LOCAL_NUGET_FEED_PATH remain, for local runs against a feed that holds an unpublished version. just pipeline-pack-validate now ends with the proof, so packing locally proves the rules without a second command. ReleaseGateTests fails when a gate script stops stating its exit code, when the proof sits behind the consumer gate, or when a failed tool restore is not raised. --- .github/workflows/pr.yml | 55 ++++++++++--- AGENTS.md | 10 +-- Justfile | 9 ++- docs/wiki/Release-Flow.md | 21 ++--- scripts/test-pack-validation-rules.ps1 | 15 +++- scripts/test-packed-consumers.ps1 | 14 ++-- .../Repository.UnitTests/ReleaseGateTests.cs | 79 ++++++++++++++++++- 7 files changed, 166 insertions(+), 37 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 02b061cd..bced508a 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -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: a transient NuGet failure, or the manifest asks for a command the pinned package does not carry.' + } + 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: @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 0f9f05a2..a3ea7400 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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). diff --git a/Justfile b/Justfile index 4ff1cc3b..50b1d436 100644 --- a/Justfile +++ b/Justfile @@ -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..." @@ -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 diff --git a/docs/wiki/Release-Flow.md b/docs/wiki/Release-Flow.md index 092aec0d..642c3a02 100644 --- a/docs/wiki/Release-Flow.md +++ b/docs/wiki/Release-Flow.md @@ -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 ...` | 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`. diff --git a/scripts/test-pack-validation-rules.ps1 b/scripts/test-pack-validation-rules.ps1 index fd161fdf..d98f588f 100644 --- a/scripts/test-pack-validation-rules.ps1 +++ b/scripts/test-pack-validation-rules.ps1 @@ -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, @@ -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 ` @@ -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 diff --git a/scripts/test-packed-consumers.ps1 b/scripts/test-packed-consumers.ps1 index 23d99eaa..a8c6ea16 100644 --- a/scripts/test-packed-consumers.ps1 +++ b/scripts/test-packed-consumers.ps1 @@ -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 @@ -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, @@ -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 , 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 , 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.' ) } } @@ -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 diff --git a/src/tests/Repository.UnitTests/ReleaseGateTests.cs b/src/tests/Repository.UnitTests/ReleaseGateTests.cs index dfd065dc..a6523c49 100644 --- a/src/tests/Repository.UnitTests/ReleaseGateTests.cs +++ b/src/tests/Repository.UnitTests/ReleaseGateTests.cs @@ -256,7 +256,7 @@ await Assert } [Test] - public async Task PrWorkflow_GivenThePackedConsumersJob_RunsTheGateAndTheRuleProof( + public async Task PrWorkflow_GivenThePackedConsumersJob_RunsTheGateWithoutTheRuleProof( CancellationToken cancellationToken ) { @@ -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"); + } + + /// + /// 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. + /// + [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"); + } + + /// + /// GitHub's pwsh shell appends if ((Test-Path -LiteralPath variable:\LASTEXITCODE)) { exit $LASTEXITCODE } + /// 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. + /// + [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] From 7427652d861732e7f74715e0d33635f972008a9e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Thu, 29 Oct 2026 21:43:27 +0000 Subject: [PATCH 2/2] fix(build): drop the unused dotnet-inspect tool that breaks tool restores A cold `dotnet tool restore` fails whenever dotnet-inspect is in the manifest, on a hosted runner and on a clean machine alike: The command "purview-build" specified in the tool manifest file is not contained in the package with Package Id purview.build. The commands contained in the package are dotnet-inspect. dotnet-inspect 0.26.0 is a RID-specific tool (DotNetCliTool Version="2" with RuntimeIdentifierPackages, laid out under tools/any/any). Installing it beside another tool leaves NuGet validating the wrong package's commands. Reversing the manifest order changes nothing and --disable-parallel does not help, so it is not a race: it is every cold restore. Nothing here ever ran the tool. It was added in 75bf81e and is referenced nowhere. Three cold-cache manifests prove the trigger: purview.build alone restores, csharpier plus purview.build restores, and all three fail. Without the entry the repository's own manifest restores cold in 7s - which is what every hosted run and every fresh clone does. This is why the gate in the parent commit could not pass: the shared build's LintModule hits the same failure and retries past it. --- .config/dotnet-tools.json | 7 ------- .github/workflows/pr.yml | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index 3fc1c2ce..261b74ae 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,13 +9,6 @@ ], "rollForward": false }, - "dotnet-inspect": { - "version": "0.26.0", - "commands": [ - "dotnet-inspect" - ], - "rollForward": false - }, "purview.build": { "version": "0.8.0", "commands": [ diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index bced508a..af181942 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -79,7 +79,7 @@ jobs: run: | dotnet tool restore if ($LASTEXITCODE -ne 0) { - throw 'dotnet tool restore failed: a transient NuGet failure, or the manifest asks for a command the pinned package does not carry.' + throw 'dotnet tool restore failed; the message above names the command and the package it is missing from.' } dotnet purview-build ` --Build:RunPack=true `