Publishing Stoat to nuget.org
Stoat ships as one package: Stoat, a .NET global tool (dotnet tool install -g Stoat, invoked as stoat). Nothing else in the tree is packable — Directory.Build.props sets IsPackable=false and only src/Stoat/Stoat.csproj opts back in.
0.5.0 — the name claim
0.5.0 exists to claim Stoat on nuget.org, not to announce a finished product. The number matches CritterWatch's first BSL release rather than Stoat's actual maturity, which is still 0.x in substance. Treat the published 0.5.0 as a placeholder: the tool works, the coordination model is real, but the license gate is currently disabled (see LicenseGuard.IsAllowed) and the entitlement is coarser than the product boundary (issue #7).
Releases run in CI, never from a laptop
.github/workflows/publish.yml is the only thing that pushes to nuget.org. Bump <Version> in Directory.Build.props, then either push a matching v* tag or run the workflow from the Actions tab. A local dotnet nuget push skips the suites, can ship a working tree nobody can reproduce, and puts the org's NuGet key on a developer machine — so it is not how Stoat ships.
The workflow gates the release on the full dotnet test run, the spec suite (which dotnet test silently skips — see CLAUDE.md), and the frontend tests; refuses a tag that disagrees with <Version>; fails fast when NUGET_API_KEY is empty (see below); and, after pushing, polls the flat container until the version is actually downloadable — because --skip-duplicate makes a swallowed push and a genuine duplicate look identical, which is how Bobcat.Console 0.11.0 sat invisible in validation until a consumer hit "not found" (bobcat#224).
Packing (what the workflow does, and what to run locally to inspect a package)
dotnet pack src/Stoat -c Release -p:EmbedFrontend=true -o artifacts/nupkgEmbedFrontend=true is not optional for a published package. It runs npm ci && npm run build and embeds dist/ as resources; IsPackable is gated on the same flag, so a pack without it produces nothing rather than a hollow tool. The target errors out if the frontend build left no dist/index.html.
Verify before pushing — these are the things nuget.org validates and the things that are easy to get silently wrong:
unzip -p artifacts/nupkg/Stoat.0.5.0.nupkg Stoat.nuspec | grep -E "license|icon|version"
unzip -l artifacts/nupkg/Stoat.0.5.0.nupkg | grep -iE "LICENSE|\.png"Expect <license type="file">LICENSE.txt</license>, <icon>stoat-icon-128.png</icon>, <requireLicenseAcceptance>true</requireLicenseAcceptance>, and both files present at the package root.
⚠️ Note the absence of --no-build, in the workflow and here. IsPackable is gated on EmbedFrontend, and the target that builds the SPA and adds it as EmbeddedResource runs BeforeTargets=BeforeBuild. With --no-build it never runs — and neither does the guard that errors when dist/index.html is missing — so the tool would ship serving no UI and nothing would say so.
Pushing
The workflow pushes with --skip-duplicate and then verifies the version is live. The push is irreversible in the way that matters: a version can be unlisted but never replaced, and the id is claimed permanently once accepted.
License
BSL 1.1 — the same license CritterWatch ships under. LICENSE.txt at the repository root carries the parameters (Licensor: JasperFx Software LLC; Change Date 2030-08-08; Change License: Apache 2.0; the hosted-service Additional Use Grant).
Two packaging details that are load-bearing rather than cosmetic:
- BSL has no SPDX identifier, so the license is packed as a file (
PackageLicenseFile) rather than declared withPackageLicenseExpression. - The
.txtextension is required. NuGet accepts only.txtor.mdfor a packed license file, which is why the root file isLICENSE.txtand notLICENSE.
PackageRequireLicenseAcceptance=true surfaces the acceptance prompt in IDE package-add flows. Without it a BSL package gets the same UX as a permissive one, which these terms explicitly call out as needing affirmative consent.
The NuGet key is a REPOSITORY secret, and has to be
JasperFx keeps NUGET_API_KEY as an org secret with visibility: all, which is why bobcat and fisher publish without a repo secret of their own. That does not work here: on a GitHub Free organization, org-level Actions secrets are only available to public repositories, and Stoat is private on purpose (BSL, commercial boundary). The org secret resolves to an empty string in this repo's workflows regardless of its access list — the first publish attempt failed exactly that way.
So Stoat carries its own:
gh secret set NUGET_API_KEY --repo JasperFx/stoatMaking the repository public, or moving the org to Team, would also resolve it — both are decisions well outside a release workflow.
Icon
src/Stoat/stoat-icon-128.png — a 128px PNG export of docs/public/avatar-circle.svg, because NuGet does not accept SVG icons. Regenerate it from the SVG (the brand's source of truth, see docs/BRAND.md) by rendering with qlmanage at 512 and downsizing with sips to 128; the csproj comment carries the exact invocation. 128px keeps the accent S legible — below the brand's 24px floor the badge becomes mark.svg, silhouette only.
Known gap: no package README
dotnet pack warns that the package has no readme, and it currently ships without one. The repository README.md opens with  — a relative path that nuget.org cannot resolve, and the repository is private so a raw URL would 404. Shipping it as-is would render a broken image on the package page. Fix by adding a package-specific readme with no images (or with images hosted somewhere public) before the next release.