diff --git a/.github/workflows/canary.yml b/.github/workflows/canary.yml
new file mode 100644
index 0000000..3dda2ce
--- /dev/null
+++ b/.github/workflows/canary.yml
@@ -0,0 +1,102 @@
+name: canary
+
+# Early-warning that the documentation tooling still runs on the NEXT, not-yet-released .NET.
+#
+# The shipped tooling targets net8.0 and reaches every newer runtime purely by roll-forward (the worker uses
+# LatestMajor; see maintainers/adr/0002-floor-the-tooling-runtime.md). A runtime breaking change in a future
+# major would surface here first — on a schedule, against the current preview — instead of in a user's CI the
+# day that major ships. It is the upper-end counterpart of the `floor` job in ci.yml, which pins the lower end.
+#
+# Deliberately NOT a pull-request gate: preview runtimes are unstable and sometimes not published yet, and their
+# breakage is not this repo's bug. It runs on a schedule (and on demand); a real regression turns the scheduled
+# run red and GitHub notifies the maintainer, while an unavailable preview ends the run neutral.
+
+on:
+ schedule:
+ # Weekly, Monday 06:00 UTC. Preview builds drop roughly monthly, so weekly catches a new one within days
+ # without adding noise. Scheduled workflows run from the default branch only.
+ - cron: '0 6 * * 1'
+ workflow_dispatch:
+
+concurrency:
+ group: canary-${{ github.ref }}
+ cancel-in-progress: true
+
+# Least privilege: checkout + build only.
+permissions:
+ contents: read
+
+env:
+ DOTNET_NOLOGO: 'true'
+ DOTNET_CLI_TELEMETRY_OPTOUT: 'true'
+ DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 'true'
+
+jobs:
+ preview:
+ name: Documentation tooling on the next .NET preview
+ runs-on: ubuntu-latest
+ # Build (~20s) plus one doc-generation run; cap a hung run like the ci jobs.
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+
+ # Best effort: a preview for the next major may not be published yet, or may fail to install. Marking this
+ # step continue-on-error keeps that from reddening the canary — the build/run steps below are gated on its
+ # outcome and simply skip, leaving the job neutral. Bump the major (11.0.x -> 12.0.x -> ...) once the
+ # current preview reaches GA; see ADR 0002.
+ - name: Setup the next .NET preview (best effort)
+ id: preview
+ continue-on-error: true
+ uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ with:
+ dotnet-version: '11.0.x'
+ dotnet-quality: preview
+
+ # The build SDK. net8.0 is only a target framework; global.json pins the .NET 10 SDK for the build itself.
+ - name: Setup the build SDK (.NET 10)
+ uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ with:
+ dotnet-version: '10.0.x'
+
+ - name: No preview available — skip (not a failure)
+ if: steps.preview.outcome != 'success'
+ run: echo "::notice::no .NET preview was available to canary against; skipping this run"
+
+ # The fce tool, its GenDoc worker (copied next to fce by the CLI build target) and a net8 build of the Usage
+ # sample as a real target to document.
+ - name: Build the net8 tooling and a net8 target
+ if: steps.preview.outcome == 'success'
+ run: |
+ dotnet build FirstClassErrors.Cli/FirstClassErrors.Cli.csproj -c Release
+ dotnet build FirstClassErrors.Usage/FirstClassErrors.Usage.csproj -c Release -f net8.0
+
+ # Force the net8 tooling onto the newest installed major, INCLUDING the prerelease preview:
+ # * DOTNET_ROLL_FORWARD=LatestMajor overrides the CLI's Major (and, redundantly, the worker's LatestMajor)
+ # so both processes select the highest installed major rather than staying on a stable one;
+ # * DOTNET_ROLL_FORWARD_TO_PRERELEASE=1 lets a stable net8 app bind a *prerelease* runtime, which
+ # roll-forward refuses by default.
+ # A guard first confirms a newer major than the build SDK is actually installed, so the canary never
+ # false-passes by silently running on .NET 10. A regression in the preview runtime's roll-forward behaviour
+ # then turns this step red, and the scheduled run notifies the maintainer.
+ - name: Generate documentation on the preview runtime
+ if: steps.preview.outcome == 'success'
+ env:
+ DOTNET_ROLL_FORWARD: LatestMajor
+ DOTNET_ROLL_FORWARD_TO_PRERELEASE: '1'
+ run: |
+ set -euo pipefail
+ if ! dotnet --list-runtimes | grep -Eq 'Microsoft\.NETCore\.App (1[1-9]|[2-9][0-9])\.'; then
+ echo "::notice::the installed 'preview' is not a newer major than the build SDK; skipping this run"
+ exit 0
+ fi
+ dotnet FirstClassErrors.Cli/bin/Release/net8.0/fce.dll generate \
+ --assemblies FirstClassErrors.Usage/bin/Release/net8.0/FirstClassErrors.Usage.dll \
+ --format json --verbose > canary-catalog.json
+ # Positive proof, not just exit 0: the worker actually loaded the target and extracted documented errors.
+ if ! grep -q '"code"' canary-catalog.json; then
+ echo "::error::the net8 tooling failed to document a target on the .NET preview runtime"
+ cat canary-catalog.json
+ exit 1
+ fi
+ echo "ok: the net8 fce and worker documented a net8 target on the .NET preview runtime"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aba916c..3d2baf6 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -69,3 +69,58 @@ jobs:
name: coverage-${{ matrix.os }}
path: artifacts/coverage/**/coverage.opencover.xml
if-no-files-found: error
+
+ # build-test above runs the whole suite on the LATEST .NET (10). This job proves the other end of the
+ # supported range: that the fce tool and its worker, which ship targeting net8.0 (the floor — see
+ # maintainers/adr/0002-floor-the-tooling-runtime.md), actually run on the .NET 8 RUNTIME. The net8 TFM
+ # already guards the compile-time API surface on every build; what it cannot show is that the tooling
+ # executes on its advertised minimum runtime, which is what this job checks against the real artifacts.
+ floor:
+ name: Documentation tooling on the .NET 8 floor
+ runs-on: ubuntu-latest
+ # Build (~20s) plus one doc-generation run; cap a hung run like the build-test job.
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
+
+ # Two SDKs on purpose: 10.0.x satisfies the repo global.json (which pins the .NET 10 SDK) so the build
+ # runs normally, and 8.0.x brings the .NET 8 runtime — the floor the tooling ships against — so the run
+ # step below can execute the net8 binaries on it.
+ - name: Setup .NET (floor runtime + build SDK)
+ uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ # The fce tool, its GenDoc worker (copied next to fce by the CLI build target) and a net8 build of the
+ # Usage sample as a real target to document. Built with the .NET 10 SDK — net8.0 is only a target here.
+ - name: Build the net8 tooling and a net8 target
+ run: |
+ dotnet build FirstClassErrors.Cli/FirstClassErrors.Cli.csproj -c Release
+ dotnet build FirstClassErrors.Usage/FirstClassErrors.Usage.csproj -c Release -f net8.0
+
+ # Run the net8 fce + worker on the .NET 8 RUNTIME (not rolled forward to the newer one the runner also
+ # carries) and document a net8 assembly end to end.
+ #
+ # DOTNET_ROLL_FORWARD=LatestPatch overrides the roll-forward baked into each runtimeconfig (Major on the
+ # CLI, LatestMajor on the worker): the environment variable wins over runtimeconfig, and LatestPatch stays
+ # within the requested major, so both processes bind the highest .NET 8 patch present and can never roll
+ # onto .NET 10. If .NET 8 were absent the host would fail to start — so a green step means the net8 tooling
+ # genuinely ran on the .NET 8 runtime (the worker loads the net8 target via Assembly.LoadFrom).
+ - name: Generate documentation on the .NET 8 runtime
+ env:
+ DOTNET_ROLL_FORWARD: LatestPatch
+ run: |
+ set -euo pipefail
+ dotnet FirstClassErrors.Cli/bin/Release/net8.0/fce.dll generate \
+ --assemblies FirstClassErrors.Usage/bin/Release/net8.0/FirstClassErrors.Usage.dll \
+ --format json --verbose > floor-catalog.json
+ # Positive proof, not just exit 0: the worker actually loaded the target and extracted documented errors.
+ if ! grep -q '"code"' floor-catalog.json; then
+ echo "::error::no documented errors were extracted on the .NET 8 floor"
+ cat floor-catalog.json
+ exit 1
+ fi
+ echo "ok: the net8 fce and worker documented a net8 target on the .NET 8 runtime"
diff --git a/FirstClassErrors.Cli/FirstClassErrors.Cli.csproj b/FirstClassErrors.Cli/FirstClassErrors.Cli.csproj
index 213f3ec..4fcabc5 100644
--- a/FirstClassErrors.Cli/FirstClassErrors.Cli.csproj
+++ b/FirstClassErrors.Cli/FirstClassErrors.Cli.csproj
@@ -2,9 +2,17 @@
Exe
- net10.0
+
+ net8.0
+ Major
enable
enable
+
+ latest
fce
diff --git a/FirstClassErrors.Cli/README.nuget.md b/FirstClassErrors.Cli/README.nuget.md
index 978d123..9a86794 100644
--- a/FirstClassErrors.Cli/README.nuget.md
+++ b/FirstClassErrors.Cli/README.nuget.md
@@ -12,7 +12,9 @@ artifact and always matches the deployed system, with no manual upkeep.
dotnet tool install --global FirstClassErrors.Cli
-This installs the `fce` command and requires the .NET 10 runtime. Use `--global` for a
+This installs the `fce` command and requires the **.NET 8 runtime or newer** — `fce` targets
+.NET 8 and rolls forward onto any later runtime, so a machine that has only .NET 10 (or a
+future major) runs it just as well. Use `--global` for a
machine-wide tool, or install it into a
[tool manifest](https://learn.microsoft.com/dotnet/core/tools/local-tools-how-to-use)
for a version-pinned, per-repository tool.
diff --git a/FirstClassErrors.GenDoc.Worker/FirstClassErrors.GenDoc.Worker.csproj b/FirstClassErrors.GenDoc.Worker/FirstClassErrors.GenDoc.Worker.csproj
index 9078d6b..7e59c88 100644
--- a/FirstClassErrors.GenDoc.Worker/FirstClassErrors.GenDoc.Worker.csproj
+++ b/FirstClassErrors.GenDoc.Worker/FirstClassErrors.GenDoc.Worker.csproj
@@ -2,13 +2,20 @@
Exe
- net10.0
+ net8.0
enable
enable
+
+ latest
false
-
- Major
+
+ LatestMajor
diff --git a/FirstClassErrors.GenDoc/FirstClassErrors.GenDoc.csproj b/FirstClassErrors.GenDoc/FirstClassErrors.GenDoc.csproj
index 21d48eb..087a873 100644
--- a/FirstClassErrors.GenDoc/FirstClassErrors.GenDoc.csproj
+++ b/FirstClassErrors.GenDoc/FirstClassErrors.GenDoc.csproj
@@ -1,9 +1,13 @@
- net10.0
+
+ net8.0
enable
enable
+
+ latest
diff --git a/FirstClassErrors.Usage/FirstClassErrors.Usage.csproj b/FirstClassErrors.Usage/FirstClassErrors.Usage.csproj
index c665238..3f991b1 100644
--- a/FirstClassErrors.Usage/FirstClassErrors.Usage.csproj
+++ b/FirstClassErrors.Usage/FirstClassErrors.Usage.csproj
@@ -1,7 +1,10 @@
- net10.0
+
+ net8.0;net10.0
enable
enable
diff --git a/maintainers/adr/0002-floor-the-tooling-runtime.md b/maintainers/adr/0002-floor-the-tooling-runtime.md
new file mode 100644
index 0000000..d043531
--- /dev/null
+++ b/maintainers/adr/0002-floor-the-tooling-runtime.md
@@ -0,0 +1,161 @@
+# 2. Floor the tooling runtime at the oldest supported LTS
+
+- **Status:** Accepted
+- **Deciders:** Reefact
+- **Related:** ADR 0001 (the analyzer's Roslyn floor — the build-time sibling of this runtime-time decision)
+
+## Context
+
+FirstClassErrors ships two very different kinds of artifact:
+
+- the **library** (`FirstClassErrors`, `FirstClassErrors.Testing`) targets
+ **`netstandard2.0`**. A netstandard library is consumed by *any* runtime that
+ implements the standard — .NET Framework 4.6.1+, .NET Core 2.0+, .NET 5–10+,
+ Mono/Unity — so the "runs almost everywhere" question is already answered, once,
+ by the TFM, and needs nothing here.
+- the **tooling** (`FirstClassErrors.Cli` — the `fce` .NET tool — plus
+ `FirstClassErrors.GenDoc` and `FirstClassErrors.GenDoc.Worker`, which it loads
+ in-process and spawns as a child) is a **runnable framework-dependent app**. Its
+ TFM is a **hard minimum**: a framework-dependent app can never run on a runtime
+ **older** than its TFM, and **roll-forward only ever goes up, never down**.
+
+The tooling TFM therefore decides *which consumers can run `fce` at all*. It was
+`net10.0`, which meant a shop whose newest installed runtime is .NET 8 could
+reference the library but **could not run the documentation generator** — even
+though the library it documents is `netstandard2.0`.
+
+There is a second, subtler constraint. The worker loads the **target** assembly
+via `Assembly.LoadFrom` (see `FirstClassErrors.GenDoc.Worker/Program.cs`). That
+target can be built for any runtime the consumer chose, so the worker **process**
+must run on a runtime `>=` the target's. This is a *roll-forward* problem, not a
+*TFM-count* problem, and the two are easy to conflate.
+
+## Decision
+
+**Single-target the tooling at `net8.0`** — the oldest .NET still in Microsoft
+support, and the **same floor as the analyzer** (ADR 0001 pins Roslyn 4.8 == the
+.NET 8.0.100 SDK). The product now states **one** support number: *FirstClassErrors
+supports .NET 8 and up for its tooling and its analyzer; the library itself is
+`netstandard2.0` and runs down to .NET Framework 4.6.1.*
+
+Cover every **newer** runtime with **roll-forward**, not a target matrix:
+
+| Project | `RollForward` | Why |
+|---|---|---|
+| `FirstClassErrors.Cli` (`fce`) | `Major` | The front-end only needs to *run*. `Major` rolls the net8 build up to the next major when .NET 8 is absent, so a machine that has only .NET 10 runs it (rolls 8→10). Without it, the default `Minor` policy never crosses a major and `fce` would fail to start on the common "newer .NET, no .NET 8" machine. |
+| `FirstClassErrors.GenDoc.Worker` | `LatestMajor` | The worker must **out-rank the target it loads**. `Major` only rolls up when the requested major is *absent*, so on a machine carrying **both** .NET 8 and .NET 10 a net8 worker would bind to 8 and fail to load a net10 target. `LatestMajor` always binds the **highest installed** major, so the worker can document a target built for any runtime present. |
+| `FirstClassErrors.GenDoc` | — | Loaded in-process by `fce`; the runtime is chosen by the CLI's runtimeconfig, so a library sets no policy. |
+
+Keep `latest` on the three projects so the net8 floor
+bounds only the **BCL surface and target runtime**, not the C# the source may use
+(the `netstandard2.0` projects already do exactly this).
+
+### Why not multi-target (`net8.0;net10.0`)?
+
+- Roll-forward already lets a single `net8.0` build run on 8 / 9 / 10 / 11+, so a
+ second TFM buys reach we already have.
+- A documentation generator has no need for `net10`-only BCL APIs.
+- A matrix puts the tooling on a per-release "add at the top, drop at the bottom"
+ cadence, and **re-introduces the worker trap**: the low build in the matrix is
+ exactly the one that cannot load a higher-TFM target.
+
+One floor build + the two roll-forward settings above is strictly simpler and has
+the same reach.
+
+### Two guards: the TFM at build time, a CI job at run time
+
+The floor is guarded on both axes it can regress on:
+
+- **API surface, at build time — the TFM itself.** Because the projects *target*
+ `net8.0`, every CI build (on the .NET 10 SDK) compiles them against the **net8
+ reference pack**, so a `net10`-only API cannot slip in silently — it breaks the
+ ordinary build. No dedicated job is needed for this, unlike the analyzer's Roslyn
+ floor, which is invisible on a modern CI and needs `tools/floor-check` (ADR 0001).
+- **Runtime execution, at run time — the `floor` job in `ci.yml`.** *Documentation
+ tooling on the .NET 8 floor* builds the net8 tooling and a net8 build of the
+ `Usage` sample, then runs `fce generate` against it with
+ `DOTNET_ROLL_FORWARD=LatestPatch` in the environment. That override wins over each
+ runtimeconfig's baked-in policy (`Major` / `LatestMajor`) and stays within the
+ requested major, so both the CLI and the worker bind the highest **.NET 8** patch
+ present and can never roll onto the newer runtime the runner also carries. A green
+ step therefore means the shipped net8 tooling genuinely executed on the .NET 8
+ runtime; were .NET 8 absent, the host would fail to start. This is the runtime
+ counterpart of the analyzer floor-check, and it is why `Usage` is multi-targeted
+ `net8.0;net10.0` — the net8 build gives the job a real target to document.
+
+The one surface neither guard covers is roll-forward onto a **not-yet-released**
+major. That is the **`canary.yml`** workflow: on a weekly schedule (and on demand)
+it installs the next .NET **preview**, then runs the net8 tooling on it with
+`DOTNET_ROLL_FORWARD=LatestMajor` and `DOTNET_ROLL_FORWARD_TO_PRERELEASE=1` so both
+processes bind that prerelease major. It is deliberately **not** a pull-request gate
+— a preview is often unpublished or unstable, and that is not this repo's bug — so a
+missing preview ends the run neutral, while a genuine roll-forward regression turns
+the scheduled run red and notifies the maintainer before that major ships.
+
+## Consequences
+
+**Positive**
+
+- Any consumer on **.NET 8 or newer** can run `fce`, not just those on the latest
+ runtime.
+- **One** shipped tooling artifact; no per-release TFM matrix to maintain.
+- The tooling floor and the analyzer floor state the **same** minimum (.NET 8),
+ so the support story is a single sentence.
+- Verified end to end: a `net8.0` `fce` documents a **`net10`** target assembly on
+ a machine that has **only** the .NET 10 runtime — `fce` rolls 8→10 (`Major`) and
+ the worker binds the highest major (`LatestMajor`) to load the net10 target.
+- Guarded in CI across the whole range: `build-test` runs the suite on the latest
+ released .NET (10); the `floor` job runs the shipped tooling on the .NET 8 runtime;
+ and `canary.yml` runs it on the next .NET preview (see the two-guards section).
+
+**Negative / accepted costs**
+
+- `fce` cannot run on a machine whose newest runtime predates .NET 8 (e.g. an
+ EOL .NET 6/7, or .NET-Framework-only). Accepted: those consumers still **use**
+ the `netstandard2.0` library in their app; running a dev/CI *tool* on a
+ currently-supported runtime is a reasonable prerequisite (a modern .NET SDK is
+ already present wherever modern .NET is built).
+- `LatestMajor` on the worker will, on a box that has a **preview** of the next
+ major installed, bind that preview. This is only a risk for machines that opt
+ into previews, and `canary.yml` is exactly the early-warning that this binding
+ still works before that major ships.
+
+## Do I have to revise code at each new .NET release, or at each EOL?
+
+Almost never — this design is chosen precisely to avoid a per-release treadmill:
+
+- **A new .NET release (net11, net12, …):** nothing to do. Roll-forward runs the
+ existing `net8.0` binaries on it — **no rebuild, no code change, no re-release**.
+- **A runtime *above* the floor reaching EOL:** nothing to do; we do not target it.
+- **The floor LTS itself reaching EOL** (`.NET 8` → **2026-11-10**): bump the floor
+ **one line per project** (`net8.0` → `net10.0`), **no logic change**. Even this is
+ optional for *function* — roll-forward keeps the net8 build working on newer
+ runtimes after EOL — it is **hygiene** (stop advertising an unpatched floor), on a
+ roughly **biennial** cadence.
+
+So: no code churn from .NET version movement; at most a one-line TFM bump about
+once every two years.
+
+## How to raise the floor (when .NET 8 goes EOL)
+
+1. Change `` from `net8.0` to the new floor in the three tooling
+ csprojs: `FirstClassErrors.Cli`, `FirstClassErrors.GenDoc`,
+ `FirstClassErrors.GenDoc.Worker`. Leave the `RollForward` settings as they are.
+2. Bump the new floor in the `Usage` sample's `` (so the CI floor
+ job still has a target on the new floor) and in the `floor` job of `ci.yml`
+ (`dotnet-version` `8.0.x` → the new floor's runtime).
+3. Update the runtime note in `FirstClassErrors.Cli/README.nuget.md`.
+4. Update this ADR (new floor, new minimum runtime).
+5. Optionally drop the `latest` overrides if the new
+ floor's default C# is already the version you want.
+
+Keeping this in step with the analyzer floor (ADR 0001) keeps the product's single
+".NET N and up" support statement true.
+
+## Maintaining the canary
+
+`canary.yml` pins the preview major it targets (`dotnet-version: 11.0.x`, quality
+`preview`). Once that major reaches GA, bump it to the next one (`12.0.x`, …) so the
+canary keeps looking one release ahead. Nothing breaks if you forget: `build-test`
+picks up the newly released major as "latest", and the canary simply stops finding a
+newer-than-build-SDK preview and ends its runs neutral until bumped.