clone

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

List release files on the releases page

cat committed 2e295bf
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
10 files changed, +211 -5
M.forge/ci6++
Mdemo/make-demo.sh3+
Mlib/forge.ts4+-
Alib/releases.ts92++++++++++++++++++++++++++++++
Mlib/repo.ts4+-
Mlib/sites.ts11+++-
Mlib/types.ts27+++++++++
Mlib/urls.ts2+
Msite/repo/releases.njk27++++++++-
Atest/scenarios/release-page.sh40+++++++++++++
M .forge/ci +6 -0
M demo/make-demo.sh +3 -0
M lib/forge.ts +3 -1
A lib/releases.ts +92 -0
@@ -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+}
M lib/repo.ts +3 -1
M lib/sites.ts +9 -2
M lib/types.ts +27 -0
M lib/urls.ts +2 -0
M site/repo/releases.njk +26 -1
@@ -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 &lt;file&gt;</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 %}
A test/scenarios/release-page.sh +40 -0
@@ -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>'