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 commentandforge approveadd 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, andmaincan require them from someone other than the author, as many and from whom.forge/says for the files a change touches.approvals forge landchecks both and fast-forwards. - Agent reviews, signed like CI.
forge critiqueruns the reviewer the target branch names (.forge/) 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.review - CI runs on your machine.
forge attestruns.forge/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, andci maincan 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/and served from the forge. Each review branch gets its own preview. The wiki is markdown insites/ <name> wiki/. - Releases are signed files.
forge release publish v1.0 <file>...puts a tag's binaries onrefs/with areleases/ v1.0 SHA256SUMSsigned 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/, 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
mainturns away because nobody approved it; - a draft that grows, then is rebased onto a newer
mainand 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/ 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 /, 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/ |
Tails .forge/, which hooks and builds append to, and streams it as server-sent events at /. 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/ |
Serves private repos' pages to their readers, signed in with a passkey or holding a token, the sign-in pages (/, /), 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/ |
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 / 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/ to the backup bucket as it changes (see identity.db). |
Without a pages domain, Caddy serves sites straight from / and
/ 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/ sends the same headers.
Identity and permissions
/ 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 throughforge shell <name>, so hooks know who pushed;allowed_signers, so the same keys verify CI attestations, signed site deploys and release checksums (namespacesforge-ci,forge-releaseandgit).
Repos live at /, and <owner> owns them. Anyone
can read a public repo; a private one, only the people below. An owner's
page, /, 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/ 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>/ 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:/, 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, / (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/ (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/ (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/ 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/ takes a repo of yours off the
forge (without --yes it says what would go); collaborators can't, and
forge repo rm <owner>/ 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/ for FORGE_TRASH_DAYS (14) days,
backed up like the rest of the forge. forge repo restore <owner>/
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/: 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/ instead of www, with the same layout: pages, search
index, code.db, release downloads, insights exports. The build writes
them in .forge/ and swaps them in from there, so
they are never outside .forge/, which only git can read, even
mid-build. They link the same hashed assets, which the build puts in
www/ (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/, 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/ applies, kept in step with lib/ 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.
/ lists the private repos you can read.
Web credentials live in one SQLite file, .forge/ (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/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. Afterlogin/ 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-challengecookie 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-forgecookie (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 asAuthorization: Bearer <token>, or as the password of basic auth:git clone https:/, a credential helper or the token alone as the user name./ cat:<token>@git.example.com/ cat/ notes.git
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/ and pins the tip under refs/ 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 iswip/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:/,
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/: 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.forge/ comments/ <review>/ <n> - 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 truein 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 alongsideforge.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 (--forceturns 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>/, or<repo> <id|branch> forge land --serverfrom 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. Sopre-receiveapplies exactly the rules any push meets (who may move the default branch,forge.requireci,forge.requireapproval, fast-forward only unlessforge.allowforce), andpost-receivedoes 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 /,
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/ on the default branch says how much approval a change to
it needs, by the files the change touches (lib/):
# 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.@mechanicalmatches 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, optionallyfroma 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 (or3 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/lands under the old ones (andapprovals .forge/above makes that need two). - Where they show. The gate (
pre-receive, withforge.requireapproval) prints what is missing.forge landasks 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. Withoutforge.requireapprovalthe 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
/ (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 / (fields body, at, line as
<path>:<n>) and .../ (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 /); 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/. 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/, 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 atteston their machine. Attestations from several signers stack up on the same tree.
.forge/ 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/):
- It reads the reviewer,
.forge/, and its rules,review .forge/, from the review's target branch, so a change can't bring its own reviewer or loosen the rules.review.md - 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.
- It posts each finding as a comment, with
agent <model>andseverity blocking|notein 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. - It signs a statement into
refs/on the tip, withnotes/ review 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/, 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;
--fullreviews 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
--fixthe reviewer may change files in the worktree; what it changed becomes a commit on<you>/, pushed as a review stacked on this one. The review itself is untouched.fix-<id> - 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
+ agentwants one of the tip's tree, verified, withopen 0. Keys may sign them onceforge keys sync(whichforge server initand the container's boot run) writesforge-reviewintoallowed_signers.
Sites
A site deploy is a commit on refs/ 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 whengpg.format=sshis set.
Declare sites in .forge/:
# 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/. On any other branch it becomes
that review's preview, refs/. 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/, then points
www/ at it with a symlink. Only the owner can
deploy refs/.
Deleting a branch deletes its previews: post-receive removes
refs/, 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/ 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/).
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 overgit config forge.releasemaxsize(256m unless set; git's k, m and g work); SHA256SUMSlists every file once with its sha256, and nothing else;SHA256SUMS.sigis aforge-releasesignature 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, asforge.requireciasks ofmain(forge attest v1.0before 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/, as for a
site (a private repo's into a store of its own, below), and hard-links its
files into
/ (a / in the tag becomes ~).
latest/ next to them holds the files of the newest tag that has a
release, so / 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/ (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/), 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/ either, but into
.forge/, and linked from there. .forge/ 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/,
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/ (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
/ (and /), 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/): 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/ runs the same code in Node, for
test/. Pages load search.ts as a module; it
and codesearch.ts import ./, which a build rewrites to that
file's content-hashed name.
Code search
File contents are in a second index per repo, /,
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/) whose
reads are HTTP range requests, so a query fetches only the pages it touches.
Any static server that honors Range works: Caddy, demo/ 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/, which serves a
built forge with demo/ 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/ 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/ 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/
(lib/ 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/): its catalog is a SQLite file,
.forge/, and its data Parquet files in
.forge/, 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.jsonlthat 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 lakeholds.forge/; a second one waits for it (lake.lock FORGE_LAKE_WAITseconds, 300) or gives up and says so. Builds start it the way they start the mirror,FORGE_LAKE_DELAYseconds (10) after the last one;FORGE_LAKE=0stops 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_CHECKPOINThours) a run checkpoints, which writes them out and merges small files. - Read-only queries.
forge lake queryattaches the lake read-only, runs onlySELECT,EXPLAINandPRAGMAstatements, and can't touch files other than the lake's data.--jsonand--csvchange the output. - Offline. DuckDB downloads extensions at first use. The image installs
them at build time (
node lib/, which checks they are the versions this was tried with), so the container never does. Elsewhere they go tolake.ts install ~/the first time..cache/ duckdb/ extensions
Data on R2 instead of the volume: put FORGE_LAKE_DATA=s3:/
and FORGE_LAKE_ENDPOINT=https:/ 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
/ covers the forge, and each repo has /
(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/ 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/ |
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/ starts them all. All state is in
/, 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/ 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 / (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 /.
The edge (deploy/) 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
/, its file in the TAILCAT_CLIENT_KEY secret and redeploy both.
A plain VPS instead
server/ 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 / 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/); forge build rebuilds everything.
Cleaning up
Every distinct deploy is extracted once into .forge/ (a
private repo's releases into .forge/), and the old
tree stays when a newer deploy takes its link. forge gc removes trees in
.forge/ that no www/ or www/ link points at
(hostnames point at those links) and no public repo's refs/ ref
names, and trees in .forge/ 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 looserefs/.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/, 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/, 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/ still lists
refs/ and refs/; 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/.
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 / that can't be rebuilt:
- every bare repo,
<owner>/, with its refs (releases too, and so their files, which restic stores once however many backups hold them), its config, its<repo>.git git-daemon-export-okif it has one (so a private repo comes back private) and its wholeforge/directory: review logs, comments, collaborators, readers and anything else kept there; .forge/,keys .forge/andconfig .forge/, which hostname each site has;www/ hosts/ .forge/, as SQLite copies it (identity.db 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/): the catalog, copied with SQLite'slake VACUUM INTOwhile noforge lakeruns, 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/orsrv/ git .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/ and .forge/, the extracted sites and releases in
.forge/ and private repos' releases in .forge/ (all
are git refs),
.forge/, authorized_keys, allowed_signers, git-shell-commands/,
hooks, .forge/ and .forge/ (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
- forgets and prunes old snapshots:
--keep-daily 7 --keep-weekly 5by default (FORGE_BACKUP_KEEP_DAILY,FORGE_BACKUP_KEEP_WEEKLY; a daily of 0 keeps only the newest snapshot of each week); - runs
restic check, and about once a week also reads a fifth of the stored data, a different fifth each week; - restores the new snapshot into a scratch directory and drills it: the same
files as staged, a clean full
git fsckin every repo and the ref count each live repo had.
A failure goes to .forge/, makes the command exit non-zero and is
recorded in .forge/. 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/. With backups set up, the container runs forge identity replicate: litestream v0.5 copies each
change to the backup bucket within seconds, under litestream/
(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/ puts in:
| In the copy | Left out |
|---|---|
/, owner pages, /, / with its exports, /, / (scripts, styles, fonts), repos.json |
/, /, /: 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/ |
/: file pages |
tree/, each branch's top-level file list |
tree/: 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/); the background
ones log to .forge/.
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:
-
Two R2 buckets:
forge-backup, private, andforge-pages, with public access on, through a custom domain such asmirror.src.rest(best) or itsr2.devURL (rate-limited, fine for an offline copy). -
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 -
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 offorge.envthere too; a new box needs it. -
/on slate, thensrv/ forge/ forge.env 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/identityAt every boot the container writes these to
.forge/, which only git and root can read.backup.env -
The edge: set
FORGE_MIRROR_URLindeploy/to the bucket's public URL (edge/ fly.toml FORGE_DOMAINis set already), thenfly deploy. Its secrets are unchanged:TAILCAT_CLIENT_KEYandFORGE_TAILCAT_ADDR. -
Check it:
docker exec forge forge-admin backup, thenbackupsandforge-admin mirrorfor 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
/. 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/ 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/ 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 / (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/, 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/: no incremental build, and caches go to .cache/.
Live updates have no event stream there, so the status line stays empty.
The scenarios (test/, one behavior each, end to end in a
throwaway forge) run with sh test/, as CI does. They need
git, ssh-keygen and Node, and use Caddy and litestream where they are
installed; HTTP goes through test/, 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/ 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/). Landed reviews come from the review logs; the rest
from .forge/, 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
(/, which opens on its tab), and one that several owners
share to /, 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 /, 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' > /).
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/), 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 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/ 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/), 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/ 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/, and / 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, /, 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 (/,
ui.<hash>.js, sqlite-wasm), so browsers can cache them for good;
the stylesheet's url(./ 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/), 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/ 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/ 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 |
|---|---|
/ |
open reviews, recent commits, README |
/ |
log for a branch (/ in branch names becomes ~) |
/ |
directory |
/ |
file |
/ |
commit |
/ |
stacked diff view; diff/ combined, r<n>/ what changed since the revision before |
/ |
wiki |
/ |
the review list as data, for forge reviews |
/ |
the default branch's code index, read by code search in byte ranges |
/ |
deploys and previews |
/ |
release notes per tag, and each release's files |
/ |
a release file; latest/ for the newest release's |
/ |
charts from the lake; insights.json and Parquet files next to it |
/ |
the same for the whole forge, and a table by repo |
/ |
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 |
/ |
the private repos you can read |
/ |
a deployed site |
Not yet
- Bugs or issues in the args.io style.