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):
| Setting | Value |
|---|---|
| Build command | (none — the site is static) |
| Build output directory | docs/site |
| Root directory | / |
| Production branch | main |
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.mdrule exists to guarantee; or - a public landing page goes at the apex and the crate READMEs point there,
with
docs.thalweg.devstaying 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.shscansdocs/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.html → index.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.
| Surface | Served at | Source | Rendered by | Committed to |
|---|---|---|---|---|
| API reference (rustdoc) | docs.thalweg.dev/api/, per-crate /api/<crate>/ | every workspace crate | cargo doc --workspace --no-deps --all-features via scripts/gen-api-docs.sh | docs/site/api/ |
| Prose reference (mdBook) | docs.thalweg.dev/reference/ | docs/adr, docs/blueprints, docs/operations, docs/testing, docs/PUBLISHING.md | mdbook build docs/book via scripts/gen-reference-book.sh | docs/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.