clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

Give releases a tab, and link the latest from a repo's home

cat committed 844fa10
The releases page was filed under Branches, reachable only from the tags
heading's "release notes" link, so a repo's releases were hard to find.
A Releases tab (key e) now sits after Sites on every repo with tags, and
is the current tab on the releases page; the home page's summary links
the newest release that has files, with how many.
commit
844fa108ec9a508d6d2b58fb5636f2f156f825d3
parent
214c315000be11da92d8036b7c5adb98c4d7c7e1
author
cat <admin@dev.site>
ci
✓ passed in 148s, signed by cat
kind
feat risk score 1.8 of 3, Jev's estimate
ci attestation
forge-attestation 1
type ci
commit 844fa108ec9a508d6d2b58fb5636f2f156f825d3
tree 2bf790524fd2a50caad4aed8e4297ea246df53c4
script .forge/ci 05d612a331a46d602928c28846e036e53299df48
result pass
exit 0
started 2026-10-18T20:45:40Z
duration 148s
runner (not shown here; refs/notes/ci has it)
system Darwin 25.5.0 arm64
env host
log sha256:f694e345124ad46c7e2ad7785a7ac0a1fb55da5e1a36c29660af788fe7aa2533 234 lines
|pushed wip/i18n
|4b6bac8 running .forge/ci: greet.sh: say what it is
|  | sh: /bin/sh
|  | ok   greet
|4b6bac8 pass in 0s, signed
|pushed main
|remote: review #4: /dami/greet/reviews/4/
|pushed wip/i18n
|366a122 running .forge/ci: wave at the world
|  | sh: /bin/sh
|  | FAIL greet : got 'hello, world o/', want 'hello, world'
|366a122 fail in 1s, signed
|forge: 1 commit(s) did not pass
|remote: review #6: /dami/greet/reviews/6/
|pushed ana/wave
|c34e5ef running .forge/ci: update the test for the wave
|  | sh: /bin/sh
|  | ok   greet
|c34e5ef 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 7dde9a8 -> /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
|61b326d running .forge/ci: web, server and Python versions of greet
|  | sh: /bin/sh
|  | ok   greet
|61b326d pass in 0s, signed
|pushed main
|a2ba1b3 running .forge/ci: messages: notes for the later regions
|  | sh: /bin/sh
|  | ok   greet
|a2ba1b3 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 c554a50 -> /sites/dami/forge/guide/
|(node:8993) 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 -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:9120) 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 -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:9285) 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 -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:9424) 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 -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|(node:10105) 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 1s -> /Users/dami/Library/Caches/forge/ci/35f7a65d0235/demo/root/.forge/www
|226 of 226 pages render the same without 11ty
|guide: 4 files, links ok
|restic is not installed: skipped the restic round trip
|ok

Signature: good, cat

10 files changed, +42 -8
MREADME.md2+-
Mlib/forge.ts12++++++++----
Mlib/types.ts3+++
Mlib/urls.ts2++
Msite/_includes/base.njk1+
Msite/assets/keys.ts4++--
Msite/repo/home.njk1+
Msite/repo/refs.njk2+-
Mtest/scenarios/release-page.sh6++++++
Atest/scenarios/release-tab.sh17+++++++++++++++++
M README.md +1 -1
M lib/forge.ts +8 -4
@@ -194,6 +194,7 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
194194 reviews: repo.reviews.filter((r) => r.status === 'open' || r.status === 'draft').length,195195 sites: repo.sites.length,196196 wiki: repo.wiki.length,197+ releases: repo.tags.length,197198 },198199 private: s.private,199200 };
@@ -223,11 +224,16 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
223224 const defaultLog = (repo.logs.get(repo.defaultBranch) ?? []).map((sha) => repo.commits.get(sha)).filter((c): c is Commit => !!c);224225 const rootTree = repo.trees.get(repo.defaultBranch);225226 const entryLinks = (b: string, entries: { type: string; path: string }[]) => entries.map((e) => ({ ...e, href: e.type === 'commit' ? null : e.type === 'blob' ? u.blob(b, e.path).href : u.tree(b, e.path).href }));227+ // Each tag's notes, its files if it has a release, and its commit's CI.228+ const notes = releases(repo, cache.ranges).map((r) => ({ ...r, files: repo.releaseFiles.get(r.tag.name) ?? null, ci: repo.commits.get(r.tag.sha)?.ci ?? null }));229+ // The newest release with files, linked from the home page.230+ const shipped = notes.find((r) => r.files);231+ const release = shipped ? { tag: shipped.tag.name, files: shipped.files?.files.length ?? 0, href: `${u.releases.href}#${shipped.tag.name}` } : null;226232 add(pages.homes, page(u.home.path, `${repo.slug}`, 'home', {227233 readme: repo.readme, latest: defaultLog[0] ?? null, commitCount: defaultLog.length, branchCount: repo.branches.length,228234 entries: rootTree ? entryLinks(repo.defaultBranch, rootTree.dirs.get('') ?? []) : [],229235 filesHref: rootTree ? u.files(repo.defaultBranch).href : null,230- reviews: firstStacks(nest(repo.reviews.filter((r) => r.status === 'open')), 5), topic,236+ reviews: firstStacks(nest(repo.reviews.filter((r) => r.status === 'open')), 5), topic, release,231237 }));232238 pages.atom.push({ permalink: u.atom.path, repo: head, commits: defaultLog.slice(0, 30) });233239 for (const kind of ['tree', 'log']) pages.branchMenus.push({ permalink: u.branchMenu(kind).path, repo: head, kind, branches: repo.branches });
@@ -296,9 +302,7 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
296302 }297303 }298304 add(pages.refs, page(u.refs.path, `Refs - ${repo.slug}`, 'refs', { branches: repo.branches, tags: repo.tags }));299- // Each tag's notes, its files if it has a release, and its commit's CI.300- const notes = releases(repo, cache.ranges).map((r) => ({ ...r, files: repo.releaseFiles.get(r.tag.name) ?? null, ci: repo.commits.get(r.tag.sha)?.ci ?? null }));301- add(pages.releases, page(`${u.home.path}releases/`, `Releases - ${repo.slug}`, 'refs', { releases: notes }));305+ add(pages.releases, page(u.releases.path, `Releases - ${repo.slug}`, 'releases', { releases: notes }));302306 303307 const groups = (['open', 'draft', 'merged', 'closed'] as const).map((status) => ({ status, items: nest(repo.reviews.filter((r) => r.status === status)) })).filter((g) => g.items.length);304308 add(pages.reviewLists, page(u.reviews.path, `Reviews - ${repo.slug}`, 'reviews', { groups, total: repo.reviews.length }));
M lib/types.ts +3 -0
M lib/urls.ts +2 -0
M site/_includes/base.njk +1 -0
M site/assets/keys.ts +2 -2
M site/repo/home.njk +1 -0
M site/repo/refs.njk +1 -1
M test/scenarios/release-page.sh +6 -0
A test/scenarios/release-tab.sh +17 -0