carlos / carloku Public

🤖 The runbook: ship _site/, bust both stylesheets, never the tree again

The deploy section said shipping publishes the repo tree. Since the site
moved under src/ that is not merely stale, it is destructive: a tree ship
has no _site/ in it, so it would publish index.html buried under src/, no
built pages and no /docs at all. That wording is gone rather than qualified
— left beside the new sequence, someone follows the wrong one at 2am.

What replaces it is the sequence that shipped carlosframework.com, with
this repo's real coordinates (app carloku, account bab, channel
canary/rehearsal, bucket carlos-flagship-271376211898, profile keymail),
confirmed against the bucket rather than remembered: clean git archive
export, npm ci, npm run check, cache-bust, ship _site/, promote.

The cache-bust carries two traps. This repo now serves TWO stylesheets, so
the bump must cover site.css and docs.css across all 13 built pages (24
links today) — hence a find over _site rather than a file list. And both
the sed and its verification grep end at the closing quote, because a sha
beginning with 0 — this app has shipped 01b5365 — makes ?v=01b5365 contain
the literal ?v=0: unanchored, the grep called 13 of 13 correctly bumped
files stale and a second sed run produced ?v=01b536501b5365. Rehearsed
against a clean export on a zero-leading, a digit-leading and a
letter-leading sha, twice each.

Also new: a "Building the docs" section saying that src/docs/ is authored
here — there is no sync step, unlike the sibling site whose corpus is
vendored from the platform repo — and warning that Eleventy does not clean
stale output, so a deleted page survives in _site/ with the gate none the
wiser. README states the rule the sibling paid for: if a URL should return
it, the file goes under src/.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 3d01a74dcbf45741a4a2d3863266f1b0bd9261ad parent 4673d8c
2 files changed, +202 −15
  • AGENTS.md +183 −12
  • README.md +19 −3
diff --git a/AGENTS.md b/AGENTS.md
index 5dfe272..caabe96 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -34,15 +34,186 @@ Rules inherited from the `website` repo, which governs the sibling site:
- **Don't overclaim.** The console requires Keymail sign-in; pricing does
not exist yet, so the site says nothing about it.
-Deploys: bucket-mode CARLOS app `carloku` on flagship — ship a clean
-`git archive` export via the pinfra `scripts/ship-app.sh` / `promote-app.sh`
-(see the platform repo's AGENTS.md "Deploy reality"). Merging does NOT
-publish; the site is stale until someone ships. Since platform PR #108
-(2026-08-08), the fleet converges within seconds of ship+promote — no
-need to wait for the next adoption pass. Verify by content, e.g.
-`curl -s https://carloku.com/ | grep -i "<something from the change>"`,
-or with `carlos channels -app carloku` showing the new sha promoted —
-static routes carry `X-Carlos-Version` too (since the platform#112
-roll — verified live 2026-08-23: the header reports the promoted sha),
-so `curl -sI` is also proof. The pinfra ship+promote scripts remain the
-operator default, and for this app that pair IS the deploy.
+## Building the docs
+
+`/docs` is ten pages of markdown under `src/docs/`, rendered by Eleventy into
+`_site/` before you ship. **The corpus is authored here, by hand.** That is the
+opposite of carlosframework.com, whose `/docs` corpus is vendored out of the
+platform repo by a sync script: there is no sync step in this repo, nothing
+under `src/docs/` or `src/_data/docsnav.json` is generated, and nothing will
+overwrite an edit you make. The markdown IS the source — edit it here.
+
+```
+npm install
+npm run check # builds, then gates the rendered output
+npm run serve # localhost:8080, live reload
+```
+
+`npm run check` is `eleventy` followed by `hack/check-docs.mjs`, which reads
+`_site/` after the build: the hand-written pages and shared assets were
+emitted at all; every nav entry has a built page and a built `.md` twin; every
+internal `/docs` href resolves to a built file, and to a real `id="…"` when it
+carries a fragment; and `slugify` in `eleventy.config.js` agrees with Go's
+`internal/docsite.Anchor` on every case in `src/_data/docsanchors.json`. That
+last one is why the anchor fixture is vendored rather than retyped: a heading
+whose fragment the platform's Go gate accepts cannot 404 in a browser here
+because the two slug rules drifted.
+
+**Eleventy does not clean stale output.** Delete or rename a page and its old
+built HTML stays in `_site/` until you remove it — and the gate will *not*
+notice, because every check it runs is satisfied by a file that should no
+longer exist. `_site/` is gitignored, so a stale page never shows up in a diff
+either. **Build clean whenever you are verifying anything:**
+
+```
+rm -rf _site && npm run check
+```
+
+The deploy sequence below gets this for free: it builds inside a fresh
+`git archive` export, which has no `_site/` in it at all.
+
+`index.html` and `get-started/index.html` are hand-written and pass through the
+build byte-identical — `templateFormats` excludes `html` on purpose, so
+Eleventy copies them instead of rendering them as templates. If either changes
+in `_site/` without you editing its source, something is wrong.
+
+## Deploying
+
+Merging does **not** publish. `carloku.com` is a bucket-mode CARLOS app on the
+flagship, and the live site is stale until someone ships.
+
+**There is a build step now, so what ships is `_site/` — never the repo tree.**
+A tree ship has no `_site/` in it: it would publish `index.html` buried under
+`src/`, no built pages, and no `/docs` at all.
+
+The deployment coordinates, confirmed against the bucket on 2026-08-27:
+
+| | |
+| --- | --- |
+| app | `carloku` |
+| account (sqid) | `bab` |
+| channel | `canary/rehearsal` — the app's only channel |
+| kind | `static` |
+| bucket | `carlos-flagship-271376211898` |
+| AWS profile / region | `keymail` / `eu-west-1` |
+
+```
+SHA=$(git rev-parse --short HEAD) # the sha you are shipping
+
+# 1. Clean export. Never ship a working checkout — PackDir packs every
+# regular file it sees, including .git, node_modules/ and .claude/.
+rm -rf /tmp/carloku-ship && mkdir -p /tmp/carloku-ship
+git archive "$SHA" --prefix=export/ | tar -x -C /tmp/carloku-ship
+cd /tmp/carloku-ship/export
+
+# 2. Build, and gate the rendered output. `check` runs eleventy, then
+# hack/check-docs.mjs over what it produced.
+npm ci
+npm run check
+
+# 3. Cache-bust BOTH stylesheets across every built page — see Caching
+# below; this is not optional.
+find _site -name '*.html' -exec sed -i \
+ -e "s/\.css\"/.css?v=$SHA\"/g" \
+ -e "s/\.css?v=[^\"]*\"/.css?v=$SHA\"/g" {} +
+
+grep -rho '\.css?v=[^"]*"' _site --include='*.html' | sort -u # ONE line: your sha
+grep -rn '\.css"' _site --include='*.html' \
+ && echo "UNBUSTED LINK — do not ship" || echo "cache-bust ok"
+
+# 4. Ship the BUILT OUTPUT, then promote. The pinfra scripts are the
+# canonical form for this app: the signing key is read from SSM inside
+# the script and never appears in a terminal or a transcript.
+cd ~/github.com/carlosframework/platform-infrastructure
+CARLOS_BIN=$(command -v carlos) scripts/ship-app.sh \
+ keymail carlos-flagship-271376211898 \
+ carloku "$SHA" canary/rehearsal static /tmp/carloku-ship/export/_site
+```
+
+`ship-app.sh` takes `<aws-profile> <bucket> <app> <version> <channel> <kind>
+<file-or-dir>` and does both halves. The equivalent by hand, if you are not in
+the pinfra checkout, is:
+
+```
+export AWS_PROFILE=keymail AWS_REGION=eu-west-1 \
+ CARLOS_DEPLOYMENT_BUCKET=carlos-flagship-271376211898
+carlos ship --app carloku --kind static --version "$SHA" _site
+CARLOS_RELEASE_KEY=$(aws ssm get-parameter --name /carlos/release-key \
+ --with-decryption --query Parameter.Value --output text) \
+ carlos promote --app carloku "$SHA" canary/rehearsal
+```
+
+The env matters: without `CARLOS_DEPLOYMENT_BUCKET` the CLI goes through the
+console API, where this app was never registered, and fails with "not found".
+This is bucket mode. Use a current `carlos` binary — the ship/promote pair is
+the platform's own release path, and an old binary on your `PATH` is a real
+hazard here.
+
+**Step 3 explained, because both halves are load-bearing.** The links in `src/`
+are plain (`href="/site.css"`), so the first expression stamps a token onto a
+bare link and the second re-stamps a link that already carries one. Both end at
+the closing quote on purpose, and so does the verification grep. A sha
+beginning with `0` — this app has already shipped `01b5365` — makes
+`?v=01b5365` contain the literal substring `?v=0`, so an unanchored grep for
+`?v=0` calls every freshly bumped file stale (13 of 13, measured), and an
+unanchored `sed` run twice yields `?v=01b536501b5365`. Anchoring on `"` fixes
+both and makes step 3 idempotent: run it again and again, the answer is the
+same. Today that is 24 links across 13 built pages — 13 × `site.css`, 11 ×
+`docs.css`. Don't hard-code those counts anywhere; the corpus grows, which is
+also why the `find` walks `_site` instead of naming files.
+
+**Caching: the edge sends no `Cache-Control` and no `ETag` on this static
+route — only `Last-Modified`** (confirmed live on carloku.com 2026-08-27).
+Browsers therefore apply HEURISTIC caching, roughly 10% of the age since
+`Last-Modified`, so a returning visitor can hold a stale page for days. This
+bit the sibling site the day its redesign shipped: one browser served the whole
+old page, another served the NEW html against the OLD stylesheet. The real fix
+is server-side `Cache-Control` on static routes — platform issue
+**carlosframework/platform#234**. Until that lands, **step 3 is a required part
+of deploying this site**, and it is required for `docs.css` exactly as much as
+for `site.css`: the docs pages link both, and a docs page rendered against a
+stale `docs.css` loses its whole layout.
+
+**Convergence is seconds, not minutes** (platform PR #108, live 2026-08-08): a
+promote is picked up by the edge within ~2s. Verify by header and by content,
+and verify `/docs` **specifically** — the landing page looks right whether or
+not the built docs made it into the artifact:
+
+```
+# by header — --resolve so a stale DNS answer cannot satisfy it. 99.81.104.219
+# is the flagship, where the carloku.com A record points.
+curl -sI --resolve carloku.com:443:99.81.104.219 \
+ https://carloku.com/docs/ | grep -i x-carlos-version
+
+# by content
+curl -s https://carloku.com/ | grep -i "<something from the change>"
+curl -s https://carloku.com/docs/ | grep -o 'docs\.css?v=[^"]*' # your sha
+```
+
+Static routes do carry `X-Carlos-Version` (platform#112). `-I` is fine for that
+header, but it sends a HEAD, and HEAD is excluded from edge compression —
+anything about `Content-Encoding` needs a GET.
+
+**`carlos channels` cannot verify this app.** It is direct-bucket and invisible
+to the console API: `carlos channels -app carloku` answers "not found"
+(re-confirmed 2026-08-27). Verify by header or content as above, or read the
+pointer out of the bucket
+(`aws s3 cp s3://carlos-flagship-271376211898/apps/bab/carloku/channels/canary/rehearsal.json -`).
+
+Rollback is a pointer move, like any CARLOS app — promote the previous good sha
+back onto the same channel; the edge picks it up in seconds:
+
+```
+cd ~/github.com/carlosframework/platform-infrastructure
+CARLOS_BIN=$(command -v carlos) scripts/promote-app.sh \
+ keymail carlos-flagship-271376211898 bab carloku <previous-sha> canary/rehearsal
+```
+
+That previous release is still in the bucket under
+`apps/bab/carloku/releases/<sha>/`, cache-busted as it was shipped, so a
+rollback restores a self-consistent page-plus-stylesheet pair.
+
+(The channel is `canary/rehearsal`, not `stable`, for the same reason the
+sibling site's is: `stable` bakes 72h on a box's *first* sighting of a channel
+head, which would mean 72h of downtime for a never-before-served route. A
+future `stable` flip is optional cleanup, not required.)
diff --git a/README.md b/README.md
index 26fe728..dbec513 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,22 @@
# carloku.com
-🤖 Marketing site for Carloku, the hosted CARLOS platform, plus the
-customer docs at /docs. The marketing pages are hand-written static HTML; the
-docs are built from markdown before shipping. See AGENTS.md for the rules; see
+🤖 Marketing site for Carloku, the hosted CARLOS platform, plus the customer
+docs at /docs. The marketing pages are hand-written static HTML; the docs are
+markdown under `src/docs/`, built by Eleventy before shipping.
+
+**There is a build step, and it decides what the site serves.** Eleventy reads
+`src/` and writes `_site/`, and `_site/` is what ships — never the repo tree.
+So: **if a URL should return it, the file goes under `src/`.** A file at the
+repo root is a repo file, not a page, and will not be served. Anything the site
+needs — a page, a stylesheet, a font, an image — lives under `src/`, and a
+non-template asset also needs an `addPassthroughCopy` line in
+`eleventy.config.js` to be emitted at all.
+
+```
+npm install
+npm run check # build, then gate the rendered output
+npm run serve # localhost:8080
+```
+
+See AGENTS.md for the deploy runbook and the rules of the repo; see
https://carlosframework.com for the framework itself.