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
| M | README.md | 2 | +- |
| M | lib/ | 12 | ++++++++---- |
| M | lib/ | 3 | +++ |
| M | lib/ | 2 | ++ |
| M | site/ | 1 | + |
| M | site/ | 4 | ++-- |
| M | site/ | 1 | + |
| M | site/ | 2 | +- |
| M | test/ | 6 | ++++++ |
| A | test/ | 17 | +++++++++++++++++ |
@@ -1160,7 +1160,7 @@ Keyboard shortcuts (`?` lists them):
11601160 | `x` | fold or unfold the file in view |11611161 | `v` | mark the file in view as viewed |11621162 | `h` `g` | repo home, all repositories |1163-| `f` `l` `b` `r` `w` `s` `i` | code, commits, branches, reviews, wiki, sites, insights |1163+| `f` `l` `b` `r` `w` `s` `e` `i` | code, commits, branches, reviews, wiki, sites, releases, insights |11641164 | `u` | up a level |11651165 11661166 ## Templates@@ -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 }));@@ -332,6 +332,7 @@ export interface RepoUrls {
332332 download: (tag: string, file: string) => Both;333333 // Charts from the lake (lib/insights.ts), and the files they draw from.334334 insights: Both;335+ releases: Both;335336 wiki: (path?: string) => Both;336337 atom: Both;337338 site: (site: string) => string;@@ -342,6 +343,8 @@ export interface RepoUrls {
342343 343344 export interface RepoCounts {344345 reviews?: number;346+ // Tags: the releases page lists each one's notes, and its files if it has a release.347+ releases?: number;345348 sites?: number;346349 wiki?: number;347350 }@@ -59,6 +59,8 @@ export function repoUrls(cfg: Config, owner: string, name: string): RepoUrls {
5959 // A release's file, hard-linked there by the hooks and the build.6060 download: (tag, file) => both(`${base}releases/download/${encRef(tag)}/${file}`),6161 insights: both(`${base}insights/`),62+ // Each tag's notes and release files (site/repo/releases.njk).63+ releases: both(`${base}releases/`),6264 wiki: (p = '') => both(`${base}wiki/${p ? p + '/' : ''}`),6365 atom: both(`${base}atom.xml`),6466 site: (site) => hostUrl(owner, name, site) ?? fill(cfg.sitesUrl, { owner, repo: name, site }),@@ -60,6 +60,7 @@
6060 <a href="{{ r.urls.reviews.href }}" data-key="r"{% if p.nav == 'reviews' %} class="cur"{% endif %}>Reviews</a>6161 {% if r.counts.wiki %}<a href="{{ r.urls.wiki().href }}" data-key="w"{% if p.nav == 'wiki' %} class="cur"{% endif %}>Wiki</a>{% endif %}6262 {% if r.counts.sites %}<a href="{{ r.urls.sites.href }}" data-key="s"{% if p.nav == 'sites' %} class="cur"{% endif %}>Sites</a>{% endif %}63+{% if r.counts.releases %}<a href="{{ r.urls.releases.href }}" data-key="e"{% if p.nav == 'releases' %} class="cur"{% endif %}>Releases</a>{% endif %}6364 <a href="{{ r.urls.insights.href }}" data-key="i"{% if p.nav == 'insights' %} class="cur"{% endif %}>Insights</a>6465 </nav>6566 {% endif %}@@ -3,7 +3,7 @@
33 // n/N next/previous commit or file in a diff44 // t or p find a file on this branch55 // ? the list of shortcuts below6-// links with data-key: header letters (l f b r w s i h g), u goes up,6+// links with data-key: header letters (l f b r w s e i h g), u goes up,77 // [ and ] for stack layers. diffs.ts adds x and v for diff files.88 (() => {99 // Every shortcut, for the help overlay.@@ -19,7 +19,7 @@
1919 [['v'], 'mark the file in view as viewed'],2020 [['h'], 'repository home'],2121 [['f', 'l', 'b', 'r'], 'code, commits, branches, reviews'],22- [['w', 's', 'i'], 'wiki, sites, insights'],22+ [['w', 's', 'e', 'i'], 'wiki, sites, releases, insights'],2323 [['u'], 'up a level'],2424 [['g'], 'all repositories'],2525 ];@@ -19,6 +19,7 @@ pagination:
1919 <span class="grow"><a href="{{ u.commit(p.latest.sha).href }}">{{ p.latest.subject }}</a> <span class="desc">{{ p.latest.author.name }}, {{ p.latest.author.date | date }}</span> {{ p.latest.ci | ciHtml | safe }}</span>2020 <a href="{{ u.log(p.repo.defaultBranch).href }}">{{ p.commitCount | plural('commit') }}</a>2121 <a href="{{ u.refs.href }}">{{ p.branchCount | plural('branch', 'branches') }}</a>22+{% if p.release %}<a href="{{ p.release.href }}" title="the latest release: {{ p.release.files | plural('file') }}">release {{ p.release.tag }}</a>{% endif %}2223 {{ m.topic(p) }}2324 {% else %}<span class="grow desc">No commits yet. Push to {{ u.cloneSsh }}</span>{% endif %}2425 </div>@@ -18,7 +18,7 @@ pagination:
1818 </table>1919 </div>2020 {% if p.tags.length %}21-<h2>Tags <a class="releases-link" href="{{ u.home.href }}releases/">release notes</a></h2>21+<h2>Tags <a class="releases-link" href="{{ u.releases.href }}">releases</a></h2>2222 <div class="box">2323 <table class="list stack nav">2424 <tbody>@@ -1,6 +1,7 @@
11 # The releases page lists a tag's files: links to the downloads, sizes,22 # sha256s, whether SHA256SUMS.sig verifies and whose it is, and the CI of33 # the tag's commit. A tag without a release has its notes only.4+# The repo has a Releases tab, and its home page links the newest release.45 . "$(dirname "$0")/../lib.sh"56 user dami67 user ana@@ -33,6 +34,11 @@ has "$p" 'CI of [0-9a-f]{7}: <span class="ci pass">'
3334 has "$p" 'published .* by dami@'3435 eq "$(grep -c 'class="downloads"' "$p")" 13536 has "$p" 'id="v0"'37+# A Releases tab (key e), current on this page; the home page links the38+# newest release that has files.39+has "$p" '<a href="/dami/greet/releases/" data-key="e" class="cur">Releases</a>'40+has "$FORGE_WWW/dami/greet/index.html" '<a href="/dami/greet/releases/" data-key="e">Releases</a>'41+has "$FORGE_WWW/dami/greet/index.html" '<a href="/dami/greet/releases/#v1" title="the latest release: 2 files">release v1</a>'3642 3743 # Once dami's key is gone, nobody vouches for SHA256SUMS any more.3844 ok forge user rm dami@@ -0,0 +1,17 @@
1+# A repo without tags has no Releases tab and no release link at home.2+. "$(dirname "$0")/../lib.sh"3+user dami4+repo dami/greet5+clone dami dami/greet "$T/w/d"6+at "$T/w/d" commit "first"7+ok push_as dami "$T/w/d" origin main8+export FORGE_STRICT=19+build dami/greet10+lacks "$FORGE_WWW/dami/greet/index.html" 'data-key="e"'11+lacks "$FORGE_WWW/dami/greet/index.html" '>release '12+# A tag alone brings the tab (its notes), but no release link.13+at "$T/w/d" git tag v014+ok push_as dami "$T/w/d" origin v015+build dami/greet16+has "$FORGE_WWW/dami/greet/index.html" 'data-key="e">Releases</a>'17+lacks "$FORGE_WWW/dami/greet/index.html" '>release '