--- id: publishing title: A post reaches the network in one deploy status: continuous repos: [headquarters, infra] dependsOn: [] exitCriterion: > Writing a post, deploying it and publishing it to ATProto is one pass, and a shared link unfurls as the thing it points at. --- # publishing The blog is prerendered from markdown in `web/src/content/blog` with an Atom feed at `/atom.xml`, and posts are published to lance.blue's own repository as standard.site records with [sequoia](https://sequoia.pub). The first post carries its `site.standard.document` record. ## Open - [ ] **`web/scripts/publish.sh` has never run.** The publication record and the first document record were made with the sequoia CLI directly, so the script exists and is tested but has never met the real thing. Its live check reads the URLs out of `sequoia publish --dry-run --verbose`, and whether that parsing matches the CLI's actual output is unproven. The next post is the first honest test of it. - [ ] **A new post takes two deploys.** Publishing writes `atUri` back into the post's frontmatter and the link tag is rendered from it, so the tag can only appear in a build made after the record exists: commit, deploy, publish, commit the `atUri`, deploy again. Editing a post that already has a record is one deploy. The lag is cheap. The record carries the post's own text, so a reader seeing it in the network gets the whole post either way; what the missing tag costs is Bluesky's richer preview, which reads the tag off the page. Doing it in the other order is not cheap: publishing also posts to Bluesky announcing the URL, so publishing before the page is served sends that announcement's clicks to the app shell, which answers 200 rather than 404. Worth deciding whether `bluesky.enabled` should be on at all. - [ ] **Two posts published on one day have no order.** `/blog` sorts on `publishDate`, which is a date with no time, so posts sharing a day compare equal and fall back to whatever order the collection loader happened to return. The same value becomes the record's `publishedAt`, so a reader sorting by that ties too. `z.coerce.date()` already accepts a time, so writing one in the frontmatter would settle it — but nothing says so, and nothing catches the tie. - [ ] **One social card for the whole site.** `npm run social-card` renders `web/public/social-card.png` from the insignia, and every page points the Open Graph tags at it. A prerendered page can carry its own title and description, but the app's client routes still share one set of tags, because there is one file behind all of them. A per-match card needs something server-side that renders the tags for a match URL — unfurlers do not run JavaScript, so the SPA can never do it. - [ ] **`VITE_API_ORIGIN` is baked in at build time.** Fine for one environment; it means a second one is a second build, not a second config. Unset, a production build calls same-origin paths; the loopback default is dev-only, because a deployed page fetching 127.0.0.1 trips Chrome's Local Network Access prompt on every visitor. ## The plan, rendered - [ ] **`plan/` is a public page, written on deploy.** Every epic already carries the frontmatter an Astro content collection wants, and `web/src/content.config.ts` already runs the `glob` loader the blog uses; pointing a second collection at this directory, with README.md excluded, is the build half. Nothing renders it today. The reason it sat unbuilt is not the build step. A public roadmap is a promise, and twenty-seven epics with statuses read as a commitment to twenty-seven things. What to render is therefore part of the work: the register as it stands, or the milestones in [milestones.md](milestones.md), which is the same work in prose and without the appearance of a schedule. ## Done - [x] **The card is set in the site's own type.** It was drawn in Noto Sans while every page was set in Chakra Petch, and the wordmark on it was lower case with negative tracking where `.brand-name` is upper case, tracked out, and puts `.blue` in the brand colour. Nothing said so: sharp renders SVG through librsvg, librsvg finds faces through fontconfig, and fontconfig reads neither woff2 nor `src/fonts/` — so a `` element could only ever resolve to whatever the machine had installed, and the PNG is committed, so no build ever redrew it. The glyphs are now outlines taken from the same woff2 files the browser downloads, which fixes the drawing and the reproducibility together. The alpha stamp is on it as well, built from `.stamp`'s own numbers, so a card in a feed says what every page of the site says. `social-card.test.mjs` resolves each `--font-*` token the script names and fails if the file it loads is a different family. - [x] **Deploy is `web/scripts/push.sh`** — builds and pushes to `releases/-` in infra's site bucket. Serving a build is infra's side: point `site_origin_path` at the path, apply, invalidate. `scripts/deploy.sh` does both artifacts and writes the release id into infra's `releases.auto.tfvars.json`, leaving the apply to be run there. - [x] **A dead link answers 404.** `web/src/pages/404.astro` builds a real `404.html` and infra's side points CloudFront's 403/404 mappings at it with an honest status. Both halves are live: `curl -i lance.blue/no-such-page` returns `HTTP/2 404` and the real page, which was this item's stated closing condition. The router's `notFoundScreen` stays for dead hashes, which never reach CloudFront.