forge guide

for whoever maintains it next

internals

How a push becomes pages, where the code is, what it keeps on disk, who may push what, what runs where and how to keep it running. Read the guide first for what forge does; this page is about how.

01 a push

What happens when you push

A push is how almost everything changes. Here is one, followed through the server.

  1. your machine

    git push, or forge push, which also merges the server's CI notes with yours, pushes them along and passes options such as a review title.

  2. sshd

    Your key matches a line in authorized_keys that forces forge shell <name>. It records who you are, creates a repo of yours that a push names but isn't there yet, and hands the command to git-shell, which runs git or one of the commands in git-shell-commands/ (new, ls, reviews, land, comment, who, passkey, token and the rest; help lists them). An admin's session also finds the admins' own, invite, invites, users and motd.

    bin/forge: cmd_shell

  3. pre-receive

    Decides who may push which refs, refuses to delete main or rewrite it (unless the repo allows that), turns away a push that adds a credential, checks a release's sums and signature, and, when the repo asks for it, refuses a new main without a verified passing CI attestation or another person's approval.

    bin/forge: hook_pre_receive, scan_secrets, check_release

  4. git

    The refs move.

  5. post-receive

    Goes through the updated refs:

    • a branch other than main gets a revision in its review log, and its tip is pinned;
    • a site or preview deploy is extracted and linked into the web root, and so are a release's files;
    • a deleted branch takes its previews with it;
    • the push is appended to .forge/events.jsonl.

    bin/forge: hook_post_receive, record_review, deploy_site

  6. your git push returns here; the rest runs in the background
  7. forge build

    11ty rebuilds that repo's pages into a scratch directory, hard-linking every page that didn't change from the live site, swaps them in and appends a built event. Builds take turns under a lock (the build).

    bin/forge: cmd_build, eleventy.config.ts, lib/forge.ts

  8. your browser

    Open pages hold a server-sent events stream from /events/<owner>/<repo>/. The push shows up as cat pushed cat/flags, updating; when the build lands, the page fetches itself and swaps in the new content, keeping your scroll position.

    lib/events-server.ts, site/assets/live.ts

fig. 1one push, from your terminal to every open page

Building after the push rather than on each request means public pages cost nothing to serve, can be cached anywhere and keep working if the small Node servers stop. Those only add what files can't: live updates, private pages for their readers, comments and approvals from the web, and sites on their own hostnames.

02 source

Where the code is

Four kinds of code, kept small: one shell script for everything git and ssh touch, a TypeScript data layer that Node 22 runs as it is (types are stripped, never compiled), Nunjucks templates with a base stylesheet, and container and deploy files.

pathwhat it is
bin/forgeThe whole CLI in POSIX sh. Developer commands (attest, verify, push, site, import) run in a clone; server commands (server init, user, keys sync, repo, build, gc, shell, hook) run as the git user. Both hooks are this script.
lib/forge.tsFinds repos and flattens them into one list per page type for 11ty. Fingerprints every page so unchanged ones are skipped, and holds the filters that render heavy HTML when a page is written.
lib/repo.tsLoads one bare repo: refs, commit metadata, trees, CI notes, reviews, sites. Diffs and file contents are read later, only for pages that will render.
lib/reviews.tsReview logs plus live refs become stacks, layers, statuses and range-diffs.
lib/attest.tsParses attestation notes and verifies their signatures with ssh-keygen, remembering each result.
lib/sites.ts, lib/hosts.tsDeploy history and signatures for the sites page; per-site hostnames, with www/hosts/ as the registry. hosts.ts is also a small CLI the hooks call.
lib/events-server.ts, lib/private-server.ts, lib/pages-server.tsThe long-running servers: live updates over server-sent events; private pages, sign-in, and comments and approvals from the web; sites on their own hostnames.
lib/identity.ts, lib/access.ts, lib/login.tsWeb credentials in identity.db (passkeys, sessions, tokens, invites), who may read what, and passkey sign-in.
lib/, the restgit.ts wraps the git CLI, diff.ts parses patches, render.ts writes diff and code HTML, markdown.ts renders and sanitizes READMEs and wiki pages, urls.ts is the URL scheme, cache.ts the per-repo build cache, assets.ts the hashed asset names, render-page.ts the Nunjucks setup 11ty shares and pages rendered without 11ty, metrics.ts build timings, types.ts the model.
site/, eleventy.config.tsTemplates, each extending _includes/base.njk and carrying its own CSS in {% css %} blocks; assets/style.css for what every page needs; the small client scripts in assets/*.ts, whose types are stripped when the site is built.
server/The container's start.sh, sshd.conf and forge-admin; provision.sh for a plain VPS; an example Caddyfile.
deploy/edge/, the Fly relay; slate/, the compose file and the rsync deploy for the home box.
demo/, .forge/, guide/A throwaway forge built with the real hooks, and a local server for it; forge's own CI script and site list; this guide.

03 on disk

State on disk

Everything lives under the git user's home, FORGE_ROOT: /srv/git on a server, demo/root in the demo. Back up this one directory and you have the forge; forge backup does, leaving out what a restore rebuilds (upkeep).

/srv/git/
├── .ssh/authorized_keys          generated from .forge/keys
├── git-shell-commands/           new, ls, reviews, land, comment, approve, help...
├── cat/greet.git/                a bare repo; the directory names the owner
│   ├── hooks/                    pre-receive, post-receive: exec forge hook ...
│   ├── forge/reviews/<id>        one log per review
│   ├── forge/collaborators       names that may push anything
│   ├── refs/forge/keep/<sha>     every revision's tip, hidden from clients
│   ├── refs/notes/ci             signed attestations
│   ├── refs/sites/<name>         deploys
│   └── refs/site-previews/<name>/<branch>
└── .forge/
    ├── keys                      name and key per line: the only identity
    ├── allowed_signers           generated from keys
    ├── config                    shell variables, sourced by forge
    ├── www/                      the pages, served as plain files
    │   ├── sites/, previews/     symlinks into store/
    │   └── hosts/<label>         symlinks to sites/ or previews/
    ├── store/<tree>/             each deployed tree, extracted once
    ├── cache/                    facts keyed by object id, page fingerprints
    ├── events.jsonl              pushes and builds, for live pages
    ├── backup.env                backup and mirror keys, written from forge.env
    ├── backup.status, backup.log the last backup's health, for forge backups
    ├── backup/                   staging, restic's cache, the restore drill
    └── build.log, metrics.jsonl
fig. 2the forge's state, all of it

A review log is a few lines appended by post-receive:

branch cat/help
created 1788276612 cat
opt title=Usage and --help
rev 1788276612 ef1d881b67e0... cat 895bc48ac1d9...    # time, tip, who, merge-base with main
rev 1788282012 6771779c76bb... cat 895bc48ac1d9...
deleted 1788292812 cat                                # only once the branch is gone

The container adds .hostkeys/, so clients see the same host key across deploys, and .tailcat/, whose server key keeps the tunnel address stable. Both are root's, and both are in every backup, so a restored box keeps its identity.

04 identity

Identity is one file of keys

.forge/keys holds name key lines and is the root of identity; passkeys, sessions and tokens in identity.db each belong to a name in it. forge keys sync (run by forge user add, user rm and a taken-up invite) writes two files from it, so the key you push with is also the key that signs your CI results, deploys and releases.

.forge/keysfox ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... fox@laptop
forge keys sync
.ssh/authorized_keysrestrict,command="/opt/forge/bin/forge shell fox" ssh-ed25519 AAAAC3Nz... fox@laptop

Who is pushing: hooks see FORGE_USER=fox.

.forge/allowed_signersfox namespaces="forge-ci,forge-release,forge-review,git" ssh-ed25519 AAAAC3Nz... fox@laptop

Who signed a CI result, a deploy, a release's checksums or an agent review. ssh git@host signers prints it for forge verify.

fig. 3one line in, two generated files out

Repos live at /srv/git/<owner>/<repo>.git, and the directory names the owner. The owner, and anyone in <repo>.git/forge/collaborators, can push anything; everyone else who can read the repo works under their own name. This is what pre-receive enforces, ref by ref:

refowner, collaboratorsanyone else who can read it, e.g. fox
main (the default branch)fast-forward only, unless forge.allowforce; never deletedno
refs/heads/fox/*yesyes, their own prefix
other branchesyesno
refs/site-previews/*/fox/*yesyes
refs/tags/*, refs/sites/*, refs/releases/*yesno
refs/notes/ciyes, and may rewriteadd only: fast-forward, no delete
refs/forge/*internalinternal

Removing someone's line revokes their pushes, their web credentials and their signatures together: their old attestations stop verifying. A private repo is one without git-daemon-export-ok, git's own flag; forge shell turns away anyone who may not read it with the same answer as for a missing repo, and so does the private server for its pages.

05 signed refs

CI notes, sites and releases are refs

CI. forge attest checks each commit out into a worktree (one per repo, reused so build caches stay warm), runs .forge/ci in it (inside nix develop when there is a flake.nix and Nix is installed), signs the statement with ssh-keygen -Y sign -n forge-ci and appends it to refs/notes/ci, keyed by commit. pre-receive verifies against the server's own allowed_signers when forge.requireci asks it to; anyone who can push may add notes, as long as refs/notes/ci only fast-forwards.

Reviews. A review's parent is the nearest other review whose tip lies inside its branch; its layer is the commits between that parent (or main) and its tip. Status and revisions come from the review log and the refs, so nothing else is stored.

Sites. A deploy is a commit on refs/sites/<name> whose tree is the built output, so git provides content-addressed storage, dedup across deploys, history and revert by pushing an older deploy. Trailers record the source commit, the build command and who built it, and the commit carries an SSH signature when your key can sign. A preview is the same on refs/site-previews/<name>/<branch>. The build gets FORGE_SITE_BASE, the path it will be served from (/ on its own hostname).

  1. refrefs/sites/docsa deploy commit, signed
  2. post-receive extracts its tree, once per tree
  3. store.forge/store/339f775e.../
  4. symlink
  5. servedwww/sites/cat/greet/docs
  6. symlink, with a pages domain
  7. hostnamewww/hosts/greet-docs
  8. the pages server looks up the Host header
  9. browserhttps://greet-docs.pages.example.com/
fig. 4a deploy, from ref to hostname

The www/hosts/ directory of symlinks is the whole hostname registry. Without a pages domain, sites are plain files under /sites/<owner>/<repo>/<name>/ on the forge itself, and old /sites/... paths redirect to the hostname when there is one. Only the owner and collaborators deploy sites; anyone may deploy previews of their own branches.

Releases. forge release publish commits a tag's files to refs/releases/<tag> with a SHA256SUMS signed with ssh-keygen -n forge-release over the repo and tag too. pre-receive checks every sum, the signature (an owner's or collaborator's key), the tag and a size cap, and with forge.releaserequireci a verified pass for the tag's tree. post-receive extracts the tree into the store and hard-links the files into /<owner>/<repo>/releases/download/<tag>/, plus latest/; hard links, so the mirror copies them. Clones and fetches never see refs/releases. A private repo's go to the private root next to its pages, never the mirror.

06 what runs

What runs and why

The container's entrypoint is tini, which also reaps the background builds, and then server/container/start.sh. It creates host keys on first boot, runs forge server init, writes .forge/config from the environment, adds the owner's key, syncs keys, writes the Caddyfile with forge server caddyfile and starts:

processlistensaswhy
sshd:2222rootgit over ssh, only for the git user, keys only.
fcgiwrapunix socketgitRuns git http-backend for HTTPS clones; Caddy routes only info/refs and git-upload-pack to it.
tailcatthe tunnelrootOnly with FORGE_TAILCAT_ALLOW: carries :2222 and :8080 to the edge.
events server127.0.0.1:8091gitTails events.jsonl, streams it at /events/.
private server127.0.0.1:8093gitEvery path www has no file for: private pages for their readers, /login/, comment and approve posts, and the one 404.
litestreamgitOnly with FORGE_BACKUP_REPOSITORY: copies identity.db to the backup bucket as it changes.
pages server127.0.0.1:8092caddyOnly with FORGE_PAGES_DOMAIN: sites on their own hostnames. Runs as caddy because deploys are untrusted.
forge buildgitOnce at boot, in the background, since templates may have changed with the image; after that, after each push.
backup looprootOnly with FORGE_BACKUP_REPOSITORY: runs forge backup once a day at FORGE_BACKUP_AT, catching up after downtime, and prints its health to the container log.
forge mirrorgitOnly with FORGE_MIRROR_S3: after builds, at most hourly, syncs the forge's pages to the bucket the edge falls back to.
Caddy:8080caddyIn the foreground. Plain HTTP; TLS ends at the edge. Pages, .git files, go get meta tags and the two proxies.

The container takes FORGE_DOMAIN, FORGE_PAGES_DOMAIN, FORGE_OWNER, FORGE_OWNER_KEY, FORGE_NAME and FORGE_TAILCAT_ALLOW, plus FORGE_BACKUP_* and FORGE_MIRROR_*, which it writes to .forge/backup.env for backups and the mirror. On a VPS, provision.sh installs the same pieces natively and runs the Node servers as systemd units, forge-events, forge-private and, with a pages domain, forge-pages, with Caddy on :443 getting its own certificates.

07 hosting

Where it runs

The forge is one container: sshd for git, Caddy for pages, sites and read-only HTTPS clones (git http-backend through fcgiwrap, and go get works), the events server, the private server, the pages server when there is a pages domain and optionally tailcat. All state is in /srv/git, a volume. forge backup snapshots it every day with restic into an R2 bucket and test-restores each snapshot, and forge restore brings the forge back on a new box, host keys and tunnel address included.

  1. internetssh on :22, https on :443. DNS for the forge and pages domains points at the edge, DNS-only, since ssh can't go through Cloudflare's proxy.
  2. raw TCP for :22, TLS ends here for :443
  3. Fly edgedeploy/edge. Stateless, with a dedicated IPv4 so raw TCP on :22 works. Its key and the container's address are Fly secrets. While the home box is down, its Caddy serves the forge's pages from a copy in R2.
  4. tailcat: point-to-point WireGuard on Tailscale's data plane, with no tailnet, accounts or control plane
  5. forge container on a home boxdeploy/slate. Publishes no ports. Its tailcat server keeps its key on the volume and accepts only the edge's client key.
    • sshd :2222
    • Caddy :8080
    • events :8091
    • pages :8092
    • private :8093
fig. 5how src.rest runs
  1. internetssh on :22, https on :443
  2. straight in
  3. a Debian or Ubuntu VPSserver/provision.sh installs git, sshd and Caddy, which gets certificates itself, and runs the Node servers under systemd. It needs Node 22.18 or newer.
    • sshd :22
    • Caddy :443
    • forge-events
    • forge-private
    • forge-pages
fig. 6or a plain VPS

Either way, Caddy serves the pages directory as plain files and hands on only a few things: clones to git http-backend, /events/ to the events server, comment and approve posts and every path the pages directory has no file for to the private server and, with a pages domain, sites to the pages server. The pages server runs as the caddy user, because deploys are untrusted content. A private repo's pages are built into a root of their own and served by the private server to readers signed in with a passkey or showing a token; to anyone else every page of it is the 404 of a page that isn't there. Passkeys, sessions and tokens live, as hashes, in one SQLite file that litestream copies to the backup bucket as it changes.

08 the build

How a push becomes pages

  1. post-receive

    Starts forge build <owner>/<repo> with nohup, logging to build.log. FORGE_SYNC_BUILD=1 runs it in the foreground instead; FORGE_NO_BUILD=1 skips it.

  2. lock

    mkdir .forge/build.lock. Builds wait their turn, for up to 10 minutes.

  3. graph

    git commit-graph write --reachable --changed-paths --split adds a small layer for the new commits, with Bloom filters, so git log -- <path> and blame skip commits that don't touch the path. Here rather than in the hook, so the pusher never waits on it.

  4. data

    11ty loads lib/forge.ts with FORGE_ONLY set, so only that repo is read. Index and owner pages use cached summaries of the others. Commit metadata, range-diffs and signature checks come from .forge/cache/, keyed by object ids that never change.

  5. skip

    Every page has a fingerprint of what it shows, plus a hash of the forge's own code. A page whose fingerprint matches the last build, and whose file still exists, isn't rendered: its file is hard-linked from the live site.

  6. render

    The rest render into .forge/build.<pid>/. Diffs and file views are written by filters as each page is, so memory stays bounded by the largest page, not the repo. Each page links one CSS bundle of the blocks its templates carry.

  7. assets

    Stylesheet, scripts and bundles get names from a hash of their contents, so browsers cache them for good. Pages from older code are still live (the other repos mid-rebuild, or a repo whose build failed), so the files of the last five code versions are carried over from the live site; assets/versions.json lists them.

  8. swap

    The repo's directory is moved into www/ in place of the old one, and the owner's index page is copied over. sites/, previews/ and hosts/ belong to the hooks and are never touched.

  9. after

    A line in metrics.jsonl (forge metrics reads it) and a built event that open pages act on.

fig. 7a single-repo build

forge build with no argument rebuilds every repo, one at a time so memory stays bounded by the largest, then the index pages, then removes pages of repos that no longer exist and prunes dangling hostnames.

09 urls

URLs

Every page is a directory with an index.html, so URLs read like paths. Branch names may contain /, which would make /tree/<branch>/<path> ambiguous, so it is written ~, which git forbids in ref names: cat/flags becomes cat~flags.

urlpage
/<owner>/<repo>/open reviews, recent commits, README
.../log/<branch>/log for a branch
.../tree/<branch>/<path>/directory
.../blob/<branch>/<path>/file; markdown renders, with its source at source/
.../commit/<sha>/commit
.../reviews/<id>/stacked diff; diff/ combined, r<n>/ range-diff
.../wiki/<page>/, .../sites/, .../atom.xmlwiki, deploys and previews, feed
/<owner>/<repo>.gitHTTPS clone
/sites/<owner>/<repo>/<name>/a site, or a redirect to its hostname
/previews/.../<name>/<branch>/a preview, likewise
/events/<owner>/<repo>/the live event stream

10 settings

Settings, wherever they live

.forge/config, on the server

FORGE_NAME, FORGE_BASE_URL, FORGE_SSH
The forge's name, web address and ssh address, for pages and clone URLs.
FORGE_PAGES_DOMAIN, FORGE_OWNER
Turn on per-site hostnames; the owner's sites get the short ones.
FORGE_MAX_COMMITS, FORGE_MAX_FILES, FORGE_MAX_BLOB, FORGE_MAX_DIFF_LINES
Limits per branch log, per tree, per file shown and per diff shown. The defaults are 1,000 commits, 5,000 files, 1 MiB and 20,000 lines.

a bare repo's git config

forge.requireci tip | all
A verified pass before main moves, for the new tip or every new commit.
forge.allowforce true
Let admins force-push main.
transfer.hideRefs refs/forge
Set by forge on every repo, with receive.advertisePushOptions true and commitGraph.changedPathsVersion 2; see upkeep.

your git config

forge.signingkey, forge.allowedsigners
Sign with this key; verify against this file.
forge.host, forge.remote, forge.base
Where forge import goes; which remote and base branch the other commands use.
forge.cidir, forge.nix
Where CI worktrees live; forge.nix false skips nix develop.

forge.env, for backups

FORGE_BACKUP_REPOSITORY, _PASSWORD, _KEY_ID, _SECRET
The restic repository (an R2 bucket), its password and the bucket's keys. Keep the password off the box.
FORGE_BACKUP_AT, _KEEP_DAILY, _KEEP_WEEKLY
When the daily backup runs (UTC) and what retention keeps: 03:30, 7 and 5.
FORGE_MIRROR_S3, _KEY_ID, _SECRET, _DELAY, _MIN_INTERVAL
The pages mirror's bucket and keys, how long it waits after the last build, 600 seconds, and how long at least between syncs, 3600 seconds.

the environment, for tests

FORGE_NOW
Pins the clock reviews are stamped with; the demo moves it with its commits.
FORGE_NO_BUILD, FORGE_SYNC_BUILD
Skip the build after a push, or run it in the foreground.
FORGE_STRICT
1 makes a template that prints an undefined or null value fail the build; CI sets it.

11 on the server

Running it, as root

Administer as root on the server (forge-admin ... in the container, which runs forge as the git user; on a VPS, sudo -u git /opt/forge/bin/forge), never by logging in as git: every key is forced through forge shell. The forge's admins can run the people commands over ssh instead.

as root

forge-admin user add fox 'ssh-ed25519 AAAA...'
Add a user and key; user rm fox revokes their keys and web credentials. user invite makes a link instead.
forge-admin repo new cat/greet "description"
Or repo import cat/greet <url> to fetch one server side; repo rm and repo restore for the trash.
forge-admin collaborators cat/greet add fox
The same lists the owner keeps over ssh, for any repo; readers too.
forge-admin build [owner/repo]
Rebuild pages; metrics shows where builds spend their time.
forge-admin gc [--dry-run]
Remove unused site and release files, hostnames and leftover previews, then repack each repo (upkeep). Daily from cron.
forge-admin backups
How the last backup went, then the snapshots. backup takes one now; the container takes one daily.
forge-admin restore latest
Bring the forge back on an empty box, from the newest snapshot or a named one.

git config, on the server

git config forge.requireci tip
Require a verified pass for the new tip of main; all checks every new commit.
git config forge.requireapproval true
Require an approval of the new tip from someone other than the review's author.
git config forge.releaserequireci true
Require a verified pass for a release's tag.
git config forge.allowforce true
Let the owner and collaborators force-push main.
git config --file /srv/git/.gitconfig --add forge.admins cat
In the git user's own config: the forge's admins, who may invite, list users and set the motd over ssh.

12 upkeep

Keeping it tidy

Previews go when their branch does: post-receive deletes the preview ref, the served link and the hostname of a deleted branch. Previews of merged branches stay until the branch is deleted.

The store keeps every tree that was ever deployed until forge gc, which removes

  • previews of review branches that are gone (deleted outside the hook, or before it cleaned up),
  • hostnames whose deploy is gone,
  • store trees that no www/sites or www/previews link points at.

A deploy extracts its tree before it links it, and touches a tree it reuses, so gc skips store entries changed in the last 10 minutes (--age) and is safe to run while people push. --dry-run shows what would go. Run it daily, from the host's crontab for the container (docker exec forge forge-admin gc) or the git user's on a VPS.

Repos are maintained by the same forge gc, in repack_repo: git repack -d --geometric=2 --write-midx --write-bitmap-index merges packs only until each is at least twice the next one's size, so a day's run rewrites the day's pushes and loose objects, not the repo, and one multi-pack index with a bitmap serves clones and fetches across the packs. It deletes only packs it rewrote and prunes nothing, so pushes can land while it runs. Then the commit-graph is rewritten as one layer (--split=replace, reusing the Bloom filters it has, adding any that old layers lack) and refs are packed, since every revision pins a loose refs/forge/keep/<sha>. Between runs, each build adds a commit-graph layer for its push.

Repo settings come from repo_setup, when a repo is created (repo new, ssh new, a push to a missing repo, an import) and for every repo on forge server init, which the container runs on boot: the hooks, receive.advertisePushOptions, transfer.hideRefs refs/forge so clones, fetches and pushes never see the pinned revisions (the build reads them directly; pre-receive also refuses them), and commitGraph.changedPathsVersion 2, filters that hash non-ASCII paths the same on every platform.

Backups: forge backup stages a copy of /srv/git without what a restore rebuilds (pages, extracted sites, caches, generated keys files, hooks, logs), uploads it with restic, prunes old snapshots, runs restic check and restores the new snapshot into a scratch directory to git fsck every repo and compare its refs. The copy is consistent mid-push: per repo it copies review logs and comments, then reads the refs, then hard-links the objects, which git writes before anything names them. It refuses a forge with no repos, or half the last backup's, so a new box can't push good snapshots out. forge-admin backups says how the last one went, and loudly when it failed or is a day and a half old.

Moving: forge restore latest on an empty forge unpacks a snapshot and rebuilds the rest: what server init and keys sync write, hooks, pinned revisions, site files, pages. The restored tailcat key keeps the tunnel address, so the edge follows the forge to a new box by itself. The README walks through a new slate and a VPS.

The offline copy: after builds, at most hourly, forge mirror syncs the forge's own pages, without sites, previews or the pages of single files and folders, to an R2 bucket. When slate doesn't answer, the edge's Caddy serves them from there with X-Forge-Offline: mirror, and a page the copy leaves out is a 404 that says so; sites, clones and pushes wait for slate.

Updates: deploy/slate/deploy.sh rsyncs the checkout to the box and rebuilds the container; the boot build refreshes every page. A change to lib/ or site/ changes the code hash in every fingerprint, so that first build renders everything.

Logs: .forge/build.log for builds, .forge/events.log for the events server, and backup.log and mirror.log, kept under a megabyte. events.jsonl is cut back to its last 1,000 lines once it passes a megabyte.

13 hacking

Working on forge

run it

npm install
11ty, markdown-it, sanitize-html, Pele; TypeScript only for checking.
sh demo/make-demo.sh
A throwaway forge in demo/root: two users, stacked reviews with revisions, CI that fails and passes, a wiki, sites and previews, all through the real hooks over file:// pushes.
node demo/serve.ts 4817
Serves it with live updates and cache headers, the way Caddy and the events server do.
npm run dev
Serves the demo from _site/ on :8080 and rebuilds it, reloading the browser, when templates, assets or lib/ change. It never writes to demo/root.

check it

sh .forge/ci
Lints the shell, type-checks, builds the demo, checks a few pages and checks this guide's links. It is forge's own CI, so forge attest runs it and signs the result.
npx tsc -p .
Type check only. tsc never emits.
node lib/check-links.ts guide
Every relative link and anchor in the guide resolves.
node lib/render-page.ts check <www>
Every page renders the same with plain Nunjucks as with 11ty, the base for rendering pages on demand.

Conventions: shell stays POSIX (sh -n bin/forge); TypeScript uses erasable syntax only and imports name their .ts files, since Node strips types and runs the sources; no frameworks; comments say why. Pages must work without JavaScript, which only adds shortcuts, live updates and conveniences.

This guide is plain HTML and CSS with a few lines of script, and every link in it is relative, so it works on its own hostname and under any path. Edit guide/, run the checks and publish from main with forge site deploy guide.