No serial comma in a simple series, numbers per AP, long sentences split. DESIGN.md gains Words in docs: docs prose is AP style, interface chrome keeps the lowercase rules.
- commit
- 00f0dc723056bab0795a7c25a973c1d0dfc73f35
- parent
- 719bd89270c4caf5f75ac373a87f7ef923289adf
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 154s, signed by cat
- kind
- docs risk score 0.0 of 3, Jev's estimate
- review
- #23 The guide says what forge is; internals says how
ci attestation
forge-attestation 1 type ci commit 00f0dc723056bab0795a7c25a973c1d0dfc73f35 tree 222bb6772ad516a133dc4621c0cb57c595698841 script .forge/ci 91d640bd19a231e82d21cc0a10d907253d9b0f41 result pass exit 0 started 2026-10-19T06:26:40Z duration 154s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:1b665637aaead66c1b68bcfde70dcfbfb06fb9cb0f1842925248ac56b3c06139 295 lines |pushed wip/i18n |d87d8a8 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |d87d8a8 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |430005e running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |430005e fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |4418ff1 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |4418ff1 pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed e1e43cf -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |6856a5f running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |6856a5f pass in 0s, signed |pushed main |74a925e running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |74a925e pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed 061838f -> /sites/dami/forge/guide/ |(node:59075) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.RvA7ul/wt/demo/root/.forge/www |(node:59275) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.RvA7ul/wt/demo/root/.forge/www |(node:59487) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.RvA7ul/wt/demo/root/.forge/www |(node:59698) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 4s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.RvA7ul/wt/demo/root/.forge/www |(node:60497) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.RvA7ul/wt/demo/root/.forge/www |235 of 235 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | DESIGN.md | 10 | ++ |
| M | README.md | 153 | +++++++++++++++--------------- |
@@ -120,6 +120,16 @@ minute, with the reader's full local date and time on hover.
120120 - The page has the day before the script runs, no wider than what replaces121121 it, so nothing moves on load.122122 123+## Words in docs124+125+The guide, the README and other docs are written in AP style, for126+readability: no serial comma in a simple series; one through nine spelled127+out and figures from 10, always figures for versions, sizes, times and128+percentages; sentence-case headings; short sentences in the active voice;129+dates like Oct. 8, 2026. Interface chrome follows the lowercase rules130+above. Code, CLI output and the interface's compact times keep their own131+forms.132+123133 ## Controls124134 125135 Words for where you are, symbols for how you look at it.@@ -2,7 +2,7 @@
22 33 A personal forge built from plain git. The server is the setup from44 [Git on the Server](https://git-scm.com/book/en/v2/Git-on-the-Server-Setting-Up-the-Server):5-a `git` user, SSH keys, and bare repositories. Pushes run two hooks, and an5+a `git` user, SSH keys and bare repositories. Pushes run two hooks, and an66 [11ty](https://www.11ty.dev/) build writes static pages in the style of77 [stagit](https://git.suckless.org/). The state is files in the bare repos,88 a directory of HTML that any static file server can serve, and one SQLite@@ -14,7 +14,7 @@ runs](#what-runs)).
1414 1515 It adds what stagit doesn't have, borrowing from [args.io](https://args.io):1616 17-- **Every branch is browsable.** Log, files, and blobs for each branch, plus a17+- **Every branch is browsable.** Log, files and blobs for each branch, plus a1818 refs page with ahead and behind counts.1919 - **Reviews are stacked diffs.** Any pushed branch is a review. A branch built2020 on another review's branch is detected as the next layer of a stack. A@@ -32,7 +32,7 @@ It adds what stagit doesn't have, borrowing from [args.io](https://args.io):
3232 worktree. It signs the result with the SSH key you push with and stores it3333 as a git note. Pages show the result for each commit, and `main` can require3434 a verified pass to accept a push.35-- **Sites ship with repos.** Docs, Docusaurus builds, or anything static is35+- **Sites ship with repos.** Docs, Docusaurus builds or anything static is3636 committed to `refs/sites/<name>` and served from the forge. Each review3737 branch gets its own preview. The wiki is markdown in `wiki/`.3838 - **Releases are signed files.** `forge release publish v1.0 <file>...`@@ -66,11 +66,11 @@ The demo creates two users and a repo with:
6666 - a contribution from the second user whose CI fails and then passes, and which `main`6767 turns away because nobody approved it;6868 - a draft that grows, then is rebased onto a newer `main` and reworked;69-- an abandoned branch, a wiki, and a docs site with a preview;69+- an abandoned branch, a wiki and a docs site with a preview;7070 - a release of `v0.1`: the script and a source tarball;7171 - a standalone site that the second user owns;7272 - code in JavaScript, Go, CSS, Python and markdown fences, and a review of a73- 1200-line file with long lines.73+ 1,200-line file with long lines.7474 7575 Its clock is fixed: commits get set dates, and the hooks stamp reviews with7676 `FORGE_NOW` (an override for the demo and tests) instead of the wall clock.@@ -97,7 +97,7 @@ review page, signed in ─── HTTPS ────────────> the
9797 | Process | Why |9898 | --- | --- |9999 | sshd | git over ssh. Every key is forced through `forge shell <name>`. |100-| Caddy | Serves `.forge/www` as plain files, read-only HTTPS clones (`git http-backend` through fcgiwrap; nothing else inside a bare repo is served; a private repo takes a token), and `go get` meta tags; proxies `/events/`, every path `www` has no file for (to the private server) and, with a pages domain, sites. |100+| Caddy | Serves `.forge/www` as plain files, read-only HTTPS clones (`git http-backend` through fcgiwrap; nothing else inside a bare repo is served; a private repo takes a token) and `go get` meta tags; proxies `/events/`, every path `www` has no file for (to the private server) and, with a pages domain, sites. |101101 | `forge build` | Runs 11ty after each push, in the background and one repo at a time, then swaps the new pages into `www/`. Unchanged pages are hard links to the last build's. First it adds the push's commits to the repo's commit-graph (see [Cleaning up](#cleaning-up)). |102102 | `lib/events-server.ts` | Tails `.forge/events.jsonl`, which hooks and builds append to, and streams it as server-sent events at `/events/<owner>/<repo>/`. Open pages say who pushed and swap in fresh content when the build finishes. Without it pages still work; their status line says offline. A private repo streams only to its readers, signed in. |103103 | `lib/private-server.ts` | Serves private repos' pages to their readers, signed in with a passkey or holding a token, the sign-in pages (`/login/`, `/private/`), and the 404 of every page that isn't there; takes comments and approvals posted from review pages (see [Comments and approvals](#comments-and-approvals)); answers Caddy whether a token may clone a private repo over HTTPS (see [Private pages](#private-pages)). |@@ -111,11 +111,11 @@ Without a pages domain, Caddy serves sites straight from `/sites/...` and
111111 `/previews/...` on the forge's own origin, and `_headers` and `_redirects` don't112112 apply. `forge server caddyfile <address> [<pages-domain>]` prints the Caddy113113 config for either setup. It serves assets, whose names carry a hash of their114-contents, with `Cache-Control: public, max-age=31536000, immutable`, and115-pages and the JSON and text indexes they fetch with `no-cache`, so browsers116-revalidate them with the ETag and Last-Modified Caddy sends and get a 304117-when nothing changed. Release downloads revalidate the same way, aren't118-compressed, and are sent as attachments (see [Releases](#releases)).114+contents, with `Cache-Control: public, max-age=31536000, immutable`. It115+serves pages, and the JSON and text indexes they fetch, with `no-cache`, so116+browsers revalidate them with the ETag and Last-Modified Caddy sends and get117+a 304 when nothing changed. Release downloads revalidate the same way, aren't118+compressed and are sent as attachments (see [Releases](#releases)).119119 `demo/serve.ts` sends the same headers.120120 121121 ### Identity and permissions@@ -147,7 +147,7 @@ Only the owner changes a repo's collaborators; its collaborators can list
147147 them, and no one else can. The name must be a user of the forge. `who` is148148 for anyone who can read the repo, so a private repo's is the same `no repo`149149 as a missing one's to everyone else. The operator has the same lists for150-any repo: `forge collaborators <owner>/<repo> [add|rm <user>]`, and `forge150+any repo: `forge collaborators <owner>/<repo> [add|rm <user>]` and `forge151151 readers`.152152 153153 #### People@@ -170,7 +170,7 @@ nobody on the forge has already), then does what `user add` does: the line
170170 in keys, the home directory, `keys sync`. With `--repo`, the new user is171171 that repo's reader or collaborator too. Then the same page offers to add a172172 passkey, as the link `ssh passkey` prints does. The code is in the URL's173-fragment and identity.db keeps only its hash, like enrollment codes; the173+fragment and identity.db keeps only its hash, like enrollment codes. The174174 posts come only from the forge's own page (Origin), at most 30 a minute175175 across the forge, one taking a key at a time. `user add` checks a key the176176 same way, so no key adds a second line to keys.@@ -220,7 +220,7 @@ ssh command on.
220220 `ssh git@git.example.com new <name>` creates a repo under your name, and so221221 does pushing to one that doesn't exist yet: `forge shell` sees the push before222222 git does, creates the repo the same way (same name rules, hooks installed),223-prints `created cat/notes`, and lets the push go on.223+prints `created cat/notes` and lets the push go on.224224 225225 ```sh226226 git push git@git.example.com:cat/notes.git main@@ -236,7 +236,7 @@ forge (without `--yes` it says what would go); collaborators can't, and
236236 either root, sites, previews, hostnames, release downloads and insights go237237 at once, and a mirror sync within 30 seconds deletes its pages. The bare repo waits in238238 `.forge/trash/<owner>/<repo>.git.<time>` for `FORGE_TRASH_DAYS` (14) days,239-backed up like the rest of the forge: `forge repo restore <owner>/<repo>`239+backed up like the rest of the forge. `forge repo restore <owner>/<repo>`240240 puts back the newest removal if the name is free, and `forge gc` deletes it241241 after that.242242 @@ -246,7 +246,7 @@ A repo is public while its bare repo has a `git-daemon-export-ok` file. That
246246 is git's own flag: `git daemon` and `git http-backend` serve only repos that247247 have it, and Forgejo toggles the same file. `forge repo new` adds it, and248248 `--private` leaves it out: `ssh git@git.example.com new --private <name>`,249-`forge repo new --private`, and the imports below.249+`forge repo new --private` and the imports below.250250 251251 A private repo can be read by its owner, its collaborators and the names in252252 `<repo>.git/forge/readers`: over ssh, on the web once they sign in (see@@ -254,10 +254,10 @@ A private repo can be read by its owner, its collaborators and the names in
254254 else it isn't there: a clone, fetch, archive, push or ssh command gets the255255 same answer, byte for byte, as for a repo that doesn't exist, however the256256 path is spelled, and so does every page of it. It has no entry on the257-index, owner pages or `repos.json`, its pages and live events go to its257+index, owner pages or `repos.json`. Its pages and live events go to its258258 readers only, and the pages mirror never sees it. Its sites and previews259259 aren't served unless `git config forge.publicsites true`. Its releases260-are, to its readers only, next to its pages, and over ssh (see260+are, to its readers only, next to its pages and over ssh (see261261 [Releases](#releases)). Pushes record reviews and print their links, which262262 work for its readers, and `forge reviews` reads them from the review logs263263 (without CI). The secret check still runs, since a secret stays in the@@ -275,10 +275,10 @@ ssh git@git.example.com ls # yours, and the private o
275275 The owner and collaborators flip a repo, the way Forgejo does. `private`276276 removes the flag, the repo's public pages, its entries on the index, owner277277 pages and `repos.json`, and its served sites, previews and their278-hostnames, moves its release downloads into the private root (and their279-files into the private store, see [Releases](#releases)), and builds280-its pages again there. Then it starts281-a mirror sync within 30 seconds, which deletes them from the bucket. Clones, caches and copies278+hostnames. It moves its release downloads into the private root (and279+their files into the private store, see [Releases](#releases)) and builds280+its pages again there. Then it starts a mirror sync within 30 seconds,281+which deletes them from the bucket. Clones, caches and copies282282 people already have can't be recalled. `public` first runs the secret check283283 over the whole history: every commit on a branch or tag, and the old review284284 revisions that review pages show, not only what a push adds. If it finds@@ -305,9 +305,9 @@ serves a private repo's page to someone who may read it (the rule
305305 `can_read` in `bin/forge` applies, kept in step with `lib/access.ts` by a306306 scenario) and is still a user, signed in with a passkey or showing a token,307307 with the headers Caddy gives pages (ETag, 304s, byte ranges, which code308-search reads `code.db` with) except `Cache-Control: private, no-cache`,309-and its release downloads as attachments, as Caddy sends a public repo's.310-Not signed in, not allowed, or a page that isn't there: the same 404, byte308+search reads `code.db` with) except `Cache-Control: private, no-cache`.309+It serves the repo's release downloads as attachments, as Caddy sends a310+public repo's. Not signed in, not allowed or a page that isn't there: the same 404, byte311311 for byte, which every missing page of the forge gets, public or not.312312 `/private/` lists the private repos you can read.313313 @@ -342,7 +342,7 @@ ssh git@git.example.com tokens [rm <id>]
342342 used, for 30 days unless `--ttl` (at most 365d). Send one as343343 `Authorization: Bearer <token>`, or as the password of basic auth:344344 `git clone https://cat:<token>@git.example.com/cat/notes.git`, a345- credential helper, or the token alone as the user name.345+ credential helper or the token alone as the user name.346346 347347 identity.db keeps only SHA-256 hashes of cookies, tokens, enrollment348348 and invite codes, and every credential stops working when its user's keys go (`forge@@ -433,7 +433,7 @@ add them (the container does that on every boot).
433433 #### On the web434434 435435 Signed in with a passkey (see [Private pages](#private-pages)), a review436-page ends in a comment box and an Approve button, and the combined diff436+page ends in a comment box and an Approve button. The combined diff437437 puts a + beside the line under the pointer, which opens a box for a438438 comment on that line. Pages are static files, the same for everyone, so439439 these start hidden and do nothing without JavaScript: a script asks@@ -545,7 +545,7 @@ publishes a directory you already have.
545545 deploy `refs/sites/*`.546546 547547 Deleting a branch deletes its previews: `post-receive` removes548-`refs/site-previews/*/<branch>`, the served link, and the preview's hostname,548+`refs/site-previews/*/<branch>`, the served link and the preview's hostname,549549 so the sites and review pages stop listing it. Previews of merged branches550550 stay until their branch is deleted. Their extracted files stay in the store551551 until `forge gc` (below).@@ -621,8 +621,8 @@ release, so `/cat/greet/releases/download/latest/greet-linux-amd64` keeps
621621 working from one release to the next. Hard links, not a symlink as for a622622 site: rclone skips symlinks, and the [mirror](#the-pages-mirror) copies623623 these. The page build links them into the repo's new pages before it swaps624-them in. Caddy serves them with `no-cache`, since a tag can be published625-again, uncompressed, and with `Content-Disposition: attachment`, since they624+them in. Caddy serves them uncompressed. It sends `no-cache`, since a tag625+can be published again, and `Content-Disposition: attachment`, since they626626 are anyone's files on the forge's own origin. Deleting the ref removes them.627627 628628 Clones and fetches don't see `refs/releases` (`uploadpack.hideRefs`), so@@ -672,12 +672,12 @@ forge's keys file, and leaves who administers the repo to that.
672672 673673 `/`, or the search button in the header, opens a search over the repo's674674 commits (subject, body, sha, author), reviews (number, title, branch,675-author), wiki pages, file paths, and the contents of the files on the675+author), wiki pages, file paths and the contents of the files on the676676 default branch. On the index and owner pages it searches repo names and677677 descriptions. There is no server: each build writes678678 `/<owner>/<repo>/search.json` (and `/repos.json`), which the page fetches the679679 first time the search opens, and matching runs in the browser. Commit bodies680-are cut to 240 characters and wiki pages to 2000, to keep the index small.680+are cut to 240 characters and wiki pages to 2,000, to keep the index small.681681 `t` still finds files by name on the current branch.682682 683683 Every section reads a query the same way (`site/assets/query.ts`): terms@@ -687,7 +687,7 @@ of whitespace in it matches any run of whitespace: `"fix the"` finds
687687 `fix the`, and `"fix the"` finds `fix the`. Every term must688688 match, ignoring case, and results mark each match, a phrase as a whole. A689689 quote opens a phrase only at the start of a term (in `it's"` it is just a690-character), an unclosed quote runs to the end of the query as a phrase, so a690+character). An unclosed quote runs to the end of the query as a phrase, so a691691 phrase narrows as you type it, and empty quotes are no term. So692692 `fix search` finds "Search fix for the box" and `"fix the search"` doesn't.693693 `node test/search-phrases.ts` runs the same code in Node, for@@ -703,19 +703,19 @@ which `search.json` points at (`"code"`). It is a SQLite database with an
703703 downloads it whole: the official [sqlite-wasm](https://sqlite.org/wasm) build704704 opens it through a small read-only VFS (`site/assets/codesearch.ts`) whose705705 reads are HTTP range requests, so a query fetches only the pages it touches.706-Any static server that honors `Range` works: Caddy, `demo/serve.ts`, and the706+Any static server that honors `Range` works: Caddy, `demo/serve.ts` and the707707 pages mirror on R2 through the edge, whose fallback passes the header on.708708 709-Once the query has a word of 3 characters (a trigram can't match less), it709+Once the query has a word of three characters (a trigram can't match less), it710710 goes to a worker, and a Code section lists each line that has every word,711711 ignoring case, as `path:line`, linked to that line of the file's page (the712712 source page for markdown). Its words are the terms the other sections read,713713 so a quoted phrase (`"import fs from"`) is one literal string, spaces and714714 all, in which a run of whitespace matches any run, tabs and alignment too;715715 a word inside another goes, since a line with the longer one has it.716-Words of 3 characters or more are looked up716+Words of three characters or more are looked up717717 in the index; shorter ones only filter the lines it finds. A phrase is718-looked up by its pieces between whitespace, those of 3 characters or more,718+looked up by its pieces between whitespace, those of three characters or more,719719 since no trigram may span whitespace that can be any length: all of them720720 within `NEAR` distance of the phrase's length plus 32 characters per gap,721721 and the lines found are checked with any run of whitespace for a space. A@@ -729,13 +729,13 @@ different lines, so a first look that finds chunks but no line reads on, once,
729729 up to 100.730730 731731 What goes in: every file on the default branch except binary files (a NUL in732-the first 8000 bytes, as git decides), files over the 256K highlight cap,732+the first 8,000 bytes, as git decides), files over the 256K highlight cap,733733 lockfiles (`package-lock.json`, `yarn.lock`, `go.sum`, `Cargo.lock`, ...),734734 minified files and source maps, images, symlinks and submodules. Files are735-stored as chunks of 8 lines (`file`, `chunk`, and the FTS5 table `code` over735+stored as chunks of eight lines (`file`, `chunk` and the FTS5 table `code` over736736 the chunks' text, with `detail=full`, which substring queries need); the737737 browser finds the line inside a chunk itself. The build writes 16K pages,738-merges the index into one segment (`optimize`), and `VACUUM`s, so every table738+merges the index into one segment (`optimize`) and `VACUUM`s, so every table739739 is its root page over its leaves, laid out in order.740740 741741 Each round trip counts, since SQLite reads one page at a time and waits for@@ -786,7 +786,7 @@ CSS variables (`--tok-*`) in `style.css`.
786786 787787 Mermaid diagrams in markdown (` ```mermaid ` fences) are drawn at build788788 time too, as inline SVG, by [Pele](https://pele.run), so pages ship no789-diagram script. `style.css` colours them through Pele's `--pele-*`789+diagram script. `style.css` colors them through Pele's `--pele-*`790790 variables, which follow light and dark mode without drawing them again.791791 There is no browser at build time to measure text in, so labels are sized792792 from a table of the sans font's character widths in `lib/diagrams.ts`@@ -795,14 +795,14 @@ drawn a second time, laid out for a phone's width (often top to bottom),
795795 and the stylesheet shows that one in a narrow column. A diagram that796796 doesn't parse shows its source and the reason instead. Markdown is anyone's797797 who can push a repo: Pele escapes a diagram's text and filters its links798-and styles, then the SVG goes through a sanitizer of its own that keeps798+and styles. Then the SVG goes through a sanitizer of its own that keeps799799 only the shapes, text and links Pele draws with (no images, no `url()`),800800 while raw SVG in the markdown itself is still dropped.801801 802802 ## The lake and insights803803 804804 The forge's facts are append-only, but they don't last: `events.jsonl` is805-cut to its last 1000 lines once it passes 1 MB, `metrics.jsonl` isn't backed805+cut to its last 1,000 lines once it passes 1 MB, `metrics.jsonl` isn't backed806806 up, and review logs and CI notes are spread over the repos. `forge lake`807807 gathers them into a [DuckLake](https://ducklake.select/) (DuckDB 1.5.6808808 through `@duckdb/node-api`): its catalog is a SQLite file,@@ -831,7 +831,7 @@ when the lake saw it.
831831 file's inode, a hash of its first line and hashes of the last lines read:832832 when the build cuts the file into a new one, the next run finds the last833833 line it read and goes on from there. If the cut went past it (more than834- 1000 lines came in between runs), it says so, and only those lines are834+ 1,000 lines came in between runs), it says so, and only those lines are835835 missing. Review logs are read again whole when they change, CI notes when836836 their ref moves.837837 - **One writer.** A DuckDB file can't be written by two processes, and@@ -904,8 +904,8 @@ What it costs, measured on an M-series Mac and in the image (linux/arm64):
904904 ## Hosting905905 906906 The forge is one container (`Dockerfile`, on Node 24 LTS): sshd for git,907-Caddy for pages, sites, and HTTPS clones (with fcgiwrap for `git908-http-backend`), the events, private and pages servers, litestream, and907+Caddy for pages, sites and HTTPS clones (with fcgiwrap for `git908+http-backend`), the events, private and pages servers, litestream and909909 tailcat. `server/container/start.sh` starts them all. All state is in910910 `/srv/git`, a volume. Caddy, rclone, litestream and tailcat are pinned911911 releases the Dockerfile builds from source; the rest comes from Debian.@@ -961,15 +961,15 @@ port 22 doesn't work on shared addresses. DNS for `src.rest` and
961961 `pages.src.rest` points at the edge's addresses, DNS-only on Cloudflare962962 (SSH can't go through Cloudflare's proxy). To rotate the edge key:963963 `tailcat genkey --client --key=edge.private.json`, put its nodekey in964-`/srv/forge/forge.env`, its file in the `TAILCAT_CLIENT_KEY` secret, and redeploy both.964+`/srv/forge/forge.env`, its file in the `TAILCAT_CLIENT_KEY` secret and redeploy both.965965 966966 ### A plain VPS instead967967 968968 `server/provision.sh` turns a fresh Debian 13 or Ubuntu 24.04 box into the969969 forge directly (git, sshd, Node, Caddy with automatic TLS, and systemd units970970 for the events, private and pages servers). It needs Node 22.18 or newer,971-which runs the TypeScript sources as they are, and 24 LTS (24.7 or later) is972-best, as in the container, for post-quantum passkeys; those distros ship971+which runs the TypeScript sources as they are. Node 24 LTS (24.7 or later) is972+best, as in the container, for post-quantum passkeys. Those distros ship973973 older ones, so install a current Node first:974974 975975 ```sh@@ -995,7 +995,7 @@ tree stays when a newer deploy takes its link. `forge gc` removes trees in
995995 names, and trees in `.forge/private/store` that no private repo's release996996 ref names (release files are hard links, which name no tree), so a repo997997 that flipped leaves nothing in the store it left. It also removes hostnames998-whose deploy is gone, previews of review branches that no longer exist,998+whose deploy is gone, previews of review branches that no longer exist999999 and the downloads of releases whose ref was deleted without the hooks, in10001000 `www` or the private root. A deploy extracts its tree before it links it, and10011001 touches a tree it reuses, so store entries changed in the last 10 minutes@@ -1071,7 +1071,7 @@ Then point existing clones at the forge:
10711071 10721072 A public repo is public everywhere: pages, sites, releases, HTTPS clones and10731073 the pages mirror. A private one is read over ssh, on its pages once you sign1074-in, and over HTTPS with a token (see [Private repos](#private-repos)).1074+in and over HTTPS with a token (see [Private repos](#private-repos)).10751075 10761076 ## Backups and moving hosts10771077 @@ -1088,11 +1088,10 @@ Everything in `/srv/git` that can't be rebuilt:
10881088 10891089 - every bare repo, `<owner>/<repo>.git`, with its refs (releases too, and so10901090 their files, which restic stores once however many backups hold them),1091- its config, its1092- `git-daemon-export-ok` if it has one (so a private repo comes back1093- private), and its whole `forge/` directory: review logs, comments,1094- collaborators, readers, and anything else kept there;1095-- `.forge/keys`, `.forge/config`, and `.forge/www/hosts/`, which hostname each1091+ its config, its `git-daemon-export-ok` if it has one (so a private repo1092+ comes back private) and its whole `forge/` directory: review logs,1093+ comments, collaborators, readers and anything else kept there;1094+- `.forge/keys`, `.forge/config` and `.forge/www/hosts/`, which hostname each10961095 site has;10971096 - `.forge/identity.db`, as SQLite copies it (`VACUUM INTO`): one consistent10981097 file, never the live one with its WAL;@@ -1136,10 +1135,10 @@ After uploading, a backup
11361135 2. runs `restic check`, and about once a week also reads a fifth of the stored11371136 data, a different fifth each week;11381137 3. restores the new snapshot into a scratch directory and drills it: the same1139- files as staged, a clean full `git fsck` in every repo, and the ref count1138+ files as staged, a clean full `git fsck` in every repo and the ref count11401139 each live repo had.11411140 1142-A failure goes to `.forge/backup.log`, makes the command exit non-zero, and is1141+A failure goes to `.forge/backup.log`, makes the command exit non-zero and is11431142 recorded in `.forge/backup.status`. A backup also refuses a forge with no11441143 repos, or with under half the repos of the last backup (`--force` overrides):11451144 retention keeps one snapshot a day, and a new box backing itself up before@@ -1178,7 +1177,7 @@ open (`docker compose stop forge`, then `docker compose run --rm forge
11781177 forge-admin identity restore ...`); the file it replaces is kept as11791178 `identity.db.before-restore`. A box that boots without identity.db (a new11801179 one) takes it from the replica before anything makes an empty one, whose1181-replication would bury the replica's; if the replica can't be read then,1180+replication would bury the replica's. If the replica can't be read then,11821181 nothing replicates until the next boot. `forge restore` takes whichever of11831182 the backup's copy and the replica changed last (every write, deletions11841183 too, stamps the file), which is litestream's unless replication had@@ -1264,19 +1263,18 @@ What you set up yourself, once:
12641263 5. **The edge**: set `FORGE_MIRROR_URL` in `deploy/edge/fly.toml` to the12651264 bucket's public URL (`FORGE_DOMAIN` is set already), then `fly deploy`. Its12661265 secrets are unchanged: `TAILCAT_CLIENT_KEY` and `FORGE_TAILCAT_ADDR`.1267-6. **Check it**: `docker exec forge forge-admin backup`, then `backups`, and1266+6. **Check it**: `docker exec forge forge-admin backup`, then `backups` and12681267 `forge-admin mirror` for the first sync instead of waiting for a push.12691268 12701269 ### Restoring, or moving to another host12711270 12721271 `forge restore [<snapshot>|latest]` brings a forge back into an empty1273-`/srv/git`. It restores the files, rebuilds what a backup leaves out (what1274-`forge server init` and `forge keys sync` write, hooks in every repo, pinned1275-review revisions, extracted sites, release downloads, a private repo's in1276-the private root, extracted into `.forge/private/store` again, with the1277-mtimes they had, so the mirror uploads none1278-again) and runs `forge1279-build`. identity.db is the backup's copy or litestream's replica, whichever1272+`/srv/git`. It restores the files and rebuilds what a backup leaves out:1273+what `forge server init` and `forge keys sync` write, hooks in every repo,1274+pinned review revisions, extracted sites and release downloads (a private1275+repo's in the private root, extracted into `.forge/private/store` again),1276+with the mtimes they had, so the mirror uploads none again. Then it runs1277+`forge build`. identity.db is the backup's copy or litestream's replica, whichever12801278 changed last (see [above](#identitydb-backed-up-as-it-changes)). It refuses12811279 a forge that already has repos. The host's own `.forge/config` wins over the backup's,12821280 since it is written for the domain that host serves, and users from both are@@ -1305,7 +1303,7 @@ booted on the new box, run `docker exec forge forge-admin restore latest`, then
13051303 13061304 **A plain VPS**: provision it as above (that installs restic and rclone too),13071305 write the `FORGE_BACKUP_*` lines into `/srv/git/.forge/backup.env` (quote1308-values with spaces or `$` in them), restore as git, and point DNS at it:1306+values with spaces or `$` in them), restore as git and point DNS at it:13091307 13101308 ```sh13111309 ssh root@HOST 'install -o git -g git -m 600 /dev/stdin /srv/git/.forge/backup.env' <backup.env@@ -1377,7 +1375,7 @@ from `.forge/events.jsonl`, whose push lines carry the default branch's new
13771375 tip as `head`. A push whose head is a review's tip is that review landing,13781376 and shows once, as landed. Every build redoes the home page, and open13791377 copies of it swap in the new one when the build is done, so the list is1380-as fresh as the last push; its times stay relative in the browser. The1378+as fresh as the last push. Its times stay relative in the browser. The13811379 events log is cut and left out of backups, so after a restore the list has13821380 only landed reviews until the next pushes. **topics**: the shelves (below)13831381 across every public repo; one owner's links to that shelf on their page@@ -1391,7 +1389,7 @@ newest first. A repo's category is one line in the bare repo's `category`
13911389 file, next to `description` (`printf 'games\n' > /srv/git/cat/greet.git/category`).13921390 A repo without one goes on the shelf for what Jev says it is (a web app, a13931391 library), and failing that on "other". Links above the shelves show one shelf1394-alone, only repos with a site, or sort by name.1392+alone, only repos with a site or sort by name.13951393 13961394 A review page reads top to bottom: the stack, the layer's commits, then each13971395 commit's diff. While you read the diffs, a bar sticks to the top with the@@ -1413,7 +1411,7 @@ this.
14131411 "What changed since rN" compares a revision with the one before. If both were14141412 pushed on the same base, it is the diff between the two tips. If the branch14151413 was rebased, that diff would include everything that landed meanwhile, so1416-each commit is paired with its earlier version instead and labelled1414+each commit is paired with its earlier version instead and labeled14171415 unchanged, rewritten, added or dropped.14181416 14191417 Review lists nest stacks, each layer under the one below it, and a revised@@ -1466,13 +1464,14 @@ pagination:
14661464 ```14671465 14681466 `site/assets/style.css` holds what every page needs: the design tokens1469-(every colour, size, weight and font), layout, lists, code views. The rules1467+(every color, size, weight and font), layout, lists, code views. The rules14701468 for the look, and for changing it, are in [DESIGN.md](DESIGN.md); the1471-controls are macros in `site/_includes/ui.njk`, and `/design/` shows both. CSS for one feature sits next to its markup in a `{% css %}`1472-block, in the template, include or macro that renders it (the discussion,1473-the search overlay, the shortcuts dialog, the review bar, review lists, the1474-wiki), and 11ty's bundle plugin gives each page one file of the blocks it1475-rendered, `/assets/<hash>.css`, shared by pages of a kind. Import1469+controls are macros in `site/_includes/ui.njk`, and `/design/` shows both.1470+CSS for one feature sits next to its markup in a `{% css %}` block, in the1471+template, include or macro that renders it (the discussion, the search1472+overlay, the shortcuts dialog, the review bar, review lists, the wiki).1473+From those blocks, 11ty's bundle plugin gives each page one file of the1474+blocks it rendered, `/assets/<hash>.css`, shared by pages of a kind. Import14761475 `macros.njk` with context, or blocks in macros don't reach the bundle.14771476 14781477 Assets ship under content-hashed names (`/assets/style.<hash>.css`,