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
7 changes: 7 additions & 0 deletions .config/dotnet-tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
"dotnet-CycloneDX"
],
"rollForward": false
},
"docfx": {
"version": "2.81.0",
"commands": [
"docfx"
],
"rollForward": false
}
}
}
139 changes: 139 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
# Builds the docs site from the guides in docs/, the root README, the package READMEs, the changelog and the API of
# the run-time packages, and publishes it to GitHub Pages from main (#115, design/adr/0116-the-docs-site-is-docfx-built-from-main.md):
#
# https://caffeinatedcoder.github.io/CodoMetis.TypeKit/
#
# Only main is published, because main is the released state: topics merge into release/<version>, which merges into
# main when it is tagged, so the site always describes the version `dotnet add package` installs. A release branch
# is never published. A second line under its own path (/2.x/, /3.x/) waits until two lines are supported at once;
# CONTRIBUTING.md ("The docs site") says how one would be added.
#
# A pull request into main or a release branch that touches what a page is built from builds the site and publishes
# nothing. DocFX runs with warnings as errors, so a link to a missing page, a missing heading or a missing file of the
# repository fails the pull request rather than the deployment.
#
# The pages are the Markdown files as they are: nothing copies or edits them, so ReadmeSampleTests and
# OracleCatalogueTests hold the text readers see. A link to a file of the repository that is not a page (design/,
# CONTRIBUTING.md) becomes a GitHub address at the built commit in site/template, at build time.
#
# Pages is configured to deploy from GitHub Actions (Settings > Pages > Source), the one setting no workflow can make.
#
# Actions are pinned to commit SHAs, as in dotnet.yml.

name: Docs

# Everything a page is built from: the Markdown, the XML docs in src/, the site's config, and the SDK and tool pins.
# Keep the two lists the same.
on:
push:
branches: [ "main" ]
paths:
- "*.md"
- "LICENSE"
- "docs/**"
- "design/**"
- "src/**"
- "site/**"
- "global.json"
- "Directory.Build.props"
- "Directory.Packages.props"
- ".config/dotnet-tools.json"
- ".github/workflows/docs.yml"
pull_request:
branches: [ "main", "release/**" ]
paths:
- "*.md"
- "LICENSE"
- "docs/**"
- "design/**"
- "src/**"
- "site/**"
- "global.json"
- "Directory.Build.props"
- "Directory.Packages.props"
- ".config/dotnet-tools.json"
- ".github/workflows/docs.yml"
workflow_dispatch:

permissions:
contents: read

defaults:
run:
shell: bash

# A pull request's run is superseded by its next push. Publishing runs queue instead, so a deployment is never
# cancelled halfway and the last one to finish publishes the latest main.
concurrency:
group: ${{ github.event_name == 'pull_request' && format('docs-{0}', github.ref) || 'docs-publish' }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}

env:
DOTNET_NOLOGO: true
DOTNET_CLI_TELEMETRY_OPTOUT: true
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true

jobs:
build:
name: Build the site
runs-on: ubuntu-latest
timeout-minutes: 20

steps:
- uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
with:
persist-credentials: false

- name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
with:
global-json-file: global.json

# The API pages are read from published assemblies, which carry the XML docs and every dependency DocFX needs
# to resolve a signature. The packages are the ones site/docfx.json names, so the two cannot disagree.
- name: Publish the run-time packages
run: |
for assembly in $(jq -r '.metadata[].src[].files[]' site/docfx.json); do
dotnet publish "src/${assembly%.dll}" -c Release -o site/obj/pub
done

- name: Build the site, warnings as errors
run: |
dotnet tool restore
dotnet docfx site/docfx.json --warningsAsErrors --metadata _commit="$GITHUB_SHA"

# The members of C# 14 extension blocks are listed as extension methods, on their static class and on the type
# they extend: measured with DocFX 2.81.0 (decision 116). A DocFX that stops listing them fails here, not on
# a reader who finds Option<T>'s page without its combinators.
- name: The extension-block members are listed
run: |
grep -qF ' - CodoMetis.TypeKit.Option`1.CodoMetis.TypeKit.Option.Select``2(' site/obj/api/CodoMetis.TypeKit.Option-1.yml
grep -qF ' - CodoMetis.TypeKit.Result`2.CodoMetis.TypeKit.Result.Select``3(' site/obj/api/CodoMetis.TypeKit.Result-2.yml

# The files a link of a page names to be checked, which the site links on GitHub instead (site/template).
- name: Leave the repository's own files out
run: rm -rf site/obj/_site/_repository

- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
with:
path: site/obj/_site

publish:
name: Publish
needs: build
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}

steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0

- id: deploy
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ section is the GitHub release's notes.
- Testing: every assertion works on a `Task` or `ValueTask` of what it checks, `await service.FindAsync(id).ShouldBeSome()`; a task that throws fails the test with its own exception, and an assertion left without `await` is reported (CMTK0003) ([CodoMetis.TypeKit.Testing](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Testing/README.md)).
- OpenAPI: `AddTypeKit(typeKit => typeKit.InlineValueObjects = true)` describes every value object in place by its underlying type's schema, in every position and in OpenAPI 3.1, 3.0 and 2.0, with no component of its own; closed sets keep theirs, and without the setting the document is unchanged ([Describe value objects as plain types](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/docs/aspnet-core.md#describe-value-objects-as-plain-types)).
- Analyzers: CMTK0009 warns at `FirstOrNone`, `SingleOrNone` or `FirstOrError` on a query given a `Func` variable or a method group, which binds to the `IEnumerable<T>` overload and reads every row; a lambda at the call or an `Expression` stays silent ([A condition that reads every row](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#a-condition-that-reads-every-row)).
- Docs: the guides, the package READMEs, the changelog and the API of every run-time package as one site with a menu and search, built from the latest release ([caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/)).
- Analyzers: a validated value object without `Create` gets a code fix, **Add Create**, that writes a `Create` accepting every value, ready for your rules ([Add Create](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/blob/main/src/CodoMetis.TypeKit.Analyzers/README.md#add-create)).

### Changed
Expand Down
42 changes: 42 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,48 @@ The packages share one version and are released together, only through

`workflow_dispatch` with `dry_run` (the default) rehearses everything but the push.

## The docs site

The guides in `docs/`, the root README, the package READMEs, the changelog and the API pages of the
run-time packages are published to GitHub Pages, at
[caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/), by
`.github/workflows/docs.yml` with DocFX
([decision 116](design/adr/0116-the-docs-site-is-docfx-built-from-main.md)).

- **Only `main` is published**, on every push to it, so the site always shows the latest release: the
release's PR into `main` publishes it. A release branch is never published.
- **The pages are the files as they are.** Nothing copies or edits them for the site. The menu is
`site/toc.yml`: a new guide goes into the README's Documentation table and into the menu's Guides, in
the same order, and `DocsSiteTests` fails until both list it. A new run-time package goes into
`site/docfx.json`'s `metadata`, or into `DocsSiteTests.WithoutApiPages` with the reason it has no API
pages.
- **Links are checked on every pull request** that touches what a page is built from. DocFX runs with
`--warningsAsErrors`, so a link to a missing page, heading or file fails the PR. A link to a file that
is not a page (`design/`, this file) becomes a GitHub address at the built commit, at build time
(`site/template/conceptual.extension.js`).
- **Pages is configured** to deploy from GitHub Actions (Settings > Pages > Source), the one setting a
workflow cannot make.

To build the site locally (`jq` reads the packages from `site/docfx.json`) and serve it at
http://localhost:8080:

```bash
for assembly in $(jq -r '.metadata[].src[].files[]' site/docfx.json); do dotnet publish "src/${assembly%.dll}" -c Release -o site/obj/pub; done
dotnet tool restore
dotnet docfx site/docfx.json --warningsAsErrors --metadata _commit="$(git rev-parse HEAD)" --serve
```

**A second line**, such as `3.x` beside `2.x`, waits until two lines are supported at once. GitHub Pages
serves one artifact per repository, so each deployment then carries every line:

1. Build each line from its own branch into its own folder (`/2.x/`, `/3.x/`), with `main`'s
`site/` config, as a matrix in the build job; DocFX's `_appTitle` names the line.
2. Assemble the folders into one artifact with a root `index.html` that redirects to the default line,
the latest release, and deploy that.
3. Start the workflow from both branches. A push to the older line's branch cannot run a workflow that
branch lacks, so run on `workflow_run` of the build workflow there, and check that line out by name,
never by the triggering run's SHA. CodoMetis.ValueRanges' `docs.yml` does this for its two lines.

## AI-assisted development

A substantial portion of this codebase was written with AI assistance, using
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,10 @@ under [Packages](#packages), are the reference for each package: what it adds, h
exception message and analyzer help link points into them. Every C# block in the guides and the
READMEs is compiled, and the outputs it states are tested.

You can also read all of it as one website, with a menu, search and the API of every run-time
package: [caffeinatedcoder.github.io/CodoMetis.TypeKit](https://caffeinatedcoder.github.io/CodoMetis.TypeKit/).
It shows the latest release.

| Page | Covers |
|---|---|
| [Value objects](docs/value-objects.md) | Declare one, pick a struct or a class, validate input, design its errors, pick the right factory, write your own members, generic code |
Expand Down Expand Up @@ -381,7 +385,7 @@ one.

[CHANGELOG.md](CHANGELOG.md) lists what each version changed. The design notes, the decision records
and the roadmap are under [design/](design/plan.md). The measurements that decided the design are in
[spikes/](spikes/).
[spikes/](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/tree/main/spikes).

## Building

Expand Down
67 changes: 67 additions & 0 deletions design/adr/0116-the-docs-site-is-docfx-built-from-main.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# 0116. The docs site is DocFX, built from main, from the sources as they are

Date: 2026-10-29
Status: accepted

Context: [#115](https://github.com/CaffeinatedCoder/CodoMetis.TypeKit/issues/115); CodoMetis.ValueRanges chose DocFX
in its spike ([CaffeinatedCoder/CodoMetis.ValueRanges#417](https://github.com/CaffeinatedCoder/CodoMetis.ValueRanges/issues/417));
[decision 113](0113-ci-runs-the-lanes-a-change-can-affect.md) holds the CI lanes this adds a path to.

The guides in `docs/`, the root README and the package READMEs could only be read as Markdown on GitHub: no
navigation beyond the README's Documentation table, no search across them, and the XML docs of every public
member shown only by an IDE.

**The site is DocFX, publishing to GitHub Pages from `main` alone.** `.github/workflows/docs.yml` builds
`docs/*.md`, `README.md`, `src/*/README.md`, `CHANGELOG.md` and the API pages of the run-time packages
(`CodoMetis.TypeKit`, `.EntityFrameworkCore`, `.AspNetCore`, `.Serilog`, `.Testing`) and deploys them on a push to
`main`, which is the released state: topics merge into `release/<version>`, which merges into `main` when it is
tagged. A release branch is never published, so the site always describes the version `dotnet add package`
installs. The Analyzers' surface is their rules and the Generators' is what they generate into a consumer, so
their READMEs are their reference and they get no API pages. DocFX is pinned in `.config/dotnet-tools.json`
(2.81.0) and configured in `site/docfx.json`, inside the repository because DocFX refuses a content root outside
its base directory. A second line (`/2.x/`, `/3.x/`) waits until two are supported at once; CONTRIBUTING.md says
how one would be added.

**The pages are the files as they are.** Nothing copies or edits a page for the site, so `ReadmeSampleTests` and
`OracleCatalogueTests` hold the text a reader sees. A link between two pages resolves as written, since every
page is a site page. A link to a file of the repository that is not a page (`design/`, `CONTRIBUTING.md`,
`AGENTS.md`, `SECURITY.md`, `LICENSE`) becomes a GitHub address at the built commit, rewritten by
`site/template/conceptual.extension.js` at build time. Those files are the build's resources, under `_repository/`,
so DocFX checks that each exists; the workflow then deletes them from the output. A link to a directory names no
file DocFX can check, so the README's one link to `spikes/` became an absolute address on `main`, which
`DocumentationLinkTests` checks.

**A pull request that touches a page checks its links.** On a pull request into `main` or a release branch whose
paths a page is built from (`*.md`, `docs/`, `design/`, `src/`, `site/`, the SDK and tool pins), the workflow runs
the whole build with `--warningsAsErrors` and publishes nothing. Measured on 2026-10-29: a seeded link to a missing
page (`InvalidFileLink`), to a missing heading of a page (`InvalidBookmark`) and to a missing design record
(`InvalidFileLink`, through the resources) each fail the build; without them it reports no warning. An anchor into
a file that is not a page is left to `DocumentationLinkTests`, which reads every anchor as GitHub derives it.

**`site/**` is prose** in `tools/CiLanes`: the docs workflow and the conventions tests read it, and nothing is
built from it, so a change to it alone runs the prose suite. `ProseReaderTests` counts a literal naming `site` as
prose, so no test outside `test/Prose.slnf` reads it unseen. The workflow itself is a workflow, which decision 113
already classes as anything else. `DocsSiteTests` holds the menu (`site/toc.yml`) to the README's Documentation
table, in its order, and to every shipping package's README, and the API pages to every run-time package.

**The spike: DocFX lists the members of C# 14 extension blocks.** `src/` holds 35 `extension(...)` blocks, which
carry most of the `Option` and `Result` extensions, `AsText` and the `IQueryable` helpers, and ValueRanges had none.
Measured with DocFX 2.81.0 on the published assemblies:

- Every instance member of a block is listed on its static class as the extension method the compiler lowers it
to, with its XML docs: `Option.FirstOrNone<T>(this IEnumerable<T> source)`, `StringTextExtensions.AsText(this
string? text)`: 37 extension methods on `Option` and 52 on `Result`.
- The type a block extends lists them under "Extension Methods": `Option<T>`'s page shows `Select`, `SelectMany`,
`Where`, `Zip`, `ToResult` and `Result.Traverse`. A member whose constraints the open type does not meet is
left out there, as for any extension method: `OrDefault()` (`where T : struct`) and `OrNull()`
(`where T : class`) are on `Option`'s page only.
- The signature shown is the lowered one, `this in Option<T> a`, not the `extension(...)` block it is declared in.
- The one static member of a block, `GuidValueExtensions.New()`, is listed as `New<TSelf>()` with its constraints,
on `GuidValueExtensions` only, not as `OrderId.New()` on the value objects it serves.

So the API pages ship. The workflow fails if `Option<T>`'s or `Result<T, TError>`'s page stops listing its
extension-block `Select`, so a DocFX update that loses them fails there, not on a reader.

**Pages is configured** to deploy from GitHub Actions (Settings > Pages > Source), the one setting no workflow can
make: on 2026-10-29 the API reports `build_type: workflow` at `https://caffeinatedcoder.github.io/CodoMetis.TypeKit/`.
The first deployment follows the 2.3.0 release's merge into `main`.
1 change: 1 addition & 0 deletions design/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,4 @@ per area ([design](../plan.md)) link to the records they rest on.
| [0113](0113-ci-runs-the-lanes-a-change-can-affect.md) | CI runs the lanes a change can affect: each changed path selects Linux, Windows and the pack by its class in `tools/CiLanes` (Windows for the analyzers, the generators, tests and tools; the pack for the packages), a path no class names selects all three, a prose-only change runs only the test projects that read prose (`test/Prose.slnf`), and only a pull request into `main`, a push to `main`, the nightly and the release run macOS; a gate job, `CI`, over every job is the required check; a convention test refuses non-portable file names; `release.yml` calls the build workflow before it verifies | 2026-10-29 | accepted |
| [0114](0114-a-value-object-s-temporary-number-is-its-wrapped-type-s.md) | A value object over a number gets the temporary values a property of its wrapped type gets, from a factory in its converter's mapping hints, which EF consults after a generator on the property and after HiLo: a default `ulong` key no longer throws at `Add`, and a `char` key configured on add gets a temporary `char` on SQL Server; under `--nativeaot` the factory is lost and a default `ulong` key still throws | 2026-10-29 | accepted |
| [0115](0115-value-objects-can-be-inlined-everywhere.md) | `AddTypeKit(typeKit => typeKit.InlineValueObjects = true)` writes every value object in place, by its wrapped type's schema, in every position and version, with no component of its own; off by default, the document unchanged; closed sets and wrapped enums keep their components; an inlined value object claims no name (decision 55) and shares no schema between its JSON positions and its parameters (decision 106) | 2026-10-29 | accepted |
| [0116](0116-the-docs-site-is-docfx-built-from-main.md) | The docs site is DocFX, published to GitHub Pages from `main` alone: the guides, the READMEs, the changelog and the API pages of the run-time packages, from the files as they are; a link to a file that is not a page becomes a GitHub address at the built commit; a pull request touching a page builds it with warnings as errors; `site/**` is prose; DocFX 2.81.0 lists the members of C# 14 extension blocks as extension methods | 2026-10-29 | accepted |
Loading
Loading