01 a push
What happens when you push
A push is how almost everything changes. Here is one, followed through the server.
- your machine
git push, orforge push, which also merges the server's CI notes with yours, pushes them along and passes options such as a review title. - sshd
Your key matches a line in
authorized_keysthat forcesforge 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 togit-shell, which runs git or one of the commands ingit-shell-commands/(new,ls,reviews,land,comment,who,passkey,tokenand the rest;helplists them). An admin's session also finds the admins' own,invite,invites,usersandmotd.bin/forge: cmd_shell
- pre-receive
Decides who may push which refs, refuses to delete
mainor 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 newmainwithout a verified passing CI attestation or another person's approval.bin/forge: hook_pre_receive, scan_secrets, check_release
- git
The refs move.
- post-receive
Goes through the updated refs:
- a branch other than
maingets 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
- a branch other than
- your git push returns here; the rest runs in the background
- 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
builtevent. Builds take turns under a lock (the build).bin/forge: cmd_build, eleventy.config.ts, lib/forge.ts
- your browser
Open pages hold a server-sent events stream from
/events/<owner>/<repo>/. The push shows up ascat 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
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.
| path | what it is |
|---|---|
bin/forge | The 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.ts | Finds 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.ts | Loads 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.ts | Review logs plus live refs become stacks, layers, statuses and range-diffs. |
lib/attest.ts | Parses attestation notes and verifies their signatures with ssh-keygen, remembering each result. |
lib/sites.ts, lib/hosts.ts | Deploy 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.ts | The long-running servers: live updates over server-sent events; private pages, sign-in, and comments and approvals from the web; sites on their own hostnames. |
lib/identity.ts, lib/access.ts, lib/login.ts | Web credentials in identity.db (passkeys, sessions, tokens, invites), who may read what, and passkey sign-in. |
lib/, the rest | git.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.ts | Templates, 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
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.
fox ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... fox@laptop restrict,command="/opt/forge/bin/forge shell fox" ssh-ed25519 AAAAC3Nz... fox@laptop Who is pushing: hooks see FORGE_USER=fox.
fox 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.
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:
| ref | owner, collaborators | anyone else who can read it, e.g. fox |
|---|---|---|
main (the default branch) | fast-forward only, unless forge.allowforce; never deleted | no |
refs/heads/ | yes | yes, their own prefix |
| other branches | yes | no |
refs/ | yes | yes |
refs/, refs/, refs/ | yes | no |
refs/ | yes, and may rewrite | add only: fast-forward, no delete |
refs/ | internal | internal |
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/. The build gets FORGE_SITE_BASE, the path it will be served from (/ on its own hostname).
- refrefs/sites/docsa deploy commit, signed
- post-receive extracts its tree, once per tree
- store.forge/store/339f775e.../
- symlink
- servedwww/
sites/ cat/ greet/ docs - symlink, with a pages domain
- hostnamewww/hosts/greet-docs
- the pages server looks up the Host header
- browserhttps://
greet-docs .pages.example.com/
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>/, 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:
| process | listens | as | why |
|---|---|---|---|
| sshd | :2222 | root | git over ssh, only for the git user, keys only. |
| fcgiwrap | unix socket | git | Runs git http-backend for HTTPS clones; Caddy routes only info/refs and git-upload-pack to it. |
| tailcat | the tunnel | root | Only with FORGE_TAILCAT_ALLOW: carries :2222 and :8080 to the edge. |
| events server | 127.0.0.1:8091 | git | Tails events.jsonl, streams it at /events/. |
| private server | 127.0.0.1:8093 | git | Every path www has no file for: private pages for their readers, /login/, comment and approve posts, and the one 404. |
| litestream | git | Only with FORGE_BACKUP_REPOSITORY: copies identity.db to the backup bucket as it changes. | |
| pages server | 127.0.0.1:8092 | caddy | Only with FORGE_PAGES_DOMAIN: sites on their own hostnames. Runs as caddy because deploys are untrusted. |
forge build | git | Once at boot, in the background, since templates may have changed with the image; after that, after each push. | |
| backup loop | root | Only 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 mirror | git | Only with FORGE_MIRROR_S3: after builds, at most hourly, syncs the forge's pages to the bucket the edge falls back to. | |
| Caddy | :8080 | caddy | In 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.
- 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.
- raw TCP for :22, TLS ends here for :443
- Fly edge
deploy/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. - tailcat: point-to-point WireGuard on Tailscale's data plane, with no tailnet, accounts or control plane
- forge container on a home box
deploy/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
- internetssh on :22, https on :443
- straight in
- a Debian or Ubuntu VPS
server/provision.shinstalls 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
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
- post-receive
Starts
forge build <owner>/<repo>withnohup, logging tobuild.log.FORGE_SYNC_BUILD=1runs it in the foreground instead;FORGE_NO_BUILD=1skips it. - lock
mkdir .forge/build.lock. Builds wait their turn, for up to 10 minutes. - graph
gitadds a small layer for the new commits, with Bloom filters, socommit-graph write --reachable --changed-paths --split 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. - data
11ty loads
lib/forge.tswithFORGE_ONLYset, 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. - 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.
- 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. - 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.jsonlists them. - 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/andhosts/belong to the hooks and are never touched. - after
A line in
metrics.jsonl(forge metricsreads it) and abuiltevent that open pages act on.
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.
| url | page |
|---|---|
/<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.xml | wiki, deploys and previews, feed |
/<owner>/<repo>.git | HTTPS 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
mainmoves, 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.advertisePushOptionsandtrue commitGraph.changedPathsVersion; see upkeep.2
your git config
- forge.signingkey, forge.allowedsigners
- Sign with this key; verify against this file.
- forge.host, forge.remote, forge.base
- Where
forge importgoes; which remote and base branch the other commands use. - forge.cidir, forge.nix
- Where CI worktrees live;
forge.nix falseskipsnix 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 ), 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 foxrevokes their keys and web credentials.user invitemakes a link instead. - forge-admin repo new cat/greet "description"
- Or
repo import cat/greet <url>to fetch one server side;repo rmandrepo restorefor the trash. - forge-admin collaborators cat/greet add fox
- The same lists the owner keeps over ssh, for any repo;
readerstoo. - forge-admin build [owner/repo]
- Rebuild pages;
metricsshows 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.
backuptakes 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;allchecks 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, listusersand set themotdover 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/sitesorwww/previewslink 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 ) or the git user's on a VPS.
Repos are maintained by the same forge gc, in repack_repo: git 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 , 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 orlib/change. It never writes todemo/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 attestruns it and signs the result. - npx tsc -p .
- Type check only.
tscnever 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.