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 0920ac2..0923f30 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,17 +129,18 @@ 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 - # 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 @@ -182,18 +209,28 @@ jobs: env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.version.outputs.version }} + COMPONENT: ${{ steps.version.outputs.component }} run: | - TAG="v$VERSION" + # 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) # A SemVer pre-release label (anything after '-') marks the GitHub Release as a pre-release, # matching how NuGet lists it, so a preview never shows up as the repo's "Latest" release. # Build metadata '+...' is already rejected in "Resolve version", so a '-' here is # unambiguously the pre-release separator (-preview.1, -beta.1, -rc.1, ...). PRERELEASE=false case "$VERSION" in *-*) PRERELEASE=true ;; esac - # 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. - gh release create "$TAG" artifacts/*.nupkg artifacts/*.snupkg \ - --title "$TAG" --generate-notes --target "$GITHUB_SHA" --prerelease="$PRERELEASE" \ - || { gh release upload "$TAG" artifacts/*.nupkg artifacts/*.snupkg --clobber \ + # 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. 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. + gh release create "$TAG" "${assets[@]}" \ + --title "$TAG" --notes-file release-notes.md --target "$GITHUB_SHA" --prerelease="$PRERELEASE" \ + || { gh release upload "$TAG" "${assets[@]}" --clobber \ && gh release edit "$TAG" --prerelease="$PRERELEASE"; } 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..978d123 --- /dev/null +++ b/FirstClassErrors.Cli/README.nuget.md @@ -0,0 +1,72 @@ +# 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. + +## 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: +https://github.com/Reefact/first-class-errors 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; } + +} 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 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. diff --git a/tools/packaging/pack.sh b/tools/packaging/pack.sh index 967eac9..346ad65 100755 --- a/tools/packaging/pack.sh +++ b/tools/packaging/pack.sh @@ -10,25 +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 two projects that carry NuGet identity. 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 -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..6315e89 --- /dev/null +++ b/tools/packaging/release-notes.sh @@ -0,0 +1,78 @@ +#!/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. +# 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 [ "$#" -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' ;; + 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}..${end_ref}" +else + range="$end_ref" +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 <