Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions .github/workflows/release-dryrun.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
87 changes: 62 additions & 25 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -66,31 +80,43 @@ 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
echo "::error::Invalid version '$VERSION'; expected a SemVer value like 1.2.3 or 1.2.3-beta.1 (build metadata '+...' is not allowed: NuGet drops it from the package identity)."
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
Expand All @@ -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
Expand Down Expand Up @@ -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"; }
61 changes: 61 additions & 0 deletions FirstClassErrors.Cli/FirstClassErrors.Cli.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,71 @@
<AssemblyName>fce</AssemblyName>
</PropertyGroup>

<!-- NuGet packaging: this project ships as the `fce` .NET tool.
PackAsTool bundles the app and its dependency closure under tools/<tfm>/any, so a
`dotnet tool install FirstClassErrors.Cli` yields a runnable `fce`. The GenDoc worker the tool
spawns as a separate process is copied next to the app by the targets below and travels inside the
same folder, which is where the tool resolves it at runtime (AppContext.BaseDirectory). Packed by
tools/packaging/pack.sh alongside the two library packages. -->
<PropertyGroup>
<PackAsTool>true</PackAsTool>
<ToolCommandName>fce</ToolCommandName>

<!-- No separate symbol package for the tool. A .snupkg exists so consumers can debug into a LIBRARY
they reference; a global tool is executed, not referenced, and its PDBs already travel inside the
tool package. Overrides IncludeSymbols=true from Directory.Build.props so the release neither builds
nor pushes a FirstClassErrors.Cli.snupkg (which nuget.org's symbol validation does not expect for a
tool package). -->
<IncludeSymbols>false</IncludeSymbols>

<!-- NuGet identity -->
<PackageId>FirstClassErrors.Cli</PackageId>
<Authors>Sylvain AURAT</Authors>
<Company>Reefact</Company>

<!-- Version. This is only a local/dev fallback: release.yml overrides it with -p:Version from the Git tag. -->
<Version>0.1.0-dev</Version>

<!-- Description -->
<Description>
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.
</Description>

<!-- Metadata -->
<PackageTags>error-handling;errors;documentation;living-documentation;cli;dotnet-tool;fce;ci-cd;firstclasserrors</PackageTags>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>

<!-- Links -->
<PackageProjectUrl>https://github.com/Reefact/first-class-errors</PackageProjectUrl>
<RepositoryUrl>https://github.com/Reefact/first-class-errors.git</RepositoryUrl>
<RepositoryType>git</RepositoryType>

<!-- Package assets -->
<PackageIcon>icon.png</PackageIcon>
<PackageReadmeFile>readme.md</PackageReadmeFile>
<PackageReleaseNotes>Initial preview release of the fce documentation generator.</PackageReleaseNotes>

<Copyright>© Reefact 2026</Copyright>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Spectre.Console.Cli" />
</ItemGroup>

<ItemGroup>
<None Include="..\icon.png" Pack="true" PackagePath="/" />
<None Include="README.nuget.md" Pack="true" PackagePath="readme.md" />
</ItemGroup>

<ItemGroup>
<!-- Embed an SPDX SBOM inside the package at pack time (_manifest/spdx_2.2/manifest.spdx.json), matching
the two library packages so tools/packaging/pack.sh's SBOM-present assertion holds for this package
too. Activated only when pack.sh passes -p:GenerateSBOM=true; build-time only (PrivateAssets=all),
so tool users never see it as a dependency. -->
<PackageReference Include="Microsoft.Sbom.Targets" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="FirstClassErrors.Cli.UnitTests" />
</ItemGroup>
Expand Down
72 changes: 72 additions & 0 deletions FirstClassErrors.Cli/README.nuget.md
Original file line number Diff line number Diff line change
@@ -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
`<GenerateErrorDocumentation>true</GenerateErrorDocumentation>` 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
33 changes: 33 additions & 0 deletions FirstClassErrors.UnitTests/DocumentationContractVersionTests.cs
Original file line number Diff line number Diff line change
@@ -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<DocumentationContractVersionAttribute>();

// 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);
}

}
Loading