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.