Release and versioning
Branching, semver policy, the PR-creation procedure, and the release pipeline.
Branching
The branch/channel/versioning model is defined by ADR-0004 (calendar versioning + dual-pace channels), which amends ADR-0001 (release-branch + tag-triggered multi-channel CD) and ADR-0002 (nuget.org opt-in).
A three-tier maturity ladder (experimental → main → release/YYYY) feeding the production line. GitHub Packages = test/preview; nuget.org = production. Long-lived branches:
experimental— the fast / AI-assisted lane. Every push publishes an NB.GV-native prereleaseYYYY.MINOR.PATCH-alpha.<height>.g<commit>(e.g.2026.1.0-alpha.42.gfbb83ef) to GitHub Packages only. Intentionally unstable; breaking changes land here only and accumulate for the yearly major. Light/fast review.main— integration trunk +-previewchannel. Default branch. Deliberate improvements + bug fixes land here; non-breaking work is promoted up fromexperimental. Every push publishesYYYY.MINOR.PATCH-preview.<height>.g<commit>to GitHub Packages only — never nuget.org.experimentalandmainshare the same version core. Ordinary review.release/YYYY(e.g.release/2026) — the production line for the calendar year. Cut frommainon demand at the first release of the year, not preemptively (ADR-0007); until thenmain(-preview) is the most-stable line. Hardened deliberately (slow crowd's domain, rigorous review),-rc.N→ GA. After the cut it takes non-breaking minors + patches only — never a breaking change. Tag-triggered releases fire from here (the nuget.org tier). Protected per the policy below.support/v10(+hotfix/v10.1,hotfix/v10.2) — legacy semver maintenance line,10.x, security and critical fixes only, no new features (renamed fromrelease/v10). Not renumbered into CalVer. Coexists indefinitely.support/YYYY— a retired year production line (e.g.support/2026once 2027 supersedes it). Security/critical fixes only.release/v11— retired. Nothing clean shipped under it (the11.0.xpackages were unlisted); its rebrand/plugin work re-homed onto the2026line. Kept for archaeology, marked EoL — not a release target. Not renamed tosupport/(not a maintained line).
Short-lived branches (squash- or rebase-merged via PR): feature/<slug>, bugfix/<slug>, chore/<slug>, docs/<slug>, pr/<num>-<slug>. They target experimental for fast/breaking work, or main for deliberate non-breaking improvements/fixes.
No develop (literal) or master branches — experimental is gitflow's develop. The ladder flows forward-only: experimental → main → release/YYYY. The support/* lines are maintenance-only — security/critical fixes land via a PR targeting (or cherry-pick to) support/v10 / support/YYYY (or the relevant hotfix/v10.x) and are tagged from there. A stable-urgent fix that lands low (on main or a production branch) is forward-ported up to experimental so the fast lane never regresses.
CI providers in use: GitHub Actions only (others were dropped — see #8 for the demand-driven revival roadmap).
Branch protection on release/YYYY and support/*
experimental, main, every release/YYYY, and every support/* branch share main's protection profile:
- Required status check:
ubuntu-latest - Linear history required (no merge commits)
- CODEOWNER review required
- Dismiss stale approvals when new commits land
- Direct pushes blocked (PRs only)
- Force-push and branch deletion blocked
- Conversation resolution required
- Admins not enforced (admins can bypass in emergencies)
Apply by mirroring main's protection JSON to the new branch via the GitHub API (or via repo Settings → Branches). Tag protection for v* tags (restricting who can fire a release tag) is tracked separately under milestone #13.
Validation workflows. ubuntu-latest runs on every PR targeting experimental, main, release/*, or support/* (with paths-ignore for docs/**, .assets/**, **/*.md). windows-latest and macos-latest run on push to those branches — they're post-merge / release validation, not PR gates. This is a deliberate cost trade-off. (These three workflows are generated from build/Build.CI.GitHubActions.cs — change the branch lists in the MainBranch/ExperimentalBranch/*BranchPattern constants there and regenerate, don't hand-edit the .yml.)
Merging. Both squash and rebase merge are enabled (plain merge commits are disabled by repo setting and would fail linear-history protection anyway). Squash is the default; rebase is opt-in for curated commit sequences. See CONTRIBUTING.md → Merging for the convention.
Versioning
Calendar versioning: YYYY.MINOR.PATCH (see ADR-0004). It is mechanically valid SemVer 2.0 — all three components are numeric — so Nerdbank.GitVersioning, NuGet, and version ordering all work unchanged. The major is the calendar year.
MAJOR= year, hand-set inversion.jsonat the yearly cut.MINOR= feature drop within the year.PATCH= git-height fixes.- Per-branch via
version.json. The test lanes are non-public refs carrying the next planned version with a prerelease tag:experimental→"2026.1.0-alpha.{height}",main→"2026.1.0-preview.{height}"(same core;firstUnstableTagisalpha/previewrespectively). Eachrelease/YYYYcarries"version": "YYYY.x";support/v10keeps"version": "10.x";support/YYYYkeeps"version": "YYYY.x".publicReleaseRefSpecmatches the three production patterns:^refs/heads/release/\d{4}$,^refs/heads/support/\d{4}$,^refs/heads/support/v\d+$(notmain/experimental). - Test-lane builds carry the height + commit in the prerelease segment (
2026.1.0-alpha.<height>.g<commit>,2026.1.0-preview.<height>.g<commit>), never the version core — a core like2026.05.29would parse as a stable release, not a nightly. Both lanes are non-public refs, so NB.GV appends the.g<commit>suffix. The ladder orders cleanly:-alpha<-preview<-rc< GA.
GitVersion is still installed as a transitional helper for MajorMinorPatchVersion in Build.cs; full removal is a follow-up.
Versioning policy
This project ships calendar versions that are valid Semantic Versioning per CHANGELOG.md. The rule is: breaking changes are batched to the yearly major cut.
- A breaking change may land on
experimentalonly, is held for the next yearly major (it does not bumpversion.json's major mid-year), and is recorded inCHANGELOG.mdunder the next-major[Unreleased]heading with a migration path. - Neither
mainnor arelease/YYYYproduction line takes a breaking change mid-year — both are strictly non-breaking (minor = features, patch = fixes). A PR that breaks may targetexperimentalonly. - Surface that isn't ready to commit to can ship behind
[Experimental("FALLOUT0xx")]instead of being held back — opt-in for consumers, and not a breaking change to add or remove.
A "breaking change" is any of:
- A conventional-commit subject with the
!suffix (e.g.feat(globaltool)!: …,fix(security)!: …). - A
BREAKING CHANGE:footer in the commit body. - A change a reviewer reasonably flags as breaking even without the marker (renamed/removed public API, package ID change, on-disk format change, CI/CD shape change consumers depend on) — except changes to
[Experimental]surface, which carry no stability guarantee.
Reviewer responsibility: if a PR carries ! (or a flagged breaking change), confirm it targets experimental (not main and not a production train) and that the CHANGELOG entry sits under the next-major heading. Block otherwise.
Milestones and version targeting
Milestones are theme-based (e.g. "Plugin Architecture Foundation & Rebrand Completion", "Public Plugin SDK", "Continuous Delivery Vision") and carry across releases; version targeting uses target/YYYY labels (target/2026, target/2027, …). Legacy v10 maintenance work uses target/v10. A breaking change is held for the next yearly major — so its PR carries target/<next-year>.
PR-creation flow
At PR-creation time — not after, not as a follow-up — every PR gets:
- A
target/YYYYlabel matching where it will release. Default totarget/<current-year>(target/2026). If the PR carries a breaking change, it's held for the next yearly major — usetarget/<next-year>. Legacy v10 maintenance work usestarget/v10. Pass via--label target/2026togh pr create.
If the PR includes a breaking change (any commit uses !, has a BREAKING CHANGE: footer, or otherwise meets the breaking-change definition above), additionally:
- Add the
breaking-changelabel.gh pr create --label target/<next-year> --label breaking-change …. - Open the PR body with a
⚠️ Breaking changecallout that names the affected surface (public API, package ID, CLI flag, on-disk format, CI/CD shape, etc.) and the consumer-side impact in one sentence. This is what reviewers and downstream consumers read first. - Confirm the PR targets
experimental, notmainor arelease/YYYYproduction train. Breaking changes accumulate onexperimentalfor the next yearly major; they may not land onmainor a production train. (Do not bumpversion.json's major in the PR — the major is set once, at the yearly cut.) - Add a
CHANGELOG.mdentry under the next-major[Unreleased]heading, in the same PR, describing the breaking change and the migration path (one paragraph minimum).
If you only discover the breaking nature mid-review, apply all relevant steps before requesting re-review.
Release pipeline
.github/workflows/release.yml is tag-triggered: pushing a v* tag on a production branch (release/YYYY or support/*) fires the pipeline. The workflow validates the tag is reachable from such a branch, then fans out a Test+Pack job to three parallel publish jobs:
| Job | Environment | Fires on tag push? | What ships | Gating |
|---|---|---|---|---|
publish-nuget-org | nuget-org | No — opt-in only via workflow_dispatch flag | Fallout.*.nupkg to https://api.nuget.org/v3/index.json | Workflow flag + approval-gated env |
publish-github-packages | github-packages | Yes | All *.nupkg (Fallout.* + Nuke.*) to https://nuget.pkg.github.com/Fallout-build/index.json | None |
publish-github-releases | github-releases | Yes | All *.nupkg attached to a GitHub Release on the tag, auto-generated notes | None |
Test lanes (from experimental and main)
Pushes to experimental publish alpha prereleases (YYYY.MINOR.PATCH-alpha.<height>.g<commit>); pushes to main publish preview prereleases (YYYY.MINOR.PATCH-preview.<height>.g<commit>). Both go to GitHub Packages only — never nuget.org, never a GitHub Release. experimental is the intentionally-unstable fast lane; main is the deliberate -preview trunk. Neither causes nuget.org Dependabot fan-out into consumer repos (GitHub Packages is opt-in for consumers — the reason these lanes are non-publishing to nuget.org per ADR-0001/0002). Implemented in .github/workflows/experimental.yml (alpha) and .github/workflows/preview.yml (preview, formerly edge.yml).
Why nuget.org stays opt-in
GitHub Packages is the default channel for the test lanes (alpha/preview) and for stable tag pushes. nuget.org is reserved for the deliberate publish of a stabilised release/YYYY (or a support/v10 legacy security patch). To publish Fallout.* to nuget.org you must run workflow_dispatch with publish-to-nugetorg=true — a conscious "this release is ready for nuget.org" switch. Tag pushes alone publish to GitHub Packages + GitHub Releases only.
Two layers of protection on the nuget.org path: the input flag opt-in, plus the nuget-org environment's required-reviewer rule.
Nuke.* shims
Nuke.* transition-shim package IDs are owned by the original NUKE maintainer on nuget.org (see #47) — they're permanently routed to GitHub Packages, never nuget.org, regardless of the input flag.
Re-runs
Each dotnet nuget push uses --skip-duplicate, so re-runs of a partial publish (one channel failed transiently) are idempotent on packages that already succeeded.
Tag protection
v* tags are protected via a repository ruleset (rules: creation, deletion, update). Bypass actors: repo admins only. Combined with the workflow-dispatch flag and env approval, the nuget.org path has three layers (tag-creation + flag opt-in + env approval).
workflow_dispatch inputs
tag(required) — existing tag to (re-)release.publish-to-nugetorg(boolean, defaultfalse) — opt into the nuget.org publish job for this run.
Common use cases: re-running a transient-failed publish (tag only), or shipping a stabilised release to nuget.org (tag + publish-to-nugetorg=true).
Channel philosophy
Per RFC #267: nuget.org = production-grade & slow; GitHub Packages = faster cadence (the test/preview channel — alpha + preview + every tag's packages); GitHub Releases = bundled artifacts. A planned Tier 3 (Docker-based local NuGet server for pre-merge testing) shipped via #279 — see tests/integration/docker-compose.yml.
NUGET_API_KEY is scoped to the nuget-org GitHub Environment (per #273) — only resolves in the gated job. Prefix reservation tracked in #33.
Adding a new Fallout.X package — first-publish gotcha
nuget.org's Fallout.* prefix reservation is per-ID, not per-prefix-wildcard: CI's first nuget push for any never-published Fallout.X package ID returns 403 (does not have permission to access the specified package) until someone manually web-uploads one nupkg to register the ID. Two traps when doing that upload:
- Set the package owner to the org, not your personal account. The nuget.org upload UI doesn't prompt you; ownership defaults to the uploading user's profile. If you forget, the package ID is reserved but the org's
NUGET_API_KEYstill 403s on subsequent pushes (the key is scoped to org-owned packages). Fix viaManage Package → Owners → Add owner → <org>then optionally remove your personal account. Or upload using credentials of the org's service account directly. See #208 for what this looks like when it goes wrong. - Validation can lag the upload by 5–30 minutes. The package page may say "approved" while the API key permission hasn't propagated yet. Wait, then rerun the release pipeline (
gh run rerun <id> --failed);--skip-duplicatemakes the retry safe for already-published packages.