diff --git a/.github/workflows/windows-latest.yml b/.github/workflows/build-cross-platform.yml
similarity index 62%
rename from .github/workflows/windows-latest.yml
rename to .github/workflows/build-cross-platform.yml
index bc97f4c73..23a6d71b1 100644
--- a/.github/workflows/windows-latest.yml
+++ b/.github/workflows/build-cross-platform.yml
@@ -9,12 +9,12 @@
#
# - To trigger manual generation invoke:
#
-# fallout --generate-configuration GitHubActions_windows-latest --host GitHubActions
+# fallout --generate-configuration GitHubActions_build-cross-platform --host GitHubActions
#
#
# ------------------------------------------------------------------------------
-name: windows-latest
+name: build-cross-platform
on:
push:
@@ -34,6 +34,28 @@ concurrency:
cancel-in-progress: true
jobs:
+ macos-latest:
+ name: macos-latest
+ runs-on: macos-latest
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ fetch-depth: 0
+ - name: 'Cache: .fallout/temp, ~/.nuget/packages'
+ uses: actions/cache@v4
+ with:
+ path: |
+ .fallout/temp
+ ~/.nuget/packages
+ key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
+ - name: 'Setup: .NET SDK'
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
+ - name: 'Restore: dotnet tools'
+ run: dotnet tool restore
+ - name: 'Run: Test, Pack'
+ run: dotnet fallout Test Pack
windows-latest:
name: windows-latest
runs-on: windows-latest
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
new file mode 100644
index 000000000..6cfe52187
--- /dev/null
+++ b/.github/workflows/build-docs.yml
@@ -0,0 +1,40 @@
+# Hand-written (not auto-generated). Companion to build.yml.
+#
+# Why this exists:
+# - main branch protection requires the `ubuntu-latest` status check (the job
+# name emitted by build.yml — branch protection keys on the job, not the
+# workflow file/name).
+# - build.yml has `paths-ignore: docs/**, .assets/**, **/*.md` so it doesn't
+# fire on docs-only PRs.
+# - Without a substitute, docs-only PRs sit BLOCKED waiting for a check that
+# never reports.
+#
+# This workflow fires on the inverse path set (docs-only changes), runs nothing
+# of substance, and reports success under the same `ubuntu-latest` status-check
+# context — satisfying the protection rule without spending CI minutes on a real
+# build.
+#
+# Keep the job name `ubuntu-latest` aligned with build.yml so both files produce
+# a status check named `ubuntu-latest`; the workflow `name:` mirrors build.yml's
+# `build` so docs-only runs appear under the same workflow in the Actions tab.
+
+name: build
+
+on:
+ pull_request:
+ branches:
+ - main
+ - 'release/*'
+ - 'support/*'
+ paths:
+ - 'docs/**'
+ - '.assets/**'
+ - '**/*.md'
+
+jobs:
+ ubuntu-latest:
+ name: ubuntu-latest
+ runs-on: ubuntu-latest
+ steps:
+ - name: 'Skip: docs-only PR, no build needed'
+ run: echo "Docs-only change — ubuntu-latest validation skipped via .github/workflows/build-docs.yml."
diff --git a/.github/workflows/ubuntu-latest.yml b/.github/workflows/build.yml
similarity index 92%
rename from .github/workflows/ubuntu-latest.yml
rename to .github/workflows/build.yml
index 0e591238a..c03380533 100644
--- a/.github/workflows/ubuntu-latest.yml
+++ b/.github/workflows/build.yml
@@ -9,12 +9,12 @@
#
# - To trigger manual generation invoke:
#
-# fallout --generate-configuration GitHubActions_ubuntu-latest --host GitHubActions
+# fallout --generate-configuration GitHubActions_build --host GitHubActions
#
#
# ------------------------------------------------------------------------------
-name: ubuntu-latest
+name: build
on:
pull_request:
diff --git a/.github/workflows/macos-latest.yml b/.github/workflows/macos-latest.yml
deleted file mode 100644
index 426d42ffa..000000000
--- a/.github/workflows/macos-latest.yml
+++ /dev/null
@@ -1,58 +0,0 @@
-# ------------------------------------------------------------------------------
-#
-#
-# This code was generated.
-#
-# - To turn off auto-generation set:
-#
-# [GitHubActions (AutoGenerate = false)]
-#
-# - To trigger manual generation invoke:
-#
-# fallout --generate-configuration GitHubActions_macos-latest --host GitHubActions
-#
-#
-# ------------------------------------------------------------------------------
-
-name: macos-latest
-
-on:
- push:
- tags:
- - 'v*'
- pull_request:
- branches:
- - 'release/*'
- - 'support/*'
- paths-ignore:
- - 'docs/**'
- - '.assets/**'
- - '**/*.md'
-
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
-
-jobs:
- macos-latest:
- name: macos-latest
- runs-on: macos-latest
- steps:
- - uses: actions/checkout@v6
- with:
- fetch-depth: 0
- - name: 'Cache: .fallout/temp, ~/.nuget/packages'
- uses: actions/cache@v4
- with:
- path: |
- .fallout/temp
- ~/.nuget/packages
- key: ${{ runner.os }}-${{ hashFiles('**/global.json', '**/*.csproj', '**/Directory.Packages.props') }}
- - name: 'Setup: .NET SDK'
- uses: actions/setup-dotnet@v4
- with:
- global-json-file: global.json
- - name: 'Restore: dotnet tools'
- run: dotnet tool restore
- - name: 'Run: Test, Pack'
- run: dotnet fallout Test Pack
diff --git a/.github/workflows/preview.yml b/.github/workflows/publish-packages-preview.yml
similarity index 88%
rename from .github/workflows/preview.yml
rename to .github/workflows/publish-packages-preview.yml
index 7a49b72d6..a2c24d615 100644
--- a/.github/workflows/preview.yml
+++ b/.github/workflows/publish-packages-preview.yml
@@ -14,11 +14,13 @@
#
# `main` is the sole continuous prerelease lane (ADR-0008 collapsed the former
# `experimental` `-alpha` lane into `main`). Stable + legacy releases are
-# deliberate and tag-triggered — see release.yml.
-# Cross-platform validation on `main` pushes is covered by macos-latest.yml /
-# windows-latest.yml; this workflow's job is publishing, not gating.
+# deliberate and tag-triggered — see publish-packages-release.yml.
+# Cross-platform validation (macOS/Windows) is gated to release intent
+# (build-cross-platform.yml) and does NOT run on `main` pushes; on `main` the
+# ubuntu-latest gate (build.yml) is the edge. This workflow's job is publishing,
+# not gating.
-name: preview
+name: publish-packages-preview
on:
push:
@@ -29,7 +31,7 @@ on:
- '.assets/**'
- '**/*.md'
-# Cancel a superseded preview build when a newer commit lands (#322). Never on release.yml.
+# Cancel a superseded preview build when a newer commit lands (#322). Never on publish-packages-release.yml.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
diff --git a/.github/workflows/release.yml b/.github/workflows/publish-packages-release.yml
similarity index 95%
rename from .github/workflows/release.yml
rename to .github/workflows/publish-packages-release.yml
index eb0dd5d9a..23fcbf2ee 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/publish-packages-release.yml
@@ -3,7 +3,7 @@
# NuGetApiKey parameter, which would otherwise have to share a name).
#
# This is the DELIBERATE (production) release path. The fast test lane —
-# main (-preview), push → GitHub Packages — lives in preview.yml. See ADR-0004
+# main (-preview), push → GitHub Packages — lives in publish-packages-preview.yml. See ADR-0004
# (amended 2026-05-30), as further amended by ADR-0008 (which collapsed the
# former experimental/-alpha lane into main).
#
@@ -33,7 +33,12 @@
#
# See docs/branching-and-release.md for the full runbook.
-name: release
+# The workflow `name:` below is load-bearing: Build.cs gates
+# ICreateGitHubRelease.CreateGitHubRelease on `github.workflow ==
+# ReleaseWorkflow` (the const in build/Build.CI.GitHubActions.cs). Keep the two
+# in sync — rename here and you must rename the const, or GitHub Releases stop
+# being cut.
+name: publish-packages-release
on:
push:
diff --git a/.github/workflows/ubuntu-latest-docs.yml b/.github/workflows/ubuntu-latest-docs.yml
deleted file mode 100644
index 68a767c8f..000000000
--- a/.github/workflows/ubuntu-latest-docs.yml
+++ /dev/null
@@ -1,36 +0,0 @@
-# Hand-written (not auto-generated). Companion to ubuntu-latest.yml.
-#
-# Why this exists:
-# - main branch protection requires the `ubuntu-latest` status check.
-# - ubuntu-latest.yml has `paths-ignore: docs/**, .assets/**, **/*.md` so
-# it doesn't fire on docs-only PRs.
-# - Without a substitute, docs-only PRs sit BLOCKED waiting for a check
-# that never reports.
-#
-# This workflow fires on the inverse path set (docs-only changes), runs
-# nothing of substance, and reports success under the same `ubuntu-latest`
-# status-check context — satisfying the protection rule without spending
-# CI minutes on a real build.
-#
-# Keep the workflow `name:` and the job name aligned with ubuntu-latest.yml
-# so both files produce a status check named `ubuntu-latest`.
-
-name: ubuntu-latest
-
-on:
- pull_request:
- branches:
- - main
- - 'release/*'
- paths:
- - 'docs/**'
- - '.assets/**'
- - '**/*.md'
-
-jobs:
- ubuntu-latest:
- name: ubuntu-latest
- runs-on: ubuntu-latest
- steps:
- - name: 'Skip: docs-only PR, no build needed'
- run: echo "Docs-only change — ubuntu-latest validation skipped via .github/workflows/ubuntu-latest-docs.yml."
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 9bc712ca6..8e2c45c64 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -75,7 +75,7 @@ Tool wrapper JSON lives under `src/Fallout.Common/Tools//.json`. Whe
### After opening a PR
-- The PR gate is `ubuntu-latest` only — fires on PRs against `main`, `release/*`, or `support/*`. Docs-only PRs hit a no-op shim workflow that reports the same status check name. `windows-latest` and `macos-latest` run post-merge for cross-platform validation.
+- The PR gate is the `ubuntu-latest` job (from `build.yml`) only — fires on PRs against `main`, `release/*`, or `support/*`. Docs-only PRs hit a no-op shim workflow (`build-docs.yml`) that reports the same status check name. `build-cross-platform.yml` runs Windows + macOS validation on `release/*` / `support/*` PRs and `v*` tag pushes (gated to release intent), not on routine `main` work.
- **Review rises with the ladder.** PRs to `main` (preview) get ordinary review — it's the integration trunk. Promotion to a `release/YYYY` production train (and the GA cut) gets rigorous, unhurried review — that's the project's quality gate. Match your expectations to where the PR is headed.
- Address review feedback in additional commits rather than force-pushing — easier to review the changes.
- If CI fails on something unrelated to your change, ping a maintainer.
diff --git a/README.md b/README.md
index f173fdc2d..a033f2f30 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
> Build automation for C#/.NET — the hard-fork successor to NUKE.
[](https://docs.fallout.build/)
-[](https://github.com/Fallout-build/Fallout/actions/workflows/ubuntu-latest.yml)
+[](https://github.com/Fallout-build/Fallout/actions/workflows/build.yml)
[](https://www.nuget.org/packages/Fallout.Common)
[](https://www.nuget.org/packages/Fallout.Common)
[](https://github.com/Fallout-build/Fallout/releases/latest)
@@ -84,15 +84,14 @@ Fallout (NUKE's successor) brings your build automation to an even level with ev
## Build Status
-CI runs on every PR targeting `main`, `release/*`, or `support/*` across `ubuntu-latest` — the only required status check. After merge, post-merge validation runs on `windows-latest` and `macos-latest`, and a `…-preview` prerelease is published to **GitHub Packages** under the reserved `Fallout.*` prefix from `main`. **Stable** releases fire from `release/YYYY` tags via `.github/workflows/release.yml` (GitHub Packages + GitHub Releases by default; nuget.org opt-in per release). Docs-only PRs are served by a no-op companion workflow (`ubuntu-latest-docs`) so branch protection is satisfied without spending CI minutes on a real build.
+CI runs on every PR targeting `main`, `release/*`, or `support/*` on `build.yml` (Linux) — its `ubuntu-latest` job is the only required status check. Cross-platform Test+Pack (Windows + macOS) is gated to release intent — PRs into `release/*` / `support/*` and `v*` tag pushes — via `build-cross-platform.yml`; it does not run on routine `main` work. A `…-preview` prerelease is published to **GitHub Packages** under the reserved `Fallout.*` prefix on every push to `main`. **Stable** releases fire from `release/YYYY` tags via `.github/workflows/publish-packages-release.yml` (GitHub Packages + GitHub Releases by default; nuget.org opt-in per release). Docs-only PRs are served by a no-op companion workflow (`build-docs`) so branch protection is satisfied without spending CI minutes on a real build.
| Workflow | Status | Trigger |
|---|---|---|
-| [`ubuntu-latest`](.github/workflows/ubuntu-latest.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/ubuntu-latest.yml) | PR to `main` / `release/*` / `support/*` (code paths) — **required check** |
-| [`windows-latest`](.github/workflows/windows-latest.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/windows-latest.yml) | push to those branches (post-merge validation) |
-| [`macos-latest`](.github/workflows/macos-latest.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/macos-latest.yml) | push to those branches (post-merge validation) |
-| [`preview`](.github/workflows/preview.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/preview.yml) | push to `main` → `…-preview` prerelease to GitHub Packages |
-| [`release`](.github/workflows/release.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/release.yml) | tag push on `release/YYYY` (stable) or `support/*` (legacy/retired) — nuget.org opt-in |
+| [`build`](.github/workflows/build.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/build.yml) | PR to `main` / `release/*` / `support/*` (code paths) — job `ubuntu-latest` is the **required check** |
+| [`build-cross-platform`](.github/workflows/build-cross-platform.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/build-cross-platform.yml) | PR to `release/*` / `support/*` or `v*` tag push — Windows + macOS (release intent) |
+| [`publish-packages-preview`](.github/workflows/publish-packages-preview.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/publish-packages-preview.yml) | push to `main` → `…-preview` prerelease to GitHub Packages |
+| [`publish-packages-release`](.github/workflows/publish-packages-release.yml) | [](https://github.com/Fallout-build/Fallout/actions/workflows/publish-packages-release.yml) | tag push on `release/YYYY` (stable) or `support/*` (legacy/retired) — nuget.org opt-in |
Multi-provider CI support (Azure Pipelines, GitLab, TeamCity, AppVeyor) was removed during the takeover and is being revived demand-driven — see [#8](https://github.com/Fallout-build/Fallout/issues/8).
diff --git a/build/Build.CI.GitHubActions.cs b/build/Build.CI.GitHubActions.cs
index 360607b4c..28f1c35d4 100644
--- a/build/Build.CI.GitHubActions.cs
+++ b/build/Build.CI.GitHubActions.cs
@@ -1,30 +1,54 @@
using Fallout.Common.CI.GitHubActions;
using Fallout.Components;
-// Cross-platform (macOS/Windows) full Test+Pack is gated to RELEASE INTENT
-// (#318/#326): it runs only on a PR into a production branch (release/YYYY,
-// support/*) and on a release tag push (v*) — never on routine pushes to
-// main, never on a per-merge basis. On main "we've got our edge": the
-// ubuntu-latest PR gate + the preview pipeline (.github/workflows/preview.yml).
-// (workflow_dispatch as a manual cross-platform trigger isn't emitted here —
-// the generator only writes workflow_dispatch when it has inputs; GitHub's
-// built-in run re-run covers the on-demand case.)
+// Two generated build workflows. Both run Test+Pack; both are GENERATED from the
+// attributes below — edit here and regenerate (`./build.sh`), never hand-edit the
+// `.yml`.
//
-// concurrency cancel-in-progress (#322): superseded runs are cancelled rather
-// than stacked. Never applied to release.yml (a publish must not be cancelled).
+// build.yml — the Linux PR gate, and the ONLY required status
+// check (job `ubuntu-latest`; branch protection keys on
+// that job name, not the workflow file/name). PR-only:
+// feature-branch pushes run zero CI until a PR is opened
+// against a long-lived branch (#327), targeting main,
+// release/YYYY, or support/*. CheckoutRef = github.head_ref
+// pins checkout to the PR source branch instead of the
+// merge SHA, keeping HEAD attached so
+// GitHubTasksTest.GitHubRepositoryFromLocalDirectoryTest
+// (which reads .git/HEAD via GitRepository.FromLocalDirectory)
+// resolves a non-null branch.
+//
+// build-cross-platform.yml — macOS + Windows in ONE workflow (one job per image).
+// Cross-platform full Test+Pack is gated to RELEASE
+// INTENT (#318/#326): it runs only on a PR into a
+// production branch (release/YYYY, support/*) and on a
+// release tag push (v*) — never on routine pushes/PRs to
+// main. On main "we've got our edge": the ubuntu-latest
+// gate above + the preview pipeline
+// (.github/workflows/publish-packages-preview.yml).
+// (workflow_dispatch as a manual cross-platform trigger
+// isn't emitted — the generator only writes
+// workflow_dispatch when it has inputs; GitHub's built-in
+// run re-run covers the on-demand case.)
+//
+// concurrency cancel-in-progress (#322): superseded runs are cancelled rather than
+// stacked. Never applied to the publish-packages-release workflow (a publish must
+// not be cancelled).
[GitHubActions(
- "macos-latest",
- GitHubActionsImage.MacOsLatest,
+ "build",
+ GitHubActionsImage.UbuntuLatest,
FetchDepth = 0,
ConcurrencyGroup = "${{ github.workflow }}-${{ github.ref }}",
ConcurrencyCancelInProgress = true,
- OnPushTags = new[] { "v*" },
- OnPullRequestBranches = new[] { ReleaseBranchPattern, SupportBranchPattern },
+ CheckoutRef = "${{ github.head_ref }}",
+ // PRs targeting main or any release/YYYY / support/* branch — all long-lived and
+ // protected; all require the ubuntu-latest check.
+ OnPullRequestBranches = new[] { MainBranch, ReleaseBranchPattern, SupportBranchPattern },
OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
InvokedTargets = new[] { nameof(ITest.Test), nameof(IPack.Pack) },
PublishArtifacts = false)]
[GitHubActions(
- "windows-latest",
+ "build-cross-platform",
+ GitHubActionsImage.MacOsLatest,
GitHubActionsImage.WindowsLatest,
FetchDepth = 0,
ConcurrencyGroup = "${{ github.workflow }}-${{ github.ref }}",
@@ -34,31 +58,14 @@
OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
InvokedTargets = new[] { nameof(ITest.Test), nameof(IPack.Pack) },
PublishArtifacts = false)]
-// The Linux PR gate — the only required status check. pull_request only:
-// feature-branch pushes run zero CI until a PR is opened against a long-lived
-// branch (#327). CheckoutRef = github.head_ref pins checkout to the PR source
-// branch instead of the merge SHA, keeping HEAD attached so
-// GitHubTasksTest.GitHubRepositoryFromLocalDirectoryTest (which reads .git/HEAD
-// via GitRepository.FromLocalDirectory) resolves a non-null branch.
-[GitHubActions(
- "ubuntu-latest",
- GitHubActionsImage.UbuntuLatest,
- FetchDepth = 0,
- ConcurrencyGroup = "${{ github.workflow }}-${{ github.ref }}",
- ConcurrencyCancelInProgress = true,
- CheckoutRef = "${{ github.head_ref }}",
- // Trigger for PRs targeting main or any release/YYYY / support/* branch —
- // all are long-lived and protected; all require the ubuntu-latest check.
- OnPullRequestBranches = new[] { MainBranch, ReleaseBranchPattern, SupportBranchPattern },
- OnPullRequestExcludePaths = new[] { "docs/**", ".assets/**", "**/*.md" },
- InvokedTargets = new[] { nameof(ITest.Test), nameof(IPack.Pack) },
- PublishArtifacts = false)]
partial class Build
{
// The release workflow is intentionally hand-written at
- // .github/workflows/release.yml — that lets us name the GitHub secret
- // NUGET_API_KEY (conventional screaming-snake-case) while keeping the
+ // .github/workflows/publish-packages-release.yml — that lets us name the GitHub
+ // secret NUGET_API_KEY (conventional screaming-snake-case) while keeping the
// Build.cs property name NuGetApiKey (idiomatic C#). The NUKE attribute
- // generator would force the two to match.
- const string ReleaseWorkflow = "release";
+ // generator would force the two to match. This constant must match that
+ // workflow's `name:` — it gates ICreateGitHubRelease.CreateGitHubRelease
+ // (Build.cs) to the release workflow only.
+ const string ReleaseWorkflow = "publish-packages-release";
}
diff --git a/docs/agents/conventions.md b/docs/agents/conventions.md
index d14b74b7f..5deeadbb1 100644
--- a/docs/agents/conventions.md
+++ b/docs/agents/conventions.md
@@ -75,13 +75,13 @@ public string[] OnWorkflowDispatchOptionalInputs { get; set; } = new string[0];
Shaped by [milestone #18](https://github.com/Fallout-build/Fallout/milestone/18) and the [ADR-0004](../adr/0004-calendar-versioning-and-dual-pace-channels.md) ladder (amended by [ADR-0008](../adr/0008-collapse-experimental-into-main.md), which collapsed `experimental` into `main`). Invariants:
- **Feature branches run zero CI until a PR is opened.** Push triggers list **only** long-lived branches; nothing fires on `feature/*`, `bugfix/*`, etc. until they're PR'd against `main`/`release/*`/`support/*`. Do **not** add a working-branch pattern to any `OnPush*`/`branches:` trigger.
-- **The Linux PR gate (`ubuntu-latest`) is the only required check** — runs on PRs to the long-lived branches.
-- **`main` (push) → `-preview`** to GitHub Packages (`preview.yml`) — the sole continuous publisher now that `experimental.yml` is deleted.
-- **Cross-platform `windows`/`macos` are gated to release intent** — PR-to-`release/*`/`support/*` or a `v*` tag push only. They do **not** run on `main` pushes. ("On `main` we've got our edge.")
-- **`concurrency: cancel-in-progress` on every build workflow except `release.yml`** — never cancel a publish mid-flight.
+- **The Linux PR gate (job `ubuntu-latest`, from `build.yml`) is the only required check** — runs on PRs to the long-lived branches. (Branch protection keys on the job name, not the workflow file.)
+- **`main` (push) → `-preview`** to GitHub Packages (`publish-packages-preview.yml`) — the sole continuous publisher now that `experimental.yml` is deleted.
+- **Cross-platform `windows`/`macos` are gated to release intent** — one `build-cross-platform.yml` workflow (a job per OS), firing on PR-to-`release/*`/`support/*` or a `v*` tag push only. They do **not** run on `main` pushes. ("On `main` we've got our edge.")
+- **`concurrency: cancel-in-progress` on every build workflow except `publish-packages-release.yml`** — never cancel a publish mid-flight.
- **Canonical CI-ignore paths:** `docs/**`, `.assets/**`, `**/*.md` — applied to every PR/push trigger.
-- The `ubuntu-latest` / `windows-latest` / `macos-latest` workflows are **generated** from `build/Build.CI.GitHubActions.cs` — edit the attributes + constants there and regenerate (`./build.sh`), never hand-edit the `.yml`. `preview.yml` / `release.yml` are hand-written.
-- **Every publishing lane runs `Test` before it publishes** (#324). `preview.yml` and `release.yml` both run a single `dotnet fallout Test Pack` invocation — NUKE executes it as discrete internal stages (Restore → Compile → Test → Pack) and fails at the breaking stage, so a test failure stops the job before the push step. Don't split a lane into separate `dotnet fallout Compile`/`Test`/`Pack` steps — each invocation re-runs the dependency graph (double-compile); the single invocation *is* the staged build.
+- The `build.yml` (Linux gate) and `build-cross-platform.yml` (macOS+Windows) workflows are **generated** from `build/Build.CI.GitHubActions.cs` — edit the attributes + constants there and regenerate (`./build.sh`), never hand-edit the `.yml`. `build-docs.yml`, `publish-packages-preview.yml`, and `publish-packages-release.yml` are hand-written.
+- **Every publishing lane runs `Test` before it publishes** (#324). `publish-packages-preview.yml` and `publish-packages-release.yml` both run a single `dotnet fallout Test Pack` invocation — NUKE executes it as discrete internal stages (Restore → Compile → Test → Pack) and fails at the breaking stage, so a test failure stops the job before the push step. Don't split a lane into separate `dotnet fallout Compile`/`Test`/`Pack` steps — each invocation re-runs the dependency graph (double-compile); the single invocation *is* the staged build.
- **Caching** (#328): every workflow caches `~/.nuget/packages` + `.fallout/temp`, keyed on `global.json` + `**/*.csproj` + `Directory.Packages.props` (the dependency-affecting set), with a `restore-keys:` prefix fallback for partial restores. There is no `packages.lock.json` to add to the key, and build outputs (`bin`/`obj`) are deliberately **not** cached (stale-artifact correctness risk).
## What not to do
diff --git a/docs/agents/release-and-versioning.md b/docs/agents/release-and-versioning.md
index 588a52638..42a3ecc66 100644
--- a/docs/agents/release-and-versioning.md
+++ b/docs/agents/release-and-versioning.md
@@ -35,7 +35,7 @@ CI providers in use: **GitHub Actions only** (others were dropped — see [#8](h
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 `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`/`*BranchPattern` constants there and regenerate, don't hand-edit the `.yml`.)
+**Validation workflows.** `build.yml` runs Test+Pack on Linux on every PR targeting `main`, `release/*`, or `support/*` (with `paths-ignore` for `docs/**`, `.assets/**`, `**/*.md`); its job `ubuntu-latest` is the only required status check. `build-cross-platform.yml` runs Test+Pack on Windows and macOS (one job each) only on PRs targeting `release/*` / `support/*` and on `v*` tag pushes — cross-platform is gated to release intent, not routine `main` work. This is a deliberate cost trade-off. (Both workflows are **generated** from `build/Build.CI.GitHubActions.cs` — change the branch lists in the `MainBranch`/`*BranchPattern` constants there and regenerate, don't hand-edit the `.yml`. The `build-docs.yml` no-op shim reports the `ubuntu-latest` check on docs-only PRs.)
**Merging.** Rebase merge only — squash and plain merge commits are both disabled by repo setting (linear history). Every reviewed commit lands on `main` verbatim, so curate commits into a clean sequence before final approval. See [CONTRIBUTING.md → Merging](https://github.com/Fallout-build/Fallout/blob/main/CONTRIBUTING.md#merging) for the convention.
@@ -88,7 +88,7 @@ If you only discover the breaking nature mid-review, apply all relevant steps be
## 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:
+`.github/workflows/publish-packages-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 |
|---|---|---|---|---|
@@ -98,7 +98,7 @@ If you only discover the breaking nature mid-review, apply all relevant steps be
### Preview lane (from `main`)
-Pushes to `main` publish **preview prereleases** (`YYYY.MINOR.PATCH-preview..g`) to **GitHub Packages only** — never nuget.org, never a GitHub Release. `main` is the sole continuous prerelease lane (per [ADR-0008](../adr/0008-collapse-experimental-into-main.md), which collapsed the former `experimental`/`-alpha` lane into `main`). It does not cause nuget.org Dependabot fan-out into consumer repos (GitHub Packages is opt-in for consumers — the reason this lane is non-publishing to nuget.org per ADR-0001/0002). Implemented in `.github/workflows/preview.yml` (the former `experimental.yml` is deleted).
+Pushes to `main` publish **preview prereleases** (`YYYY.MINOR.PATCH-preview..g`) to **GitHub Packages only** — never nuget.org, never a GitHub Release. `main` is the sole continuous prerelease lane (per [ADR-0008](../adr/0008-collapse-experimental-into-main.md), which collapsed the former `experimental`/`-alpha` lane into `main`). It does not cause nuget.org Dependabot fan-out into consumer repos (GitHub Packages is opt-in for consumers — the reason this lane is non-publishing to nuget.org per ADR-0001/0002). Implemented in `.github/workflows/publish-packages-preview.yml` (the former `experimental.yml` is deleted).
### Why nuget.org stays opt-in
diff --git a/docs/architecture.md b/docs/architecture.md
index cc4a7e466..355c64c7a 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -81,12 +81,13 @@ This file covers *layout*. For how the build orchestrator works inside — the s
| Workflow | When it runs | What it does |
|---|---|---|
-| `ubuntu-latest.yml` | Every PR targeting `main` (with `paths-ignore` for docs/.assets/markdown) | Test + Pack. Fast Linux feedback loop. |
-| `windows-latest.yml` | Push to `main` only | Test + Pack on Windows after merge. |
-| `macos-latest.yml` | Push to `main` only | Test + Pack on macOS after merge. |
-| `release.yml` | Push to `main` | Test + Pack + Publish to GitHub Packages. |
+| `build.yml` (generated) | Every PR targeting `main`, `release/*`, or `support/*` (with `paths-ignore` for docs/.assets/markdown) | Test + Pack on Linux. Fast feedback loop; the job `ubuntu-latest` is the only required status check. |
+| `build-docs.yml` | Docs-only PRs to the same branches | No-op that reports the `ubuntu-latest` check so docs-only PRs aren't blocked. |
+| `build-cross-platform.yml` (generated) | PRs targeting `release/*` / `support/*`, and `v*` tag pushes | Test + Pack on macOS **and** Windows (one job each). Gated to release intent. |
+| `publish-packages-preview.yml` | Push to `main` | Test + Pack + publish `-preview` to GitHub Packages only. |
+| `publish-packages-release.yml` | `v*` tag push on a production branch (or `workflow_dispatch`) | Test + Pack + publish to GitHub Packages + GitHub Releases (nuget.org opt-in). |
-Linux runs on PRs because it's cheap and fast; Windows and macOS are reserved for post-merge validation. If either breaks post-merge, it surfaces as a red commit on `main` and we fix forward.
+Linux runs on every PR because it's cheap and fast; macOS and Windows are gated to release intent (release/support PRs and release tags) to save CI minutes. On `main` the Linux gate plus the preview pipeline are the edge; if cross-platform breaks it surfaces on a release PR or tag and we fix before shipping.
## What this doc deliberately does NOT cover
diff --git a/docs/branching-and-release.md b/docs/branching-and-release.md
index 2ad752093..ab92a5a2f 100644
--- a/docs/branching-and-release.md
+++ b/docs/branching-and-release.md
@@ -36,7 +36,7 @@ Releases fire to multiple channels, each with its own GitHub Environment:
| `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:** `main` (preview) publishes to GitHub Packages only — **never nuget.org, never a GH Release**. `preview.yml` (main → `-preview`) is the only continuous publisher; the former `experimental.yml` workflow has been deleted ([ADR-0008](adr/0008-collapse-experimental-into-main.md)). 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](https://github.com/Fallout-build/Fallout/issues/267#issuecomment-4570408325) and [ADR-0004](adr/0004-calendar-versioning-and-dual-pace-channels.md).
+**Defaults:** `main` (preview) publishes to GitHub Packages only — **never nuget.org, never a GH Release**. `publish-packages-preview.yml` (main → `-preview`) is the only continuous publisher; the former `experimental.yml` workflow has been deleted ([ADR-0008](adr/0008-collapse-experimental-into-main.md)). 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](https://github.com/Fallout-build/Fallout/issues/267#issuecomment-4570408325) and [ADR-0004](adr/0004-calendar-versioning-and-dual-pace-channels.md).
## Cutting a release
@@ -60,7 +60,7 @@ gh release create v2026.1.X \
--generate-notes
```
-That tag push triggers `.github/workflows/release.yml`:
+That tag push triggers `.github/workflows/publish-packages-release.yml`:
1. **`validate-ref`** confirms the tag points at a commit reachable from a production branch (`release/YYYY` or `support/*`).
2. **`test-and-pack`** runs `dotnet fallout Test Pack`, uploads `output/packages/*.nupkg` as an artifact.
@@ -75,11 +75,11 @@ When a `release/2026` release is stabilised enough for nuget.org, or for cutting
```bash
# Option A: via gh CLI
-gh workflow run release.yml \
+gh workflow run publish-packages-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
+# Option B: via Actions UI → publish-packages-release → "Run workflow" → set publish-to-nugetorg to true
```
The workflow:
@@ -98,10 +98,10 @@ Each `dotnet nuget push` uses `--skip-duplicate`. Re-running a publish job is id
```bash
# Routine re-run — leave publish-to-nugetorg false
-gh workflow run release.yml -f tag=v2026.1.X
+gh workflow run publish-packages-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
+gh workflow run publish-packages-release.yml -f tag=v2026.1.X -f publish-to-nugetorg=true
```
## Promotion and hotfixing