Comments and approvals gets a part of its own on the web: what a page shows to someone signed in and why it starts hidden, which revision a post from a page belongs to, the endpoints and their fields, how a post is authenticated (a session with its CSRF token and Origin, or a Bearer token), the 404 for a private repo you can't read, the rate limit, and that the server runs the same command as ssh. Reading pages says how to use the controls, the overview and What runs mention them, and the item leaves Not yet.
- commit
- f00bfd0eaef101e535cca5ca0f819f4c5bd0753e
- parent
- d114bf8d10ac6bba9b5b7d08afe1127966677dc3
- author
- cat <admin@dev.site>
- ci
- ✓ passed in 112s, signed by cat
- kind
- docs risk score 0.0 of 3, Jev's estimate
- review
- #11 Comment and approve from the web
ci attestation
forge-attestation 1 type ci commit f00bfd0eaef101e535cca5ca0f819f4c5bd0753e tree 553a2160f09b9b8aca82209d771255c5d56bc030 script .forge/ci 38df37c0d5a6382f60569079a0dd821094e37e4a result pass exit 0 started 2026-10-19T01:15:10Z duration 112s runner (not shown here; refs/notes/ci has it) system Darwin 25.5.0 arm64 env host log sha256:82761938a9ebf36a586aaa9debb34bfa08c4dd8331f0bbe977c925c3ed103d65 347 lines |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |3d7241a running .forge/ci: greet.sh: say what it is | | sh: /bin/sh | | ok greet |3d7241a pass in 0s, signed |pushed main |remote: review #4: /dami/greet/reviews/4/ |pushed wip/i18n |9d68337 running .forge/ci: wave at the world | | sh: /bin/sh | | FAIL greet : got 'hello, world o/', want 'hello, world' |9d68337 fail in 0s, signed |forge: 1 commit(s) did not pass |remote: review #6: /dami/greet/reviews/6/ |pushed ana/wave |363acc4 running .forge/ci: update the test for the wave | | sh: /bin/sh | | ok greet |363acc4 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 d1c4a94 -> /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 |6489964 running .forge/ci: web, server and Python versions of greet | | sh: /bin/sh | | ok greet |6489964 pass in 0s, signed |pushed main |84334d3 running .forge/ci: messages: notes for the later regions | | sh: /bin/sh | | ok greet |84334d3 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: deployed 17bc4ed -> /sites/dami/forge/guide/ |(node:44439) 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 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.LjayBs/wt/demo/root/.forge/www |(node:44564) 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 1s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.LjayBs/wt/demo/root/.forge/www |(node:44708) 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 0s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.LjayBs/wt/demo/root/.forge/www |(node:44838) 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 2s -> /var/folders/b4/mv8w4c8j0zj0_mldppxdbdw00000gn/T/forge-ci.LjayBs/wt/demo/root/.forge/www |(node:45505) 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.LjayBs/wt/demo/root/.forge/www |226 of 226 pages render the same without 11ty |guide: 4 files, links ok |ok
Signature: good, cat
| M | README.md | 66 | +++++++++++++++++++++++++----- |
@@ -22,10 +22,11 @@ It adds what stagit doesn't have, borrowing from [args.io](https://args.io):
2222 - **Revisions are recorded per push.** Force-pushing a review records a new2323 revision. Its page shows what changed: the diff between the two tips, or2424 after a rebase, each commit paired with its earlier version (`git range-diff`).25-- **Discussion from the terminal.** `forge comment` and `forge approve` add26- comments (on a line or the whole review) and approvals, which the review27- page shows. Approvals are keyed by tree like CI, and `main` can require one28- from someone other than the author. `forge land` checks both and25+- **Discussion from the terminal, or the page.** `forge comment` and26+ `forge approve` add comments (on a line or the whole review) and27+ approvals, which the review page shows; signed in, the page adds them28+ too. Approvals are keyed by tree like CI, and `main` can require one from29+ someone other than the author. `forge land` checks both and2930 fast-forwards.3031 - **CI runs on your machine.** `forge attest` runs `.forge/ci` in a clean3132 worktree. It signs the result with the SSH key you push with and stores it@@ -88,6 +89,7 @@ forge release publish -> refs/releases/<tag>
8889 11ty build -> /srv/git/.forge/www (static)8990 events.jsonl -> live page updates9091 forge comment, approve ─── ssh ──────────────> comment, approve: next to the review log92+review page, signed in ─── HTTPS ────────────> the same, through the private server9193 ```9294 9395 ### What runs@@ -98,7 +100,7 @@ forge comment, approve ─── ssh ──────────────>
98100 | Caddy | Serves `.forge/www` as plain files, read-only HTTPS clones (`git http-backend` through fcgiwrap; nothing else inside a bare repo is served; a private repo takes a token), and `go get` meta tags; proxies `/events/`, every path `www` has no file for (to the private server) and, with a pages domain, sites. |99101 | `forge build` | Runs 11ty after each push, in the background and one repo at a time, then swaps the new pages into `www/`. Unchanged pages are hard links to the last build's. First it adds the push's commits to the repo's commit-graph (see [Cleaning up](#cleaning-up)). |100102 | `lib/events-server.ts` | Tails `.forge/events.jsonl`, which hooks and builds append to, and streams it as server-sent events at `/events/<owner>/<repo>/`. Open pages say who pushed and swap in fresh content when the build finishes. Without it pages still work; their status line says offline. A private repo streams only to its readers, signed in. |101-| `lib/private-server.ts` | Serves private repos' pages to their readers, signed in with a passkey or holding a token, the sign-in pages (`/login/`, `/private/`), and the 404 of every page that isn't there; answers Caddy whether a token may clone a private repo over HTTPS (see [Private pages](#private-pages)). |103+| `lib/private-server.ts` | Serves private repos' pages to their readers, signed in with a passkey or holding a token, the sign-in pages (`/login/`, `/private/`), and the 404 of every page that isn't there; takes comments and approvals posted from review pages (see [Comments and approvals](#comments-and-approvals)); answers Caddy whether a token may clone a private repo over HTTPS (see [Private pages](#private-pages)). |102104 | `lib/pages-server.ts` | Only with a pages domain. Serves each site on its own hostname the way Cloudflare Pages does (`_headers`, `_redirects`, pretty URLs, the nearest `404.html` or a single-page-app fallback) and redirects old `/sites/...` paths there. Runs as the caddy user, since deploys are untrusted content. |103105 | `forge backup` | Daily, from a loop in `start.sh`. Snapshots the forge with restic into a bucket, then checks the snapshots and test-restores the new one (see [Backups and moving hosts](#backups-and-moving-hosts)). |104106 | `forge mirror` | After builds, in the background: syncs the forge's own pages to a bucket with rclone, for the edge to serve while the forge is down. |@@ -291,8 +293,10 @@ from `FORGE_BASE_URL` (just the path if that isn't set).
291293 292294 ### Comments and approvals293295 294-Both are written from the terminal over the forge's ssh account, and the295-pages rebuild in the background, so an open review page updates live.296+Both are written from the terminal over the forge's ssh account, or from297+the review page once you are signed in (see [On the web](#on-the-web)298+below), and the pages rebuild in the background, so an open review page299+updates live.296300 297301 ```sh298302 forge reviews # open and draft reviews: CI, approvals@@ -312,7 +316,7 @@ ssh git@git.example.com land dami/greet 7 # the forge lands it itself; no clon
312316 older comments show as outdated and still quote the line they were about.313317 Line comments on the current revision also appear under their line in the314318 combined diff. A comment made from a checkout belongs to the revision315- checked out.319+ checked out, and one from a page to the revision the page shows.316320 - **Approvals** are lines in the review log, `approve <ts> <user> <sha>317321 <tree>`. Like CI results they are keyed by tree, so rewording or318322 reordering commits keeps them; if the tree changes, the page shows@@ -346,6 +350,39 @@ land a review pushed a moment ago. The ssh commands live in
346350 `git-shell-commands/`; on an existing server, run `forge server init` again to347351 add them (the container does that on every boot).348352 353+#### On the web354+355+Signed in with a passkey (see [Private pages](#private-pages)), a review356+page ends in a comment box and an Approve button, and the combined diff357+puts a + beside the line under the pointer, which opens a box for a358+comment on that line. Pages are static files, the same for everyone, so359+these start hidden and do nothing without JavaScript: a script asks360+`/login/whoami` (never cached) who is signed in and shows them to a user,361+without Approve for the review's author. A page posts the tip it shows,362+like `forge comment` from a checkout: if the review has moved on since it363+was built, a comment goes to the revision you read, and an approval is364+refused. Until the rebuilt page arrives, a sent comment shows as pending,365+as typed; the build renders and sanitizes its markdown, as for any other.366+367+The posts go to the private server, `POST368+/<owner>/<repo>/reviews/<id>/comment` (fields `body`, `at`, `line` as369+`<path>:<n>`) and `.../approve` (`at`), as a form or JSON, and it runs370+`forge shell-cmd comment|approve` as you with the message on stdin, the371+command ssh runs. So a comment file, an approve line, the rules and the372+messages are the same either way, byte for byte. A session's post must373+come from the forge's own origin and carry its CSRF token (the page gets it374+from `/login/whoami`); a token works as `Authorization: Bearer` without375+one, for curl, and a token for one repo only there. Basic auth doesn't376+post, since browsers send it by themselves. A private repo you can't read377+answers 404, the same as a missing one. Each user may post 20 times a378+minute. Caddy sends these posts to the private server before it looks in379+`www`, so public reviews, whose pages are plain files, take them too.380+381+```sh382+curl -H "Authorization: Bearer $token" -d 'body=Looks good' -d 'line=greet.sh:12' \383+ https://git.example.com/dami/greet/reviews/7/comment384+```385+349386 ### CI attestations, and what they prove350387 351388 `forge attest [--stack]` checks out each commit into a temporary worktree and@@ -1220,6 +1257,15 @@ viewed". Viewed marks are kept in the browser's localStorage, keyed by repo,
12201257 path and a hash of that file's diff, so they survive a force-push that leaves12211258 the file alone and clear when the file changes.12221259 1260+Above the diffs, the discussion: approvals, then comments oldest first. Signed1261+in, it ends in a box to comment in, with Approve beside it (Ctrl or Cmd and1262+Enter sends), and on the combined diff a + follows the pointer down the lines:1263+click it for a box under that line (on a phone, tap the line, then the +). A1264+comment you send waits on the page, as you typed it, until the rebuilt page1265+swaps in a moment later with it rendered; what you are typing outlasts that1266+swap, and a reload. Signed out, or without JavaScript, the page shows none of1267+this.1268+12231269 "What changed since rN" compares a revision with the one before. If both were12241270 pushed on the same base, it is the diff between the two tips. If the branch12251271 was rebased, that diff would include everything that landed meanwhile, so@@ -1321,12 +1367,10 @@ may be missing needs an explicit check or `or ''`.
13211367 | `/<owner>/<repo>/releases/download/<tag>/<file>` | a release file; `latest/` for the newest release's |13221368 | `/<owner>/<repo>/insights/` | charts from the lake; `insights.json` and Parquet files next to it |13231369 | `/insights/` | the same for the whole forge, and a table by repo |1324-| `/login/` | sign in with a passkey, add one (`#enroll=<code>`), sign out |1370+| `/login/` | sign in with a passkey, add one (`#enroll=<code>`), sign out; `whoami` says who is signed in, for review pages |13251371 | `/private/` | the private repos you can read |13261372 | `/sites/<owner>/<repo>/<name>/` | a deployed site |13271373 13281374 ## Not yet13291375 1330-- Commenting and approving from the web pages; for now both come from the1331- terminal.13321376 - Bugs or issues in the args.io style.