The guide is now what you can do, a command or two each: browse, reviews, CI, sites and releases, people, the cheat sheet, and what isn't there yet. The push walkthrough, identity files, ref rules, deploy and release internals, hosting and the server's own commands are on the internals page, updated for the private server. Prior art is in the README only. The guide's tokens match the forge's, and its tables and cheat sheet sit in panels.
- commit
- 2938c4b214517a73b983cf0d2bdfe656b904a9eb
- parent
- 5f38414ad68b715997d0e89fc5ac31305f3b362c
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 93s, signed by cat
- kind
- docs risk score 0.8 of 3, Jev's estimate
- review
- #23 The guide says what forge is; internals says how
ci attestation
forge-attestation 1 type ci commit 2938c4b214517a73b983cf0d2bdfe656b904a9eb tree f4e169a119a897c363d29009f2ba1ece3d61a05a script .forge/ci 91d640bd19a231e82d21cc0a10d907253d9b0f41 result pass exit 0 started 2026-10-19T06:23:27Z duration 93s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:51012ee8bfe087305df93b145d50f8de3f4050fbc05bfcb01f7f72611271da6a 295 lines |pushed wip/i18n |d2cd892 running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |d2cd892 pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |7c3a578 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |7c3a578 fail in 1s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |cd8aa3f running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |cd8aa3f pass in 0s, signed |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |warning: You appear to have cloned an empty repository. |site home: deployed 1564de7 -> /sites/ana/dotfiles/home/ |created ana/notes |remote: review #1: /ana/notes/reviews/1/ |pushed ana/todo |landing #1 A todo list: ana/todo -> main |57be798 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |57be798 pass in 0s, signed |pushed main |6526c9c running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |6526c9c pass in 0s, signed |remote: review #7: /dami/greet/reviews/7/ |pushed dami/regions |created dami/forge |warning: You appear to have cloned an empty repository. |site guide: mkdir -p guide/fonts && cp site/assets/fonts/*.woff2 guide/fonts/ |site guide: deployed a6205b4 -> /sites/dami/forge/guide/ |(node:93662) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/dotfiles in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.sex5pw/wt/demo/root/.forge/www |(node:93833) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built ana/notes in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.sex5pw/wt/demo/root/.forge/www |(node:94011) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/forge in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.sex5pw/wt/demo/root/.forge/www |(node:94179) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built dami/greet in 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.sex5pw/wt/demo/root/.forge/www |(node:94894) ExperimentalWarning: stripTypeScriptTypes is an experimental feature and might change at any time |(Use `node --trace-warnings ...` to show where the warning was created) |built - in 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.sex5pw/wt/demo/root/.forge/www |235 of 235 pages render the same without 11ty |guide: 8 files, links ok |ok
Signature: good, cat
| M | guide/ | 124 | ++++++---- |
| M | guide/ | 393 | +++++++++++++++--------------- |
| M | guide/ | 229 | ++++++++++++++---- |
@@ -18,14 +18,15 @@
1818 @font-face { font-family: "Noto Serif"; src: url(fonts/noto-serif.woff2) format("woff2"); font-weight: 400 700; font-style: normal; font-display: swap; }1919 @font-face { font-family: "Noto Serif"; src: url(fonts/noto-serif-italic.woff2) format("woff2"); font-weight: 400 700; font-style: italic; font-display: swap; }2020 :root {21- /* colour: the page, surfaces raised over it, ink in three strengths,22- rules, and the accent */21+ /* colour: the page, panels raised over it with their tinted head strip,22+ ink in three strengths, rules, and the accent; the forge's own values */2323 --bg: #e9e9e9;2424 --raised: #ffffff;25- --hover: #f4f4f4;25+ --tint: #f6f6f6;26+ --hover: #f2f3f5;2627 --fg: #1c2127;2728 --muted: #5b6169;28- --faint: #7c828a;29+ --faint: #6f757d;2930 --border: #d2d4d7;3031 --border-strong: #a9aeb4;3132 --link: #3148f6;@@ -55,10 +56,10 @@
5556 --text-code: 12.5px;5657 --text-sm: 13.5px;5758 --text-md: 15px;58- --text-prose: 17px;59+ --text-prose: 16px;5960 --text-lg: 18px;6061 --text-xl: 22px;61- --text-2xl: 30px;62+ --text-2xl: 28px;6263 --text-3xl: clamp(2.4rem, 9vw, 4rem);6364 /* mono inline in serif text runs large; this brings it level */6465 --text-inline-mono: 0.88em;@@ -88,11 +89,12 @@
8889 :root {8990 --bg: #08090b;9091 --raised: #111418;91- --hover: #15181d;92+ --tint: #15191e;93+ --hover: #191d23;9294 --fg: #e2e4e7;9395 --muted: #8f959d;94- --faint: #6b7178;95- --border: #1f2329;96+ --faint: #7d838b;97+ --border: #252a31;9698 --border-strong: #3a4048;9799 --link: #8e9bff;98100 --deep: #b4bcff;@@ -223,38 +225,39 @@ b, strong { font-weight: var(--fw-strong); }
223225 .commit h2 { font-size: var(--text-2xl); }224226 }225227 228+/* panels, as the forge's (DESIGN.md): a raised surface on a hairline, an229+ optional head strip on the tint naming what it holds, rows between230+ hairlines with none under the last */231+.panel { margin: var(--space-5) 0 var(--space-6); min-width: 0; background: var(--raised); border: var(--hairline) solid var(--border); }232+.panel-head { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1) var(--space-3); margin: 0; padding: var(--space-2) var(--space-3); background: var(--tint); border-bottom: var(--hairline) solid var(--border); font: var(--fw-regular) var(--text-sm)/1.5 var(--sans); color: var(--muted); }233+.panel-head b { font-weight: var(--fw-medium); color: var(--fg); }234+.panel-head .end { margin-left: auto; }235+.panel-head .n { font: var(--fw-regular) var(--text-xs) var(--mono); color: var(--faint); }236+.panel > .table { margin: 0; border: 0; }237+.panel th:first-child, .panel td:first-child { padding-left: var(--space-3); }238+.panel th:last-child, .panel td:last-child { padding-right: var(--space-3); }239+/* a command or two between paragraphs */240+.commit > .code { margin: var(--space-4) 0 var(--space-5); }241+226242 /* figures: a raised plate on a hairline, a caption under it */227243 figure { margin: var(--space-5) 0 var(--space-6); }244+/* what is in a figure is labels and notes, in the sans; shas, paths and245+ commands in the mono */246+.plate { font-family: var(--sans); line-height: 1.5; }228247 .plate { border: var(--hairline) solid var(--border); background: var(--raised); padding: var(--space-4); overflow: hidden; }229248 figcaption { font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-2); }230249 figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: var(--space-2); }231250 @media (min-width: 720px) { .plate { padding: 22px var(--space-6); } }232251 233-/* fig: three parents merged into forge */234-.merge .parents { list-style: none; margin: 0; padding: 0 0 0 26px; display: grid; gap: 10px; position: relative; }235-.merge .parents::before { content: ""; position: absolute; left: 6px; top: 18px; bottom: -26px; border-left: 2px solid var(--accent); }236-.merge .parents li { position: relative; background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 10px var(--space-3); }237-.merge .parents li::before { content: ""; position: absolute; left: -21px; top: 16px; width: 20px; border-top: 2px solid var(--accent); }238-.merge .parents b { display: block; font-family: var(--sans); font-size: var(--text-md); font-weight: var(--fw-strong); }239-.merge .parents span { display: block; font-size: var(--text-md); color: var(--muted); line-height: 1.5; margin-top: var(--space-half); }240-.merge .child { position: relative; margin: 14px 0 0; display: flex; align-items: center; gap: 10px; font-family: var(--sans); font-size: var(--text-lg); font-weight: var(--fw-strong); }241-.merge .child .mark { width: 22px; height: 22px; background: var(--raised); position: relative; }242-.merge .child small { font-size: var(--text-sm); font-weight: var(--fw-regular); color: var(--muted); }243-@media (min-width: 720px) {244- .merge .parents { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; padding: 0 0 28px; }245- .merge .parents::before { left: calc((100% - 28px) / 6); right: calc((100% - 28px) / 6); top: auto; bottom: 0; border-left: 0; border-top: 2px solid var(--accent); }246- .merge .parents li::before { left: 50%; top: 100%; width: 0; height: 29px; border-top: 0; border-left: 2px solid var(--accent); }247- .merge .child { justify-content: center; margin-top: 0; padding-top: 18px; }248- .merge .child::before { content: ""; position: absolute; left: 50%; top: 0; height: 18px; border-left: 2px solid var(--accent); }249-}250-251252 /* fig: the push, as a trace down one line */252253 .flow { list-style: none; margin: 0; padding: 0; position: relative; --x: 6px; }253254 .flow::before { content: ""; position: absolute; left: var(--x); top: 8px; bottom: 14px; width: 2px; background: var(--border); }254255 .flow > li { position: relative; padding: 0 0 var(--space-4) 26px; }255-.flow > li::before { content: ""; position: absolute; left: calc(var(--x) - 5px); top: 7px; width: 8px; height: 8px; background: var(--raised); border: 2px solid var(--accent); }256-.flow .where { display: block; font-family: var(--mono); font-size: var(--text-code); font-weight: var(--fw-strong); color: var(--fg); }257-.flow p { margin: var(--space-half) 0 0; font-size: var(--text-md); }256+/* the marker: centred on the rail and on the first 24px line */257+.flow > li::before { content: ""; position: absolute; left: calc(var(--x) - 4px); top: 7px; width: 10px; height: 10px; background: var(--raised); border: 2px solid var(--accent); }258+.flow .where { display: block; font-family: var(--mono); font-size: var(--text-code); line-height: 24px; font-weight: var(--fw-strong); color: var(--fg); }259+.flow p { margin: 0; font-size: var(--text-md); line-height: 24px; }260+.flow p + p { margin-top: var(--space-1); }258261 .flow .src { font-family: var(--mono); font-size: var(--text-xs); color: var(--muted); margin-top: var(--space-1); }259262 .flow ul { margin: 6px 0 0; padding: 0; list-style: none; font-size: var(--text-md); }260263 .flow ul li { position: relative; padding-left: var(--space-4); margin: 3px 0; }@@ -265,29 +268,39 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
265268 @media (min-width: 720px) {266269 .flow { --x: 148px; }267270 .flow > li { display: grid; grid-template-columns: 128px 1fr; gap: 0 40px; padding-left: 0; }268- .flow .where { text-align: right; padding-top: 3px; }271+ .flow .where { text-align: right; }269272 .flow .returns { grid-template-columns: 1fr; padding-left: 168px; }270273 }271274 272-/* fig: a stack of reviews, as a graph */273-.graph { list-style: none; margin: 0; padding: 0; font-size: var(--text-md); }274-.graph ol { list-style: none; margin: 0; padding: 0; }275-.graph .layer { position: relative; display: grid; gap: var(--space-1) 18px; padding: 6px 0; }276-.graph .layer::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: var(--accent); }277-.graph > .layer:first-child::before { top: 1.3em; }278-.graph .base::before { background: var(--faint); bottom: auto; height: 1.3em; }279-.graph .c { position: relative; padding: 5px 0 5px 28px; display: flex; flex-wrap: wrap; gap: var(--space-half) var(--space-2); align-items: baseline; }280-.graph .c::after { content: ""; position: absolute; left: 1px; top: 0.85em; width: 8px; height: 8px; background: var(--raised); border: 2px solid var(--accent); }275+/* fig: a stack of reviews, as a graph. Every commit is one row of the276+ same height, its marker centred on the row, so the rail's segments277+ between markers are equal; a layer's review is a bracket beside its278+ rows, which on phones shows only its number. */279+.graph { list-style: none; margin: 0; padding: 0; --row: 32px; }280+.graph ol { list-style: none; margin: 0; padding: 0; min-width: 0; }281+.graph .layer { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 0 var(--space-3); }282+/* sha, subject and branch share one baseline, centred in the row */283+.graph .c { position: relative; display: flex; align-items: baseline; gap: var(--space-2); height: var(--row); padding-left: 28px; font-size: var(--text-md); line-height: var(--row); white-space: nowrap; }284+/* the rail through the row, and the marker on it */285+.graph .c::before { content: ""; position: absolute; left: 6px; top: 0; bottom: 0; width: 2px; background: var(--accent); }286+.graph > .layer:first-child > ol > .c:first-child::before { top: 50%; }287+.graph .base .c::before { bottom: 50%; background: var(--faint); }288+.graph .c::after { content: ""; position: absolute; left: 2px; top: 50%; width: 10px; height: 10px; margin-top: -5px; background: var(--raised); border: 2px solid var(--accent); }281289 .graph .base .c::after { border-color: var(--faint); }282-.graph code { font-size: var(--text-xs); color: var(--muted); }290+.graph code { flex: none; font-size: var(--text-xs); color: var(--muted); }291+.graph .s { min-width: 0; overflow: hidden; text-overflow: ellipsis; }292+.graph .pill { flex: none; line-height: 1.5; }283293 /* a branch name on a commit: a square label on a hairline */284294 .pill { font-family: var(--mono); font-size: var(--text-xs); padding: 0 6px; border: var(--hairline) solid var(--accent); color: var(--link); white-space: nowrap; }285295 .pill.main { border-color: var(--border-strong); color: var(--muted); }286-.graph .tag { margin: 0 0 var(--space-1) 28px; font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); border-left: 2px solid var(--border); padding: var(--space-half) 0 var(--space-half) 10px; }287-.graph .tag b { display: block; color: var(--fg); }296+.graph .tag { display: flex; flex-direction: column; justify-content: center; margin: var(--space-1) 0; padding: 0 0 0 10px; border-left: 2px solid var(--accent); font-size: var(--text-sm); line-height: 1.4; color: var(--muted); }297+.graph .tag b { color: var(--fg); }298+.graph .tag .t, .graph .tag small { display: none; }299+.graph .tag small { font-size: var(--text-sm); }288300 @media (min-width: 720px) {289- .graph .layer { grid-template-columns: minmax(0, 1fr) 210px; align-items: center; }290- .graph .tag { margin: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: center; border-left: 2px solid var(--accent); }301+ .graph .layer { grid-template-columns: minmax(0, 1fr) 210px; }302+ .graph .tag .t { display: inline; }303+ .graph .tag small { display: block; }291304 }292305 293306 /* code-ish figures: range-diff, statements */@@ -302,8 +315,8 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
302315 303316 .annotated { display: grid; gap: var(--space-4); }304317 .annotated ol { margin: 0; padding: 0; list-style: none; counter-reset: n; font-size: var(--text-md); }305-.annotated ol li { counter-increment: n; position: relative; padding-left: 30px; margin: 0 0 var(--space-2); }306-.annotated ol li::before { content: counter(n); position: absolute; left: 0; top: 0.25em; min-width: 1.6em; padding: 0 var(--space-1); background: var(--accent); color: var(--on-accent); font: var(--fw-strong) var(--text-xs)/1.5 var(--mono); text-align: center; }318+.annotated ol li { counter-increment: n; position: relative; padding-left: 30px; margin: 0 0 var(--space-2); line-height: 24px; }319+.annotated ol li::before { content: counter(n); position: absolute; left: 0; top: 3px; min-width: 1.6em; padding: 0 var(--space-1); background: var(--accent); color: var(--on-accent); font: var(--fw-strong) var(--text-xs)/1.5 var(--mono); text-align: center; }307320 .annotated figcaption { grid-column: 1 / -1; margin: 0; }308321 @media (min-width: 900px) { .annotated { grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr); align-items: start; } }309322 @@ -323,8 +336,9 @@ figcaption b { color: var(--fg); font-weight: var(--fw-strong); margin-right: va
323336 .chain { list-style: none; margin: 0; padding: 0; }324337 .chain li { display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-half) var(--space-3); align-items: baseline; }325338 .chain .k { font-family: var(--sans); font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); }326-.chain .v { font-family: var(--mono); font-size: var(--text-code); background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 6px 10px; overflow-wrap: anywhere; }327-.chain .v small { display: block; font-family: var(--serif); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-half); }339+/* values break only where they have <wbr>, never at a hyphen */340+.chain .v { font-family: var(--mono); font-size: var(--text-code); background: var(--raised); border: var(--hairline) solid var(--border-strong); padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }341+.chain .v small { display: block; white-space: normal; font-family: var(--sans); font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-half); }328342 .chain .hop { min-height: 34px; }329343 .chain .hop span { grid-column: -2; position: relative; padding: 6px 0 6px 26px; font-family: var(--sans); font-size: var(--text-xs); color: var(--muted); }330344 .chain .hop span::before { content: ""; position: absolute; left: 11px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }@@ -350,6 +364,8 @@ td.y::before { content: "✓ "; font-weight: var(--fw-strong); color: var(--pass
350364 td.n::before { content: "✗ "; font-weight: var(--fw-strong); color: var(--fail); }351365 td.n { color: var(--muted); }352366 td code, th code { font-size: var(--text-code); white-space: normal; }367+/* code that never breaks at its hyphens, only where it has <wbr> */368+table.whole code { white-space: nowrap; }353369 @media (max-width: 719px) { th, td { padding: var(--space-2) var(--space-2); } }354370 /* tables marked .rows restack on phones: the first cell on its own line,355371 then the rest, labelled from data-l when there is more than one */@@ -362,6 +378,9 @@ td code, th code { font-size: var(--text-code); white-space: normal; }
362378 table.rows td:not([data-l]):not(:first-child) { grid-column: 1 / -1; padding-top: 3px; }363379 table.rows td[data-l] { position: relative; padding-top: var(--space-6); }364380 table.rows td[data-l]::after { content: attr(data-l); position: absolute; left: 0; top: 6px; font-size: var(--text-xs); font-weight: var(--fw-medium); color: var(--muted); }381+ .panel table.rows tr { padding: 0 var(--space-3); }382+ table.rows.three tr { grid-template-columns: 1fr 1fr 1fr; }383+ .panel table.rows :is(th, td) { padding-left: 0; padding-right: 0; }365384 }366385 367386 /* fig: one key, two generated files */@@ -390,13 +409,18 @@ td code, th code { font-size: var(--text-code); white-space: normal; }
390409 .hops .wire::before { content: ""; position: absolute; left: 18px; top: 0; bottom: 0; border-left: 2px solid var(--accent); }391410 .hops .wire.tunnel::before { border-left: 6px double var(--accent); left: 16px; }392411 393-/* cheat sheet: groups under a strong rule, entries between hairlines */412+/* cheat sheet: groups under a strong rule, entries between hairlines; in413+ panels, each group's name is its head strip */394414 .cheat { display: grid; gap: var(--space-6) var(--space-8); }395415 @media (min-width: 900px) { .cheat { grid-template-columns: 1fr 1fr; } }396416 .cheat section { border-top: var(--hairline) solid var(--border-strong); }397417 .cheat h3 { margin: 0; padding: var(--space-2) 0 var(--space-1); font-size: var(--text-sm); font-weight: var(--fw-strong); color: var(--muted); }398418 .cheat dl { margin: 0; }399419 .cheat dl > div { padding: var(--space-2) 0; border-bottom: var(--hairline) solid var(--border); }420+.cheat.panels { gap: var(--space-4); align-items: start; }421+.cheat section.panel { margin: 0; border-top: var(--hairline) solid var(--border); }422+.cheat h3.panel-head { padding: var(--space-2) var(--space-3); font-size: var(--text-sm); font-weight: var(--fw-medium); color: var(--fg); }423+.cheat .panel dl > div { padding: var(--space-2) var(--space-3); }400424 .cheat dl > div:last-child { border-bottom: 0; }401425 .cheat dt { font-family: var(--mono); font-size: var(--text-code); line-height: 1.5; overflow-wrap: anywhere; }402426 .cheat dt::before { content: "$ "; color: var(--link); }@@ -3,8 +3,8 @@
33 <head>44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width, initial-scale=1">6-<title>forge: a git forge made of plain git</title>7-<meta name="description" content="How forge works: plain git over ssh, static pages built after each push, reviews as stacked branches, CI signed on your own machine, and sites that ship with repos.">6+<title>forge: your code, its reviews, CI and sites, on your own server</title>7+<meta name="description" content="forge keeps your code, its reviews, CI results, sites and releases on your own server, as a static site. Changes go through git and ssh.">88 <meta name="theme-color" content="#e9e9e9" media="(prefers-color-scheme: light)">99 <meta name="theme-color" content="#08090b" media="(prefers-color-scheme: dark)">1010 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%231c2127%7Dpath%7Bstroke:%233148f6%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23e2e4e7%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8'/%3E%3C/svg%3E">@@ -13,7 +13,7 @@
1313 <script src="guide.js" defer></script>1414 </head>1515 <body>16-<a class="skip" href="#main">Skip to the guide</a>16+<a class="skip" href="#main">skip to the guide</a>1717 <header class="bar">1818 <div class="wrap">1919 <a class="brand" href="./"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge <span>guide</span></a>@@ -26,30 +26,29 @@
2626 <div class="hero-text">2727 <p class="eyebrow">a personal git forge</p>2828 <h1 id="top"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge</h1>29-<p class="lede">Plain git on a server, and web pages written after every push. Reviews are branches, CI runs on <em>your</em> machine and is signed with your SSH key, and any repo can ship a static site.</p>30-<p class="cta"><a class="btn go" href="#push">follow a push</a><a class="btn" href="#cli">cheat sheet</a><a class="btn" href="internals.html">internals</a></p>29+<p class="lede">Your code, its reviews, CI results, sites and releases, on your own server, as a static site. Changes go through git and ssh.</p>30+<p class="cta"><a class="btn go" href="#what">start reading</a><a class="btn" href="#cli">cheat sheet</a><a class="btn" href="internals.html">internals</a></p>3131 </div>32-<figure class="term" aria-label="A terminal session: attest, push, deploy a preview">33-<div class="chrome" aria-hidden="true">~/greet (dami/flags)</div>34-<pre><span class="line" style="--i:0"><span class="p">$ </span>forge attest</span><span class="line o" style="--i:1">80b9dda running .forge/ci: add --shout</span><span class="line o" style="--i:2"> | ok greet --shout</span><span class="line" style="--i:3">80b9dda <span class="ok">pass</span> in 2s, signed</span><span class="line" style="--i:4"><span class="p">$ </span>forge push -t "Flags"</span><span class="line o" style="--i:5">pushed dami/flags</span><span class="line" style="--i:6"><span class="p">$ </span>forge site deploy docs</span><span class="line o" style="--i:7">site docs: sh docs/build.sh</span><span class="line r" style="--i:8">remote: site docs/dami~flags: <span class="u">https://greet-docs--dami-flags.pages.example.com/</span></span><span class="line o" style="--i:9">site docs: deployed 942ff39 -> https://greet-docs--dami-flags.pages.example.com/</span></pre>32+<figure class="term" aria-label="A terminal session: attest, push for review, deploy a preview">33+<div class="chrome" aria-hidden="true">~/greet (cat/flags)</div>34+<pre><span class="line" style="--i:0"><span class="p">$ </span>forge attest --stack</span><span class="line o" style="--i:1">afd124b running .forge/ci: parse --name</span><span class="line" style="--i:2">afd124b <span class="ok">pass</span> in 1s, signed</span><span class="line o" style="--i:3">80b9dda running .forge/ci: add --shout</span><span class="line" style="--i:4">80b9dda <span class="ok">pass</span> in 2s, signed</span><span class="line" style="--i:5"><span class="p">$ </span>forge push -t "Flags"</span><span class="line r" style="--i:6">remote: review #2: <span class="u">https://src.rest/cat/greet/reviews/2/</span></span><span class="line" style="--i:7"><span class="p">$ </span>forge site deploy docs</span><span class="line o" style="--i:8">site docs: deployed 942ff39 -> <span class="u">https://greet-docs--cat-flags.pages.src.rest/</span></span></pre>3535 </figure>3636 </div>3737 </section>3838 3939 <div class="wrap page">40-<nav class="toc" aria-label="On this page">40+<nav class="toc" aria-label="on this page">4141 <div>4242 <h2>on this page</h2>4343 <ol>44-<li><a href="#what"><span>01</span>what and why</a></li>45-<li><a href="#push"><span>02</span>a push</a></li>44+<li><a href="#what"><span>01</span>what forge is</a></li>45+<li><a href="#browse"><span>02</span>browse</a></li>4646 <li><a href="#reviews"><span>03</span>reviews</a></li>4747 <li><a href="#ci"><span>04</span>ci</a></li>48-<li><a href="#sites"><span>05</span>sites</a></li>49-<li><a href="#identity"><span>06</span>identity</a></li>50-<li><a href="#hosting"><span>07</span>hosting</a></li>51-<li><a href="#cli"><span>08</span>cheat sheet</a></li>52-<li><a href="#not-yet"><span>09</span>not yet</a></li>48+<li><a href="#sites"><span>05</span>sites and releases</a></li>49+<li><a href="#people"><span>06</span>people</a></li>50+<li><a href="#cli"><span>07</span>day to day</a></li>51+<li><a href="#not-yet"><span>08</span>not yet</a></li>5352 </ol>5453 </div>5554 </nav>@@ -57,61 +56,83 @@
5756 <main id="main" class="log">5857 5958 <section class="commit" id="what" aria-labelledby="what-h">60-<header><p class="ref"><b>01</b> what and why</p><h2 id="what-h"><a href="#what">A forge that is mostly files</a></h2></header>59+<header><p class="ref"><b>01</b> what forge is</p><h2 id="what-h"><a href="#what">Your forge, on your own server</a></h2></header>6160 <div class="prose">62-<p>A forge is usually an application with a database. This one is the setup from the Git book's <a href="https://git-scm.com/book/en/v2/Git-on-the-Server-Setting-Up-the-Server">Git on the Server</a> chapter: a <code>git</code> user, SSH keys and bare repositories, plus two hooks. After each push an <a href="https://www.11ty.dev/">11ty</a> build writes the repositories out as static HTML, the way <a href="https://git.suckless.org/">stagit</a> does. Reading a page is reading a file.</p>63-<p>What stagit doesn't have is borrowed from <a href="https://args.io">args.io</a>: every branch is browsable, reviews are stacked diffs, each push of a review is a revision, CI runs on your own machine, and repos can ship sites.</p>61+<p>forge keeps your repositories on your own server and publishes them as a static site: every branch, file and commit, the reviews on them, the CI result of every commit, and each repo's sites and releases. The pages work without JavaScript and update while you read them.</p>62+<p>Changes go through git and ssh. You push code, reviews, sites and releases; you run an ssh command to make a repo, land a review or invite someone. On the web you read. Sign in with a passkey to comment and approve. A push that adds a password or a key is refused.</p>63+</div>64+<div class="panel">65+<div class="panel-head"><b>what you do, and what you get</b></div>66+<div class="table">67+<table class="rows">68+<thead><tr><th scope="col">you</th><th scope="col">with</th><th scope="col">the forge shows</th></tr></thead>69+<tbody>70+<tr><th scope="row">push code</th><td data-l="with"><code>git push</code></td><td data-l="the forge shows">a page for every branch, directory, file and commit</td></tr>71+<tr><th scope="row">push a branch</th><td data-l="with"><code>forge push</code></td><td data-l="the forge shows">a review, with a page for each revision</td></tr>72+<tr><th scope="row">run CI</th><td data-l="with"><code>forge attest</code></td><td data-l="the forge shows">a signed pass or fail on each commit</td></tr>73+<tr><th scope="row">publish a site</th><td data-l="with"><code>forge site deploy</code></td><td data-l="the forge shows">the site on its own address, and a preview per branch</td></tr>74+<tr><th scope="row">cut a release</th><td data-l="with"><code>forge release publish</code></td><td data-l="the forge shows">downloads with signed checksums</td></tr>75+<tr><th scope="row">invite someone</th><td data-l="with"><code>ssh git@src.rest invite</code></td><td data-l="the forge shows">a one-time link to join, as a collaborator or reader</td></tr>76+</tbody>77+</table>6478 </div>65-<figure class="plate merge">66-<ol class="parents">67-<li><b>Git on the Server</b><span>One <code>git</code> account, SSH keys and bare repos. Identity is a file of keys, each forced through one command.</span></li>68-<li><b>stagit</b><span>Log, files, blobs and refs as plain pages, written when something is pushed instead of computed per request.</span></li>69-<li><b>args.io</b><span>Reviews as stacked branches with revisions, CI attested on your machine, a site per repo.</span></li>70-</ol>71-<p class="child"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge <small>a merge of three ideas</small></p>72-<figcaption><b>fig. 1</b>where it comes from</figcaption>73-</figure>74-<div class="prose">75-<p>There is no database and nothing to log in to. Everything that changes the forge is a <code>git push</code>, an ssh command, or an admin command on the server, usually by way of <code>forge</code>, one POSIX shell script. Pages also have keyboard navigation, clean URLs such as <code>/dami/greet/tree/main/greet.sh/</code>, an Atom feed per repo, a wiki from <code>wiki/</code>, syntax highlighting done at build time, and a <kbd>/</kbd> search over commits, reviews, wiki pages and paths. <kbd>?</kbd> lists the shortcuts.</p>7679 </div>80+<p class="prose">The examples use <a href="https://src.rest/">src.rest</a>, where forge hosts itself as <code>cat/forge</code>. <code>cat</code> runs it; <code>fox</code> is another user.</p>7781 </section>7882 79-<section class="commit" id="push" aria-labelledby="push-h">80-<header><p class="ref"><b>02</b> a push</p><h2 id="push-h"><a href="#push">What happens when you push</a></h2></header>81-<p class="prose">A push is the only way anything changes. Here is one, followed through the server.</p>82-<figure class="plate">83-<ol class="flow">84-<li><span class="where">your machine</span><div><p><code>git push</code>, or <code>forge push</code>, which also merges the server's CI notes with yours, pushes them along, and passes options such as a review title.</p></div></li>85-<li><span class="where">sshd</span><div><p>Your key matches a line in <code>authorized_keys</code> that forces <code>forge shell <name></code>. It records who you are and hands the command to <code>git-shell</code>, which runs git and a few commands: <code>new</code>, <code>ls</code>, <code>whoami</code>, <code>signers</code>, <code>site-url</code>, <code>help</code>.</p><p class="src">bin/forge: cmd_shell</p></div></li>86-<li><span class="where">pre-receive</span><div><p>Decides who may push which refs, refuses to delete <code>main</code> or force-push it (unless the repo allows that), and, when the repo asks for it, refuses a new <code>main</code> without a verified passing CI attestation.</p><p class="src">bin/forge: hook_pre_receive</p></div></li>87-<li><span class="where">git</span><div><p>The refs move.</p></div></li>88-<li><span class="where">post-receive</span><div><p>Goes through the updated refs:</p><ul><li>a branch other than <code>main</code> gets a revision in its review log, and its tip is pinned;</li><li>a site or preview deploy is extracted and linked into the web root, and so are a release's files;</li><li>a deleted branch takes its previews with it;</li><li>the push is appended to <code>.forge/events.jsonl</code>.</li></ul><p class="src">bin/forge: hook_post_receive, record_review, deploy_site</p></div></li>89-<li class="returns"><span>your git push returns here; the rest runs in the background</span></li>90-<li class="bg"><span class="where">forge build</span><div><p>11ty rebuilds that repo's pages into a scratch directory, hard-linking every page that didn't change from the live site, swaps them in, and appends a <code>built</code> event. Builds take turns under a lock.</p><p class="src">bin/forge: cmd_build, eleventy.config.ts, lib/forge.ts</p></div></li>91-<li class="bg"><span class="where">your browser</span><div><p>Open pages hold a server-sent events stream from <code>/events/<owner>/<repo>/</code>. The push shows up as <q>dami pushed dami/flags, updating</q>; when the build lands, the page fetches itself and swaps in the new content, keeping your scroll position.</p><p class="src">lib/events-server.ts, site/assets/live.ts</p></div></li>92-</ol>93-<figcaption><b>fig. 2</b>one push, from your terminal to every open page</figcaption>94-</figure>95-<p class="prose">Building after the push rather than on each request means pages cost nothing to serve, can be cached anywhere, and keep working if the two small Node servers stop. Those only add what files can't: the live updates, and sites on their own hostnames.</p>83+<section class="commit" id="browse" aria-labelledby="browse-h">84+<header><p class="ref"><b>02</b> browse</p><h2 id="browse-h"><a href="#browse">Every branch, file, commit and review is a page</a></h2></header>85+<div class="prose">86+<p>URLs are paths: <code>/cat/greet/tree/main/greet.sh/</code> is that file on <code>main</code>, and every branch has the same pages. Markdown is rendered and code is highlighted. The home page lists people, recent activity and repos by topic.</p>87+</div>88+<div class="panel">89+<div class="panel-head"><b>a repo's pages</b><span class="end"><span class="n">https://src.rest/cat/greet/...</span></span></div>90+<div class="table">91+<table class="rows">92+<thead><tr><th scope="col">page</th><th scope="col">what is on it</th></tr></thead>93+<tbody>94+<tr><td><code>/cat/greet/</code></td><td>open reviews, recent commits, the README</td></tr>95+<tr><td><code>tree/<branch>/</code>, <code>blob/<branch>/<path>/</code></td><td>files and directories on any branch</td></tr>96+<tr><td><code>log/<branch>/</code>, <code>commit/<sha>/</code></td><td>history, each commit with its diff and CI result</td></tr>97+<tr><td><code>reviews/</code>, <code>reviews/<id>/</code></td><td>reviews, as stacks, and each review's diffs and discussion</td></tr>98+<tr><td><code>wiki/</code>, <code>sites/</code>, <code>releases/</code></td><td>the wiki from <code>wiki/</code>, deployed sites and previews, downloads</td></tr>99+<tr><td><code>insights/</code></td><td>pushes, reviews and CI results over time, charted</td></tr>100+<tr><td><code>atom.xml</code></td><td>a feed of the newest commits on <code>main</code></td></tr>101+</tbody>102+</table>103+</div>104+</div>105+<div class="prose">106+<ul>107+<li><strong>Search.</strong> <kbd>/</kbd> searches a repo's commits, reviews, wiki, paths and file contents; <kbd>t</kbd> finds a file on the current branch.</li>108+<li><strong>The keyboard.</strong> <kbd>j</kbd> <kbd>k</kbd> move through a list, <kbd>n</kbd> <kbd>N</kbd> through a review's commits, <kbd>[</kbd> <kbd>]</kbd> up and down a stack, <kbd>v</kbd> marks a file viewed. <kbd>?</kbd> lists them all.</li>109+<li><strong>Live.</strong> An open page updates itself after a push, without losing your place.</li>110+<li><strong>Feeds.</strong> Every repo has an Atom feed of its newest commits.</li>111+<li><strong>Diagrams.</strong> <code>```mermaid</code> fences in any markdown file are drawn as diagrams, without JavaScript.</li>112+</ul>113+</div>96114 </section>97115 98116 <section class="commit" id="reviews" aria-labelledby="reviews-h">99-<header><p class="ref"><b>03</b> reviews</p><h2 id="reviews-h"><a href="#reviews">Reviews are branches</a></h2></header>100-<p class="prose">Push any branch other than <code>main</code> and it is a review. There is nothing to open: <code>post-receive</code> appends the push to <code><repo>.git/forge/reviews/<id></code> and pins the tip under <code>refs/forge/keep/</code>, so every revision stays reachable after a force push. The pages work out everything else from the refs.</p>117+<header><p class="ref"><b>03</b> reviews</p><h2 id="reviews-h"><a href="#reviews">Push a branch and it is a review</a></h2></header>118+<p class="prose">Any branch other than <code>main</code> is a review once pushed: its commits' diffs in order, plus a combined diff. A branch made from another review's branch stacks on top of it.</p>119+<pre class="code"><span class="dim">$</span> git switch -c cat/flags120+<span class="dim">$</span> forge push -t "parse --name" -d "Flags, parsed by hand"121+<span class="dim">remote: review #2: https://src.rest/cat/greet/reviews/2/</span></pre>101122 <figure class="plate">102123 <ol class="graph" aria-label="A stack: two reviews, each two commits, on top of main">103-<li class="layer"><ol><li class="c"><code>6771779</code> README: document flags <span class="pill">dami/help</span></li><li class="c"><code>dff8bca</code> add --help</li></ol><p class="tag"><b>#3 Usage and --help</b>layer 2, targets dami/flags</p></li>104-<li class="layer"><ol><li class="c"><code>80b9dda</code> add --shout <span class="pill">dami/flags</span></li><li class="c"><code>afd124b</code> parse --name</li></ol><p class="tag"><b>#2 parse --name</b>layer 1, targets main</p></li>105-<li class="layer base"><ol><li class="c"><code>895bc48</code> README: be polite <span class="pill main">main</span></li></ol></li>124+<li class="layer"><ol><li class="c"><code>6771779</code><span class="s">README: document flags</span><span class="pill">cat/help</span></li><li class="c"><code>dff8bca</code><span class="s">add --help</span></li></ol><p class="tag"><b>#3<span class="t"> Usage and --help</span></b><small>layer 2, on cat/flags</small></p></li>125+<li class="layer"><ol><li class="c"><code>80b9dda</code><span class="s">add --shout</span><span class="pill">cat/flags</span></li><li class="c"><code>afd124b</code><span class="s">parse --name</span></li></ol><p class="tag"><b>#2<span class="t"> parse --name</span></b><small>layer 1, on main</small></p></li>126+<li class="layer base"><ol><li class="c"><code>895bc48</code><span class="s">README: be polite</span><span class="pill main">main</span></li></ol></li>106127 </ol>107-<figcaption><b>fig. 3</b>a stack from the demo: dami/help was branched from dami/flags</figcaption>128+<figcaption><b>fig. 1</b>a stack: cat/help was branched from cat/flags</figcaption>108129 </figure>109130 <div class="prose">110131 <ul>111-<li><strong>Stack.</strong> A review's parent is the nearest other review whose tip lies inside its branch, so a branch built on another review's branch is detected as the next layer.</li>112-<li><strong>Layer.</strong> The commits between the parent (or <code>main</code>) and the tip. A review page is its commits' diffs in order, plus a combined diff.</li>113-<li><strong>Status.</strong> Merged once the tip is in <code>main</code>, closed when the branch is deleted, draft for <code>wip/...</code> branches or a push with <code>--draft</code>.</li>114-<li><strong>Revisions.</strong> Each push is one. Rewrite a branch and the review shows <code>git range-diff</code> between the two pushes.</li>132+<li><strong>Revisions.</strong> Each push is a revision. The page shows what changed since the last one, commit by commit, even after a rebase.</li>133+<li><strong>Stacks.</strong> <code>forge push --stack</code> pushes every layer, lowest first; <kbd>[</kbd> and <kbd>]</kbd> move between layers on the page.</li>134+<li><strong>Status.</strong> Open, draft (<code>--draft</code>, or a <code>wip/</code> branch), merged once it is in <code>main</code>, closed when its branch is deleted.</li>135+<li><strong>Reading.</strong> Files fold, can be marked viewed and stay viewed until they change.</li>115136 </ul>116137 </div>117138 <figure>@@ -126,17 +147,27 @@
126147 <span class="chg">4: ef1d881 ! 4: 6771779 README: document flags</span>127148 <span class="del"> -+ --shout uppercase the greeting</span>128149 <span class="add"> ++ --shout UPPERCASE the greeting</span></pre>129-<figcaption><b>fig. 4</b>review #3, r1 against r2, shortened: <code>=</code> unchanged, <code>!</code> changed</figcaption>150+<figcaption><b>fig. 2</b>what changed between two revisions, shortened: <code>=</code> unchanged, <code>!</code> changed</figcaption>130151 </figure>131152 <div class="prose">132-<p>Push options carry the rest: <code>forge push -t "Title"</code> with <code>--draft</code> or <code>--target <branch></code>, and <code>forge push --stack</code> pushes every layer, lowest first. <code>git rebase -i --update-refs</code> keeps the layers in sync while you rewrite them. Landing is a push to <code>main</code>, which must fast-forward: <code>git push origin dami/topic:main</code>.</p>133-<p>Reviews also take comments and approvals (a repo can require one before <code>main</code> moves, with <code>forge.requireapproval</code>), a description from <code>forge push -d</code>, and the command line has <code>forge reviews</code>, <code>forge checkout <id></code> and <code>forge land</code>, which the forge can also do by itself, from a phone or an agent, with <code>ssh git@host land</code>. On the page, files collapse, can be marked viewed, and the stack is one click away.</p>153+<h3>Comments and approvals</h3>154+<p>Signed in, you comment and approve at the end of a review page, or comment on a line with the <b>+</b> in the combined diff. From the terminal:</p>134155 </div>156+<pre class="code"><span class="dim">$</span> forge comment 2 greet.sh:12 -m "Quote the name?"157+<span class="dim">$</span> forge approve 2</pre>158+<div class="prose">159+<p>An approval is for the code as it is: rewording a commit keeps it, changing the code marks it stale. You can't approve your own review. After a new revision, older comments are marked outdated and keep the line they quoted. A repo can require someone else's approval before <code>main</code> moves.</p>160+<h3>Landing</h3>161+<p><code>forge land</code> checks CI (and approval, if required), fast-forwards <code>main</code> and deletes the landed branches. Over ssh the forge does it itself, no clone needed:</p>162+</div>163+<pre class="code"><span class="dim">$</span> forge land 2164+<span class="dim">$</span> ssh git@src.rest land cat/greet 2</pre>165+<p class="prose"><code>main</code> only fast-forwards, so rebase a review onto <code>main</code> before landing; <code>git rebase -i --update-refs</code> moves every layer of a stack with it.</p>135166 </section>136167 137168 <section class="commit" id="ci" aria-labelledby="ci-h">138-<header><p class="ref"><b>04</b> ci</p><h2 id="ci-h"><a href="#ci">CI runs on your machine, and is signed</a></h2></header>139-<p class="prose"><code>forge attest</code> checks each commit out into a worktree (one per repo, reused so build caches stay warm), runs <code>.forge/ci</code> in it (inside <code>nix develop</code> when there is a <code>flake.nix</code> and Nix is installed), and writes a statement. It signs that with <code>ssh-keygen -Y sign -n forge-ci</code> and the SSH key you push with, and appends it to <code>refs/notes/ci</code>.</p>169+<header><p class="ref"><b>04</b> ci</p><h2 id="ci-h"><a href="#ci">CI runs on your machine and is signed</a></h2></header>170+<p class="prose"><code>forge attest</code> runs <code>.forge/ci</code> on your machine, on a clean checkout of each commit. It signs the result with your SSH key. <code>forge push</code> uploads the results, and every commit, review and branch shows pass or fail and who signed it. Failures are shown too.</p>140171 <figure class="plate annotated">141172 <pre class="code"><span class="key">forge-attestation 1</span>142173 type ci@@ -147,194 +178,168 @@ script .forge/ci 99a0d5ea2eee...<span class="n">2</span>
147178 exit 0148179 started 2026-10-18T02:00:14Z149180 duration 0s150-runner cat@early-yak-00<span class="n">4</span>181+runner cat@laptop<span class="n">4</span>151182 system Darwin 25.5.0 arm64152183 env host153184 log sha256:6cc79019... 4 lines154-<span class="dim">|ok greet --name ana</span>185+<span class="dim">|ok greet --name fox</span>155186 <span class="dim">|ok greet --shout</span>156187 <span class="dim">-----BEGIN SSH SIGNATURE-----</span><span class="n">5</span>157188 <span class="dim">U1NIU0lHAAAAAQAAADMAAAALc3No...</span></pre>158189 <ol>159-<li><strong>The tree, not the commit.</strong> A reworded or reordered commit with the same files keeps its result, and <code>forge attest</code> skips trees that already passed unless you pass <code>--force</code>.</li>160-<li><strong>The script</strong>, by blob id, so a different <code>.forge/ci</code> is a different claim.</li>161-<li><strong>The result</strong> and exit code. A failure is signed and pushed too.</li>162-<li><strong>The runner</strong> as the signer describes it: user, host, system, and with Nix the <code>flake.lock</code> it ran under.</li>190+<li><strong>The files, not the commit.</strong> A reworded or reordered commit with the same files keeps its result, and <code>forge attest</code> skips what already passed unless you pass <code>--force</code>.</li>191+<li><strong>The script</strong>, by hash: change <code>.forge/ci</code> and it is a different claim.</li>192+<li><strong>The result</strong> and exit code.</li>193+<li><strong>The runner</strong> as the signer describes it: user, machine and system, plus the Nix lock file if there is one.</li>163194 <li><strong>The signature</strong> over everything above. Log lines start with <code>|</code>, so output can't fake a field.</li>164195 </ol>165-<figcaption><b>fig. 5</b>an attestation from the demo, shortened</figcaption>196+<figcaption><b>fig. 3</b>a signed CI result, shortened</figcaption>166197 </figure>198+<pre class="code"><span class="dim">$</span> forge attest --stack <span class="dim"># every commit on top of main</span>199+<span class="dim">$</span> forge verify <span class="dim"># each commit's results, and who signed them</span></pre>167200 <div class="verdict">168-<div class="yes"><h3>What a verified pass proves</h3><ul><li>Someone holding a key in the forge's keys file claims that this exact tree passed this exact script, on a machine they describe.</li><li>Nobody edited it since: turning <code>result fail</code> into <code>result pass</code> breaks the signature.</li><li>Who it was: the signer is looked up in <code>allowed_signers</code>.</li></ul></div>169-<div class="no"><h3>What it doesn't</h3><ul><li>That the key holder is honest. A dishonest one can sign a pass for tests they never ran.</li><li>That the machine was clean or the environment reproducible, unless Nix pinned it.</li><li>Anything about the commits nobody attested.</li></ul></div>201+<div class="yes"><h3>What a verified pass proves</h3><ul><li>Someone with a key on this forge claims these exact files passed this exact script, on a machine they describe.</li><li>Nobody edited it since: turning <code>result fail</code> into <code>result pass</code> breaks the signature.</li><li>Who signed it.</li></ul></div>202+<div class="no"><h3>What it doesn't</h3><ul><li>That the person is honest. Someone can sign a pass for tests they never ran.</li><li>That the machine was clean or the setup reproducible, unless Nix pinned it.</li><li>Anything about commits nobody tested.</li></ul></div>170203 </div>171204 <div class="prose">172-<p>To make the claim stronger, keep the key in hardware (a Secure Enclave key through <a href="https://github.com/maxgoedjen/secretive">Secretive</a>, or a FIDO <code>sk-</code> key) so it is bound to one device; use Nix so the environment is pinned and recorded; and have someone else run <code>forge attest</code> too. Attestations from several signers stack up on the same tree.</p>173-<p>To require a verified pass before <code>main</code> moves, set <code>git config forge.requireci tip</code> in the bare repo, or <code>all</code> to check every new commit. <code>pre-receive</code> verifies against the server's own <code>allowed_signers</code>; anyone may add notes, as long as <code>refs/notes/ci</code> only fast-forwards.</p>205+<p>To make it stronger, keep the key in hardware: a Secure Enclave key through <a href="https://github.com/maxgoedjen/secretive">Secretive</a>, or a FIDO <code>sk-</code> key. Add a <code>flake.nix</code> so CI runs under <code>nix develop</code>. Have a second person run <code>forge attest</code> too; results from several people add up.</p>206+<p>A repo can require a verified pass before <code>main</code> moves (for the new tip or every new commit) and for a release's tag. Pushes and lands without one are refused.</p>174207 </div>175208 </section>176209 177210 <section class="commit" id="sites" aria-labelledby="sites-h">178-<header><p class="ref"><b>05</b> sites</p><h2 id="sites-h"><a href="#sites">Sites ship with the repo</a></h2></header>179-<p class="prose">A deploy is a commit on <code>refs/sites/<name></code> whose tree is the built output. So git provides content-addressed storage, dedup across deploys, history, and revert by pushing an older deploy. Trailers record the source commit, the build command and who built it, and the commit carries an SSH signature when your key can sign.</p>180-<div class="split">211+<header><p class="ref"><b>05</b> sites and releases</p><h2 id="sites-h"><a href="#sites">Sites and releases ship with the repo</a></h2></header>181212 <div class="prose">182-<p>Declare sites in <code>.forge/sites</code>, one per line: a name, the output directory, and a build command, or <code>-</code> for none. This guide is <code>guide guide</code>, with a build command that copies the forge's fonts in.</p>183-<p><code>forge site deploy <name></code> builds from a clean worktree of <code>HEAD</code>, with <code>FORGE_SITE_BASE</code> set to the path it will be served from (<code>/</code> on its own hostname). From <code>main</code> it publishes the site. From any other branch it publishes that review's preview, <code>refs/site-previews/<name>/<branch></code>, which the review page links and marks stale once the branch moves on. <code>forge site push <dir> <name></code> publishes a directory you already built.</p>184-<p>Only the owner and collaborators deploy sites. Anyone can deploy previews of their own branches.</p>185-</div>186-<figure class="plate">187-<ol class="chain">188-<li><span class="k">ref</span><span class="v">refs/sites/docs<small>a deploy commit, signed</small></span></li>189-<li class="hop"><span>post-receive extracts its tree, once per tree</span></li>190-<li><span class="k">store</span><span class="v">.forge/store/339f775e.../</span></li>191-<li class="hop up"><span>symlink</span></li>192-<li><span class="k">served</span><span class="v">www/sites/dami/greet/docs</span></li>193-<li class="hop up"><span>symlink, with a pages domain</span></li>194-<li><span class="k">hostname</span><span class="v">www/hosts/greet-docs</span></li>195-<li class="hop up"><span>the pages server looks up the Host header</span></li>196-<li><span class="k">browser</span><span class="v">https://greet-docs.pages.example.com/</span></li>197-</ol>198-<figcaption><b>fig. 6</b>a deploy, from ref to hostname</figcaption>199-</figure>213+<p>Any repo can publish static sites: docs, a Docusaurus build, a small app. List each in <code>.forge/sites</code> with its output directory and build command, then deploy:</p>200214 </div>215+<pre class="code"><span class="dim"># .forge/sites: name, output, build command (- for none)</span>216+docs build npm ci && npm run build217+218+<span class="dim">$</span> forge site deploy docs</pre>201219 <div class="prose">202-<h3>A hostname per site</h3>203-<p>With a pages domain, every deploy gets its own hostname, so sites can't read each other's cookies, storage or service workers, and each one builds for <code>/</code>. The <code>www/hosts/</code> directory of symlinks is the whole registry.</p>220+<p>On <code>main</code> this publishes the site; on any other branch, a preview of that branch, linked from its review. Deleting the branch removes its previews. Every deploy is kept, so you can roll back. This guide is a site of <code>cat/forge</code>.</p>221+<p>Each site gets its own hostname, so sites can't read each other's cookies or storage:</p>204222 </div>223+<div class="panel">224+<div class="panel-head"><b>where a site is served</b><span class="end"><span class="n">*.pages.src.rest</span></span></div>205225 <div class="table">206-<table>207-<thead><tr><th scope="col">deploy</th><th scope="col">hostname under the pages domain</th></tr></thead>226+<table class="whole">227+<thead><tr><th scope="col">deploy</th><th scope="col">address</th></tr></thead>208228 <tbody>209-<tr><td>a site of the forge's owner: <code>dami/greet</code>, site <code>docs</code></td><td><code>greet-docs</code></td></tr>210-<tr><td>another user's site: <code>ana/dotfiles</code>, site <code>home</code></td><td><code>dotfiles-home-ana</code></td></tr>211-<tr><td>a preview: <code>docs</code> on branch <code>dami/flags</code></td><td><code>greet-docs--dami-flags</code></td></tr>229+<tr><td><code>cat/greet</code>, site <code>docs</code></td><td><code>greet-docs<wbr>.pages.src.rest</code></td></tr>230+<tr><td><code>fox/dotfiles</code>, site <code>home</code></td><td><code>dotfiles-home-fox<wbr>.pages.src.rest</code></td></tr>231+<tr><td><code>docs</code> on the branch <code>cat/flags</code></td><td><code>greet-docs--cat-flags<wbr>.pages.src.rest</code></td></tr>232+<tr><td><code>cat/forge</code>, site <code>guide</code></td><td><code>forge-guide<wbr>.pages.src.rest</code>, this page</td></tr>212233 </tbody>213234 </table>214235 </div>236+</div>215237 <div class="prose">216-<p>The pages server serves them the way Cloudflare Pages does, so a site built for there works unchanged: <code>_headers</code> and <code>_redirects</code> apply, <code>/about</code> finds <code>about.html</code> or <code>about/index.html</code>, a missing page gets the nearest <code>404.html</code> (or <code>index.html</code>, for a single-page app), and previews are sent with <code>noindex</code>. Old <code>/sites/...</code> paths redirect to the hostname. Without a pages domain, sites are plain files under <code>/sites/<owner>/<repo>/<name>/</code> on the forge itself.</p>217-<p>Deleting a branch deletes its previews: the ref, the served link and the hostname, so the sites and review pages stop listing them. Previews of merged branches stay until their branch is deleted. The extracted files stay in the store until <code>forge gc</code>, which removes store trees nothing links to, hostnames whose deploy is gone and leftover previews, skipping store entries changed in the last ten minutes, so it can run while people push.</p>218-<h3>Releases are the same idea</h3>219-<p><code>forge release publish v1.0 <file>...</code> commits a tag's files to <code>refs/releases/v1.0</code> with a <code>SHA256SUMS</code> signed by your key for that repo and tag (<code>ssh-keygen -n forge-release</code>), so it can't pass for another release. <code>pre-receive</code> checks every sum, the signature (an owner's or collaborator's key), the tag and a size cap, and with <code>forge.releaserequireci</code> a verified pass for the tag's tree. <code>post-receive</code> extracts the tree into the store and hard-links the files into <code>/dami/greet/releases/download/v1.0/</code>, plus <code>latest/</code>; hard links, so the mirror copies them. Clones and fetches never see <code>refs/releases</code>. A private repo's go to the private root next to its pages, never the mirror, and its readers download them signed in or with a token, or get them with <code>ssh git@host release get</code>.</p>238+<p>The forge's owner gets the short names. Sites are served like Cloudflare Pages: <code>_headers</code> and <code>_redirects</code> work, <code>/about</code> finds <code>about.html</code>, a missing page gets the nearest <code>404.html</code> and previews aren't indexed.</p>239+<h3>Releases</h3>240+<p>A release is a tag's files plus a <code>SHA256SUMS</code> signed with your key, bound to the repo and tag. The releases page shows who signed, and anyone can verify a download:</p>220241 </div>242+<pre class="code"><span class="dim">$</span> forge release publish v1.0 greet.sh greet-1.0.tar.gz243+<span class="dim">$</span> forge release verify --url https://src.rest/cat/greet/releases/download/v1.0/ greet.sh244+<span class="dim">ok greet.sh: matches SHA256SUMS of cat/greet v1.0, signed by cat</span></pre>245+<p class="prose">Downloads keep stable links: <code>/cat/<wbr>greet/<wbr>releases/<wbr>download/<wbr>latest/<wbr>greet.sh</code> is always the newest. A private repo's releases are for its readers only.</p>221246 </section>222247 223-<section class="commit" id="identity" aria-labelledby="identity-h">224-<header><p class="ref"><b>06</b> identity</p><h2 id="identity-h"><a href="#identity">Identity is one file of keys</a></h2></header>225-<p class="prose"><code>.forge/keys</code> holds <code>name key</code> lines and is the only source of identity. <code>forge keys sync</code> (run by <code>forge user add</code> and <code>user rm</code>) writes two files from it, so the key you push with is also the key that signs your CI results and deploys.</p>226-<figure class="plate fan">227-<div class="box"><span class="file">.forge/keys</span><code>ana ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... ana@laptop</code></div>228-<div class="by">forge keys sync</div>229-<div class="outs">230-<div class="box"><span class="file">.ssh/authorized_keys</span><code>restrict,command="/opt/forge/bin/forge shell ana" ssh-ed25519 AAAAC3Nz... ana@laptop</code><p>Who is pushing: hooks see <code>FORGE_USER=ana</code>.</p></div>231-<div class="box"><span class="file">.forge/allowed_signers</span><code>ana namespaces="forge-ci,forge-release,git" ssh-ed25519 AAAAC3Nz... ana@laptop</code><p>Who signed a CI result, a deploy or a release's checksums. <code>ssh git@host signers</code> prints it for <code>forge verify</code>.</p></div>248+<section class="commit" id="people" aria-labelledby="people-h">249+<header><p class="ref"><b>06</b> people</p><h2 id="people-h"><a href="#people">Who can do what</a></h2></header>250+<div class="prose">251+<p>A user is a name and an SSH key; the same key signs their CI results, deploys and releases. An admin invites someone with a link:</p>232252 </div>233-<figcaption><b>fig. 7</b>one line in, two generated files out</figcaption>234-</figure>235-<p class="prose">Repos live at <code>/srv/git/<owner>/<repo>.git</code>, and the directory names the owner. <code>ssh git@host new <repo></code> creates one under your name, and so does pushing to one that isn't there yet. The owner, and anyone listed in <code><repo>.git/forge/collaborators</code>, can push anything; everyone else works under their own name:</p>253+<pre class="code"><span class="dim">$</span> ssh git@src.rest invite fox --repo cat/012 --as collaborator254+<span class="dim">https://src.rest/login/#invite=...</span></pre>255+<p class="prose">The link works once, for a week: <code>fox</code> opens it, pastes a public key and can add a passkey. Each repo's owner decides who else works on it:</p>256+<pre class="code"><span class="dim">$</span> ssh git@src.rest collaborators cat/012 add fox <span class="dim"># may push anything</span>257+<span class="dim">$</span> ssh git@src.rest readers cat/012 add fox <span class="dim"># may read it while it is private</span>258+<span class="dim">$</span> ssh git@src.rest who cat/012259+<span class="dim">owner cat260+collaborator fox</span></pre>261+<div class="panel">262+<div class="panel-head"><b>who can do what</b><span class="end">on <code>cat/012</code></span></div>236263 <div class="table">237-<table class="rows">238-<thead><tr><th scope="col">ref</th><th scope="col">owner, collaborators</th><th scope="col">anyone else, e.g. ana</th></tr></thead>264+<table class="rows three">265+<thead><tr><th scope="col"></th><th scope="col">owner, collaborators</th><th scope="col">readers, and any user of a public repo</th><th scope="col">anyone</th></tr></thead>239266 <tbody>240-<tr><th scope="row"><code>main</code> (the default branch)</th><td class="y" data-l="owner, collaborators">fast-forward only, unless <code>forge.allowforce</code>; never deleted</td><td class="n" data-l="anyone else">no</td></tr>241-<tr><th scope="row"><code>refs/heads/<wbr>ana/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes, her own prefix</td></tr>242-<tr><th scope="row">other branches</th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>243-<tr><th scope="row"><code>refs/<wbr>site-previews/<wbr>*/ana/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes</td></tr>244-<tr><th scope="row"><code>refs/<wbr>tags/*</code>, <code>refs/<wbr>sites/*</code>, <code>refs/<wbr>releases/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>245-<tr><th scope="row"><code>refs/<wbr>notes/ci</code></th><td class="y" data-l="owner, collaborators">yes, and may rewrite</td><td class="y" data-l="anyone else">add only: fast-forward, no delete</td></tr>246-<tr><th scope="row"><code>refs/<wbr>forge/*</code></th><td class="n" data-l="owner, collaborators">internal</td><td class="n" data-l="anyone else">internal</td></tr>267+<tr><th scope="row">read pages, clone</th><td class="y" data-l="owner, collab.">yes</td><td class="y" data-l="readers, users">yes</td><td class="y" data-l="anyone">a public repo</td></tr>268+<tr><th scope="row">comment, approve</th><td class="y" data-l="owner, collab.">yes</td><td class="y" data-l="readers, users">yes</td><td class="n" data-l="anyone">no</td></tr>269+<tr><th scope="row">push a review, a preview, CI results</th><td class="y" data-l="owner, collab.">yes</td><td class="y" data-l="readers, users">under their own name, <code>fox/...</code></td><td class="n" data-l="anyone">no</td></tr>270+<tr><th scope="row">move <code>main</code>, land, tag, deploy, release</th><td class="y" data-l="owner, collab.">yes</td><td class="n" data-l="readers, users">no</td><td class="n" data-l="anyone">no</td></tr>271+<tr><th scope="row">make it private or public, choose readers</th><td class="y" data-l="owner, collab.">yes</td><td class="n" data-l="readers, users">no</td><td class="n" data-l="anyone">no</td></tr>272+<tr><th scope="row">choose collaborators, remove it</th><td class="y" data-l="owner, collab.">the owner</td><td class="n" data-l="readers, users">no</td><td class="n" data-l="anyone">no</td></tr>247273 </tbody>248274 </table>249275 </div>250-<p class="prose">Removing someone's line revokes their pushes and their signatures together: their old attestations stop verifying. Administer as root on the server (<code>forge-admin ...</code> in the container), never by logging in as <code>git</code>; every key is forced through <code>forge shell</code>.</p>251-</section>252-253-<section class="commit" id="hosting" aria-labelledby="hosting-h">254-<header><p class="ref"><b>07</b> hosting</p><h2 id="hosting-h"><a href="#hosting">Where it runs</a></h2></header>255-<p class="prose">The forge is one container: sshd for git, Caddy for pages, sites and read-only HTTPS clones (<code>git http-backend</code> through fcgiwrap, and <code>go get</code> works), the events server, the pages server when there is a pages domain, and optionally tailcat. All state is in <code>/srv/git</code>, a volume. <code>forge backup</code> snapshots it every day with restic into an R2 bucket and test-restores each snapshot, and <code>forge restore</code> brings the forge back on a new box, host keys and tunnel address included.</p>256-<div class="hosts">257-<figure class="plate">258-<ol class="hops">259-<li class="node"><b>internet</b><span>ssh on :22, https on :443. DNS for the forge and pages domains points at the edge, DNS-only, since ssh can't go through Cloudflare's proxy.</span></li>260-<li class="wire">raw TCP for :22, TLS ends here for :443</li>261-<li class="node"><b>Fly edge</b><span><code>deploy/edge</code>. Stateless, with a dedicated IPv4 so raw TCP on :22 works. Its key and the container's address are Fly secrets. While the home box is down, its Caddy serves the forge's pages from a copy in R2.</span></li>262-<li class="wire tunnel">tailcat: point-to-point WireGuard on Tailscale's data plane, with no tailnet, accounts or control plane</li>263-<li class="node home"><b>forge container on a home box</b><span><code>deploy/slate</code>. Publishes no ports. Its tailcat server keeps its key on the volume and accepts only the edge's client key.</span><ul><li>sshd :2222</li><li>Caddy :8080</li><li>events :8091</li><li>pages :8092</li></ul></li>264-</ol>265-<figcaption><b>fig. 8</b>how src.rest runs</figcaption>266-</figure>267-<figure class="plate">268-<ol class="hops">269-<li class="node"><b>internet</b><span>ssh on :22, https on :443</span></li>270-<li class="wire">straight in</li>271-<li class="node home"><b>a Debian or Ubuntu VPS</b><span><code>server/provision.sh</code> installs git, sshd and Caddy, which gets certificates itself, and runs the Node servers under systemd. It needs Node 22.18 or newer.</span><ul><li>sshd :22</li><li>Caddy :443</li><li>forge-events</li><li>forge-pages</li></ul></li>272-</ol>273-<figcaption><b>fig. 9</b>or a plain VPS</figcaption>274-</figure>275276 </div>276-<p class="prose">Either way, Caddy serves the pages directory as plain files and hands on only four things: clones to <code>git http-backend</code>, <code>/events/</code> to the events server, every path the pages directory has no file for to the private server and, with a pages domain, sites to the pages server. The pages server runs as the caddy user, because deploys are untrusted content. A public repo is public everywhere: pages, sites and clones. A private one (no <code>git-daemon-export-ok</code>) is read by its owner, collaborators and readers only: over ssh, on its pages, which are built into a root of their own and served by the private server once they sign in with a passkey, and over HTTPS with a token. To anyone else every page of it is the 404 of a page that isn't there. Passkeys and tokens come from ssh (<code>ssh git@host passkey</code>, <code>token</code>), and live, as hashes, in one SQLite file that litestream copies to the backup bucket as it changes.</p>277+<div class="prose">278+<h3>Private repos</h3>279+<p>A private repo is visible only to its owner, collaborators and readers: over ssh, on the web when signed in and over HTTPS with a token. To everyone else it doesn't exist.</p>280+</div>281+<pre class="code"><span class="dim">$</span> ssh git@src.rest new --private notes282+<span class="dim">$</span> ssh git@src.rest private cat/012 <span class="dim"># or public, after a check of its whole history for secrets</span></pre>283+<div class="prose">284+<h3>Passkeys and tokens</h3>285+<p>There are no passwords. Passkeys and tokens are made over ssh; lose one and make another.</p>286+</div>287+<pre class="code"><span class="dim">$</span> ssh git@src.rest passkey <span class="dim"># a one-time link that adds a passkey</span>288+<span class="dim">$</span> ssh git@src.rest token --repo cat/012 <span class="dim"># for curl and HTTPS clones, shown once</span>289+<span class="dim">$</span> ssh git@src.rest sessions <span class="dim"># where you are signed in; rm signs one out</span></pre>277290 </section>278291 279292 <section class="commit" id="cli" aria-labelledby="cli-h">280-<header><p class="ref"><b>08</b> cheat sheet</p><h2 id="cli-h"><a href="#cli">Day to day</a></h2></header>281-<div class="cheat">282-<section aria-labelledby="cs-setup"><h3 id="cs-setup">once</h3><dl>293+<header><p class="ref"><b>07</b> day to day</p><h2 id="cli-h"><a href="#cli">Day to day</a></h2></header>294+<div class="cheat panels">295+<section class="panel" aria-labelledby="cs-setup"><h3 class="panel-head" id="cs-setup">once</h3><dl>283296 <div><dt>git config --global forge.signingkey ~/.ssh/id_ed25519.pub</dt><dd>The key that signs CI results and deploys; a <code>.pub</code> path signs through ssh-agent.</dd></div>284-<div><dt>ssh git@host signers > ~/.config/forge/allowed_signers</dt><dd>Then point <code>forge.allowedsigners</code> at it, so <code>forge verify</code> can check signatures.</dd></div>285-<div><dt>ssh git@host new greet "say hello"</dt><dd>Create a repo under your name; prints the remote to add.</dd></div>286-<div><dt>git push git@host:dami/greet.git main</dt><dd>Or just push: a repo under your name that doesn't exist yet is created first, the same way.</dd></div>297+<div><dt>ssh git@src.rest signers > ~/.config/forge/allowed_signers</dt><dd>Then point <code>forge.allowedsigners</code> at it, so <code>forge verify</code> can check signatures.</dd></div>298+<div><dt>ssh git@src.rest new greet "say hello"</dt><dd>Make a repo under your name; prints the remote to add. <code>--private</code> makes it private.</dd></div>299+<div><dt>git push git@src.rest:cat/greet.git main</dt><dd>Or just push: the repo is created if it doesn't exist.</dd></div>287300 <div><dt>forge import https://github.com/you/thing.git</dt><dd>Copy a repo in (set <code>forge.host</code> first). <code>--branches</code> takes every branch.</dd></div>288301 </dl></section>289-<section aria-labelledby="cs-review"><h3 id="cs-review">a review</h3><dl>290-<div><dt>git switch -c dami/topic</dt><dd>Branch under your name; once pushed, it is a review.</dd></div>291-<div><dt>forge attest --stack</dt><dd>CI for every commit on top of <code>main</code>, signed.</dd></div>292-<div><dt>forge push -t "Title"</dt><dd>Push the branch and the CI notes. <code>--draft</code>, <code>--ready</code>, <code>--target <branch></code>, <code>-d</code> for a description.</dd></div>302+<section class="panel" aria-labelledby="cs-review"><h3 class="panel-head" id="cs-review">a review</h3><dl>303+<div><dt>git switch -c cat/topic</dt><dd>Branch under your name; once pushed, it is a review.</dd></div>304+<div><dt>forge push -t "Title"</dt><dd>Push the branch and the CI results. <code>--draft</code>, <code>--ready</code>, <code>--target <branch></code>, <code>-d</code> for a description.</dd></div>293305 <div><dt>forge push --stack</dt><dd>Push every layer of a stack, lowest first.</dd></div>294-<div><dt>forge reviews</dt><dd>List reviews; <code>forge checkout <id></code> fetches one.</dd></div>306+<div><dt>forge reviews</dt><dd>Open and draft reviews; <code>forge checkout <id></code> fetches one.</dd></div>295307 <div><dt>forge comment [<id>] [path:line] -m "..."</dt><dd>Comment on a review, or on one line of it. Without <code>-m</code>, reads stdin or opens <code>$EDITOR</code>.</dd></div>296-<div><dt>forge approve [<id>]</dt><dd>Approve the review as it is now. The approval follows the tree, so a reword keeps it.</dd></div>297-<div><dt>git push origin dami/topic:main</dt><dd>Land it (or <code>forge land</code>). <code>main</code> only fast-forwards.</dd></div>298-<div><dt>ssh git@host land dami/greet 7</dt><dd>Have the forge land review 7, no clone needed: it pushes to itself as you, so the same gates apply, and deletes the landed branches. <code>forge land --server</code> does it from a clone.</dd></div>308+<div><dt>forge approve [<id>]</dt><dd>Approve the review as it is now.</dd></div>309+<div><dt>forge land [<id>]</dt><dd>Check CI and approval, move <code>main</code> forward, delete the landed branches.</dd></div>310+<div><dt>ssh git@src.rest land cat/greet 7</dt><dd>Have the forge land review 7, no clone needed, with the same checks. <code>forge land --server</code> does it from a clone.</dd></div>299311 </dl></section>300-<section aria-labelledby="cs-ci"><h3 id="cs-ci">ci</h3><dl>301-<div><dt>forge attest [rev...]</dt><dd>Attest given commits (default <code>HEAD</code>). <code>--force</code> re-runs a passed tree, <code>--clean</code> also removes ignored files from the worktree.</dd></div>302-<div><dt>forge verify [rev...]</dt><dd>Show each commit's attestations and who signed them.</dd></div>312+<section class="panel" aria-labelledby="cs-ci"><h3 class="panel-head" id="cs-ci">ci</h3><dl>313+<div><dt>forge attest [--stack] [rev...]</dt><dd>Run and sign CI for commits (default <code>HEAD</code>; <code>--stack</code>, everything on top of <code>main</code>). <code>--force</code> reruns what passed.</dd></div>314+<div><dt>forge verify [rev...]</dt><dd>Show each commit's results and who signed them.</dd></div>303315 </dl></section>304-<section aria-labelledby="cs-sites"><h3 id="cs-sites">sites</h3><dl>305-<div><dt>forge site deploy [name...]</dt><dd>Build and publish from a clean worktree of <code>HEAD</code>: the site on <code>main</code>, a preview elsewhere. <code>--here</code> builds in your work tree.</dd></div>316+<section class="panel" aria-labelledby="cs-sites"><h3 class="panel-head" id="cs-sites">sites</h3><dl>317+<div><dt>forge site deploy [name...]</dt><dd>Build and publish from a clean checkout of <code>HEAD</code>: the site on <code>main</code>, a preview elsewhere. <code>--here</code> builds in your work tree.</dd></div>306318 <div><dt>forge site push <dir> <name></dt><dd>Publish a directory you built yourself.</dd></div>307-<div><dt>forge site url <name> [branch]</dt><dd>Where a deploy is served.</dd></div>319+<div><dt>forge site url <name> [branch]</dt><dd>Where a site or preview is served.</dd></div>308320 </dl></section>309-<section aria-labelledby="cs-releases"><h3 id="cs-releases">releases</h3><dl>310-<div><dt>forge release publish <tag> <file>...</dt><dd>The tag's files with a signed <code>SHA256SUMS</code>, pushed with the tag and your CI notes; again to replace them.</dd></div>311-<div><dt>forge release verify <file>...</dt><dd>Check downloads against <code>SHA256SUMS</code> and its signature against the forge's keys.</dd></div>321+<section class="panel" aria-labelledby="cs-releases"><h3 class="panel-head" id="cs-releases">releases</h3><dl>322+<div><dt>forge release publish <tag> <file>...</dt><dd>The tag's files with signed checksums, pushed with the tag and your CI results; again to replace them.</dd></div>323+<div><dt>forge release verify <file>...</dt><dd>Check downloads against their checksums, and the signature against the forge's keys.</dd></div>312324 <div><dt>forge release ls, rm <tag></dt><dd>This repo's releases; delete one.</dd></div>313325 </dl></section>314-<section aria-labelledby="cs-server"><h3 id="cs-server">on the server, as root</h3><dl>315-<div><dt>forge-admin user add ana 'ssh-ed25519 AAAA...'</dt><dd>Add a user and key; <code>user rm ana</code> revokes their keys.</dd></div>316-<div><dt>forge-admin repo new dami/greet "description"</dt><dd>Or <code>repo import dami/greet <url></code> to fetch one server side.</dd></div>317-<div><dt>forge-admin build [owner/repo]</dt><dd>Rebuild pages; <code>metrics</code> shows where builds spend their time.</dd></div>318-<div><dt>forge-admin gc [--dry-run]</dt><dd>Remove unused site and release files, hostnames and leftover previews, then repack each repo geometrically, with a multi-pack bitmap and a fresh commit-graph. Daily from cron.</dd></div>319-<div><dt>forge-admin backups</dt><dd>How the last backup went, then the snapshots. <code>backup</code> takes one now; the container takes one daily.</dd></div>320-<div><dt>forge-admin restore latest</dt><dd>Bring the forge back on an empty box, from the newest snapshot or a named one.</dd></div>321-</dl></section>322-<section aria-labelledby="cs-repo"><h3 id="cs-repo">in a bare repo</h3><dl>323-<div><dt>git config forge.requireci tip</dt><dd>Require a verified pass for the new tip of <code>main</code>; <code>all</code> checks every new commit.</dd></div>324-<div><dt>git config forge.allowforce true</dt><dd>Let the owner and collaborators force-push <code>main</code>.</dd></div>325-<div><dt class="nop">forge/collaborators</dt><dd>Names, one per line, who can push anything.</dd></div>326+<section class="panel" aria-labelledby="cs-people"><h3 class="panel-head" id="cs-people">people, over ssh</h3><dl>327+<div><dt>ssh git@src.rest help</dt><dd>Lists the commands you can run.</dd></div>328+<div><dt>ssh git@src.rest invite fox --repo cat/012 --as collaborator</dt><dd>Admins only: a one-time invite link. <code>invites</code> lists pending ones.</dd></div>329+<div><dt>ssh git@src.rest collaborators cat/012 add fox</dt><dd>Or <code>readers</code>; <code>rm</code> takes it back, and no argument lists them.</dd></div>330+<div><dt>ssh git@src.rest who cat/012</dt><dd>Its owner, collaborators and readers.</dd></div>331+<div><dt>ssh git@src.rest ls</dt><dd>Your repos, and the private ones you may read.</dd></div>332+<div><dt>ssh git@src.rest passkey</dt><dd>A link that adds a passkey; <code>token</code> for curl and HTTPS clones.</dd></div>326333 </dl></section>327334 </div>328-<p class="aside">On the server: <code>forge-admin</code> runs <code>forge</code> as the git user in the container; on a VPS, use <code>sudo -u git /opt/forge/bin/forge</code>.</p>329335 </section>330336 331337 <section class="commit" id="not-yet" aria-labelledby="not-yet-h">332-<header><p class="ref"><b>09</b> not yet</p><h2 id="not-yet-h"><a href="#not-yet">What isn't there</a></h2></header>338+<header><p class="ref"><b>08</b> not yet</p><h2 id="not-yet-h"><a href="#not-yet">What isn't there</a></h2></header>333339 <ul class="gaps">334-<li><b>Issues.</b> Bugs and tasks in the args.io style don't exist yet.</li>335-<li><b>Notifications.</b> Nothing emails you. Open pages update themselves, and that is all.</li>336-<li><b>Buttons.</b> Pages only show things. Landing, deploying and administering happen through git, ssh and <code>forge</code>.</li>337-<li><b>CI on the server.</b> By design: it runs where the code is written, and the server checks signatures.</li>340+<li><b>Issues.</b> No bug or task tracker yet.</li>341+<li><b>Notifications.</b> No email or push notifications; open pages update themselves.</li>342+<li><b>CI on the server.</b> By design: CI runs on your machine, and the forge checks signatures.</li>338343 </ul>339344 </section>340345 @@ -344,7 +349,7 @@ log sha256:6cc79019... 4 lines
344349 <footer class="end">345350 <div class="wrap">346351 <svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>347-<p>This guide is <code>guide/</code> in forge's own repo, published with <code>forge site deploy guide</code>. For how the code fits together, read <a href="internals.html">the internals</a>.</p>352+<p>This guide is <code>guide/</code> in <a href="https://src.rest/cat/forge/">cat/forge</a>. How it works inside: <a href="internals.html">the internals</a>.</p>348353 </div>349354 </footer>350355 </body>@@ -4,7 +4,7 @@
44 <meta charset="utf-8">55 <meta name="viewport" content="width=device-width, initial-scale=1">66 <title>forge internals: where things are and how they fit</title>7-<meta name="description" content="For whoever maintains forge next: the source map, state on disk, what runs, how a build works, URLs, settings and upkeep.">7+<meta name="description" content="For whoever maintains forge next: a push through the server, the source map, state on disk, identity, what runs and where, how a build works, URLs, settings and upkeep.">88 <meta name="theme-color" content="#e9e9e9" media="(prefers-color-scheme: light)">99 <meta name="theme-color" content="#08090b" media="(prefers-color-scheme: dark)">1010 <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cstyle%3Ecircle%7Bstroke:%231c2127%7Dpath%7Bstroke:%233148f6%7D@media (prefers-color-scheme:dark)%7Bcircle%7Bstroke:%23e2e4e7%7D%7D%3C/style%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpath d='M16 15 12 7 8 15Zm-1 0L11.5 8'/%3E%3C/svg%3E">@@ -13,7 +13,7 @@
1313 <script src="guide.js" defer></script>1414 </head>1515 <body>16-<a class="skip" href="#main">Skip to the internals</a>16+<a class="skip" href="#main">skip to the internals</a>1717 <header class="bar">1818 <div class="wrap">1919 <a class="brand" href="./"><svg class="mark" viewBox="0 0 24 24" fill="none" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M16 15 12 7 8 15Zm-1 0L11.5 8"/></svg>forge <span>guide</span></a>@@ -26,32 +26,56 @@
2626 <div class="hero-text">2727 <p class="eyebrow">for whoever maintains it next</p>2828 <h1 id="top">internals</h1>29-<p class="lede">Where the code is, what it keeps on disk, what runs, and how a push becomes pages. Read <a href="./">the guide</a> first for what forge does; this page is about how.</p>29+<p class="lede">How a push becomes pages, where the code is, what it keeps on disk, who may push what, what runs where and how to keep it running. Read <a href="./">the guide</a> first for what forge does; this page is about how.</p>3030 </div>3131 </div>3232 </section>3333 3434 <div class="wrap page">35-<nav class="toc" aria-label="On this page">35+<nav class="toc" aria-label="on this page">3636 <div>3737 <h2>on this page</h2>3838 <ol>39-<li><a href="#map"><span>01</span>source</a></li>40-<li><a href="#disk"><span>02</span>on disk</a></li>41-<li><a href="#run"><span>03</span>what runs</a></li>42-<li><a href="#build"><span>04</span>the build</a></li>43-<li><a href="#urls"><span>05</span>urls</a></li>44-<li><a href="#knobs"><span>06</span>settings</a></li>45-<li><a href="#upkeep"><span>07</span>upkeep</a></li>46-<li><a href="#hacking"><span>08</span>hacking</a></li>39+<li><a href="#push"><span>01</span>a push</a></li>40+<li><a href="#map"><span>02</span>source</a></li>41+<li><a href="#disk"><span>03</span>on disk</a></li>42+<li><a href="#identity"><span>04</span>identity</a></li>43+<li><a href="#deploys"><span>05</span>signed refs</a></li>44+<li><a href="#run"><span>06</span>what runs</a></li>45+<li><a href="#hosting"><span>07</span>hosting</a></li>46+<li><a href="#build"><span>08</span>the build</a></li>47+<li><a href="#urls"><span>09</span>urls</a></li>48+<li><a href="#knobs"><span>10</span>settings</a></li>49+<li><a href="#server"><span>11</span>on the server</a></li>50+<li><a href="#upkeep"><span>12</span>upkeep</a></li>51+<li><a href="#hacking"><span>13</span>hacking</a></li>4752 </ol>4853 </div>4954 </nav>5055 5156 <main id="main" class="log">5257 58+<section class="commit" id="push" aria-labelledby="push-h">59+<header><p class="ref"><b>01</b> a push</p><h2 id="push-h"><a href="#push">What happens when you push</a></h2></header>60+<p class="prose">A push is how almost everything changes. Here is one, followed through the server.</p>61+<figure class="plate">62+<ol class="flow">63+<li><span class="where">your machine</span><div><p><code>git push</code>, or <code>forge push</code>, which also merges the server's CI notes with yours, pushes them along and passes options such as a review title.</p></div></li>64+<li><span class="where">sshd</span><div><p>Your key matches a line in <code>authorized_keys</code> that forces <code>forge shell <name></code>. It records who you are, creates a repo of yours that a push names but isn't there yet, and hands the command to <code>git-shell</code>, which runs git or one of the commands in <code>git-shell-commands/</code> (<code>new</code>, <code>ls</code>, <code>reviews</code>, <code>land</code>, <code>comment</code>, <code>who</code>, <code>passkey</code>, <code>token</code> and the rest; <code>help</code> lists them). An admin's session also finds the admins' own, <code>invite</code>, <code>invites</code>, <code>users</code> and <code>motd</code>.</p><p class="src">bin/forge: cmd_shell</p></div></li>65+<li><span class="where">pre-receive</span><div><p>Decides who may push which refs, refuses to delete <code>main</code> or rewrite it (unless the repo allows that), turns away a push that adds a credential, checks a release's sums and signature, and, when the repo asks for it, refuses a new <code>main</code> without a verified passing CI attestation or another person's approval.</p><p class="src">bin/forge: hook_pre_receive, scan_secrets, check_release</p></div></li>66+<li><span class="where">git</span><div><p>The refs move.</p></div></li>67+<li><span class="where">post-receive</span><div><p>Goes through the updated refs:</p><ul><li>a branch other than <code>main</code> gets a revision in its review log, and its tip is pinned;</li><li>a site or preview deploy is extracted and linked into the web root, and so are a release's files;</li><li>a deleted branch takes its previews with it;</li><li>the push is appended to <code>.forge/events.jsonl</code>.</li></ul><p class="src">bin/forge: hook_post_receive, record_review, deploy_site</p></div></li>68+<li class="returns"><span>your git push returns here; the rest runs in the background</span></li>69+<li class="bg"><span class="where">forge build</span><div><p>11ty rebuilds that repo's pages into a scratch directory, hard-linking every page that didn't change from the live site, swaps them in and appends a <code>built</code> event. Builds take turns under a lock (<a href="#build">the build</a>).</p><p class="src">bin/forge: cmd_build, eleventy.config.ts, lib/forge.ts</p></div></li>70+<li class="bg"><span class="where">your browser</span><div><p>Open pages hold a server-sent events stream from <code>/events/<owner>/<repo>/</code>. The push shows up as <q>cat pushed cat/flags, updating</q>; when the build lands, the page fetches itself and swaps in the new content, keeping your scroll position.</p><p class="src">lib/events-server.ts, site/assets/live.ts</p></div></li>71+</ol>72+<figcaption><b>fig. 1</b>one push, from your terminal to every open page</figcaption>73+</figure>74+<p class="prose">Building after the push rather than on each request means public pages cost nothing to serve, can be cached anywhere and keep working if the small Node servers stop. Those only add what files can't: live updates, private pages for their readers, comments and approvals from the web, and sites on their own hostnames.</p>75+</section>76+5377 <section class="commit" id="map" aria-labelledby="map-h">54-<header><p class="ref"><b>01</b> source</p><h2 id="map-h"><a href="#map">Where the code is</a></h2></header>78+<header><p class="ref"><b>02</b> source</p><h2 id="map-h"><a href="#map">Where the code is</a></h2></header>5579 <p class="prose">Four kinds of code, kept small: one shell script for everything git and ssh touch, a TypeScript data layer that Node 22 runs as it is (types are stripped, never compiled), Nunjucks templates with a base stylesheet, and container and deploy files.</p>5680 <div class="table">5781 <table class="rows">@@ -63,7 +87,8 @@
6387 <tr><th scope="row"><code>lib/reviews.ts</code></th><td>Review logs plus live refs become stacks, layers, statuses and range-diffs.</td></tr>6488 <tr><th scope="row"><code>lib/attest.ts</code></th><td>Parses attestation notes and verifies their signatures with <code>ssh-keygen</code>, remembering each result.</td></tr>6589 <tr><th scope="row"><code>lib/sites.ts</code>, <code>lib/hosts.ts</code></th><td>Deploy history and signatures for the sites page; per-site hostnames, with <code>www/hosts/</code> as the registry. <code>hosts.ts</code> is also a small CLI the hooks call.</td></tr>66-<tr><th scope="row"><code>lib/events-server.ts</code>, <code>lib/pages-server.ts</code></th><td>The two long-running servers: live updates over server-sent events, and sites on their own hostnames.</td></tr>90+<tr><th scope="row"><code>lib/events-server.ts</code>, <code>lib/private-server.ts</code>, <code>lib/pages-server.ts</code></th><td>The long-running servers: live updates over server-sent events; private pages, sign-in, and comments and approvals from the web; sites on their own hostnames.</td></tr>91+<tr><th scope="row"><code>lib/identity.ts</code>, <code>lib/access.ts</code>, <code>lib/login.ts</code></th><td>Web credentials in <code>identity.db</code> (passkeys, sessions, tokens, invites), who may read what, and passkey sign-in.</td></tr>6792 <tr><th scope="row"><code>lib/</code>, the rest</th><td><code>git.ts</code> wraps the git CLI, <code>diff.ts</code> parses patches, <code>render.ts</code> writes diff and code HTML, <code>markdown.ts</code> renders and sanitizes READMEs and wiki pages, <code>urls.ts</code> is the URL scheme, <code>cache.ts</code> the per-repo build cache, <code>assets.ts</code> the hashed asset names, <code>render-page.ts</code> the Nunjucks setup 11ty shares and pages rendered without 11ty, <code>metrics.ts</code> build timings, <code>types.ts</code> the model.</td></tr>6893 <tr><th scope="row"><code>site/</code>, <code>eleventy.config.ts</code></th><td>Templates, each extending <code>_includes/base.njk</code> and carrying its own CSS in <code>{% css %}</code> blocks; <code>assets/style.css</code> for what every page needs; the small client scripts in <code>assets/*.ts</code>, whose types are stripped when the site is built.</td></tr>6994 <tr><th scope="row"><code>server/</code></th><td>The container's <code>start.sh</code>, <code>sshd.conf</code> and <code>forge-admin</code>; <code>provision.sh</code> for a plain VPS; an example Caddyfile.</td></tr>@@ -75,13 +100,13 @@
75100 </section>76101 77102 <section class="commit" id="disk" aria-labelledby="disk-h">78-<header><p class="ref"><b>02</b> on disk</p><h2 id="disk-h"><a href="#disk">State on disk</a></h2></header>103+<header><p class="ref"><b>03</b> on disk</p><h2 id="disk-h"><a href="#disk">State on disk</a></h2></header>79104 <p class="prose">Everything lives under the git user's home, <code>FORGE_ROOT</code>: <code>/srv/git</code> on a server, <code>demo/root</code> in the demo. Back up this one directory and you have the forge; <code>forge backup</code> does, leaving out what a restore rebuilds (<a href="#upkeep">upkeep</a>).</p>80105 <figure>81106 <pre class="tree"><span class="d">/srv/git/</span>82107 ├── .ssh/authorized_keys <span class="c">generated from .forge/keys83108 </span>├── git-shell-commands/ <span class="c">new, ls, reviews, land, comment, approve, help...84-</span>├── <span class="d">dami/greet.git/</span> <span class="c">a bare repo; the directory names the owner109+</span>├── <span class="d">cat/greet.git/</span> <span class="c">a bare repo; the directory names the owner85110 </span>│ ├── hooks/ <span class="c">pre-receive, post-receive: exec forge hook ...86111 </span>│ ├── forge/reviews/<id> <span class="c">one log per review87112 </span>│ ├── forge/collaborators <span class="c">names that may push anything@@ -103,23 +128,80 @@
103128 </span> ├── backup.status, backup.log <span class="c">the last backup's health, for forge backups104129 </span> ├── backup/ <span class="c">staging, restic's cache, the restore drill105130 </span> └── build.log, metrics.jsonl</pre>106-<figcaption><b>fig. 1</b>the forge's state, all of it</figcaption>131+<figcaption><b>fig. 2</b>the forge's state, all of it</figcaption>107132 </figure>108133 <div class="prose">109134 <p>A review log is a few lines appended by <code>post-receive</code>:</p>110135 </div>111-<pre class="code">branch dami/help112-created 1788276612 dami136+<pre class="code">branch cat/help137+created 1788276612 cat113138 opt title=Usage and --help114-rev 1788276612 ef1d881b67e0... dami 895bc48ac1d9... <span class="dim"># time, tip, who, merge-base with main</span>115-rev 1788282012 6771779c76bb... dami 895bc48ac1d9...116-deleted 1788292812 dami <span class="dim"># only once the branch is gone</span></pre>117-<p class="prose" style="margin-top:1rem">The container adds <code>.hostkeys/</code>, so clients see the same host key across deploys, and <code>.tailcat/</code>, whose server key keeps the tunnel address stable. Both are root's, and both are in every backup, so a restored box keeps its identity.</p>139+rev 1788276612 ef1d881b67e0... cat 895bc48ac1d9... <span class="dim"># time, tip, who, merge-base with main</span>140+rev 1788282012 6771779c76bb... cat 895bc48ac1d9...141+deleted 1788292812 cat <span class="dim"># only once the branch is gone</span></pre>142+<p class="prose">The container adds <code>.hostkeys/</code>, so clients see the same host key across deploys, and <code>.tailcat/</code>, whose server key keeps the tunnel address stable. Both are root's, and both are in every backup, so a restored box keeps its identity.</p>143+</section>144+145+<section class="commit" id="identity" aria-labelledby="identity-h">146+<header><p class="ref"><b>04</b> identity</p><h2 id="identity-h"><a href="#identity">Identity is one file of keys</a></h2></header>147+<p class="prose"><code>.forge/keys</code> holds <code>name key</code> lines and is the root of identity; passkeys, sessions and tokens in <code>identity.db</code> each belong to a name in it. <code>forge keys sync</code> (run by <code>forge user add</code>, <code>user rm</code> and a taken-up invite) writes two files from it, so the key you push with is also the key that signs your CI results, deploys and releases.</p>148+<figure class="plate fan">149+<div class="box"><span class="file">.forge/keys</span><code>fox <wbr>ssh-ed25519 <wbr>AAAAC3NzaC1lZDI1NTE5... <wbr>fox@laptop</code></div>150+<div class="by">forge keys sync</div>151+<div class="outs">152+<div class="box"><span class="file">.ssh/authorized_keys</span><code>restrict,command="/<wbr>opt/<wbr>forge/<wbr>bin/<wbr>forge <wbr>shell <wbr>fox" <wbr>ssh-ed25519 <wbr>AAAAC3Nz... <wbr>fox@laptop</code><p>Who is pushing: hooks see <code>FORGE_USER=fox</code>.</p></div>153+<div class="box"><span class="file">.forge/allowed_signers</span><code>fox <wbr>namespaces="forge-ci,forge-release,git" <wbr>ssh-ed25519 <wbr>AAAAC3Nz... <wbr>fox@laptop</code><p>Who signed a CI result, a deploy or a release's checksums. <code>ssh git@host signers</code> prints it for <code>forge verify</code>.</p></div>154+</div>155+<figcaption><b>fig. 3</b>one line in, two generated files out</figcaption>156+</figure>157+<p class="prose">Repos live at <code>/srv/git/<owner>/<repo>.git</code>, and the directory names the owner. The owner, and anyone in <code><repo>.git/forge/collaborators</code>, can push anything; everyone else who can read the repo works under their own name. This is what <code>pre-receive</code> enforces, ref by ref:</p>158+<div class="table">159+<table class="rows">160+<thead><tr><th scope="col">ref</th><th scope="col">owner, collaborators</th><th scope="col">anyone else who can read it, e.g. fox</th></tr></thead>161+<tbody>162+<tr><th scope="row"><code>main</code> (the default branch)</th><td class="y" data-l="owner, collaborators">fast-forward only, unless <code>forge.allowforce</code>; never deleted</td><td class="n" data-l="anyone else">no</td></tr>163+<tr><th scope="row"><code>refs/heads/<wbr>fox/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes, their own prefix</td></tr>164+<tr><th scope="row">other branches</th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>165+<tr><th scope="row"><code>refs/<wbr>site-previews/<wbr>*/fox/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="y" data-l="anyone else">yes</td></tr>166+<tr><th scope="row"><code>refs/<wbr>tags/*</code>, <code>refs/<wbr>sites/*</code>, <code>refs/<wbr>releases/*</code></th><td class="y" data-l="owner, collaborators">yes</td><td class="n" data-l="anyone else">no</td></tr>167+<tr><th scope="row"><code>refs/<wbr>notes/ci</code></th><td class="y" data-l="owner, collaborators">yes, and may rewrite</td><td class="y" data-l="anyone else">add only: fast-forward, no delete</td></tr>168+<tr><th scope="row"><code>refs/<wbr>forge/*</code></th><td class="n" data-l="owner, collaborators">internal</td><td class="n" data-l="anyone else">internal</td></tr>169+</tbody>170+</table>171+</div>172+<p class="prose">Removing someone's line revokes their pushes, their web credentials and their signatures together: their old attestations stop verifying. A private repo is one without <code>git-daemon-export-ok</code>, git's own flag; <code>forge shell</code> turns away anyone who may not read it with the same answer as for a missing repo, and so does the private server for its pages.</p>173+</section>174+175+<section class="commit" id="deploys" aria-labelledby="deploys-h">176+<header><p class="ref"><b>05</b> signed refs</p><h2 id="deploys-h"><a href="#deploys">CI notes, sites and releases are refs</a></h2></header>177+<div class="prose">178+<p><strong>CI.</strong> <code>forge attest</code> checks each commit out into a worktree (one per repo, reused so build caches stay warm), runs <code>.forge/ci</code> in it (inside <code>nix develop</code> when there is a <code>flake.nix</code> and Nix is installed), signs the statement with <code>ssh-keygen -Y sign -n forge-ci</code> and appends it to <code>refs/notes/ci</code>, keyed by commit. <code>pre-receive</code> verifies against the server's own <code>allowed_signers</code> when <code>forge.requireci</code> asks it to; anyone who can push may add notes, as long as <code>refs/notes/ci</code> only fast-forwards.</p>179+<p><strong>Reviews.</strong> A review's parent is the nearest other review whose tip lies inside its branch; its layer is the commits between that parent (or <code>main</code>) and its tip. Status and revisions come from the review log and the refs, so nothing else is stored.</p>180+<p><strong>Sites.</strong> A deploy is a commit on <code>refs/sites/<name></code> whose tree is the built output, so git provides content-addressed storage, dedup across deploys, history and revert by pushing an older deploy. Trailers record the source commit, the build command and who built it, and the commit carries an SSH signature when your key can sign. A preview is the same on <code>refs/<wbr>site-previews/<wbr><name>/<wbr><branch></code>. The build gets <code>FORGE_SITE_BASE</code>, the path it will be served from (<code>/</code> on its own hostname).</p>181+</div>182+<figure class="plate">183+<ol class="chain">184+<li><span class="k">ref</span><span class="v">refs/sites/docs<small>a deploy commit, signed</small></span></li>185+<li class="hop"><span>post-receive extracts its tree, once per tree</span></li>186+<li><span class="k">store</span><span class="v">.forge/store/339f775e.../</span></li>187+<li class="hop up"><span>symlink</span></li>188+<li><span class="k">served</span><span class="v">www/<wbr>sites/<wbr>cat/<wbr>greet/<wbr>docs</span></li>189+<li class="hop up"><span>symlink, with a pages domain</span></li>190+<li><span class="k">hostname</span><span class="v">www/hosts/greet-docs</span></li>191+<li class="hop up"><span>the pages server looks up the Host header</span></li>192+<li><span class="k">browser</span><span class="v">https://<wbr>greet-docs<wbr>.pages.example.com/</span></li>193+</ol>194+<figcaption><b>fig. 4</b>a deploy, from ref to hostname</figcaption>195+</figure>196+<div class="prose">197+<p>The <code>www/hosts/</code> directory of symlinks is the whole hostname registry. Without a pages domain, sites are plain files under <code>/sites/<owner>/<repo>/<name>/</code> on the forge itself, and old <code>/sites/...</code> paths redirect to the hostname when there is one. Only the owner and collaborators deploy sites; anyone may deploy previews of their own branches.</p>198+<p><strong>Releases.</strong> <code>forge release publish</code> commits a tag's files to <code>refs/releases/<tag></code> with a <code>SHA256SUMS</code> signed with <code>ssh-keygen -n forge-release</code> over the repo and tag too. <code>pre-receive</code> checks every sum, the signature (an owner's or collaborator's key), the tag and a size cap, and with <code>forge.releaserequireci</code> a verified pass for the tag's tree. <code>post-receive</code> extracts the tree into the store and hard-links the files into <code>/<owner>/<wbr><repo>/<wbr>releases/<wbr>download/<wbr><tag>/</code>, plus <code>latest/</code>; hard links, so the mirror copies them. Clones and fetches never see <code>refs/releases</code>. A private repo's go to the private root next to its pages, never the mirror.</p>199+</div>118200 </section>119201 120202 <section class="commit" id="run" aria-labelledby="run-h">121-<header><p class="ref"><b>03</b> what runs</p><h2 id="run-h"><a href="#run">What runs, and why</a></h2></header>122-<p class="prose">The container's entrypoint is <code>tini</code>, which also reaps the background builds, and then <code>server/container/start.sh</code>. It creates host keys on first boot, runs <code>forge server init</code>, writes <code>.forge/config</code> from the environment, adds the owner's key, syncs keys, writes the Caddyfile with <code>forge server caddyfile</code>, and starts:</p>203+<header><p class="ref"><b>06</b> what runs</p><h2 id="run-h"><a href="#run">What runs and why</a></h2></header>204+<p class="prose">The container's entrypoint is <code>tini</code>, which also reaps the background builds, and then <code>server/container/start.sh</code>. It creates host keys on first boot, runs <code>forge server init</code>, writes <code>.forge/config</code> from the environment, adds the owner's key, syncs keys, writes the Caddyfile with <code>forge server caddyfile</code> and starts:</p>123205 <div class="table">124206 <table class="rows">125207 <thead><tr><th scope="col">process</th><th scope="col">listens</th><th scope="col">as</th><th scope="col">why</th></tr></thead>@@ -128,39 +210,67 @@ deleted 1788292812 dami <span class="dim"># only o
128210 <tr><th scope="row">fcgiwrap</th><td data-l="listens">unix socket</td><td data-l="as">git</td><td>Runs <code>git http-backend</code> for HTTPS clones; Caddy routes only <code>info/refs</code> and <code>git-upload-pack</code> to it.</td></tr>129211 <tr><th scope="row">tailcat</th><td data-l="listens">the tunnel</td><td data-l="as">root</td><td>Only with <code>FORGE_TAILCAT_ALLOW</code>: carries :2222 and :8080 to the edge.</td></tr>130212 <tr><th scope="row">events server</th><td data-l="listens"><code>127.0.0.1:8091</code></td><td data-l="as">git</td><td>Tails <code>events.jsonl</code>, streams it at <code>/events/</code>.</td></tr>213+<tr><th scope="row">private server</th><td data-l="listens"><code>127.0.0.1:8093</code></td><td data-l="as">git</td><td>Every path <code>www</code> has no file for: private pages for their readers, <code>/login/</code>, comment and approve posts, and the one 404.</td></tr>214+<tr><th scope="row">litestream</th><td data-l="listens"></td><td data-l="as">git</td><td>Only with <code>FORGE_BACKUP_REPOSITORY</code>: copies <code>identity.db</code> to the backup bucket as it changes.</td></tr>131215 <tr><th scope="row">pages server</th><td data-l="listens"><code>127.0.0.1:8092</code></td><td data-l="as">caddy</td><td>Only with <code>FORGE_PAGES_DOMAIN</code>: sites on their own hostnames. Runs as caddy because deploys are untrusted.</td></tr>132216 <tr><th scope="row"><code>forge build</code></th><td data-l="listens"></td><td data-l="as">git</td><td>Once at boot, in the background, since templates may have changed with the image; after that, after each push.</td></tr>133217 <tr><th scope="row">backup loop</th><td data-l="listens"></td><td data-l="as">root</td><td>Only with <code>FORGE_BACKUP_REPOSITORY</code>: runs <code>forge backup</code> once a day at <code>FORGE_BACKUP_AT</code>, catching up after downtime, and prints its health to the container log.</td></tr>134218 <tr><th scope="row"><code>forge mirror</code></th><td data-l="listens"></td><td data-l="as">git</td><td>Only with <code>FORGE_MIRROR_S3</code>: after builds, at most hourly, syncs the forge's pages to the bucket the edge falls back to.</td></tr>135-<tr><th scope="row">Caddy</th><td data-l="listens"><code>:8080</code></td><td data-l="as">caddy</td><td>In the foreground. Plain HTTP; TLS ends at the edge. Pages, <code>.git</code> files, <code>go get</code> meta tags, and the two proxies.</td></tr>219+<tr><th scope="row">Caddy</th><td data-l="listens"><code>:8080</code></td><td data-l="as">caddy</td><td>In the foreground. Plain HTTP; TLS ends at the edge. Pages, <code>.git</code> files, <code>go get</code> meta tags and the two proxies.</td></tr>136220 </tbody>137221 </table>138222 </div>139-<p class="prose">The container takes <code>FORGE_DOMAIN</code>, <code>FORGE_PAGES_DOMAIN</code>, <code>FORGE_OWNER</code>, <code>FORGE_OWNER_KEY</code>, <code>FORGE_NAME</code> and <code>FORGE_TAILCAT_ALLOW</code>, plus <code>FORGE_BACKUP_*</code> and <code>FORGE_MIRROR_*</code>, which it writes to <code>.forge/backup.env</code> for backups and the mirror. On a VPS, <code>provision.sh</code> installs the same pieces natively and runs the two Node servers as systemd units, <code>forge-events</code> and <code>forge-pages</code>, with Caddy on :443 getting its own certificates.</p>223+<p class="prose">The container takes <code>FORGE_DOMAIN</code>, <code>FORGE_PAGES_DOMAIN</code>, <code>FORGE_OWNER</code>, <code>FORGE_OWNER_KEY</code>, <code>FORGE_NAME</code> and <code>FORGE_TAILCAT_ALLOW</code>, plus <code>FORGE_BACKUP_*</code> and <code>FORGE_MIRROR_*</code>, which it writes to <code>.forge/backup.env</code> for backups and the mirror. On a VPS, <code>provision.sh</code> installs the same pieces natively and runs the Node servers as systemd units, <code>forge-events</code>, <code>forge-private</code> and, with a pages domain, <code>forge-pages</code>, with Caddy on :443 getting its own certificates.</p>224+</section>225+226+<section class="commit" id="hosting" aria-labelledby="hosting-h">227+<header><p class="ref"><b>07</b> hosting</p><h2 id="hosting-h"><a href="#hosting">Where it runs</a></h2></header>228+<p class="prose">The forge is one container: sshd for git, Caddy for pages, sites and read-only HTTPS clones (<code>git http-backend</code> through fcgiwrap, and <code>go get</code> works), the events server, the private server, the pages server when there is a pages domain and optionally tailcat. All state is in <code>/srv/git</code>, a volume. <code>forge backup</code> snapshots it every day with restic into an R2 bucket and test-restores each snapshot, and <code>forge restore</code> brings the forge back on a new box, host keys and tunnel address included.</p>229+<div class="hosts">230+<figure class="plate">231+<ol class="hops">232+<li class="node"><b>internet</b><span>ssh on :22, https on :443. DNS for the forge and pages domains points at the edge, DNS-only, since ssh can't go through Cloudflare's proxy.</span></li>233+<li class="wire">raw TCP for :22, TLS ends here for :443</li>234+<li class="node"><b>Fly edge</b><span><code>deploy/edge</code>. Stateless, with a dedicated IPv4 so raw TCP on :22 works. Its key and the container's address are Fly secrets. While the home box is down, its Caddy serves the forge's pages from a copy in R2.</span></li>235+<li class="wire tunnel">tailcat: point-to-point WireGuard on Tailscale's data plane, with no tailnet, accounts or control plane</li>236+<li class="node home"><b>forge container on a home box</b><span><code>deploy/slate</code>. Publishes no ports. Its tailcat server keeps its key on the volume and accepts only the edge's client key.</span><ul><li>sshd :2222</li><li>Caddy :8080</li><li>events :8091</li><li>pages :8092</li><li>private :8093</li></ul></li>237+</ol>238+<figcaption><b>fig. 5</b>how src.rest runs</figcaption>239+</figure>240+<figure class="plate">241+<ol class="hops">242+<li class="node"><b>internet</b><span>ssh on :22, https on :443</span></li>243+<li class="wire">straight in</li>244+<li class="node home"><b>a Debian or Ubuntu VPS</b><span><code>server/provision.sh</code> installs git, sshd and Caddy, which gets certificates itself, and runs the Node servers under systemd. It needs Node 22.18 or newer.</span><ul><li>sshd :22</li><li>Caddy :443</li><li>forge-events</li><li>forge-private</li><li>forge-pages</li></ul></li>245+</ol>246+<figcaption><b>fig. 6</b>or a plain VPS</figcaption>247+</figure>248+</div>249+<p class="prose">Either way, Caddy serves the pages directory as plain files and hands on only a few things: clones to <code>git http-backend</code>, <code>/events/</code> to the events server, comment and approve posts and every path the pages directory has no file for to the private server and, with a pages domain, sites to the pages server. The pages server runs as the caddy user, because deploys are untrusted content. A private repo's pages are built into a root of their own and served by the private server to readers signed in with a passkey or showing a token; to anyone else every page of it is the 404 of a page that isn't there. Passkeys, sessions and tokens live, as hashes, in one SQLite file that litestream copies to the backup bucket as it changes.</p>140250 </section>141251 142252 <section class="commit" id="build" aria-labelledby="build-h">143-<header><p class="ref"><b>04</b> the build</p><h2 id="build-h"><a href="#build">How a push becomes pages</a></h2></header>253+<header><p class="ref"><b>08</b> the build</p><h2 id="build-h"><a href="#build">How a push becomes pages</a></h2></header>144254 <figure class="plate">145255 <ol class="flow">146256 <li><span class="where">post-receive</span><div><p>Starts <code>forge build <owner>/<repo></code> with <code>nohup</code>, logging to <code>build.log</code>. <code>FORGE_SYNC_BUILD=1</code> runs it in the foreground instead; <code>FORGE_NO_BUILD=1</code> skips it.</p></div></li>147-<li><span class="where">lock</span><div><p><code>mkdir .forge/build.lock</code>. Builds wait their turn, for up to ten minutes.</p></div></li>148-<li><span class="where">graph</span><div><p><code>git commit-graph write --reachable --changed-paths --split</code> adds a small layer for the new commits, with Bloom filters, so <code>git log -- <path></code> and blame skip commits that don't touch the path. Here rather than in the hook, so the pusher never waits on it.</p></div></li>257+<li><span class="where">lock</span><div><p><code>mkdir .forge/build.lock</code>. Builds wait their turn, for up to 10 minutes.</p></div></li>258+<li><span class="where">graph</span><div><p><code>git <wbr>commit-graph <wbr>write <wbr>--reachable <wbr>--changed-paths <wbr>--split</code> adds a small layer for the new commits, with Bloom filters, so <code>git log -- <path></code> and blame skip commits that don't touch the path. Here rather than in the hook, so the pusher never waits on it.</p></div></li>149259 <li><span class="where">data</span><div><p>11ty loads <code>lib/forge.ts</code> with <code>FORGE_ONLY</code> set, so only that repo is read. Index and owner pages use cached summaries of the others. Commit metadata, range-diffs and signature checks come from <code>.forge/cache/</code>, keyed by object ids that never change.</p></div></li>150260 <li><span class="where">skip</span><div><p>Every page has a fingerprint of what it shows, plus a hash of the forge's own code. A page whose fingerprint matches the last build, and whose file still exists, isn't rendered: its file is hard-linked from the live site.</p></div></li>151261 <li><span class="where">render</span><div><p>The rest render into <code>.forge/build.<pid>/</code>. Diffs and file views are written by filters as each page is, so memory stays bounded by the largest page, not the repo. Each page links one CSS bundle of the blocks its templates carry.</p></div></li>152-<li><span class="where">assets</span><div><p>Stylesheet, scripts and bundles get names from a hash of their contents, so browsers cache them for good. Pages from older code are still live (the other repos, mid-rebuild, or a repo whose build failed), so the files of the last five code versions are carried over from the live site; <code>assets/versions.json</code> lists them.</p></div></li>262+<li><span class="where">assets</span><div><p>Stylesheet, scripts and bundles get names from a hash of their contents, so browsers cache them for good. Pages from older code are still live (the other repos mid-rebuild, or a repo whose build failed), so the files of the last five code versions are carried over from the live site; <code>assets/versions.json</code> lists them.</p></div></li>153263 <li><span class="where">swap</span><div><p>The repo's directory is moved into <code>www/</code> in place of the old one, and the owner's index page is copied over. <code>sites/</code>, <code>previews/</code> and <code>hosts/</code> belong to the hooks and are never touched.</p></div></li>154264 <li><span class="where">after</span><div><p>A line in <code>metrics.jsonl</code> (<code>forge metrics</code> reads it) and a <code>built</code> event that open pages act on.</p></div></li>155265 </ol>156-<figcaption><b>fig. 2</b>a single-repo build</figcaption>266+<figcaption><b>fig. 7</b>a single-repo build</figcaption>157267 </figure>158268 <p class="prose"><code>forge build</code> with no argument rebuilds every repo, one at a time so memory stays bounded by the largest, then the index pages, then removes pages of repos that no longer exist and prunes dangling hostnames.</p>159269 </section>160270 161271 <section class="commit" id="urls" aria-labelledby="urls-h">162-<header><p class="ref"><b>05</b> urls</p><h2 id="urls-h"><a href="#urls">URLs</a></h2></header>163-<p class="prose">Every page is a directory with an <code>index.html</code>, so URLs read like paths. Branch names may contain <code>/</code>, which would make <code>/tree/<branch>/<path></code> ambiguous, so it is written <code>~</code>, which git forbids in ref names: <code>dami/flags</code> becomes <code>dami~flags</code>.</p>272+<header><p class="ref"><b>09</b> urls</p><h2 id="urls-h"><a href="#urls">URLs</a></h2></header>273+<p class="prose">Every page is a directory with an <code>index.html</code>, so URLs read like paths. Branch names may contain <code>/</code>, which would make <code>/tree/<branch>/<path></code> ambiguous, so it is written <code>~</code>, which git forbids in ref names: <code>cat/flags</code> becomes <code>cat~flags</code>.</p>164274 <div class="table">165275 <table class="rows">166276 <thead><tr><th scope="col">url</th><th scope="col">page</th></tr></thead>@@ -182,17 +292,17 @@ deleted 1788292812 dami <span class="dim"># only o
182292 </section>183293 184294 <section class="commit" id="knobs" aria-labelledby="knobs-h">185-<header><p class="ref"><b>06</b> settings</p><h2 id="knobs-h"><a href="#knobs">Settings, wherever they live</a></h2></header>295+<header><p class="ref"><b>10</b> settings</p><h2 id="knobs-h"><a href="#knobs">Settings, wherever they live</a></h2></header>186296 <div class="cheat keys">187297 <section aria-labelledby="k-config"><h3 id="k-config">.forge/config, on the server</h3><dl>188298 <div><dt>FORGE_NAME, FORGE_BASE_URL, FORGE_SSH</dt><dd>The forge's name, web address and ssh address, for pages and clone URLs.</dd></div>189299 <div><dt>FORGE_PAGES_DOMAIN, FORGE_OWNER</dt><dd>Turn on per-site hostnames; the owner's sites get the short ones.</dd></div>190-<div><dt>FORGE_MAX_COMMITS, FORGE_MAX_FILES, FORGE_MAX_BLOB, FORGE_MAX_DIFF_LINES</dt><dd>Limits per branch log, per tree, per file shown and per diff shown: 1000, 5000, 1 MiB, 20000 lines.</dd></div>300+<div><dt>FORGE_MAX_COMMITS, FORGE_MAX_FILES, FORGE_MAX_BLOB, FORGE_MAX_DIFF_LINES</dt><dd>Limits per branch log, per tree, per file shown and per diff shown. The defaults are 1,000 commits, 5,000 files, 1 MiB and 20,000 lines.</dd></div>191301 </dl></section>192302 <section aria-labelledby="k-repo"><h3 id="k-repo">a bare repo's git config</h3><dl>193303 <div><dt>forge.requireci tip | all</dt><dd>A verified pass before <code>main</code> moves, for the new tip or every new commit.</dd></div>194304 <div><dt>forge.allowforce true</dt><dd>Let admins force-push <code>main</code>.</dd></div>195-<div><dt>transfer.hideRefs refs/forge</dt><dd>Set by forge on every repo, with <code>receive.advertisePushOptions true</code> and <code>commitGraph.changedPathsVersion 2</code>; see <a href="#upkeep">upkeep</a>.</dd></div>305+<div><dt>transfer.hideRefs refs/forge</dt><dd>Set by forge on every repo, with <code>receive.advertisePushOptions <wbr>true</code> and <code>commitGraph.changedPathsVersion <wbr>2</code>; see <a href="#upkeep">upkeep</a>.</dd></div>196306 </dl></section>197307 <section aria-labelledby="k-dev"><h3 id="k-dev">your git config</h3><dl>198308 <div><dt>forge.signingkey, forge.allowedsigners</dt><dd>Sign with this key; verify against this file.</dd></div>@@ -200,7 +310,7 @@ deleted 1788292812 dami <span class="dim"># only o
200310 <div><dt>forge.cidir, forge.nix</dt><dd>Where CI worktrees live; <code>forge.nix false</code> skips <code>nix develop</code>.</dd></div>201311 </dl></section>202312 <section aria-labelledby="k-backup"><h3 id="k-backup">forge.env, for backups</h3><dl>203-<div><dt>FORGE_BACKUP_REPOSITORY, _PASSWORD, _KEY_ID, _SECRET</dt><dd>The restic repository (an R2 bucket), its password, and the bucket's keys. Keep the password off the box.</dd></div>313+<div><dt>FORGE_BACKUP_REPOSITORY, _PASSWORD, _KEY_ID, _SECRET</dt><dd>The restic repository (an R2 bucket), its password and the bucket's keys. Keep the password off the box.</dd></div>204314 <div><dt>FORGE_BACKUP_AT, _KEEP_DAILY, _KEEP_WEEKLY</dt><dd>When the daily backup runs (UTC) and what retention keeps: 03:30, 7 and 5.</dd></div>205315 <div><dt>FORGE_MIRROR_S3, _KEY_ID, _SECRET, _DELAY, _MIN_INTERVAL</dt><dd>The pages mirror's bucket and keys, how long it waits after the last build, 600 seconds, and how long at least between syncs, 3600 seconds.</dd></div>206316 </dl></section>@@ -212,8 +322,31 @@ deleted 1788292812 dami <span class="dim"># only o
212322 </div>213323 </section>214324 325+<section class="commit" id="server" aria-labelledby="server-h">326+<header><p class="ref"><b>11</b> on the server</p><h2 id="server-h"><a href="#server">Running it, as root</a></h2></header>327+<p class="prose">Administer as root on the server (<code>forge-admin ...</code> in the container, which runs <code>forge</code> as the git user; on a VPS, <code>sudo <wbr>-u <wbr>git <wbr>/<wbr>opt/<wbr>forge/<wbr>bin/<wbr>forge</code>), never by logging in as <code>git</code>: every key is forced through <code>forge shell</code>. The forge's admins can run the people commands over ssh instead.</p>328+<div class="cheat">329+<section aria-labelledby="sv-root"><h3 id="sv-root">as root</h3><dl>330+<div><dt>forge-admin user add fox 'ssh-ed25519 AAAA...'</dt><dd>Add a user and key; <code>user rm fox</code> revokes their keys and web credentials. <code>user invite</code> makes a link instead.</dd></div>331+<div><dt>forge-admin repo new cat/greet "description"</dt><dd>Or <code>repo import cat/greet <url></code> to fetch one server side; <code>repo rm</code> and <code>repo restore</code> for the trash.</dd></div>332+<div><dt>forge-admin collaborators cat/greet add fox</dt><dd>The same lists the owner keeps over ssh, for any repo; <code>readers</code> too.</dd></div>333+<div><dt>forge-admin build [owner/repo]</dt><dd>Rebuild pages; <code>metrics</code> shows where builds spend their time.</dd></div>334+<div><dt>forge-admin gc [--dry-run]</dt><dd>Remove unused site and release files, hostnames and leftover previews, then repack each repo (<a href="#upkeep">upkeep</a>). Daily from cron.</dd></div>335+<div><dt>forge-admin backups</dt><dd>How the last backup went, then the snapshots. <code>backup</code> takes one now; the container takes one daily.</dd></div>336+<div><dt>forge-admin restore latest</dt><dd>Bring the forge back on an empty box, from the newest snapshot or a named one.</dd></div>337+</dl></section>338+<section aria-labelledby="sv-repo"><h3 id="sv-repo">git config, on the server</h3><dl>339+<div><dt>git config forge.requireci tip</dt><dd>Require a verified pass for the new tip of <code>main</code>; <code>all</code> checks every new commit.</dd></div>340+<div><dt>git config forge.requireapproval true</dt><dd>Require an approval of the new tip from someone other than the review's author.</dd></div>341+<div><dt>git config forge.releaserequireci true</dt><dd>Require a verified pass for a release's tag.</dd></div>342+<div><dt>git config forge.allowforce true</dt><dd>Let the owner and collaborators force-push <code>main</code>.</dd></div>343+<div><dt>git config --file /srv/git/.gitconfig --add forge.admins cat</dt><dd>In the git user's own config: the forge's admins, who may <code>invite</code>, list <code>users</code> and set the <code>motd</code> over ssh.</dd></div>344+</dl></section>345+</div>346+</section>347+215348 <section class="commit" id="upkeep" aria-labelledby="upkeep-h">216-<header><p class="ref"><b>07</b> upkeep</p><h2 id="upkeep-h"><a href="#upkeep">Keeping it tidy</a></h2></header>349+<header><p class="ref"><b>12</b> upkeep</p><h2 id="upkeep-h"><a href="#upkeep">Keeping it tidy</a></h2></header>217350 <div class="prose">218351 <p><strong>Previews</strong> go when their branch does: <code>post-receive</code> deletes the preview ref, the served link and the hostname of a deleted branch. Previews of merged branches stay until the branch is deleted.</p>219352 <p><strong>The store</strong> keeps every tree that was ever deployed until <code>forge gc</code>, which removes</p>@@ -222,19 +355,19 @@ deleted 1788292812 dami <span class="dim"># only o
222355 <li>hostnames whose deploy is gone,</li>223356 <li>store trees that no <code>www/sites</code> or <code>www/previews</code> link points at.</li>224357 </ul>225-<p>A deploy extracts its tree before it links it, and touches a tree it reuses, so gc skips store entries changed in the last ten minutes (<code>--age</code>) and is safe to run while people push. <code>--dry-run</code> shows what would go. Run it daily, from the host's crontab for the container (<code>docker exec forge forge-admin gc</code>) or the git user's on a VPS.</p>226-<p><strong>Repos</strong> are maintained by the same <code>forge gc</code>, in <code>repack_repo</code>: <code>git repack -d --geometric=2 --write-midx --write-bitmap-index</code> merges packs only until each is at least twice the next one's size, so a day's run rewrites the day's pushes and loose objects, not the repo, and one multi-pack index with a bitmap serves clones and fetches across the packs. It deletes only packs it rewrote and prunes nothing, so pushes can land while it runs. Then the commit-graph is rewritten as one layer (<code>--split=replace</code>, reusing the Bloom filters it has, adding any that old layers lack) and refs are packed, since every revision pins a loose <code>refs/forge/keep/<sha></code>. Between runs, each build adds a commit-graph layer for its push.</p>227-<p><strong>Repo settings</strong> come from <code>repo_setup</code>, when a repo is created (<code>repo new</code>, <code>ssh new</code>, a push to a missing repo, an import) and for every repo on <code>forge server init</code>, which the container runs on boot: the hooks, <code>receive.advertisePushOptions</code>, <code>transfer.hideRefs refs/forge</code> so clones, fetches and pushes never see the pinned revisions (the build reads them directly; pre-receive also refuses them), and <code>commitGraph.changedPathsVersion 2</code>, filters that hash non-ASCII paths the same on every platform.</p>228-<p><strong>Backups</strong>: <code>forge backup</code> stages a copy of <code>/srv/git</code> without what a restore rebuilds (pages, extracted sites, caches, generated keys files, hooks, logs), uploads it with restic, prunes old snapshots, runs <code>restic check</code>, and restores the new snapshot into a scratch directory to <code>git fsck</code> every repo and compare its refs. The copy is consistent mid-push: per repo it copies review logs and comments, then reads the refs, then hard-links the objects, which git writes before anything names them. It refuses a forge with no repos, or half the last backup's, so a new box can't push good snapshots out. <code>forge-admin backups</code> says how the last one went, and loudly when it failed or is a day and a half old.</p>358+<p>A deploy extracts its tree before it links it, and touches a tree it reuses, so gc skips store entries changed in the last 10 minutes (<code>--age</code>) and is safe to run while people push. <code>--dry-run</code> shows what would go. Run it daily, from the host's crontab for the container (<code>docker <wbr>exec <wbr>forge <wbr>forge-admin <wbr>gc</code>) or the git user's on a VPS.</p>359+<p><strong>Repos</strong> are maintained by the same <code>forge gc</code>, in <code>repack_repo</code>: <code>git <wbr>repack <wbr>-d <wbr>--geometric=2 <wbr>--write-midx <wbr>--write-bitmap-index</code> merges packs only until each is at least twice the next one's size, so a day's run rewrites the day's pushes and loose objects, not the repo, and one multi-pack index with a bitmap serves clones and fetches across the packs. It deletes only packs it rewrote and prunes nothing, so pushes can land while it runs. Then the commit-graph is rewritten as one layer (<code>--split=replace</code>, reusing the Bloom filters it has, adding any that old layers lack) and refs are packed, since every revision pins a loose <code>refs/forge/keep/<sha></code>. Between runs, each build adds a commit-graph layer for its push.</p>360+<p><strong>Repo settings</strong> come from <code>repo_setup</code>, when a repo is created (<code>repo new</code>, <code>ssh new</code>, a push to a missing repo, an import) and for every repo on <code>forge server init</code>, which the container runs on boot: the hooks, <code>receive.advertisePushOptions</code>, <code>transfer.hideRefs refs/forge</code> so clones, fetches and pushes never see the pinned revisions (the build reads them directly; pre-receive also refuses them), and <code>commitGraph.changedPathsVersion <wbr>2</code>, filters that hash non-ASCII paths the same on every platform.</p>361+<p><strong>Backups</strong>: <code>forge backup</code> stages a copy of <code>/srv/git</code> without what a restore rebuilds (pages, extracted sites, caches, generated keys files, hooks, logs), uploads it with restic, prunes old snapshots, runs <code>restic check</code> and restores the new snapshot into a scratch directory to <code>git fsck</code> every repo and compare its refs. The copy is consistent mid-push: per repo it copies review logs and comments, then reads the refs, then hard-links the objects, which git writes before anything names them. It refuses a forge with no repos, or half the last backup's, so a new box can't push good snapshots out. <code>forge-admin backups</code> says how the last one went, and loudly when it failed or is a day and a half old.</p>229362 <p><strong>Moving</strong>: <code>forge restore latest</code> on an empty forge unpacks a snapshot and rebuilds the rest: what <code>server init</code> and <code>keys sync</code> write, hooks, pinned revisions, site files, pages. The restored tailcat key keeps the tunnel address, so the edge follows the forge to a new box by itself. The README walks through a new slate and a VPS.</p>230-<p><strong>The offline copy</strong>: after builds, at most hourly, <code>forge mirror</code> syncs the forge's own pages, without sites, previews, or the pages of single files and folders, to an R2 bucket. When slate doesn't answer, the edge's Caddy serves them from there with <code>X-Forge-Offline: mirror</code>, and a page the copy leaves out is a 404 that says so; sites, clones and pushes wait for slate.</p>363+<p><strong>The offline copy</strong>: after builds, at most hourly, <code>forge mirror</code> syncs the forge's own pages, without sites, previews or the pages of single files and folders, to an R2 bucket. When slate doesn't answer, the edge's Caddy serves them from there with <code>X-Forge-Offline: mirror</code>, and a page the copy leaves out is a 404 that says so; sites, clones and pushes wait for slate.</p>231364 <p><strong>Updates</strong>: <code>deploy/slate/deploy.sh</code> rsyncs the checkout to the box and rebuilds the container; the boot build refreshes every page. A change to <code>lib/</code> or <code>site/</code> changes the code hash in every fingerprint, so that first build renders everything.</p>232-<p><strong>Logs</strong>: <code>.forge/build.log</code> for builds, <code>.forge/events.log</code> for the events server, and <code>backup.log</code> and <code>mirror.log</code>, kept under a megabyte. <code>events.jsonl</code> is cut back to its last thousand lines once it passes a megabyte.</p>365+<p><strong>Logs</strong>: <code>.forge/build.log</code> for builds, <code>.forge/events.log</code> for the events server, and <code>backup.log</code> and <code>mirror.log</code>, kept under a megabyte. <code>events.jsonl</code> is cut back to its last 1,000 lines once it passes a megabyte.</p>233366 </div>234367 </section>235368 236369 <section class="commit" id="hacking" aria-labelledby="hacking-h">237-<header><p class="ref"><b>08</b> hacking</p><h2 id="hacking-h"><a href="#hacking">Working on forge</a></h2></header>370+<header><p class="ref"><b>13</b> hacking</p><h2 id="hacking-h"><a href="#hacking">Working on forge</a></h2></header>238371 <div class="cheat">239372 <section aria-labelledby="hk-run"><h3 id="hk-run">run it</h3><dl>240373 <div><dt>npm install</dt><dd>11ty, markdown-it, sanitize-html, Pele; TypeScript only for checking.</dd></div>@@ -243,7 +376,7 @@ deleted 1788292812 dami <span class="dim"># only o
243376 <div><dt>npm run dev</dt><dd>Serves the demo from <code>_site/</code> on :8080 and rebuilds it, reloading the browser, when templates, assets or <code>lib/</code> change. It never writes to <code>demo/root</code>.</dd></div>244377 </dl></section>245378 <section aria-labelledby="hk-check"><h3 id="hk-check">check it</h3><dl>246-<div><dt>sh .forge/ci</dt><dd>Lints the shell, type-checks, builds the demo, checks a few pages, and checks this guide's links. It is forge's own CI, so <code>forge attest</code> runs it and signs the result.</dd></div>379+<div><dt>sh .forge/ci</dt><dd>Lints the shell, type-checks, builds the demo, checks a few pages and checks this guide's links. It is forge's own CI, so <code>forge attest</code> runs it and signs the result.</dd></div>247380 <div><dt>npx tsc -p .</dt><dd>Type check only. <code>tsc</code> never emits.</dd></div>248381 <div><dt>node lib/check-links.ts guide</dt><dd>Every relative link and anchor in the guide resolves.</dd></div>249382 <div><dt>node lib/render-page.ts check <www></dt><dd>Every page renders the same with plain Nunjucks as with 11ty, the base for rendering pages on demand.</dd></div>@@ -251,7 +384,7 @@ deleted 1788292812 dami <span class="dim"># only o
251384 </div>252385 <div class="prose" style="margin-top:1.4rem">253386 <p>Conventions: shell stays POSIX (<code>sh -n bin/forge</code>); TypeScript uses erasable syntax only and imports name their <code>.ts</code> files, since Node strips types and runs the sources; no frameworks; comments say why. Pages must work without JavaScript, which only adds shortcuts, live updates and conveniences.</p>254-<p>This guide is plain HTML and CSS with a few lines of script, and every link in it is relative, so it works on its own hostname and under any path. Edit <code>guide/</code>, run the checks, and publish from <code>main</code> with <code>forge site deploy guide</code>.</p>387+<p>This guide is plain HTML and CSS with a few lines of script, and every link in it is relative, so it works on its own hostname and under any path. Edit <code>guide/</code>, run the checks and publish from <code>main</code> with <code>forge site deploy guide</code>.</p>255388 </div>256389 </section>257390