12# Build a throwaway forge in demo/root: two users, a repo with a stacked3# review that gets rewritten, signed CI attestations (passing and failing),4# a wiki, a docs site with a review preview, and a user's standalone site.5# Pushes go over the local file transport, so the real hooks run.6set -eu7here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)8app=$(dirname "$here")9forge=$app/bin/forge10work=$here/work1112export FORGE_ROOT=$here/root13export FORGE_WWW=$FORGE_ROOT/.forge/www14export FORGE_SSH=git@forge.local15export FORGE_NO_BUILD=116# The lake runs once, at the end, not in the background after each build.17export FORGE_LAKE=018export GIT_CONFIG_GLOBAL=$work/gitconfig19export GIT_CONFIG_NOSYSTEM=12021rm -rf "$FORGE_ROOT" "$work"22mkdir -p "$work/keys"23cat >"$GIT_CONFIG_GLOBAL" <<'EOF'24[init]25 defaultBranch = main26[advice]27 detachedHead = false28EOF2930# From midnight: BSD date -j fills the hour it isn't given from the clock,31# and the demo's last day would move with the time it was made.32t=$(date -u -j -f '%Y-%m-%d %H:%M:%S' '2026-09-01 00:00:00' +%s 2>/dev/null || date -u -d '2026-09-01 00:00:00' +%s)33# The hooks stamp reviews with FORGE_NOW, so the demo's clock moves with its commits.34tick() { t=$((t + ${1:-5400})); export GIT_AUTHOR_DATE="@$t +0000" GIT_COMMITTER_DATE="@$t +0000" FORGE_NOW=$t; }35commit() {36 tick37 git add -A38 if [ $# -gt 1 ]; then git commit -q -m "$1" -m "$2"; else git commit -q -m "$1"; fi39}40step() { printf '\n== %s\n' "$*"; }41# Comments and approvals go over ssh in real use (forge comment, forge42# approve); here the server side runs directly, as that user.43as() { u=$1; shift; tick 900; FORGE_USER=$u FORGE_NOW=$t "$forge" shell-cmd "$@"; }4445step "server: init, users, repos"46"$forge" server init47for u in dami ana; do48 ssh-keygen -q -t ed25519 -N '' -C "$u@demo" -f "$work/keys/$u"49 "$forge" user add "$u" "$work/keys/$u.pub"50done51"$forge" repo new dami/greet "say hello, with flags"52"$forge" repo new ana/dotfiles "ana's dotfiles and home page"5354# ssh without sshd, for the steps that go through the forced command (push55# to create, forge land --server): what authorized_keys runs for $FORGE_USER,56# with HOME at the forge, where git-shell finds its commands.57cat >"$work/ssh" <<EOF58#!/bin/sh59while :; do case \$1 in -o | -p) shift 2 ;; -*) shift ;; *) break ;; esac; done60shift61HOME="$FORGE_ROOT" SSH_ORIGINAL_COMMAND=\$* exec "$forge" shell "\$FORGE_USER"62EOF63chmod +x "$work/ssh"6465clone() { # user repo dir66 git clone -q "$FORGE_ROOT/$2.git" "$3"67 git -C "$3" config user.name "$(printf '%s' "$1" | awk '{print toupper(substr($0,1,1)) substr($0,2)}')"68 git -C "$3" config user.email "$1@example.com"69 git -C "$3" config forge.signingkey "$work/keys/$1"70 git -C "$3" config forge.allowedsigners "$FORGE_ROOT/.forge/allowed_signers"71 git -C "$3" config gpg.format ssh72 git -C "$3" config user.signingkey "$work/keys/$1"73}7475step "dami: main history, CI script, wiki, docs site"76clone dami dami/greet "$work/dami"77cd "$work/dami"78export FORGE_USER=dami79cat >greet.sh <<'EOF'80#!/bin/sh81# greet: say hello.82echo "hello, world"83EOF84cat >README.md <<'EOF'85# greet8687Say hello from the shell.8889 sh greet.sh9091See the [wiki](wiki/Home.md) for design notes, or read [greet.sh](greet.sh).92EOF93commit "initial greet"9495cat >test.sh <<'EOF'96#!/bin/sh97# Tiny test runner: each case is "args|expected output".98set -u99fail=0100while IFS='|' read -r args want; do101 [ -n "$want" ] || continue102 got=$(sh greet.sh $args 2>&1)103 if [ "$got" = "$want" ]; then echo "ok greet $args"104 else echo "FAIL greet $args: got '$got', want '$want'"; fail=1; fi105done <tests.txt106exit $fail107EOF108printf '|hello, world\n' >tests.txt109mkdir -p .forge110cat >.forge/ci <<'EOF'111#!/bin/sh112# Runs on the developer's machine via `forge attest`, in a clean worktree.113set -e114echo "sh: $(command -v sh)"115sh test.sh116# Numbers about the commit, which the insights page charts.117echo "tests $(grep -c . tests.txt)" >>"$FORGE_METRICS"118echo "script_bytes $(wc -c <greet.sh | tr -d ' ') B" >>"$FORGE_METRICS"119EOF120chmod +x .forge/ci121commit "add tests and a local CI script" "CI runs on your own machine: forge attest signs the result122with your SSH key and stores it as a git note on refs/notes/ci."123124mkdir -p wiki125cat >wiki/Home.md <<'EOF'126# Home127128greet is deliberately small. Start with [Design](Design.md).129130The wiki is just markdown under `wiki/` on the default branch, so it is131reviewed like code.132EOF133cat >wiki/Design.md <<'EOF'134# Design135136- Flags are parsed by hand; no getopt.137- Output goes to stdout, errors to stderr.138139How a greeting is made:140141```mermaid142flowchart LR143 args[command line] --> name{--name given?}144 name -->|yes| who[that name]145 name -->|no| world[world]146 who --> greet[hello, name]147 world --> greet148 greet --> shout{--shout?}149 shout -->|yes| upper[UPPERCASE] --> out[stdout]150 shout -->|no| out151```152153Back to [Home](Home.md).154EOF155commit "add a wiki"156157mkdir -p docs158cat >docs/build.sh <<'EOF'159#!/bin/sh160# Builds the docs site into _site/. FORGE_SITE_BASE is the path it will be served from.161set -e162base=${FORGE_SITE_BASE:-/}163mkdir -p _site164{165 echo '<!doctype html><meta charset="utf-8"><title>greet docs</title>'166 echo "<link rel=\"stylesheet\" href=\"${base}style.css\">"167 echo "<h1>greet</h1><p>Built from <code>$(git rev-parse --short HEAD)</code>, served at <code>$base</code>.</p>"168 echo '<h2>Usage</h2><pre>'169 sh greet.sh --help 2>/dev/null || echo 'sh greet.sh'170 echo '</pre>'171} >_site/index.html172printf 'body{font:16px/1.5 system-ui,sans-serif;max-width:40em;margin:2em auto;padding:0 1em}pre{background:#f4f4f4;padding:1em}\n' >_site/style.css173EOF174printf '# name output build command\ndocs _site sh docs/build.sh\n' >.forge/sites175commit "add a docs site" "Declared in .forge/sites; deployed with forge site deploy."176git tag v0.1177"$forge" attest HEAD~3 HEAD~2 HEAD~1 HEAD || true178git push -q origin main v0.1179"$forge" push180"$forge" site deploy docs181# A release of v0.1: the script and a source tarball, with a signed SHA256SUMS.182git archive --format=tar.gz --prefix=greet-0.1/ -o "$work/greet-0.1.tar.gz" v0.1183"$forge" release publish v0.1 greet.sh "$work/greet-0.1.tar.gz"184185step "server: require a verified passing attestation to land on main"186git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireci tip187188step "dami: a small review that lands"189git switch -q -c dami/readme190sed -i.bak 's/Say hello from the shell./Say hello from the shell, politely./' README.md && rm README.md.bak191commit "README: be polite"192"$forge" attest193"$forge" push194"$forge" verify195# ssh git@host land, which forge land --server runs: the forge pushes the196# tip to main and deletes the branch, and the hooks check CI and log it.197as dami land dami/greet dami/readme198git switch -q main && git pull -q --ff-only origin main199200step "dami: a stacked review (two branches, four commits)"201git switch -q -c dami/flags202cat >greet.sh <<'EOF'203#!/bin/sh204# greet: say hello.205name=world206while [ $# -gt 0 ]; do207 case $1 in208 --name) name=$2; shift ;;209 *) echo "greet: unknown flag $1" >&2; exit 2 ;;210 esac211 shift212done213echo "hello, $name"214EOF215printf -- '--name ana|hello, ana\n' >>tests.txt216commit "parse --name" "Hand-rolled flag parsing, per wiki/Design.md."217cat >greet.sh <<'EOF'218#!/bin/sh219# greet: say hello.220name=world shout=0221while [ $# -gt 0 ]; do222 case $1 in223 --name) name=$2; shift ;;224 --shout) shout=1 ;;225 *) echo "greet: unknown flag $1" >&2; exit 2 ;;226 esac227 shift228done229msg="hello, $name"230[ $shout = 1 ] && msg=$(echo "$msg" | tr a-z A-Z)231echo "$msg"232EOF233printf -- '--shout|HELLO, WORLD\n' >>tests.txt234commit "add --shout"235git switch -q -c dami/help236cat >greet.sh <<'EOF'237#!/bin/sh238# greet: say hello.239usage() { echo "usage: greet [--name NAME] [--shout]"; }240name=world shout=0241while [ $# -gt 0 ]; do242 case $1 in243 --name) name=$2; shift ;;244 --shout) shout=1 ;;245 -h | --help) usage; exit 0 ;;246 *) echo "greet: unknown flag $1" >&2; usage >&2; exit 2 ;;247 esac248 shift249done250msg="hello, $name"251[ $shout = 1 ] && msg=$(echo "$msg" | tr a-z A-Z)252echo "$msg"253EOF254printf -- '--help|usage: greet [--name NAME] [--shout]\n' >>tests.txt255commit "add --help"256cat >>README.md <<'EOF'257258## Flags259260 --name NAME who to greet (default: world)261 --shout uppercase the greeting262 --help print usage263EOF264commit "README: document flags"265"$forge" attest --stack266"$forge" push --stack -t "Usage and --help" -d 'Adds `--help`, and documents every flag in the README.\n\nStacked on #2, which parses the flags. Try it with `sh greet.sh --help`.'267"$forge" site deploy docs268269step "ana reviews r1: a line comment, a reply, an approval"270as ana comment dami/greet 3 --line greet.sh:3 <<'EOF'271Could `usage` show the defaults too? Something like:272273 usage: greet [--name NAME (world)] [--shout]274EOF275as dami comment dami/greet 3 <<'EOF'276Good idea. The defaults go in the README for now, so the usage line stays short.277EOF278as ana approve dami/greet 3 </dev/null279280step "dami: address feedback by rewriting both layers (r2 of each review)"281tick282awk '{ print } /^- Output goes/ { print "- `--name` takes the next argument verbatim." }' wiki/Design.md >wiki/Design.md.new283mv wiki/Design.md.new wiki/Design.md284git add -A285git commit -q --fixup="$(git rev-parse dami/flags~1)"286GIT_SEQUENCE_EDITOR=: git rebase -q -i --autosquash --update-refs origin/main287sed -i.bak 's/uppercase the greeting/UPPERCASE the greeting/' README.md && rm README.md.bak288git commit -q -a --amend --no-edit289"$forge" attest --stack290"$forge" push --stack291292step "ana looks at r2: her r1 comment and approval are now outdated"293as ana approve dami/greet 2 </dev/null294as ana comment dami/greet 3 --line greet.sh:10 <<'EOF'295Nice: usage goes to stderr for a bad flag, and to stdout for `--help`, so `greet --help | less` works.296EOF297as ana comment dami/greet 3 <<'EOF'298Looks good. I'll approve this layer again once #2 lands.299EOF300301step "dami: a draft and an abandoned branch"302git switch -q -c wip/i18n origin/main303printf 'hello=hello\nbye=goodbye\nthanks=thank you\n' >messages.txt304commit "sketch: message catalog"305"$forge" push --draft306git switch -q -c dami/old-idea origin/main307echo "# maybe later" >>greet.sh308commit "try a config file"309"$forge" push310"$forge" site deploy docs311git push -q origin --delete dami/old-idea312313step "dami: the draft grows (r2), then main moves on and it is rebased and reworked (r3)"314git switch -q wip/i18n315printf 'hello=bonjour\nbye=au revoir\n' >messages.fr.txt316commit "sketch: French messages"317"$forge" push318git switch -q main319sed -i.bak 's/^# greet: say hello\.$/# greet: say hello from the shell./' greet.sh && rm greet.sh.bak320commit "greet.sh: say what it is"321"$forge" attest322"$forge" push323git switch -q wip/i18n324tick325printf 'yes=yes\n' >>messages.txt326git commit -q -a --fixup=HEAD~1327GIT_SEQUENCE_EDITOR=: git rebase -q -i --autosquash main328"$forge" push329330step "ana: a contribution whose CI fails, then passes"331clone ana dami/greet "$work/ana"332cd "$work/ana"333export FORGE_USER=ana334tick335git commit -q --allow-empty -m "push straight to main"336if git push -q origin HEAD:refs/heads/main 2>"$work/denied.txt"; then echo "unexpected: push to main allowed"; exit 1; fi337sed 's/^/ (expected) /' "$work/denied.txt"338git reset -q --hard origin/main339git switch -q -c ana/wave340sed -i.bak 's/echo "hello, world"/echo "hello, world o\/"/' greet.sh && rm greet.sh.bak341commit "wave at the world"342"$forge" attest || echo " (expected) CI failed; the failure is signed and pushed too"343"$forge" push -t "Wave at the world"344tick345printf '|hello, world o/\n' >tests.txt346commit "update the test for the wave"347"$forge" attest348"$forge" push349# main also needs an approval from someone other than the author. Only for350# this check: later steps push main directly, which the gate would refuse.351git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireapproval true352git -C "$work/dami" fetch -q origin ana/wave353if FORGE_USER=dami git -C "$work/dami" push -q origin FETCH_HEAD:refs/heads/main 2>"$work/denied.txt"; then echo "unexpected: unapproved landing allowed"; exit 1; fi354sed 's/^/ (expected) /' "$work/denied.txt"355if as dami land dami/greet ana/wave 2>"$work/denied.txt"; then echo "unexpected: unapproved land allowed"; exit 1; fi356sed 's/^/ (expected) /' "$work/denied.txt"357git --git-dir="$FORGE_ROOT/dami/greet.git" config --unset forge.requireapproval358359step "ana: a standalone site in her own repo"360clone ana ana/dotfiles "$work/ana-dotfiles"361cd "$work/ana-dotfiles"362printf 'set -o vi\n' >.zshrc363commit "zshrc"364git push -q origin main365mkdir -p "$work/ana-home"366printf '<!doctype html><meta charset="utf-8"><title>ana</title><h1>ana</h1><p>Pushed with <code>forge site push</code>.</p>\n' >"$work/ana-home/index.html"367"$forge" site push "$work/ana-home" home368369step "ana: a repo created by pushing to it, and a review landed over ssh"370mkdir "$work/ana-notes"371cd "$work/ana-notes"372git init -q373git config user.name Ana374git config user.email ana@example.com375export GIT_SSH_COMMAND=$work/ssh376printf '# notes\n\nThings to remember.\n' >README.md377commit "notes"378git remote add origin git@forge.local:ana/notes.git379git push -q -u origin main380git switch -q -c ana/todo381printf '\n- land reviews from a phone: ssh git@forge.local land ana/notes 1\n' >>README.md382commit "a todo list"383"$forge" push -t "A todo list"384"$forge" land --server385unset GIT_SSH_COMMAND386387step "dami: code in a few languages, and a review of a long file (syntax highlighting)"388cd "$work/dami"389export FORGE_USER=dami390git switch -q main && git pull -q --ff-only origin main391mkdir -p web server bin392cat >web/greet.js <<'EOF'393// greet in the browser: the same greeting, with a template.394import { MESSAGES } from './messages.js';395396/* A greeting is "hello, <name>", shouted on request.397 Names are trimmed and must be non-empty. */398const NAME = /^\s*(\S.*?)\s*$/;399400export class Greeter {401 #shout = false;402403 constructor({ shout = false } = {}) {404 this.#shout = shout;405 }406407 greet(name = 'world') {408 const m = NAME.exec(name);409 const msg = `hello, ${m ? m[1] : 'world'}`;410 return this.#shout ? msg.toUpperCase() : msg;411 }412}413414export function render(el, name) {415 el.innerHTML = `416 <p class="greeting">${new Greeter().greet(name)}</p>417 <small>${MESSAGES.length} regional variants</small>`;418}419EOF420cat >web/style.css <<'EOF'421/* greet's page: one column, system fonts. */422:root { --fg: #1f2328; --accent: #2ca9bc; }423body { margin: 0 auto; max-width: 40em; padding: 2em 1em; font: 16px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; color: var(--fg); }424.greeting { font-size: 2em; color: var(--accent); }425@media (prefers-color-scheme: dark) {426 :root { --fg: #e6edf3; }427 body { background: #0d1117; }428}429EOF430cat >server/main.go <<'EOF'431// Command server greets over HTTP: GET /?name=ana says "hello, ana".432package main433434import (435 "fmt"436 "log"437 "net/http"438 "strings"439)440441const usage = `usage: server [addr]442Serves greetings on addr (default :8080).`443444func greet(name string, shout bool) string {445 if name = strings.TrimSpace(name); name == "" {446 name = "world"447 }448 msg := fmt.Sprintf("hello, %s", name)449 if shout {450 return strings.ToUpper(msg)451 }452 return msg453}454455func main() {456 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {457 q := r.URL.Query()458 fmt.Fprintln(w, greet(q.Get("name"), q.Has("shout")))459 })460 log.Fatal(http.ListenAndServe(":8080", nil)) // 0x1F90461}462EOF463cat >bin/greet-py <<'EOF'464#!/usr/bin/env python3465"""greet, in Python, for comparison with greet.sh."""466import sys467468469def greet(name: str = "world", shout: bool = False) -> str:470 msg = f"hello, {name}"471 return msg.upper() if shout else msg472473474if __name__ == "__main__":475 args = sys.argv[1:]476 print(greet(*[a for a in args if a != "--shout"][:1], shout="--shout" in args))477EOF478chmod +x bin/greet-py479cat >docs/examples.md <<'EOF'480# Examples481482From the shell:483484```sh485sh greet.sh --name ana --shout # HELLO, ANA486```487488In the browser (`web/greet.js`):489490```js491import { Greeter } from './greet.js';492const g = new Greeter({ shout: true });493console.log(g.greet('ana')); // "HELLO, ANA"494```495496Over HTTP (`server/main.go`):497498```go499resp, err := http.Get("http://localhost:8080/?name=ana")500```501502A change, as a patch:503504```diff505-echo "hello, world"506+echo "hello, $name"507```508EOF509awk 'BEGIN {510 print "// Regional greetings, generated. One entry per line, so diffs stay small."511 print "export const MESSAGES = ["512 for (i = 1; i <= 1200; i++) printf "\t{ id: %d, region: \"r%04d\", text: \"hello, world\" },\n", i, i513 print "];"514}' >web/messages.js515commit "web, server and Python versions of greet" "Same greeting, three more languages, plus 1200 generated regional messages."516"$forge" attest517"$forge" push518git switch -q -c dami/regions519awk 'NR >= 1100 && NR <= 1104 { sub(/text: "hello, world"/, "text: \"hello, world\", note: \"regions from 1098 on greet in their own words; this note is deliberately long so the diff has to scroll sideways on a phone, past the sticky line numbers\"") } { print }' web/messages.js >web/messages.js.new520mv web/messages.js.new web/messages.js521sed -i.bak 's/"Noto Color Emoji";/"Noto Color Emoji", "Twemoji Mozilla", "Android Emoji", "EmojiSymbols", "Symbola";/' web/style.css && rm web/style.css.bak522commit "messages: notes for the later regions" "Long lines on purpose: the review shows line numbers past 1000 and needs sideways scrolling."523"$forge" attest524"$forge" push -t "Regional notes"525526step "dami: approval rules and an agent reviewer; ana runs it over Regional notes"527cd "$work/dami"528git switch -q main529mkdir -p .forge530cat >.forge/approvals <<'EOF'531# What a change to main needs, by the files it changes (forge.requireapproval).532.forge/ 2533web/ 1 + agent534*.md wiki/ docs/ none535* 1536EOF537cat >.forge/review.md <<'EOF'538- A font stack names at most four emoji fonts.539- Lines over 160 characters need a reason.540EOF541cat >.forge/review <<'EOF'542#!/bin/sh543# The demo's reviewer: .forge/review.md's two rules in awk, so it needs no544# model. A real one hands the diff and the rules to an agent (README.md).545echo '{"model":"demo-rules"}'546awk '547 /^\+\+\+ b\// { p = substr($0, 7); long = 0; next }548 /^@@/ { match($0, /\+[0-9]+/); n = substr($0, RSTART + 1, RLENGTH - 1) - 1; next }549 /^\+/ { n++550 x = $0551 if (p ~ /\.css$/ && (k = gsub(/Emoji|Symbol/, "", x)) > 4)552 printf "{\"severity\":\"blocking\",\"path\":\"%s\",\"line\":%d,\"body\":\"This stack names %d emoji fonts; `.forge/review.md` allows four. Which of them does a reader without Noto Color Emoji actually have?\"}\n", p, n, k553 else if (length($0) > 161 && !long++)554 printf "{\"severity\":\"note\",\"path\":\"%s\",\"line\":%d,\"body\":\"%d characters. If the length is the point (it is, here), say so in the commit message.\"}\n", p, n, length($0) - 1555 next }556 /^ / { n++ }'557EOF558chmod +x .forge/review559commit "approval rules, and a reviewer for forge critique"560"$forge" attest561git push -q origin main refs/notes/ci562# forge critique asks the forge for the review list, which the page build writes.563"$forge" build dami/greet >/dev/null564cd "$work/ana"565export FORGE_USER=ana GIT_SSH_COMMAND=$work/ssh566git remote set-url origin git@forge.local:dami/greet.git567git fetch -q origin568"$forge" critique dami/regions || echo " (expected) a blocking finding is open"569git remote set-url origin "$FORGE_ROOT/dami/greet.git"570unset GIT_SSH_COMMAND571git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireapproval true572573step "dami: forge's own guide, a site whose build copies in the forge's fonts"574export FORGE_USER=dami575"$forge" repo new dami/forge "a personal git forge"576clone dami dami/forge "$work/forge"577cd "$work/forge"578cp -R "$app/guide" "$app/README.md" . && mkdir .forge && cp "$app/.forge/sites" .forge/579mkdir -p site/assets/fonts && cp "$app"/site/assets/fonts/*.woff2 site/assets/fonts/580commit "the guide"581git push -q origin main582"$forge" site deploy guide583584# The lake first, so the pages have its activity grids and their joined585# months; the build copies its insights exports next to the insights pages.586step "lake: the facts behind the insights pages"587unset FORGE_USER588"$forge" lake589590step "build"591"$forge" build592printf '\nserve it: python3 -m http.server 8080 -d %s\n' "$FORGE_WWW"