Releasing Needlr¶
This is the in-depth maintainer guide for cutting a new Needlr release (alpha, beta, rc, or stable). It exists so nobody — human or LLM assistant — has to rediscover the release process by reading commit history.
The fast-lookup version lives at RELEASING.md in the repo root.
What a release actually is¶
A Needlr release is:
- A move of every unshipped analyzer diagnostic from
AnalyzerReleases.Unshipped.md→AnalyzerReleases.Shipped.md. - A
CHANGELOG.mdentry under a new## [x.y.z-label.N]heading. - A version bump in
version.jsonvia Nerdbank.GitVersioning. - A lightweight git tag
v<version>on the bump commit. - A push of the tag to
origin, which triggers.github/workflows/release.ymlto build, test, pack, publish to NuGet.org + GitHub Packages, and create a GitHub Release.
Steps 1-4 are orchestrated by scripts/release.ps1. Step 5 is the CI
workflow. Everything after the tag push is automated.
Prerequisites¶
You need all of the following installed and working before running the release script:
| Tool | Purpose | Install |
|---|---|---|
| .NET 10 SDK | Build + pack | dot.net |
PowerShell 7+ (pwsh) |
Runs release.ps1 |
aka.ms/pwsh |
nbgv |
Bumps version.json and tags |
dotnet tool install -g nbgv |
gh CLI |
CI gate queries GitHub check runs | cli.github.com |
| Python + mkdocs (optional) | Local docs validation | pip install -r docs/requirements.txt |
You also need:
- Push access to
origin/mainonncosentino/needlr. - A NuGet.org API key stored as
NUGET_API_KEYin the GitHub repository secrets (already set — the workflow reads it). - The GitHub-provided
GITHUB_TOKEN(automatic, no setup).
Version numbering¶
Needlr uses SemVer 2.0.0 with a specific tag
format enforced by version.json.
Source of truth¶
version.json at the repo root contains the version Nerdbank.GitVersioning
resolves at build time. Every .csproj inherits from it — there are
zero hardcoded versions in individual project files.
{
"version": "0.0.2-alpha.25",
"publicReleaseRefSpec": [
"^refs/heads/main$",
"^refs/heads/release/v\\d+\\.\\d+",
"^refs/tags/v\\d+\\.\\d+\\.\\d+(?:-[0-9A-Za-z\\.]+)?$"
]
}
Do not edit version.json by hand during a release. Always go through
nbgv set-version or release.ps1 -Prerelease alpha.
Tag format¶
Tags are lightweight (not annotated) and follow the pattern:
For alpha: v0.0.2-alpha.25, v0.0.2-alpha.26, ...
Watch the separator: it's a dot between alpha and the
counter, not a dash. v0.0.2-alpha-0026 is wrong; v0.0.2-alpha.26 is
right. NuGet displays the version with different normalization in its
UI but the tag and version.json use the dotted form.
Finding the next version¶
The -Prerelease flag scans existing tags for the highest
v0.0.2-alpha.* and increments the counter. -Base pins the base
version so a stale version.json doesn't confuse the calculation. The
dry run prints what the real run would do.
Pre-release gates¶
Before the script takes any destructive action (version bump, commit, tag, push) it runs the following gates. All must pass.
1. Clean working tree¶
Dirty trees are rejected. Stash or commit first. This exists because
release.ps1 uses git commit -am for the version bump — any
unrelated staged or unstaged changes would get dragged into the release
commit.
2. nbgv installed¶
The script checks Get-Command nbgv and falls back to
~/.dotnet/tools/nbgv.exe. If neither is present it throws a
remediation message pointing at dotnet tool install -g nbgv.
3. CI green on HEAD¶
Every check run on the current commit must be completed with
conclusion in success / skipped / neutral. Anything failing,
pending, or neutral blocks the release.
The rationale: the CI workflow on main runs the full test matrix
(unit tests, integration tests, generator tests, AspNet tests, AOT
publish, example builds). If any of those are red, the package you're
about to ship is known-broken.
Override with -SkipCiCheck only if you're deliberately cutting a
release before CI finishes (for example, you just pushed a fix and
don't want to wait five minutes, and you've personally verified the
build locally). Never skip this on a release candidate or stable.
4. Analyzer release tracking¶
This is the gate that was silently broken before
f66653c2. If you are reading this because a past release shipped with stale unshipped rules, welcome back.
Every Needlr analyzer project includes two AdditionalFiles:
AnalyzerReleases.Shipped.md— every diagnostic ID that has shipped in a prior release, grouped under## Release <version>headers.AnalyzerReleases.Unshipped.md— every diagnostic ID that is in code but has not yet been included in a released version.
Microsoft.CodeAnalysis.Analyzers (referenced from every analyzer
project) enforces these files via rules RS2000, RS2001, and
RS2002:
- RS2000: Add the new rule to
Unshipped.mdwhen you introduce it in code. - RS2001: Rule IDs in
Unshipped.mdmust eventually move toShipped.mdbefore a release. - RS2002: Rules in
Shipped.mdmust still exist in the analyzer.
Before every release, every unshipped rule must move. The release script refuses to proceed otherwise:
BLOCKED: analyzer projects have unshipped rules.
Before releasing, move each rule below from its AnalyzerReleases.Unshipped.md
file into the matching AnalyzerReleases.Shipped.md under a new header:
## Release 0.0.2-alpha.26
See Shipping analyzers below for the exact mechanical procedure.
5. Build + pack validation¶
The script walks every .csproj under src/ that is neither a test
project nor has <IsPackable>false</IsPackable>, and runs
dotnet pack -c Release -v q --no-restore on each. First failure
aborts the release. This catches:
- Projects that compile for
dotnet buildbut fail at pack time (missingDescription, invalid package id, missing README). - Projects that ship a new analyzer DLL via a custom
None Includeentry whoseOutputPathdoesn't exist yet. - Projects whose
netstandard2.0target drifts against the generator requirements.
6. Nuspec validation¶
After successful packs, scripts/test-packages.ps1 -NoBuild runs. It
extracts every .nupkg from artifacts/, parses the embedded
.nuspec, and asserts:
- The
dependenciesgraph matches expected shape. - Analyzer and generator DLLs are placed at the correct package paths
(
analyzers/dotnet/cs/). - The
Needlr.Buildpackage correctly transitively delivers the generator assembly (regression test for a past bug — see commitb77544fa).
This is the last gate before the version bump actually happens.
Shipping analyzers¶
This is the step that historically got forgotten. release.ps1 now
blocks on it, but you still have to do the mechanical move yourself.
Finding what needs to ship¶
The guardrail tells you exactly which files and which rule IDs. You can also check manually:
Get-ChildItem src -Recurse -Filter AnalyzerReleases.Unshipped.md |
ForEach-Object {
$rules = Get-Content $_.FullName | Where-Object { $_ -match '^NDLR' }
if ($rules) {
Write-Host $_.FullName
$rules | ForEach-Object { Write-Host " $($_ -split '\s*\|\s*' | Select-Object -First 1)" }
}
}
Version header format — the non-obvious rule¶
Microsoft.CodeAnalysis.Analyzers rule RS2007 rejects pre-release
labels in the release header. A header like ## Release 0.0.2-alpha.26
will fail the analyzer build with:
error RS2007: Analyzer release file 'AnalyzerReleases.Shipped.md' has a
missing or invalid release header '## Release 0.0.2-alpha.26'
The authoritative format is the base version only, no pre-release
label, no v prefix, no date:
All alpha/beta/rc releases of 0.0.2 share the same
## Release 0.0.2 section. You don't add a new section per alpha
bump — you append the newly-shipped rules to the existing section.
This is the single rule that has bounced every past release attempt
(see fix commits 83ef38ab, 6b7e1166, 22bd5b64). The commit
22bd5b64 (fix: use semver-only release header for analyzer tracking)
was a retroactive fix that merged ## Release 0.0.2-alpha.17 back into
## Release 0.0.2 after the analyzer build rejected it.
When 0.0.3 is the next base version, that's when a new section gets
created.
The mechanical move¶
For each AnalyzerReleases.Unshipped.md with unshipped rules:
- Open the paired
AnalyzerReleases.Shipped.md. - Find the existing
## Release <base-version>section that corresponds to the current base version (e.g.## Release 0.0.2if you're shippingv0.0.2-alpha.26). If one does not exist — for example, this is the first time the analyzer project has ever shipped anything — create it with the base version only:
## Release 0.0.2
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
<paste every unshipped row here, unchanged>
-
If the section already exists, append the new rule rows to its table in alphanumeric order by Rule ID (so
NDLRCOR012goes betweenNDLRCOR011andNDLRCOR015, not at the end of the section). Consistent ordering makes diffs easier to review. -
Open
AnalyzerReleases.Unshipped.mdand delete only the rule data rows. Keep: - The
; Unshipped analyzer releasescomment at the top - The help link comment
- The
### New Rulesheading - The table header row (
Rule ID | Category | Severity | Notes) - The separator row (
--------|----------|----------|-------)
The post-ship file should look like:
; Unshipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
-
Repeat for each analyzer project with unshipped rules.
-
Build each analyzer project locally to verify the updated files are accepted by
Microsoft.CodeAnalysis.Analyzers:
dotnet build src/NexusLabs.Needlr.Analyzers/NexusLabs.Needlr.Analyzers.csproj -c Release
dotnet build src/NexusLabs.Needlr.AgentFramework.Analyzers/NexusLabs.Needlr.AgentFramework.Analyzers.csproj -c Release
dotnet build src/NexusLabs.Needlr.Generators/NexusLabs.Needlr.Generators.csproj -c Release
If the build fails with RS2001, RS2002, or RS2007, re-read
the Version header format
section above and fix the header before proceeding.
- Commit with the conventional message:
Example diff¶
From the fix for v0.0.2-alpha.26 (commits f66653c2 + follow-up),
the shipping change for NexusLabs.Needlr.Analyzers looked like:
## Release 0.0.2
### New Rules
Rule ID | Category | Severity | Notes
--------|----------|----------|-------
...
NDLRCOR011 | NexusLabs.Needlr | Info | KeyedServiceResolutionAnalyzer, ...
+ NDLRCOR012 | NexusLabs.Needlr | Error | Disposable captive dependency - ...
NDLRCOR015 | NexusLabs.Needlr | Error | [RegisterAs<T>] type argument ...
+ NDLRCOR016 | NexusLabs.Needlr | Warning | [DoNotAutoRegister] applied ...
Note how NDLRCOR012 is inserted in order between NDLRCOR011
and NDLRCOR015, not appended at the end.
If you add a new analyzer diagnostic between releases¶
- Add the
DiagnosticDescriptorin the analyzer project. - Add a row to that project's
AnalyzerReleases.Unshipped.mdunder### New Ruleswith the sameRule ID | Category | Severity | Notesformat. Build immediately — if you forget, the next release script run will tell you, but it's much easier to fix at the source. - Write the doc page at
docs/analyzers/NDLRXXX.mdfollowing the template in.claude/rules/generated/docs.md. - Add a nav entry in
mkdocs.ymlunder the appropriate analyzer subgroup. - Add a row to
docs/analyzers/README.mdin the matching table.
The release script handles the shipping; you only have to remember the Unshipped.md row on the day you add the rule.
Writing the CHANGELOG entry¶
CHANGELOG.md follows Keep a Changelog.
The release workflow extracts release notes by searching for a section
matching ## [<version>] — it takes everything from that header up
until the next ## [ header.
Template¶
## [0.0.2-alpha.26] - 2026-04-10
### Added
- **Feature name** — One-sentence description. Link to the PR or
feature docs if relevant.
### Fixed
- **Bug description** — What broke, how it was fixed, observable
symptom before the fix. Include issue/PR links.
### Changed
- Internal refactors or breaking changes. Breaking changes should be
clearly flagged.
### Shipped analyzers
- `NDLRXXX001`, `NDLRXXX002`, ... (list every ID moved to Shipped.md
in this release, even if they were added in a prior alpha).
The ### Shipped analyzers section is a Needlr convention. It lets
downstream consumers see at a glance which diagnostic IDs are now
"released" in a given version, which helps them triage RS2000 errors
that pop up when they upgrade.
Finding what changed¶
Turn that list into CHANGELOG sections manually, or use the
skills/changelog-generator/scripts/generate.py helper referenced by
the dry-run output:
python skills/changelog-generator/scripts/generate.py \
--from v0.0.2-alpha.25 \
--to HEAD \
--version 0.0.2-alpha.26
Review the output, edit for tone, and append to CHANGELOG.md.
Running the release¶
Dry run first¶
Dry run:
- Runs every gate listed in Pre-release gates except the clean-working-tree check (so you can iterate).
- Computes the next version number.
- Extracts the
CHANGELOG.mdentry for that version and prints it. - Prints what it would commit, tag, and push — without doing any of it.
If the dry run reports a missing CHANGELOG entry or unshipped analyzers, fix those and re-run.
Real run¶
The real run, in order:
- Runs all gates.
- Runs
nbgv set-version <new>to bumpversion.json. - Creates a commit:
chore: bump version to 0.0.2-alpha.26. - Creates a lightweight tag via
nbgv tag. - Runs
git push origin HEAD --tags.
When the tag lands on origin¶
.github/workflows/release.yml fires on the tag push. Its steps:
- Checkout + setup .NET 10.
- Restore, build
src/NexusLabs.Needlr.slnxwith-p:PublicRelease=true. - Run full test suite with coverage collection.
- Pack every
NexusLabs.Needlr*.csprojexcept tests, benchmarks, integration tests. dotnet nuget pushevery.nupkgto NuGet.org using${{ secrets.NUGET_API_KEY }}with--skip-duplicate.dotnet nuget pushevery.nupkgto GitHub Packages using${{ secrets.GITHUB_TOKEN }}with--skip-duplicate.- Extract the matching
## [<version>]section fromCHANGELOG.md. - Create a GitHub Release via
softprops/action-gh-release@v2flagged as pre-release because the tag contains-, attaching every.nupkgand.snupkgfile.
Watch the workflow run at Actions while it runs. It typically takes 6-10 minutes.
Post-release verification¶
After the workflow succeeds:
- NuGet.org: visit
nuget.org/packages/NexusLabs.Needlr
and verify the new version appears under the Versions tab. Check a
few other key packages too (
NexusLabs.Needlr.AspNet,NexusLabs.Needlr.Generators,NexusLabs.Needlr.AgentFramework). - GitHub Release: visit
releases and verify
the new tag shows up, is marked as pre-release, has the CHANGELOG
section as the release notes, and has the
.nupkg+.snupkgassets attached. - GitHub Packages: visit Packages and verify the new version is present.
- Smoke test: create a scratch project that references the new
version and verifies the most critical feature still works. For a
web-path fix like
v0.0.2-alpha.26, that means running theMinimalWebApiSourceGenexample against the new package reference.
If any verification step fails, open an issue immediately and start the rollback conversation. See Rolling back.
Rolling back¶
NuGet.org packages can be unlisted (hidden from search and version resolution) but not deleted. GitHub Releases can be deleted. Tags can be deleted (but doing so does not unpublish the NuGet packages).
If a released version is broken:
- Unlist the bad version on NuGet.org via the web UI (Manage Package → Listing). This stops new consumers from pulling it but preserves package integrity for anyone who already has it cached.
- Delete the GitHub Release (optional, only if the release page itself is misleading).
- Delete the local and remote tag (optional, only if the tag commit itself needs to be revised):
- Cut a new release with the fix and a higher counter
(
v0.0.2-alpha.27). Never re-use a version number that has been pushed to NuGet.org. - Write a CHANGELOG entry for the new release explaining the rollback and what was broken in the unlisted version.
Unlisting is cheap and reversible; deletion is not. Prefer unlist.
Troubleshooting¶
nbgv: command not found¶
Install the tool:
Make sure ~/.dotnet/tools is on your PATH (the script also looks
there directly as a fallback).
BLOCKED: CI is not fully green on HEAD¶
One of the GitHub check runs for the current commit is failing, pending, or neutral. Open the Actions tab for the commit on GitHub, find the failing run, fix the underlying issue, push, wait for the re-run to go green, then re-run the release script.
Override with -SkipCiCheck only in true emergencies and only when
you've personally verified the build and tests locally.
BLOCKED: analyzer projects have unshipped rules¶
See Shipping analyzers. The script prints every file and every rule ID that needs to be moved; just walk the list.
Pack validation failed¶
Some .csproj failed dotnet pack. Run the failing pack command
manually to see the full error:
Common causes:
- Missing
<Description>property on a new project. PackageReadmeFilepointing at a README that wasn't marked as included in the pack.- A newly-added analyzer DLL whose
OutputPathdoesn't resolve because the build matrix is wrong (check the<None Include>entry in the.csproj).
Nuspec validation fails¶
scripts/test-packages.ps1 caught a packaging regression. Look at the
specific assertion that fired in the output. Most common: a change to
Directory.Build.props that broke a transitive dependency for one of
the bundle packages.
The workflow fires but nothing publishes¶
Check the Actions tab for the workflow run. Common causes:
- The
NUGET_API_KEYsecret expired. Renew at nuget.org/account/apikeys and update the secret in repo settings. - A transient NuGet.org outage. Re-run the workflow from the Actions tab.
- A package was already published at that version (
--skip-duplicatein the push command silently swallows this — it's usually a sign someone manually published before the workflow ran, which shouldn't happen).
Docs build fails in mkdocs strict mode¶
If your release adds new analyzer diagnostic doc pages, ensure:
- Each new
docs/analyzers/NDLRXXX.mdexists. - Each new page has a nav entry in
mkdocs.ymlunder the correct analyzer subgroup. - Each new page has a row in
docs/analyzers/README.md.
This is documented in .claude/rules/generated/docs.md. The api/stable/*
warnings emitted by strict mode are pre-existing and expected locally;
CI handles them with a placeholder step.
Historical gotchas (why this document exists)¶
Every item here has bitten past releases. They're listed not to shame anybody, but so the next maintainer can recognize the failure mode:
- Forgetting the analyzer Unshipped→Shipped move. Commits
83ef38ab,6b7e1166,22bd5b64,6e0b08bb,22c7e284are all retroactive fixes or ship-catch-up commits. This is the single most common release-day mistake, which is whyscripts/release.ps1now blocks on it. - Wrong version header format. Early releases used
## Release <date>or omitted the version entirely. The correct format is## Release <version>with no date, novprefix. - Using
alpha-0026instead ofalpha.26. The dot-separated form is whatversion.json,nbgv, and the release workflow expect. NuGet displays it differently in the web UI but the source of truth uses the dot. - Pushing to
origin/mainbefore CI runs. Always wait for CI to go green on the version-bump commit before the tag push. The release script's CI gate enforces this for you. - Releasing from a dirty working tree. Early releases accidentally included uncommitted changes in the version bump commit. The clean tree gate catches this now.
See also¶
RELEASING.md— the fast-lookup version at the repo root.scripts/release.ps1.github/workflows/release.ymlCHANGELOG.md- Roslyn analyzer release tracking docs