Branching and release flow
Maintainer reference for how Fallout branches, ships releases, hotfixes older lines, and uses GitHub Environments to gate publishes. Model defined by ADR-0004 (calendar versioning + dual-pace channels), amending ADR-0001 / milestone #13 / RFC #267.
Audience. Repository maintainers cutting releases or hotfixing older lines. Contributors filing PRs against
maindon't need to read this — see CONTRIBUTING.md instead. AI coding tools should read both this file and docs/agents/release-and-versioning.md.
Branches at a glance
A three-tier maturity ladder feeding the production line (amended ADR-0004, 2026-05-30):
| Branch | Purpose | Lifetime | Protected | Source of releases? |
|---|---|---|---|---|
experimental | Fast / AI lane. Per-commit …-alpha prereleases to GitHub Packages. Intentionally unstable; breaking work accumulates here for the yearly major. | Long-lived | Yes | Alpha only (GitHub Packages, no nuget.org / no GH Release) |
main | Integration trunk + -preview channel. Default branch. Deliberate improvements + bug fixes land here; non-breaking work is promoted up from experimental. Pushes publish …-preview prereleases to GitHub Packages. Never nuget.org. | Long-lived | Yes | Preview only (GitHub Packages, no nuget.org / no GH Release) |
release/YYYY | Production line for the calendar year (e.g. release/2026), cut from main on demand at the first release of the year, not preemptively (ADR-0007). -rc.N → GA. Non-breaking minors/patches only after the cut. | Cut on demand; long-lived once cut | Yes | Yes — tags pushed here fire the full release pipeline (nuget.org opt-in) |
support/v10 (+ hotfix/v10.1, hotfix/v10.2) | Legacy semver 10.x maintenance line — security/critical fixes only. (Renamed from release/v10.) | Long-lived | Yes | Yes — tags fire the pipeline (nuget.org opt-in) |
support/YYYY | Retired year production line (e.g. support/2026 once 2027 supersedes it). Security/critical fixes only. | Long-lived | Yes | Yes — tags fire the pipeline (nuget.org opt-in) |
release/v11 | Retired and deleted — nothing clean shipped; work re-homed onto 2026. Branch removed per ADR-0007 §6 (no unique history; dead branches are deletable, tags are the durable markers). | Deleted | — | No |
feature/<slug>, bugfix/<slug>, chore/<slug>, docs/<slug>, pr/<num>-<slug> | Working branches | Short-lived; PR-and-merge then deleted | No | No |
This is gitflow with the project's vocabulary: experimental ≈ develop, main ≈ the stable trunk, release/YYYY ≈ release/* (long-lived per year), support/* ≈ legacy/retired lines. The one deviation: main is not the production/nuget.org line — release/YYYY + support/* are. main is a -preview test channel that production is cut from.
develop (literal) and master are not used. Breaking changes land on experimental only and are batched to the yearly major cut. Non-breaking work is promoted forward-only experimental → main → release/YYYY. A stable-urgent fix lands on main (or the production branch) and is forward-ported to experimental so the fast lane never regresses — see the promotion + hotfix flow below.
Channel taxonomy
Releases fire to multiple channels, each with its own GitHub Environment:
GitHub Packages = the test/preview channels; nuget.org = production. The version ladder orders cleanly under SemVer: …-alpha.N < …-preview.N < …-rc.N < … (GA).
| Channel | Built from | Cadence | Gating | Version shape |
|---|---|---|---|---|
alpha → github-packages env | experimental | Per-commit | None | 2026.1.0-alpha.<height>.g<commit> |
preview → github-packages env | main | Per-commit | None | 2026.1.0-preview.<height>.g<commit> |
stable → nuget-org env | release/YYYY tags | Slow, deliberate | Flag opt-in + approval-gated | 2026.1.3 (CalVer) |
stable/legacy → github-packages env | release/YYYY, support/* tags | Every tag | None | CalVer / 10.x |
legacy → nuget-org env | support/v10, support/YYYY tags | Security/critical only | Flag opt-in + approval-gated | 10.x / YYYY.x |
github-releases env (bundled) | release/*, support/* tags | Same tag as the package publish | None | Same as the tag |
| Docker local NuGet server | Per-PR / per-commit | None (local) | PR-derived | Available via tests/integration/docker-compose.yml |
Defaults: experimental (alpha) and main (preview) publish to GitHub Packages only — never nuget.org, never a GH Release. Production tag pushes (release/YYYY, support/*) publish to GitHub Packages + GitHub Releases. nuget.org is always opt-in via the workflow_dispatch publish-to-nugetorg flag — used when a release/YYYY is stabilised enough for the broader consumer audience, or for a support/v10 security patch. See project_release_channels in agent memory and ADR-0004.
Cutting a release
Routine stable release (GitHub Packages only)
The default path. Pushing a v2026.1.X tag to release/2026 publishes to GitHub Packages + GitHub Releases. nuget.org is not touched. (Git tags keep the v prefix — v2026.1.3 — so the v* tag-protection ruleset and validate-ref apply; the package version core is 2026.1.3.)
# 1. Make sure your local release/YYYY is up to date
git fetch
git switch release/2026
git pull --ff-only
# 2. (Optional) Verify what version NB.GV will compute
dotnet nbgv get-version # should report 2026.1.X clean, no -g<sha>
# 3. Create the tag + GitHub Release in one step
gh release create v2026.1.X \
--target release/2026 \
--title "v2026.1.X" \
--generate-notes
That tag push triggers .github/workflows/release.yml:
validate-refconfirms the tag points at a commit reachable from a production branch (release/YYYYorsupport/*).test-and-packrunsdotnet fallout Test Pack, uploadsoutput/packages/*.nupkgas an artifact.- Three parallel publish jobs consume the artifact:
publish-nuget-org— skipped (not opt-in by default)publish-github-packages— pushes all*.nupkg(Fallout.* + Nuke.*) to GitHub Packagespublish-github-releases— attaches all*.nupkgto the GitHub Release page
Stabilised release (nuget.org publish)
When a release/2026 release is stabilised enough for nuget.org, or for cutting a support/v10 legacy security patch, use workflow_dispatch with the opt-in flag:
# Option A: via gh CLI
gh workflow run release.yml \
-f tag=v2026.1.X \
-f publish-to-nugetorg=true
# Option B: via Actions UI → release → "Run workflow" → set publish-to-nugetorg to true
The workflow:
- Skips
validate-ref(workflow_dispatch doesn't auto-validate the ref; you took the action consciously). - Re-runs
test-and-packagainst the named tag. publish-nuget-orgfires — pauses for approval at thenuget-orgenv gate (notification + entry on the run page; click "Review deployments" → checknuget-org→ "Approve and deploy"). Then pushes Fallout.* to nuget.org.publish-github-packagesre-runs idempotently (--skip-duplicateskips what's already there).publish-github-releasesre-runs idempotently (uses--clobberfor asset replacement if the GH Release already exists).
Two layers of safety on the nuget.org path: the flag opt-in + the env approval. You can also test the wiring without burning a release — set the flag, get the approval prompt, then cancel without approving.
If a publish fails partway through
Each dotnet nuget push uses --skip-duplicate. Re-running a publish job is idempotent on packages already pushed. For a transient failure mid-publish:
# Routine re-run — leave publish-to-nugetorg false
gh workflow run release.yml -f tag=v2026.1.X
# Stabilised re-run — include the flag if you want to retry the nuget.org push
gh workflow run release.yml -f tag=v2026.1.X -f publish-to-nugetorg=true
Promotion and hotfixing
The ladder flows forward-only: experimental → main → release/YYYY. Two routine directions plus the legacy case.
Promoting non-breaking work experimental → main
Most work lands on experimental (the fast lane). Non-breaking changes that are ready for the deliberate trunk are promoted to main — cherry-pick the relevant commits (or merge, if experimental carries only non-breaking work since the last promotion) onto a branch and PR it against main. Breaking work is not promoted mid-year; it waits on experimental for the yearly cut.
git fetch
git switch -c promote-XXXX-to-main main
git cherry-pick <non-breaking-sha-on-experimental> [<sha> …]
git push origin HEAD
gh pr create --base main ... # ordinary review tier
Promoting main → release/YYYY (a stable patch/minor)
A stabilised non-breaking change on main is promoted to the production line, then tagged.
git fetch
git switch -c promote-XXXX-to-2026 release/2026
git cherry-pick <sha-on-main> [<sha> …]
git push origin HEAD
gh pr create --base release/2026 ... # rigorous review tier
# once merged:
gh release create v2026.1.X+1 --target release/2026 --generate-notes
Forward-porting a stable-urgent fix
If a fix must land on the production line first (prod-down), land it on release/2026 (or main), then forward-port to main and experimental so the upper lanes never regress:
git switch -c forward-port-XXXX experimental
git cherry-pick <fix-sha>
git push origin HEAD
gh pr create --base experimental ...
Legacy support/v10
A support/v10 security/critical fix that doesn't apply to the current line (the code has moved on) lands directly on support/v10 (or the relevant hotfix/v10.x) via PR — the expected path for a maintenance line, not the exception. Such a release is the nuget.org case (use the opt-in flag). The same applies to a retired support/YYYY line.
Even one-commit cherry-picks go through a PR — branch protection blocks direct pushes and requires the
ubuntu-lateststatus check on every protected branch.
Cutting a new year (the yearly major)
At the yearly major cut, the outgoing year's production line is retired to support/YYYY and a new release/YYYY is cut from main. The accumulated breaking work on experimental becomes the new year's major.
# 1. Retire the outgoing production line: rename release/2026 → support/2026
# (GitHub Settings → Branches → rename, or via API). It keeps taking
# security/critical fixes only from here on.
# 2. Cut the new production line from main
git fetch
git switch main
git pull --ff-only
git switch -c release/2027 main
git push -u origin release/2027
# 3. Apply branch protection (mirror main's profile — see
# docs/agents/release-and-versioning.md → Branch protection on release/YYYY).
# NOTE: scripts/release-branch-protection.json does not exist yet; capture
# main's live protection JSON into it (or apply via repo Settings → Branches).
gh api -X PUT repos/ChrisonSimtian/Fallout/branches/release/2027/protection \
--input scripts/release-branch-protection.json
# 4. On release/2027 (the branch itself), set version.json "version": "2027.0".
# publicReleaseRefSpec already matches "^refs/heads/release/\\d{4}$" — confirm
# it resolves so NB.GV produces clean versions, not git-sha-suffixed.
# Commit via PR targeting release/2027.
# 5. Roll the test lanes forward so their prereleases sort above the new production
# line. Merge experimental's accumulated breaking work into main, then bump cores:
# - main/version.json → "2027.1.0-preview.{height}"
# - experimental/version.json → "2027.1.0-alpha.{height}" (or further ahead)
# Keep experimental and main on the SAME core (see ADR-0004 §2) so
# alpha < preview ordering stays honest.
Step 4 — why on release/2027, not main
publicReleaseRefSpec is per-branch. The CalVer ref pattern (^refs/heads/release/\d{4}$) matches release/2027 automatically, but the "version" field is per-branch: release/2027 pins "2027.0" (a public ref → clean versions) while main/experimental move on to the next preview/alpha target. This keeps the production line's number stable and avoids a patch-height collision with the test lanes.
Deprecating a support/* line
Once a support/YYYY or support/v10 line hits end-of-life:
- Final patch release.
- Announce EoL in the README + CHANGELOG.
- Leave the branch in place — don't delete it. Future archaeology + historical hotfix-on-demand should remain possible (this is why
release/v11stays around despite being retired). - Optionally apply a more restrictive protection profile (e.g. require admin approval on every merge) to make accidental tags less likely.
Branches are cheap. Deletion is destructive. Default to keeping.
Tag protection
A repository ruleset blocks creation/deletion/update of tags matching v* for non-admins (ruleset 17017817). Bypass actors: repo admins (RepositoryRole 5). Combined with the nuget-org env approval gate, that's two layers of "who can fire a production release."
See also
- docs/agents/release-and-versioning.md — PR-creation flow, semver policy, release pipeline reference, branch protection settings.
- docs/adr/0004-calendar-versioning-and-dual-pace-channels.md — the current versioning + channel decision.
- docs/adr/0001-release-branch-model.md — the release-branch + multi-channel CD model (versioning amended by 0004).
- milestone #13 — full work-breakdown of how this shape was implemented.
- RFC #267 — original design discussion.
- CONTRIBUTING.md — contributor-facing flow.