Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .agents/skills/cutting-a-release/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ gotchas worth knowing before you start.
- **A green pipeline run is not proof anything published** — every publish job
is conditional. Verify job conclusions and the nuget.org index after a
release (commands in the runbook).
- **nuget.org is always opt-in** via `workflow_dispatch -f publish-to-nugetorg=true`;
a tag push alone only reaches GitHub Packages + GitHub Releases.
- **GA (`10.5.0`) and `-rc` (`10.5.0-rc.1`) tags publish to nuget.org on their own**,
with no approval. Tags have no `v` prefix. Any other prerelease tag skips nuget.org.
Do not reuse a tag name.
- Never bypass a hotfix through direct pushes — branch protection blocks it
everywhere; even a one-commit cherry-pick goes through a PR.
3 changes: 3 additions & 0 deletions .fallout/build.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@
"type": "string"
}
},
"ReleaseTag": {
"type": "string"
},
"Solution": {
"type": "string",
"description": "Path to a solution file that is automatically loaded"
Expand Down
1 change: 0 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ updates:
- dependency-name: "Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.XUnit"
# Build-time only (PrivateAssets="all", never in a package's dependencies)
- dependency-name: "Microsoft.SourceLink.GitHub"
- dependency-name: "Nerdbank.GitVersioning"
- dependency-name: "PolySharp"
- dependency-name: "IsExternalInit"
# Only used by the fallout / fallout-migrate tools, not by the libraries
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/build-cross-platform.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ name: build-cross-platform
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
pull_request:
branches:
- main
Expand Down
21 changes: 11 additions & 10 deletions .github/workflows/prune-preview-packages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
# Shape:
# - Discovers the org's NuGet packages from the API rather than hard-coding a
# list, so a newly-added package is covered the moment it first publishes.
# - Prunes ONLY `-preview.` versions, keeping the newest `keep` of them.
# - `ignore-versions` protects everything that is not a per-commit preview:
# GA, `-rc.N`, and the legacy `-alpha.N` versions are never touched. Those are
# the durable release markers and must outlive any cleanup.
# - Prunes ONLY `-alpha.`, `-beta.` and (legacy) `-preview.` versions, keeping the
# newest `keep` of them.
# - `ignore-versions` protects everything that is not a per-commit build:
# GA and `-rc.N` versions are never touched. Those are the durable release
# markers and must outlive any cleanup.
# - Runs weekly, matching the Dependabot cadence, and on demand.
#
# "Newest" means most-recently-created, not highest version number: the action
Expand Down Expand Up @@ -103,7 +104,7 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
for p in $(echo '${{ steps.list.outputs.packages }}' | jq -r '.[]'); do
n=$(gh api "/orgs/${{ github.repository_owner }}/packages/nuget/$p/versions?per_page=100" \
--paginate -q '.[].name' | grep -c -- '-preview\.' || true)
--paginate -q '.[].name' | grep -cE -- '-(preview|alpha|beta)\.' || true)
d=$(( n > KEEP ? n - KEEP : 0 ))
total=$(( total + d ))
echo "| \`$p\` | $n | $d |" >> "$GITHUB_STEP_SUMMARY"
Expand All @@ -130,14 +131,14 @@ jobs:
matrix:
package: ${{ fromJSON(needs.discover.outputs.packages) }}
steps:
- name: 'Prune: old -preview versions'
- name: 'Prune: old -alpha, -beta and -preview versions'
uses: actions/delete-package-versions@v5
with:
owner: ${{ github.repository_owner }}
package-name: ${{ matrix.package }}
package-type: nuget
min-versions-to-keep: ${{ env.KEEP }}
# Protect every version that is NOT a per-commit preview — GA, `-rc.N`,
# and legacy `-alpha.N` all match this and are ignored. Deliberately not
# `delete-only-pre-release-versions`, which would also eat rc + alpha.
ignore-versions: '^(?!.*-preview\.).*$'
# Protect every version that is NOT a per-commit build — GA and `-rc.N`
# match this and are ignored. Deliberately not `delete-only-pre-release-versions`,
# which would also eat rc.
ignore-versions: '^(?!.*-(preview|alpha|beta)\.).*$'
22 changes: 11 additions & 11 deletions .github/workflows/publish-packages-preview.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
# Hand-written. The "preview" channel — `develop` (ADR-0009, superseding ADR-0004).
# Hand-written. The "preview" channels — `develop` and `release/*` (ADR-0011,
# superseding ADR-0009's versioning).
#
# Shape:
# - Trigger: push to `develop`. `develop` is the integration trunk / `-preview`
# channel — the deliberate lane where work stabilises. Nerdbank.GitVersioning
# produces a prerelease of the form `10.5.0-preview.<height>.g<commit>`
# (`develop` is intentionally NOT a public-release ref in version.json), so these
# never look like a stable release.
# - Trigger: push to `develop` or a `release/*` branch. GitVersion (GitVersion.yml)
# computes the version from the branch: `10.5.0-alpha.<n>` on `develop`,
# `10.5.0-beta.<n>` on `release/v10.5`. Neither looks like a stable release.
# `-rc` and GA versions only come from tags (publish-packages-release.yml).
# - Builds + packs, then pushes every *.nupkg to GitHub Packages ONLY.
# - Deliberately does NOT touch nuget.org and does NOT create a GitHub Release.
# GitHub Packages is the test/preview channel and is opt-in for consumers
# (they add the feed), so it causes none of the nuget.org Dependabot
# fan-out that ADR-0001 / ADR-0002 were avoiding.
#
# `develop` is the only continuous prerelease lane. ADR-0008 folded the former
# `experimental` `-alpha` lane into the trunk; ADR-0009 keeps that, just renamed
# onto `develop`. Stable and legacy releases are deliberate and tag-triggered —
# `develop` and `release/*` are the continuous prerelease lanes. Stable and
# legacy releases are deliberate and tag-triggered —
# see publish-packages-release.yml. Cross-platform validation (macOS/Windows)
# only runs on release intent (build-cross-platform.yml), not on `develop`
# pushes — `develop` relies on the ubuntu-latest gate (build.yml) instead.
Expand All @@ -26,6 +25,7 @@ on:
push:
branches:
- develop
- 'release/**'
paths-ignore:
- 'docs/**'
- '.assets/**'
Expand All @@ -51,14 +51,14 @@ jobs:
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # Nerdbank.GitVersioning needs full history
fetch-depth: 0 # GitVersion needs full history
- name: 'Cache: .fallout/temp, ~/.nuget/packages'
uses: actions/cache@v6
with:
path: |
.fallout/temp
~/.nuget/packages
key: ${{ runner.os }}-preview-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props', 'version.json') }}
key: ${{ runner.os }}-preview-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
restore-keys: |
${{ runner.os }}-preview-
- name: 'Setup: .NET SDK'
Expand Down
111 changes: 47 additions & 64 deletions .github/workflows/publish-packages-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,33 +3,28 @@
# NuGetApiKey parameter, which would otherwise have to share a name).
#
# This is the DELIBERATE (production) release path. The fast test lane —
# develop (-preview), push → GitHub Packages — lives in publish-packages-preview.yml.
# See ADR-0009 (superseding ADR-0004, and carrying forward ADR-0008's no-`experimental`
# decision onto `develop`).
# develop (-alpha) and release/* (-beta), push → GitHub Packages — lives in
# publish-packages-preview.yml. See ADR-0011 (GitVersion and tag-driven releases).
#
# Shape:
# - Trigger: tag push `v*` on a production branch — the classic GitFlow
# production trunk (main → tag v10.5.0), release stabilization lines
# (release/v10.5 → tag v10.5.0-rc.N), or gitflow support lines (support/v10 →
# v10.x, and eventually a retired support/v11 once a later major replaces it).
# develop is NEVER tagged for release. Tags keep the `v` prefix so the `v*`
# tag-protection ruleset applies; the package version core is the bare semver
# (e.g. 10.5.0). Tag protection restricts who can create those tags to repo
# admins.
# - Trigger: tag push `X.Y.Z[-label]` (no `v` prefix) on a production branch —
# the classic GitFlow production trunk (main → tag 10.5.0), release
# stabilization lines (release/v10.5 or release/10.5 → tag 10.5.0-rc.N), or
# gitflow support lines (support/v10). develop is NEVER tagged for release.
# Tag protection restricts who can create those tags to repo admins.
# - The tag IS the package version. It is passed to the build as ReleaseTag
# (see Build.cs); the branch does not matter for the version.
# - Optional fallback: workflow_dispatch with an existing-tag input, for
# re-runs after a transient publish-API failure, OR to opt into a
# nuget.org publish (see below).
# re-runs after a transient publish-API failure.
# - validate-ref → test-and-pack → fan out to three publish jobs in parallel:
# - publish-nuget-org (opt-in via workflow_dispatch flag, approval-gated)
# - publish-nuget-org (GA and -rc tags only)
# - publish-github-packages (all packages — Fallout.* + Nuke.*)
# - publish-github-releases (artifact bundling on the tag's GH Release)
#
# Channel routing (ADR-0009):
# - nuget.org is OPT-IN. Tag pushes do NOT auto-publish there. To publish
# Fallout.* to nuget.org you invoke workflow_dispatch with
# publish-to-nugetorg=true — a conscious "this release is stabilised enough"
# switch, used when a `main` GA release is ready for the broad consumer
# audience, or for a support/v10 legacy security patch.
# Channel routing (ADR-0011):
# - nuget.org gets Fallout.* from a GA tag (10.5.0) or an -rc tag
# (10.5.0-rc.2). Any other prerelease tag (10.5.0-beta.1, ...) skips it.
# There is no approval step.
# - GitHub Packages gets every Fallout.* and Nuke.* build via tag push.
# - GitHub Releases bundles nupkgs on the tag's release page regardless.
#
Expand All @@ -45,24 +40,14 @@ name: publish-packages-release
on:
push:
tags:
- 'v*'
# Manual fallback for two scenarios:
# 1. Re-running a partial publish after a transient API failure (pick the
# tag, leave publish-to-nugetorg at its default).
# 2. Opting into a nuget.org publish for a stabilised release (pick the
# tag, set publish-to-nugetorg to true). Still gated by the nuget-org
# env approval before the actual push.
# `--skip-duplicate` on each push makes re-runs idempotent.
- '[0-9]*.[0-9]*.[0-9]*'
# Manual fallback: re-run a partial publish after a transient API failure
# (pick the tag). `--skip-duplicate` on each push makes re-runs idempotent.
workflow_dispatch:
inputs:
tag:
description: 'Existing tag to (re-)release (e.g. v10.5.5)'
description: 'Existing tag to (re-)release (e.g. 10.5.5)'
required: true
publish-to-nugetorg:
description: 'Publish Fallout.* to nuget.org? Default false — opt-in for stabilised releases only.'
required: false
type: boolean
default: false

permissions:
contents: read
Expand All @@ -84,7 +69,7 @@ jobs:
# stabilization lines (release/v10.5, …, rc tags), and gitflow
# support lines (support/v10 legacy semver, and eventually a retired
# support/v11, …).
REACHABLE=$(git branch -r --contains "$TAG_SHA" | grep -E 'origin/(main|release/v[0-9]+(\.[0-9]+)?|support/v[0-9]+)$' || true)
REACHABLE=$(git branch -r --contains "$TAG_SHA" | grep -E 'origin/(main|release/v?[0-9]+(\.[0-9]+)?|support/v[0-9]+)$' || true)
if [ -z "$REACHABLE" ]; then
echo "::error::Tag ${GITHUB_REF_NAME} at $TAG_SHA is not reachable from any production branch."
echo "Tag-triggered releases only fire from main (GA), release/vX.Y (rc), or support/* (legacy/retired) branches."
Expand All @@ -99,18 +84,31 @@ jobs:
needs: [validate-ref]
# Run when validate-ref succeeded (tag push) OR was skipped (workflow_dispatch).
if: always() && (needs.validate-ref.result == 'success' || needs.validate-ref.result == 'skipped')
outputs:
push-to-nugetorg: ${{ steps.route.outputs.push }}
steps:
- name: 'Route: does this tag go to nuget.org?'
id: route
run: |
TAG="${{ inputs.tag || github.ref_name }}"
if [[ "$TAG" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$ ]]; then
echo "push=true" >> "$GITHUB_OUTPUT"
echo "Tag $TAG goes to nuget.org."
else
echo "push=false" >> "$GITHUB_OUTPUT"
echo "Tag $TAG is neither GA nor -rc. Skipping nuget.org."
fi
- uses: actions/checkout@v7
with:
ref: ${{ inputs.tag || github.ref }}
fetch-depth: 0 # Nerdbank.GitVersioning needs full history
fetch-depth: 0 # GitVersion needs full history
- name: 'Cache: .fallout/temp, ~/.nuget/packages'
uses: actions/cache@v6
with:
path: |
.fallout/temp
~/.nuget/packages
key: ${{ runner.os }}-release-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props', 'version.json') }}
key: ${{ runner.os }}-release-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
restore-keys: |
${{ runner.os }}-release-
- name: 'Setup: .NET SDK'
Expand All @@ -122,12 +120,10 @@ jobs:
- name: 'Run: Test + Pack + PackageGuard'
run: dotnet fallout Test Pack PackageGuard
env:
# We check out the tag above, so HEAD is detached and matches none of
# version.json's publicReleaseRefSpec entries (they're all branch refs,
# and nbgv never matches that spec against refs/tags/*). Without this,
# NB.GV treats the build as non-public and stamps a git-height suffix
# onto every package — v10.4.0-rc.3 shipped as 10.4.0-rc.3.geabd043cc2.
PublicRelease: true
# The tag is the package version. The tag checkout above leaves HEAD
# detached, where GitVersion ignores a tag whose label differs from the
# branch label, so Build.cs takes the version straight from ReleaseTag.
ReleaseTag: ${{ inputs.tag || github.ref_name }}
# PackageGuard's own target (build/Build.PackageGuard.cs) is gated on either being on
# a long-lived branch or running in this workflow (GitHubActions.Workflow ==
# "publish-packages-release") — the tag checkout above leaves HEAD detached, so the
Expand All @@ -150,23 +146,10 @@ jobs:
retention-days: 7
if-no-files-found: error

# Tier 1 — production / nuget.org. **OPT-IN ONLY.** Tag pushes do NOT trigger
# this job; you must invoke workflow_dispatch with publish-to-nugetorg=true.
# The env approval gate on `nuget-org` is the second layer — even after opting
# in via the input flag, you still approve the deployment.
#
# The opt-in defaults to false because the default channel is GitHub Packages
# (see header comment). When a `main` GA release is ready for the broad
# audience — or for cutting a support/v10 maintenance patch — invoke
# workflow_dispatch with the flag set.
#
# The condition below accepts the string 'true' as well as the boolean. A
# `type: boolean` input only arrives as a real boolean when the run is started
# from the Actions UI; the REST API — and therefore `gh workflow run`, which
# rejects a JSON boolean outright — can only send strings. Comparing a string
# to a boolean casts both to numbers ('true' → NaN, true → 1), so a bare
# `== true` silently skipped every CLI-triggered publish while the run still
# reported success. See the runbook's `gh workflow run` invocation.
# Tier 1 — production / nuget.org. A GA tag (10.5.0) or an -rc tag (10.5.0-rc.2)
# publishes Fallout.* here automatically. Any other prerelease tag
# (10.5.0-beta.1, ...) skips the push. There is no approval step: nuget.org
# pushes cannot be undone, so tag protection (admins only) is the gate.
publish-nuget-org:
name: publish → nuget.org
runs-on: ubuntu-latest
Expand All @@ -176,10 +159,10 @@ jobs:
# propagates through the graph — test-and-pack's own always() rescues only
# itself, not its dependents. Without this the whole dispatch path silently
# published nothing while the run still reported success.
# Routed at job level, so a skipped tag never creates a nuget-org deployment.
if: >-
always() && needs.test-and-pack.result == 'success'
&& github.event_name == 'workflow_dispatch'
&& (inputs.publish-to-nugetorg == true || inputs.publish-to-nugetorg == 'true')
&& needs.test-and-pack.outputs.push-to-nugetorg == 'true'
environment:
name: nuget-org
url: https://www.nuget.org/profiles/Fallout.build
Expand Down Expand Up @@ -213,8 +196,8 @@ jobs:
# Tier 2 — GitHub Packages. The default release channel. Pushes both
# Fallout.* AND Nuke.* transition shims. The Nuke.* shim IDs are owned by the
# original NUKE maintainer on nuget.org (#47) — GitHub Packages is their
# permanent home. Fallout.* is here because nuget.org is opt-in by default
# (see header).
# permanent home. Fallout.* is here for every tag, including the ones that
# skip nuget.org (see header).
publish-github-packages:
name: publish → GitHub Packages
runs-on: ubuntu-latest
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ One topic, one home — anything that duplicates another layer gets deleted, not

## Versioning & channels

Classic GitFlow, staying on semver `10.x` — see [ADR-0009](docs/adr/0009-gitflow-and-semver-reversion.md) (replaces the calendar-versioning [ADR-0004](docs/adr/0004-calendar-versioning-and-dual-pace-channels.md)). **GitHub Packages = test/preview/rc; nuget.org = production.**
Classic GitFlow, staying on semver `10.x` — see [ADR-0009](docs/adr/0009-gitflow-and-semver-reversion.md) (replaces the calendar-versioning [ADR-0004](docs/adr/0004-calendar-versioning-and-dual-pace-channels.md)). **GitHub Packages = every build; nuget.org = GA and `-rc` tags only.** GitVersion computes versions from the branch and tags — see [ADR-0011](docs/adr/0011-gitversion-and-tag-driven-releases.md). Tags have no `v` prefix.

| Branch | Role | Publishes |
| --- | --- | --- |
| `develop` | Integration trunk, default branch, sole preview lane. All work lands here, including breaking work gated behind `[Experimental]`. | `-preview` → GitHub Packages only |
| `release/vX.Y` | Stabilizes the next release; cut from `develop` on demand, not ahead of time. Non-breaking fixes only after the cut. | `-rc.N` → GitHub Packages |
| `main` | Production trunk; takes only `release/vX.Y` (GA) or `hotfix/vX.Y.Z` merges. | GA tags → nuget.org (opt-in) + GitHub Packages + Releases |
| `develop` | Integration trunk, default branch, sole preview lane. All work lands here, including breaking work gated behind `[Experimental]`. | `-alpha.N` → GitHub Packages only |
| `release/vX.Y` | Stabilizes the next release; cut from `develop` on demand, not ahead of time. Non-breaking fixes only after the cut. | `-beta.N` → GitHub Packages. `-rc.N` tags → nuget.org + GitHub Packages |
| `main` | Production trunk; takes only `release/vX.Y` (GA) or `hotfix/vX.Y.Z` merges. | GA tags → nuget.org + GitHub Packages + Releases |
| `support/v10` (+ `hotfix/v10.x`) | Legacy line for versions before `10.4`. Security/critical fixes only. | Tags, same channels as `main` |

`MAJOR` stays `10` until a breaking change is actually needed — there's no fixed date for v11, and it's deferred as long as possible. Not-yet-stable public APIs ship behind `[Experimental("FALLOUT0xx")]` on any channel — see the `marking-experimental-apis` skill. Full model: [docs/branching-and-release.md](docs/branching-and-release.md).
Expand Down
Loading
Loading