lib/releases.ts reads each refs/releases/<tag>: its files with their sizes and the sha256s SHA256SUMS gives them, whether SHA256SUMS.sig verifies against allowed_signers for forge-release and whose key it is (remembered by the two blobs' ids until the signers change, like site deploy signatures), and the Source-Commit and Built-By trailers. A tag with a release shows that above its notes, with download links, the CI result of the tag's commit, and a note if the tag has moved since. The demo publishes v0.1 (greet.sh and a source tarball), and CI checks that the files are served, verify, are listed with a good signature, and come back after a restore.
- commit
- 2e295bf7e3d59582e85d79f5e4f160c6ecf2ee95
- parent
- 56edc8a4a7c8b3469bcf58f7d70da14860bc6a7b
- author
- cat <admin@dev.site>
- kind
- feat risk score 1.5 of 3, Jev's estimate
| M | .forge/ | 6 | ++ |
| M | demo/ | 3 | + |
| M | lib/ | 4 | +- |
| A | lib/ | 92 | ++++++++++++++++++++++++++++++ |
| M | lib/ | 4 | +- |
| M | lib/ | 11 | +++- |
| M | lib/ | 27 | +++++++++ |
| M | lib/ | 2 | + |
| M | site/ | 27 | ++++++++- |
| A | test/ | 40 | +++++++++++++ |
@@ -41,6 +41,11 @@ node lib/review-list.ts $rv/reviews.json | grep -q '#2 .*approved by ana'
4141 w=demo/root/.forge/www4242 test ! -e "$w/previews/dami/greet/docs/dami~old-idea"4343 if grep -q old-idea "$w/dami/greet/sites/index.html"; then echo "sites page lists a deleted preview"; exit 1; fi44+# The v0.1 release: its files served, the page listing them with a good signature.45+dl=$w/dami/greet/releases/download/v0.146+sh bin/forge release verify --signers demo/root/.forge/allowed_signers "$dl/greet.sh" "$dl/greet-0.1.tar.gz" >/dev/null47+grep -q 'releases/download/v0.1/greet.sh" download>' "$w/dami/greet/releases/index.html"48+grep -q '✓ signed by dami' "$w/dami/greet/releases/index.html"4449 # Push to create, and landing on the server: ana/notes was made by a push and4550 # its review landed by ssh land, as dami/greet's first was; the hooks logged4651 # the deleted branches, and main is where they were.@@ -82,6 +87,7 @@ for x in dami/greet ana/dotfiles; do
8287 done8388 test -f "$r/.forge/www/dami/greet/reviews/3/index.html"8489 test -f "$r/.forge/www/sites/dami/greet/docs/index.html"90+cmp "$w/dami/greet/releases/download/v0.1/greet.sh" "$r/.forge/www/dami/greet/releases/download/v0.1/greet.sh"8591 if command -v restic >/dev/null 2>&1; then8692 export FORGE_BACKUP_REPOSITORY="$r/restic" FORGE_BACKUP_PASSWORD=ci8793 FORGE_ROOT=$PWD/demo/root sh bin/forge backup 2>/dev/null@@ -157,6 +157,9 @@ git tag v0.1
157157 git push -q origin main v0.1158158 "$forge" push159159 "$forge" site deploy docs160+# A release of v0.1: the script and a source tarball, with a signed SHA256SUMS.161+git archive --format=tar.gz --prefix=greet-0.1/ -o "$work/greet-0.1.tar.gz" v0.1162+"$forge" release publish v0.1 greet.sh "$work/greet-0.1.tar.gz"160163 161164 step "server: require a verified passing attestation to land on main"162165 git --git-dir="$FORGE_ROOT/dami/greet.git" config forge.requireci tip@@ -289,7 +289,9 @@ export function loadForge(env: NodeJS.ProcessEnv = process.env): Forge {
289289 }290290 }291291 add(pages.refs, page(u.refs.path, `Refs - ${repo.slug}`, 'refs', { branches: repo.branches, tags: repo.tags }));292- add(pages.releases, page(`${u.home.path}releases/`, `Releases - ${repo.slug}`, 'refs', { releases: releases(repo, cache.ranges) }));292+ // Each tag's notes, its files if it has a release, and its commit's CI.293+ 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 }));294+ add(pages.releases, page(`${u.home.path}releases/`, `Releases - ${repo.slug}`, 'refs', { releases: notes }));293295 294296 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);295297 add(pages.reviewLists, page(u.reviews.path, `Reviews - ${repo.slug}`, 'reviews', { groups, total: repo.reviews.length }));@@ -0,0 +1,92 @@
1+// Release files, for the releases page. A release is a commit on2+// refs/releases/<tag> whose tree is its files plus SHA256SUMS and3+// SHA256SUMS.sig, an ssh-keygen signature of SHA256SUMS for the4+// forge-release namespace (forge release publish; pre-receive checks5+// both). The hooks and the build hard-link the files under6+// /<owner>/<repo>/releases/download/<tag>/; this module reads what the page7+// shows about them: each file's size and sha256, who signed SHA256SUMS, and8+// where the release came from.9+import { spawnSync } from 'node:child_process';10+import fs from 'node:fs';11+import os from 'node:os';12+import path from 'node:path';13+import { catFileBatch, tryRun } from './git.ts';14+import { signersFor } from './sites.ts';15+import type { RepoCache } from './cache.ts';16+import type { Ref, ReleaseFiles, RepoUrls } from './types.ts';17+18+export const NAMESPACE = 'forge-release';19+const SUMS = 'SHA256SUMS';20+const SIG = 'SHA256SUMS.sig';21+const FMT = ['%H', '%cI', '%(trailers:key=Source-Commit,valueonly,separator=%x2c)', '%(trailers:key=Built-By,valueonly,separator=%x2c)'].join('%x1f');22+23+// "<sha256> <name>" lines, as sha256sum writes them ("*<name>" in binary mode).24+export function parseSums(text: string): Map<string, string> {25+ const out = new Map<string, string>();26+ for (const line of text.split('\n')) {27+ const m = /^([0-9a-f]{64}) [ *](\S+)$/.exec(line);28+ if (m) out.set(m[2], m[1]);29+ }30+ return out;31+}32+33+function verify(sums: Buffer, sig: Buffer, signers: string): { signed: 'good' | 'bad' | 'unknown'; signer: string | null } {34+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-release-'));35+ try {36+ const file = path.join(dir, 'sig');37+ fs.writeFileSync(file, sig);38+ const find = spawnSync('ssh-keygen', ['-Y', 'find-principals', '-s', file, '-f', signers], { encoding: 'utf8' });39+ const signer = find.status === 0 ? find.stdout.split('\n')[0].trim() || null : null;40+ if (!signer) return { signed: 'unknown', signer: null };41+ const v = spawnSync('ssh-keygen', ['-Y', 'verify', '-f', signers, '-I', signer, '-n', NAMESPACE, '-s', file], { input: sums });42+ return { signed: v.status === 0 ? 'good' : 'bad', signer };43+ } finally {44+ fs.rmSync(dir, { recursive: true, force: true });45+ }46+}47+48+interface LoadOptions {49+ urls: RepoUrls;50+ allowedSigners: string;51+ cache: RepoCache;52+}53+54+// Every release of the repo, by tag. Signature checks are remembered by the55+// blob ids of SHA256SUMS and its signature, until the allowed signers change.56+export function loadReleases(gitDir: string, refs: Ref[], { urls, allowedSigners, cache }: LoadOptions): Map<string, ReleaseFiles> {57+ const out = new Map<string, ReleaseFiles>();58+ const list = refs.filter((r) => r.name.startsWith('refs/releases/'));59+ if (!list.length) return out;60+ const meta = new Map<string, string[]>();61+ const log = tryRun(gitDir, ['log', '--no-walk=unsorted', '--stdin', `--format=${FMT}%x1e`], { input: list.map((r) => r.sha).join('\n') + '\n' }) ?? '';62+ for (const rec of log.split('\x1e').map((s) => s.trim()).filter(Boolean)) {63+ const f = rec.split('\x1f');64+ meta.set(f[0], f);65+ }66+ const signers = signersFor(allowedSigners, cache);67+ for (const r of list) {68+ const tag = r.name.slice('refs/releases/'.length);69+ const entries = (tryRun(gitDir, ['ls-tree', '-l', '-z', r.sha]) ?? '').split('\0').filter(Boolean).map((rec) => {70+ const tab = rec.indexOf('\t');71+ const [, , oid, size] = rec.slice(0, tab).split(/ +/);72+ return { name: rec.slice(tab + 1), oid, size: Number(size) };73+ });74+ const sums = entries.find((e) => e.name === SUMS);75+ const sig = entries.find((e) => e.name === SIG);76+ const blobs = catFileBatch(gitDir, [sums?.oid, sig?.oid].filter((x): x is string => !!x));77+ const sumsData = sums ? blobs.get(sums.oid)?.data : undefined;78+ const sigData = sig ? blobs.get(sig.oid)?.data : undefined;79+ const hashes = parseSums(sumsData?.toString('utf8') ?? '');80+ const check = sums && sig && sumsData && sigData && signers ? (cache.sigs[`release ${sums.oid} ${sig.oid}`] ??= verify(sumsData, sigData, signers)) : {};81+ const [, date = r.date, source = '', builtBy = ''] = meta.get(r.sha) ?? [];82+ out.set(tag, {83+ tag, sha: r.sha, date, source: source.trim() || null, builtBy: builtBy.trim() || null,84+ files: entries.filter((e) => e !== sums && e !== sig).map((e) => ({ name: e.name, size: e.size, sha256: hashes.get(e.name) ?? null, href: urls.download(tag, e.name).href })),85+ sums: urls.download(tag, SUMS).href,86+ sig: urls.download(tag, SIG).href,87+ signed: check.signed ?? null,88+ signer: check.signer ?? null,89+ });90+ }91+ return out;92+}@@ -14,6 +14,7 @@ import { parseNote, ciStatus, type Verifier } from './attest.ts';
1414 import { readReviewLogs, buildReviews } from './reviews.ts';1515 import { settle } from './discussion.ts';1616 import { loadSites } from './sites.ts';17+import { loadReleases } from './releases.ts';1718 import { repoUrls } from './urls.ts';1819 import { renderMarkdown, resolvePath, type LinkResolver } from './markdown.ts';1920 import { Spans } from './metrics.ts';@@ -318,11 +319,12 @@ export function loadRepo(cfg: Config, summary: RepoSummary, verifier: Verifier,
318319 319320 const { sites, previews } = spans.time('sites', () => loadSites(dir, refs, { urls, allowedSigners: cfg.allowedSigners, cache }));320321 for (const r of reviews) r.previews = previews.filter((p) => p.branch === r.branch);322+ const releaseFiles = spans.time('releases', () => loadReleases(dir, refs, { urls, allowedSigners: cfg.allowedSigners, cache }));321323 322324 return {323325 ...summary, defaultBranch, branches, tags, commits, logs, trees,324326 blobData: new Map(),325- reviews, sites, previews, readme, wiki,327+ reviews, sites, previews, releaseFiles, readme, wiki,326328 // Heavy reads, for pages that will render.327329 loadDiffs: (list) => spans.time('diffs', () => loadDiffs(dir, list, cfg.maxDiffLines)),328330 loadBlobs: (oids) => spans.time('blobs', () => catFileBatch(dir, oids)),@@ -19,11 +19,18 @@ function history(gitDir: string, ref: string, n: number): SiteDeploy[] {
1919 });2020 }2121 22+// The allowed signers file, if there is one; remembered signature checks23+// are dropped when it changes.24+export function signersFor(allowedSigners: string, cache: RepoCache): string | null {25+ const signers = fs.existsSync(allowedSigners) ? allowedSigners : null;26+ cache.useSigners(signers ? `${signers}:${fs.statSync(signers).mtimeMs}` : 'none');27+ return signers;28+}29+2230 // Signature checks run ssh-keygen per commit; a deploy commit never changes,2331 // so each is checked once and remembered (until the allowed signers change).2432 function checkSignatures(gitDir: string, deploys: SiteDeploy[], allowedSigners: string, cache: RepoCache): void {25- const signers = fs.existsSync(allowedSigners) ? allowedSigners : null;26- cache.useSigners(signers ? `${signers}:${fs.statSync(signers).mtimeMs}` : 'none');33+ const signers = signersFor(allowedSigners, cache);2734 const missing = [...new Set(deploys.map((c) => c.sha))].filter((sha) => !cache.sigs[sha]);2835 if (missing.length && signers) {2936 const out = tryRun(gitDir, ['-c', `gpg.ssh.allowedSignersFile=${signers}`, 'log', '--no-walk=unsorted', '--stdin', '--format=%H%x1f%G?%x1f%GS'], { input: missing.join('\n') + '\n' }) ?? '';@@ -237,6 +237,30 @@ export interface Preview extends Site {
237237 branch: string;238238 }239239 240+// A release's files (lib/releases.ts): refs/releases/<tag>.241+export interface ReleaseFile {242+ name: string;243+ size: number;244+ // From SHA256SUMS, which pre-receive checked against the file.245+ sha256: string | null;246+ href: string;247+}248+249+export interface ReleaseFiles {250+ tag: string;251+ sha: string;252+ date: string;253+ // The tag's commit when it was published.254+ source: string | null;255+ builtBy: string | null;256+ files: ReleaseFile[];257+ sums: string;258+ sig: string;259+ // SHA256SUMS.sig checked against allowed_signers.260+ signed: 'good' | 'bad' | 'unknown' | null;261+ signer: string | null;262+}263+240264 export interface Ref {241265 name: string;242266 sha: string;@@ -305,6 +329,7 @@ export interface RepoUrls {
305329 reviewDiff: (id: number) => Both;306330 revision: (id: number, n: number) => Both;307331 sites: Both;332+ download: (tag: string, file: string) => Both;308333 wiki: (path?: string) => Both;309334 atom: Both;310335 site: (site: string) => string;@@ -373,6 +398,8 @@ export interface Repo extends RepoSummary {
373398 reviews: Review[];374399 sites: Site[];375400 previews: Preview[];401+ // By tag.402+ releaseFiles: Map<string, ReleaseFiles>;376403 readme: Readme | null;377404 wiki: WikiPage[];378405 loadDiffs: (commits: Commit[]) => void;@@ -56,6 +56,8 @@ export function repoUrls(cfg: Config, owner: string, name: string): RepoUrls {
5656 reviewDiff: (id) => both(`${base}reviews/${id}/diff/`),5757 revision: (id, n) => both(`${base}reviews/${id}/r${n}/`),5858 sites: both(`${base}sites/`),59+ // A release's file, hard-linked there by the hooks and the build.60+ download: (tag, file) => both(`${base}releases/download/${encRef(tag)}/${file}`),5961 wiki: (p = '') => both(`${base}wiki/${p ? p + '/' : ''}`),6062 atom: both(`${base}atom.xml`),6163 site: (site) => hostUrl(owner, name, site) ?? fill(cfg.sitesUrl, { owner, repo: name, site }),@@ -5,13 +5,38 @@ pagination:
55 {% extends "base.njk" %}66 {% import "macros.njk" as m with context %}77 {% block content %}8+{% css %}9+/* A release's files: name, size, sha256; then who signed SHA256SUMS. */10+.downloads { padding: 8px 16px 4px; border-bottom: 1px solid var(--border); }11+.downloads table { width: 100%; border-collapse: collapse; }12+.downloads td { padding: 3px 12px 3px 0; vertical-align: top; }13+.downloads td.file { white-space: nowrap; font-weight: 600; }14+.downloads td.size { white-space: nowrap; text-align: right; }15+.downloads td.sum { font-size: 0.8em; overflow-wrap: anywhere; width: 100%; }16+.downloads p { margin: 6px 0 4px; font-size: 0.9em; }17+{% endcss %}818 {# Release notes: each tag's commits since the tag before it, under the919 section Jev sorted them into (lib/jev-commits.ts). The wording is the10- commit subjects; commits without an answer are under Other. #}20+ commit subjects; commits without an answer are under Other. A tag with21+ a release (forge release publish, lib/releases.ts) lists its files22+ first. #}1123 <h2>Releases</h2>1224 {% for r in p.releases %}1325 <div class="box" id="{{ r.tag.name }}">1426 <div class="box-head"><b class="mono">{{ r.tag.sha | commitLink(p.repo, r.tag.name) | safe }}</b><span class="grow desc">{{ ('since ' ~ r.prev) if r.prev else 'first release' }}, {{ r.count | plural('commit') }}{{ ' or more' if r.more }}</span><span class="date">{{ r.tag.date | date }}</span></div>27+{% if r.files %}28+<div class="downloads">29+<table>30+<tbody>31+{% for f in r.files.files %}<tr><td class="file mono"><a href="{{ f.href }}" download>{{ f.name }}</a></td><td class="size desc">{{ f.size | size }}</td><td class="sum mono desc" title="sha256">{{ f.sha256 or 'not in SHA256SUMS' }}</td></tr>32+{% endfor %}33+</tbody>34+</table>35+<p><a href="{{ r.files.sums }}" download>SHA256SUMS</a> {% if r.files.signed == 'good' %}<span class="ci pass" title="SHA256SUMS.sig verified against the forge's keys">✓ signed by {{ r.files.signer }}</span>{% elif r.files.signed %}<span class="ci fail">signature {{ r.files.signed }}</span>{% else %}<span class="desc">signature not checked</span>{% endif %} <span class="desc">(<a href="{{ r.files.sig }}" download>.sig</a>; <code>forge release verify <file></code>)</span>36+<br>CI of {{ r.tag.sha | short }}: {{ r.ci | ciHtml(true) | safe }}37+<br><span class="desc">published {{ r.files.date | ago | safe }}{{ (' by ' ~ r.files.builtBy) if r.files.builtBy }}{% if r.files.source and r.files.source != r.tag.sha %}, from {{ r.files.source | short }}, where the tag was then{% endif %}</span></p>38+</div>39+{% endif %}1540 <div class="notes">1641 {% for g in r.groups %}1742 {% if g.folded %}<details><summary>{{ g.title }} <span class="desc">{{ g.items.length }}</span></summary>{% else %}<h3>{{ g.title }}</h3>{% endif %}@@ -0,0 +1,40 @@
1+# The releases page lists a tag's files: links to the downloads, sizes,2+# sha256s, whether SHA256SUMS.sig verifies and whose it is, and the CI of3+# the tag's commit. A tag without a release has its notes only.4+. "$(dirname "$0")/../lib.sh"5+user dami6+user ana7+repo dami/greet8+clone dami dami/greet "$T/w/d"9+mkdir -p "$T/w/d/.forge"10+printf '#!/bin/sh\ntrue\n' >"$T/w/d/.forge/ci"11+chmod +x "$T/w/d/.forge/ci"12+at "$T/w/d" commit "first"13+at "$T/w/d" git tag v014+tick 360015+at "$T/w/d" commit "second"16+at "$T/w/d" git tag v117+ok push_as dami "$T/w/d" origin main v018+export FORGE_USER=dami19+ok at "$T/w/d" "$FORGE" attest v120+mkdir "$T/f"21+head -c 2500 /dev/zero >"$T/f/greet-linux-amd64"22+printf 'notes\n' >"$T/f/notes.txt"23+ok at "$T/w/d" "$FORGE" release publish v1 "$T/f/greet-linux-amd64" "$T/f/notes.txt"24+unset FORGE_USER25+export FORGE_STRICT=126+build dami/greet27+p=$FORGE_WWW/dami/greet/releases/index.html28+has "$p" '<a href="/dami/greet/releases/download/v1/greet-linux-amd64" download>greet-linux-amd64</a></td><td class="size desc">2.4K</td>'29+has "$p" "<td class=\"sum mono desc\" title=\"sha256\">$(t_sha256 "$T/f/greet-linux-amd64")</td>"30+has "$p" '<a href="/dami/greet/releases/download/v1/notes.txt" download>notes.txt</a>'31+has "$p" '<a href="/dami/greet/releases/download/v1/SHA256SUMS" download>SHA256SUMS</a> <span class="ci pass"[^>]*>✓ signed by dami</span>'32+has "$p" 'CI of [0-9a-f]{7}: <span class="ci pass">'33+has "$p" 'published .* by dami@'34+eq "$(grep -c 'class="downloads"' "$p")" 135+has "$p" 'id="v0"'36+37+# Once dami's key is gone, nobody vouches for SHA256SUMS any more.38+ok forge user rm dami39+build dami/greet40+has "$p" '<span class="ci fail">signature unknown</span>'