clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

The guide says what forge is; internals says how #23

7 files changed, +584 -413
MDESIGN.md10+
MREADME.md231+++++++++---------
Mguide/guide.css124++++++----
Mguide/index.html393+++++++++++++++---------------
Mguide/internals.html229++++++++++++++----
Mserver/Caddyfile.example2+-
Msite/design.njk8+-
M DESIGN.md +10 -0
M README.md +115 -116
@@ -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,13 +32,13 @@ 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>...`3939 puts a tag's binaries on `refs/releases/v1.0` with a `SHA256SUMS` signed4040 by your key; the forge checks both and serves the files.41-- **Keyboard navigation and clean URLs**, e.g. `/dami/greet/tree/main/greet.sh/`.41+- **Keyboard navigation and clean URLs**, e.g. `/cat/greet/tree/main/greet.sh/`.4242 - **Search and syntax highlighting.** `/` searches a repo's commits, reviews,4343 wiki and file paths from a static index, and file contents from a SQLite4444 index the browser reads in byte ranges; code is highlighted at build time.
@@ -58,19 +58,19 @@ sh demo/make-demo.sh # throwaway forge in demo/root, real hooks over fi
5858 python3 -m http.server 4817 -d demo/root/.forge/www5959 ```6060 61-The demo creates two users (`dami`, `ana`) and a repo with:61+The demo creates two users and a repo with:6262 6363 - a landed review and a two-layer stack, with both layers rewritten once,6464 and a description, comments and approvals on it (one comment outdated by6565 the rewrite, one approval stale);66-- a contribution from `ana` whose CI fails and then passes, and which `main`66+- 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;71-- a standalone site that `ana` owns;71+- 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
@@ -135,19 +135,19 @@ Repos live at `/srv/git/<owner>/<repo>.git`, and `<owner>` owns them. Anyone
135135 can read a public repo; a private one, only the people below. The owner and136136 anyone listed in `<repo>.git/forge/collaborators` can push anything. Other137137 users who can read it can push branches and previews under their own name138-(`ana/...`) and add CI notes, which must fast-forward. Nobody can force-push138+(`fox/...`) and add CI notes, which must fast-forward. Nobody can force-push139139 `main` unless `forge.allowforce` is set.140140 141141 ```sh142-ssh git@git.example.com collaborators dami/notes add ana # rm to take it back; no argument lists them143-ssh git@git.example.com who dami/notes # its owner, collaborators and readers142+ssh git@git.example.com collaborators cat/notes add fox # rm to take it back; no argument lists them143+ssh git@git.example.com who cat/notes # its owner, collaborators and readers144144 ```145145 146146 Only the owner changes a repo's collaborators; its collaborators can list147147 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
@@ -156,8 +156,8 @@ A user is added by the operator, with their SSH public key, or invited by
156156 a link that lets them give it themselves:157157 158158 ```sh159-forge user add ana 'ssh-ed25519 AAAA... ana@laptop'160-forge user invite fox [--ttl 7d] [--repo dami/notes --as reader|collaborator]159+forge user add fox 'ssh-ed25519 AAAA... fox@laptop'160+forge user invite fox [--ttl 7d] [--repo cat/notes --as reader|collaborator]161161 forge user invites [rm fox] # invites not taken up yet; rm makes a link dead162162 forge users # everyone: keys, passkeys and tokens; then the invites163163 ```
@@ -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.
@@ -183,7 +183,7 @@ several names split by spaces or commas. Owning repos doesn't make anyone
183183 an admin. Set it on the server, as git:184184 185185 ```sh186-git config --file /srv/git/.gitconfig --add forge.admins dami # in the container:186+git config --file /srv/git/.gitconfig --add forge.admins cat # in the container:187187 ssh slate docker exec -u git forge git config --file /srv/git/.gitconfig --add forge.admins cat188188 git config --file /srv/git/.gitconfig --get-all forge.admins # the list; --unset-all forge.admins empties it189189 ```
@@ -192,7 +192,7 @@ An admin runs the people commands above over ssh, from their own machine,
192192 the same as on the server:193193 194194 ```sh195-ssh git@git.example.com invite fox [--ttl 7d] [--repo dami/notes --as reader|collaborator]195+ssh git@git.example.com invite fox [--ttl 7d] [--repo cat/notes --as reader|collaborator]196196 ssh git@git.example.com invites [rm fox]197197 ssh git@git.example.com users198198 ```
@@ -220,23 +220,23 @@ 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 dami/notes`, and lets the push go on.223+prints `created cat/notes` and lets the push go on.224224 225225 ```sh226-git push git@git.example.com:dami/notes.git main226+git push git@git.example.com:cat/notes.git main227227 ```228228 229229 The first branch pushed becomes the default if it isn't `main`. A push to a230230 missing repo under someone else's name, or a clone or fetch of one, fails231231 as before.232232 233-`ssh git@git.example.com rm dami/notes --yes` takes a repo of yours off the233+`ssh git@git.example.com rm cat/notes --yes` takes a repo of yours off the234234 forge (without `--yes` it says what would go); collaborators can't, and235235 `forge repo rm <owner>/<repo>` does the same as root. Its clones, pages in236236 either root, sites, previews, hostnames, release downloads and insights go237237 at once, and a mirror sync within 30 seconds deletes its pages. The bare repo waits in238238 `.forge/trash/<owner>/<repo>.git.<time>` for `FORGE_TRASH_DAYS` (14) days,239-backed up like the rest of the forge: `forge repo restore <owner>/<repo>`239+backed up like the rest of the forge. `forge repo restore <owner>/<repo>`240240 puts back the newest removal if the name is free, and `forge gc` deletes it241241 after that.242242
@@ -246,7 +246,7 @@ A repo is public while its bare repo has a `git-daemon-export-ok` file. That
246246 is git's own flag: `git daemon` and `git http-backend` serve only repos that247247 have it, and Forgejo toggles the same file. `forge repo new` adds it, and248248 `--private` leaves it out: `ssh git@git.example.com new --private <name>`,249-`forge repo new --private`, and the imports below.249+`forge repo new --private` and the imports below.250250 251251 A private repo can be read by its owner, its collaborators and the names in252252 `<repo>.git/forge/readers`: over ssh, on the web once they sign in (see
@@ -254,10 +254,10 @@ A private repo can be read by its owner, its collaborators and the names in
254254 else it isn't there: a clone, fetch, archive, push or ssh command gets the255255 same answer, byte for byte, as for a repo that doesn't exist, however the256256 path is spelled, and so does every page of it. It has no entry on the257-index, owner pages or `repos.json`, its pages and live events go to its257+index, owner pages or `repos.json`. Its pages and live events go to its258258 readers only, and the pages mirror never sees it. Its sites and previews259259 aren't served unless `git config forge.publicsites true`. Its releases260-are, to its readers only, next to its pages, and over ssh (see260+are, to its readers only, next to its pages and over ssh (see261261 [Releases](#releases)). Pushes record reviews and print their links, which262262 work for its readers, and `forge reviews` reads them from the review logs263263 (without CI). The secret check still runs, since a secret stays in the
@@ -265,20 +265,20 @@ history when the repo goes public later; Jev isn't asked about private
265265 repos.266266 267267 ```sh268-ssh git@git.example.com readers dami/notes add ana # rm to take it back; no argument lists them269-ssh git@git.example.com who dami/notes # who reads it, and who may push it270-ssh git@git.example.com private dami/greet271-ssh git@git.example.com public dami/notes [--force]268+ssh git@git.example.com readers cat/notes add fox # rm to take it back; no argument lists them269+ssh git@git.example.com who cat/notes # who reads it, and who may push it270+ssh git@git.example.com private cat/greet271+ssh git@git.example.com public cat/notes [--force]272272 ssh git@git.example.com ls # yours, and the private ones you may read273273 ```274274 275275 The owner and collaborators flip a repo, the way Forgejo does. `private`276276 removes the flag, the repo's public pages, its entries on the index, owner277277 pages and `repos.json`, and its served sites, previews and their278-hostnames, moves its release downloads into the private root (and their279-files into the private store, see [Releases](#releases)), and builds280-its pages again there. Then it starts281-a mirror sync within 30 seconds, which deletes them from the bucket. Clones, caches and copies278+hostnames. It moves its release downloads into the private root (and279+their files into the private store, see [Releases](#releases)) and builds280+its pages again there. Then it starts a mirror sync within 30 seconds,281+which deletes them from the bucket. Clones, caches and copies282282 people already have can't be recalled. `public` first runs the secret check283283 over the whole history: every commit on a branch or tag, and the old review284284 revisions that review pages show, not only what a push adds. If it finds
@@ -305,9 +305,9 @@ serves a private repo's page to someone who may read it (the rule
305305 `can_read` in `bin/forge` applies, kept in step with `lib/access.ts` by a306306 scenario) and is still a user, signed in with a passkey or showing a token,307307 with the headers Caddy gives pages (ETag, 304s, byte ranges, which code308-search reads `code.db` with) except `Cache-Control: private, no-cache`,309-and its release downloads as attachments, as Caddy sends a public repo's.310-Not signed in, not allowed, or a page that isn't there: the same 404, byte308+search reads `code.db` with) except `Cache-Control: private, no-cache`.309+It serves the repo's release downloads as attachments, as Caddy sends a310+public repo's. Not signed in, not allowed or a page that isn't there: the same 404, byte311311 for byte, which every missing page of the forge gets, public or not.312312 `/private/` lists the private repos you can read.313313
@@ -319,7 +319,7 @@ or emails, and losing them is fixed by running ssh again:
319319 ssh git@git.example.com passkey [name] # a link that adds a passkey: once, within 10 minutes320320 ssh git@git.example.com passkeys [rm <id>]321321 ssh git@git.example.com sessions [rm <id>|--all]322-ssh git@git.example.com token [--repo dami/notes] [--ttl 30d] [name] # printed once322+ssh git@git.example.com token [--repo cat/notes] [--ttl 30d] [name] # printed once323323 ssh git@git.example.com tokens [rm <id>]324324 ```325325
@@ -341,8 +341,8 @@ ssh git@git.example.com tokens [rm <id>]
341341 - **Tokens** are for one repo or for every repo you can read when they are342342 used, for 30 days unless `--ttl` (at most 365d). Send one as343343 `Authorization: Bearer <token>`, or as the password of basic auth:344- `git clone https://dami:<token>@git.example.com/dami/notes.git`, a345- credential helper, or the token alone as the user name.344+ `git clone https://cat:<token>@git.example.com/cat/notes.git`, a345+ 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
@@ -368,7 +368,7 @@ Push options set metadata: `forge push -t "Title" -d "Description" --draft --tar
368368 The description is one line (`\n` makes a line break, and so does a real369369 newline in the argument) and renders as markdown under the title, sanitized370370 like a README. Each push that creates or updates a review prints its link,371-`review #7: https://git.example.com/dami/greet/reviews/7/ (on #6 dami/flags)`,371+`review #7: https://git.example.com/cat/greet/reviews/7/ (on #6 cat/flags)`,372372 from `FORGE_BASE_URL` (just the path if that isn't set).373373 374374 ### Comments and approvals
@@ -385,7 +385,7 @@ forge comment -m "Looks good" # the current branch's review
385385 forge comment 7 greet.sh:12 # a line comment; the message from $EDITOR or stdin386386 forge approve 7387387 forge land 7 # what the server gates check, then fast-forward388-ssh git@git.example.com land dami/greet 7 # the forge lands it itself; no clone needed388+ssh git@git.example.com land cat/greet 7 # the forge lands it itself; no clone needed389389 ```390390 391391 - **Comments** are files, `<repo>.git/forge/comments/<review>/<n>`: header
@@ -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
@@ -460,7 +460,7 @@ minute. Caddy sends these posts to the private server before it looks in
460460 461461 ```sh462462 curl -H "Authorization: Bearer $token" -d 'body=Looks good' -d 'line=greet.sh:12' \463- https://git.example.com/dami/greet/reviews/7/comment463+ https://git.example.com/cat/greet/reviews/7/comment464464 ```465465 466466 ### CI attestations, and what they prove
@@ -478,11 +478,11 @@ script .forge/ci 99a0d5e...
478478 result pass479479 exit 0480480 duration 41s481-runner cat@early-yak-00481+runner cat@laptop482482 system Darwin 25.5.0 arm64483483 env host484484 log sha256:... 120 lines485-|ok greet --name ana485+|ok greet --name fox486486 -----BEGIN SSH SIGNATURE-----487487 ```488488
@@ -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).
@@ -559,7 +559,7 @@ commit whose tree is the built output.
559559 ```sh560560 git tag v1.0561561 forge release publish v1.0 dist/greet-linux-amd64 dist/greet-1.0.tar.gz562-forge release verify --url https://git.example.com/dami/greet/releases/download/v1.0/ \562+forge release verify --url https://git.example.com/cat/greet/releases/download/v1.0/ \563563 ~/Downloads/greet-linux-amd64 # SHA256SUMS and its .sig next to it564564 forge release ls # tag, file, size, sha256565565 forge release rm v1.0
@@ -572,7 +572,7 @@ forge-release`, using the key that signs CI results. What it signs is
572572 573573 ```574574 forge-release 1575-repo dami/greet575+repo cat/greet576576 tag v1.0577577 <the bytes of SHA256SUMS>578578 ```
@@ -589,7 +589,7 @@ the files and the old ones aren't kept reachable.
589589 release they say they are from: `--repo` and `--tag`, else `--url`, else590590 the path they were saved under if it is a download path (`wget -x`), else591591 the remote for the repo. It says which release it verified (`ok592-greet-linux-amd64: matches SHA256SUMS of dami/greet v1.0, signed by dami`).592+greet-linux-amd64: matches SHA256SUMS of cat/greet v1.0, signed by cat`).593593 The forge's keys come from `--signers <file>`, or `ssh <host> signers` with594594 `--host`, `forge.allowedsigners`, `forge.host` or the remote.595595
@@ -617,12 +617,12 @@ site (a private repo's into a store of its own, below), and hard-links its
617617 files into618618 `/<owner>/<repo>/releases/download/<tag>/` (a `/` in the tag becomes `~`).619619 `latest/` next to them holds the files of the newest tag that has a620-release, so `/dami/greet/releases/download/latest/greet-linux-amd64` keeps620+release, so `/cat/greet/releases/download/latest/greet-linux-amd64` keeps621621 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
@@ -656,10 +656,10 @@ Readers can also get them over ssh, and to anyone else these commands
656656 answer as for a missing repo:657657 658658 ```sh659-curl -fO -u ana:<token> https://git.example.com/dami/notes/releases/download/v1.0/tool # token --repo dami/notes660-ssh git@git.example.com release ls dami/notes661-ssh git@git.example.com release get dami/notes v1.0 tool > tool # also SHA256SUMS, SHA256SUMS.sig662-forge release verify --repo dami/notes --tag v1.0 tool659+curl -fO -u fox:<token> https://git.example.com/cat/notes/releases/download/v1.0/tool # token --repo cat/notes660+ssh git@git.example.com release ls cat/notes661+ssh git@git.example.com release get cat/notes v1.0 tool > tool # also SHA256SUMS, SHA256SUMS.sig662+forge release verify --repo cat/notes --tag v1.0 tool663663 ```664664 665665 A verified download proves what a verified attestation does, about these
@@ -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.
@@ -945,8 +945,8 @@ On slate, everything lives in `/srv/forge` (`src/`, `data/`, `forge.env`):
945945 946946 ```sh947947 deploy/slate/deploy.sh # ship HEAD (git archive), docker compose up -d --build948-ssh slate docker exec forge forge-admin user add ana 'ssh-ed25519 AAAA...'949-ssh slate docker exec forge forge-admin user invite ana --repo dami/notes --as collaborator # a link instead948+ssh slate docker exec forge forge-admin user add fox 'ssh-ed25519 AAAA...'949+ssh slate docker exec forge forge-admin user invite fox --repo cat/notes --as collaborator # a link instead950950 ssh slate docker exec forge forge-admin users951951 ```952952
@@ -961,21 +961,21 @@ port 22 doesn't work on shared addresses. DNS for `src.rest` and
961961 `pages.src.rest` points at the edge's addresses, DNS-only on Cloudflare962962 (SSH can't go through Cloudflare's proxy). To rotate the edge key:963963 `tailcat genkey --client --key=edge.private.json`, put its nodekey in964-`/srv/forge/forge.env`, its file in the `TAILCAT_CLIENT_KEY` secret, and redeploy both.964+`/srv/forge/forge.env`, its file in the `TAILCAT_CLIENT_KEY` secret and redeploy both.965965 966966 ### A plain VPS instead967967 968968 `server/provision.sh` turns a fresh Debian 13 or Ubuntu 24.04 box into the969969 forge directly (git, sshd, Node, Caddy with automatic TLS, and systemd units970970 for the events, private and pages servers). It needs Node 22.18 or newer,971-which runs the TypeScript sources as they are, and 24 LTS (24.7 or later) is972-best, as in the container, for post-quantum passkeys; those distros ship971+which runs the TypeScript sources as they are. Node 24 LTS (24.7 or later) is972+best, as in the container, for post-quantum passkeys. Those distros ship973973 older ones, so install a current Node first:974974 975975 ```sh976976 rsync -a --exclude node_modules --exclude demo/root --exclude demo/work ./ root@HOST:/opt/forge/977977 scp ~/.ssh/id_ed25519.pub root@HOST:/tmp/me.pub978-ssh root@HOST sh /opt/forge/server/provision.sh git.example.com dami /tmp/me.pub [pages.example.com]978+ssh root@HOST sh /opt/forge/server/provision.sh git.example.com cat /tmp/me.pub [pages.example.com]979979 ```980980 981981 Either way: every key in `authorized_keys` is forced through `forge shell`, so
@@ -995,7 +995,7 @@ tree stays when a newer deploy takes its link. `forge gc` removes trees in
995995 names, and trees in `.forge/private/store` that no private repo's release996996 ref names (release files are hard links, which name no tree), so a repo997997 that flipped leaves nothing in the store it left. It also removes hostnames998-whose deploy is gone, previews of review branches that no longer exist,998+whose deploy is gone, previews of review branches that no longer exist999999 and the downloads of releases whose ref was deleted without the hooks, in10001000 `www` or the private root. A deploy extracts its tree before it links it, and10011001 touches a tree it reuses, so store entries changed in the last 10 minutes
@@ -1067,11 +1067,11 @@ For the private ones, run the same loop with `select(.isPrivate)` and
10671067 credentials.10681068 10691069 Then point existing clones at the forge:1070-`git remote set-url origin git@git.example.com:dami/<repo>.git`.1070+`git remote set-url origin git@git.example.com:cat/<repo>.git`.10711071 10721072 A public repo is public everywhere: pages, sites, releases, HTTPS clones and10731073 the pages mirror. A private one is read over ssh, on its pages once you sign1074-in, and over HTTPS with a token (see [Private repos](#private-repos)).1074+in and over HTTPS with a token (see [Private repos](#private-repos)).10751075 10761076 ## Backups and moving hosts10771077
@@ -1088,11 +1088,10 @@ Everything in `/srv/git` that can't be rebuilt:
10881088 10891089 - every bare repo, `<owner>/<repo>.git`, with its refs (releases too, and so10901090 their files, which restic stores once however many backups hold them),1091- its config, its1092- `git-daemon-export-ok` if it has one (so a private repo comes back1093- private), and its whole `forge/` directory: review logs, comments,1094- collaborators, readers, and anything else kept there;1095-- `.forge/keys`, `.forge/config`, and `.forge/www/hosts/`, which hostname each1091+ its config, its `git-daemon-export-ok` if it has one (so a private repo1092+ comes back private) and its whole `forge/` directory: review logs,1093+ comments, collaborators, readers and anything else kept there;1094+- `.forge/keys`, `.forge/config` and `.forge/www/hosts/`, which hostname each10961095 site has;10971096 - `.forge/identity.db`, as SQLite copies it (`VACUUM INTO`): one consistent10981097 file, never the live one with its WAL;
@@ -1136,10 +1135,10 @@ After uploading, a backup
11361135 2. runs `restic check`, and about once a week also reads a fifth of the stored11371136 data, a different fifth each week;11381137 3. restores the new snapshot into a scratch directory and drills it: the same1139- files as staged, a clean full `git fsck` in every repo, and the ref count1138+ files as staged, a clean full `git fsck` in every repo and the ref count11401139 each live repo had.11411140 1142-A failure goes to `.forge/backup.log`, makes the command exit non-zero, and is1141+A failure goes to `.forge/backup.log`, makes the command exit non-zero and is11431142 recorded in `.forge/backup.status`. A backup also refuses a forge with no11441143 repos, or with under half the repos of the last backup (`--force` overrides):11451144 retention keeps one snapshot a day, and a new box backing itself up before
@@ -1178,7 +1177,7 @@ open (`docker compose stop forge`, then `docker compose run --rm forge
11781177 forge-admin identity restore ...`); the file it replaces is kept as11791178 `identity.db.before-restore`. A box that boots without identity.db (a new11801179 one) takes it from the replica before anything makes an empty one, whose1181-replication would bury the replica's; if the replica can't be read then,1180+replication would bury the replica's. If the replica can't be read then,11821181 nothing replicates until the next boot. `forge restore` takes whichever of11831182 the backup's copy and the replica changed last (every write, deletions11841183 too, stamps the file), which is litestream's unless replication had
@@ -1264,19 +1263,18 @@ What you set up yourself, once:
12641263 5. **The edge**: set `FORGE_MIRROR_URL` in `deploy/edge/fly.toml` to the12651264 bucket's public URL (`FORGE_DOMAIN` is set already), then `fly deploy`. Its12661265 secrets are unchanged: `TAILCAT_CLIENT_KEY` and `FORGE_TAILCAT_ADDR`.1267-6. **Check it**: `docker exec forge forge-admin backup`, then `backups`, and1266+6. **Check it**: `docker exec forge forge-admin backup`, then `backups` and12681267 `forge-admin mirror` for the first sync instead of waiting for a push.12691268 12701269 ### Restoring, or moving to another host12711270 12721271 `forge restore [<snapshot>|latest]` brings a forge back into an empty1273-`/srv/git`. It restores the files, rebuilds what a backup leaves out (what1274-`forge server init` and `forge keys sync` write, hooks in every repo, pinned1275-review revisions, extracted sites, release downloads, a private repo's in1276-the private root, extracted into `.forge/private/store` again, with the1277-mtimes they had, so the mirror uploads none1278-again) and runs `forge1279-build`. identity.db is the backup's copy or litestream's replica, whichever1272+`/srv/git`. It restores the files and rebuilds what a backup leaves out:1273+what `forge server init` and `forge keys sync` write, hooks in every repo,1274+pinned review revisions, extracted sites and release downloads (a private1275+repo's in the private root, extracted into `.forge/private/store` again),1276+with the mtimes they had, so the mirror uploads none again. Then it runs1277+`forge build`. identity.db is the backup's copy or litestream's replica, whichever12801278 changed last (see [above](#identitydb-backed-up-as-it-changes)). It refuses12811279 a forge that already has repos. The host's own `.forge/config` wins over the backup's,12821280 since it is written for the domain that host serves, and users from both are
@@ -1305,7 +1303,7 @@ booted on the new box, run `docker exec forge forge-admin restore latest`, then
13051303 13061304 **A plain VPS**: provision it as above (that installs restic and rclone too),13071305 write the `FORGE_BACKUP_*` lines into `/srv/git/.forge/backup.env` (quote1308-values with spaces or `$` in them), restore as git, and point DNS at it:1306+values with spaces or `$` in them), restore as git and point DNS at it:13091307 13101308 ```sh13111309 ssh root@HOST 'install -o git -g git -m 600 /dev/stdin /srv/git/.forge/backup.env' <backup.env
@@ -1330,7 +1328,7 @@ git config --global forge.host git@src.rest # for forge import
13301328 Day to day:13311329 13321330 ```sh1333-git switch -c dami/topic && git commit ...1331+git switch -c cat/topic && git commit ...13341332 forge attest --stack # CI for every commit on top of main13351333 forge push -t "Title" # branch + notes; becomes a review13361334 forge land # CI and approval checks, then fast-forward main
@@ -1377,21 +1375,21 @@ from `.forge/events.jsonl`, whose push lines carry the default branch's new
13771375 tip as `head`. A push whose head is a review's tip is that review landing,13781376 and shows once, as landed. Every build redoes the home page, and open13791377 copies of it swap in the new one when the build is done, so the list is1380-as fresh as the last push; its times stay relative in the browser. The1378+as fresh as the last push. Its times stay relative in the browser. The13811379 events log is cut and left out of backups, so after a restore the list has13821380 only landed reviews until the next pushes. **topics**: the shelves (below)13831381 across every public repo; one owner's links to that shelf on their page1384-(`/dami/#games`, which opens on its tab), and one that several owners1382+(`/cat/#games`, which opens on its tab), and one that several owners13851383 share to `/topics/#games`, every public repo on its shelf, each naming its13861384 owner. Private repos, and owners with only private ones, are never on13871385 any of it.13881386 13891387 Each owner's page, and `/topics/`, show repos as rows on shelves by category,13901388 newest first. A repo's category is one line in the bare repo's `category`1391-file, next to `description` (`printf 'games\n' > /srv/git/dami/greet.git/category`).1389+file, next to `description` (`printf 'games\n' > /srv/git/cat/greet.git/category`).13921390 A repo without one goes on the shelf for what Jev says it is (a web app, a13931391 library), and failing that on "other". Links above the shelves show one shelf1394-alone, only repos with a site, or sort by name.1392+alone, only repos with a site or sort by name.13951393 13961394 A review page reads top to bottom: the stack, the layer's commits, then each13971395 commit's diff. While you read the diffs, a bar sticks to the top with the
@@ -1413,7 +1411,7 @@ this.
14131411 "What changed since rN" compares a revision with the one before. If both were14141412 pushed on the same base, it is the diff between the two tips. If the branch14151413 was rebased, that diff would include everything that landed meanwhile, so1416-each commit is paired with its earlier version instead and labelled1414+each commit is paired with its earlier version instead and labeled14171415 unchanged, rewritten, added or dropped.14181416 14191417 Review lists nest stacks, each layer under the one below it, and a revised
@@ -1466,13 +1464,14 @@ pagination:
14661464 ```14671465 14681466 `site/assets/style.css` holds what every page needs: the design tokens1469-(every colour, size, weight and font), layout, lists, code views. The rules1467+(every color, size, weight and font), layout, lists, code views. The rules14701468 for the look, and for changing it, are in [DESIGN.md](DESIGN.md); the1471-controls are macros in `site/_includes/ui.njk`, and `/design/` shows both. CSS for one feature sits next to its markup in a `{% css %}`1472-block, in the template, include or macro that renders it (the discussion,1473-the search overlay, the shortcuts dialog, the review bar, review lists, the1474-wiki), and 11ty's bundle plugin gives each page one file of the blocks it1475-rendered, `/assets/<hash>.css`, shared by pages of a kind. Import1469+controls are macros in `site/_includes/ui.njk`, and `/design/` shows both.1470+CSS for one feature sits next to its markup in a `{% css %}` block, in the1471+template, include or macro that renders it (the discussion, the search1472+overlay, the shortcuts dialog, the review bar, review lists, the wiki).1473+From those blocks, 11ty's bundle plugin gives each page one file of the1474+blocks it rendered, `/assets/<hash>.css`, shared by pages of a kind. Import14761475 `macros.njk` with context, or blocks in macros don't reach the bundle.14771476 14781477 Assets ship under content-hashed names (`/assets/style.<hash>.css`,
M guide/guide.css +74 -50
@@ -18,14 +18,15 @@
1818 @font-face { font-family: "Noto Serif"; src: url(fonts/noto-serif.woff2) format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }1919 @font-face { font-family: "Noto Serif"; src: url(fonts/noto-serif-italic.woff2) format("woff2"); font-weight: 400 700; font-style: italic; font-display: swap; }2020 :root {21- /* colour: the page, surfaces raised over it, ink in three strengths,22- rules, and the accent */21+ /* colour: the page, panels raised over it with their tinted head strip,22+ ink in three strengths, rules, and the accent; the forge's own values */2323 --bg: #e9e9e9;2424 --raised: #ffffff;25- --hover: #f4f4f4;25+ --tint: #f6f6f6;26+ --hover: #f2f3f5;2627 --fg: #1c2127;2728 --muted: #5b6169;28- --faint: #7c828a;29+ --faint: #6f757d;2930 --border: #d2d4d7;3031 --border-strong: #a9aeb4;3132 --link: #3148f6;
@@ -55,10 +56,10 @@
5556 --text-code: 12.5px;5657 --text-sm: 13.5px;5758 --text-md: 15px;58- --text-prose: 17px;59+ --text-prose: 16px;5960 --text-lg: 18px;6061 --text-xl: 22px;61- --text-2xl: 30px;62+ --text-2xl: 28px;6263 --text-3xl: clamp(2.4rem, 9vw, 4rem);6364 /* mono inline in serif text runs large; this brings it level */6465 --text-inline-mono: 0.88em;
@@ -88,11 +89,12 @@
8889 :root {8990 --bg: #08090b;9091 --raised: #111418;91- --hover: #15181d;92+ --tint: #15191e;93+ --hover: #191d23;9294 --fg: #e2e4e7;9395 --muted: #8f959d;94- --faint: #6b7178;95- --border: #1f2329;96+ --faint: #7d838b;97+ --border: #252a31;9698 --border-strong: #3a4048;9799 --link: #8e9bff;98100 --deep: #b4bcff;
@@ -223,38 +225,39 @@ b, strong { font-weight: var(--fw-strong); }
223225 .commit h2 { font-size: var(--text-2xl); }224226 }225227 228+/* panels, as the forge's (DESIGN.md): a raised surface on a hairline, an229+ optional head strip on the tint naming what it holds, rows between230+ hairlines with none under the last */231+.panel { margin: var(--space-5) 0 var(--space-6); min-width: 0; background: var(--raised); border: var(--hairline) solid var(--border); }232+.panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); margin: 0; padding: var(--space-2) var(--space-3); background: var(--tint); border-bottom: var(--hairline) solid var(--border); font: var(--fw-regular) var(--text-sm)/1.5 var(--sans); color: var(--muted); }233+.panel-head b { font-weight: var(--fw-medium); color: var(--fg); }234+.panel-head .end { margin-left: auto; }235+.panel-head .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }236+.panel > .table { margin: 0; border: 0; }237+.panel th:first-child, .panel td:first-child { padding-left: var(--space-3); }238+.panel th:last-child, .panel td:last-child { padding-right: var(--space-3); }239+/* a command or two between paragraphs */240+.commit > .code { margin: var(--space-4) 0 var(--space-5); }241+226242 /* figures: a raised plate on a hairline, a caption under it */227243 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; }228247 .plate { border: var(--hairline) solid var(--border); background: var(--raised); padding: var(--space-4); overflow: hidden; }229248 figcaption { font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-2); }230249 figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: var(--space-2); }231250 @media (min-width: 720px) { .plate { padding: 22px var(--space-6); } }232251 233-/* fig: three parents merged into forge */234-.merge .parents { list-style: none; margin: 0; padding: 0 0 0 26px; display: grid; gap: 10px; position: relative; }235-.merge .parents::before { content: ""; position: absolute; left: 6px; top: 18px; bottom: -26px; border-left: 2px solid var(--accent); }236-.merge .parents li { position: relative; background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 10px var(--space-3); }237-.merge .parents li::before { content: ""; position: absolute; left: -21px; top: 16px; width: 20px; border-top: 2px solid var(--accent); }238-.merge .parents b { display: block; font-family: var(--sans); font-size: var(--text-md); font-weight: var(--fw-strong); }239-.merge .parents span { display: block; font-size: var(--text-md); color: var(--muted); line-height: 1.5; margin-top: var(--space-half); }240-.merge .child { position: relative; margin: 14px 0 0; display: flex; align-items: center; gap: 10px; font-family: var(--sans); font-size: var(--text-lg); font-weight: var(--fw-strong); }241-.merge .child .mark { width: 22px; height: 22px; background: var(--raised); position: relative; }242-.merge .child small { font-size: var(--text-sm); font-weight: var(--fw-regular); color: var(--muted); }243-@media (min-width: 720px) {244- .merge .parents { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; padding: 0 0 28px; }245- .merge .parents::before { left: calc((100% - 28px) / 6); right: calc((100% - 28px) / 6); top: auto; bottom: 0; border-left: 0; border-top: 2px solid var(--accent); }246- .merge .parents li::before { left: 50%; top: 100%; width: 0; height: 29px; border-top: 0; border-left: 2px solid var(--accent); }247- .merge .child { justify-content: center; margin-top: 0; padding-top: 18px; }248- .merge .child::before { content: ""; position: absolute; left: 50%; top: 0; height: 18px; border-left: 2px solid var(--accent); }249-}250-251252 /* fig: the push, as a trace down one line */252253 .flow { list-style: none; margin: 0; padding: 0; position: relative; --x: 6px; }253254 .flow::before { content: ""; position: absolute; left: var(--x); top: 8px; bottom: 14px; width: 2px; background: var(--border); }254255 .flow > li { position: relative; padding: 0 0 var(--space-4) 26px; }255-.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); }256-.flow .where { display: block; font-family: var(--mono); font-size: var(--text-code); font-weight: var(--fw-strong); color: var(--fg); }257-.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); }258261 .flow .src { font-family: var(--mono); font-size: var(--text-xs); color: var(--muted); margin-top: var(--space-1); }259262 .flow ul { margin: 6px 0 0; padding: 0; list-style: none; font-size: var(--text-md); }260263 .flow ul li { position: relative; padding-left: var(--space-4); margin: 3px 0; }
@@ -265,29 +268,39 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
265268 @media (min-width: 720px) {266269 .flow { --x: 148px; }267270 .flow > li { display: grid; grid-template-columns: 128px 1fr; gap: 0 40px; padding-left: 0; }268- .flow .where { text-align: right; padding-top: 3px; }271+ .flow .where { text-align: right; }269272 .flow .returns { grid-template-columns: 1fr; padding-left: 168px; }270273 }271274 272-/* fig: a stack of reviews, as a graph */273-.graph { list-style: none; margin: 0; padding: 0; font-size: var(--text-md); }274-.graph ol { list-style: none; margin: 0; padding: 0; }275-.graph .layer { position: relative; display: grid; gap: var(--space-1) 18px; padding: 6px 0; }276-.graph .layer::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: var(--accent); }277-.graph > .layer:first-child::before { top: 1.3em; }278-.graph .base::before { background: var(--faint); bottom: auto; height: 1.3em; }279-.graph .c { position: relative; padding: 5px 0 5px 28px; display: flex; flex-wrap: wrap; gap: var(--space-half) var(--space-2); align-items: baseline; }280-.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); }281289 .graph .base .c::after { border-color: var(--faint); }282-.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; }283293 /* a branch name on a commit: a square label on a hairline */284294 .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; }285295 .pill.main { border-color: var(--border-strong); color: var(--muted); }286-.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; }287-.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); }288300 @media (min-width: 720px) {289- .graph .layer { grid-template-columns: minmax(0, 1fr) 210px; align-items: center; }290- .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; }291304 }292305 293306 /* code-ish figures: range-diff, statements */
@@ -302,8 +315,8 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
302315 303316 .annotated { display: grid; gap: var(--space-4); }304317 .annotated ol { margin: 0; padding: 0; list-style: none; counter-reset: n; font-size: var(--text-md); }305-.annotated ol li { counter-increment: n; position: relative; padding-left: 30px; margin: 0 0 var(--space-2); }306-.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; }307320 .annotated figcaption { grid-column: 1 / -1; margin: 0; }308321 @media (min-width: 900px) { .annotated { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: start; } }309322
@@ -323,8 +336,9 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
323336 .chain { list-style: none; margin: 0; padding: 0; }324337 .chain li { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-half) var(--space-3); align-items: baseline; }325338 .chain .k { font-family: var(--sans); font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); }326-.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; }327-.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); }328342 .chain .hop { min-height: 34px; }329343 .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); }330344 .chain .hop span::before { content: ""; position: absolute; left: 11px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }
@@ -350,6 +364,8 @@ td.y::before { content: "✓ "; font-weight: var(--fw-strong); color: var(--pass
350364 td.n::before { content: "✗ "; font-weight: var(--fw-strong); color: var(--fail); }351365 td.n { color: var(--muted); }352366 td code, th code { font-size: var(--text-code); white-space: normal; }367+/* code that never breaks at its hyphens, only where it has <wbr> */368+table.whole code { white-space: nowrap; }353369 @media (max-width: 719px) { th, td { padding: var(--space-2) var(--space-2); } }354370 /* tables marked .rows restack on phones: the first cell on its own line,355371 then the rest, labelled from data-l when there is more than one */
@@ -362,6 +378,9 @@ td code, th code { font-size: var(--text-code); white-space: normal; }
362378 table.rows td:not([data-l]):not(:first-child) { grid-column: 1 / -1; padding-top: 3px; }363379 table.rows td[data-l] { position: relative; padding-top: var(--space-6); }364380 table.rows td[data-l]::after { content: attr(data-l); position: absolute; left: 0; top: 6px; font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); }381+ .panel table.rows tr { padding: 0 var(--space-3); }382+ table.rows.three tr { grid-template-columns: 1fr 1fr 1fr; }383+ .panel table.rows :is(th, td) { padding-left: 0; padding-right: 0; }365384 }366385 367386 /* fig: one key, two generated files */
@@ -390,13 +409,18 @@ td code, th code { font-size: var(--text-code); white-space: normal; }
390409 .hops .wire::before { content: ""; position: absolute; left: 18px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }391410 .hops .wire.tunnel::before { border-left: 6px double var(--accent); left: 16px; }392411 393-/* cheat sheet: groups under a strong rule, entries between hairlines */412+/* cheat sheet: groups under a strong rule, entries between hairlines; in413+ panels, each group's name is its head strip */394414 .cheat { display: grid; gap: var(--space-6) var(--space-8); }395415 @media (min-width: 900px) { .cheat { grid-template-columns: 1fr 1fr; } }396416 .cheat section { border-top: var(--hairline) solid var(--border-strong); }397417 .cheat h3 { margin: 0; padding: var(--space-2) 0 var(--space-1); font-size: var(--text-sm); font-weight: var(--fw-strong); color: var(--muted); }398418 .cheat dl { margin: 0; }399419 .cheat dl > div { padding: var(--space-2) 0; border-bottom: var(--hairline) solid var(--border); }420+.cheat.panels { gap: var(--space-4); align-items: start; }421+.cheat section.panel { margin: 0; border-top: var(--hairline) solid var(--border); }422+.cheat h3.panel-head { padding: var(--space-2) var(--space-3); font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--fg); }423+.cheat .panel dl > div { padding: var(--space-2) var(--space-3); }400424 .cheat dl > div:last-child { border-bottom: 0; }401425 .cheat dt { font-family: var(--mono); font-size: var(--text-code); line-height: 1.5; overflow-wrap: anywhere; }402426 .cheat dt::before { content: "$ "; color: var(--link); }
M guide/index.html +199 -194
@@ -3,8 +3,8 @@
33 <head>44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width, initial-scale=1">6-<title>forge: a git forge made of plain git</title>7-<meta name="description" content="How forge works: plain git over ssh, static pages built after each push, reviews as stacked branches, CI signed on your own machine, and sites that ship with repos.">6+<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 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">
@@ -13,7 +13,7 @@
1313 <script src="guide.js" defer></script>1414 </head>1515 <body>16-<a class="skip" href="#main">Skip to the guide</a>16+<a class="skip" href="#main">skip to the guide</a>1717 <header class="bar">1818 <div class="wrap">1919 <a class="brand" href="./"><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 <span>guide</span></a>
@@ -26,30 +26,29 @@
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">Plain git on a server, and web pages written after every push. Reviews are branches, CI runs on <em>your</em> machine and is signed with your SSH key, and any repo can ship a static site.</p>30-<p class="cta"><a class="btn go" href="#push">follow a push</a><a class="btn" href="#cli">cheat sheet</a><a class="btn" href="internals.html">internals</a></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>30+<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>32-<figure class="term" aria-label="A terminal session: attest, push, deploy a preview">33-<div class="chrome" aria-hidden="true">~/greet (dami/flags)</div>34-<pre><span class="line" style="--i:0"><span class="p">$ </span>forge attest</span><span class="line o" style="--i:1">80b9dda running .forge/ci: add --shout</span><span class="line o" style="--i:2"> | ok greet --shout</span><span class="line" style="--i:3">80b9dda <span class="ok">pass</span> in 2s, signed</span><span class="line" style="--i:4"><span class="p">$ </span>forge push -t "Flags"</span><span class="line o" style="--i:5">pushed dami/flags</span><span class="line" style="--i:6"><span class="p">$ </span>forge site deploy docs</span><span class="line o" style="--i:7">site docs: sh docs/build.sh</span><span class="line r" style="--i:8">remote: site docs/dami~flags: <span class="u">https://greet-docs--dami-flags.pages.example.com/</span></span><span class="line o" style="--i:9">site docs: deployed 942ff39 -&gt; https://greet-docs--dami-flags.pages.example.com/</span></pre>32+<figure class="term" aria-label="A terminal session: attest, push for review, deploy a preview">33+<div class="chrome" aria-hidden="true">~/greet (cat/flags)</div>34+<pre><span class="line" style="--i:0"><span class="p">$ </span>forge attest --stack</span><span class="line o" style="--i:1">afd124b running .forge/ci: parse --name</span><span class="line" style="--i:2">afd124b <span class="ok">pass</span> in 1s, signed</span><span class="line o" style="--i:3">80b9dda running .forge/ci: add --shout</span><span class="line" style="--i:4">80b9dda <span class="ok">pass</span> in 2s, signed</span><span class="line" style="--i:5"><span class="p">$ </span>forge push -t "Flags"</span><span class="line r" style="--i:6">remote: review #2: <span class="u">https://src.rest/cat/greet/reviews/2/</span></span><span class="line" style="--i:7"><span class="p">$ </span>forge site deploy docs</span><span class="line o" style="--i:8">site docs: deployed 942ff39 -&gt; <span class="u">https://greet-docs--cat-flags.pages.src.rest/</span></span></pre>3535 </figure>3636 </div>3737 </section>3838 3939 <div class="wrap page">40-<nav class="toc" aria-label="On this page">40+<nav class="toc" aria-label="on this page">4141 <div>4242 <h2>on this page</h2>4343 <ol>44-<li><a href="#what"><span>01</span>what and why</a></li>45-<li><a href="#push"><span>02</span>a push</a></li>44+<li><a href="#what"><span>01</span>what forge is</a></li>45+<li><a href="#browse"><span>02</span>browse</a></li>4646 <li><a href="#reviews"><span>03</span>reviews</a></li>4747 <li><a href="#ci"><span>04</span>ci</a></li>48-<li><a href="#sites"><span>05</span>sites</a></li>49-<li><a href="#identity"><span>06</span>identity</a></li>50-<li><a href="#hosting"><span>07</span>hosting</a></li>51-<li><a href="#cli"><span>08</span>cheat sheet</a></li>52-<li><a href="#not-yet"><span>09</span>not yet</a></li>48+<li><a href="#sites"><span>05</span>sites and releases</a></li>49+<li><a href="#people"><span>06</span>people</a></li>50+<li><a href="#cli"><span>07</span>day to day</a></li>51+<li><a href="#not-yet"><span>08</span>not yet</a></li>5352 </ol>5453 </div>5554 </nav>
@@ -57,61 +56,83 @@
5756 <main id="main" class="log">5857 5958 <section class="commit" id="what" aria-labelledby="what-h">60-<header><p class="ref"><b>01</b> what and why</p><h2 id="what-h"><a href="#what">A forge that is mostly files</a></h2></header>59+<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>6160 <div class="prose">62-<p>A forge is usually an application with a database. This one is the setup from the Git book's <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Setting-Up-the-Server">Git on the Server</a> chapter: a <code>git</code> user, SSH keys and bare repositories, plus two hooks. After each push an <a href="https://www.11ty.dev/">11ty</a> build writes the repositories out as static HTML, the way <a href="https://git.suckless.org/">stagit</a> does. Reading a page is reading a file.</p>63-<p>What stagit doesn't have is borrowed from <a href="https://args.io">args.io</a>: every branch is browsable, reviews are stacked diffs, each push of a review is a revision, CI runs on your own machine, and repos can ship sites.</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>63+</div>64+<div class="panel">65+<div class="panel-head"><b>what you do, and what you get</b></div>66+<div class="table">67+<table class="rows">68+<thead><tr><th scope="col">you</th><th scope="col">with</th><th scope="col">the forge shows</th></tr></thead>69+<tbody>70+<tr><th scope="row">push code</th><td data-l="with"><code>git push</code></td><td data-l="the forge shows">a page for every branch, directory, file and commit</td></tr>71+<tr><th scope="row">push a branch</th><td data-l="with"><code>forge push</code></td><td data-l="the forge shows">a review, with a page for each revision</td></tr>72+<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>73+<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>74+<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">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>76+</tbody>77+</table>6478 </div>65-<figure class="plate merge">66-<ol class="parents">67-<li><b>Git on the Server</b><span>One <code>git</code> account, SSH keys and bare repos. Identity is a file of keys, each forced through one command.</span></li>68-<li><b>stagit</b><span>Log, files, blobs and refs as plain pages, written when something is pushed instead of computed per request.</span></li>69-<li><b>args.io</b><span>Reviews as stacked branches with revisions, CI attested on your machine, a site per repo.</span></li>70-</ol>71-<p class="child"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2.4" 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 <small>a merge of three ideas</small></p>72-<figcaption><b>fig. 1</b>where it comes from</figcaption>73-</figure>74-<div class="prose">75-<p>There is no database and nothing to log in to. Everything that changes the forge is a <code>git push</code>, an ssh command, or an admin command on the server, usually by way of <code>forge</code>, one POSIX shell script. Pages also have keyboard navigation, clean URLs such as <code>/dami/greet/tree/main/greet.sh/</code>, an Atom feed per repo, a wiki from <code>wiki/</code>, syntax highlighting done at build time, and a <kbd>/</kbd> search over commits, reviews, wiki pages and paths. <kbd>?</kbd> lists the shortcuts.</p>7679 </div>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>7781 </section>7882 79-<section class="commit" id="push" aria-labelledby="push-h">80-<header><p class="ref"><b>02</b> a push</p><h2 id="push-h"><a href="#push">What happens when you push</a></h2></header>81-<p class="prose">A push is the only way anything changes. Here is one, followed through the server.</p>82-<figure class="plate">83-<ol class="flow">84-<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>85-<li><span class="where">sshd</span><div><p>Your key matches a line in <code>authorized_keys</code> that forces <code>forge shell &lt;name&gt;</code>. It records who you are and hands the command to <code>git-shell</code>, which runs git and a few commands: <code>new</code>, <code>ls</code>, <code>whoami</code>, <code>signers</code>, <code>site-url</code>, <code>help</code>.</p><p class="src">bin/forge: cmd_shell</p></div></li>86-<li><span class="where">pre-receive</span><div><p>Decides who may push which refs, refuses to delete <code>main</code> or force-push it (unless the repo allows that), and, when the repo asks for it, refuses a new <code>main</code> without a verified passing CI attestation.</p><p class="src">bin/forge: hook_pre_receive</p></div></li>87-<li><span class="where">git</span><div><p>The refs move.</p></div></li>88-<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>89-<li class="returns"><span>your git push returns here; the rest runs in the background</span></li>90-<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.</p><p class="src">bin/forge: cmd_build, eleventy.config.ts, lib/forge.ts</p></div></li>91-<li class="bg"><span class="where">your browser</span><div><p>Open pages hold a server-sent events stream from <code>/events/&lt;owner&gt;/&lt;repo&gt;/</code>. The push shows up as <q>dami pushed dami/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>92-</ol>93-<figcaption><b>fig. 2</b>one push, from your terminal to every open page</figcaption>94-</figure>95-<p class="prose">Building after the push rather than on each request means pages cost nothing to serve, can be cached anywhere, and keep working if the two small Node servers stop. Those only add what files can't: the live updates, and sites on their own hostnames.</p>83+<section class="commit" id="browse" aria-labelledby="browse-h">84+<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>85+<div class="prose">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>87+</div>88+<div class="panel">89+<div class="panel-head"><b>a repo's pages</b><span class="end"><span class="n">https://src.rest/cat/greet/...</span></span></div>90+<div class="table">91+<table class="rows">92+<thead><tr><th scope="col">page</th><th scope="col">what is on it</th></tr></thead>93+<tbody>94+<tr><td><code>/cat/greet/</code></td><td>open reviews, recent commits, the README</td></tr>95+<tr><td><code>tree/&lt;branch&gt;/</code>, <code>blob/&lt;branch&gt;/&lt;path&gt;/</code></td><td>files and directories on any branch</td></tr>96+<tr><td><code>log/&lt;branch&gt;/</code>, <code>commit/&lt;sha&gt;/</code></td><td>history, each commit with its diff and CI result</td></tr>97+<tr><td><code>reviews/</code>, <code>reviews/&lt;id&gt;/</code></td><td>reviews, as stacks, and each review's diffs and discussion</td></tr>98+<tr><td><code>wiki/</code>, <code>sites/</code>, <code>releases/</code></td><td>the wiki from <code>wiki/</code>, deployed sites and previews, downloads</td></tr>99+<tr><td><code>insights/</code></td><td>pushes, reviews and CI results over time, charted</td></tr>100+<tr><td><code>atom.xml</code></td><td>a feed of the newest commits on <code>main</code></td></tr>101+</tbody>102+</table>103+</div>104+</div>105+<div class="prose">106+<ul>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>108+<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> 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>112+</ul>113+</div>96114 </section>97115 98116 <section class="commit" id="reviews" aria-labelledby="reviews-h">99-<header><p class="ref"><b>03</b> reviews</p><h2 id="reviews-h"><a href="#reviews">Reviews are branches</a></h2></header>100-<p class="prose">Push any branch other than <code>main</code> and it is a review. There is nothing to open: <code>post-receive</code> appends the push to <code>&lt;repo&gt;.git/forge/reviews/&lt;id&gt;</code> and pins the tip under <code>refs/forge/keep/</code>, so every revision stays reachable after a force push. The pages work out everything else from the refs.</p>117+<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">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>119+<pre class="code"><span class="dim">$</span> git switch -c cat/flags120+<span class="dim">$</span> forge push -t "parse --name" -d "Flags, parsed by hand"121+<span class="dim">remote: review #2: https://src.rest/cat/greet/reviews/2/</span></pre>101122 <figure class="plate">102123 <ol class="graph" aria-label="A stack: two reviews, each two commits, on top of main">103-<li class="layer"><ol><li class="c"><code>6771779</code> README: document flags <span class="pill">dami/help</span></li><li class="c"><code>dff8bca</code> add --help</li></ol><p class="tag"><b>#3 Usage and --help</b>layer 2, targets dami/flags</p></li>104-<li class="layer"><ol><li class="c"><code>80b9dda</code> add --shout <span class="pill">dami/flags</span></li><li class="c"><code>afd124b</code> parse --name</li></ol><p class="tag"><b>#2 parse --name</b>layer 1, targets main</p></li>105-<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>106127 </ol>107-<figcaption><b>fig. 3</b>a stack from the demo: dami/help was branched from dami/flags</figcaption>128+<figcaption><b>fig. 1</b>a stack: cat/help was branched from cat/flags</figcaption>108129 </figure>109130 <div class="prose">110131 <ul>111-<li><strong>Stack.</strong> A review's parent is the nearest other review whose tip lies inside its branch, so a branch built on another review's branch is detected as the next layer.</li>112-<li><strong>Layer.</strong> The commits between the parent (or <code>main</code>) and the tip. A review page is its commits' diffs in order, plus a combined diff.</li>113-<li><strong>Status.</strong> Merged once the tip is in <code>main</code>, closed when the branch is deleted, draft for <code>wip/...</code> branches or a push with <code>--draft</code>.</li>114-<li><strong>Revisions.</strong> Each push is one. Rewrite a branch and the review shows <code>git range-diff</code> between the two pushes.</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>134+<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>115136 </ul>116137 </div>117138 <figure>
@@ -126,17 +147,27 @@
126147 <span class="chg">4: ef1d881 ! 4: 6771779 README: document flags</span>127148 <span class="del"> -+ --shout uppercase the greeting</span>128149 <span class="add"> ++ --shout UPPERCASE the greeting</span></pre>129-<figcaption><b>fig. 4</b>review #3, r1 against r2, shortened: <code>=</code> unchanged, <code>!</code> changed</figcaption>150+<figcaption><b>fig. 2</b>what changed between two revisions, shortened: <code>=</code> unchanged, <code>!</code> changed</figcaption>130151 </figure>131152 <div class="prose">132-<p>Push options carry the rest: <code>forge push -t "Title"</code> with <code>--draft</code> or <code>--target &lt;branch&gt;</code>, and <code>forge push --stack</code> pushes every layer, lowest first. <code>git rebase -i --update-refs</code> keeps the layers in sync while you rewrite them. Landing is a push to <code>main</code>, which must fast-forward: <code>git push origin dami/topic:main</code>.</p>133-<p>Reviews also take comments and approvals (a repo can require one before <code>main</code> moves, with <code>forge.requireapproval</code>), a description from <code>forge push -d</code>, and the command line has <code>forge reviews</code>, <code>forge checkout &lt;id&gt;</code> and <code>forge land</code>, which the forge can also do by itself, from a phone or an agent, with <code>ssh git@host land</code>. On the page, files collapse, can be marked viewed, and the stack is one click away.</p>153+<h3>Comments and approvals</h3>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>134155 </div>156+<pre class="code"><span class="dim">$</span> forge comment 2 greet.sh:12 -m "Quote the name?"157+<span class="dim">$</span> forge approve 2</pre>158+<div class="prose">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>160+<h3>Landing</h3>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>162+</div>163+<pre class="code"><span class="dim">$</span> forge land 2164+<span class="dim">$</span> ssh git@src.rest land cat/greet 2</pre>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>135166 </section>136167 137168 <section class="commit" id="ci" aria-labelledby="ci-h">138-<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>139-<p class="prose"><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), and writes a statement. It signs that with <code>ssh-keygen -Y sign -n forge-ci</code> and the SSH key you push with, and appends it to <code>refs/notes/ci</code>.</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>140171 <figure class="plate annotated">141172 <pre class="code"><span class="key">forge-attestation 1</span>142173 type ci
@@ -147,194 +178,168 @@ script .forge/ci 99a0d5ea2eee...<span class="n">2</span>
147178 exit 0148179 started 2026-10-18T02:00:14Z149180 duration 0s150-runner cat@early-yak-00<span class="n">4</span>181+runner cat@laptop<span class="n">4</span>151182 system Darwin 25.5.0 arm64152183 env host153184 log sha256:6cc79019... 4 lines154-<span class="dim">|ok greet --name ana</span>185+<span class="dim">|ok greet --name fox</span>155186 <span class="dim">|ok greet --shout</span>156187 <span class="dim">-----BEGIN SSH SIGNATURE-----</span><span class="n">5</span>157188 <span class="dim">U1NIU0lHAAAAAQAAADMAAAALc3No...</span></pre>158189 <ol>159-<li><strong>The tree, not the commit.</strong> A reworded or reordered commit with the same files keeps its result, and <code>forge attest</code> skips trees that already passed unless you pass <code>--force</code>.</li>160-<li><strong>The script</strong>, by blob id, so a different <code>.forge/ci</code> is a different claim.</li>161-<li><strong>The result</strong> and exit code. A failure is signed and pushed too.</li>162-<li><strong>The runner</strong> as the signer describes it: user, host, system, and with Nix the <code>flake.lock</code> it ran under.</li>190+<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>, by hash: change <code>.forge/ci</code> and it is a different claim.</li>192+<li><strong>The result</strong> and exit code.</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>163194 <li><strong>The signature</strong> over everything above. Log lines start with <code>|</code>, so output can't fake a field.</li>164195 </ol>165-<figcaption><b>fig. 5</b>an attestation from the demo, shortened</figcaption>196+<figcaption><b>fig. 3</b>a signed CI result, shortened</figcaption>166197 </figure>198+<pre class="code"><span class="dim">$</span> forge attest --stack <span class="dim"># every commit on top of main</span>199+<span class="dim">$</span> forge verify <span class="dim"># each commit's results, and who signed them</span></pre>167200 <div class="verdict">168-<div class="yes"><h3>What a verified pass proves</h3><ul><li>Someone holding a key in the forge's keys file claims that this exact tree 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: the signer is looked up in <code>allowed_signers</code>.</li></ul></div>169-<div class="no"><h3>What it doesn't</h3><ul><li>That the key holder is honest. A dishonest one can sign a pass for tests they never ran.</li><li>That the machine was clean or the environment reproducible, unless Nix pinned it.</li><li>Anything about the commits nobody attested.</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>170203 </div>171204 <div class="prose">172-<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) so it is bound to one device; use Nix so the environment is pinned and recorded; and have someone else run <code>forge attest</code> too. Attestations from several signers stack up on the same tree.</p>173-<p>To require a verified pass before <code>main</code> moves, set <code>git config forge.requireci tip</code> in the bare repo, or <code>all</code> to check every new commit. <code>pre-receive</code> verifies against the server's own <code>allowed_signers</code>; anyone may add notes, as long as <code>refs/notes/ci</code> only fast-forwards.</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>174207 </div>175208 </section>176209 177210 <section class="commit" id="sites" aria-labelledby="sites-h">178-<header><p class="ref"><b>05</b> sites</p><h2 id="sites-h"><a href="#sites">Sites ship with the repo</a></h2></header>179-<p class="prose">A deploy is a commit on <code>refs/sites/&lt;name&gt;</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.</p>180-<div class="split">211+<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>181212 <div class="prose">182-<p>Declare sites in <code>.forge/sites</code>, one per line: a name, the output directory, and a build command, or <code>-</code> for none. This guide is <code>guide guide</code>, with a build command that copies the forge's fonts in.</p>183-<p><code>forge site deploy &lt;name&gt;</code> builds from a clean worktree of <code>HEAD</code>, with <code>FORGE_SITE_BASE</code> set to the path it will be served from (<code>/</code> on its own hostname). From <code>main</code> it publishes the site. From any other branch it publishes that review's preview, <code>refs/site-previews/&lt;name&gt;/&lt;branch&gt;</code>, which the review page links and marks stale once the branch moves on. <code>forge site push &lt;dir&gt; &lt;name&gt;</code> publishes a directory you already built.</p>184-<p>Only the owner and collaborators deploy sites. Anyone can deploy previews of their own branches.</p>185-</div>186-<figure class="plate">187-<ol class="chain">188-<li><span class="k">ref</span><span class="v">refs/sites/docs<small>a deploy commit, signed</small></span></li>189-<li class="hop"><span>post-receive extracts its tree, once per tree</span></li>190-<li><span class="k">store</span><span class="v">.forge/store/339f775e.../</span></li>191-<li class="hop up"><span>symlink</span></li>192-<li><span class="k">served</span><span class="v">www/sites/dami/greet/docs</span></li>193-<li class="hop up"><span>symlink, with a pages domain</span></li>194-<li><span class="k">hostname</span><span class="v">www/hosts/greet-docs</span></li>195-<li class="hop up"><span>the pages server looks up the Host header</span></li>196-<li><span class="k">browser</span><span class="v">https://greet-docs.pages.example.com/</span></li>197-</ol>198-<figcaption><b>fig. 6</b>a deploy, from ref to hostname</figcaption>199-</figure>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>200214 </div>215+<pre class="code"><span class="dim"># .forge/sites: name, output, build command (- for none)</span>216+docs build npm ci &amp;&amp; npm run build217+218+<span class="dim">$</span> forge site deploy docs</pre>201219 <div class="prose">202-<h3>A hostname per site</h3>203-<p>With a pages domain, every deploy gets its own hostname, so sites can't read each other's cookies, storage or service workers, and each one builds for <code>/</code>. The <code>www/hosts/</code> directory of symlinks is the whole registry.</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>204222 </div>223+<div class="panel">224+<div class="panel-head"><b>where a site is served</b><span class="end"><span class="n">*.pages.src.rest</span></span></div>205225 <div class="table">206-<table>207-<thead><tr><th scope="col">deploy</th><th scope="col">hostname under the pages domain</th></tr></thead>226+<table class="whole">227+<thead><tr><th scope="col">deploy</th><th scope="col">address</th></tr></thead>208228 <tbody>209-<tr><td>a site of the forge's owner: <code>dami/greet</code>, site <code>docs</code></td><td><code>greet-docs</code></td></tr>210-<tr><td>another user's site: <code>ana/dotfiles</code>, site <code>home</code></td><td><code>dotfiles-home-ana</code></td></tr>211-<tr><td>a preview: <code>docs</code> on branch <code>dami/flags</code></td><td><code>greet-docs--dami-flags</code></td></tr>229+<tr><td><code>cat/greet</code>, site <code>docs</code></td><td><code>greet-docs<wbr>.pages.src.rest</code></td></tr>230+<tr><td><code>fox/dotfiles</code>, site <code>home</code></td><td><code>dotfiles-home-fox<wbr>.pages.src.rest</code></td></tr>231+<tr><td><code>docs</code> on the branch <code>cat/flags</code></td><td><code>greet-docs--cat-flags<wbr>.pages.src.rest</code></td></tr>232+<tr><td><code>cat/forge</code>, site <code>guide</code></td><td><code>forge-guide<wbr>.pages.src.rest</code>, this page</td></tr>212233 </tbody>213234 </table>214235 </div>236+</div>215237 <div class="prose">216-<p>The pages server serves them the way Cloudflare Pages does, so a site built for there works unchanged: <code>_headers</code> and <code>_redirects</code> apply, <code>/about</code> finds <code>about.html</code> or <code>about/index.html</code>, a missing page gets the nearest <code>404.html</code> (or <code>index.html</code>, for a single-page app), and previews are sent with <code>noindex</code>. Old <code>/sites/...</code> paths redirect to the hostname. Without a pages domain, sites are plain files under <code>/sites/&lt;owner&gt;/&lt;repo&gt;/&lt;name&gt;/</code> on the forge itself.</p>217-<p>Deleting a branch deletes its previews: the ref, the served link and the hostname, so the sites and review pages stop listing them. Previews of merged branches stay until their branch is deleted. The extracted files stay in the store until <code>forge gc</code>, which removes store trees nothing links to, hostnames whose deploy is gone and leftover previews, skipping store entries changed in the last ten minutes, so it can run while people push.</p>218-<h3>Releases are the same idea</h3>219-<p><code>forge release publish v1.0 &lt;file&gt;...</code> commits a tag's files to <code>refs/releases/v1.0</code> with a <code>SHA256SUMS</code> signed by your key for that repo and tag (<code>ssh-keygen -n forge-release</code>), so it can't pass for another release. <code>pre-receive</code> checks every sum, the signature (an owner's or collaborator's key), the tag and a size cap, and with <code>forge.releaserequireci</code> a verified pass for the tag's tree. <code>post-receive</code> extracts the tree into the store and hard-links the files into <code>/dami/greet/releases/download/v1.0/</code>, plus <code>latest/</code>; hard links, so the mirror copies them. Clones and fetches never see <code>refs/releases</code>. A private repo's go to the private root next to its pages, never the mirror, and its readers download them signed in or with a token, or get them with <code>ssh git@host release get</code>.</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>239+<h3>Releases</h3>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>220241 </div>242+<pre class="code"><span class="dim">$</span> forge release publish v1.0 greet.sh greet-1.0.tar.gz243+<span class="dim">$</span> forge release verify --url https://src.rest/cat/greet/releases/download/v1.0/ greet.sh244+<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 are for its readers only.</p>221246 </section>222247 223-<section class="commit" id="identity" aria-labelledby="identity-h">224-<header><p class="ref"><b>06</b> identity</p><h2 id="identity-h"><a href="#identity">Identity is one file of keys</a></h2></header>225-<p class="prose"><code>.forge/keys</code> holds <code>name key</code> lines and is the only source of identity. <code>forge keys sync</code> (run by <code>forge user add</code> and <code>user rm</code>) writes two files from it, so the key you push with is also the key that signs your CI results and deploys.</p>226-<figure class="plate fan">227-<div class="box"><span class="file">.forge/keys</span><code>ana ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... ana@laptop</code></div>228-<div class="by">forge keys sync</div>229-<div class="outs">230-<div class="box"><span class="file">.ssh/authorized_keys</span><code>restrict,command="/opt/forge/bin/forge shell ana" ssh-ed25519 AAAAC3Nz... ana@laptop</code><p>Who is pushing: hooks see <code>FORGE_USER=ana</code>.</p></div>231-<div class="box"><span class="file">.forge/allowed_signers</span><code>ana namespaces="forge-ci,forge-release,git" ssh-ed25519 AAAAC3Nz... ana@laptop</code><p>Who signed a CI result, a deploy or a release's checksums. <code>ssh git@host signers</code> prints it for <code>forge verify</code>.</p></div>248+<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">Who can do what</a></h2></header>250+<div class="prose">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>232252 </div>233-<figcaption><b>fig. 7</b>one line in, two generated files out</figcaption>234-</figure>235-<p class="prose">Repos live at <code>/srv/git/&lt;owner&gt;/&lt;repo&gt;.git</code>, and the directory names the owner. <code>ssh git@host new &lt;repo&gt;</code> creates one under your name, and so does pushing to one that isn't there yet. The owner, and anyone listed in <code>&lt;repo&gt;.git/forge/collaborators</code>, can push anything; everyone else works under their own name:</p>253+<pre class="code"><span class="dim">$</span> ssh git@src.rest invite fox --repo cat/012 --as collaborator254+<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. Each repo's owner decides who else works on it:</p>256+<pre class="code"><span class="dim">$</span> ssh git@src.rest collaborators cat/012 add fox <span class="dim"># may push anything</span>257+<span class="dim">$</span> ssh git@src.rest readers cat/012 add fox <span class="dim"># may read it while it is private</span>258+<span class="dim">$</span> ssh git@src.rest who cat/012259+<span class="dim">owner cat260+collaborator fox</span></pre>261+<div class="panel">262+<div class="panel-head"><b>who can do what</b><span class="end">on <code>cat/012</code></span></div>236263 <div class="table">237-<table class="rows">238-<thead><tr><th scope="col">ref</th><th scope="col">owner, collaborators</th><th scope="col">anyone else, e.g. ana</th></tr></thead>264+<table class="rows three">265+<thead><tr><th scope="col"></th><th scope="col">owner, collaborators</th><th scope="col">readers, and any user of a public repo</th><th scope="col">anyone</th></tr></thead>239266 <tbody>240-<tr><th scope="row"><code>main</code> (the default branch)</th><td class="y" data-l="owner, collaborators">fast-forward only, unless <code>forge.allowforce</code>; never deleted</td><td class="n" data-l="anyone else">no</td></tr>241-<tr><th scope="row"><code>refs/heads/<wbr>ana/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes, her own prefix</td></tr>242-<tr><th scope="row">other branches</th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>243-<tr><th scope="row"><code>refs/<wbr>site-previews/<wbr>*/ana/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes</td></tr>244-<tr><th scope="row"><code>refs/<wbr>tags/*</code>, <code>refs/<wbr>sites/*</code>, <code>refs/<wbr>releases/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>245-<tr><th scope="row"><code>refs/<wbr>notes/ci</code></th><td class="y" data-l="owner, collaborators">yes, and may rewrite</td><td class="y" data-l="anyone else">add only: fast-forward, no delete</td></tr>246-<tr><th scope="row"><code>refs/<wbr>forge/*</code></th><td class="n" data-l="owner, collaborators">internal</td><td class="n" data-l="anyone else">internal</td></tr>267+<tr><th scope="row">read pages, clone</th><td class="y" data-l="owner, collab.">yes</td><td class="y" data-l="readers, users">yes</td><td class="y" data-l="anyone">a public repo</td></tr>268+<tr><th scope="row">comment, approve</th><td class="y" data-l="owner, collab.">yes</td><td class="y" data-l="readers, users">yes</td><td class="n" data-l="anyone">no</td></tr>269+<tr><th scope="row">push a review, a preview, CI results</th><td class="y" data-l="owner, collab.">yes</td><td class="y" data-l="readers, users">under their own name, <code>fox/...</code></td><td class="n" data-l="anyone">no</td></tr>270+<tr><th scope="row">move <code>main</code>, land, tag, deploy, release</th><td class="y" data-l="owner, collab.">yes</td><td class="n" data-l="readers, users">no</td><td class="n" data-l="anyone">no</td></tr>271+<tr><th scope="row">make it private or public, choose readers</th><td class="y" data-l="owner, collab.">yes</td><td class="n" data-l="readers, users">no</td><td class="n" data-l="anyone">no</td></tr>272+<tr><th scope="row">choose collaborators, remove it</th><td class="y" data-l="owner, collab.">the owner</td><td class="n" data-l="readers, users">no</td><td class="n" data-l="anyone">no</td></tr>247273 </tbody>248274 </table>249275 </div>250-<p class="prose">Removing someone's line revokes their pushes and their signatures together: their old attestations stop verifying. Administer as root on the server (<code>forge-admin ...</code> in the container), never by logging in as <code>git</code>; every key is forced through <code>forge shell</code>.</p>251-</section>252-253-<section class="commit" id="hosting" aria-labelledby="hosting-h">254-<header><p class="ref"><b>07</b> hosting</p><h2 id="hosting-h"><a href="#hosting">Where it runs</a></h2></header>255-<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 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>256-<div class="hosts">257-<figure class="plate">258-<ol class="hops">259-<li class="node"><b>internet</b><span>ssh on :22, https on :443. DNS for the forge and pages domains points at the edge, DNS-only, since ssh can't go through Cloudflare's proxy.</span></li>260-<li class="wire">raw TCP for :22, TLS ends here for :443</li>261-<li class="node"><b>Fly edge</b><span><code>deploy/edge</code>. Stateless, with a dedicated IPv4 so raw TCP on :22 works. Its key and the container's address are Fly secrets. While the home box is down, its Caddy serves the forge's pages from a copy in R2.</span></li>262-<li class="wire tunnel">tailcat: point-to-point WireGuard on Tailscale's data plane, with no tailnet, accounts or control plane</li>263-<li class="node home"><b>forge container on a home box</b><span><code>deploy/slate</code>. Publishes no ports. Its tailcat server keeps its key on the volume and accepts only the edge's client key.</span><ul><li>sshd :2222</li><li>Caddy :8080</li><li>events :8091</li><li>pages :8092</li></ul></li>264-</ol>265-<figcaption><b>fig. 8</b>how src.rest runs</figcaption>266-</figure>267-<figure class="plate">268-<ol class="hops">269-<li class="node"><b>internet</b><span>ssh on :22, https on :443</span></li>270-<li class="wire">straight in</li>271-<li class="node home"><b>a Debian or Ubuntu VPS</b><span><code>server/provision.sh</code> installs git, sshd and Caddy, which gets certificates itself, and runs the Node servers under systemd. It needs Node 22.18 or newer.</span><ul><li>sshd :22</li><li>Caddy :443</li><li>forge-events</li><li>forge-pages</li></ul></li>272-</ol>273-<figcaption><b>fig. 9</b>or a plain VPS</figcaption>274-</figure>275276 </div>276-<p class="prose">Either way, Caddy serves the pages directory as plain files and hands on only four things: clones to <code>git http-backend</code>, <code>/events/</code> to the events server, every path the pages directory has no file for to the private server and, with a pages domain, sites to the pages server. The pages server runs as the caddy user, because deploys are untrusted content. A public repo is public everywhere: pages, sites and clones. A private one (no <code>git-daemon-export-ok</code>) is read by its owner, collaborators and readers only: over ssh, on its pages, which are built into a root of their own and served by the private server once they sign in with a passkey, and over HTTPS with a token. To anyone else every page of it is the 404 of a page that isn't there. Passkeys and tokens come from ssh (<code>ssh git@host passkey</code>, <code>token</code>), and live, as hashes, in one SQLite file that litestream copies to the backup bucket as it changes.</p>277+<div class="prose">278+<h3>Private repos</h3>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>280+</div>281+<pre class="code"><span class="dim">$</span> ssh git@src.rest new --private notes282+<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>283+<div class="prose">284+<h3>Passkeys and tokens</h3>285+<p>There are no passwords. Passkeys and tokens are made over ssh; lose one and make another.</p>286+</div>287+<pre class="code"><span class="dim">$</span> ssh git@src.rest passkey <span class="dim"># a one-time link that adds a passkey</span>288+<span class="dim">$</span> ssh git@src.rest token --repo cat/012 <span class="dim"># for curl and HTTPS clones, shown once</span>289+<span class="dim">$</span> ssh git@src.rest sessions <span class="dim"># where you are signed in; rm signs one out</span></pre>277290 </section>278291 279292 <section class="commit" id="cli" aria-labelledby="cli-h">280-<header><p class="ref"><b>08</b> cheat sheet</p><h2 id="cli-h"><a href="#cli">Day to day</a></h2></header>281-<div class="cheat">282-<section aria-labelledby="cs-setup"><h3 id="cs-setup">once</h3><dl>293+<header><p class="ref"><b>07</b> day to day</p><h2 id="cli-h"><a href="#cli">Day to day</a></h2></header>294+<div class="cheat panels">295+<section class="panel" aria-labelledby="cs-setup"><h3 class="panel-head" id="cs-setup">once</h3><dl>283296 <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>284-<div><dt>ssh git@host signers &gt; ~/.config/forge/allowed_signers</dt><dd>Then point <code>forge.allowedsigners</code> at it, so <code>forge verify</code> can check signatures.</dd></div>285-<div><dt>ssh git@host new greet "say hello"</dt><dd>Create a repo under your name; prints the remote to add.</dd></div>286-<div><dt>git push git@host:dami/greet.git main</dt><dd>Or just push: a repo under your name that doesn't exist yet is created first, the same way.</dd></div>297+<div><dt>ssh git@src.rest signers &gt; ~/.config/forge/allowed_signers</dt><dd>Then point <code>forge.allowedsigners</code> at it, so <code>forge verify</code> can check signatures.</dd></div>298+<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: the repo is created if it doesn't exist.</dd></div>287300 <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>288301 </dl></section>289-<section aria-labelledby="cs-review"><h3 id="cs-review">a review</h3><dl>290-<div><dt>git switch -c dami/topic</dt><dd>Branch under your name; once pushed, it is a review.</dd></div>291-<div><dt>forge attest --stack</dt><dd>CI for every commit on top of <code>main</code>, signed.</dd></div>292-<div><dt>forge push -t "Title"</dt><dd>Push the branch and the CI notes. <code>--draft</code>, <code>--ready</code>, <code>--target &lt;branch&gt;</code>, <code>-d</code> for a description.</dd></div>302+<section class="panel" aria-labelledby="cs-review"><h3 class="panel-head" id="cs-review">a review</h3><dl>303+<div><dt>git switch -c cat/topic</dt><dd>Branch under your name; once pushed, it is a review.</dd></div>304+<div><dt>forge push -t "Title"</dt><dd>Push the branch and the CI results. <code>--draft</code>, <code>--ready</code>, <code>--target &lt;branch&gt;</code>, <code>-d</code> for a description.</dd></div>293305 <div><dt>forge push --stack</dt><dd>Push every layer of a stack, lowest first.</dd></div>294-<div><dt>forge reviews</dt><dd>List reviews; <code>forge checkout &lt;id&gt;</code> fetches one.</dd></div>306+<div><dt>forge reviews</dt><dd>Open and draft reviews; <code>forge checkout &lt;id&gt;</code> fetches one.</dd></div>295307 <div><dt>forge comment [&lt;id&gt;] [path:line] -m "..."</dt><dd>Comment on a review, or on one line of it. Without <code>-m</code>, reads stdin or opens <code>$EDITOR</code>.</dd></div>296-<div><dt>forge approve [&lt;id&gt;]</dt><dd>Approve the review as it is now. The approval follows the tree, so a reword keeps it.</dd></div>297-<div><dt>git push origin dami/topic:main</dt><dd>Land it (or <code>forge land</code>). <code>main</code> only fast-forwards.</dd></div>298-<div><dt>ssh git@host land dami/greet 7</dt><dd>Have the forge land review 7, no clone needed: it pushes to itself as you, so the same gates apply, and deletes the landed branches. <code>forge land --server</code> does it from a clone.</dd></div>308+<div><dt>forge approve [&lt;id&gt;]</dt><dd>Approve the review as it is now.</dd></div>309+<div><dt>forge land [&lt;id&gt;]</dt><dd>Check CI and approval, move <code>main</code> forward, delete the landed branches.</dd></div>310+<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>299311 </dl></section>300-<section aria-labelledby="cs-ci"><h3 id="cs-ci">ci</h3><dl>301-<div><dt>forge attest [rev...]</dt><dd>Attest given commits (default <code>HEAD</code>). <code>--force</code> re-runs a passed tree, <code>--clean</code> also removes ignored files from the worktree.</dd></div>302-<div><dt>forge verify [rev...]</dt><dd>Show each commit's attestations and who signed them.</dd></div>312+<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> reruns what passed.</dd></div>314+<div><dt>forge verify [rev...]</dt><dd>Show each commit's results and who signed them.</dd></div>303315 </dl></section>304-<section aria-labelledby="cs-sites"><h3 id="cs-sites">sites</h3><dl>305-<div><dt>forge site deploy [name...]</dt><dd>Build and publish from a clean worktree of <code>HEAD</code>: the site on <code>main</code>, a preview elsewhere. <code>--here</code> builds in your work tree.</dd></div>316+<section class="panel" aria-labelledby="cs-sites"><h3 class="panel-head" id="cs-sites">sites</h3><dl>317+<div><dt>forge site deploy [name...]</dt><dd>Build and publish from a clean checkout of <code>HEAD</code>: the site on <code>main</code>, a preview elsewhere. <code>--here</code> builds in your work tree.</dd></div>306318 <div><dt>forge site push &lt;dir&gt; &lt;name&gt;</dt><dd>Publish a directory you built yourself.</dd></div>307-<div><dt>forge site url &lt;name&gt; [branch]</dt><dd>Where a deploy is served.</dd></div>319+<div><dt>forge site url &lt;name&gt; [branch]</dt><dd>Where a site or preview is served.</dd></div>308320 </dl></section>309-<section aria-labelledby="cs-releases"><h3 id="cs-releases">releases</h3><dl>310-<div><dt>forge release publish &lt;tag&gt; &lt;file&gt;...</dt><dd>The tag's files with a signed <code>SHA256SUMS</code>, pushed with the tag and your CI notes; again to replace them.</dd></div>311-<div><dt>forge release verify &lt;file&gt;...</dt><dd>Check downloads against <code>SHA256SUMS</code> and its signature against the forge's keys.</dd></div>321+<section class="panel" aria-labelledby="cs-releases"><h3 class="panel-head" id="cs-releases">releases</h3><dl>322+<div><dt>forge release publish &lt;tag&gt; &lt;file&gt;...</dt><dd>The tag's files with signed checksums, pushed with the tag and your CI results; again to replace them.</dd></div>323+<div><dt>forge release verify &lt;file&gt;...</dt><dd>Check downloads against their checksums, and the signature against the forge's keys.</dd></div>312324 <div><dt>forge release ls, rm &lt;tag&gt;</dt><dd>This repo's releases; delete one.</dd></div>313325 </dl></section>314-<section aria-labelledby="cs-server"><h3 id="cs-server">on the server, as root</h3><dl>315-<div><dt>forge-admin user add ana 'ssh-ed25519 AAAA...'</dt><dd>Add a user and key; <code>user rm ana</code> revokes their keys.</dd></div>316-<div><dt>forge-admin repo new dami/greet "description"</dt><dd>Or <code>repo import dami/greet &lt;url&gt;</code> to fetch one server side.</dd></div>317-<div><dt>forge-admin build [owner/repo]</dt><dd>Rebuild pages; <code>metrics</code> shows where builds spend their time.</dd></div>318-<div><dt>forge-admin gc [--dry-run]</dt><dd>Remove unused site and release files, hostnames and leftover previews, then repack each repo geometrically, with a multi-pack bitmap and a fresh commit-graph. Daily from cron.</dd></div>319-<div><dt>forge-admin backups</dt><dd>How the last backup went, then the snapshots. <code>backup</code> takes one now; the container takes one daily.</dd></div>320-<div><dt>forge-admin restore latest</dt><dd>Bring the forge back on an empty box, from the newest snapshot or a named one.</dd></div>321-</dl></section>322-<section aria-labelledby="cs-repo"><h3 id="cs-repo">in a bare repo</h3><dl>323-<div><dt>git config forge.requireci tip</dt><dd>Require a verified pass for the new tip of <code>main</code>; <code>all</code> checks every new commit.</dd></div>324-<div><dt>git config forge.allowforce true</dt><dd>Let the owner and collaborators force-push <code>main</code>.</dd></div>325-<div><dt class="nop">forge/collaborators</dt><dd>Names, one per line, who can push anything.</dd></div>326+<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>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>329+<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>330+<div><dt>ssh git@src.rest who cat/012</dt><dd>Its owner, collaborators and readers.</dd></div>331+<div><dt>ssh git@src.rest ls</dt><dd>Your repos, and the private ones you may read.</dd></div>332+<div><dt>ssh git@src.rest passkey</dt><dd>A link that adds a passkey; <code>token</code> for curl and HTTPS clones.</dd></div>326333 </dl></section>327334 </div>328-<p class="aside">On the server: <code>forge-admin</code> runs <code>forge</code> as the git user in the container; on a VPS, use <code>sudo -u git /opt/forge/bin/forge</code>.</p>329335 </section>330336 331337 <section class="commit" id="not-yet" aria-labelledby="not-yet-h">332-<header><p class="ref"><b>09</b> not yet</p><h2 id="not-yet-h"><a href="#not-yet">What isn't there</a></h2></header>338+<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>333339 <ul class="gaps">334-<li><b>Issues.</b> Bugs and tasks in the args.io style don't exist yet.</li>335-<li><b>Notifications.</b> Nothing emails you. Open pages update themselves, and that is all.</li>336-<li><b>Buttons.</b> Pages only show things. Landing, deploying and administering happen through git, ssh and <code>forge</code>.</li>337-<li><b>CI on the server.</b> By design: it runs where the code is written, and the server checks 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>338343 </ul>339344 </section>340345
@@ -344,7 +349,7 @@ log sha256:6cc79019... 4 lines
344349 <footer class="end">345350 <div class="wrap">346351 <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>347-<p>This guide is <code>guide/</code> in forge's own repo, published with <code>forge site deploy guide</code>. For how the code fits together, read <a href="internals.html">the internals</a>.</p>352+<p>This guide is <code>guide/</code> in <a href="https://src.rest/cat/forge/">cat/forge</a>. How it works inside: <a href="internals.html">the internals</a>.</p>348353 </div>349354 </footer>350355 </body>
M guide/internals.html +181 -48
@@ -4,7 +4,7 @@
44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width, initial-scale=1">66 <title>forge internals: where things are and how they fit</title>7-<meta name="description" content="For whoever maintains forge next: the source map, state on disk, what runs, how a build works, URLs, settings and upkeep.">7+<meta name="description" content="For whoever maintains forge next: a push through the server, the source map, state on disk, identity, what runs and where, how a build works, URLs, settings and upkeep.">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">
@@ -13,7 +13,7 @@
1313 <script src="guide.js" defer></script>1414 </head>1515 <body>16-<a class="skip" href="#main">Skip to the internals</a>16+<a class="skip" href="#main">skip to the internals</a>1717 <header class="bar">1818 <div class="wrap">1919 <a class="brand" href="./"><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 <span>guide</span></a>
@@ -26,32 +26,56 @@
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">Where the code is, what it keeps on disk, what runs, and how a push becomes pages. 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>3333 3434 <div class="wrap page">35-<nav class="toc" aria-label="On this page">35+<nav class="toc" aria-label="on this page">3636 <div>3737 <h2>on this page</h2>3838 <ol>39-<li><a href="#map"><span>01</span>source</a></li>40-<li><a href="#disk"><span>02</span>on disk</a></li>41-<li><a href="#run"><span>03</span>what runs</a></li>42-<li><a href="#build"><span>04</span>the build</a></li>43-<li><a href="#urls"><span>05</span>urls</a></li>44-<li><a href="#knobs"><span>06</span>settings</a></li>45-<li><a href="#upkeep"><span>07</span>upkeep</a></li>46-<li><a href="#hacking"><span>08</span>hacking</a></li>39+<li><a href="#push"><span>01</span>a push</a></li>40+<li><a href="#map"><span>02</span>source</a></li>41+<li><a href="#disk"><span>03</span>on disk</a></li>42+<li><a href="#identity"><span>04</span>identity</a></li>43+<li><a href="#deploys"><span>05</span>signed refs</a></li>44+<li><a href="#run"><span>06</span>what runs</a></li>45+<li><a href="#hosting"><span>07</span>hosting</a></li>46+<li><a href="#build"><span>08</span>the build</a></li>47+<li><a href="#urls"><span>09</span>urls</a></li>48+<li><a href="#knobs"><span>10</span>settings</a></li>49+<li><a href="#server"><span>11</span>on the server</a></li>50+<li><a href="#upkeep"><span>12</span>upkeep</a></li>51+<li><a href="#hacking"><span>13</span>hacking</a></li>4752 </ol>4853 </div>4954 </nav>5055 5156 <main id="main" class="log">5257 58+<section class="commit" id="push" aria-labelledby="push-h">59+<header><p class="ref"><b>01</b> a push</p><h2 id="push-h"><a href="#push">What happens when you push</a></h2></header>60+<p class="prose">A push is how almost everything changes. Here is one, followed through the server.</p>61+<figure class="plate">62+<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>64+<li><span class="where">sshd</span><div><p>Your key matches a line in <code>authorized_keys</code> that forces <code>forge shell &lt;name&gt;</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>65+<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>66+<li><span class="where">git</span><div><p>The refs move.</p></div></li>67+<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>68+<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>70+<li class="bg"><span class="where">your browser</span><div><p>Open pages hold a server-sent events stream from <code>/events/&lt;owner&gt;/&lt;repo&gt;/</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>71+</ol>72+<figcaption><b>fig. 1</b>one push, from your terminal to every open page</figcaption>73+</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>75+</section>76+5377 <section class="commit" id="map" aria-labelledby="map-h">54-<header><p class="ref"><b>01</b> source</p><h2 id="map-h"><a href="#map">Where the code is</a></h2></header>78+<header><p class="ref"><b>02</b> source</p><h2 id="map-h"><a href="#map">Where the code is</a></h2></header>5579 <p class="prose">Four kinds of code, kept small: one shell script for everything git and ssh touch, a TypeScript data layer that Node 22 runs as it is (types are stripped, never compiled), Nunjucks templates with a base stylesheet, and container and deploy files.</p>5680 <div class="table">5781 <table class="rows">
@@ -63,7 +87,8 @@
6387 <tr><th scope="row"><code>lib/reviews.ts</code></th><td>Review logs plus live refs become stacks, layers, statuses and range-diffs.</td></tr>6488 <tr><th scope="row"><code>lib/attest.ts</code></th><td>Parses attestation notes and verifies their signatures with <code>ssh-keygen</code>, remembering each result.</td></tr>6589 <tr><th scope="row"><code>lib/sites.ts</code>, <code>lib/hosts.ts</code></th><td>Deploy history and signatures for the sites page; per-site hostnames, with <code>www/hosts/</code> as the registry. <code>hosts.ts</code> is also a small CLI the hooks call.</td></tr>66-<tr><th scope="row"><code>lib/events-server.ts</code>, <code>lib/pages-server.ts</code></th><td>The two long-running servers: live updates over server-sent events, and sites on their own hostnames.</td></tr>90+<tr><th scope="row"><code>lib/events-server.ts</code>, <code>lib/private-server.ts</code>, <code>lib/pages-server.ts</code></th><td>The long-running servers: live updates over server-sent events; private pages, sign-in, and comments and approvals from the web; sites on their own hostnames.</td></tr>91+<tr><th scope="row"><code>lib/identity.ts</code>, <code>lib/access.ts</code>, <code>lib/login.ts</code></th><td>Web credentials in <code>identity.db</code> (passkeys, sessions, tokens, invites), who may read what, and passkey sign-in.</td></tr>6792 <tr><th scope="row"><code>lib/</code>, the rest</th><td><code>git.ts</code> wraps the git CLI, <code>diff.ts</code> parses patches, <code>render.ts</code> writes diff and code HTML, <code>markdown.ts</code> renders and sanitizes READMEs and wiki pages, <code>urls.ts</code> is the URL scheme, <code>cache.ts</code> the per-repo build cache, <code>assets.ts</code> the hashed asset names, <code>render-page.ts</code> the Nunjucks setup 11ty shares and pages rendered without 11ty, <code>metrics.ts</code> build timings, <code>types.ts</code> the model.</td></tr>6893 <tr><th scope="row"><code>site/</code>, <code>eleventy.config.ts</code></th><td>Templates, each extending <code>_includes/base.njk</code> and carrying its own CSS in <code>{% css %}</code> blocks; <code>assets/style.css</code> for what every page needs; the small client scripts in <code>assets/*.ts</code>, whose types are stripped when the site is built.</td></tr>6994 <tr><th scope="row"><code>server/</code></th><td>The container's <code>start.sh</code>, <code>sshd.conf</code> and <code>forge-admin</code>; <code>provision.sh</code> for a plain VPS; an example Caddyfile.</td></tr>
@@ -75,13 +100,13 @@
75100 </section>76101 77102 <section class="commit" id="disk" aria-labelledby="disk-h">78-<header><p class="ref"><b>02</b> on disk</p><h2 id="disk-h"><a href="#disk">State on disk</a></h2></header>103+<header><p class="ref"><b>03</b> on disk</p><h2 id="disk-h"><a href="#disk">State on disk</a></h2></header>79104 <p class="prose">Everything lives under the git user's home, <code>FORGE_ROOT</code>: <code>/srv/git</code> on a server, <code>demo/root</code> in the demo. Back up this one directory and you have the forge; <code>forge backup</code> does, leaving out what a restore rebuilds (<a href="#upkeep">upkeep</a>).</p>80105 <figure>81106 <pre class="tree"><span class="d">/srv/git/</span>82107 ├── .ssh/authorized_keys <span class="c">generated from .forge/keys83108 </span>├── git-shell-commands/ <span class="c">new, ls, reviews, land, comment, approve, help...84-</span>├── <span class="d">dami/greet.git/</span> <span class="c">a bare repo; the directory names the owner109+</span>├── <span class="d">cat/greet.git/</span> <span class="c">a bare repo; the directory names the owner85110 </span>│ ├── hooks/ <span class="c">pre-receive, post-receive: exec forge hook ...86111 </span>│ ├── forge/reviews/&lt;id&gt; <span class="c">one log per review87112 </span>│ ├── forge/collaborators <span class="c">names that may push anything
@@ -103,23 +128,80 @@
103128 </span> ├── backup.status, backup.log <span class="c">the last backup's health, for forge backups104129 </span> ├── backup/ <span class="c">staging, restic's cache, the restore drill105130 </span> └── build.log, metrics.jsonl</pre>106-<figcaption><b>fig. 1</b>the forge's state, all of it</figcaption>131+<figcaption><b>fig. 2</b>the forge's state, all of it</figcaption>107132 </figure>108133 <div class="prose">109134 <p>A review log is a few lines appended by <code>post-receive</code>:</p>110135 </div>111-<pre class="code">branch dami/help112-created 1788276612 dami136+<pre class="code">branch cat/help137+created 1788276612 cat113138 opt title=Usage and --help114-rev 1788276612 ef1d881b67e0... dami 895bc48ac1d9... <span class="dim"># time, tip, who, merge-base with main</span>115-rev 1788282012 6771779c76bb... dami 895bc48ac1d9...116-deleted 1788292812 dami <span class="dim"># only once the branch is gone</span></pre>117-<p class="prose" style="margin-top:1rem">The container adds <code>.hostkeys/</code>, so clients see the same host key across deploys, and <code>.tailcat/</code>, whose server key keeps the tunnel address stable. Both are root's, and both are in every backup, so a restored box keeps its identity.</p>139+rev 1788276612 ef1d881b67e0... cat 895bc48ac1d9... <span class="dim"># time, tip, who, merge-base with main</span>140+rev 1788282012 6771779c76bb... cat 895bc48ac1d9...141+deleted 1788292812 cat <span class="dim"># only once the branch is gone</span></pre>142+<p class="prose">The container adds <code>.hostkeys/</code>, so clients see the same host key across deploys, and <code>.tailcat/</code>, whose server key keeps the tunnel address stable. Both are root's, and both are in every backup, so a restored box keeps its identity.</p>143+</section>144+145+<section class="commit" id="identity" aria-labelledby="identity-h">146+<header><p class="ref"><b>04</b> identity</p><h2 id="identity-h"><a href="#identity">Identity is one file of keys</a></h2></header>147+<p class="prose"><code>.forge/keys</code> holds <code>name key</code> lines and is the root of identity; passkeys, sessions and tokens in <code>identity.db</code> each belong to a name in it. <code>forge keys sync</code> (run by <code>forge user add</code>, <code>user rm</code> and a taken-up invite) writes two files from it, so the key you push with is also the key that signs your CI results, deploys and releases.</p>148+<figure class="plate fan">149+<div class="box"><span class="file">.forge/keys</span><code>fox <wbr>ssh-ed25519 <wbr>AAAAC3NzaC1lZDI1NTE5... <wbr>fox@laptop</code></div>150+<div class="by">forge keys sync</div>151+<div class="outs">152+<div class="box"><span class="file">.ssh/authorized_keys</span><code>restrict,command="/<wbr>opt/<wbr>forge/<wbr>bin/<wbr>forge <wbr>shell <wbr>fox" <wbr>ssh-ed25519 <wbr>AAAAC3Nz... <wbr>fox@laptop</code><p>Who is pushing: hooks see <code>FORGE_USER=fox</code>.</p></div>153+<div class="box"><span class="file">.forge/allowed_signers</span><code>fox <wbr>namespaces="forge-ci,forge-release,git" <wbr>ssh-ed25519 <wbr>AAAAC3Nz... <wbr>fox@laptop</code><p>Who signed a CI result, a deploy or a release's checksums. <code>ssh git@host signers</code> prints it for <code>forge verify</code>.</p></div>154+</div>155+<figcaption><b>fig. 3</b>one line in, two generated files out</figcaption>156+</figure>157+<p class="prose">Repos live at <code>/srv/git/&lt;owner&gt;/&lt;repo&gt;.git</code>, and the directory names the owner. The owner, and anyone in <code>&lt;repo&gt;.git/forge/collaborators</code>, can push anything; everyone else who can read the repo works under their own name. This is what <code>pre-receive</code> enforces, ref by ref:</p>158+<div class="table">159+<table class="rows">160+<thead><tr><th scope="col">ref</th><th scope="col">owner, collaborators</th><th scope="col">anyone else who can read it, e.g. fox</th></tr></thead>161+<tbody>162+<tr><th scope="row"><code>main</code> (the default branch)</th><td class="y" data-l="owner, collaborators">fast-forward only, unless <code>forge.allowforce</code>; never deleted</td><td class="n" data-l="anyone else">no</td></tr>163+<tr><th scope="row"><code>refs/heads/<wbr>fox/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes, their own prefix</td></tr>164+<tr><th scope="row">other branches</th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>165+<tr><th scope="row"><code>refs/<wbr>site-previews/<wbr>*/fox/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes</td></tr>166+<tr><th scope="row"><code>refs/<wbr>tags/*</code>, <code>refs/<wbr>sites/*</code>, <code>refs/<wbr>releases/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>167+<tr><th scope="row"><code>refs/<wbr>notes/ci</code></th><td class="y" data-l="owner, collaborators">yes, and may rewrite</td><td class="y" data-l="anyone else">add only: fast-forward, no delete</td></tr>168+<tr><th scope="row"><code>refs/<wbr>forge/*</code></th><td class="n" data-l="owner, collaborators">internal</td><td class="n" data-l="anyone else">internal</td></tr>169+</tbody>170+</table>171+</div>172+<p class="prose">Removing someone's line revokes their pushes, their web credentials and their signatures together: their old attestations stop verifying. A private repo is one without <code>git-daemon-export-ok</code>, git's own flag; <code>forge shell</code> turns away anyone who may not read it with the same answer as for a missing repo, and so does the private server for its pages.</p>173+</section>174+175+<section class="commit" id="deploys" aria-labelledby="deploys-h">176+<header><p class="ref"><b>05</b> signed refs</p><h2 id="deploys-h"><a href="#deploys">CI notes, sites and releases are refs</a></h2></header>177+<div class="prose">178+<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>179+<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/&lt;name&gt;</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>&lt;name&gt;/<wbr>&lt;branch&gt;</code>. The build gets <code>FORGE_SITE_BASE</code>, the path it will be served from (<code>/</code> on its own hostname).</p>181+</div>182+<figure class="plate">183+<ol class="chain">184+<li><span class="k">ref</span><span class="v">refs/sites/docs<small>a deploy commit, signed</small></span></li>185+<li class="hop"><span>post-receive extracts its tree, once per tree</span></li>186+<li><span class="k">store</span><span class="v">.forge/store/339f775e.../</span></li>187+<li class="hop up"><span>symlink</span></li>188+<li><span class="k">served</span><span class="v">www/<wbr>sites/<wbr>cat/<wbr>greet/<wbr>docs</span></li>189+<li class="hop up"><span>symlink, with a pages domain</span></li>190+<li><span class="k">hostname</span><span class="v">www/hosts/greet-docs</span></li>191+<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://<wbr>greet-docs<wbr>.pages.example.com/</span></li>193+</ol>194+<figcaption><b>fig. 4</b>a deploy, from ref to hostname</figcaption>195+</figure>196+<div class="prose">197+<p>The <code>www/hosts/</code> directory of symlinks is the whole hostname registry. Without a pages domain, sites are plain files under <code>/sites/&lt;owner&gt;/&lt;repo&gt;/&lt;name&gt;/</code> on the forge itself, and old <code>/sites/...</code> paths redirect to the hostname when there is one. Only the owner and collaborators deploy sites; anyone may deploy previews of their own branches.</p>198+<p><strong>Releases.</strong> <code>forge release publish</code> commits a tag's files to <code>refs/releases/&lt;tag&gt;</code> with a <code>SHA256SUMS</code> signed with <code>ssh-keygen -n forge-release</code> over the repo and tag too. <code>pre-receive</code> checks every sum, the signature (an owner's or collaborator's key), the tag and a size cap, and with <code>forge.releaserequireci</code> a verified pass for the tag's tree. <code>post-receive</code> extracts the tree into the store and hard-links the files into <code>/&lt;owner&gt;/<wbr>&lt;repo&gt;/<wbr>releases/<wbr>download/<wbr>&lt;tag&gt;/</code>, plus <code>latest/</code>; hard links, so the mirror copies them. Clones and fetches never see <code>refs/releases</code>. A private repo's go to the private root next to its pages, never the mirror.</p>199+</div>118200 </section>119201 120202 <section class="commit" id="run" aria-labelledby="run-h">121-<header><p class="ref"><b>03</b> what runs</p><h2 id="run-h"><a href="#run">What runs, and why</a></h2></header>122-<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>123205 <div class="table">124206 <table class="rows">125207 <thead><tr><th scope="col">process</th><th scope="col">listens</th><th scope="col">as</th><th scope="col">why</th></tr></thead>
@@ -128,39 +210,67 @@ deleted 1788292812 dami <span class="dim"># only o
128210 <tr><th scope="row">fcgiwrap</th><td data-l="listens">unix socket</td><td data-l="as">git</td><td>Runs <code>git http-backend</code> for HTTPS clones; Caddy routes only <code>info/refs</code> and <code>git-upload-pack</code> to it.</td></tr>129211 <tr><th scope="row">tailcat</th><td data-l="listens">the tunnel</td><td data-l="as">root</td><td>Only with <code>FORGE_TAILCAT_ALLOW</code>: carries :2222 and :8080 to the edge.</td></tr>130212 <tr><th scope="row">events server</th><td data-l="listens"><code>127.0.0.1:8091</code></td><td data-l="as">git</td><td>Tails <code>events.jsonl</code>, streams it at <code>/events/</code>.</td></tr>213+<tr><th scope="row">private server</th><td data-l="listens"><code>127.0.0.1:8093</code></td><td data-l="as">git</td><td>Every path <code>www</code> has no file for: private pages for their readers, <code>/login/</code>, comment and approve posts, and the one 404.</td></tr>214+<tr><th scope="row">litestream</th><td data-l="listens"></td><td data-l="as">git</td><td>Only with <code>FORGE_BACKUP_REPOSITORY</code>: copies <code>identity.db</code> to the backup bucket as it changes.</td></tr>131215 <tr><th scope="row">pages server</th><td data-l="listens"><code>127.0.0.1:8092</code></td><td data-l="as">caddy</td><td>Only with <code>FORGE_PAGES_DOMAIN</code>: sites on their own hostnames. Runs as caddy because deploys are untrusted.</td></tr>132216 <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>133217 <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>134218 <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, at most hourly, syncs the forge's pages to the bucket the edge falls back to.</td></tr>135-<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>136220 </tbody>137221 </table>138222 </div>139-<p class="prose">The container takes <code>FORGE_DOMAIN</code>, <code>FORGE_PAGES_DOMAIN</code>, <code>FORGE_OWNER</code>, <code>FORGE_OWNER_KEY</code>, <code>FORGE_NAME</code> and <code>FORGE_TAILCAT_ALLOW</code>, plus <code>FORGE_BACKUP_*</code> and <code>FORGE_MIRROR_*</code>, which it writes to <code>.forge/backup.env</code> for backups and the mirror. On a VPS, <code>provision.sh</code> installs the same pieces natively and runs the two Node servers as systemd units, <code>forge-events</code> and <code>forge-pages</code>, with Caddy on :443 getting its own certificates.</p>223+<p class="prose">The container takes <code>FORGE_DOMAIN</code>, <code>FORGE_PAGES_DOMAIN</code>, <code>FORGE_OWNER</code>, <code>FORGE_OWNER_KEY</code>, <code>FORGE_NAME</code> and <code>FORGE_TAILCAT_ALLOW</code>, plus <code>FORGE_BACKUP_*</code> and <code>FORGE_MIRROR_*</code>, which it writes to <code>.forge/backup.env</code> for backups and the mirror. On a VPS, <code>provision.sh</code> installs the same pieces natively and runs the Node servers as systemd units, <code>forge-events</code>, <code>forge-private</code> and, with a pages domain, <code>forge-pages</code>, with Caddy on :443 getting its own certificates.</p>224+</section>225+226+<section class="commit" id="hosting" aria-labelledby="hosting-h">227+<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>229+<div class="hosts">230+<figure class="plate">231+<ol class="hops">232+<li class="node"><b>internet</b><span>ssh on :22, https on :443. DNS for the forge and pages domains points at the edge, DNS-only, since ssh can't go through Cloudflare's proxy.</span></li>233+<li class="wire">raw TCP for :22, TLS ends here for :443</li>234+<li class="node"><b>Fly edge</b><span><code>deploy/edge</code>. Stateless, with a dedicated IPv4 so raw TCP on :22 works. Its key and the container's address are Fly secrets. While the home box is down, its Caddy serves the forge's pages from a copy in R2.</span></li>235+<li class="wire tunnel">tailcat: point-to-point WireGuard on Tailscale's data plane, with no tailnet, accounts or control plane</li>236+<li class="node home"><b>forge container on a home box</b><span><code>deploy/slate</code>. Publishes no ports. Its tailcat server keeps its key on the volume and accepts only the edge's client key.</span><ul><li>sshd :2222</li><li>Caddy :8080</li><li>events :8091</li><li>pages :8092</li><li>private :8093</li></ul></li>237+</ol>238+<figcaption><b>fig. 5</b>how src.rest runs</figcaption>239+</figure>240+<figure class="plate">241+<ol class="hops">242+<li class="node"><b>internet</b><span>ssh on :22, https on :443</span></li>243+<li class="wire">straight in</li>244+<li class="node home"><b>a Debian or Ubuntu VPS</b><span><code>server/provision.sh</code> installs git, sshd and Caddy, which gets certificates itself, and runs the Node servers under systemd. It needs Node 22.18 or newer.</span><ul><li>sshd :22</li><li>Caddy :443</li><li>forge-events</li><li>forge-private</li><li>forge-pages</li></ul></li>245+</ol>246+<figcaption><b>fig. 6</b>or a plain VPS</figcaption>247+</figure>248+</div>249+<p class="prose">Either way, Caddy serves the pages directory as plain files and hands on only a few things: clones to <code>git http-backend</code>, <code>/events/</code> to the events server, comment and approve posts and every path the pages directory has no file for to the private server and, with a pages domain, sites to the pages server. The pages server runs as the caddy user, because deploys are untrusted content. A private repo's pages are built into a root of their own and served by the private server to readers signed in with a passkey or showing a token; to anyone else every page of it is the 404 of a page that isn't there. Passkeys, sessions and tokens live, as hashes, in one SQLite file that litestream copies to the backup bucket as it changes.</p>140250 </section>141251 142252 <section class="commit" id="build" aria-labelledby="build-h">143-<header><p class="ref"><b>04</b> the build</p><h2 id="build-h"><a href="#build">How a push becomes pages</a></h2></header>253+<header><p class="ref"><b>08</b> the build</p><h2 id="build-h"><a href="#build">How a push becomes pages</a></h2></header>144254 <figure class="plate">145255 <ol class="flow">146256 <li><span class="where">post-receive</span><div><p>Starts <code>forge build &lt;owner&gt;/&lt;repo&gt;</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>147-<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>148-<li><span class="where">graph</span><div><p><code>git commit-graph write --reachable --changed-paths --split</code> adds a small layer for the new commits, with Bloom filters, so <code>git log -- &lt;path&gt;</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>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>258+<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 -- &lt;path&gt;</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>149259 <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>150260 <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>151261 <li><span class="where">render</span><div><p>The rest render into <code>.forge/build.&lt;pid&gt;/</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>152-<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>153263 <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>154264 <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>155265 </ol>156-<figcaption><b>fig. 2</b>a single-repo build</figcaption>266+<figcaption><b>fig. 7</b>a single-repo build</figcaption>157267 </figure>158268 <p class="prose"><code>forge build</code> with no argument rebuilds every repo, one at a time so memory stays bounded by the largest, then the index pages, then removes pages of repos that no longer exist and prunes dangling hostnames.</p>159269 </section>160270 161271 <section class="commit" id="urls" aria-labelledby="urls-h">162-<header><p class="ref"><b>05</b> urls</p><h2 id="urls-h"><a href="#urls">URLs</a></h2></header>163-<p class="prose">Every page is a directory with an <code>index.html</code>, so URLs read like paths. Branch names may contain <code>/</code>, which would make <code>/tree/&lt;branch&gt;/&lt;path&gt;</code> ambiguous, so it is written <code>~</code>, which git forbids in ref names: <code>dami/flags</code> becomes <code>dami~flags</code>.</p>272+<header><p class="ref"><b>09</b> urls</p><h2 id="urls-h"><a href="#urls">URLs</a></h2></header>273+<p class="prose">Every page is a directory with an <code>index.html</code>, so URLs read like paths. Branch names may contain <code>/</code>, which would make <code>/tree/&lt;branch&gt;/&lt;path&gt;</code> ambiguous, so it is written <code>~</code>, which git forbids in ref names: <code>cat/flags</code> becomes <code>cat~flags</code>.</p>164274 <div class="table">165275 <table class="rows">166276 <thead><tr><th scope="col">url</th><th scope="col">page</th></tr></thead>
@@ -182,17 +292,17 @@ deleted 1788292812 dami <span class="dim"># only o
182292 </section>183293 184294 <section class="commit" id="knobs" aria-labelledby="knobs-h">185-<header><p class="ref"><b>06</b> settings</p><h2 id="knobs-h"><a href="#knobs">Settings, wherever they live</a></h2></header>295+<header><p class="ref"><b>10</b> settings</p><h2 id="knobs-h"><a href="#knobs">Settings, wherever they live</a></h2></header>186296 <div class="cheat keys">187297 <section aria-labelledby="k-config"><h3 id="k-config">.forge/config, on the server</h3><dl>188298 <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>189299 <div><dt>FORGE_PAGES_DOMAIN, FORGE_OWNER</dt><dd>Turn on per-site hostnames; the owner's sites get the short ones.</dd></div>190-<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>191301 </dl></section>192302 <section aria-labelledby="k-repo"><h3 id="k-repo">a bare repo's git config</h3><dl>193303 <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>194304 <div><dt>forge.allowforce true</dt><dd>Let admins force-push <code>main</code>.</dd></div>195-<div><dt>transfer.hideRefs refs/forge</dt><dd>Set by forge on every repo, with <code>receive.advertisePushOptions true</code> and <code>commitGraph.changedPathsVersion 2</code>; see <a href="#upkeep">upkeep</a>.</dd></div>305+<div><dt>transfer.hideRefs refs/forge</dt><dd>Set by forge on every repo, with <code>receive.advertisePushOptions <wbr>true</code> and <code>commitGraph.changedPathsVersion <wbr>2</code>; see <a href="#upkeep">upkeep</a>.</dd></div>196306 </dl></section>197307 <section aria-labelledby="k-dev"><h3 id="k-dev">your git config</h3><dl>198308 <div><dt>forge.signingkey, forge.allowedsigners</dt><dd>Sign with this key; verify against this file.</dd></div>
@@ -200,7 +310,7 @@ deleted 1788292812 dami <span class="dim"># only o
200310 <div><dt>forge.cidir, forge.nix</dt><dd>Where CI worktrees live; <code>forge.nix false</code> skips <code>nix develop</code>.</dd></div>201311 </dl></section>202312 <section aria-labelledby="k-backup"><h3 id="k-backup">forge.env, for backups</h3><dl>203-<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>204314 <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>205315 <div><dt>FORGE_MIRROR_S3, _KEY_ID, _SECRET, _DELAY, _MIN_INTERVAL</dt><dd>The pages mirror's bucket and keys, how long it waits after the last build, 600 seconds, and how long at least between syncs, 3600 seconds.</dd></div>206316 </dl></section>
@@ -212,8 +322,31 @@ deleted 1788292812 dami <span class="dim"># only o
212322 </div>213323 </section>214324 325+<section class="commit" id="server" aria-labelledby="server-h">326+<header><p class="ref"><b>11</b> on the server</p><h2 id="server-h"><a href="#server">Running it, as root</a></h2></header>327+<p class="prose">Administer as root on the server (<code>forge-admin ...</code> in the container, which runs <code>forge</code> as the git user; on a VPS, <code>sudo <wbr>-u <wbr>git <wbr>/<wbr>opt/<wbr>forge/<wbr>bin/<wbr>forge</code>), never by logging in as <code>git</code>: every key is forced through <code>forge shell</code>. The forge's admins can run the people commands over ssh instead.</p>328+<div class="cheat">329+<section aria-labelledby="sv-root"><h3 id="sv-root">as root</h3><dl>330+<div><dt>forge-admin user add fox 'ssh-ed25519 AAAA...'</dt><dd>Add a user and key; <code>user rm fox</code> revokes their keys and web credentials. <code>user invite</code> makes a link instead.</dd></div>331+<div><dt>forge-admin repo new cat/greet "description"</dt><dd>Or <code>repo import cat/greet &lt;url&gt;</code> to fetch one server side; <code>repo rm</code> and <code>repo restore</code> for the trash.</dd></div>332+<div><dt>forge-admin collaborators cat/greet add fox</dt><dd>The same lists the owner keeps over ssh, for any repo; <code>readers</code> too.</dd></div>333+<div><dt>forge-admin build [owner/repo]</dt><dd>Rebuild pages; <code>metrics</code> shows where builds spend their time.</dd></div>334+<div><dt>forge-admin gc [--dry-run]</dt><dd>Remove unused site and release files, hostnames and leftover previews, then repack each repo (<a href="#upkeep">upkeep</a>). Daily from cron.</dd></div>335+<div><dt>forge-admin backups</dt><dd>How the last backup went, then the snapshots. <code>backup</code> takes one now; the container takes one daily.</dd></div>336+<div><dt>forge-admin restore latest</dt><dd>Bring the forge back on an empty box, from the newest snapshot or a named one.</dd></div>337+</dl></section>338+<section aria-labelledby="sv-repo"><h3 id="sv-repo">git config, on the server</h3><dl>339+<div><dt>git config forge.requireci tip</dt><dd>Require a verified pass for the new tip of <code>main</code>; <code>all</code> checks every new commit.</dd></div>340+<div><dt>git config forge.requireapproval true</dt><dd>Require an approval of the new tip from someone other than the review's author.</dd></div>341+<div><dt>git config forge.releaserequireci true</dt><dd>Require a verified pass for a release's tag.</dd></div>342+<div><dt>git config forge.allowforce true</dt><dd>Let the owner and collaborators force-push <code>main</code>.</dd></div>343+<div><dt>git config --file /srv/git/.gitconfig --add forge.admins cat</dt><dd>In the git user's own config: the forge's admins, who may <code>invite</code>, list <code>users</code> and set the <code>motd</code> over ssh.</dd></div>344+</dl></section>345+</div>346+</section>347+215348 <section class="commit" id="upkeep" aria-labelledby="upkeep-h">216-<header><p class="ref"><b>07</b> upkeep</p><h2 id="upkeep-h"><a href="#upkeep">Keeping it tidy</a></h2></header>349+<header><p class="ref"><b>12</b> upkeep</p><h2 id="upkeep-h"><a href="#upkeep">Keeping it tidy</a></h2></header>217350 <div class="prose">218351 <p><strong>Previews</strong> go when their branch does: <code>post-receive</code> deletes the preview ref, the served link and the hostname of a deleted branch. Previews of merged branches stay until the branch is deleted.</p>219352 <p><strong>The store</strong> keeps every tree that was ever deployed until <code>forge gc</code>, which removes</p>
@@ -222,19 +355,19 @@ deleted 1788292812 dami <span class="dim"># only o
222355 <li>hostnames whose deploy is gone,</li>223356 <li>store trees that no <code>www/sites</code> or <code>www/previews</code> link points at.</li>224357 </ul>225-<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 exec forge forge-admin gc</code>) or the git user's on a VPS.</p>226-<p><strong>Repos</strong> are maintained by the same <code>forge gc</code>, in <code>repack_repo</code>: <code>git repack -d --geometric=2 --write-midx --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/&lt;sha&gt;</code>. Between runs, each build adds a commit-graph layer for its push.</p>227-<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 2</code>, filters that hash non-ASCII paths the same on every platform.</p>228-<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>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>359+<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/&lt;sha&gt;</code>. Between runs, each build adds a commit-graph layer for its push.</p>360+<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>229362 <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>230-<p><strong>The offline copy</strong>: after builds, at most hourly, <code>forge mirror</code> syncs the forge's own pages, without sites, previews, or the pages of single files and folders, to an R2 bucket. When slate doesn't answer, the edge's Caddy serves them from there with <code>X-Forge-Offline: mirror</code>, and a page the copy leaves out is a 404 that says so; sites, clones and pushes wait for slate.</p>363+<p><strong>The offline copy</strong>: after builds, at most hourly, <code>forge mirror</code> syncs the forge's own pages, without sites, previews or the pages of single files and folders, to an R2 bucket. When slate doesn't answer, the edge's Caddy serves them from there with <code>X-Forge-Offline: mirror</code>, and a page the copy leaves out is a 404 that says so; sites, clones and pushes wait for slate.</p>231364 <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>232-<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>233366 </div>234367 </section>235368 236369 <section class="commit" id="hacking" aria-labelledby="hacking-h">237-<header><p class="ref"><b>08</b> hacking</p><h2 id="hacking-h"><a href="#hacking">Working on forge</a></h2></header>370+<header><p class="ref"><b>13</b> hacking</p><h2 id="hacking-h"><a href="#hacking">Working on forge</a></h2></header>238371 <div class="cheat">239372 <section aria-labelledby="hk-run"><h3 id="hk-run">run it</h3><dl>240373 <div><dt>npm install</dt><dd>11ty, markdown-it, sanitize-html, Pele; TypeScript only for checking.</dd></div>
@@ -243,7 +376,7 @@ deleted 1788292812 dami <span class="dim"># only o
243376 <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>244377 </dl></section>245378 <section aria-labelledby="hk-check"><h3 id="hk-check">check it</h3><dl>246-<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>247380 <div><dt>npx tsc -p .</dt><dd>Type check only. <code>tsc</code> never emits.</dd></div>248381 <div><dt>node lib/check-links.ts guide</dt><dd>Every relative link and anchor in the guide resolves.</dd></div>249382 <div><dt>node lib/render-page.ts check &lt;www&gt;</dt><dd>Every page renders the same with plain Nunjucks as with 11ty, the base for rendering pages on demand.</dd></div>
@@ -251,7 +384,7 @@ deleted 1788292812 dami <span class="dim"># only o
251384 </div>252385 <div class="prose" style="margin-top:1.4rem">253386 <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>254-<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>255388 </div>256389 </section>257390
M server/Caddyfile.example +1 -1 mechanical
M site/design.njk +4 -4
@@ -61,7 +61,7 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
6161 <div class="dg-page">{{ ui.tabs('example', [{href: '#tabs', text: 'all', n: 12, cur: true}, {href: '#tabs', text: 'tools', n: 5}, {href: '#tabs', text: 'games', n: 4}, {href: '#tabs', text: 'other', n: 3}]) }}</div>6262 6363 {{ ui.heading('panels', null, 'where content sits: ui.panel') }}64-{% set head %}<span class="grow"><b>dami</b> <a class="key" href="#panels">Keep a range-diff's marker at the left edge</a></span><span class="end"><a class="sha" href="#panels">8318d80</a><span class="ci pass">&#10003;</span><time>2h</time><a href="#panels">412 commits</a></span>{% endset %}64+{% set head %}<span class="grow"><b>cat</b> <a class="key" href="#panels">Keep a range-diff's marker at the left edge</a></span><span class="end"><a class="sha" href="#panels">8318d80</a><span class="ci pass">&#10003;</span><time>2h</time><a href="#panels">412 commits</a></span>{% endset %}6565 {% call ui.panel(head) %}6666 <table class="list files nav"><tbody>6767 <tr><td class="file-icon">{{ ui.icon('folder') }}</td><td class="main"><a href="#panels">lib</a></td><td class="num size"></td></tr>
@@ -96,8 +96,8 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
9696 {{ ui.heading('section heading', 4, 'a count, then a quieter note') }}9797 {% call ui.panel() %}9898 {% call ui.listTable(['commit', 'author', {text: '+', cls: 'num'}, {text: '-', cls: 'num'}, 'ci']) %}99-<tr><td class="main"><a href="#list">Parse --name and --shout</a></td><td class="nowrap">dami</td><td class="num i">+12</td><td class="num d">-3</td><td><span class="ci pass">&#10003;</span></td></tr>100-<tr><td class="main"><a href="#list">Keep a range-diff's marker at the left edge</a></td><td class="nowrap">ana</td><td class="num i">+4</td><td class="num d">-1</td><td><span class="ci fail">&#10007;</span></td></tr>99+<tr><td class="main"><a href="#list">Parse --name and --shout</a></td><td class="nowrap">cat</td><td class="num i">+12</td><td class="num d">-3</td><td><span class="ci pass">&#10003;</span></td></tr>100+<tr><td class="main"><a href="#list">Keep a range-diff's marker at the left edge</a></td><td class="nowrap">fox</td><td class="num i">+4</td><td class="num d">-1</td><td><span class="ci fail">&#10007;</span></td></tr>101101 {% endcall %}102102 {% endcall %}103103
@@ -116,7 +116,7 @@ table.dg-tokens td.v { font: var(--fw-regular) var(--text-xs) var(--mono); color
116116 {{ ui.heading('type') }}117117 {% call ui.panel() %}118118 <div class="dg-fam"><div class="big" style="font-family: var(--sans)">Open Sans: tabs, lists, subjects, buttons</div><div class="use"><code>--sans</code> all interface text</div></div>119-<div class="dg-fam"><div class="big" style="font-family: var(--mono)">Noto Sans Mono: 0f0215e dami/help +12</div><div class="use"><code>--mono</code> anything copied or compared: hashes, ids, branches, paths, counts, code, diffs, commands</div></div>119+<div class="dg-fam"><div class="big" style="font-family: var(--mono)">Noto Sans Mono: 0f0215e cat/help +12</div><div class="use"><code>--mono</code> anything copied or compared: hashes, ids, branches, paths, counts, code, diffs, commands</div></div>120120 <div class="dg-fam"><div class="big" style="font-family: var(--serif)">Noto Serif: what people wrote</div><div class="use"><code>--serif</code> rendered prose only</div></div>121121 {% endcall %}122122