Skip to content

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.md and CONTRIBUTING.md have to stay at the root — that is where GitHub renders them, where every existing link points, and where a contributor looks — but MkDocs only sees docs_dir. mkdocs_hooks.py reads them at build time and appends them to the file collection as File.generated pages (index, context, contributing). Committing copies under docs/ 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.md is built by walking docs/adr/, taking the number from each filename and the title from each file's H1. The point is the cost of the next ADR: one git add and 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 --strict is what tests it. The Markdown in this repo is written to read on GitHub: relative paths into docs/, into internal/, 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: with validation.unrecognized_links/omitted_files/anchors: warn and --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 of docs/agents/, and non-doc files like LICENSE, internal/compat/compat.md, nix/module.nix, the workflow files — becomes a https://github.com/Cloonar/coding-lab/blob/main/… URL, so no link on the site 404s. Directories become tree/main/…, with docs/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 makes exclude_docs and 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 under use_directory_urls is not the source path — and getting that distinction right is what makes README's three <img> screenshots render.

  • exclude_docs is 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 under docs/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.md is 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 with not_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 no requirements.txt. flake.nix gains pkgs.python3.withPackages (ps: [ ps.mkdocs-material ]) — mkdocs 1.6.1 and Material 9.7.6 at the current lock — added to the default devshell so mkdocs serve works without leaving nix develop, and to a new lean devShells.docs holding 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: a requirements.txt beside mkdocs.yml would 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.yml runs build on every trigger: contents: read, no secrets.* referenced, nix develop .#docs --command mkdocs build --strict, then upload the Pages artifact. deploy (needs: build) is if-gated to tag pushes and workflow_dispatch, so a pull_request cannot reach it, and it is the only job in the file that holds pages: 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 why mkdocs gh-deploy is not used: gh-deploy puts a build artifact in git history on a gh-pages branch and needs contents: 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 on v* tags plus a workflow_dispatch escape 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 their lab does not have. Merging to main is not a publish event; cutting a release is. workflow_dispatch is the deliberate valve for the cases where that rule needs breaking — a dead link, a wrong command — without inventing a second trigger.

  • docs is deliberately not a required check, which is precisely why it needs no no-op twin. ci-nix-noop.yml exists because ci-nix is 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 no docs check — 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.md trustworthy — 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.md and CONTRIBUTING.md into docs/. 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 install in the CI job. Rejected on ADR-0002's grounds: a second pinning mechanism beside flake.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-deploy pushing a gh-pages branch. Rejected: it commits a build artifact into git history — every deploy a tree of generated HTML in the repo, forever — and it needs contents: write on the job that publishes. The Pages artifact flow needs neither, and keeps the only write scope in the file down to pages: write + id-token: write on 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.

  • mike and 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, a gh-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 a CNAME are likewise deferred; cloonar.github.io/coding-lab is a URL that works today and costs no DNS.

  • Publish docs/agents/ and the lab-v0 reference. Rejected: docs/agents/ is written for agents working on this repo and is read from the checkout, and docs/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, and mkdocs.yml hardcodes site_url.

Consequences

  • The three root files and the docs/ layout are now load-bearing site inputs. Renaming CONTEXT.md, moving a page out of docs/, or restructuring docs/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, and mkdocs_hooks.py's INJECTED map is where the rename lands.

  • Docs PRs now have a gate that fails on a broken link. --strict plus the three validation settings 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's main and 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.py takes 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 main is 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 deploy job fails loudly inside actions/deploy-pages rather 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 serve is the answer, and CONTRIBUTING.md now says so.