Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Deploying the documentation site

The site lives at docs/site/ and deploys to Cloudflare Pages.

Why not GitHub Pages

GitHub Pages does not serve from a private repository on the free tier. On paid tiers it will — but the published site is public regardless; only Enterprise offers access-controlled Pages. For a private repository whose docs should stay private, GitHub Pages cannot do the job at any tier below Enterprise.

Cloudflare Pages serves from a private repository, and Cloudflare Access can put the site behind SSO on the free Zero Trust tier. A private repo gets private docs.

How it deploys

Cloudflare’s GitHub App watches the repository and rebuilds on every push to main that touches the site. There is no deployment workflow in this repository and there should not be one — a second deploy path would race the first.

Project settings (Cloudflare dashboard → Workers & Pages → the project):

SettingValue
Build command(none — the site is static)
Build output directorydocs/site
Root directory/
Production branchmain

The ordering consequence, which matters

Cloudflare deploys independently of GitHub Actions. Its app reacts to the push directly, so the checks in docs.yml run alongside the deploy rather than before it.

That is fine for the correctness gates — a stale count merged and deployed is embarrassing, not dangerous. It matters for check-public-surface.sh, which exists to stop a credential or an internal identifier reaching a world-readable site. On a direct push to main, that scan runs while the content is already live.

So the ordering must come from branch protection rather than from workflow sequencing. Require pull requests into main, and make the docs checks required status checks. Then nothing reaches main without passing, and Cloudflare only ever sees a branch that has passed — the ordering is restored, just enforced a step earlier.

Without branch protection the gates are advisory for the published site. That is a legitimate choice for a small trusted team, but it should be a choice rather than an assumption, because the failure it stops is the one that cannot be undone by reverting: a secret that was briefly public has been public.

Visibility: public, deliberately

The site is published publicly. Cloudflare Access is deliberately not applied.

The repository stays private; the documentation does not. The site holds architecture, decisions and interfaces — no production identifiers, no credentials, no data, no commercial terms — and that is a property the hard rule in AGENTS.md exists to maintain rather than a happy accident.

It also resolves a coupling that would otherwise bite at first publish: crates are published to crates.io publicly and their READMEs link here, so Access-protected docs would strand every reader arriving from a crate listing.

What this changes

The sensitive-information rule is no longer precautionary. It is the control on a world-readable surface, and anything merged under docs/site/ is public on the next deploy. scripts/check-public-surface.sh runs in the Docs CI checks (docs.yml) as a backstop for the mechanical classes of mistake — credential shapes, internal system names, workspace hostnames, connection strings, private addresses. It is not a guarantee: it cannot catch a plainly-worded sentence naming something confidential, and the rule remains the actual control.

If this needs to change

Cloudflare dashboard → Zero Trust → Access → Applications → Add an application → Self-hosted, pointed at docs.thalweg.dev, with an email-domain or identity- provider policy. The free tier covers 50 users. Note that applying it strands crates.io readers unless a public landing page exists at the apex for the crate READMEs to point at instead.

Custom domain: docs.thalweg.dev

Cloudflare dashboard → the thalweg-docs Pages project → Custom domains → Set up a custom domain → docs.thalweg.dev. If the DNS is hosted by Cloudflare the record is created automatically; otherwise add a CNAME pointing at the project’s pages.dev hostname.

The apex thalweg.dev is deliberately left free for a future public landing page.

.dev is on the HSTS preload list, so browsers require HTTPS before the first request is sent. Cloudflare provisions the certificate automatically; there is nothing to configure, but a deploy is not reachable until the certificate is issued, which is usually a minute or two rather than instant.

Why a subdomain rather than the apex

Cloudflare Access is applied per-hostname. Putting the docs at the apex would put the entire domain behind whatever policy the docs need, leaving no room for a public landing page later without moving the docs — and moving them once links exist is the expensive version. A subdomain costs nothing now and keeps the apex free.

If the project goes public, nothing moves: Access comes off docs.thalweg.dev and the URL keeps working.

The coupling with publishing crates

Published crates are public on crates.io, and their READMEs link to docs.thalweg.dev. If the docs sit behind Access, a reader arriving from crates.io hits an authentication wall — and the repository is private too, so there is nothing else for them to reach.

Resolve this before the first publish, not after. Either:

  • the docs are public and Access stays off — defensible, since the site holds architecture and decisions with no production identifiers, credentials or data, which the AGENTS.md rule exists to guarantee; or
  • a public landing page goes at the apex and the crate READMEs point there, with docs.thalweg.dev staying behind Access; or
  • crates are not published while the documentation is private.

Publishing public crates that link to private documentation is the one combination to avoid: it produces crates.io listings nobody can follow up on.

Validation and publishing

There is no deploy workflow in this repository. Publishing is Cloudflare’s GitHub App reacting to the push; validation is the Docs CI checks (docs.yml), run as required status checks on the pull request so nothing reaches main unvalidated:

  • every HTML file parses — a broken page should not merge;
  • the site’s crate list matches the workspace manifest — stale documentation should not merge either, which is the failure more likely to go unnoticed;
  • check-public-surface.sh scans docs/site/ for sensitive content.

Once merged, Cloudflare’s app builds and publishes automatically. Deployments are immutable and Cloudflare keeps history, so rolling back is selecting a previous deployment in the dashboard.

The repository map (graphify)

The site also serves an interactive repository map at docs.thalweg.dev/graphify/, generated with graphify.com. It follows the same single-source-of-truth discipline as the rest of the site.

graphify-out/ is canonical. It lives at the repository root and is regenerated by graphify’s git hooks — never hand-edited. It holds the full graph plus caches, cost/telemetry and manifests, none of which belong on a public site.

docs/site/graphify/ is DERIVED — never hand-edit it. It is a published copy of exactly three world-safe files, produced by scripts/sync-graphify-site.sh: graph.htmlindex.html, graph.json, and GRAPH_REPORT.md. The script copies nothing else — no cache/, no cost.json, no manifest.json, no .graphify_* — so telemetry and internal metadata never reach the public surface.

The install: this repository uses tracked hooks. Run once, locally:

git config core.hooksPath .githooks

This supersedes graphify’s locally-installed .git/hooks/ post-commit and post-checkout — their rebuild machinery (Python discovery via graphify-out/.graphify_python, scan root via graphify-out/.graphify_root, the GRAPHIFY_OUT default, and the graphify.watch._rebuild_code call) is ported into .githooks/. .githooks/post-checkout keeps the detached branch-switch rebuild; there is deliberately no .githooks/post-commit, because the synchronous .githooks/pre-commit now owns rebuild-and-publish and a post-commit rebuild would be redundant.

.githooks/pre-commit does a synchronous, incremental rebuild of just the staged changes, then runs scripts/sync-graphify-site.sh to publish and stage the three files — so the map lands in the same commit as the changes it reflects. It is fail-soft: a graphify error or a graphify that is not installed prints a non-fatal warning and the commit proceeds, publishing the last-good map. A missing or broken graph never blocks a commit.

Rendered documentation: rustdoc and mdBook (ADR-0059)

Two reference surfaces are rendered locally and committed into docs/site/, so Cloudflare serves them verbatim and the build command stays NONE. They are decided in ADR-0059.

SurfaceServed atSourceRendered byCommitted to
API reference (rustdoc)docs.thalweg.dev/api/, per-crate /api/<crate>/every workspace cratecargo doc --workspace --no-deps --all-features via scripts/gen-api-docs.shdocs/site/api/
Prose reference (mdBook)docs.thalweg.dev/reference/docs/adr, docs/blueprints, docs/operations, docs/testing, docs/PUBLISHING.mdmdbook build docs/book via scripts/gen-reference-book.shdocs/site/reference/

The crates are unpublished (ADR-0047), so docs.rs cannot carry them — self-hosting is what lets the API reference exist at all, and it covers the four permanently internal crates (twg-cli, the two *-arrow compositions, twg-e2e) too.

Navigation is generated, not hand-maintained. gen-reference-book.sh mirrors the docs tree into the book’s src/ (frontmatter stripped as a pre-render step so the ADR/blueprint YAML does not show as raw text), regenerates SUMMARY.md, and builds into docs/site/reference/ (build-dir in docs/book/book.toml). Drop a markdown file into the right folder and it appears on the site with no nav edit. docs/book/src/ is derived and git-ignored; only the rendered output is committed.

Regeneration is wired into the pre-commit hook. .githooks/pre-commit (which already rebuilds the graphify map) additionally, on each commit: regenerates the API docs for any changed crates/<name>/**, and rebuilds the reference book when any docs/** markdown changed — staging the output so it lands in the same commit. Every step is fail-soft: a missing cargo/mdbook or any error warns and the commit proceeds. This requires the tracked hooks to be active:

git config core.hooksPath .githooks

Prerequisites for local regeneration: the Rust toolchain (for cargo doc) and mdBook (cargo install mdbook). Without them the hook fail-soft no-ops and the rendered trees can go stale until someone with the tooling commits.

The generated search indexes are the one wrinkle for check-public-surface.sh: rustdoc’s search.index/ and mdBook’s searchindex.js are minified blobs, and rustdoc’s matches the AWS-key shape by coincidence. The gate excludes only those search-index artifacts, never the trees around them, so every rendered HTML page is still scanned for secrets.

Limits

The free tier allows 500 builds per month and 20,000 files per deployment, with unlimited bandwidth. The hand-authored landing page plus the graphify map is a handful of files; the rendered rustdoc and mdBook trees add a few thousand more — still an order of magnitude under the 20,000-file ceiling, but a number to watch as the crates gain real API surface. Run find docs/site -type f | wc -l to check.