clone

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

main
forge / README.md
README.md 92.3K source

forge

A personal forge built from plain git. The server is the setup from Git on the Server: a git user, SSH keys and bare repositories. Pushes run two hooks, and an 11ty build writes static pages in the style of stagit. The state is files in the bare repos, a directory of HTML that any static file server can serve, and one SQLite file of web sign-ins (passkeys, sessions, tokens) for private repos' pages. Small Node servers sit next to it for what static files can't do: one streams live page updates, one serves private repos' pages to their readers, one serves repo sites on their own hostnames (see What runs).

It adds what stagit doesn't have, borrowing from args.io:

  • Every branch is browsable. Log, files and blobs for each branch, plus a refs page with ahead and behind counts.
  • Reviews are stacked diffs. Any pushed branch is a review. A branch built on another review's branch is detected as the next layer of a stack. A review page is its commits' diffs in order, plus a combined diff.
  • Revisions are recorded per push. Force-pushing a review records a new revision. Its page shows what changed: the diff between the two tips, or after a rebase, each commit paired with its earlier version (git range-diff).
  • Discussion from the terminal, or the page. forge comment and forge approve add comments (on a line or the whole review) and approvals, which the review page shows; signed in, the page adds them too. Approvals are keyed by tree like CI, and main can require them from someone other than the author, as many and from whom .forge/approvals says for the files a change touches. forge land checks both and fast-forwards.
  • Agent reviews, signed like CI. forge critique runs the reviewer the target branch names (.forge/review) on your machine, posts what it finds as comments, and signs what is still open into a git note. An approval rule can ask for one with nothing blocking.
  • CI runs on your machine. forge attest runs .forge/ci in a clean worktree. It signs the result with the SSH key you push with and stores it as a git note. Pages show the result for each commit, and main can require a verified pass to accept a push. CI can report numbers (bundle size, test count) that a repo's insights chart by commit.
  • Sites ship with repos. Docs, Docusaurus builds or anything static is committed to refs/sites/<name> and served from the forge. Each review branch gets its own preview. The wiki is markdown in wiki/.
  • Releases are signed files. forge release publish v1.0 <file>... puts a tag's binaries on refs/releases/v1.0 with a SHA256SUMS signed by your key; the forge checks both and serves the files.
  • Keyboard navigation and clean URLs, e.g. /cat/greet/tree/main/greet.sh/.
  • Blame and history. A file on the default branch shows who last changed each line and every commit that changed it, each with the review it landed in.
  • Search and syntax highlighting. / searches a repo's commits, reviews, wiki and file paths from a static index, and file contents from a SQLite index the browser reads in byte ranges; code is highlighted at build time.
  • Insights. The forge's facts (pushes, builds, reviews, CI results) are kept in a DuckLake, and static pages chart them for the forge and for each repo.

The guide explains all of this with diagrams. It is a static site in guide/, declared in .forge/sites, so the forge serves it next to this repo after forge site deploy guide.

Try it

npm install
sh demo/make-demo.sh          # throwaway forge in demo/root, real hooks over file:// pushes
python3 -m http.server 4817 -d demo/root/.forge/www

The demo creates two users and a repo with:

  • a landed review and a two-layer stack, with both layers rewritten once, and a description, comments and approvals on it (one comment outdated by the rewrite, one approval stale);
  • a contribution from the second user whose CI fails and then passes, and which main turns away because nobody approved it;
  • a draft that grows, then is rebased onto a newer main and reworked;
  • an abandoned branch, a wiki and a docs site with a preview;
  • a release of v0.1: the script and a source tarball;
  • a standalone site that the second user owns;
  • code in JavaScript, Go, CSS, Python and markdown fences, and a review of a 1,200-line file with long lines.

Its clock is fixed: commits get set dates, and the hooks stamp reviews with FORGE_NOW (an override for the demo and tests) instead of the wall clock.

How it fits together

your machine                                   server (git user, /srv/git)
------------                                   ---------------------------
git push ─────────── ssh, forced command ────> forge shell <user> -> git-shell
forge attest  -> refs/notes/ci (signed)        pre-receive: who may push what; CI and
                                                 approval gates on main
forge site deploy -> refs/sites/<name>         post-receive: record review revisions,
                     refs/site-previews/...      extract sites and releases, rebuild pages
forge release publish -> refs/releases/<tag>
                                               11ty build -> /srv/git/.forge/www (static)
                                               events.jsonl -> live page updates
forge comment, approve ─── ssh ──────────────> comment, approve: next to the review log
review page, signed in ─── HTTPS ────────────> the same, through the private server

What runs

Process Why
sshd git over ssh. Every key is forced through forge shell <name>.
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, with security headers (HSTS, nosniff, a referrer policy and a CSP against framing) on the forge's own pages; proxies /events/, every path www has no file for (to the private server) and, with a pages domain, sites.
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).
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. At most 2,000 streams are open for people not signed in, and 16 from one address.
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); answers Caddy whether a token may clone a private repo over HTTPS (see Private pages).
lib/pages-server.ts Only with a pages domain. Serves each site on its own hostname the way Cloudflare Pages does (_headers, _redirects, pretty URLs, the nearest 404.html or a single-page-app fallback) and redirects old /sites/... paths there. Runs as the caddy user, since deploys are untrusted content.
forge backup Daily, from a loop in start.sh. Snapshots the forge with restic into a bucket, then checks the snapshots and test-restores the new one (see Backups and moving hosts).
forge mirror After builds, in the background, at most hourly: syncs the forge's own pages, less those of single files and folders, to a bucket with rclone, for the edge to serve while the forge is down.
forge lake After builds, in the background: adds what is new in the event log, build metrics, review logs and CI notes to the lake, and writes the insights exports (see The lake and insights).
forge identity replicate With backups set up: litestream copies .forge/identity.db to the backup bucket as it changes (see identity.db).

Without a pages domain, Caddy serves sites straight from /sites/... and /previews/... on the forge's own origin, and _headers and _redirects don't apply. forge server caddyfile <address> [<pages-domain>] prints the Caddy config for either setup. It serves assets, whose names carry a hash of their contents, with Cache-Control: public, max-age=31536000, immutable. It serves pages, and the JSON and text indexes they fetch, with no-cache, so browsers revalidate them with the ETag and Last-Modified Caddy sends and get a 304 when nothing changed. Release downloads revalidate the same way, aren't compressed and are sent as attachments (see Releases). demo/serve.ts sends the same headers.

Identity and permissions

/srv/git/.forge/keys holds name ssh-ed25519 AAAA... lines and is the root of identity: a user is a name there, and everything else (web sign-ins included, below) belongs to one. forge keys sync generates two files from it:

  • authorized_keys, which forces every key through forge shell <name>, so hooks know who pushed;
  • allowed_signers, so the same keys verify CI attestations, signed site deploys and release checksums (namespaces forge-ci, forge-release and git).

Repos live at /srv/git/<owner>/<repo>.git, and <owner> owns them. Anyone can read a public repo; a private one, only the people below. An owner's page, /<owner>/, is there only while they have a public repo, so a user's name alone isn't published. The owner and anyone listed in <repo>.git/forge/collaborators can push anything. Other users who can read it can push branches and previews under their own name (fox/...) and add CI notes, which must fast-forward. Nobody can force-push main unless forge.allowforce is set.

ssh git@git.example.com collaborators cat/notes add fox   # rm to take it back; no argument lists them
ssh git@git.example.com who cat/notes                     # its owner, collaborators and readers

Only the owner changes a repo's collaborators; its collaborators can list them, and no one else can. The name must be a user of the forge. who is for anyone who can read the repo, so a private repo's is the same no repo as a missing one's to everyone else. The operator has the same lists for any repo: forge collaborators <owner>/<repo> [add|rm <user>] and forge readers.

People

A user is added by the operator, with their SSH public key, or invited by a link that lets them give it themselves:

forge user add fox 'ssh-ed25519 AAAA... fox@laptop'
forge user invite fox [--ttl 7d] [--repo cat/notes --as reader|collaborator]
forge user invites [rm fox]     # invites not taken up yet; rm makes a link dead
forge users                     # everyone: keys, passkeys and tokens; then the invites

invite holds the name (nobody can be added or invited as it meanwhile) and prints https://<domain>/login/#invite=<code>, which works once, for 7 days unless --ttl (at most 30d). The page it opens takes one SSH public key (ssh-*, ecdsa-* or sk-*, on one line, one ssh-keygen reads and nobody on the forge has already), then does what user add does: the line in keys, the home directory, keys sync. With --repo, the new user is that repo's reader or collaborator too. Then the same page offers to add a passkey, as the link ssh passkey prints does. The code is in the URL's fragment and identity.db keeps only its hash, like enrollment codes. The posts come only from the forge's own page (Origin), at most 10 a minute from one address with a live invite's code, one taking a key at a time. The code is checked first: without a live invite every post gets the same answer, whatever its key, so the page can't be asked whether a key has an account, and posts with dead codes aren't counted. user add checks a key the same way, so no key adds a second line to keys.

Admins

The forge's admins are the users named in forge.admins in the server's git config, /srv/git/.gitconfig (the git user's own): one value each, or several names split by spaces or commas. Owning repos doesn't make anyone an admin. Set it on the server, as git:

git config --file /srv/git/.gitconfig --add forge.admins cat         # in the container:
ssh slate docker exec -u git forge git config --file /srv/git/.gitconfig --add forge.admins cat
git config --file /srv/git/.gitconfig --get-all forge.admins         # the list; --unset-all forge.admins empties it

An admin runs the people commands above over ssh, from their own machine, the same as on the server:

ssh git@git.example.com invite fox [--ttl 7d] [--repo cat/notes --as reader|collaborator]
ssh git@git.example.com invites [rm fox]
ssh git@git.example.com users

They also set the home page's motd, the quiet line under its header (the server's form is forge motd):

ssh git@git.example.com motd "notes, tools and games"   # one line of plain text, 72 characters at most
ssh git@git.example.com motd                            # print it
ssh git@git.example.com motd --clear                    # no line at all
ssh git@git.example.com motd --default                  # back to "a personal forge on plain git"

It is kept in .forge/motd (no file is the default, an empty one no line), which backups carry, and setting it rebuilds the home page.

An admin's ssh session finds them in a git-shell-commands of their own, .forge/admin/git-shell-commands (forge server init writes it), so to anyone else they don't exist: git-shell answers fatal: unrecognized command, with the exit status of any command that isn't there, and help doesn't list them. A change to forge.admins counts from the next ssh command on.

ssh git@git.example.com new <name> creates a repo under your name, and so does pushing to one that doesn't exist yet: forge shell sees the push before git does, creates the repo the same way (same name rules, hooks installed), prints created cat/notes and lets the push go on.

git push git@git.example.com:cat/notes.git main

The first branch pushed becomes the default if it isn't main. A push to a missing repo under someone else's name, or a clone or fetch of one, fails as before.

ssh git@git.example.com rm cat/notes --yes takes a repo of yours off the forge (without --yes it says what would go); collaborators can't, and forge repo rm <owner>/<repo> does the same as root. Its clones, pages in either root, sites, previews, hostnames, release downloads and insights go at once, as do its rows in the lake, so a new repo of the same name starts with no history. The index and owner pages are rebuilt before the command returns, and a mirror sync within 30 seconds deletes its pages. The bare repo waits in .forge/trash/<owner>/<repo>.git.<time> for FORGE_TRASH_DAYS (14) days, backed up like the rest of the forge. forge repo restore <owner>/<repo> puts back the newest removal if the name is free, and forge gc deletes it after that.

Private repos

A repo is public while its bare repo has a git-daemon-export-ok file. That is git's own flag: git daemon and git http-backend serve only repos that have it, and Forgejo toggles the same file. forge repo new adds it, and --private leaves it out: ssh git@git.example.com new --private <name>, forge repo new --private and the imports below.

A private repo can be read by its owner, its collaborators and the names in <repo>.git/forge/readers: over ssh, on the web once they sign in (see Private pages), and over HTTPS with a token. To anyone else it isn't there: a clone, fetch, archive, push or ssh command gets the same answer, byte for byte, as for a repo that doesn't exist, however the path is spelled, and so does every page of it. It has no entry on the index, owner pages or repos.json. Its pages and live events go to its readers only, and the pages mirror never sees it. Its sites and previews aren't served unless git config forge.publicsites true. Its releases are, to its readers only, next to its pages and over ssh (see Releases). Pushes record reviews and print their links, which work for its readers, and forge reviews reads them from the review logs (without CI). The secret check still runs, since a secret stays in the history when the repo goes public later; Jev isn't asked about private repos.

ssh git@git.example.com readers cat/notes add fox   # rm to take it back; no argument lists them
ssh git@git.example.com who cat/notes               # who reads it, and who may push it
ssh git@git.example.com private cat/greet
ssh git@git.example.com public cat/notes [--force]
ssh git@git.example.com ls                            # yours, and the private ones you may read

The owner and collaborators flip a repo, the way Forgejo does. private removes the flag, the repo's public pages, its entries on the index, owner pages and repos.json, and its served sites, previews and their hostnames. It moves its release downloads into the private root (and their files into the private store, see Releases) and builds its pages again there. Then it starts a mirror sync within 30 seconds, which deletes them from the bucket. Clones, caches and copies people already have can't be recalled. public first runs the secret check over the whole history: every commit on a branch or tag, and the old review revisions that review pages show, not only what a push adds. If it finds something, the repo stays private unless you pass --force. Then it adds the flag, removes its private pages, links the sites, previews and release downloads again from their refs, and rebuilds the pages into www. A flag added or removed by hand moves the pages at the repo's next build, and forge build drops any left in the wrong place.

Private pages

A private repo's pages are built like any repo's, into .forge/private/www instead of www, with the same layout: pages, search index, code.db, release downloads, insights exports. The build writes them in .forge/private/build.<pid> and swaps them in from there, so they are never outside .forge/private, which only git can read, even mid-build. They link the same hashed assets, which the build puts in www/assets (they are the forge's scripts and styles, nothing of the repo), and their header marks the repo private. Caddy serves a file of www when there is one and hands every other path to lib/private-server.ts, so it needs no list of private repos. That server serves a private repo's page to someone who may read it (the rule can_read in bin/forge applies, kept in step with lib/access.ts by a scenario) and is still a user, signed in with a passkey or showing a token, with the headers Caddy gives pages (ETag, 304s, byte ranges, which code search reads code.db with) except Cache-Control: private, no-cache. It serves the repo's release downloads as attachments, as Caddy sends a public repo's. Not signed in, not allowed or a page that isn't there: the same 404, byte for byte, which every missing page of the forge gets, public or not. /private/ lists the private repos you can read.

Web credentials live in one SQLite file, .forge/identity.db (WAL mode, only git reads it), and are made over ssh only, so there are no passwords or emails, and losing them is fixed by running ssh again:

ssh git@git.example.com passkey [name]      # a link that adds a passkey: once, within 10 minutes
ssh git@git.example.com passkeys [rm <id>]
ssh git@git.example.com sessions [rm <id>|--all]
ssh git@git.example.com token [--repo cat/notes] [--ttl 30d] [name]   # printed once
ssh git@git.example.com tokens [rm <id>]
  • Passkeys (SimpleWebAuthn 14). The relying party is the forge's origin, FORGE_BASE_URL, whose host is the RP ID; responses from any other origin are refused. The link carries a one-time code in its fragment, so it reaches no log, and binds the new passkey to your ssh user. Sign-in on /login/ needs no user name: the browser offers the forge's passkeys, which are discoverable, and user verification is required. On Node 24.7 and later (the container) ML-DSA-44 is offered first, so a passkey can be post-quantum. After passkeys rm, the next page you open signed in tells your password manager which passkeys still work (the WebAuthn Signal API), so it stops offering the removed one; it also signs out the sessions that passkey made. The server keeps no challenges until they are used: each one carries its expiry and an HMAC, and is bound to the browser that asked for it by a __Host-forge-challenge cookie that lasts five minutes, so a flood of requests can't push out anyone's sign-in. Each address may ask for options 20 times a minute.
  • Sessions are a __Host-forge cookie (Secure, HttpOnly, SameSite=Lax) that lasts 30 days from its last use. Sign-out is a POST from the forge's page, with a CSRF token. The events server streams a private repo to a page whose session may read it.
  • Tokens are for one repo or for every repo you can read when they are used, for 30 days unless --ttl (at most 365d). Send one as Authorization: Bearer <token>, or as the password of basic auth: git clone https://cat:<token>@git.example.com/cat/notes.git, a credential helper or the token alone as the user name.

identity.db keeps only SHA-256 hashes of cookies, tokens, enrollment and invite codes, and every credential stops working when its user's keys go (forge user rm deletes them too). Its SQL is the plain subset Turso runs as well. HTTPS clones of a repo without git-daemon-export-ok go through Caddy's forward_auth: the private server answers 401 without a token (so git sends one) and, with one, 200 or the same 404 for a private repo and a missing one; only then does git http-backend run, with GIT_HTTP_EXPORT_ALL.

Reviews

post-receive appends each push of a non-default branch to <repo>.git/forge/reviews/<id> and pins the tip under refs/forge/keep/ so old revisions survive. The generator works out the rest from refs:

  • Stack: the parent is the nearest other review whose tip lies inside this branch.
  • Layer: the commits between the parent (or main) and the tip.
  • Status: merged if the tip is in main; closed if the branch was deleted; draft if the branch is wip/... or pushed with -o draft.

Push options set metadata: forge push -t "Title" -d "Description" --draft --target <branch>. The description is one line (\n makes a line break, and so does a real newline in the argument) and renders as markdown under the title, sanitized like a README. Each push that creates or updates a review prints its link, review #7: https://git.example.com/cat/greet/reviews/7/ (on #6 cat/flags), from FORGE_BASE_URL (just the path if that isn't set).

Comments and approvals

Both are written from the terminal over the forge's ssh account, or from the review page once you are signed in (see On the web below), and the pages rebuild in the background, so an open review page updates live.

forge reviews                         # open and draft reviews: CI, approvals
forge checkout 7                      # fetch the branch, switch to a tracking branch
forge comment -m "Looks good"         # the current branch's review
forge comment 7 greet.sh:12           # a line comment; the message from $EDITOR or stdin
forge approve 7
forge land 7                          # what the server gates check, then fast-forward
ssh git@git.example.com land cat/greet 7   # the forge lands it itself; no clone needed
  • Comments are files, <repo>.git/forge/comments/<review>/<n>: header lines (time, user, the review's tip and revision, and for a line comment the path, the new-side line number and that line's text), a blank line, then the markdown message (at most 32 KiB). Any registered user can comment, or on a private repo anyone who can read it. Once the review gets a new revision, older comments show as outdated and still quote the line they were about. Line comments on the current revision also appear under their line in the combined diff. A comment made from a checkout belongs to the revision checked out, and one from a page to the revision the page shows.
  • Approvals are lines in the review log, approve <ts> <user> <sha> <tree>. Like CI results they are keyed by tree, so rewording or reordering commits keeps them; if the tree changes, the page shows "approved r1, changed since". Authors can't approve their own review.
  • Gate: git config forge.requireapproval true in a bare repo means a push that moves the default branch needs approvals of the new tip's tree from someone other than that review's author: one, or what the approval rules (below) say. It works alongside forge.requireci.
  • Landing: forge land [<id>|<branch>] checks that every commit between the target and the branch has a verified passing attestation and, if the repo requires it, the approvals the rules ask for (--force turns the local checks into warnings; the server's gates still apply). It refuses anything that isn't a fast-forward, pushes the branch to its target (the target of the stack's lowest layer), then deletes the landed branch and the stack layers below it.
  • Landing on the server: ssh git@host land <owner>/<repo> <id|branch>, or forge land --server from a clone, lands a review without fetching anything, for agents or a phone. The forge resolves the review's tip and target from the review logs and refs, then pushes inside itself, from the bare repo to the bare repo, as you: the tip to the target and deletions of the branch and its landed layers, in one atomic push with leases. So pre-receive applies exactly the rules any push meets (who may move the default branch, forge.requireci, forge.requireapproval, fast-forward only unless forge.allowforce), and post-receive does what it does after one: the review logs record the deleted branches, their previews go, an event is appended and the pages rebuild. If it refuses, nothing changes.

forge reviews, checkout and land read /<owner>/<repo>/reviews.json, which the page build writes; ssh ... land reads the review logs, so it can land a review pushed a moment ago. The ssh commands live in git-shell-commands/; on an existing server, run forge server init again to add them (the container does that on every boot).

Approval rules

.forge/approvals on the default branch says how much approval a change to it needs, by the files the change touches (lib/rules.ts):

# paths                          rule
.forge/ bin/forge                2
lib/identity.ts lib/login.ts     1 from fox
lib/                             1 + agent
*.md guide/ wiki/                none
@mechanical                      none
*                                1
  • Matching. Each changed file takes the first line it matches. A pattern without a slash matches a file's name in any directory; with one, from the top. * is any run of characters but /, ** any run at all, ? one character, and a trailing / everything under that directory. @mechanical matches files Jev judged mechanical (the ones review pages fold), from its stored answers; a file with none isn't. A file no line matches needs 1, as it would without the file. A rename counts both its paths, so moving a file out of a guarded directory needs that rule.
  • Rules. none, or a number of approvals, optionally from a comma-separated list of people (that many of them), optionally + agent: a verified agent review of the tree with no blocking finding open (see Agent reviews). A change needs every rule its files took. A line the parser can't read (or 3 from fox, which can never be met) is an error the gate reports with its line, and holds.
  • Whose rules. The default branch's, as they were before the push: a review can't loosen its own, so a change to .forge/approvals lands under the old ones (and .forge/ above makes that need two).
  • Where they show. The gate (pre-receive, with forge.requireapproval) prints what is missing. forge land asks the forge (ssh ... approvals) for the same verdict before it pushes. A live review's page starts with a to land panel: CI, each rule its files take, who approved, and its latest agent review. Without forge.requireapproval the panel says approval is optional there.

On the web

Signed in with a passkey (see Private pages), a review page ends in a comment box and an Approve button. The combined diff puts a + beside the line under the pointer, which opens a box for a comment on that line. Pages are static files, the same for everyone, so these start hidden and do nothing without JavaScript: a script asks /login/whoami (never cached) who is signed in and shows them to a user, without Approve for the review's author. A page posts the tip it shows, like forge comment from a checkout: if the review has moved on since it was built, a comment goes to the revision you read, and an approval is refused. Until the rebuilt page arrives, a sent comment shows as pending, as typed; the build renders and sanitizes its markdown, as for any other.

The posts go to the private server, POST /<owner>/<repo>/reviews/<id>/comment (fields body, at, line as <path>:<n>) and .../approve (at), as a form or JSON, and it runs forge shell-cmd comment|approve as you with the message on stdin, the command ssh runs. So a comment file, an approve line, the rules and the messages are the same either way, byte for byte. A session's post must come from the forge's own origin and carry its CSRF token (the page gets it from /login/whoami); a token works as Authorization: Bearer without one, for curl, and a token for one repo only there. Basic auth doesn't post, since browsers send it by themselves. A private repo you can't read answers 404, the same as a missing one. Each user may post 20 times a minute. Caddy sends these posts to the private server before it looks in www, so public reviews, whose pages are plain files, take them too.

curl -H "Authorization: Bearer $token" -d 'body=Looks good' -d 'line=greet.sh:12' \
  https://git.example.com/cat/greet/reviews/7/comment

CI attestations, and what they prove

forge attest [--stack] checks out each commit into a temporary worktree and runs .forge/ci. If a flake.nix exists, it runs inside nix develop. It then writes a statement like this and signs it with ssh-keygen -Y sign -n forge-ci:

forge-attestation 1
commit 2aa8af0...
tree 58945d0...
script .forge/ci 99a0d5e...
result pass
exit 0
duration 41s
runner cat@laptop
system Darwin 25.5.0 arm64
env host
log sha256:... 120 lines
|ok   greet --name fox
-----BEGIN SSH SIGNATURE-----

runner is your forge name and a made-up name for the machine: git config forge.user, else the name allowed_signers gives your signing key; and git config forge.machine, else two words and two hex digits picked once from a hash of the hostname and a random salt, then kept in your global git config. Never the hostname or your local account, since the statement is published; site deploys and releases name their builder the same way (Built-By). Pages don't show either: they show who signed.

The statement is appended to refs/notes/ci, and forge push merges and pushes those notes with the branch. Results are keyed by tree, so a reworded or reordered commit with an identical tree keeps its result without re-running.

What a verified attestation proves: the holder of a registered key claims that this exact tree passed this exact script on a machine they describe. Tampering is caught; changing result fail to result pass breaks the signature. A dishonest key holder is not caught. To make the claim stronger:

  • keep the key in hardware (a Secure Enclave key via Secretive, or a FIDO sk- key) so it is bound to one device;
  • use Nix so the environment is pinned by flake.lock, which is recorded in the statement;
  • have a second person run forge attest on their machine. Attestations from several signers stack up on the same tree.

.forge/ci can also report numbers about the commit: bundle size, test count, benchmark times. It writes them to the file named by $FORGE_METRICS, a line each, as a name, a number and an optional unit:

echo "bundle_kb $(du -k dist/app.js | cut -f1) KB" >>"$FORGE_METRICS"
echo "tests $(grep -c '^ok' test.log)" >>"$FORGE_METRICS"

They go into the statement as metric bundle_kb 412 KB lines, before the log, so they are signed with the result. A name is lowercase letters, digits and _ . -, starting with a letter; a unit is up to 16 letters, % or /. forge attest leaves out, with a warning, a line that isn't one, a name seen before and anything past the 100th. Like the result, they are what the signer's machine said. The repo's insights page charts each one by commit on the default branch (see Insights pages).

To require a verified pass before main moves, set git config forge.requireci tip (or all, for every new commit) in the bare repo.

Agent reviews

forge critique [<id>|<branch>] reviews a review with an agent, on your machine, the way forge attest runs CI (lib/critique.ts):

  1. It reads the reviewer, .forge/review, and its rules, .forge/review.md, from the review's target branch, so a change can't bring its own reviewer or loosen the rules.
  2. It runs the reviewer in a clean worktree of the review's pushed tip, with the diff on stdin. The first time that is everything since the target. After that, while the last reviewed tip is still in the branch, only what changed since. Files Jev judged mechanical are left out.
  3. It posts each finding as a comment, with agent <model> and severity blocking|note in its header: the page marks them agent and blocking. A finding on a file the review doesn't change, or past a file's end, goes on the whole review or the file's first line, with a warning.
  4. It signs a statement into refs/notes/review on the tip, with ssh-keygen -Y sign -n forge-review, and pushes it: what it reviewed, which reviewer and rules (by blob), the model, how many findings and how many blocking ones are still open, carried over from earlier runs. It exits 1 while any is.
forge-review 1
review cat/greet#7
commit 2aa8af0...
tree 58945d0...
since 3c1e2aa...
scope incremental
script .forge/review 99a0d5e...
rules .forge/review.md 1b2c3d4...
model claude-opus-5-5
reviewer cat@gentle-yak-2e
started 2026-11-04T10:00:00Z
findings 1
open 0
|{"id":"2aa8af0-1","severity":"note","path":"lib/a.ts","line":12,"body":"..."}
|{"resolved":"9f8e7d6-1","by":"agent"}
-----BEGIN SSH SIGNATURE-----

The reviewer is any program. It gets the diff on stdin, and FORGE_REVIEW_ID, FORGE_REVIEW_BASE and FORGE_REVIEW_TIP (the commits the diff is between), FORGE_REVIEW_SCOPE (full or incremental), FORGE_REVIEW_RULES (a file with .forge/review.md, or empty), FORGE_REVIEW_OPEN (the open blocking findings, a JSON object a line), FORGE_REVIEW_SKIPPED (the paths left out) and FORGE_REVIEW_FIX (1 with --fix). It prints JSON objects, a line each; anything else is ignored:

{"model":"claude-opus-5-5"}
{"severity":"blocking","path":"lib/a.ts","line":12,"body":"markdown"}
{"severity":"note","body":"about the whole change"}
{"resolved":"9f8e7d6-1"}

At most 50 findings a run, each up to 4,000 characters. resolved names an open finding the change dealt with. A reviewer with Claude Code, for one:

#!/bin/sh
# .forge/review: Claude Code, headless and read-only, in the worktree of the tip.
diff=$(cat)
{
	echo "Review this change. The project's rules:"
	cat "${FORGE_REVIEW_RULES:-/dev/null}"
	echo "Findings still open from earlier runs (print {\"resolved\":id} for any the change fixes):"
	cat "$FORGE_REVIEW_OPEN"
	echo "Answer with JSON objects only, one a line:"
	echo '{"severity":"blocking"|"note","path":"...","line":N,"body":"markdown"}'
	echo "The diff:"
	printf '%s\n' "$diff"
} | claude -p --model claude-opus-5-5 --allowedTools Read Grep Glob
echo '{"model":"claude-opus-5-5"}'
  • Again on the same tree does nothing but say what is open; --full reviews everything again.
  • Dismissing. --resolve <id>,... signs a statement without those findings, naming you as who resolved them, without running the reviewer (on the same tree) or along with it.
  • Fixes. With --fix the reviewer may change files in the worktree; what it changed becomes a commit on <you>/fix-<id>, pushed as a review stacked on this one. The review itself is untouched.
  • What it proves. Like an attestation: the key holder claims this reviewer, with these rules, said this about this tree. The review page's to land panel shows the latest one, and an approval rule's + agent wants one of the tip's tree, verified, with open 0. Keys may sign them once forge keys sync (which forge server init and the container's boot run) writes forge-review into allowed_signers.

Sites

A site deploy is a commit on refs/sites/<name> whose tree is the built output, so git provides what nzip builds by hand:

  • content-addressed storage and dedup across deploys;
  • history, with revert by pushing an older deploy;
  • provenance through commit trailers (Source-Commit, Build, Built-By), plus an SSH signature when gpg.format=ssh is set.

Declare sites in .forge/sites:

# name  output  build command
docs    build   npm ci && npm run build

forge site deploy docs builds from a clean worktree of HEAD and pushes the result. On main it goes to refs/sites/docs. On any other branch it becomes that review's preview, refs/site-previews/docs/<branch>. The build gets FORGE_SITE_BASE, the path it will be served from, so Docusaurus can use baseUrl: process.env.FORGE_SITE_BASE. forge site push <dir> <name> publishes a directory you already have.

post-receive extracts each tree once into .forge/store/<tree>/, then points www/sites/<owner>/<repo>/<name> at it with a symlink. Only the owner can deploy refs/sites/*.

Deleting a branch deletes its previews: post-receive removes refs/site-previews/*/<branch>, the served link and the preview's hostname, so the sites and review pages stop listing it. Previews of merged branches stay until their branch is deleted. Their extracted files stay in the store until forge gc (below).

Releases

A release is a commit on refs/releases/<tag> whose tree is the release's files plus SHA256SUMS and SHA256SUMS.sig, the way a site deploy is a commit whose tree is the built output.

git tag v1.0
forge release publish v1.0 dist/greet-linux-amd64 dist/greet-1.0.tar.gz
forge release verify --url https://git.example.com/cat/greet/releases/download/v1.0/ \
    ~/Downloads/greet-linux-amd64                    # SHA256SUMS and its .sig next to it
forge release ls                                     # tag, file, size, sha256
forge release rm v1.0

publish writes SHA256SUMS in sha256sum's format, so sha256sum -c reads it, and signs it for the release with ssh-keygen -Y sign -n forge-release, using the key that signs CI results. What it signs is SHA256SUMS behind a short header naming the repo and tag:

forge-release 1
repo cat/greet
tag v1.0
<the bytes of SHA256SUMS>

so the signature holds for that release only: v1.0's files can't pass for v1.1's, nor a copy in another repo for the original. The signature is SHA256SUMS.sig. Then it makes a commit with trailers Tag, Source-Commit (the tag's commit) and Built-By, signed like a site deploy, and pushes the commit, the tag and your CI notes in one atomic push. The commit has no parent, so publishing under the tag again replaces the files and the old ones aren't kept reachable.

verify checks files against SHA256SUMS, and its signature for the release they say they are from: --repo and --tag, else --url, else the path they were saved under if it is a download path (wget -x), else the remote for the repo. It says which release it verified (ok greet-linux-amd64: matches SHA256SUMS of cat/greet v1.0, signed by cat). The forge's keys come from --signers <file>, or ssh <host> signers with --host, forge.allowedsigners, forge.host or the remote.

pre-receive takes a release from the owner and collaborators only, like a tag, and only if:

  • the tag exists once the push lands (it may come in the same push), and the trailers name it and its commit;
  • the tree is plain files at the top level, named with letters, digits and . _ + -, none over git config forge.releasemaxsize (256m unless set; git's k, m and g work);
  • SHA256SUMS lists every file once with its sha256, and nothing else;
  • SHA256SUMS.sig is a forge-release signature of it for this repo and the ref's tag, by the key of the repo's owner or a collaborator: another registered user's won't do, even pushed by the owner;
  • with git config forge.releaserequireci true, the tag's tree has a verified passing attestation, as forge.requireci asks of main (forge attest v1.0 before publishing).

The secret check skips releases. It reads branches and tags, and a release's source is its tag.

post-receive extracts the tree once into .forge/store/<tree>/, as for a site (a private repo's into a store of its own, below), and hard-links its files into /<owner>/<repo>/releases/download/<tag>/ (a / in the tag becomes ~). latest/ next to them holds the files of the newest tag that has a release, so /cat/greet/releases/download/latest/greet-linux-amd64 keeps working from one release to the next. Hard links, not a symlink as for a site: rclone skips symlinks, and the mirror copies these. The page build links them into the repo's new pages before it swaps them in. Caddy serves them uncompressed. It sends no-cache, since a tag can be published again, and Content-Disposition: attachment, since they are anyone's files on the forge's own origin. Deleting the ref removes them.

Clones and fetches don't see refs/releases (uploadpack.hideRefs), so nobody downloads binaries with the source; pushes do, so publish can replace and rm delete them. Blobs over 16M are stored without deltas (core.bigFileThreshold). The releases page lists each tag's files, with size and sha256, whether SHA256SUMS.sig verifies and whose key signed it, and the CI result of the tag's commit.

A private repo's releases go where its pages are, the private root (.forge/private/www), with the same layout, never www, so the mirror doesn't see them and forge.publicsites doesn't change that. The private server gives them to its readers, signed in or showing a token (see Private pages) as Caddy gives a public repo's (attachments, uncompressed, byte ranges) but Cache-Control: private, no-cache, and anyone else gets the 404 of a file that isn't there.

Their files aren't extracted into .forge/store either, but into .forge/private/store/<tree>/, and linked from there. .forge/store is readable by the caddy user, which serves sites from it; nothing serves a release tree from a store, but a private repo's files shouldn't sit where anything but the forge can read them, so they live under .forge/private, which only git can read (mode 700). Both stores are under .forge, so the downloads stay hard links on one filesystem. A repo that goes private takes its downloads to the private root at once, linked from the private store (extracted there), and one that goes public brings them back to www, linked from .forge/store (extracted there again); forge gc frees the trees left in the store it no longer uses.

Readers can also get them over ssh, and to anyone else these commands answer as for a missing repo:

curl -fO -u fox:<token> https://git.example.com/cat/notes/releases/download/v1.0/tool   # token --repo cat/notes
ssh git@git.example.com release ls cat/notes
ssh git@git.example.com release get cat/notes v1.0 tool > tool   # also SHA256SUMS, SHA256SUMS.sig
forge release verify --repo cat/notes --tag v1.0 tool

A verified download proves what a verified attestation does, about these bytes and the release they name: the holder of a key the forge knows vouched for this SHA256SUMS as that repo's tag. The forge took it only from an owner's or collaborator's key; verify checks the key against the forge's keys file, and leaves who administers the repo to that.

Search and highlighting

/, or the search button in the header, opens a search over the repo's commits (subject, body, sha, author), reviews (number, title, branch, author), wiki pages, file paths and the contents of the files on the default branch. On the index and owner pages it searches repo names and descriptions. There is no server: each build writes /<owner>/<repo>/search.json (and /repos.json), which the page fetches the first time the search opens, and matching runs in the browser. Commit bodies are cut to 240 characters and wiki pages to 2,000, to keep the index small. t still finds files by name on the current branch.

Every section reads a query the same way (site/assets/query.ts): terms split on whitespace, in any order, and a quoted phrase ("fix the search") is one term, spaces and all, that must appear as written, except that a run of whitespace in it matches any run of whitespace: "fix the" finds fix the, and "fix the" finds fix the. Every term must match, ignoring case, and results mark each match, a phrase as a whole. A quote opens a phrase only at the start of a term (in it's" it is just a character). An unclosed quote runs to the end of the query as a phrase, so a phrase narrows as you type it, and empty quotes are no term. So fix search finds "Search fix for the box" and "fix the search" doesn't. node test/search-phrases.ts runs the same code in Node, for test/scenarios/search-phrases.sh. Pages load search.ts as a module; it and codesearch.ts import ./query.ts, which a build rewrites to that file's content-hashed name.

File contents are in a second index per repo, /<owner>/<repo>/code.db, which search.json points at ("code"). It is a SQLite database with an FTS5 trigram index, and the browser never downloads it whole: the official sqlite-wasm build opens it through a small read-only VFS (site/assets/codesearch.ts) whose reads are HTTP range requests, so a query fetches only the pages it touches. Any static server that honors Range works: Caddy, demo/serve.ts and the pages mirror on R2 through the edge, whose fallback passes the header on.

Once the query has a word of three characters (a trigram can't match less), it goes to a worker, and a Code section lists each line that has every word, ignoring case, as path:line, linked to that line of the file's page (the source page for markdown). Its words are the terms the other sections read, so a quoted phrase ("import fs from") is one literal string, spaces and all, in which a run of whitespace matches any run, tabs and alignment too; a word inside another goes, since a line with the longer one has it. Words of three characters or more are looked up in the index; shorter ones only filter the lines it finds. A phrase is looked up by its pieces between whitespace, those of three characters or more, since no trigram may span whitespace that can be any length: all of them within NEAR distance of the phrase's length plus 32 characters per gap, and the lines found are checked with any run of whitespace for a space. A phrase without such a piece ("x = 1") is looked up as written, so it finds only single-spaced lines. It loads nothing until then: the first code query starts the worker, which loads sqlite3.wasm and sqlite-wasm's script, both content-hashed assets. Results come in path order; the first look reads 12 chunks, and "more" reads up to 100. The index finds chunks with every word, and their words can be on different lines, so a first look that finds chunks but no line reads on, once, up to 100.

What goes in: every file on the default branch except binary files (a NUL in the first 8,000 bytes, as git decides), files over the 256K highlight cap, lockfiles (package-lock.json, yarn.lock, go.sum, Cargo.lock, ...), minified files and source maps, images, symlinks and submodules. Files are stored as chunks of eight lines (file, chunk and the FTS5 table code over the chunks' text, with detail=full, which substring queries need); the browser finds the line inside a chunk itself. The build writes 16K pages, merges the index into one segment (optimize) and VACUUMs, so every table is its root page over its leaves, laid out in order.

Each round trip counts, since SQLite reads one page at a time and waits for it. So the worker names most of a query's pages before SQLite asks: it opens the file by fetching both ends at once (the schema, every table's root and the file list are at the front; FTS5's term index is at the back), looks up where each term's doclist lives in that term index and fetches those pages together, then fetches the pages of the matching chunks together. A query looks up every third trigram of each word (and the last), within NEAR distance, rather than all of them, and checks the lines for the exact words. The words of a query are looked up in the same round trips as one word. If the index is rebuilt while a page is open, the next query notices the new ETag and reopens it.

Measured with node test/code-search-bench.ts bench <state>, which serves a built forge with demo/serve.ts and runs the same worker code in Node:

Repo Indexed text code.db Cold query Next queries
this repo, 180 files 1.0M 4.2M 3 round trips, 5 to 13 requests, 240K to 432K 1 or 2 round trips, up to 160K
the same, two or three words (Cache-Control private, fs readFileSync json) 3 round trips, 9 to 16 requests, 416K to 480K 1 or 2 round trips, up to 128K
an 11M tree (eleventy, markdown-it, highlight.js, typescript's tsc.js and more), 662 files 4.4M 16.1M 3 round trips, 6 to 12 requests, 290K to 400K 1 or 2 round trips, 80K to 160K

Of a cold query, 128K is the file's head and 64K its tail, read once per page. An index under 256K is read whole on the first query. The first code search also costs the assets, once and then cached for good: sqlite3.wasm 849K (394K gzipped) and sqlite-wasm's script 628K (171K gzipped). Building the index took about 0.3s for this repo and 1.5 to 2s for the 11M tree.

The index is cached in .forge/cache/code/<owner>/<repo>/ by the default branch's tree id and hard-linked into the pages, so a push that leaves that tree alone (a review branch, an empty commit) keeps the very same file. A new tree starts from the previous index and only deletes and inserts the files whose blob changed: about 0.35s for one changed file in the 11M tree. Don't compress code.db in transit: a range of a compressed response is not a range of the file. forge server caddyfile compresses .wasm and scripts but not the index.

Files, diffs and fenced code in markdown are highlighted at build time with highlight.js, so pages need no script for it. The language comes from the file name, or the #! line of an extensionless script; unknown types and files over 256K stay plain rather than being guessed at. Each diff hunk's old and new sides are highlighted as whole text, so strings and comments that span lines mostly come out right. Highlighted files are cached in .forge/cache/highlight/ by blob id, so pushing a new branch doesn't redo files that other branches already have. Token colors are CSS variables (--tok-*) in style.css.

Mermaid diagrams in markdown (```mermaid fences) are drawn at build time too, as inline SVG, by Pele, so pages ship no diagram script. style.css colors them through Pele's --pele-* variables, which follow light and dark mode without drawing them again. There is no browser at build time to measure text in, so labels are sized from a table of the sans font's character widths in lib/diagrams.ts (lib/text-widths.sh makes it from the font). One wider than a phone is drawn a second time, laid out for a phone's width (often top to bottom), and the stylesheet shows that one in a narrow column. A diagram that doesn't parse shows its source and the reason instead. Markdown is anyone's who can push a repo: Pele escapes a diagram's text and filters its links and styles. Then the SVG goes through a sanitizer of its own that keeps only the shapes, text and links Pele draws with (no images, no url()), while raw SVG in the markdown itself is still dropped.

The lake and insights

The forge's facts are append-only, but they don't last: events.jsonl is cut to its last 1,000 lines once it passes 1 MB, metrics.jsonl isn't backed up, and review logs and CI notes are spread over the repos. forge lake gathers them into a DuckLake (DuckDB 1.5.6 through @duckdb/node-api): its catalog is a SQLite file, .forge/lake/catalog.sqlite, and its data Parquet files in .forge/lake/data, or in a bucket (below).

forge lake                     # add what is new; builds run it by themselves
forge lake --dry-run           # say what it would add
forge lake query "SELECT repo, count(*) FROM events WHERE type = 'push' GROUP BY repo"
forge metrics                  # build history, from the lake as well as metrics.jsonl

Tables: events (pushes, comments, approvals, builds), builds and build_repos (each line of metrics.jsonl), reviews (each line of a review log: created, revisions, approvals, deleted), review_ends (landed or closed, and when) and ci (each attestation: commit, tree, result, duration, runner, signer; ci.parquet leaves the runner out), ci_metrics (each metric an attestation reported, with its id) and commits (each commit on a default branch: time, author, subject and tree). A review counts as landed when its branch is deleted with its tip in the default branch, as forge land leaves it; one whose tip reaches the default branch while the branch stays counts from when the lake saw it.

  • Incremental. Each source remembers how far it was read, in the same transaction as its rows, so a run that dies adds nothing and a second run adds nothing new. For events.jsonl that is a byte offset plus the file's inode, a hash of its first line and hashes of the last lines read: when the build cuts the file into a new one, the next run finds the last line it read and goes on from there. If the cut went past it (more than 1,000 lines came in between runs), it says so, and only those lines are missing. Review logs are read again whole when they change, CI notes when their ref moves.
  • One writer. A DuckDB file can't be written by two processes, and DuckLake over SQLite turned away half of 120 concurrent inserts with "database is locked". So forge lake holds .forge/lake.lock; a second one waits for it (FORGE_LAKE_WAIT seconds, 300) or gives up and says so. Builds start it the way they start the mirror, FORGE_LAKE_DELAY seconds (10) after the last one; FORGE_LAKE=0 stops that.
  • New columns. A table that gains a column in a newer forge gets it when the lake is next opened to write, empty in the rows it had.
  • Small writes. DuckLake keeps inserts under 10 rows in the catalog rather than writing a Parquet file for each; about once a day (FORGE_LAKE_CHECKPOINT hours) a run checkpoints, which writes them out and merges small files.
  • Read-only queries. forge lake query attaches the lake read-only, runs only SELECT, EXPLAIN and PRAGMA statements, and can't touch files other than the lake's data. --json and --csv change the output.
  • Offline. DuckDB downloads extensions at first use. The image installs them at build time (node lib/lake.ts install, which checks they are the versions this was tried with), so the container never does. Elsewhere they go to ~/.cache/duckdb/extensions the first time.

Data on R2 instead of the volume: put FORGE_LAKE_DATA=s3://<bucket>/lake and FORGE_LAKE_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com in forge.env (the FORGE_BACKUP_* keys are used unless FORGE_LAKE_KEY_ID and FORGE_LAKE_SECRET are set). Set it before the first run; the catalog stays on the volume either way. This was tried against rclone serve s3, not R2 itself.

Insights pages

/insights/ covers the forge, and each repo has /<owner>/<repo>/insights/ (the Insights tab, i): stat tiles, then build time, pushes, time to land, revisions per review, CI results and CI duration. A repo whose CI reports metrics also gets a chart for each, its value at each commit on the default branch (the latest 400): a commit takes the value from an attestation of its tree, so a reworded commit keeps it, and a verified, passing one comes first. The forge's page has none, since a metric's name means something only in its repo. The pages are static and built once; their script (insights.js, on these pages only) fetches insights.json and draws the charts as SVG, light and dark, with a tooltip on hover and keyboard focus and a table of each chart's numbers. After each run, forge lake writes that JSON and the rows behind it as Parquet (events, builds, reviews, ci, ci_metrics) into .forge/lake/export and straight into the pages, so they update without a build, and the mirror picks them up. A repo's exports sit in its page directory, which builds keep. DuckDB reads the Parquet files over HTTP in byte ranges:

duckdb -c "SELECT result, count(*) FROM 'https://src.rest/insights/ci.parquet' GROUP BY result"

Private repos are in no public insights: the forge's exports leave their facts out, and www has no page of theirs. A private repo's own insights page and exports are in its pages in the private root, which forge lake keeps fresh the same way, for its readers only (see Private pages); the mirror never sees them. Making a repo private removes the forge's exports from www at once; the next run writes them without it. Making it public again publishes its facts from while it was private as well, as its whole history becomes public then. The builds exports give a build's reason as push or other, since the others would say when a repo went private or away.

The page has no query console. DuckDB-Wasm could run SQL over those files in the browser, but its build is 34.2 MiB (7.7 MiB gzipped, 5.1 MiB with brotli) to query exports of a few kilobytes, and its npm package is 149 MB.

What it costs, measured on an M-series Mac and in the image (linux/arm64):

@duckdb/node-api the native DuckDB library: 61M in the image (71M for x64; npm also fetches the musl build, which the image drops)
Extensions 80M in the image: ducklake 30.7M, sqlite 29.6M, httpfs 18.9M
Image about 143M more, all told
Disk the demo's lake: 225K. 20,000 events and 5,000 builds (3.3M of JSON lines): 406K, a 180K catalog and Parquet
Time 20,000 events and 5,000 builds in 0.18s; a run after a push 0.1 to 0.5s, plus about half a second to start Node and load DuckDB; the demo's first run 0.7s
Exports the demo's: insights.json 1.5K, each Parquet file 2 to 4K; with 300 builds, insights.json 8.5K and builds.parquet 9K

Hosting

The forge is one container (Dockerfile, on Node 24 LTS): sshd for git, Caddy for pages, sites and HTTPS clones (with fcgiwrap for git http-backend), the events, private and pages servers, litestream and tailcat. server/container/start.sh starts them all. All state is in /srv/git, a volume. Caddy, rclone, litestream and tailcat are pinned releases the Dockerfile builds from source; the rest comes from Debian. Caddy is on the edge's line, 2.11, where Debian has 2.6.

Home machine behind a public edge (how src.rest runs)

internet ── :22, :443 ──> Fly edge (deploy/edge) ── tailcat (WireGuard) ──> forge container on slate
                          TLS ends here              accepts only the       publishes no ports;
                                                     edge's client key      the host stays closed

tailcat gives the edge and the container a point-to-point WireGuard tunnel using Tailscale's data plane without a tailnet: no accounts, no control plane, nothing on your own tailnet. The container's tailcat server keeps its key on the volume (stable address) and only accepts the edge's client key (FORGE_TAILCAT_ALLOW). The edge is stateless; its key and the container's address are Fly secrets (TAILCAT_CLIENT_KEY, FORGE_TAILCAT_ADDR).

On the edge, Caddy takes the public HTTP port and passes everything through the tunnel. When slate doesn't answer (the tunnel or the box is down, a timeout, or a 502, 503 or 504), Caddy serves GET and HEAD requests for the forge's own pages from the pages mirror instead, with X-Forge-Offline: mirror and Cache-Control: no-store. A page the copy hasn't got, one it leaves out like a file's, or one that isn't there at all, is a 404: the forge's not-found page, saying the forge is offline and the page isn't in its offline copy. Sites, clones, live updates and pushes need slate; they get a 503, or for ssh no connection, until it's back. Caddy checks slate every 10 seconds, so only the first request after it goes away waits. deploy/edge/fly.toml sets FORGE_DOMAIN (only that host falls back) and FORGE_MIRROR_URL, the mirror's public URL; without it there is no fallback. With the Fly secret FORGE_MIRROR_TOKEN set, the edge sends it to the bucket as X-Forge-Mirror-Token, so a rule in front of the bucket can refuse requests without it.

On slate, everything lives in /srv/forge (src/, data/, forge.env):

deploy/slate/deploy.sh          # ship HEAD (git archive), docker compose up -d --build
ssh slate docker exec forge forge-admin user add fox 'ssh-ed25519 AAAA...'
ssh slate docker exec forge forge-admin user invite fox --repo cat/notes --as collaborator   # a link instead
ssh slate docker exec forge forge-admin users

deploy.sh [--force] [<commit>] ships a commit, HEAD by default, exported with git archive: uncommitted edits in the checkout stay behind. It wants a verified passing CI attestation for it (forge verify), as main on the forge does with forge.requireci, and --force ships one without. The commit running on slate is in /srv/forge/src/DEPLOYED.

The edge (deploy/edge) needs a dedicated IPv4 on Fly, because raw TCP on port 22 doesn't work on shared addresses. DNS for src.rest and pages.src.rest points at the edge's addresses, DNS-only on Cloudflare (SSH can't go through Cloudflare's proxy). To rotate the edge key: tailcat genkey --client --key=edge.private.json, put its nodekey in /srv/forge/forge.env, its file in the TAILCAT_CLIENT_KEY secret and redeploy both.

A plain VPS instead

server/provision.sh turns a fresh Debian 13 or Ubuntu 24.04 box into the forge directly (git, sshd, Node, Caddy with automatic TLS, and systemd units for the events, private and pages servers). It needs Node 22.18 or newer, which runs the TypeScript sources as they are. Node 24 LTS (24.7 or later) is best, as in the container, for post-quantum passkeys. Those distros ship older ones, so install a current Node first:

rsync -a --exclude node_modules --exclude demo/root --exclude demo/work ./ root@HOST:/opt/forge/
scp ~/.ssh/id_ed25519.pub root@HOST:/tmp/me.pub
ssh root@HOST sh /opt/forge/server/provision.sh git.example.com cat /tmp/me.pub [pages.example.com]

Either way: every key in authorized_keys is forced through forge shell, so administer as root (forge-admin ... in the container, or sudo -u git /opt/forge/bin/forge ... on a VPS), never by logging in as git. Back up with forge backup (Backups and moving hosts). Builds run in the background after each push (log: .forge/build.log); forge build rebuilds everything.

Cleaning up

Every distinct deploy is extracted once into .forge/store/<tree>/ (a private repo's releases into .forge/private/store/<tree>/), and the old tree stays when a newer deploy takes its link. forge gc removes trees in .forge/store that no www/sites or www/previews link points at (hostnames point at those links) and no public repo's refs/releases/* ref names, and trees in .forge/private/store that no private repo's release ref names (release files are hard links, which name no tree), so a repo that flipped leaves nothing in the store it left. It also removes hostnames whose deploy is gone, previews of review branches that no longer exist and the downloads of releases whose ref was deleted without the hooks, in www or the private root. A deploy extracts its tree before it links it, and touches a tree it reuses, so store entries changed in the last 10 minutes are kept (--age <minutes>), which makes gc safe to run while people push. --dry-run lists what it would remove. A release published again leaves its old files in the repo's packs, unreachable: gc prunes nothing. Removed repos leave the trash after FORGE_TRASH_DAYS (14) days.

Then it maintains every repo:

  • git repack -d --geometric=2 --write-midx --write-bitmap-index: packs are merged only until each is at least twice the size of the next, so a run rewrites what is new rather than the whole repo, and loose objects go into the new pack. A multi-pack index with a reachability bitmap serves clones and fetches from all the packs at once. It deletes only packs it rewrote and prunes nothing, so pushes can land meanwhile.
  • the commit-graph, rewritten as one layer with changed-path Bloom filters for every commit (filters it already has are reused);
  • git pack-refs --all, since every review revision pins a loose refs/forge/keep/<sha>.

Between runs, each build after a push adds a small commit-graph layer for the new commits (about 20 ms), so git log -- <path> and blame skip commits that don't touch the path. That happens in the background build, not while the pusher waits.

Run it daily:

# the host's crontab, for the container
17 4 * * * docker exec forge forge-admin gc
# on a VPS: crontab -u git -e
PATH=/usr/local/bin:/usr/bin:/bin
17 4 * * * /opt/forge/bin/forge gc

Settings forge gives every repo, when it is created and again on forge server init (which the container runs on every boot): its hooks, receive.advertisePushOptions true for review metadata, transfer.hideRefs refs/forge, so clones, fetches and pushes don't see the pinned revisions (the build reads them directly, and pushes to them are refused), uploadpack.hideRefs refs/releases, so clones and fetches don't download release binaries but pushes still reach them, core.bigFileThreshold 16m, so big blobs are stored without deltas, and commitGraph.changedPathsVersion 2, Bloom filters that hash non-ASCII paths the same on every platform. The dumb protocol's info/refs still lists refs/forge and refs/releases; git clients use the smart protocol.

Moving repos over

git config --global forge.host git@git.example.com
forge import https://github.com/you/thing.git           # default branch + tags
forge import --branches --desc "..." <any-git-url>      # every branch
forge import --private <url>                            # lands as a private repo

Imports keep the source's default branch and don't turn existing branches into reviews. To move every public repo from a GitHub account:

gh repo list you --limit 200 --json url,description,isPrivate \
  --jq '.[] | select(.isPrivate | not) | [.url, (.description // "")] | @tsv' |
while IFS="$(printf '\t')" read -r url desc; do forge import --desc "$desc" "$url"; done

For the private ones, run the same loop with select(.isPrivate) and forge import --private. The clone runs on your machine, with your GitHub credentials.

Then point existing clones at the forge: git remote set-url origin git@git.example.com:cat/<repo>.git.

A public repo is public everywhere: pages, sites, releases, HTTPS clones and the pages mirror. A private one is read over ssh, on its pages once you sign in and over HTTPS with a token (see Private repos).

Backups and moving hosts

forge backup snapshots the forge with restic into an S3-compatible bucket, Cloudflare R2 for src.rest. Snapshots are encrypted with a password you keep, and deduplicated, so a day's snapshot of a forge that barely changed costs kilobytes. In the container one runs every day at FORGE_BACKUP_AT (UTC, default 03:30), or soon after boot if that day's hasn't run, so a box that was off catches up; a failed one is retried hourly.

What a backup holds

Everything in /srv/git that can't be rebuilt:

  • every bare repo, <owner>/<repo>.git, with its refs (releases too, and so their files, which restic stores once however many backups hold them), its config, its git-daemon-export-ok if it has one (so a private repo comes back private) and its whole forge/ directory: review logs, comments, collaborators, readers and anything else kept there;
  • .forge/keys, .forge/config and .forge/www/hosts/, which hostname each site has;
  • .forge/identity.db, as SQLite copies it (VACUUM INTO): one consistent file, never the live one with its WAL;
  • .hostkeys/, so clients see the same ssh host key, and .tailcat/, so a restored box keeps its tunnel address and the edge finds it;
  • the lake (.forge/lake): the catalog, copied with SQLite's VACUUM INTO while no forge lake runs, never the live file, and checked; and its data files, as hard links. It can't be rebuilt, since the files it read are cut or left out. Data on R2 stays in its bucket: the lake keeps snapshots for 40 days and deletes a file a week after the last one that used it, so a catalog from any kept backup still finds its files;
  • .ssh/, and anything else under /srv/git or .forge/ that isn't left out below, so state added later is backed up by default.

Left out, because a restore rebuilds it or every boot rewrites it: the pages in .forge/www and .forge/private, the extracted sites and releases in .forge/store and private repos' releases in .forge/private/store (all are git refs), .forge/cache, authorized_keys, allowed_signers, git-shell-commands/, hooks, .forge/secrets and .forge/backup.env (both written from forge.env at boot), and logs, locks and scratch files.

Consistent while people push

A backup doesn't read the live repos. It stages a copy first, per repo: the review logs, comments and config; then the refs, read once by git; then the objects, as hard links (free, since git never changes an object file once written), loose ones before packs. Git writes objects before the refs and logs that name them, so everything the copied refs and logs mention is in the copy, even mid-push or mid-repack. Each staged repo must pass git fsck --connectivity-only, or is staged again. Nothing waits on a lock: pushes, comments and forge gc carry on during a backup.

Every run checks itself

After uploading, a backup

  1. forgets and prunes old snapshots: --keep-daily 7 --keep-weekly 5 by default (FORGE_BACKUP_KEEP_DAILY, FORGE_BACKUP_KEEP_WEEKLY; a daily of 0 keeps only the newest snapshot of each week);
  2. runs restic check, and about once a week also reads a fifth of the stored data, a different fifth each week;
  3. restores the new snapshot into a scratch directory and drills it: the same files as staged, a clean full git fsck in every repo and the ref count each live repo had.

A failure goes to .forge/backup.log, makes the command exit non-zero and is recorded in .forge/backup.status. A backup also refuses a forge with no repos, or with under half the repos of the last backup (--force overrides): retention keeps one snapshot a day, and a new box backing itself up before its restore must not push the good ones out.

docker exec forge forge-admin backups            # health, then the snapshots
docker exec forge forge-admin backups --status   # health only; exits 1 if failed, stale (36h) or off
docker exec forge forge-admin backup             # one now
docker exec forge forge-admin backup --dry-run   # stage it and say what would be added

The health line is last good backup: 5h ago ... with the drill and check results, or, loudly, LAST BACKUP FAILED, BACKUPS ARE STALE, NO GOOD BACKUP YET or BACKUPS ARE OFF. The daily run prints it to the container log (docker logs forge | grep backup:). To hear about trouble, run --status from cron on another machine: ssh slate docker exec forge forge-admin backups --status.

identity.db, backed up as it changes

Sign-ins and tokens change all day, so a daily snapshot isn't enough for .forge/identity.db. With backups set up, the container runs forge identity replicate: litestream v0.5 copies each change to the backup bucket within seconds, under litestream/identity (FORGE_LITESTREAM_PATH), with the same keys. Its copy holds no secrets (identity.db has only hashes and public keys), so it isn't encrypted the way restic's is.

docker exec forge forge-admin identity restore                          # as it is now
docker exec forge forge-admin identity restore --at 2026-09-01T10:00:00Z   # as it was then (the last week)

Stop the forge's servers before restoring by hand, since they hold the file open (docker compose stop forge, then docker compose run --rm forge forge-admin identity restore ...); the file it replaces is kept as identity.db.before-restore. A box that boots without identity.db (a new one) takes it from the replica before anything makes an empty one, whose replication would bury the replica's. If the replica can't be read then, nothing replicates until the next boot. forge restore takes whichever of the backup's copy and the replica changed last (every write, deletions too, stamps the file), which is litestream's unless replication had stopped. Without backups set up, identity.db stays local.

The pages mirror

After a build, forge mirror syncs the forge's own pages to a second bucket with rclone, in the background, for the edge to serve while slate is down (see above). It holds public content only, the pages the forge serves anyway, and public repos' release downloads. It never replaces the copy with an empty forge.

The copy only has to keep the forge readable for a while, so it leaves out the pages there is one of per file. They were nine in ten of its files, and since every page links the stylesheet by its hash, each style change made the next sync upload all of them again. What lib/mirror.ts puts in:

In the copy Left out
/, owner pages, /topics/, /insights/ with its exports, /design/, /assets/ (scripts, styles, fonts), repos.json /sites/, /previews/, /hosts/: sites need slate
each repo's home (with its README), log/, commit/, reviews/ and each review, releases/ and their downloads, refs/, sites/, wiki/, insights/, atom.xml, search.json, code.db, reviews.json, branch menus, files/<branch>.txt /<owner>/<repo>/blob/**: file pages
tree/<branch>/, each branch's top-level file list tree/<branch>/<folder>/**: folder pages
the images a page in the copy shows, a README's or a wiki page's the rest of raw/**

A page left out gets the edge's offline 404. Whatever else is in the bucket goes at the next sync: pages from before they were left out (once), and those of a repo that went private or away.

When it syncs. Each build waits FORGE_MIRROR_DELAY seconds (600) and only the newest goes on, so a burst of pushes makes one sync. Then it waits until FORGE_MIRROR_MIN_INTERVAL seconds (3600) have passed since the last sync started; a build meanwhile takes its place, so there is one sync pending at most, and it runs when the interval ends. A sync that started after a build has its pages already, and that build's wait ends there. A repo that goes private, or is removed, makes a sync within 30 seconds whatever the interval, so its pages leave the copy then. forge mirror by hand syncs now. One sync at a time (.forge/mirror.lock); the background ones log to .forge/mirror.log.

A sync is three rclone steps. The pages on the list go by checksum, since a rebuilt page that came out the same has a new mtime and shouldn't be uploaded again; that reads every file. Then what else is in the bucket is deleted, except the downloads. The downloads, which can be big and never change in place, are synced on their own by size and mtime, rclone's default. Their mtime is their release commit's, so an unchanged binary isn't read. Uploads skip rclone's HEAD of each object afterwards (--s3-no-head): a page is one PUT, whose response has its MD5, and the next sync compares checksums with the ETags in the bucket's listing. The edge serves downloads from the copy like any file, as attachments.

Setting it up

What you set up yourself, once:

  1. Two R2 buckets: forge-backup, private, and forge-pages, with public access on, through a custom domain such as mirror.src.rest (best) or its r2.dev URL (rate-limited, fine for an offline copy).

  2. An R2 API token with Object Read & Write on both buckets (or a token each). Note its access key ID and secret, and the account's S3 endpoint, https://<account-id>.r2.cloudflarestorage.com.

  3. A restic password, e.g. openssl rand -hex 32. Put it in your password manager, off slate, before the first backup. Snapshots are encrypted with it: without it nobody can read them, not you and not Cloudflare, and a dead slate would take the only copy with it. Keep all of forge.env there too; a new box needs it.

  4. /srv/forge/forge.env on slate, then deploy/slate/deploy.sh:

    FORGE_BACKUP_REPOSITORY=s3:https://<account-id>.r2.cloudflarestorage.com/forge-backup
    FORGE_BACKUP_PASSWORD=<the restic password>
    FORGE_BACKUP_KEY_ID=<access key id>
    FORGE_BACKUP_SECRET=<secret access key>
    FORGE_MIRROR_S3=https://<account-id>.r2.cloudflarestorage.com/forge-pages
    FORGE_MIRROR_KEY_ID=<access key id>
    FORGE_MIRROR_SECRET=<secret access key>
    # optional: FORGE_BACKUP_AT=03:30 FORGE_BACKUP_KEEP_DAILY=7 FORGE_BACKUP_KEEP_WEEKLY=5 FORGE_MIRROR_DELAY=600 FORGE_MIRROR_MIN_INTERVAL=3600
    # FORGE_LITESTREAM_PATH=litestream/identity
    

    At every boot the container writes these to .forge/backup.env, which only git and root can read.

  5. The edge: set FORGE_MIRROR_URL in deploy/edge/fly.toml to the bucket's public URL (FORGE_DOMAIN is set already), then fly deploy. Its secrets are unchanged: TAILCAT_CLIENT_KEY and FORGE_TAILCAT_ADDR.

  6. Check it: docker exec forge forge-admin backup, then backups and forge-admin mirror for the first sync instead of waiting for a push.

Restoring, or moving to another host

forge restore [<snapshot>|latest] brings a forge back into an empty /srv/git. It restores the files and rebuilds what a backup leaves out: what forge server init and forge keys sync write, hooks in every repo, pinned review revisions, extracted sites and release downloads (a private repo's in the private root, extracted into .forge/private/store again), with the mtimes they had, so the mirror uploads none again. Then it runs forge build. identity.db is the backup's copy or litestream's replica, whichever changed last (see above). It refuses a forge that already has repos. The host's own .forge/config wins over the backup's, since it is written for the domain that host serves, and users from both are kept. forge restore <snapshot> --to <dir> only unpacks the files, to look at them or to copy one repo back by hand. Any machine with restic and the password can read the snapshots too: restic -r <repository> restore latest --target <dir>, then forge restore --from <dir> on the new forge.

A new slate, or any box with Docker. This is also how the forge moves:

# Docker, this repo in /srv/forge/src, forge.env from your password manager
# in /srv/forge/forge.env, an empty /srv/forge/data
cd /srv/forge/src/deploy/slate
docker compose build
docker compose run --rm forge forge-admin restore latest
docker compose up -d
docker exec forge forge-admin backups

Turn the old forge off first, since it has the same tailcat key. The restored key keeps the tunnel address, so the edge finds the new box by itself, with no change on Fly, and serves the offline copy until then. If the forge already booted on the new box, run docker exec forge forge-admin restore latest, then docker compose restart forge, so sshd and tailcat load the restored keys.

A plain VPS: provision it as above (that installs restic and rclone too), write the FORGE_BACKUP_* lines into /srv/git/.forge/backup.env (quote values with spaces or $ in them), restore as git and point DNS at it:

ssh root@HOST 'install -o git -g git -m 600 /dev/stdin /srv/git/.forge/backup.env' <backup.env
ssh root@HOST sudo -u git /opt/forge/bin/forge restore latest
# optional: keep the ssh host key clients know, or they see a changed key once
ssh root@HOST 'cp /srv/git/.hostkeys/ssh_host_* /etc/ssh/ && systemctl restart ssh'
# daily backups, from crontab -u git -e
PATH=/usr/local/bin:/usr/bin:/bin
30 3 * * * /opt/forge/bin/forge backup

Developer setup

ln -s /path/to/forge/bin/forge ~/bin/forge
git config --global forge.signingkey ~/.ssh/id_ed25519.pub   # .pub uses ssh-agent
ssh git@git.example.com signers > ~/.config/forge/allowed_signers
git config --global forge.allowedsigners ~/.config/forge/allowed_signers
git config --global forge.host git@src.rest                  # for forge import

Day to day:

git switch -c cat/topic && git commit ...
forge attest --stack          # CI for every commit on top of main
forge push -t "Title"         # branch + notes; becomes a review
forge land                    # CI and approval checks, then fast-forward main
forge land --server           # or let the forge land it, with its gates only
git tag v1.0 && forge release publish v1.0 dist/*   # files for a tag, signed

For a stack, git rebase -i --update-refs keeps the layers in sync, and forge push --stack pushes every layer, lowest first.

Working on forge itself: after sh demo/make-demo.sh, npm run dev serves the demo forge at http://localhost:8080/ (npm run dev -- <port> for another) and rebuilds it into _site/ whenever a template, asset, lib/ or eleventy.config.ts changes, reloading open tabs. Each build is a fresh 11ty process, since a running one can't pick up changed TypeScript modules. It only reads demo/root: no incremental build, and caches go to .cache/dev/. Live updates have no event stream there, so the status line stays empty.

The scenarios (test/scenarios/, one behavior each, end to end in a throwaway forge) run with sh test/run.sh [word...], as CI does. They need git, ssh-keygen and Node, and use Caddy and litestream where they are installed; HTTP goes through test/http.ts, not curl. To run them where the forge runs, in the container as root (Debian's sh, a git user that restores are handed to, Caddy and litestream, no curl):

docker build -t forge:test .     # once, and after Dockerfile or package changes
sh test/container.sh [word...]   # this checkout's code, the image's node_modules

FORGE_CI_CONTAINER=1 forge attest has .forge/ci build that image and run them there too, after the host's run; it is off by default, since the first build takes minutes.

Reading pages

The home page has the motd (above), then three panels. profiles: each owner with public repos, how many and when they were last active. recent: the last eight things that happened on public repos, newest first: landed reviews, pushes to a default branch, releases (a tag or release pushed) and site deploys, each linking to what it names (lib/recent.ts). Landed reviews come from the review logs; the rest from .forge/events.jsonl, whose push lines carry the default branch's new tip as head. A push whose head is a review's tip is that review landing, and shows once, as landed. Every build redoes the home page, and open copies of it swap in the new one when the build is done, so the list is as fresh as the last push. Its times stay relative in the browser. The events log is cut and left out of backups, so after a restore the list has only landed reviews until the next pushes. topics: the shelves (below) across every public repo; one owner's links to that shelf on their page (/cat/#games, which opens on its tab), and one that several owners share to /topics/#games, every public repo on its shelf, each naming its owner. Private repos, and owners with only private ones, are never on any of it.

Each owner's page, and /topics/, show repos as rows on shelves by category, newest first. A repo's category is one line in the bare repo's category file, next to description (printf 'games\n' > /srv/git/cat/greet.git/category). A repo without one goes on the shelf for what Jev says it is (a web app, a library), and failing that on "other". Links above the shelves show one shelf alone, only repos with a site or sort by name.

A review page reads top to bottom: the stack, the layer's commits, then each commit's diff. While you read the diffs, a bar sticks to the top with the layers below and above and the previous and next commit. Every file folds (click its header). Its Viewed box folds it and counts toward "3 of 7 files viewed". Viewed marks are kept in the browser's localStorage, keyed by repo, path and a hash of that file's diff, so they survive a force-push that leaves the file alone and clear when the file changes.

Above the diffs, the discussion: approvals, then comments oldest first. Signed in, it ends in a box to comment in, with Approve beside it (Ctrl or Cmd and Enter sends), and on the combined diff a + follows the pointer down the lines: click it for a box under that line (on a phone, tap the line, then the +). A comment you send waits on the page, as you typed it, until the rebuilt page swaps in a moment later with it rendered; what you are typing outlasts that swap, and a reload. Signed out, or without JavaScript, the page shows none of this.

"What changed since rN" compares a revision with the one before. If both were pushed on the same base, it is the diff between the two tips. If the branch was rebased, that diff would include everything that landed meanwhile, so each commit is paired with its earlier version instead and labeled unchanged, rewritten, added or dropped.

Review lists nest stacks, each layer under the one below it, and a revised review links its latest change (r2). Times are written in UTC and shown relative ("3h ago", then "Sep 1") by the browser, with the local time on hover, so static pages don't go stale. On phones, long code lines wrap unless you turn it off with the Wrap button, which is remembered.

A file on the default branch has blame and history buttons. Blame puts the commit that last changed each line, and when, in a column before the line numbers, one band per run of lines from a commit; clicking it opens that commit. History lists the commits that changed the file, newest first (the latest 200), each with the review it landed in. Both come from the file's blame.json beside its page (lib/blame.ts), fetched the first time either is asked for, so a file adds no pages. A build computes it with git blame and git log for the files commits touched since the last one, and keeps the rest in $FORGE_CACHE/blame/. Blame stays on from file to file once turned on.

Keyboard shortcuts (? lists them):

Key Does
/ search
t or p find a file on this branch
j k, Enter or o move through a list, open the row
n N next or previous commit on a review, file elsewhere
[ ] layer below or above in a stack
x fold or unfold the file in view
v mark the file in view as viewed
h g repo home, all repositories
f l b r w s e i code, commits, branches, reviews, wiki, sites, releases, insights
u up a level

Templates

Pages are Nunjucks templates in site/. The data layer in lib/ reads git with plain git commands and hands 11ty flat page lists (forge.pages.logs, .reviews, .blobs, ...), one item per page. Large diffs and files are rendered by filters when a page is written, so the build never holds all the markup at once.

Every page extends site/_includes/base.njk and fills its blocks: title, content, head (the page's description and its OpenGraph tags, so links pasted into chat apps preview well; og:url needs FORGE_BASE_URL), scripts (the diff pages add diffs.js) and heading. Front matter shared by many templates, like the pagination alias and permalink, is in directory data files (site/site.11tydata.json, site/repo/repo.11tydata.json), so a repo page's front matter is just the list it paginates:

---
pagination:
  data: forge.pages.commits
---
{% extends "base.njk" %}
{% import "macros.njk" as m with context %}
{% block content %}
...
{% endblock %}

site/assets/style.css holds what every page needs: the design tokens (every color, size, weight and font), layout, lists, code views. The rules for the look, and for changing it, are in DESIGN.md; the controls are macros in site/_includes/ui.njk, and /design/ shows both. CSS for one feature sits next to its markup in a {% css %} block, in the template, include or macro that renders it (the discussion, the search overlay, the shortcuts dialog, the review bar, review lists, the wiki). From those blocks, 11ty's bundle plugin gives each page one file of the blocks it rendered, /assets/<hash>.css, shared by pages of a kind. Import macros.njk with context, or blocks in macros don't reach the bundle.

Assets ship under content-hashed names (/assets/style.<hash>.css, ui.<hash>.js, sqlite-wasm), so browsers can cache them for good; the stylesheet's url(./fonts/open-sans.woff2) becomes the font's hashed name. The same sources give the same names, and pages' fingerprints include them, so unchanged pages are still hard-linked. A build keeps the files of the last five code versions (listed in assets/versions.json), because pages built by older code stay live for a while: a full forge build swaps one repo at a time, and a repo whose build fails keeps its old pages.

The templates render without 11ty too. lib/render-page.ts sets up plain Nunjucks with the same options, filters and bundle tags, so a server can render a page on demand:

const render = pageRenderer();
const html = render('repo/commit.njk', { forge, p });

node lib/render-page.ts check <www> renders every page that way and compares it with 11ty's; CI runs it on the demo. With FORGE_STRICT=1 (CI sets it), printing an undefined or null value is an error, so a value that may be missing needs an explicit check or or ''.

URL Page
/<owner>/<repo>/ open reviews, recent commits, README
/<owner>/<repo>/log/<branch>/ log for a branch (/ in branch names becomes ~)
/<owner>/<repo>/tree/<branch>/<path>/ directory
/<owner>/<repo>/blob/<branch>/<path>/ file
/<owner>/<repo>/commit/<sha>/ commit
/<owner>/<repo>/reviews/<id>/ stacked diff view; diff/ combined, r<n>/ what changed since the revision before
/<owner>/<repo>/wiki/<page>/ wiki
/<owner>/<repo>/reviews.json the review list as data, for forge reviews
/<owner>/<repo>/code.db the default branch's code index, read by code search in byte ranges
/<owner>/<repo>/sites/ deploys and previews
/<owner>/<repo>/releases/ release notes per tag, and each release's files
/<owner>/<repo>/releases/download/<tag>/<file> a release file; latest/ for the newest release's
/<owner>/<repo>/insights/ charts from the lake; insights.json and Parquet files next to it
/insights/ the same for the whole forge, and a table by repo
/login/ sign in with a passkey, add one (#enroll=<code>), join from an invite (#invite=<code>), sign out; whoami says who is signed in, for review pages
/private/ the private repos you can read
/sites/<owner>/<repo>/<name>/ a deployed site

Not yet

  • Bugs or issues in the args.io style.