The docs site is a MkDocs Material rendering of this repository published to GitHub Pages from v* tags, with a build-time hook that injects the three root Markdown files and rewrites every relative link rather than copying anything into docs/¶
Everything this project documents is already written down. README.md, CONTEXT.md and CONTRIBUTING.md sit at the repo root; docs/ holds getting-started, a ~500-line ops manual, model selection, the definition of done, the agent-facing conventions, the v0 reference, and the ADR log. None of it is readable as documentation: there is no search across it, no navigation between the pieces, and the only ways in are a checkout or GitHub's file browser one blob at a time. ADR-0064 open-sourced the repository, which moved the audience to people who have not cloned it and are deciding whether to — and ADR-0065 gave those people binaries, so they now arrive without the source at all. The material they need exists; the form it is served in is the problem.
This ADR adds a rendering of that material and nothing else. mkdocs.yml and mkdocs_hooks.py at the repo root, Material for MkDocs, MkDocs' default docs_dir of docs, single-version at https://cloonar.github.io/coding-lab/, built by .github/workflows/docs.yml as a pull_request gate and published from a v* tag. Every decision below follows from one constraint that is worth stating before the list, because it is the thing that makes a docs site sustainable rather than a second thing to maintain: the site is a view of the repository, never a second home for it. Nothing is authored for the site, nothing is copied into docs/ for the site's benefit, and fixing the site means fixing the repo. Where that constraint costs something — a Python file, a link rewriter, a build that can fail on a link — the bullets say what it bought.
The site, decided:
-
The root files are injected as virtual pages, not copied and not symlinked.
README.md,CONTEXT.mdandCONTRIBUTING.mdhave to stay at the root — that is where GitHub renders them, where every existing link points, and where a contributor looks — but MkDocs only seesdocs_dir.mkdocs_hooks.pyreads them at build time and appends them to the file collection asFile.generatedpages (index,context,contributing). Committing copies underdocs/was rejected outright: two files with the same name and no mechanism keeping them equal is a drift that starts on the first edit and is discovered by a reader. Symlinks were closer but still lose: the hook has to exist anyway for the link rewriting and the exclusions, and once it exists, symlinks buy nothing while adding a checkout-portability wart. -
The ADR index is generated from the directory listing.
adr/index.mdis built by walkingdocs/adr/, taking the number from each filename and the title from each file's H1. The point is the cost of the next ADR: onegit addand nothing else — no nav entry, no index line, no chance of the two disagreeing. The number is taken verbatim from the filename rather than renumbered, because the log contains duplicates from concurrent branches (two 0033, two 0034, two 0060) and renumbering here would make the index disagree with every cross-reference in the ADR texts. Sorting is by filename, which is chronological and is also stable under those duplicates, where a numeric sort would have to invent a tie-break. -
Link rewriting is the load-bearing part, and
--strictis what tests it. The Markdown in this repo is written to read on GitHub: relative paths intodocs/, intointernal/, into.github/workflows/. Most of those targets are not pages on this site. The hook resolves every relative target against the repo and sends it to one of three fates. A target that resolves to a published page becomes a source-relative path, which MkDocs then resolves and validates — that is the whole reason the rewriter is testable at all: withvalidation.unrecognized_links/omitted_files/anchors: warnand--strict, MkDocs' own resolver becomes the test suite for the hook, anchors included, and the hook cannot silently rot. A target that resolves to a repo file that is not published —docs/agent-brief.md,docs/reference/lab-v0/, the rest ofdocs/agents/, and non-doc files likeLICENSE,internal/compat/compat.md,nix/module.nix, the workflow files — becomes ahttps://github.com/Cloonar/coding-lab/blob/main/…URL, so no link on the site 404s. Directories becometree/main/…, withdocs/adr/the one exception: it points at the generated index. A target that resolves to nothing is left exactly as written, which is the deliberate part — there are no broken relative links in the content today, so anything that fails to resolve is new, and leaving it alone makes MkDocs fail the build instead of the hook papering over a typo with an invented URL. Whether a target is "published" is read off the live file collection rather than a list kept in the hook, which is what makesexclude_docsand the rewriter agree by construction. One subtlety earns its own sentence: Markdown links and raw HTML attributes need paths relative to different things — MkDocs never touches raw HTML, so the browser resolves it against the page URL, which underuse_directory_urlsis not the source path — and getting that distinction right is what makes README's three<img>screenshots render. -
exclude_docsis written exclude-then-negate, so a new agent doc is unpublished by default./agents/**,!/agents/provider-authoring.md,/reference/**,/agent-brief.md— a list of rules, not a list of today's filenames, so the default for a new file underdocs/agents/is "not on the site". That default is the right one because agent-facing conventions are written for agents working on this repo and read from the checkout, not by human readers of a site; publishing them would put issue-tracker mechanics and the triage state machine in front of readers who will never run either.provider-authoring.mdis the one file in that directory that is genuinely contributor-facing, so it is negated back in and lives under the site's Contributing section. The individual ADR pages are built, linked and searchable but kept out of the sidebar withnot_in_nav; the generated index is the nav entry, because seventy-odd numbered rows in a sidebar bury the eight pages a reader actually navigates to. -
The toolchain is pinned by
flake.lock, so there is norequirements.txt.flake.nixgainspkgs.python3.withPackages (ps: [ ps.mkdocs-material ])— mkdocs 1.6.1 and Material 9.7.6 at the current lock — added to the default devshell somkdocs serveworks without leavingnix develop, and to a new leandevShells.docsholding only that, which is what CI enters so the docs job does not realize the Go toolchain and node to turn Markdown into HTML. This is ADR-0002's rule applied to one more toolchain and nothing more: arequirements.txtbesidemkdocs.ymlwould be a second pinning mechanism to keep honest, and the failure it produces — CI rendering with a different mkdocs than the contributor did — is exactly the class of failure the flake exists to make impossible. -
One workflow, two legs, split on privilege rather than on configuration.
docs.ymlrunsbuildon every trigger:contents: read, nosecrets.*referenced,nix develop .#docs --command mkdocs build --strict, then upload the Pages artifact.deploy(needs: build) isif-gated to tag pushes andworkflow_dispatch, so apull_requestcannot reach it, and it is the only job in the file that holdspages: write/id-token: write. The leg an untrusted fork PR executes is therefore incapable of publishing, by structure and not by a setting — ADR-0064's fork-safety posture, inherited. The artifact flow (upload-pages-artifact+deploy-pages) is also whymkdocs gh-deployis not used:gh-deployputs a build artifact in git history on agh-pagesbranch and needscontents: write, where the artifact path needs neither. The PR leg uploads the artifact too, so a PR is a full rehearsal of the deploy payload rather than a partial one. -
The site tracks releases, not
main. Deploy fires onv*tags plus aworkflow_dispatchescape hatch, not on every push to the default branch — the same instinct as ADR-0065 and for the same reason: a reader of the site is almost always running the binaries the last tag produced, and documentation describing unreleased behaviour is worse than slightly stale documentation, because it sends them looking for a flag theirlabdoes not have. Merging to main is not a publish event; cutting a release is.workflow_dispatchis the deliberate valve for the cases where that rule needs breaking — a dead link, a wrong command — without inventing a second trigger. -
docsis deliberately not a required check, which is precisely why it needs no no-op twin.ci-nix-noop.ymlexists becauseci-nixis path-gated and required, a combination GitHub cannot express: a required check that never runs leaves a PR pending on "Expected" forever, so the twin reports the same check name from the inverse trigger. This workflow is path-gated (docs/**,mkdocs.yml,mkdocs_hooks.py, the three injected root files,flake.nix,flake.lock, and itself) but stays out of the required set, so a PR touching none of those paths simply has nodocscheck — nothing pends and nothing hangs. A twin here would manufacture a green check that proved nothing was built, which is strictly worse than no check at all.
Status¶
Accepted. Settled via issue #13 (2026-08-05).
ADR-0002's rule applied to one more toolchain, and ADR-0002 is unchanged. mkdocs and Material are pinned by flake.lock like everything else, reached through a devshell, so "local render == CI render" holds for the same reason "local nix flake check == CI" does. No second pinning mechanism enters the repo, and nothing about packages, nixosModules.lab or checks moves.
Belongs on GitHub under ADR-0064's dividing line, because its credential does. That line is drawn on the credential and nothing else: Pages authenticates the deploy with the built-in token and OIDC, there is no operator-provisioned secret to scope or rotate, and a fork publishes to its own github.io namespace with nothing to provision. So this workflow lives in .github/workflows/ with the rest of the public-facing pipeline, and nothing about it is mirrored to .forgejo/.
Inherits ADR-0065's release posture verbatim. Tag-triggered, fork-safe by construction, least-privilege per job, and never cancelled mid-flight — cancel-in-progress is ${{ github.event_name == 'pull_request' }}, because a superseded PR run is waste but a killed deploy-pages leaves the Pages deployment in whatever partial state the API had reached, recoverable only by hand.
Adds no required check and touches neither of ADR-0023's gates. native and flake-check are untouched in trigger, job id and content; the required-status set does not change. docs.yml is a fourth workflow on the same runner image, path-gated so it costs nothing on PRs that cannot affect the site.
Considered options¶
-
A GitHub or Forgejo wiki. Rejected: the content leaves the repository. A wiki page is unversioned alongside the code that makes it true, invisible to code review, and free to contradict the implementation for as long as nobody notices. Every property that makes
docs/ops.mdtrustworthy — it moves in the same commit as the behaviour, a reviewer sees the diff, CI can gate it — is a property a wiki removes. -
Commit copies of
README.md,CONTEXT.mdandCONTRIBUTING.mdintodocs/. Rejected: two files with the same name and no mechanism keeping them equal. The drift starts at the first edit that touches one of them, and the discovery mechanism is a reader following instructions that no longer work. -
Symlink them into
docs/instead. Rejected, though it is the closest miss. The hook has to exist regardless — for the link rewriting and for reading published-ness off the file collection — and once it exists, injection is three lines of it. Symlinks would add a checkout-portability wart (Windows clones, archive exports, some CI checkout paths) in exchange for nothing. -
A plugin instead of the built-in
hooks:mechanism —mkdocs-gen-files,mkdocs-include-markdown,mkdocs-monorepo. Rejected: each is a dependency living outside the flake pin, for a job that ~200 lines of stdlib does. None of them does the part that actually matters here — the three-way link classification against the live file collection — so a plugin would be a dependency plus the hook, not instead of it. -
requirements.txt+pip installin the CI job. Rejected on ADR-0002's grounds: a second pinning mechanism besideflake.lock, with its own staleness, its own lockfile question, and a guaranteed eventual divergence between what CI renders with and what a contributor renders with. -
mkdocs gh-deploypushing agh-pagesbranch. Rejected: it commits a build artifact into git history — every deploy a tree of generated HTML in the repo, forever — and it needscontents: writeon the job that publishes. The Pages artifact flow needs neither, and keeps the only write scope in the file down topages: write+id-token: writeon a job a pull request cannot reach. -
Deploy on every push to
main. Rejected: see the tag bullet. The site would then document unreleased behaviour to readers running the last release's binaries, which is a worse failure than staleness because it is indistinguishable from a bug in their install. -
mikeand versioned docs. Rejected for now: versioned docs are a real answer to "which release does this page describe", but they cost a version selector, a redirect layer, agh-pages-style branch of built output, and a per-release publish discipline. There is exactly one release line and no supported-version policy to express yet. Publishing from the tag gives the same property — the site describes the current release — for none of that machinery. A custom domain and aCNAMEare likewise deferred;cloonar.github.io/coding-labis a URL that works today and costs no DNS. -
Publish
docs/agents/and thelab-v0reference. Rejected:docs/agents/is written for agents working on this repo and is read from the checkout, anddocs/reference/lab-v0/is the frozen v0 prototype this codebase was rewritten from (ADR-0001), vendored for provenance rather than for reading. Publishing either puts material in front of a reader that describes neither the product nor how to contribute to it. Both remain one click away: the link rewriter turns every reference to them into a GitHub URL rather than dropping it. -
A hand-maintained ADR index page. Rejected: it is a file that must be edited in the same commit as every new ADR and whose staleness is invisible — an index missing yesterday's entry looks exactly like an index. Generating it from the directory makes the two incapable of disagreeing.
-
actions/configure-pages. Rejected as a link in the chain with nothing to contribute: its job is to hand the build a site URL and base path derived from the repository's Pages settings, andmkdocs.ymlhardcodessite_url.
Consequences¶
-
The three root files and the
docs/layout are now load-bearing site inputs. RenamingCONTEXT.md, moving a page out ofdocs/, or restructuringdocs/adr/'s filename convention breaks the build. This is caught by the PR gate rather than at deploy time, which is the right place, but it is a new coupling: a refactor of the repo's file layout now has a docs build to satisfy, andmkdocs_hooks.py'sINJECTEDmap is where the rename lands. -
Docs PRs now have a gate that fails on a broken link.
--strictplus the threevalidationsettings means a dangling relative path, a missing anchor, or a page absent from the nav fails the check. That is the point — it is what makes the rewriter trustworthy — but it is also a new bar contributors have to meet on changes that previously could not fail anything, and the failure text is MkDocs', not ours. -
The forge URLs the hook mints are pinned to
main. On a site built from an old tag, a link to an unpublished repo file resolves against today'smainand can therefore point at a file that has since moved. Deliberate: the alternative is pinning them to the built ref, which would make every such link in a PR preview point at the last release instead of at the branch under review — wrong in the case where the reader is most likely to be checking. Stale-but-current beats correct-but-misleading. -
The repo gains a Python surface it did not have. One file, stdlib plus MkDocs' own API, no test suite of its own beyond the build that exercises it on every docs PR. The rule that keeps this from growing is written into the file's docstring and belongs here too:
mkdocs_hooks.pytakes no dependency, ever. If it needs one, that is the signal that the job has outgrown a hook and should be reconsidered rather than fed. -
The link rewriter is regex-driven and therefore has a shape it does not handle. Link targets containing whitespace, parentheses or a Markdown title simply fail to match and are left alone — a deliberate fail-open, since leaving a link untouched is at worst a broken link MkDocs then reports, while mangling one is silent damage. But it means the repo's Markdown has an unwritten dialect constraint that only shows up when someone writes a link the hook does not recognise.
-
The site is only ever as fresh as the last tag. A documentation fix merged to
mainis invisible to a site reader until the next release, or until someone dispatches the workflow. The valve exists precisely because that is sometimes not good enough; using it is a judgement call, not a routine. -
Nothing publishes until a maintainer enables Pages. Settings → Pages, source "GitHub Actions" — one click no workflow token can perform. Until it happens, the
deployjob fails loudly insideactions/deploy-pagesrather than silently no-opping, which is the failure mode to want: a red tag run is discoverable, a green run that published nothing is not. -
Two renderers now have to agree on the same Markdown. GitHub renders the source; Material renders the rewritten copy. They differ in small ways — heading anchors, raw-HTML handling, table edge cases — so a page can look right in one and slightly wrong in the other, and the only way to know is to look.
nix develop .#docs --command mkdocs serveis the answer, and CONTRIBUTING.md now says so.