| M | DESIGN.md | 10 | ++ |
| M | README.md | 155 | +++++++++++++++--------------- |
| M | guide/ | 60 | +++++++---- |
| M | guide/ | 96 | +++++++++--------- |
| M | guide/ | 40 | ++++---- |
@@ -98,6 +98,16 @@ Self-hosted variable woff2 in `site/assets/fonts/` (how they were cut:
9898 - Inline code never breaks at a hyphen (`lib/markdown.ts` keeps a9999 hyphenated word whole and lets a path break after its slashes).100100 101+## Words in docs102+103+The guide, the README and other docs are written in AP style, for104+readability: no serial comma in a simple series; one through nine spelled105+out and figures from 10, always figures for versions, sizes, times and106+percentages; sentence-case headings; short sentences in the active voice;107+dates like Oct. 8, 2026. Interface chrome follows the lowercase rules108+above. Code, CLI output and the interface's compact times keep their own109+forms.110+101111 ## Controls102112 103113 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 the mirror's next sync 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, 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, which deletes them281+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.@@ -958,15 +958,15 @@ port 22 doesn't work on shared addresses. DNS for `src.rest` and
958958 `pages.src.rest` points at the edge's addresses, DNS-only on Cloudflare959959 (SSH can't go through Cloudflare's proxy). To rotate the edge key:960960 `tailcat genkey --client --key=edge.private.json`, put its nodekey in961-`/srv/forge/forge.env`, its file in the `TAILCAT_CLIENT_KEY` secret, and redeploy both.961+`/srv/forge/forge.env`, its file in the `TAILCAT_CLIENT_KEY` secret and redeploy both.962962 963963 ### A plain VPS instead964964 965965 `server/provision.sh` turns a fresh Debian 13 or Ubuntu 24.04 box into the966966 forge directly (git, sshd, Node, Caddy with automatic TLS, and systemd units967967 for the events, private and pages servers). It needs Node 22.18 or newer,968-which runs the TypeScript sources as they are, and 24 LTS (24.7 or later) is969-best, as in the container, for post-quantum passkeys; those distros ship968+which runs the TypeScript sources as they are. Node 24 LTS (24.7 or later) is969+best, as in the container, for post-quantum passkeys. Those distros ship970970 older ones, so install a current Node first:971971 972972 ```sh@@ -992,7 +992,7 @@ tree stays when a newer deploy takes its link. `forge gc` removes trees in
992992 names, and trees in `.forge/private/store` that no private repo's release993993 ref names (release files are hard links, which name no tree), so a repo994994 that flipped leaves nothing in the store it left. It also removes hostnames995-whose deploy is gone, previews of review branches that no longer exist,995+whose deploy is gone, previews of review branches that no longer exist996996 and the downloads of releases whose ref was deleted without the hooks, in997997 `www` or the private root. A deploy extracts its tree before it links it, and998998 touches a tree it reuses, so store entries changed in the last 10 minutes@@ -1068,7 +1068,7 @@ Then point existing clones at the forge:
10681068 10691069 A public repo is public everywhere: pages, sites, releases, HTTPS clones and10701070 the pages mirror. A private one is read over ssh, on its pages once you sign1071-in, and over HTTPS with a token (see [Private repos](#private-repos)).1071+in and over HTTPS with a token (see [Private repos](#private-repos)).10721072 10731073 ## Backups and moving hosts10741074 @@ -1085,11 +1085,10 @@ Everything in `/srv/git` that can't be rebuilt:
10851085 10861086 - every bare repo, `<owner>/<repo>.git`, with its refs (releases too, and so10871087 their files, which restic stores once however many backups hold them),1088- its config, its1089- `git-daemon-export-ok` if it has one (so a private repo comes back1090- private), and its whole `forge/` directory: review logs, comments,1091- collaborators, readers, and anything else kept there;1092-- `.forge/keys`, `.forge/config`, and `.forge/www/hosts/`, which hostname each1088+ its config, its `git-daemon-export-ok` if it has one (so a private repo1089+ comes back private) and its whole `forge/` directory: review logs,1090+ comments, collaborators, readers and anything else kept there;1091+- `.forge/keys`, `.forge/config` and `.forge/www/hosts/`, which hostname each10931092 site has;10941093 - `.forge/identity.db`, as SQLite copies it (`VACUUM INTO`): one consistent10951094 file, never the live one with its WAL;@@ -1133,10 +1132,10 @@ After uploading, a backup
11331132 2. runs `restic check`, and about once a week also reads a fifth of the stored11341133 data, a different fifth each week;11351134 3. restores the new snapshot into a scratch directory and drills it: the same1136- files as staged, a clean full `git fsck` in every repo, and the ref count1135+ files as staged, a clean full `git fsck` in every repo and the ref count11371136 each live repo had.11381137 1139-A failure goes to `.forge/backup.log`, makes the command exit non-zero, and is1138+A failure goes to `.forge/backup.log`, makes the command exit non-zero and is11401139 recorded in `.forge/backup.status`. A backup also refuses a forge with no11411140 repos, or with under half the repos of the last backup (`--force` overrides):11421141 retention keeps one snapshot a day, and a new box backing itself up before@@ -1175,7 +1174,7 @@ open (`docker compose stop forge`, then `docker compose run --rm forge
11751174 forge-admin identity restore ...`); the file it replaces is kept as11761175 `identity.db.before-restore`. A box that boots without identity.db (a new11771176 one) takes it from the replica before anything makes an empty one, whose1178-replication would bury the replica's; if the replica can't be read then,1177+replication would bury the replica's. If the replica can't be read then,11791178 nothing replicates until the next boot. `forge restore` takes whichever of11801179 the backup's copy and the replica changed last (every write, deletions11811180 too, stamps the file), which is litestream's unless replication had@@ -1189,7 +1188,7 @@ waits `FORGE_MIRROR_DELAY` seconds (30) after each build and only the newest
11891188 one syncs, so a burst of pushes makes one sync. It never replaces the copy11901189 with an empty forge. The edge serves this copy while slate is down (see11911190 [above](#home-machine-behind-a-public-edge-how-srcrest-runs)). It holds1192-public content only, the pages the forge serves anyway, and public repos'1191+public content only: the pages the forge serves anyway and public repos'11931192 release downloads. When a repo goes private, the next sync deletes its11941193 pages and downloads there.11951194 @@ -1236,19 +1235,18 @@ What you set up yourself, once:
12361235 5. **The edge**: set `FORGE_MIRROR_URL` in `deploy/edge/fly.toml` to the12371236 bucket's public URL (`FORGE_DOMAIN` is set already), then `fly deploy`. Its12381237 secrets are unchanged: `TAILCAT_CLIENT_KEY` and `FORGE_TAILCAT_ADDR`.1239-6. **Check it**: `docker exec forge forge-admin backup`, then `backups`, and1238+6. **Check it**: `docker exec forge forge-admin backup`, then `backups` and12401239 `forge-admin mirror` for the first sync instead of waiting for a push.12411240 12421241 ### Restoring, or moving to another host12431242 12441243 `forge restore [<snapshot>|latest]` brings a forge back into an empty1245-`/srv/git`. It restores the files, rebuilds what a backup leaves out (what1246-`forge server init` and `forge keys sync` write, hooks in every repo, pinned1247-review revisions, extracted sites, release downloads, a private repo's in1248-the private root, extracted into `.forge/private/store` again, with the1249-mtimes they had, so the mirror uploads none1250-again) and runs `forge1251-build`. identity.db is the backup's copy or litestream's replica, whichever1244+`/srv/git`. It restores the files and rebuilds what a backup leaves out:1245+what `forge server init` and `forge keys sync` write, hooks in every repo,1246+pinned review revisions, extracted sites and release downloads (a private1247+repo's in the private root, extracted into `.forge/private/store` again),1248+with the mtimes they had, so the mirror uploads none again. Then it runs1249+`forge build`. identity.db is the backup's copy or litestream's replica, whichever12521250 changed last (see [above](#identitydb-backed-up-as-it-changes)). It refuses12531251 a forge that already has repos. The host's own `.forge/config` wins over the backup's,12541252 since it is written for the domain that host serves, and users from both are@@ -1277,7 +1275,7 @@ booted on the new box, run `docker exec forge forge-admin restore latest`, then
12771275 12781276 **A plain VPS**: provision it as above (that installs restic and rclone too),12791277 write the `FORGE_BACKUP_*` lines into `/srv/git/.forge/backup.env` (quote1280-values with spaces or `$` in them), restore as git, and point DNS at it:1278+values with spaces or `$` in them), restore as git and point DNS at it:12811279 12821280 ```sh12831281 ssh root@HOST 'install -o git -g git -m 600 /dev/stdin /srv/git/.forge/backup.env' <backup.env@@ -1349,7 +1347,7 @@ from `.forge/events.jsonl`, whose push lines carry the default branch's new
13491347 tip as `head`. A push whose head is a review's tip is that review landing,13501348 and shows once, as landed. Every build redoes the home page, and open13511349 copies of it swap in the new one when the build is done, so the list is1352-as fresh as the last push; its times stay relative in the browser. The1350+as fresh as the last push. Its times stay relative in the browser. The13531351 events log is cut and left out of backups, so after a restore the list has13541352 only landed reviews until the next pushes. **topics**: the shelves (below)13551353 across every public repo; one owner's links to that shelf on their page@@ -1363,7 +1361,7 @@ newest first. A repo's category is one line in the bare repo's `category`
13631361 file, next to `description` (`printf 'games\n' > /srv/git/cat/greet.git/category`).13641362 A repo without one goes on the shelf for what Jev says it is (a web app, a13651363 library), and failing that on "other". Links above the shelves show one shelf1366-alone, only repos with a site, or sort by name.1364+alone, only repos with a site or sort by name.13671365 13681366 A review page reads top to bottom: the stack, the layer's commits, then each13691367 commit's diff. While you read the diffs, a bar sticks to the top with the@@ -1385,7 +1383,7 @@ this.
13851383 "What changed since rN" compares a revision with the one before. If both were13861384 pushed on the same base, it is the diff between the two tips. If the branch13871385 was rebased, that diff would include everything that landed meanwhile, so1388-each commit is paired with its earlier version instead and labelled1386+each commit is paired with its earlier version instead and labeled13891387 unchanged, rewritten, added or dropped.13901388 13911389 Review lists nest stacks, each layer under the one below it, and a revised@@ -1438,13 +1436,14 @@ pagination:
14381436 ```14391437 14401438 `site/assets/style.css` holds what every page needs: the design tokens1441-(every colour, size, weight and font), layout, lists, code views. The rules1439+(every color, size, weight and font), layout, lists, code views. The rules14421440 for the look, and for changing it, are in [DESIGN.md](DESIGN.md); the1443-controls are macros in `site/_includes/ui.njk`, and `/design/` shows both. CSS for one feature sits next to its markup in a `{% css %}`1444-block, in the template, include or macro that renders it (the discussion,1445-the search overlay, the shortcuts dialog, the review bar, review lists, the1446-wiki), and 11ty's bundle plugin gives each page one file of the blocks it1447-rendered, `/assets/<hash>.css`, shared by pages of a kind. Import1441+controls are macros in `site/_includes/ui.njk`, and `/design/` shows both.1442+CSS for one feature sits next to its markup in a `{% css %}` block, in the1443+template, include or macro that renders it (the discussion, the search1444+overlay, the shortcuts dialog, the review bar, review lists, the wiki).1445+From those blocks, 11ty's bundle plugin gives each page one file of the1446+blocks it rendered, `/assets/<hash>.css`, shared by pages of a kind. Import14481447 `macros.njk` with context, or blocks in macros don't reach the bundle.14491448 14501449 Assets ship under content-hashed names (`/assets/style.<hash>.css`,@@ -241,6 +241,9 @@ b, strong { font-weight: var(--fw-strong); }
241241 242242 /* figures: a raised plate on a hairline, a caption under it */243243 figure { margin: var(--space-5) 0 var(--space-6); }244+/* what is in a figure is labels and notes, in the sans; shas, paths and245+ commands in the mono */246+.plate { font-family: var(--sans); line-height: 1.5; }244247 .plate { border: var(--hairline) solid var(--border); background: var(--raised); padding: var(--space-4); overflow: hidden; }245248 figcaption { font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-2); }246249 figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: var(--space-2); }@@ -250,9 +253,11 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
250253 .flow { list-style: none; margin: 0; padding: 0; position: relative; --x: 6px; }251254 .flow::before { content: ""; position: absolute; left: var(--x); top: 8px; bottom: 14px; width: 2px; background: var(--border); }252255 .flow > li { position: relative; padding: 0 0 var(--space-4) 26px; }253-.flow > li::before { content: ""; position: absolute; left: calc(var(--x) - 5px); top: 7px; width: 8px; height: 8px; background: var(--raised); border: 2px solid var(--accent); }254-.flow .where { display: block; font-family: var(--mono); font-size: var(--text-code); font-weight: var(--fw-strong); color: var(--fg); }255-.flow p { margin: var(--space-half) 0 0; font-size: var(--text-md); }256+/* the marker: centred on the rail and on the first 24px line */257+.flow > li::before { content: ""; position: absolute; left: calc(var(--x) - 4px); top: 7px; width: 10px; height: 10px; background: var(--raised); border: 2px solid var(--accent); }258+.flow .where { display: block; font-family: var(--mono); font-size: var(--text-code); line-height: 24px; font-weight: var(--fw-strong); color: var(--fg); }259+.flow p { margin: 0; font-size: var(--text-md); line-height: 24px; }260+.flow p + p { margin-top: var(--space-1); }256261 .flow .src { font-family: var(--mono); font-size: var(--text-xs); color: var(--muted); margin-top: var(--space-1); }257262 .flow ul { margin: 6px 0 0; padding: 0; list-style: none; font-size: var(--text-md); }258263 .flow ul li { position: relative; padding-left: var(--space-4); margin: 3px 0; }@@ -263,29 +268,39 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
263268 @media (min-width: 720px) {264269 .flow { --x: 148px; }265270 .flow > li { display: grid; grid-template-columns: 128px 1fr; gap: 0 40px; padding-left: 0; }266- .flow .where { text-align: right; padding-top: 3px; }271+ .flow .where { text-align: right; }267272 .flow .returns { grid-template-columns: 1fr; padding-left: 168px; }268273 }269274 270-/* fig: a stack of reviews, as a graph */271-.graph { list-style: none; margin: 0; padding: 0; font-size: var(--text-md); }272-.graph ol { list-style: none; margin: 0; padding: 0; }273-.graph .layer { position: relative; display: grid; gap: var(--space-1) 18px; padding: 6px 0; }274-.graph .layer::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: var(--accent); }275-.graph > .layer:first-child::before { top: 1.3em; }276-.graph .base::before { background: var(--faint); bottom: auto; height: 1.3em; }277-.graph .c { position: relative; padding: 5px 0 5px 28px; display: flex; flex-wrap: wrap; gap: var(--space-half) var(--space-2); align-items: baseline; }278-.graph .c::after { content: ""; position: absolute; left: 1px; top: 0.85em; width: 8px; height: 8px; background: var(--raised); border: 2px solid var(--accent); }275+/* fig: a stack of reviews, as a graph. Every commit is one row of the276+ same height, its marker centred on the row, so the rail's segments277+ between markers are equal; a layer's review is a bracket beside its278+ rows, which on phones shows only its number. */279+.graph { list-style: none; margin: 0; padding: 0; --row: 32px; }280+.graph ol { list-style: none; margin: 0; padding: 0; min-width: 0; }281+.graph .layer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0 var(--space-3); }282+/* sha, subject and branch share one baseline, centred in the row */283+.graph .c { position: relative; display: flex; align-items: baseline; gap: var(--space-2); height: var(--row); padding-left: 28px; font-size: var(--text-md); line-height: var(--row); white-space: nowrap; }284+/* the rail through the row, and the marker on it */285+.graph .c::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: var(--accent); }286+.graph > .layer:first-child > ol > .c:first-child::before { top: 50%; }287+.graph .base .c::before { bottom: 50%; background: var(--faint); }288+.graph .c::after { content: ""; position: absolute; left: 2px; top: 50%; width: 10px; height: 10px; margin-top: -5px; background: var(--raised); border: 2px solid var(--accent); }279289 .graph .base .c::after { border-color: var(--faint); }280-.graph code { font-size: var(--text-xs); color: var(--muted); }290+.graph code { flex: none; font-size: var(--text-xs); color: var(--muted); }291+.graph .s { min-width: 0; overflow: hidden; text-overflow: ellipsis; }292+.graph .pill { flex: none; line-height: 1.5; }281293 /* a branch name on a commit: a square label on a hairline */282294 .pill { font-family: var(--mono); font-size: var(--text-xs); padding: 0 6px; border: var(--hairline) solid var(--accent); color: var(--link); white-space: nowrap; }283295 .pill.main { border-color: var(--border-strong); color: var(--muted); }284-.graph .tag { margin: 0 0 var(--space-1) 28px; font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); border-left: 2px solid var(--border); padding: var(--space-half) 0 var(--space-half) 10px; }285-.graph .tag b { display: block; color: var(--fg); }296+.graph .tag { display: flex; flex-direction: column; justify-content: center; margin: var(--space-1) 0; padding: 0 0 0 10px; border-left: 2px solid var(--accent); font-size: var(--text-sm); line-height: 1.4; color: var(--muted); }297+.graph .tag b { color: var(--fg); }298+.graph .tag .t, .graph .tag small { display: none; }299+.graph .tag small { font-size: var(--text-sm); }286300 @media (min-width: 720px) {287- .graph .layer { grid-template-columns: minmax(0, 1fr) 210px; align-items: center; }288- .graph .tag { margin: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; border-left: 2px solid var(--accent); }301+ .graph .layer { grid-template-columns: minmax(0, 1fr) 210px; }302+ .graph .tag .t { display: inline; }303+ .graph .tag small { display: block; }289304 }290305 291306 /* code-ish figures: range-diff, statements */@@ -300,8 +315,8 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
300315 301316 .annotated { display: grid; gap: var(--space-4); }302317 .annotated ol { margin: 0; padding: 0; list-style: none; counter-reset: n; font-size: var(--text-md); }303-.annotated ol li { counter-increment: n; position: relative; padding-left: 30px; margin: 0 0 var(--space-2); }304-.annotated ol li::before { content: counter(n); position: absolute; left: 0; top: 0.25em; min-width: 1.6em; padding: 0 var(--space-1); background: var(--accent); color: var(--on-accent); font: var(--fw-strong) var(--text-xs)/1.5 var(--mono); text-align: center; }318+.annotated ol li { counter-increment: n; position: relative; padding-left: 30px; margin: 0 0 var(--space-2); line-height: 24px; }319+.annotated ol li::before { content: counter(n); position: absolute; left: 0; top: 3px; min-width: 1.6em; padding: 0 var(--space-1); background: var(--accent); color: var(--on-accent); font: var(--fw-strong) var(--text-xs)/1.5 var(--mono); text-align: center; }305320 .annotated figcaption { grid-column: 1 / -1; margin: 0; }306321 @media (min-width: 900px) { .annotated { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: start; } }307322 @@ -321,8 +336,9 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
321336 .chain { list-style: none; margin: 0; padding: 0; }322337 .chain li { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-half) var(--space-3); align-items: baseline; }323338 .chain .k { font-family: var(--sans); font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); }324-.chain .v { font-family: var(--mono); font-size: var(--text-code); background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 6px 10px; overflow-wrap: anywhere; }325-.chain .v small { display: block; font-family: var(--serif); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-half); }339+/* values break only where they have <wbr>, never at a hyphen */340+.chain .v { font-family: var(--mono); font-size: var(--text-code); background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }341+.chain .v small { display: block; white-space: normal; font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-half); }326342 .chain .hop { min-height: 34px; }327343 .chain .hop span { grid-column: -2; position: relative; padding: 6px 0 6px 26px; font-family: var(--sans); font-size: var(--text-xs); color: var(--muted); }328344 .chain .hop span::before { content: ""; position: absolute; left: 11px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }@@ -4,7 +4,7 @@
44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width, initial-scale=1">66 <title>forge: your code, its reviews, CI and sites, on your own server</title>7-<meta name="description" content="forge keeps your code, its reviews, CI results, sites and releases on your own server, as fast static pages. Changes go through git and ssh.">7+<meta name="description" content="forge keeps your code, its reviews, CI results, sites and releases on your own server, as a static site. Changes go through git and ssh.">88 <meta name="theme-color" content="#e9e9e9" media="(prefers-color-scheme: light)">99 <meta name="theme-color" content="#08090b" media="(prefers-color-scheme: dark)">1010 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%231c2127%7Dpath%7Bstroke:%233148f6%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23e2e4e7%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8'/%3E%3C/svg%3E">@@ -26,7 +26,7 @@
2626 <div class="hero-text">2727 <p class="eyebrow">a personal git forge</p>2828 <h1 id="top"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge</h1>29-<p class="lede">Your code, its reviews, CI results, sites and releases, on <em>your own</em> server, as pages that open at once. Changes go through git and ssh.</p>29+<p class="lede">Your code, its reviews, CI results, sites and releases, on your own server, as a static site. Changes go through git and ssh.</p>3030 <p class="cta"><a class="btn go" href="#what">start reading</a><a class="btn" href="#cli">cheat sheet</a><a class="btn" href="internals.html">internals</a></p>3131 </div>3232 <figure class="term" aria-label="A terminal session: attest, push for review, deploy a preview">@@ -58,8 +58,8 @@
5858 <section class="commit" id="what" aria-labelledby="what-h">5959 <header><p class="ref"><b>01</b> what forge is</p><h2 id="what-h"><a href="#what">Your forge, on your own server</a></h2></header>6060 <div class="prose">61-<p>forge keeps your repositories on a server you run, and shows each of them as plain, fast pages: every branch, file and commit, the reviews on them, the CI result of every commit, the sites a repo ships and the releases it publishes. Pages open at once, work without JavaScript, and update themselves while you read them.</p>62-<p>Everything that changes the forge goes through git and ssh. You push to change code, to ask for a review, to publish a site or a release; you run a short ssh command to make a repo, land a review or invite someone. On the web you read, and once you sign in with a passkey you comment and approve too. A push that would add a password or a key to the history is turned away.</p>61+<p>forge keeps your repositories on your own server and publishes them as a static site: every branch, file and commit, the reviews on them, the CI result of every commit, and each repo's sites and releases. The pages work without JavaScript and update while you read them.</p>62+<p>Changes go through git and ssh. You push code, reviews, sites and releases; you run an ssh command to make a repo, land a review or invite someone. On the web you read. Sign in with a passkey to comment and approve. A push that adds a password or a key is refused.</p>6363 </div>6464 <div class="panel">6565 <div class="panel-head"><b>what you do, and what you get</b></div>@@ -72,18 +72,18 @@
7272 <tr><th scope="row">run CI</th><td data-l="with"><code>forge attest</code></td><td data-l="the forge shows">a signed pass or fail on each commit</td></tr>7373 <tr><th scope="row">publish a site</th><td data-l="with"><code>forge site deploy</code></td><td data-l="the forge shows">the site on its own address, and a preview per branch</td></tr>7474 <tr><th scope="row">cut a release</th><td data-l="with"><code>forge release publish</code></td><td data-l="the forge shows">downloads with signed checksums</td></tr>75-<tr><th scope="row">bring someone in</th><td data-l="with"><code>ssh git@src.rest invite</code></td><td data-l="the forge shows">a link that makes them a user, and a collaborator or reader</td></tr>75+<tr><th scope="row">invite someone</th><td data-l="with"><code>ssh git@src.rest invite</code></td><td data-l="the forge shows">a one-time link to join, as a collaborator or reader</td></tr>7676 </tbody>7777 </table>7878 </div>7979 </div>80-<p class="prose">The examples here are from <a href="https://src.rest/">src.rest</a>, where forge hosts itself as <code>cat/forge</code>. <code>cat</code> owns the forge; <code>fox</code> is someone else on it.</p>80+<p class="prose">The examples use <a href="https://src.rest/">src.rest</a>, where forge hosts itself as <code>cat/forge</code>. <code>cat</code> runs it; <code>fox</code> is another user.</p>8181 </section>8282 8383 <section class="commit" id="browse" aria-labelledby="browse-h">8484 <header><p class="ref"><b>02</b> browse</p><h2 id="browse-h"><a href="#browse">Every branch, file, commit and review is a page</a></h2></header>8585 <div class="prose">86-<p>A repo's pages read like paths: <code>/cat/greet/tree/main/greet.sh/</code> is that file on <code>main</code>, and every other branch has the same pages. Markdown renders, a README sits under its directory, and code is highlighted. The home page has who is here, what happened lately, and repos by topic.</p>86+<p>URLs are paths: <code>/cat/greet/tree/main/greet.sh/</code> is that file on <code>main</code>, and every branch has the same pages. Markdown is rendered and code is highlighted. The home page lists people, recent activity and repos by topic.</p>8787 </div>8888 <div class="panel">8989 <div class="panel-head"><b>a repo's pages</b><span class="end"><span class="n">https://src.rest/cat/greet/...</span></span></div>@@ -104,35 +104,35 @@
104104 </div>105105 <div class="prose">106106 <ul>107-<li><strong>Search.</strong> <kbd>/</kbd> searches a repo's commits, reviews, wiki pages and paths as you type, and the contents of its files; <kbd>t</kbd> finds a file on the branch you are on.</li>107+<li><strong>Search.</strong> <kbd>/</kbd> searches a repo's commits, reviews, wiki, paths and file contents; <kbd>t</kbd> finds a file on the current branch.</li>108108 <li><strong>The keyboard.</strong> <kbd>j</kbd> <kbd>k</kbd> move through a list, <kbd>n</kbd> <kbd>N</kbd> through a review's commits, <kbd>[</kbd> <kbd>]</kbd> up and down a stack, <kbd>v</kbd> marks a file viewed. <kbd>?</kbd> lists them all.</li>109-<li><strong>Live.</strong> Leave a page open: when someone pushes, it says so, and when the new pages are ready it swaps them in where you are reading.</li>110-<li><strong>Feeds.</strong> Every repo has an Atom feed of its newest commits, so a feed reader follows it.</li>111-<li><strong>Diagrams.</strong> A <code>```mermaid</code> fence in a README, a wiki page or any markdown file is drawn as a diagram on the page, with no script, in light and dark.</li>109+<li><strong>Live.</strong> An open page updates itself after a push, without losing your place.</li>110+<li><strong>Feeds.</strong> Every repo has an Atom feed of its newest commits.</li>111+<li><strong>Diagrams.</strong> <code>```mermaid</code> fences in any markdown file are drawn as diagrams, without JavaScript.</li>112112 </ul>113113 </div>114114 </section>115115 116116 <section class="commit" id="reviews" aria-labelledby="reviews-h">117117 <header><p class="ref"><b>03</b> reviews</p><h2 id="reviews-h"><a href="#reviews">Push a branch and it is a review</a></h2></header>118-<p class="prose">There is nothing to open. Push any branch other than <code>main</code> and it is a review, with its commits' diffs in order and a combined diff. A branch made from another review's branch is the next layer of a stack.</p>118+<p class="prose">Any branch other than <code>main</code> is a review once pushed: its commits' diffs in order, plus a combined diff. A branch made from another review's branch stacks on top of it.</p>119119 <pre class="code"><span class="dim">$</span> git switch -c cat/flags120120 <span class="dim">$</span> forge push -t "parse --name" -d "Flags, parsed by hand"121121 <span class="dim">remote: review #2: https://src.rest/cat/greet/reviews/2/</span></pre>122122 <figure class="plate">123123 <ol class="graph" aria-label="A stack: two reviews, each two commits, on top of main">124-<li class="layer"><ol><li class="c"><code>6771779</code> README: document flags <span class="pill">cat/help</span></li><li class="c"><code>dff8bca</code> add --help</li></ol><p class="tag"><b>#3 Usage and --help</b>layer 2, on cat/flags</p></li>125-<li class="layer"><ol><li class="c"><code>80b9dda</code> add --shout <span class="pill">cat/flags</span></li><li class="c"><code>afd124b</code> parse --name</li></ol><p class="tag"><b>#2 parse --name</b>layer 1, on main</p></li>126-<li class="layer base"><ol><li class="c"><code>895bc48</code> README: be polite <span class="pill main">main</span></li></ol></li>124+<li class="layer"><ol><li class="c"><code>6771779</code><span class="s">README: document flags</span><span class="pill">cat/help</span></li><li class="c"><code>dff8bca</code><span class="s">add --help</span></li></ol><p class="tag"><b>#3<span class="t"> Usage and --help</span></b><small>layer 2, on cat/flags</small></p></li>125+<li class="layer"><ol><li class="c"><code>80b9dda</code><span class="s">add --shout</span><span class="pill">cat/flags</span></li><li class="c"><code>afd124b</code><span class="s">parse --name</span></li></ol><p class="tag"><b>#2<span class="t"> parse --name</span></b><small>layer 1, on main</small></p></li>126+<li class="layer base"><ol><li class="c"><code>895bc48</code><span class="s">README: be polite</span><span class="pill main">main</span></li></ol></li>127127 </ol>128128 <figcaption><b>fig. 1</b>a stack: cat/help was branched from cat/flags</figcaption>129129 </figure>130130 <div class="prose">131131 <ul>132-<li><strong>Revisions.</strong> Each push of a review is a revision. Rewrite the branch and the page shows what changed since the last one, commit by commit, even across a rebase.</li>133-<li><strong>Stacks.</strong> <code>forge push --stack</code> pushes every layer, lowest first; the page has the layers above and below one key away.</li>132+<li><strong>Revisions.</strong> Each push is a revision. The page shows what changed since the last one, commit by commit, even after a rebase.</li>133+<li><strong>Stacks.</strong> <code>forge push --stack</code> pushes every layer, lowest first; <kbd>[</kbd> and <kbd>]</kbd> move between layers on the page.</li>134134 <li><strong>Status.</strong> Open, draft (<code>--draft</code>, or a <code>wip/</code> branch), merged once it is in <code>main</code>, closed when its branch is deleted.</li>135-<li><strong>Reading.</strong> Files fold, can be marked viewed, and stay viewed until they change.</li>135+<li><strong>Reading.</strong> Files fold, can be marked viewed and stay viewed until they change.</li>136136 </ul>137137 </div>138138 <figure>@@ -151,23 +151,23 @@
151151 </figure>152152 <div class="prose">153153 <h3>Comments and approvals</h3>154-<p>Signed in, a review page ends in a comment box and an approve button, and in the combined diff a <b>+</b> beside a line comments on that line. The same from the terminal:</p>154+<p>Signed in, you comment and approve at the end of a review page, or comment on a line with the <b>+</b> in the combined diff. From the terminal:</p>155155 </div>156156 <pre class="code"><span class="dim">$</span> forge comment 2 greet.sh:12 -m "Quote the name?"157157 <span class="dim">$</span> forge approve 2</pre>158158 <div class="prose">159-<p>An approval belongs to the files as they are, so rewording a commit keeps it, and changing the code shows it as changed since. Nobody approves their own review. Once the review gets a new revision, older comments show as outdated, still quoting the line they were about. A repo can require an approval from someone else before <code>main</code> moves.</p>159+<p>An approval is for the code as it is: rewording a commit keeps it, changing the code marks it stale. You can't approve your own review. After a new revision, older comments are marked outdated and keep the line they quoted. A repo can require someone else's approval before <code>main</code> moves.</p>160160 <h3>Landing</h3>161-<p><code>forge land</code> checks CI, and an approval if the repo asks for one, moves <code>main</code> forward to the review and deletes the landed branches. The forge can do it itself, with no clone, from a phone or an agent:</p>161+<p><code>forge land</code> checks CI (and approval, if required), fast-forwards <code>main</code> and deletes the landed branches. Over ssh the forge does it itself, no clone needed:</p>162162 </div>163163 <pre class="code"><span class="dim">$</span> forge land 2164164 <span class="dim">$</span> ssh git@src.rest land cat/greet 2</pre>165-<p class="prose"><code>main</code> only moves forward, so a review lands once it is on top of <code>main</code>; <code>git rebase -i --update-refs</code> keeps every layer of a stack in step while you rewrite them.</p>165+<p class="prose"><code>main</code> only fast-forwards, so rebase a review onto <code>main</code> before landing; <code>git rebase -i --update-refs</code> moves every layer of a stack with it.</p>166166 </section>167167 168168 <section class="commit" id="ci" aria-labelledby="ci-h">169-<header><p class="ref"><b>04</b> ci</p><h2 id="ci-h"><a href="#ci">CI runs on your machine, and is signed</a></h2></header>170-<p class="prose"><code>forge attest</code> runs the repo's <code>.forge/ci</code> script on a clean checkout of each commit, on your machine, and signs the result with the SSH key you push with. <code>forge push</code> takes the results along, and every commit, review and branch shows its pass or fail and who signed it. A failure is signed and shown too.</p>169+<header><p class="ref"><b>04</b> ci</p><h2 id="ci-h"><a href="#ci">CI runs on your machine and is signed</a></h2></header>170+<p class="prose"><code>forge attest</code> runs <code>.forge/ci</code> on your machine, on a clean checkout of each commit. It signs the result with your SSH key. <code>forge push</code> uploads the results, and every commit, review and branch shows pass or fail and who signed it. Failures are shown too.</p>171171 <figure class="plate annotated">172172 <pre class="code"><span class="key">forge-attestation 1</span>173173 type ci@@ -188,9 +188,9 @@ log sha256:6cc79019... 4 lines
188188 <span class="dim">U1NIU0lHAAAAAQAAADMAAAALc3No...</span></pre>189189 <ol>190190 <li><strong>The files, not the commit.</strong> A reworded or reordered commit with the same files keeps its result, and <code>forge attest</code> skips what already passed unless you pass <code>--force</code>.</li>191-<li><strong>The script</strong>, exactly, so a different <code>.forge/ci</code> is a different claim.</li>191+<li><strong>The script</strong>, by hash: change <code>.forge/ci</code> and it is a different claim.</li>192192 <li><strong>The result</strong> and exit code.</li>193-<li><strong>The runner</strong> as the signer describes it: user, machine, system, and with Nix the lock file it ran under.</li>193+<li><strong>The runner</strong> as the signer describes it: user, machine and system, plus the Nix lock file if there is one.</li>194194 <li><strong>The signature</strong> over everything above. Log lines start with <code>|</code>, so output can't fake a field.</li>195195 </ol>196196 <figcaption><b>fig. 3</b>a signed CI result, shortened</figcaption>@@ -198,27 +198,27 @@ log sha256:6cc79019... 4 lines
198198 <pre class="code"><span class="dim">$</span> forge attest --stack <span class="dim"># every commit on top of main</span>199199 <span class="dim">$</span> forge verify <span class="dim"># each commit's results, and who signed them</span></pre>200200 <div class="verdict">201-<div class="yes"><h3>What a verified pass proves</h3><ul><li>Someone with a key on this forge claims these exact files passed this exact script, on a machine they describe.</li><li>Nobody edited it since: turning <code>result fail</code> into <code>result pass</code> breaks the signature.</li><li>Who it was.</li></ul></div>202-<div class="no"><h3>What it doesn't</h3><ul><li>That the person is honest. Someone can sign a pass for tests they never ran.</li><li>That the machine was clean or the setup reproducible, unless Nix pinned it.</li><li>Anything about commits nobody ran.</li></ul></div>201+<div class="yes"><h3>What a verified pass proves</h3><ul><li>Someone with a key on this forge claims these exact files passed this exact script, on a machine they describe.</li><li>Nobody edited it since: turning <code>result fail</code> into <code>result pass</code> breaks the signature.</li><li>Who signed it.</li></ul></div>202+<div class="no"><h3>What it doesn't</h3><ul><li>That the person is honest. Someone can sign a pass for tests they never ran.</li><li>That the machine was clean or the setup reproducible, unless Nix pinned it.</li><li>Anything about commits nobody tested.</li></ul></div>203203 </div>204204 <div class="prose">205-<p>To make the claim stronger, keep the key in hardware (a Secure Enclave key through <a href="https://github.com/maxgoedjen/secretive">Secretive</a>, or a FIDO <code>sk-</code> key), add a <code>flake.nix</code> so CI runs under <code>nix develop</code>, and have someone else run <code>forge attest</code> too: results from several people stack up on the same commit.</p>206-<p>A repo can require a verified pass before <code>main</code> moves, for its new tip or for every new commit. Then a push or a land without one is turned away, and so is a release whose tag never passed, if the repo asks for that as well.</p>205+<p>To make it stronger, keep the key in hardware: a Secure Enclave key through <a href="https://github.com/maxgoedjen/secretive">Secretive</a>, or a FIDO <code>sk-</code> key. Add a <code>flake.nix</code> so CI runs under <code>nix develop</code>. Have a second person run <code>forge attest</code> too; results from several people add up.</p>206+<p>A repo can require a verified pass before <code>main</code> moves (for the new tip or every new commit) and for a release's tag. Pushes and lands without one are refused.</p>207207 </div>208208 </section>209209 210210 <section class="commit" id="sites" aria-labelledby="sites-h">211211 <header><p class="ref"><b>05</b> sites and releases</p><h2 id="sites-h"><a href="#sites">Sites and releases ship with the repo</a></h2></header>212212 <div class="prose">213-<p>Any repo can publish static sites: docs, a Docusaurus build, a small app. Name each one in <code>.forge/sites</code> with its output directory and its build command, and deploy:</p>213+<p>Any repo can publish static sites: docs, a Docusaurus build, a small app. List each in <code>.forge/sites</code> with its output directory and build command, then deploy:</p>214214 </div>215215 <pre class="code"><span class="dim"># .forge/sites: name, output, build command (- for none)</span>216216 docs build npm ci && npm run build217217 218218 <span class="dim">$</span> forge site deploy docs</pre>219219 <div class="prose">220-<p>From <code>main</code> that publishes the site. From any other branch it publishes a preview of that branch, which its review links and marks stale once the branch moves on; deleting the branch takes its previews down. Every deploy is kept, so an older one can go back up. This guide is a site of <code>cat/forge</code>, and its preview for a branch is how a change to it is read before it lands.</p>221-<p>Each site has an address of its own, so sites can't read each other's cookies or storage:</p>220+<p>On <code>main</code> this publishes the site; on any other branch, a preview of that branch, linked from its review. Deleting the branch removes its previews. Every deploy is kept, so you can roll back. This guide is a site of <code>cat/forge</code>.</p>221+<p>Each site gets its own hostname, so sites can't read each other's cookies or storage:</p>222222 </div>223223 <div class="panel">224224 <div class="panel-head"><b>where a site is served</b><span class="end"><span class="n">*.pages.src.rest</span></span></div>@@ -235,24 +235,24 @@ docs build npm ci && npm run build
235235 </div>236236 </div>237237 <div class="prose">238-<p>The forge's owner gets the short addresses. Sites are served the way Cloudflare Pages serves them, so a site built for there works here unchanged: <code>_headers</code> and <code>_redirects</code> apply, <code>/about</code> finds <code>about.html</code>, a missing page gets the nearest <code>404.html</code>, and previews stay out of search engines.</p>238+<p>The forge's owner gets the short names. Sites are served like Cloudflare Pages: <code>_headers</code> and <code>_redirects</code> work, <code>/about</code> finds <code>about.html</code>, a missing page gets the nearest <code>404.html</code> and previews aren't indexed.</p>239239 <h3>Releases</h3>240-<p>A release is a tag's files, with a list of their checksums signed by your key for that repo and that tag, so it can't pass for another. The repo's releases page lists them with who signed, and anyone can check a download against the forge's keys:</p>240+<p>A release is a tag's files plus a <code>SHA256SUMS</code> signed with your key, bound to the repo and tag. The releases page shows who signed, and anyone can verify a download:</p>241241 </div>242242 <pre class="code"><span class="dim">$</span> forge release publish v1.0 greet.sh greet-1.0.tar.gz243243 <span class="dim">$</span> forge release verify --url https://src.rest/cat/greet/releases/download/v1.0/ greet.sh244244 <span class="dim">ok greet.sh: matches SHA256SUMS of cat/greet v1.0, signed by cat</span></pre>245-<p class="prose">Downloads keep stable links: <code>/cat/<wbr>greet/<wbr>releases/<wbr>download/<wbr>latest/<wbr>greet.sh</code> is always the newest. A private repo's releases go to its readers only, on the web signed in or with a token, and over ssh.</p>245+<p class="prose">Downloads keep stable links: <code>/cat/<wbr>greet/<wbr>releases/<wbr>download/<wbr>latest/<wbr>greet.sh</code> is always the newest. A private repo's releases are for its readers only.</p>246246 </section>247247 248248 <section class="commit" id="people" aria-labelledby="people-h">249-<header><p class="ref"><b>06</b> people</p><h2 id="people-h"><a href="#people">People, and who can do what</a></h2></header>249+<header><p class="ref"><b>06</b> people</p><h2 id="people-h"><a href="#people">Who can do what</a></h2></header>250250 <div class="prose">251-<p>Everyone on the forge is a name and an SSH key: the key you push with is the one that signs your CI results, deploys and releases. An admin brings someone in with a link:</p>251+<p>A user is a name and an SSH key; the same key signs their CI results, deploys and releases. An admin invites someone with a link:</p>252252 </div>253253 <pre class="code"><span class="dim">$</span> ssh git@src.rest invite fox --repo cat/012 --as collaborator254254 <span class="dim">https://src.rest/login/#invite=...</span></pre>255-<p class="prose">The link works once, for a week. <code>fox</code> opens it, pastes a public key, and can add a passkey on the same page; from then on <code>fox</code> pushes over ssh and signs in on the web. Each repo's owner decides who else works on it:</p>255+<p class="prose">The link works once, for a week: <code>fox</code> opens it, pastes a public key and can add a passkey. Each repo's owner decides who else works on it:</p>256256 <pre class="code"><span class="dim">$</span> ssh git@src.rest collaborators cat/012 add fox <span class="dim"># may push anything</span>257257 <span class="dim">$</span> ssh git@src.rest readers cat/012 add fox <span class="dim"># may read it while it is private</span>258258 <span class="dim">$</span> ssh git@src.rest who cat/012@@ -276,13 +276,13 @@ collaborator fox</span></pre>
276276 </div>277277 <div class="prose">278278 <h3>Private repos</h3>279-<p>A private repo is there only for its owner, collaborators and readers, over ssh, on the web once they sign in, and over HTTPS with a token. To anyone else it isn't there at all: every page, clone and command answers the way it does for a repo that doesn't exist.</p>279+<p>A private repo is visible only to its owner, collaborators and readers: over ssh, on the web when signed in and over HTTPS with a token. To everyone else it doesn't exist.</p>280280 </div>281281 <pre class="code"><span class="dim">$</span> ssh git@src.rest new --private notes282282 <span class="dim">$</span> ssh git@src.rest private cat/012 <span class="dim"># or public, after a check of its whole history for secrets</span></pre>283283 <div class="prose">284284 <h3>Passkeys and tokens</h3>285-<p>There are no passwords. You make your web credentials over ssh, with the key you already have, and lose nothing if you lose one: run the command again.</p>285+<p>There are no passwords. Passkeys and tokens are made over ssh; lose one and make another.</p>286286 </div>287287 <pre class="code"><span class="dim">$</span> ssh git@src.rest passkey <span class="dim"># a one-time link that adds a passkey</span>288288 <span class="dim">$</span> ssh git@src.rest token --repo cat/012 <span class="dim"># for curl and HTTPS clones, shown once</span>@@ -296,7 +296,7 @@ collaborator fox</span></pre>
296296 <div><dt>git config --global forge.signingkey ~/.ssh/id_ed25519.pub</dt><dd>The key that signs CI results and deploys; a <code>.pub</code> path signs through ssh-agent.</dd></div>297297 <div><dt>ssh git@src.rest signers > ~/.config/forge/allowed_signers</dt><dd>Then point <code>forge.allowedsigners</code> at it, so <code>forge verify</code> can check signatures.</dd></div>298298 <div><dt>ssh git@src.rest new greet "say hello"</dt><dd>Make a repo under your name; prints the remote to add. <code>--private</code> makes it private.</dd></div>299-<div><dt>git push git@src.rest:cat/greet.git main</dt><dd>Or just push: a repo of yours that isn't there yet is made first.</dd></div>299+<div><dt>git push git@src.rest:cat/greet.git main</dt><dd>Or just push: the repo is created if it doesn't exist.</dd></div>300300 <div><dt>forge import https://github.com/you/thing.git</dt><dd>Copy a repo in (set <code>forge.host</code> first). <code>--branches</code> takes every branch.</dd></div>301301 </dl></section>302302 <section class="panel" aria-labelledby="cs-review"><h3 class="panel-head" id="cs-review">a review</h3><dl>@@ -310,7 +310,7 @@ collaborator fox</span></pre>
310310 <div><dt>ssh git@src.rest land cat/greet 7</dt><dd>Have the forge land review 7, no clone needed, with the same checks. <code>forge land --server</code> does it from a clone.</dd></div>311311 </dl></section>312312 <section class="panel" aria-labelledby="cs-ci"><h3 class="panel-head" id="cs-ci">ci</h3><dl>313-<div><dt>forge attest [--stack] [rev...]</dt><dd>Run and sign CI for commits (default <code>HEAD</code>; <code>--stack</code>, everything on top of <code>main</code>). <code>--force</code> runs again what passed.</dd></div>313+<div><dt>forge attest [--stack] [rev...]</dt><dd>Run and sign CI for commits (default <code>HEAD</code>; <code>--stack</code>, everything on top of <code>main</code>). <code>--force</code> reruns what passed.</dd></div>314314 <div><dt>forge verify [rev...]</dt><dd>Show each commit's results and who signed them.</dd></div>315315 </dl></section>316316 <section class="panel" aria-labelledby="cs-sites"><h3 class="panel-head" id="cs-sites">sites</h3><dl>@@ -324,8 +324,8 @@ collaborator fox</span></pre>
324324 <div><dt>forge release ls, rm <tag></dt><dd>This repo's releases; delete one.</dd></div>325325 </dl></section>326326 <section class="panel" aria-labelledby="cs-people"><h3 class="panel-head" id="cs-people">people, over ssh</h3><dl>327-<div><dt>ssh git@src.rest help</dt><dd>Every command you can run, and an admin's too.</dd></div>328-<div><dt>ssh git@src.rest invite fox --repo cat/012 --as collaborator</dt><dd>For admins: a one-time link that makes someone a user. <code>invites</code> lists the ones not taken up.</dd></div>327+<div><dt>ssh git@src.rest help</dt><dd>Lists the commands you can run.</dd></div>328+<div><dt>ssh git@src.rest invite fox --repo cat/012 --as collaborator</dt><dd>Admins only: a one-time invite link. <code>invites</code> lists pending ones.</dd></div>329329 <div><dt>ssh git@src.rest collaborators cat/012 add fox</dt><dd>Or <code>readers</code>; <code>rm</code> takes it back, and no argument lists them.</dd></div>330330 <div><dt>ssh git@src.rest who cat/012</dt><dd>Its owner, collaborators and readers.</dd></div>331331 <div><dt>ssh git@src.rest ls</dt><dd>Your repos, and the private ones you may read.</dd></div>@@ -337,9 +337,9 @@ collaborator fox</span></pre>
337337 <section class="commit" id="not-yet" aria-labelledby="not-yet-h">338338 <header><p class="ref"><b>08</b> not yet</p><h2 id="not-yet-h"><a href="#not-yet">What isn't there</a></h2></header>339339 <ul class="gaps">340-<li><b>Issues.</b> There is nowhere yet to file a bug or keep a list of tasks.</li>341-<li><b>Notifications.</b> Nothing emails you or pings you. Open pages update themselves, and that is all.</li>342-<li><b>CI on the server.</b> By design: CI runs where the code is written, and the forge checks the signatures.</li>340+<li><b>Issues.</b> No bug or task tracker yet.</li>341+<li><b>Notifications.</b> No email or push notifications; open pages update themselves.</li>342+<li><b>CI on the server.</b> By design: CI runs on your machine, and the forge checks signatures.</li>343343 </ul>344344 </section>345345 @@ -26,7 +26,7 @@
2626 <div class="hero-text">2727 <p class="eyebrow">for whoever maintains it next</p>2828 <h1 id="top">internals</h1>29-<p class="lede">How a push becomes pages, where the code is, what it keeps on disk, who may push what, what runs where, and how to keep it running. Read <a href="./">the guide</a> first for what forge does; this page is about how.</p>29+<p class="lede">How a push becomes pages, where the code is, what it keeps on disk, who may push what, what runs where and how to keep it running. Read <a href="./">the guide</a> first for what forge does; this page is about how.</p>3030 </div>3131 </div>3232 </section>@@ -60,18 +60,18 @@
6060 <p class="prose">A push is how almost everything changes. Here is one, followed through the server.</p>6161 <figure class="plate">6262 <ol class="flow">63-<li><span class="where">your machine</span><div><p><code>git push</code>, or <code>forge push</code>, which also merges the server's CI notes with yours, pushes them along, and passes options such as a review title.</p></div></li>63+<li><span class="where">your machine</span><div><p><code>git push</code>, or <code>forge push</code>, which also merges the server's CI notes with yours, pushes them along and passes options such as a review title.</p></div></li>6464 <li><span class="where">sshd</span><div><p>Your key matches a line in <code>authorized_keys</code> that forces <code>forge shell <name></code>. It records who you are, creates a repo of yours that a push names but isn't there yet, and hands the command to <code>git-shell</code>, which runs git or one of the commands in <code>git-shell-commands/</code> (<code>new</code>, <code>ls</code>, <code>reviews</code>, <code>land</code>, <code>comment</code>, <code>who</code>, <code>passkey</code>, <code>token</code> and the rest; <code>help</code> lists them). An admin's session also finds the admins' own, <code>invite</code>, <code>invites</code>, <code>users</code> and <code>motd</code>.</p><p class="src">bin/forge: cmd_shell</p></div></li>6565 <li><span class="where">pre-receive</span><div><p>Decides who may push which refs, refuses to delete <code>main</code> or rewrite it (unless the repo allows that), turns away a push that adds a credential, checks a release's sums and signature, and, when the repo asks for it, refuses a new <code>main</code> without a verified passing CI attestation or another person's approval.</p><p class="src">bin/forge: hook_pre_receive, scan_secrets, check_release</p></div></li>6666 <li><span class="where">git</span><div><p>The refs move.</p></div></li>6767 <li><span class="where">post-receive</span><div><p>Goes through the updated refs:</p><ul><li>a branch other than <code>main</code> gets a revision in its review log, and its tip is pinned;</li><li>a site or preview deploy is extracted and linked into the web root, and so are a release's files;</li><li>a deleted branch takes its previews with it;</li><li>the push is appended to <code>.forge/events.jsonl</code>.</li></ul><p class="src">bin/forge: hook_post_receive, record_review, deploy_site</p></div></li>6868 <li class="returns"><span>your git push returns here; the rest runs in the background</span></li>69-<li class="bg"><span class="where">forge build</span><div><p>11ty rebuilds that repo's pages into a scratch directory, hard-linking every page that didn't change from the live site, swaps them in, and appends a <code>built</code> event. Builds take turns under a lock (<a href="#build">the build</a>).</p><p class="src">bin/forge: cmd_build, eleventy.config.ts, lib/forge.ts</p></div></li>69+<li class="bg"><span class="where">forge build</span><div><p>11ty rebuilds that repo's pages into a scratch directory, hard-linking every page that didn't change from the live site, swaps them in and appends a <code>built</code> event. Builds take turns under a lock (<a href="#build">the build</a>).</p><p class="src">bin/forge: cmd_build, eleventy.config.ts, lib/forge.ts</p></div></li>7070 <li class="bg"><span class="where">your browser</span><div><p>Open pages hold a server-sent events stream from <code>/events/<owner>/<repo>/</code>. The push shows up as <q>cat pushed cat/flags, updating</q>; when the build lands, the page fetches itself and swaps in the new content, keeping your scroll position.</p><p class="src">lib/events-server.ts, site/assets/live.ts</p></div></li>7171 </ol>7272 <figcaption><b>fig. 1</b>one push, from your terminal to every open page</figcaption>7373 </figure>74-<p class="prose">Building after the push rather than on each request means public pages cost nothing to serve, can be cached anywhere, and keep working if the small Node servers stop. Those only add what files can't: live updates, private pages for their readers, comments and approvals from the web, and sites on their own hostnames.</p>74+<p class="prose">Building after the push rather than on each request means public pages cost nothing to serve, can be cached anywhere and keep working if the small Node servers stop. Those only add what files can't: live updates, private pages for their readers, comments and approvals from the web, and sites on their own hostnames.</p>7575 </section>7676 7777 <section class="commit" id="map" aria-labelledby="map-h">@@ -177,7 +177,7 @@ deleted 1788292812 cat <span class="dim"># only o
177177 <div class="prose">178178 <p><strong>CI.</strong> <code>forge attest</code> checks each commit out into a worktree (one per repo, reused so build caches stay warm), runs <code>.forge/ci</code> in it (inside <code>nix develop</code> when there is a <code>flake.nix</code> and Nix is installed), signs the statement with <code>ssh-keygen -Y sign -n forge-ci</code> and appends it to <code>refs/notes/ci</code>, keyed by commit. <code>pre-receive</code> verifies against the server's own <code>allowed_signers</code> when <code>forge.requireci</code> asks it to; anyone who can push may add notes, as long as <code>refs/notes/ci</code> only fast-forwards.</p>179179 <p><strong>Reviews.</strong> A review's parent is the nearest other review whose tip lies inside its branch; its layer is the commits between that parent (or <code>main</code>) and its tip. Status and revisions come from the review log and the refs, so nothing else is stored.</p>180-<p><strong>Sites.</strong> A deploy is a commit on <code>refs/sites/<name></code> whose tree is the built output, so git provides content-addressed storage, dedup across deploys, history, and revert by pushing an older deploy. Trailers record the source commit, the build command and who built it, and the commit carries an SSH signature when your key can sign. A preview is the same on <code>refs/<wbr>site-previews/<wbr><name>/<wbr><branch></code>. The build gets <code>FORGE_SITE_BASE</code>, the path it will be served from (<code>/</code> on its own hostname).</p>180+<p><strong>Sites.</strong> A deploy is a commit on <code>refs/sites/<name></code> whose tree is the built output, so git provides content-addressed storage, dedup across deploys, history and revert by pushing an older deploy. Trailers record the source commit, the build command and who built it, and the commit carries an SSH signature when your key can sign. A preview is the same on <code>refs/<wbr>site-previews/<wbr><name>/<wbr><branch></code>. The build gets <code>FORGE_SITE_BASE</code>, the path it will be served from (<code>/</code> on its own hostname).</p>181181 </div>182182 <figure class="plate">183183 <ol class="chain">@@ -185,11 +185,11 @@ deleted 1788292812 cat <span class="dim"># only o
185185 <li class="hop"><span>post-receive extracts its tree, once per tree</span></li>186186 <li><span class="k">store</span><span class="v">.forge/store/339f775e.../</span></li>187187 <li class="hop up"><span>symlink</span></li>188-<li><span class="k">served</span><span class="v">www/sites/cat/greet/docs</span></li>188+<li><span class="k">served</span><span class="v">www/<wbr>sites/<wbr>cat/<wbr>greet/<wbr>docs</span></li>189189 <li class="hop up"><span>symlink, with a pages domain</span></li>190190 <li><span class="k">hostname</span><span class="v">www/hosts/greet-docs</span></li>191191 <li class="hop up"><span>the pages server looks up the Host header</span></li>192-<li><span class="k">browser</span><span class="v">https://greet-docs.pages.example.com/</span></li>192+<li><span class="k">browser</span><span class="v">https://<wbr>greet-docs<wbr>.pages.example.com/</span></li>193193 </ol>194194 <figcaption><b>fig. 4</b>a deploy, from ref to hostname</figcaption>195195 </figure>@@ -200,8 +200,8 @@ deleted 1788292812 cat <span class="dim"># only o
200200 </section>201201 202202 <section class="commit" id="run" aria-labelledby="run-h">203-<header><p class="ref"><b>06</b> what runs</p><h2 id="run-h"><a href="#run">What runs, and why</a></h2></header>204-<p class="prose">The container's entrypoint is <code>tini</code>, which also reaps the background builds, and then <code>server/container/start.sh</code>. It creates host keys on first boot, runs <code>forge server init</code>, writes <code>.forge/config</code> from the environment, adds the owner's key, syncs keys, writes the Caddyfile with <code>forge server caddyfile</code>, and starts:</p>203+<header><p class="ref"><b>06</b> what runs</p><h2 id="run-h"><a href="#run">What runs and why</a></h2></header>204+<p class="prose">The container's entrypoint is <code>tini</code>, which also reaps the background builds, and then <code>server/container/start.sh</code>. It creates host keys on first boot, runs <code>forge server init</code>, writes <code>.forge/config</code> from the environment, adds the owner's key, syncs keys, writes the Caddyfile with <code>forge server caddyfile</code> and starts:</p>205205 <div class="table">206206 <table class="rows">207207 <thead><tr><th scope="col">process</th><th scope="col">listens</th><th scope="col">as</th><th scope="col">why</th></tr></thead>@@ -216,7 +216,7 @@ deleted 1788292812 cat <span class="dim"># only o
216216 <tr><th scope="row"><code>forge build</code></th><td data-l="listens"></td><td data-l="as">git</td><td>Once at boot, in the background, since templates may have changed with the image; after that, after each push.</td></tr>217217 <tr><th scope="row">backup loop</th><td data-l="listens"></td><td data-l="as">root</td><td>Only with <code>FORGE_BACKUP_REPOSITORY</code>: runs <code>forge backup</code> once a day at <code>FORGE_BACKUP_AT</code>, catching up after downtime, and prints its health to the container log.</td></tr>218218 <tr><th scope="row"><code>forge mirror</code></th><td data-l="listens"></td><td data-l="as">git</td><td>Only with <code>FORGE_MIRROR_S3</code>: after builds, syncs the forge's pages to the bucket the edge falls back to.</td></tr>219-<tr><th scope="row">Caddy</th><td data-l="listens"><code>:8080</code></td><td data-l="as">caddy</td><td>In the foreground. Plain HTTP; TLS ends at the edge. Pages, <code>.git</code> files, <code>go get</code> meta tags, and the two proxies.</td></tr>219+<tr><th scope="row">Caddy</th><td data-l="listens"><code>:8080</code></td><td data-l="as">caddy</td><td>In the foreground. Plain HTTP; TLS ends at the edge. Pages, <code>.git</code> files, <code>go get</code> meta tags and the two proxies.</td></tr>220220 </tbody>221221 </table>222222 </div>@@ -225,7 +225,7 @@ deleted 1788292812 cat <span class="dim"># only o
225225 226226 <section class="commit" id="hosting" aria-labelledby="hosting-h">227227 <header><p class="ref"><b>07</b> hosting</p><h2 id="hosting-h"><a href="#hosting">Where it runs</a></h2></header>228-<p class="prose">The forge is one container: sshd for git, Caddy for pages, sites and read-only HTTPS clones (<code>git http-backend</code> through fcgiwrap, and <code>go get</code> works), the events server, the private server, the pages server when there is a pages domain, and optionally tailcat. All state is in <code>/srv/git</code>, a volume. <code>forge backup</code> snapshots it every day with restic into an R2 bucket and test-restores each snapshot, and <code>forge restore</code> brings the forge back on a new box, host keys and tunnel address included.</p>228+<p class="prose">The forge is one container: sshd for git, Caddy for pages, sites and read-only HTTPS clones (<code>git http-backend</code> through fcgiwrap, and <code>go get</code> works), the events server, the private server, the pages server when there is a pages domain and optionally tailcat. All state is in <code>/srv/git</code>, a volume. <code>forge backup</code> snapshots it every day with restic into an R2 bucket and test-restores each snapshot, and <code>forge restore</code> brings the forge back on a new box, host keys and tunnel address included.</p>229229 <div class="hosts">230230 <figure class="plate">231231 <ol class="hops">@@ -254,12 +254,12 @@ deleted 1788292812 cat <span class="dim"># only o
254254 <figure class="plate">255255 <ol class="flow">256256 <li><span class="where">post-receive</span><div><p>Starts <code>forge build <owner>/<repo></code> with <code>nohup</code>, logging to <code>build.log</code>. <code>FORGE_SYNC_BUILD=1</code> runs it in the foreground instead; <code>FORGE_NO_BUILD=1</code> skips it.</p></div></li>257-<li><span class="where">lock</span><div><p><code>mkdir .forge/build.lock</code>. Builds wait their turn, for up to ten minutes.</p></div></li>257+<li><span class="where">lock</span><div><p><code>mkdir .forge/build.lock</code>. Builds wait their turn, for up to 10 minutes.</p></div></li>258258 <li><span class="where">graph</span><div><p><code>git <wbr>commit-graph <wbr>write <wbr>--reachable <wbr>--changed-paths <wbr>--split</code> adds a small layer for the new commits, with Bloom filters, so <code>git log -- <path></code> and blame skip commits that don't touch the path. Here rather than in the hook, so the pusher never waits on it.</p></div></li>259259 <li><span class="where">data</span><div><p>11ty loads <code>lib/forge.ts</code> with <code>FORGE_ONLY</code> set, so only that repo is read. Index and owner pages use cached summaries of the others. Commit metadata, range-diffs and signature checks come from <code>.forge/cache/</code>, keyed by object ids that never change.</p></div></li>260260 <li><span class="where">skip</span><div><p>Every page has a fingerprint of what it shows, plus a hash of the forge's own code. A page whose fingerprint matches the last build, and whose file still exists, isn't rendered: its file is hard-linked from the live site.</p></div></li>261261 <li><span class="where">render</span><div><p>The rest render into <code>.forge/build.<pid>/</code>. Diffs and file views are written by filters as each page is, so memory stays bounded by the largest page, not the repo. Each page links one CSS bundle of the blocks its templates carry.</p></div></li>262-<li><span class="where">assets</span><div><p>Stylesheet, scripts and bundles get names from a hash of their contents, so browsers cache them for good. Pages from older code are still live (the other repos, mid-rebuild, or a repo whose build failed), so the files of the last five code versions are carried over from the live site; <code>assets/versions.json</code> lists them.</p></div></li>262+<li><span class="where">assets</span><div><p>Stylesheet, scripts and bundles get names from a hash of their contents, so browsers cache them for good. Pages from older code are still live (the other repos mid-rebuild, or a repo whose build failed), so the files of the last five code versions are carried over from the live site; <code>assets/versions.json</code> lists them.</p></div></li>263263 <li><span class="where">swap</span><div><p>The repo's directory is moved into <code>www/</code> in place of the old one, and the owner's index page is copied over. <code>sites/</code>, <code>previews/</code> and <code>hosts/</code> belong to the hooks and are never touched.</p></div></li>264264 <li><span class="where">after</span><div><p>A line in <code>metrics.jsonl</code> (<code>forge metrics</code> reads it) and a <code>built</code> event that open pages act on.</p></div></li>265265 </ol>@@ -297,7 +297,7 @@ deleted 1788292812 cat <span class="dim"># only o
297297 <section aria-labelledby="k-config"><h3 id="k-config">.forge/config, on the server</h3><dl>298298 <div><dt>FORGE_NAME, FORGE_BASE_URL, FORGE_SSH</dt><dd>The forge's name, web address and ssh address, for pages and clone URLs.</dd></div>299299 <div><dt>FORGE_PAGES_DOMAIN, FORGE_OWNER</dt><dd>Turn on per-site hostnames; the owner's sites get the short ones.</dd></div>300-<div><dt>FORGE_MAX_COMMITS, FORGE_MAX_FILES, FORGE_MAX_BLOB, FORGE_MAX_DIFF_LINES</dt><dd>Limits per branch log, per tree, per file shown and per diff shown: 1000, 5000, 1 MiB, 20000 lines.</dd></div>300+<div><dt>FORGE_MAX_COMMITS, FORGE_MAX_FILES, FORGE_MAX_BLOB, FORGE_MAX_DIFF_LINES</dt><dd>Limits per branch log, per tree, per file shown and per diff shown. The defaults are 1,000 commits, 5,000 files, 1 MiB and 20,000 lines.</dd></div>301301 </dl></section>302302 <section aria-labelledby="k-repo"><h3 id="k-repo">a bare repo's git config</h3><dl>303303 <div><dt>forge.requireci tip | all</dt><dd>A verified pass before <code>main</code> moves, for the new tip or every new commit.</dd></div>@@ -310,7 +310,7 @@ deleted 1788292812 cat <span class="dim"># only o
310310 <div><dt>forge.cidir, forge.nix</dt><dd>Where CI worktrees live; <code>forge.nix false</code> skips <code>nix develop</code>.</dd></div>311311 </dl></section>312312 <section aria-labelledby="k-backup"><h3 id="k-backup">forge.env, for backups</h3><dl>313-<div><dt>FORGE_BACKUP_REPOSITORY, _PASSWORD, _KEY_ID, _SECRET</dt><dd>The restic repository (an R2 bucket), its password, and the bucket's keys. Keep the password off the box.</dd></div>313+<div><dt>FORGE_BACKUP_REPOSITORY, _PASSWORD, _KEY_ID, _SECRET</dt><dd>The restic repository (an R2 bucket), its password and the bucket's keys. Keep the password off the box.</dd></div>314314 <div><dt>FORGE_BACKUP_AT, _KEEP_DAILY, _KEEP_WEEKLY</dt><dd>When the daily backup runs (UTC) and what retention keeps: 03:30, 7 and 5.</dd></div>315315 <div><dt>FORGE_MIRROR_S3, _KEY_ID, _SECRET, _DELAY</dt><dd>The pages mirror's bucket and keys, and how long it waits after a build: 30 seconds.</dd></div>316316 </dl></section>@@ -355,14 +355,14 @@ deleted 1788292812 cat <span class="dim"># only o
355355 <li>hostnames whose deploy is gone,</li>356356 <li>store trees that no <code>www/sites</code> or <code>www/previews</code> link points at.</li>357357 </ul>358-<p>A deploy extracts its tree before it links it, and touches a tree it reuses, so gc skips store entries changed in the last ten minutes (<code>--age</code>) and is safe to run while people push. <code>--dry-run</code> shows what would go. Run it daily, from the host's crontab for the container (<code>docker <wbr>exec <wbr>forge <wbr>forge-admin <wbr>gc</code>) or the git user's on a VPS.</p>358+<p>A deploy extracts its tree before it links it, and touches a tree it reuses, so gc skips store entries changed in the last 10 minutes (<code>--age</code>) and is safe to run while people push. <code>--dry-run</code> shows what would go. Run it daily, from the host's crontab for the container (<code>docker <wbr>exec <wbr>forge <wbr>forge-admin <wbr>gc</code>) or the git user's on a VPS.</p>359359 <p><strong>Repos</strong> are maintained by the same <code>forge gc</code>, in <code>repack_repo</code>: <code>git <wbr>repack <wbr>-d <wbr>--geometric=2 <wbr>--write-midx <wbr>--write-bitmap-index</code> merges packs only until each is at least twice the next one's size, so a day's run rewrites the day's pushes and loose objects, not the repo, and one multi-pack index with a bitmap serves clones and fetches across the packs. It deletes only packs it rewrote and prunes nothing, so pushes can land while it runs. Then the commit-graph is rewritten as one layer (<code>--split=replace</code>, reusing the Bloom filters it has, adding any that old layers lack) and refs are packed, since every revision pins a loose <code>refs/forge/keep/<sha></code>. Between runs, each build adds a commit-graph layer for its push.</p>360360 <p><strong>Repo settings</strong> come from <code>repo_setup</code>, when a repo is created (<code>repo new</code>, <code>ssh new</code>, a push to a missing repo, an import) and for every repo on <code>forge server init</code>, which the container runs on boot: the hooks, <code>receive.advertisePushOptions</code>, <code>transfer.hideRefs refs/forge</code> so clones, fetches and pushes never see the pinned revisions (the build reads them directly; pre-receive also refuses them), and <code>commitGraph.changedPathsVersion <wbr>2</code>, filters that hash non-ASCII paths the same on every platform.</p>361-<p><strong>Backups</strong>: <code>forge backup</code> stages a copy of <code>/srv/git</code> without what a restore rebuilds (pages, extracted sites, caches, generated keys files, hooks, logs), uploads it with restic, prunes old snapshots, runs <code>restic check</code>, and restores the new snapshot into a scratch directory to <code>git fsck</code> every repo and compare its refs. The copy is consistent mid-push: per repo it copies review logs and comments, then reads the refs, then hard-links the objects, which git writes before anything names them. It refuses a forge with no repos, or half the last backup's, so a new box can't push good snapshots out. <code>forge-admin backups</code> says how the last one went, and loudly when it failed or is a day and a half old.</p>361+<p><strong>Backups</strong>: <code>forge backup</code> stages a copy of <code>/srv/git</code> without what a restore rebuilds (pages, extracted sites, caches, generated keys files, hooks, logs), uploads it with restic, prunes old snapshots, runs <code>restic check</code> and restores the new snapshot into a scratch directory to <code>git fsck</code> every repo and compare its refs. The copy is consistent mid-push: per repo it copies review logs and comments, then reads the refs, then hard-links the objects, which git writes before anything names them. It refuses a forge with no repos, or half the last backup's, so a new box can't push good snapshots out. <code>forge-admin backups</code> says how the last one went, and loudly when it failed or is a day and a half old.</p>362362 <p><strong>Moving</strong>: <code>forge restore latest</code> on an empty forge unpacks a snapshot and rebuilds the rest: what <code>server init</code> and <code>keys sync</code> write, hooks, pinned revisions, site files, pages. The restored tailcat key keeps the tunnel address, so the edge follows the forge to a new box by itself. The README walks through a new slate and a VPS.</p>363363 <p><strong>The offline copy</strong>: after builds, <code>forge mirror</code> syncs the forge's own pages, without sites or previews, to an R2 bucket. When slate doesn't answer, the edge's Caddy serves them from there with <code>X-Forge-Offline: mirror</code>; sites, clones and pushes wait for slate.</p>364364 <p><strong>Updates</strong>: <code>deploy/slate/deploy.sh</code> rsyncs the checkout to the box and rebuilds the container; the boot build refreshes every page. A change to <code>lib/</code> or <code>site/</code> changes the code hash in every fingerprint, so that first build renders everything.</p>365-<p><strong>Logs</strong>: <code>.forge/build.log</code> for builds, <code>.forge/events.log</code> for the events server, and <code>backup.log</code> and <code>mirror.log</code>, kept under a megabyte. <code>events.jsonl</code> is cut back to its last thousand lines once it passes a megabyte.</p>365+<p><strong>Logs</strong>: <code>.forge/build.log</code> for builds, <code>.forge/events.log</code> for the events server, and <code>backup.log</code> and <code>mirror.log</code>, kept under a megabyte. <code>events.jsonl</code> is cut back to its last 1,000 lines once it passes a megabyte.</p>366366 </div>367367 </section>368368 @@ -376,7 +376,7 @@ deleted 1788292812 cat <span class="dim"># only o
376376 <div><dt>npm run dev</dt><dd>Serves the demo from <code>_site/</code> on :8080 and rebuilds it, reloading the browser, when templates, assets or <code>lib/</code> change. It never writes to <code>demo/root</code>.</dd></div>377377 </dl></section>378378 <section aria-labelledby="hk-check"><h3 id="hk-check">check it</h3><dl>379-<div><dt>sh .forge/ci</dt><dd>Lints the shell, type-checks, builds the demo, checks a few pages, and checks this guide's links. It is forge's own CI, so <code>forge attest</code> runs it and signs the result.</dd></div>379+<div><dt>sh .forge/ci</dt><dd>Lints the shell, type-checks, builds the demo, checks a few pages and checks this guide's links. It is forge's own CI, so <code>forge attest</code> runs it and signs the result.</dd></div>380380 <div><dt>npx tsc -p .</dt><dd>Type check only. <code>tsc</code> never emits.</dd></div>381381 <div><dt>node lib/check-links.ts guide</dt><dd>Every relative link and anchor in the guide resolves.</dd></div>382382 <div><dt>node lib/render-page.ts check <www></dt><dd>Every page renders the same with plain Nunjucks as with 11ty, the base for rendering pages on demand.</dd></div>@@ -384,7 +384,7 @@ deleted 1788292812 cat <span class="dim"># only o
384384 </div>385385 <div class="prose" style="margin-top:1.4rem">386386 <p>Conventions: shell stays POSIX (<code>sh -n bin/forge</code>); TypeScript uses erasable syntax only and imports name their <code>.ts</code> files, since Node strips types and runs the sources; no frameworks; comments say why. Pages must work without JavaScript, which only adds shortcuts, live updates and conveniences.</p>387-<p>This guide is plain HTML and CSS with a few lines of script, and every link in it is relative, so it works on its own hostname and under any path. Edit <code>guide/</code>, run the checks, and publish from <code>main</code> with <code>forge site deploy guide</code>.</p>387+<p>This guide is plain HTML and CSS with a few lines of script, and every link in it is relative, so it works on its own hostname and under any path. Edit <code>guide/</code>, run the checks and publish from <code>main</code> with <code>forge site deploy guide</code>.</p>388388 </div>389389 </section>390390