From a91a9e43d9a9be39e99515195e5ee71fce4b2c2a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:41:27 +0000 Subject: [PATCH 1/7] build(cli): package fce as a .NET global tool Make FirstClassErrors.Cli publishable as a .NET tool (PackAsTool, ToolCommandName "fce", PackageId FirstClassErrors.Cli) so the error-documentation generator can be installed with `dotnet tool install` and run in CI, instead of requiring consumers to build it from source. The GenDoc worker the tool spawns as a separate process is bundled inside the tool package (tools//any) by the existing copy targets and resolved at runtime from AppContext.BaseDirectory; a packed tool was installed from a local feed and `fce generate` produced a catalog end to end. The package embeds the same SPDX SBOM as the two library packages, so pack.sh packs three projects now and its SBOM-present assertion covers the tool too; the release.yml comment is updated to match. No symbol package is produced for the tool (IncludeSymbols overridden): a .snupkg serves referenced libraries, not an executed tool. Publishing requires a nuget.org trusted-publishing policy for the new package id FirstClassErrors.Cli, as already required per published package. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JejR9K9ABeQ8LqB5fwGeM2 --- .github/workflows/release.yml | 7 ++- .../FirstClassErrors.Cli.csproj | 61 +++++++++++++++++++ FirstClassErrors.Cli/README.nuget.md | 54 ++++++++++++++++ tools/packaging/pack.sh | 7 ++- 4 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 FirstClassErrors.Cli/README.nuget.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 740d905..423009c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -103,9 +103,10 @@ jobs: - name: Test run: dotnet test FirstClassErrors.sln -c Release --no-build --logger "console;verbosity=normal" - # Only the two projects that carry NuGet identity are packed. The analyzer ships - # bundled inside the main package, and the CLI / worker / samples are not published. - # GenerateSBOM activates Microsoft.Sbom.Targets (referenced by both packable projects): each + # The three projects that carry NuGet identity are packed: the library, its testing helpers, and + # the `fce` CLI (shipped as a .NET tool — the analyzer is bundled inside the main package and the + # GenDoc worker travels bundled inside the CLI tool; the samples are not published). + # GenerateSBOM activates Microsoft.Sbom.Targets (referenced by all three packable projects): each # package embeds its SPDX inventory at _manifest/spdx_2.2/manifest.spdx.json. Deliberately # tools/packaging/pack.sh is the single source of truth for producing the packages (packed projects, # flags, embedded SBOM, and the SBOM-present check). The release-dryrun workflow calls the same script, diff --git a/FirstClassErrors.Cli/FirstClassErrors.Cli.csproj b/FirstClassErrors.Cli/FirstClassErrors.Cli.csproj index 34af61c..d1ac920 100644 --- a/FirstClassErrors.Cli/FirstClassErrors.Cli.csproj +++ b/FirstClassErrors.Cli/FirstClassErrors.Cli.csproj @@ -9,10 +9,71 @@ fce + + + true + fce + + + false + + + FirstClassErrors.Cli + Sylvain AURAT + Reefact + + + 0.1.0-dev + + + + fce is the command-line error-documentation generator for FirstClassErrors. It scans a solution or built assemblies for errors documented in code and renders the catalog as Markdown, HTML or JSON — designed to run in CI so error documentation is published as a build artifact and never drifts from the deployed system. + + + + error-handling;errors;documentation;living-documentation;cli;dotnet-tool;fce;ci-cd;firstclasserrors + Apache-2.0 + false + + + https://github.com/Reefact/first-class-errors + https://github.com/Reefact/first-class-errors.git + git + + + icon.png + readme.md + Initial preview release of the fce documentation generator. + + © Reefact 2026 + + + + + + + + + + + + diff --git a/FirstClassErrors.Cli/README.nuget.md b/FirstClassErrors.Cli/README.nuget.md new file mode 100644 index 0000000..7229723 --- /dev/null +++ b/FirstClassErrors.Cli/README.nuget.md @@ -0,0 +1,54 @@ +# FirstClassErrors.Cli (`fce`) + +`fce` is the command-line **documentation generator** for +[FirstClassErrors](https://www.nuget.org/packages/FirstClassErrors). It scans a +solution — or already-built assemblies — for the errors you documented in code and +renders the whole catalog as **Markdown, HTML, or JSON**. + +It exists for CI/CD: run it as a build step so your error catalog is published as an +artifact and always matches the deployed system, with no manual upkeep. + +## Install + + dotnet tool install --global FirstClassErrors.Cli + +This installs the `fce` command and requires the .NET 10 runtime. 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. + +## Use + +Document a solution and write a Markdown catalog: + + fce generate --solution MyApp.sln --output docs/errors.md --format markdown --service-name my-api + +Only projects whose `.csproj` sets +`true` are scanned. The +`--service-name` option is required for the `markdown` and `html` formats — it forms the +RFC 9457 problem `type` of rendered examples (`urn:problem:{service}:{code}`); the `json` +format does not need it. + +Other commands: + +- `fce config init` — create an `fce.json` so options need not be repeated on every run +- `fce config show` — print the resolved configuration +- `fce renderer add|list|remove` — manage custom renderer libraries + +Run `fce --help` or `fce generate --help` for the full option list. + +## In CI + + dotnet tool install --global FirstClassErrors.Cli + dotnet build MyApp.sln -c Release + fce generate --solution MyApp.sln --no-build \ + --output artifacts/errors.md --format markdown --service-name my-api + # then publish artifacts/errors.md as a pipeline artifact or to a docs portal + +Emit one catalog per locale by adding `--language` (e.g. a matrix over `en`, `fr`); file +names and anchors stay stable across languages. + +## Documentation + +Full CI/CD integration guide and the rest of the documentation on GitHub: +https://github.com/Reefact/first-class-errors diff --git a/tools/packaging/pack.sh b/tools/packaging/pack.sh index 967eac9..d248577 100755 --- a/tools/packaging/pack.sh +++ b/tools/packaging/pack.sh @@ -22,12 +22,15 @@ if [ "$#" -ne 1 ] || [ -z "$1" ]; then fi version="$1" -# The two projects that carry NuGet identity. GenerateSBOM embeds an SPDX SBOM +# The three projects that carry NuGet identity: the library, its testing helpers, and the +# `fce` CLI (packed as a .NET tool via PackAsTool; the GenDoc worker it spawns travels bundled +# inside that tool package). GenerateSBOM embeds an SPDX SBOM # (_manifest/spdx_2.2/manifest.spdx.json) inside each package; it is passed here, # not hardcoded in the csproj, so local and floor-check packs stay SBOM-free. for project in \ FirstClassErrors/FirstClassErrors.csproj \ - FirstClassErrors.Testing/FirstClassErrors.Testing.csproj + FirstClassErrors.Testing/FirstClassErrors.Testing.csproj \ + FirstClassErrors.Cli/FirstClassErrors.Cli.csproj do dotnet pack "$project" -c Release --no-build -p:Version="$version" -p:GenerateSBOM=true -o artifacts done From 24ee451f818d80c0c74ae91772412365b9e2168a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:41:36 +0000 Subject: [PATCH 2/7] docs(core): rework the NuGet README to surface key features The NuGet README led with the concept but understated what the package concretely delivers. Surface, honestly and without hype, the features that sell it: errors as values or exceptions, the two-audience message model that cannot leak internals (mapped onto RFC 9457), the 16 bundled Roslyn analyzers, zero runtime dependencies on .NET Standard 2.0, and the companion Testing package. Give the CI/CD documentation generation its own section now that the generator ships as the installable `fce` tool, with a concrete install-build-generate snippet so the "documentation as a build artifact" story is turnkey rather than a single passing mention. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JejR9K9ABeQ8LqB5fwGeM2 --- FirstClassErrors/README.nuget.md | 105 ++++++++++++++++++++++--------- 1 file changed, 76 insertions(+), 29 deletions(-) diff --git a/FirstClassErrors/README.nuget.md b/FirstClassErrors/README.nuget.md index 55dbbc4..800c3b5 100644 --- a/FirstClassErrors/README.nuget.md +++ b/FirstClassErrors/README.nuget.md @@ -1,46 +1,93 @@ # FirstClassErrors -**FirstClassErrors** is a lightweight .NET library that turns exceptions into **structured, documented, and diagnosable errors**. +**Treat errors as first-class, documented, and diagnosable concepts — carried as +values or thrown as exceptions — so failures stay explicit and easy to support in +production.** + +Define each error once, in code: a stable code, the messages its two audiences are +allowed to see, its diagnostics, and its documentation. That single definition is +checked at build time and can be extracted into a living error catalog. + +## What you get + +- **Errors as values *or* exceptions.** Carry an error as data with `Outcome` / + `Outcome`, or throw it with `.ToException()` — same model both ways. Pick per + context (domain logic, validation, pipelines) instead of committing everything to + one style. +- **A message model that can't leak internals.** Each error separates a public + `ShortMessage` (+ optional public `DetailedMessage`) from an internal + `DiagnosticMessage`. The split is enforced by construction, so support/developer + detail never reaches an API client by accident. Maps onto RFC 9457 problem details + (`Code` → `type`, `ShortMessage` → `title`, `DetailedMessage` → `detail`). +- **Structured diagnostics.** Each error can declare its likely causes and where to + start investigating — guidance that travels with the error instead of living in + someone's head. +- **Living documentation, generated from code.** Document errors with a small fluent + DSL, then extract the whole catalog as **Markdown / HTML / JSON** (custom renderers, + opt-in per project, multi-language) with the companion `fce` tool. Run it in CI so + the catalog is published as a build artifact and never drifts from the deployed + system. See *Generate the catalog in CI* below. +- **16 Roslyn analyzers in the box (`FCE001`–`FCE016`).** Bundled in the package — no + separate install. They catch, at build time, what would otherwise surface late: + duplicate error codes, unresolved `[DocumentedBy]` references, documented errors + missing from the catalog, an unused `ToException()` result, and more. +- **Zero runtime dependencies, .NET Standard 2.0.** Runs on .NET Framework 4.6.1+, + .NET Core 2.0+, .NET 5+ (and Mono / Xamarin / Unity). Nothing added to your + dependency graph. -Instead of throwing ad-hoc messages, the library helps you define errors as **explicit concepts with diagnostics and documentation directly in code**. +## Example -It is especially useful for systems where **supportability, troubleshooting, and operational clarity matter**. + [ProvidesErrorsFor(nameof(Temperature))] + public static class InvalidTemperatureError { -## Key ideas + [DocumentedBy(nameof(BelowAbsoluteZeroDocumentation))] + internal static DomainError BelowAbsoluteZero(decimal value, TemperatureUnit unit) => + DomainError.Create( + Code.TemperatureBelowAbsoluteZero, + diagnosticMessage: $"Failed to instantiate temperature: {value} {unit} is below absolute zero.") + .WithPublicMessage( + shortMessage: "Temperature is invalid.", + detailedMessage: $"The temperature {value} {unit} is below absolute zero."); -FirstClassErrors allows you to: + private static ErrorDocumentation BelowAbsoluteZeroDocumentation() => + DescribeError.WithTitle("Temperature below absolute zero") + .WithDescription("Occurs when instantiating a temperature below absolute zero.") + .WithRule("Absolute zero is the point of minimum possible energy; nothing goes below it.") + .WithDiagnostics(ValueObjectDiagnostic.Diagnostic) + .WithExamples(() => BelowAbsoluteZero(-1, TemperatureUnit.Kelvin)); + } -- define **structured errors with stable error codes** -- attach **diagnostics and investigation leads** -- keep **error documentation close to the code** -- generate **human-readable documentation automatically** +The error, its rule, its diagnostics, and its examples are defined together — and that +same definition is what the analyzers check and the documentation pipeline extracts. -Errors become **documented knowledge about your system**, not just runtime failures. +## Installation -## Example + dotnet add package FirstClassErrors - return DescribeError.WithTitle("Temperature below absolute zero") - .WithDescription("This error occurs when trying to instantiate a temperature with a value that is below absolute zero.") - .WithRule("Temperature cannot go below absolute zero because absolute zero is the point where particles have minimum possible energy.") - .WithDiagnostics(ValueObjectDiagnostic.Diagnostic) - .WithExamples( - () => BelowAbsoluteZero(-1, TemperatureUnit.Kelvin), - () => BelowAbsoluteZero(-280, TemperatureUnit.Celsius)); +Analyzers are included. Loading them requires .NET 8 SDK / Visual Studio 2022 17.8+. +Each release ships with signed build provenance (SLSA) and an embedded SBOM. -This produces **structured documentation tied directly to the error definition**. +## Generate the catalog in CI -## When to use FirstClassErrors +The documentation generator ships as a .NET tool, +[`FirstClassErrors.Cli`](https://www.nuget.org/packages/FirstClassErrors.Cli), so a +pipeline can produce the error catalog as a build artifact: -This library is particularly useful when: + dotnet tool install --global FirstClassErrors.Cli + dotnet build MyApp.sln -c Release + fce generate --solution MyApp.sln --no-build \ + --output artifacts/errors.md --format markdown --service-name my-api -- building **complex business systems** -- designing **domain-driven models** -- improving **error observability** -- supporting **production troubleshooting** -- generating **living documentation from code** +Only projects whose `.csproj` sets +`true` are scanned. -## Documentation +## Companion package -Full documentation and guides are available on GitHub: +**FirstClassErrors.Testing** adds framework-agnostic assertions on `Outcome` / `Error` +(`ShouldSucceed()`, `ShouldFail().WithCode(...)`) plus a freezable clock and instance +ids for deterministic tests. + +## Documentation -https://github.com/Reefact/first-class-errors \ No newline at end of file +Full guides, the analyzer reference, and the CI/CD integration guide on GitHub: +https://github.com/Reefact/first-class-errors From 590b017e02946c3a2dad585c9be0879e347461ad Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 13:41:42 +0000 Subject: [PATCH 3/7] docs(cli): document installing fce as a tool in the CI guide The CI/CD guide told readers to run `fce generate` without saying how to obtain the generator. Now that it ships as the FirstClassErrors.Cli tool, add the `dotnet tool install` step and a concrete install-build-generate snippet, so the pipeline it describes is reproducible. Mirrored in the French translation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JejR9K9ABeQ8LqB5fwGeM2 --- doc/OperationalIntegration.en.md | 19 +++++++++++++++---- doc/OperationalIntegration.fr.md | 19 +++++++++++++++---- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/doc/OperationalIntegration.en.md b/doc/OperationalIntegration.en.md index 4b25367..17739da 100644 --- a/doc/OperationalIntegration.en.md +++ b/doc/OperationalIntegration.en.md @@ -9,13 +9,24 @@ FirstClassErrors reaches its full value when it is integrated into the delivery Error documentation should be generated automatically during CI. -A typical pipeline step: +The `fce` generator is distributed as a .NET tool +([`FirstClassErrors.Cli`](https://www.nuget.org/packages/FirstClassErrors.Cli)), so a +pipeline installs it and runs it as build steps: -1. Build the solution -2. Run the `fce` documentation generator (`fce generate`) -3. Generate the error catalog (Markdown or JSON) +1. Install the generator: `dotnet tool install --global FirstClassErrors.Cli` +2. Build the solution +3. Run the generator (`fce generate`) to produce the error catalog (Markdown, HTML or JSON) 4. Publish it as a pipeline artifact or deploy it to a documentation portal +Concretely: + +```bash +dotnet tool install --global FirstClassErrors.Cli +dotnet build MyApp.sln -c Release +fce generate --solution MyApp.sln --no-build \ + --output artifacts/errors.md --format markdown --service-name my-api +``` + Generation is **opt-in per project**: only projects whose project file (`.csproj`) sets `true` are scanned; a project without it is silently skipped. The marker must sit in the `.csproj` itself — it is read straight from the project XML, so a value inherited from a shared `Directory.Build.props` is not picked up. When not a single project opts in, the generator logs a warning naming the property rather than producing an empty catalog silently. If a fresh pipeline produces an empty catalog, check the opt-in first. See [Opting a project in](ArchitectureOfTheDocumentationPipeline.en.md#opting-a-project-in). This ensures that documentation always matches the version of the system that is deployed. No manual updates are required, and no drift can occur. diff --git a/doc/OperationalIntegration.fr.md b/doc/OperationalIntegration.fr.md index 80ae4cb..17067ac 100644 --- a/doc/OperationalIntegration.fr.md +++ b/doc/OperationalIntegration.fr.md @@ -9,13 +9,24 @@ FirstClassErrors révèle toute sa valeur lorsqu’il est intégré dans la cha La documentation des erreurs doit être générée automatiquement pendant la CI. -Étape typique dans un pipeline : +Le générateur `fce` est distribué comme outil .NET +([`FirstClassErrors.Cli`](https://www.nuget.org/packages/FirstClassErrors.Cli)) : un +pipeline l’installe puis l’exécute comme étapes de build : -1. Compiler la solution -2. Exécuter le générateur de documentation `fce` (`fce generate`) -3. Générer le catalogue d’erreurs (Markdown ou JSON) +1. Installer le générateur : `dotnet tool install --global FirstClassErrors.Cli` +2. Compiler la solution +3. Exécuter le générateur (`fce generate`) pour produire le catalogue d’erreurs (Markdown, HTML ou JSON) 4. Le publier comme artefact du pipeline ou le déployer sur un portail documentaire +Concrètement : + +```bash +dotnet tool install --global FirstClassErrors.Cli +dotnet build MyApp.sln -c Release +fce generate --solution MyApp.sln --no-build \ + --output artifacts/errors.md --format markdown --service-name my-api +``` + La génération est **opt-in par projet** : seuls les projets dont le fichier projet (`.csproj`) définit `true` sont analysés ; un projet sans cette propriété est silencieusement ignoré. Le marqueur doit figurer dans le `.csproj` lui-même — il est lu directement dans le XML du projet, donc une valeur héritée d’un `Directory.Build.props` partagé n’est pas prise en compte. Quand aucun projet n’a opté, le générateur journalise un avertissement nommant la propriété plutôt que de produire un catalogue vide en silence. Si un pipeline neuf produit un catalogue vide, vérifiez d’abord l’opt-in. Voir [Activer un projet (opt-in)](ArchitectureOfTheDocumentationPipeline.fr.md#activer-un-projet-opt-in). Cela garantit que la documentation correspond toujours à la version du système déployée. Aucune mise à jour manuelle n’est nécessaire et aucune dérive ne peut apparaître. From 4375a75b67650e56359aeed29018fdd2cd79465d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 07:10:23 +0000 Subject: [PATCH 4/7] ci: split releases into independent lib and cli trains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Version and release the fce CLI independently of the library. release.yml now triggers on two tag families — lib-v* (FirstClassErrors + FirstClassErrors.Testing, in lockstep) and cli-v* (FirstClassErrors.Cli) — resolves the train and version from the tag prefix (or the workflow_dispatch component input) and packs only that train, so a lib release never republishes the CLI and vice versa. pack.sh takes a lib|cli scope selecting which projects to pack. Release notes are generated per train from Conventional Commit scopes (release-notes.sh: core, analyzers, testing for lib; cli, gendoc for cli), replacing --generate-notes so one train's notes never carry the other's commits; unscoped infrastructure commits are excluded. Checkout fetches full history and tags for that lookup, and the dry-run rehearses both trains' packing and note generation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JejR9K9ABeQ8LqB5fwGeM2 --- .github/workflows/release-dryrun.yml | 21 ++++++-- .github/workflows/release.yml | 80 ++++++++++++++++++++-------- tools/packaging/pack.sh | 45 +++++++++++----- tools/packaging/release-notes.sh | 72 +++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 41 deletions(-) create mode 100755 tools/packaging/release-notes.sh diff --git a/.github/workflows/release-dryrun.yml b/.github/workflows/release-dryrun.yml index f49fec9..c07d2b6 100644 --- a/.github/workflows/release-dryrun.yml +++ b/.github/workflows/release-dryrun.yml @@ -48,6 +48,9 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + # Full history + tags so the release-notes rehearsal below can resolve the previous same-train tag. + fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 @@ -58,9 +61,19 @@ jobs: - name: Build run: dotnet build FirstClassErrors.sln -c Release -p:Version="$DRYRUN_VERSION" - # The SAME script release.yml packs with — it packs the two published projects with their SBOM and - # then asserts the SBOM is actually embedded. Sharing it is the point: this rehearsal cannot drift - # from the real release, because there is only one definition of "pack the release artifacts". + # The SAME script release.yml packs with — it packs each train's projects with their SBOM and then + # asserts the SBOM is actually embedded. Sharing it is the point: this rehearsal cannot drift from the + # real release, because there is only one definition of "pack the release artifacts". Both trains are + # rehearsed so neither packaging path first runs in production. # (The unit/integration tests run in ci.yml; this job's unique contribution is the packaging.) - name: Pack with SBOM (build artifacts, no publish) - run: tools/packaging/pack.sh "$DRYRUN_VERSION" + run: | + tools/packaging/pack.sh "$DRYRUN_VERSION" lib + tools/packaging/pack.sh "$DRYRUN_VERSION" cli + + # Rehearse the train-scoped release-notes generation too (release.yml's other production-only path), + # so a bug in it surfaces here instead of during a real release. Prints to the log; publishes nothing. + - name: Rehearse release notes (both trains, no publish) + run: | + echo "----- lib notes -----"; tools/packaging/release-notes.sh lib HEAD + echo "----- cli notes -----"; tools/packaging/release-notes.sh cli HEAD diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 423009c..75064e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,13 +2,23 @@ name: release on: push: - # Publish on semantic-version tags, e.g. v1.2.3 or v1.2.3-beta.1. + # Publish on a train-prefixed semantic-version tag. The two trains version independently: + # lib-v1.2.3 -> FirstClassErrors + FirstClassErrors.Testing + # cli-v1.2.3 -> FirstClassErrors.Cli (the fce .NET tool) tags: - - 'v*.*.*' + - 'lib-v*.*.*' + - 'cli-v*.*.*' workflow_dispatch: inputs: + component: + description: 'Which release train to pack/publish' + type: choice + options: + - lib + - cli + required: true version: - description: 'Package version, without the leading "v" (e.g. 1.2.3; a dry run can use any SemVer, e.g. 0.0.0-dry.1)' + description: 'Package version, without any prefix (e.g. 1.2.3; a dry run can use any SemVer, e.g. 0.0.0-dry.1)' required: true # release.yml is the only workflow no CI run exercises before a real tag: version resolution, # pack, SBOM generation, OIDC and the attestation permissions only ever execute in production @@ -57,6 +67,10 @@ jobs: steps: - name: Checkout uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + # Full history + tags so tools/packaging/release-notes.sh can resolve the previous + # same-train tag — the lower bound of the release-notes commit range. + fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 @@ -66,23 +80,34 @@ jobs: - name: Resolve version id: version shell: bash - # Read the (attacker-controllable) tag name and manual input via the environment rather than interpolating + # Read the (attacker-controllable) tag name and manual inputs via the environment rather than interpolating # ${{ ... }} straight into the script, and validate the result against a strict SemVer allowlist. A tag such - # as "v1.2.3;id" is a valid Git ref that matches the v*.*.* trigger, so an unchecked value could inject shell - # commands into every step that uses it. + # as "lib-v1.2.3;id" is a valid Git ref that matches the release-tag trigger, so an unchecked value could + # inject shell commands into every step that uses it. env: EVENT_NAME: ${{ github.event_name }} + INPUT_COMPONENT: ${{ github.event.inputs.component }} INPUT_VERSION: ${{ github.event.inputs.version }} REF_NAME: ${{ github.ref_name }} run: | if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + COMPONENT="$INPUT_COMPONENT" VERSION="$INPUT_VERSION" else - # REF_NAME is the tag name (e.g. v1.2.3); strip the leading "v". - VERSION="${REF_NAME#v}" + # REF_NAME is the train-prefixed tag (e.g. lib-v1.2.3 / cli-v1.2.3): pick the train and strip + # its prefix. Anything else is not a release tag. + case "$REF_NAME" in + lib-v*) COMPONENT="lib"; VERSION="${REF_NAME#lib-v}" ;; + cli-v*) COMPONENT="cli"; VERSION="${REF_NAME#cli-v}" ;; + *) echo "::error::Tag '$REF_NAME' is not a release tag; expected lib-v*.*.* or cli-v*.*.*."; exit 1 ;; + esac fi + case "$COMPONENT" in + lib|cli) ;; + *) echo "::error::Invalid component '$COMPONENT'; expected 'lib' or 'cli'."; exit 1 ;; + esac # Build metadata (+...) is deliberately REJECTED even though SemVer allows it: NuGet strips it - # from the package identity, so a tag like v1.2.3+build5 packs as FirstClassErrors.1.2.3.nupkg. + # from the package identity, so a tag like lib-v1.2.3+build5 packs as FirstClassErrors.1.2.3.nupkg. # If 1.2.3 were already published, the push's --skip-duplicate would then turn this release into # a green no-op that publishes nothing. Better to fail loudly here than to "succeed" silently. if ! printf '%s' "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then @@ -90,7 +115,8 @@ jobs: exit 1 fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" - echo "Resolved package version: $VERSION" + echo "component=$COMPONENT" >> "$GITHUB_OUTPUT" + echo "Resolved $COMPONENT release version: $VERSION" - name: Restore run: dotnet restore FirstClassErrors.sln @@ -103,18 +129,18 @@ jobs: - name: Test run: dotnet test FirstClassErrors.sln -c Release --no-build --logger "console;verbosity=normal" - # The three projects that carry NuGet identity are packed: the library, its testing helpers, and - # the `fce` CLI (shipped as a .NET tool — the analyzer is bundled inside the main package and the - # GenDoc worker travels bundled inside the CLI tool; the samples are not published). - # GenerateSBOM activates Microsoft.Sbom.Targets (referenced by all three packable projects): each - # package embeds its SPDX inventory at _manifest/spdx_2.2/manifest.spdx.json. Deliberately - # tools/packaging/pack.sh is the single source of truth for producing the packages (packed projects, - # flags, embedded SBOM, and the SBOM-present check). The release-dryrun workflow calls the same script, - # so the automatic rehearsal can never drift from the real release. + # Pack only the train this release targets (lib -> FirstClassErrors + .Testing; cli -> the fce tool), + # so a lib release never republishes the CLI and vice versa. The analyzer is bundled inside the main + # package and the GenDoc worker inside the CLI tool; the samples are not published. + # GenerateSBOM activates Microsoft.Sbom.Targets in each packable project: each package embeds its SPDX + # inventory at _manifest/spdx_2.2/manifest.spdx.json. tools/packaging/pack.sh is the single source of + # truth (packed projects per train, flags, embedded SBOM, and the SBOM-present check); the + # release-dryrun workflow calls the same script, so the rehearsal can never drift from the real release. - name: Pack env: VERSION: ${{ steps.version.outputs.version }} - run: tools/packaging/pack.sh "$VERSION" + COMPONENT: ${{ steps.version.outputs.component }} + run: tools/packaging/pack.sh "$VERSION" "$COMPONENT" - name: Upload packages as build artifacts uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -183,8 +209,16 @@ jobs: env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.version.outputs.version }} + COMPONENT: ${{ steps.version.outputs.component }} run: | - TAG="v$VERSION" - gh release create "$TAG" artifacts/*.nupkg artifacts/*.snupkg \ - --title "$TAG" --generate-notes --target "$GITHUB_SHA" \ - || gh release upload "$TAG" artifacts/*.nupkg artifacts/*.snupkg --clobber + # nullglob: a cli release ships no .snupkg (the tool has no symbol package), so the pattern + # must expand to nothing rather than to a literal path gh would then fail to upload. + shopt -s nullglob + TAG="${COMPONENT}-v${VERSION}" + assets=(artifacts/*.nupkg artifacts/*.snupkg) + # Train-scoped notes (only this train's Conventional Commit scopes) instead of --generate-notes, + # so a lib release never lists cli commits and vice versa. + tools/packaging/release-notes.sh "$COMPONENT" "$TAG" > release-notes.md + gh release create "$TAG" "${assets[@]}" \ + --title "$TAG" --notes-file release-notes.md --target "$GITHUB_SHA" \ + || gh release upload "$TAG" "${assets[@]}" --clobber diff --git a/tools/packaging/pack.sh b/tools/packaging/pack.sh index d248577..346ad65 100755 --- a/tools/packaging/pack.sh +++ b/tools/packaging/pack.sh @@ -10,28 +10,45 @@ # It assumes the solution has already been built in Release (it packs with # --no-build). It writes the .nupkg / .snupkg into ./artifacts. # -# Usage: tools/packaging/pack.sh +# Usage: tools/packaging/pack.sh # is any valid SemVer (a real release passes the tag version; the -# dry run passes a throwaway like 0.0.0-dryrun). +# dry run passes a throwaway like 0.0.0-dryrun). +# selects which release train to pack, since lib and the fce CLI are +# versioned and released independently: +# lib -> FirstClassErrors + FirstClassErrors.Testing (lockstep) +# cli -> FirstClassErrors.Cli (the `fce` .NET tool) set -eu -if [ "$#" -ne 1 ] || [ -z "$1" ]; then - echo "usage: tools/packaging/pack.sh " >&2 +if [ "$#" -ne 2 ] || [ -z "$1" ] || [ -z "$2" ]; then + echo "usage: tools/packaging/pack.sh " >&2 exit 2 fi version="$1" +scope="$2" -# The three projects that carry NuGet identity: the library, its testing helpers, and the -# `fce` CLI (packed as a .NET tool via PackAsTool; the GenDoc worker it spawns travels bundled -# inside that tool package). GenerateSBOM embeds an SPDX SBOM -# (_manifest/spdx_2.2/manifest.spdx.json) inside each package; it is passed here, -# not hardcoded in the csproj, so local and floor-check packs stay SBOM-free. -for project in \ - FirstClassErrors/FirstClassErrors.csproj \ - FirstClassErrors.Testing/FirstClassErrors.Testing.csproj \ - FirstClassErrors.Cli/FirstClassErrors.Cli.csproj -do +# The projects that carry NuGet identity, selected by release train. GenerateSBOM embeds an SPDX SBOM +# (_manifest/spdx_2.2/manifest.spdx.json) inside each package; it is passed here, not hardcoded in the +# csproj, so local and floor-check packs stay SBOM-free. +case "$scope" in + lib) + # FirstClassErrors + its testing helpers: intrinsically coupled (Testing has a ProjectReference on + # the library and sees its internals), so they always ship together at the same version. + projects='FirstClassErrors/FirstClassErrors.csproj FirstClassErrors.Testing/FirstClassErrors.Testing.csproj' + ;; + cli) + # The `fce` .NET tool (PackAsTool; the GenDoc worker it spawns travels bundled inside the tool + # package). Released on its own cadence and version. + projects='FirstClassErrors.Cli/FirstClassErrors.Cli.csproj' + ;; + *) + echo "error: unknown scope '$scope' (expected 'lib' or 'cli')" >&2 + exit 2 + ;; +esac + +# Intentionally unquoted: $projects is a space-separated list of project paths (no spaces in paths). +for project in $projects; do dotnet pack "$project" -c Release --no-build -p:Version="$version" -p:GenerateSBOM=true -o artifacts done diff --git a/tools/packaging/release-notes.sh b/tools/packaging/release-notes.sh new file mode 100755 index 0000000..ac0449e --- /dev/null +++ b/tools/packaging/release-notes.sh @@ -0,0 +1,72 @@ +#!/bin/sh +# Generate GitHub Release notes for ONE release train (lib or cli), containing only the commits +# that belong to that train — so a lib release never lists cli work, and vice versa. +# +# The partition is by Conventional Commit scope (enforced by tools/commit-lint): +# lib -> scopes core, analyzers, testing (FirstClassErrors + FirstClassErrors.Testing) +# cli -> scopes cli, gendoc (the fce tool: CLI + GenDoc + worker) +# Commits with no scope (bare `ci:`, `build:`, `chore:` ...) are infrastructure and are left out +# of both trains: these notes describe what changed for the consumer of the package, nothing else. +# +# Usage: tools/packaging/release-notes.sh +# Emits Markdown on stdout. Needs full history + tags in the checkout (actions/checkout with +# fetch-depth: 0) so the previous same-train tag — the lower bound of the range — resolves. + +set -eu + +if [ "$#" -ne 2 ] || [ -z "$1" ] || [ -z "$2" ]; then + echo "usage: tools/packaging/release-notes.sh " >&2 + exit 2 +fi +scope="$1" +current_tag="$2" + +case "$scope" in + lib) prefix='lib-v'; train_scopes='core,analyzers,testing' ;; + cli) prefix='cli-v'; train_scopes='cli,gendoc' ;; + *) echo "error: unknown scope '$scope' (expected 'lib' or 'cli')" >&2; exit 2 ;; +esac + +# Previous tag of the SAME train (most recent one that is not the current tag). When there is none, +# this is the train's first release: take the whole history up to the current tag. +previous_tag="$(git tag --list "${prefix}*" --sort=-version:refname | grep -Fxv "$current_tag" | head -n1 || true)" +if [ -n "$previous_tag" ]; then + range="${previous_tag}..${current_tag}" +else + range="$current_tag" +fi + +# One line per commit: "". Merge commits are skipped — a PR merge commit +# carries no Conventional Commit scope; the real work lives in the commits it brings in. +commits="$(git log "$range" --no-merges --format='%h%x09%s')" + +# Keep a commit only when its Conventional Commit scope list intersects this train's scopes. Header +# shape: type(scope[,scope...])[!]: description. A commit with no (scope) group is dropped. +notes='' +while IFS=' ' read -r hash subject; do + [ -z "${subject:-}" ] && continue + # Extract the first parenthesised scope group ("type(core,cli)!: ..." -> "core,cli"); empty when + # the header has no scope, which drops the commit. + scope_group="$(printf '%s' "$subject" | sed -n 's/^[a-z][a-z]*(\([a-z,]*\)).*$/\1/p')" + [ -z "$scope_group" ] && continue + matched=0 + OLDIFS=$IFS; IFS=',' + for sc in $scope_group; do + case ",${train_scopes}," in + *",${sc},"*) matched=1; break ;; + esac + done + IFS=$OLDIFS + [ "$matched" = 1 ] && notes="${notes}- ${subject} (${hash}) +" +done < Date: Mon, 13 Jul 2026 07:52:19 +0000 Subject: [PATCH 5/7] docs(cli): document fce compatibility with FirstClassErrors fce is versioned independently of the library, so its NuGet page needs to state the relationship: it reads the FirstClassErrors documentation contract, a minor fce release adds support for a new contract while a major one changes fce's own surface, and at run time it binds to the target's own FirstClassErrors version. Set expectations for the 0.x preview (keep fce and the library on the same preview line) and pre-announce the 1.0 behaviour where fce declares the contract versions it supports. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JejR9K9ABeQ8LqB5fwGeM2 --- FirstClassErrors.Cli/README.nuget.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/FirstClassErrors.Cli/README.nuget.md b/FirstClassErrors.Cli/README.nuget.md index 7229723..978d123 100644 --- a/FirstClassErrors.Cli/README.nuget.md +++ b/FirstClassErrors.Cli/README.nuget.md @@ -48,6 +48,24 @@ Run `fce --help` or `fce generate --help` for the full option list. Emit one catalog per locale by adding `--language` (e.g. a matrix over `en`, `fr`); file names and anchors stay stable across languages. +## Compatibility with FirstClassErrors + +`fce` reads the error documentation you author with FirstClassErrors' attributes and the +`DescribeError` DSL — the *documentation contract* — and is versioned **independently** of +the library: + +- a **minor** `fce` release adds support for a new FirstClassErrors contract, or a new + option or output format; a **major** `fce` release changes `fce`'s own command-line + surface or drops support for an older contract. +- at run time `fce` documents your solution against **its** FirstClassErrors version (the + worker binds to your target's dependency closure), so what matters is that your `fce` + understands the contract the library version you use produces. + +While FirstClassErrors is in **0.x preview** that contract may still change between minor +versions — use an `fce` from the same preview line as the FirstClassErrors version you +document. From FirstClassErrors 1.0 on, `fce` will state the contract version(s) it +supports and stop with a clear message rather than mis-read a newer, unsupported one. + ## Documentation Full CI/CD integration guide and the rest of the documentation on GitHub: From 47203b9d069496b2dd81c41d1181fe84b54b5ef5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 08:04:29 +0000 Subject: [PATCH 6/7] feat(core): declare the documentation contract version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamp the library assembly with an internal DocumentationContractVersion (starting at 1): the version of the shape fce reads by reflection — the [ProvidesErrorsFor] / [DocumentedBy] attributes, the DescribeError DSL, and the documentation model. It is bumped only when a change breaks that contract, independently of the library's SemVer. Every library published from now on carries the marker, so a future fce can identify the contract a target was built against, and treat an unmarked assembly as version 1, without relying on the library's package version — which the CLI's decoupled build does not preserve on its bundled copy. The attribute is internal diagnostic metadata for the tooling, not public API, and inert until the generator's compatibility gate reads it. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JejR9K9ABeQ8LqB5fwGeM2 --- .../DocumentationContractVersionTests.cs | 33 +++++++++++++++++++ .../DocumentationContractVersionAttribute.cs | 30 +++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 FirstClassErrors.UnitTests/DocumentationContractVersionTests.cs create mode 100644 FirstClassErrors/DocumentationContractVersionAttribute.cs diff --git a/FirstClassErrors.UnitTests/DocumentationContractVersionTests.cs b/FirstClassErrors.UnitTests/DocumentationContractVersionTests.cs new file mode 100644 index 0000000..3df06e3 --- /dev/null +++ b/FirstClassErrors.UnitTests/DocumentationContractVersionTests.cs @@ -0,0 +1,33 @@ +#region Usings declarations + +using System.Reflection; + +using JetBrains.Annotations; + +using NFluent; + +#endregion + +namespace FirstClassErrors.UnitTests; + +[TestSubject(typeof(DocumentationContractVersionAttribute))] +public sealed class DocumentationContractVersionTests { + + [Fact(DisplayName = "The library assembly declares the current documentation-contract version.")] + public void TheLibraryAssemblyDeclaresTheCurrentDocumentationContractVersion() { + // Exercise + DocumentationContractVersionAttribute? attribute = + typeof(DocumentationContractVersionAttribute).Assembly.GetCustomAttribute(); + + // Verify + Check.That(attribute).IsNotNull(); + Check.That(attribute!.Version).IsEqualTo(DocumentationContractVersionAttribute.CurrentVersion); + } + + [Fact(DisplayName = "The documentation-contract version starts at 1 (an unmarked assembly reads as version 1).")] + public void TheDocumentationContractVersionStartsAtOne() { + // Verify + Check.That(DocumentationContractVersionAttribute.CurrentVersion).IsStrictlyGreaterThan(0); + } + +} diff --git a/FirstClassErrors/DocumentationContractVersionAttribute.cs b/FirstClassErrors/DocumentationContractVersionAttribute.cs new file mode 100644 index 0000000..44f7fe7 --- /dev/null +++ b/FirstClassErrors/DocumentationContractVersionAttribute.cs @@ -0,0 +1,30 @@ +[assembly: FirstClassErrors.DocumentationContractVersion(FirstClassErrors.DocumentationContractVersionAttribute.CurrentVersion)] + +namespace FirstClassErrors; + +/// +/// Stamps the assembly with the version of the error-documentation contract it produces — the shape the +/// documentation generator (fce) reads by reflection: the [ProvidesErrorsFor] / [DocumentedBy] +/// attributes, the DescribeError DSL, and the resulting documentation model. +/// +/// +/// Increment ONLY when a change breaks that contract, independently of the library's +/// SemVer: a breaking contract change need not coincide with a major library release, and a major library release +/// need not break the contract. The generator reads this value from the target assembly to detect a contract it was +/// not built to extract; an assembly published without the attribute is treated as contract version 1, the original. +/// The attribute is internal and consumer-invisible — it is diagnostic metadata for the tooling, not public API. +/// +[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)] +internal sealed class DocumentationContractVersionAttribute : Attribute { + + /// The documentation-contract version this build of the library produces. + internal const int CurrentVersion = 1; + + public DocumentationContractVersionAttribute(int version) { + Version = version; + } + + /// The declared documentation-contract version. + public int Version { get; } + +} From dea91bbe5f77a1c1154e2ad97f924f6bdd78dc22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 13 Jul 2026 08:21:44 +0000 Subject: [PATCH 7/7] ci: base release notes on the release commit, not the tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release-notes.sh ran `git log` against the tag being released. On a workflow_dispatch publish the tag does not exist yet — gh release create makes it after the notes are built — so git log failed with "ambiguous argument" and the manual release path never reached release creation. A tag push was unaffected: the tag already exists. Take an optional end-ref (the commit) as the range's upper bound, defaulting to the tag so the dry-run's two-argument calls keep working; release.yml now passes $GITHUB_SHA. The tag name still serves only to exclude the tag being created from the previous-same-train tag lookup, so it need not resolve as a ref. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JejR9K9ABeQ8LqB5fwGeM2 --- .github/workflows/release.yml | 6 ++++-- tools/packaging/release-notes.sh | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9e82b20..0923f30 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,8 +223,10 @@ jobs: PRERELEASE=false case "$VERSION" in *-*) PRERELEASE=true ;; esac # Train-scoped notes (only this train's Conventional Commit scopes) instead of --generate-notes, - # so a lib release never lists cli commits and vice versa. - tools/packaging/release-notes.sh "$COMPONENT" "$TAG" > release-notes.md + # so a lib release never lists cli commits and vice versa. Pass $GITHUB_SHA — not $TAG — as the + # range's upper bound: on a workflow_dispatch publish the tag does not exist yet (gh creates it + # below), so git log must resolve against the commit. + tools/packaging/release-notes.sh "$COMPONENT" "$TAG" "$GITHUB_SHA" > release-notes.md # On a re-run the release already exists, so `gh release create` fails and the fallback runs: # `gh release upload` refreshes the assets but cannot change the prerelease flag, so # `gh release edit` reconciles it — keeping a repair run idempotent in both directions. diff --git a/tools/packaging/release-notes.sh b/tools/packaging/release-notes.sh index ac0449e..6315e89 100755 --- a/tools/packaging/release-notes.sh +++ b/tools/packaging/release-notes.sh @@ -8,18 +8,24 @@ # Commits with no scope (bare `ci:`, `build:`, `chore:` ...) are infrastructure and are left out # of both trains: these notes describe what changed for the consumer of the package, nothing else. # -# Usage: tools/packaging/release-notes.sh +# Usage: tools/packaging/release-notes.sh [] # Emits Markdown on stdout. Needs full history + tags in the checkout (actions/checkout with -# fetch-depth: 0) so the previous same-train tag — the lower bound of the range — resolves. +# fetch-depth: 0) so the previous same-train tag — the lower bound of the range — resolves. +# is the upper bound and defaults to ; pass the release commit when the tag does not exist +# yet (a workflow_dispatch publish creates the tag only after the notes are built). set -eu -if [ "$#" -ne 2 ] || [ -z "$1" ] || [ -z "$2" ]; then - echo "usage: tools/packaging/release-notes.sh " >&2 +if [ "$#" -lt 2 ] || [ "$#" -gt 3 ] || [ -z "$1" ] || [ -z "$2" ]; then + echo "usage: tools/packaging/release-notes.sh []" >&2 exit 2 fi scope="$1" current_tag="$2" +# Upper bound of the commit range. Defaults to , but a caller that has not created the tag yet +# passes the release commit (e.g. $GITHUB_SHA) so `git log` resolves. is used only to exclude +# the tag being created from the previous-same-train-tag lookup, so it need not exist as a ref. +end_ref="${3:-$current_tag}" case "$scope" in lib) prefix='lib-v'; train_scopes='core,analyzers,testing' ;; @@ -31,9 +37,9 @@ esac # this is the train's first release: take the whole history up to the current tag. previous_tag="$(git tag --list "${prefix}*" --sort=-version:refname | grep -Fxv "$current_tag" | head -n1 || true)" if [ -n "$previous_tag" ]; then - range="${previous_tag}..${current_tag}" + range="${previous_tag}..${end_ref}" else - range="$current_tag" + range="$end_ref" fi # One line per commit: "". Merge commits are skipped — a PR merge commit