storebound/tools/forgejo-release.ts

324 lines
20 KiB
TypeScript

#!/usr/bin/env bun
// The one Forgejo release of storebound (git.crt.fyi/CRT/storebound/releases). The release workflow
// (.forgejo/workflows/release.yml) makes it on a `v*` tag push; the Mac adds the signed DMG to it afterwards
// (tools/desktop-release.ts upload-forgejo). Only the newest release entry is kept: older entries are deleted, their
// git tags stay. Notes are short: the CHANGELOG section's summary, the install line and links, never the whole section.
//
// bun tools/forgejo-release.ts notes <version> [--tag v<version>]
// Prints the short notes (summary paragraph of the CHANGELOG section, at most 4 lines, then install and links).
// bun tools/forgejo-release.ts upsert --tag <tag> [--title <t>] [--notes-file <f>] [--target <sha>] [--draft]
// [--no-create] <files...> [--yes]
// Creates or updates the release of <tag> (found by tag, drafts included), replaces assets of the same name and
// merges SHA256SUMS with the one already attached (the Mac's files join the runners' files). Safe to run again.
// bun tools/forgejo-release.ts prune --keep <tag> [--yes]
// Deletes every release entry except <tag>'s (published releases and leftover test-* drafts); tags stay.
// bun tools/forgejo-release.ts delete --tag test-<sha> [--yes]
// Deletes a dry-run draft release (and its tag, if one was made). Only test-* tags.
//
// Without --yes every write command prints its plan and changes nothing. Token: FORGEJO_TOKEN (the workflow's
// RELEASE_TOKEN secret, scope write:repository), else the git credential helper's login for the host (on the Mac).
// Never printed. FORGEJO_URL (default https://git.crt.fyi) and FORGEJO_REPOSITORY / GITHUB_REPOSITORY (CRT/storebound).
// Uploads go to the origin server directly when FORGEJO_ORIGIN (an IP; else ~/.storebound-keys/forgejo-origin) is
// set: git.crt.fyi sits behind Cloudflare, which refuses request bodies over 100 MB (502), and the AppImage and the
// DMG are bigger. TLS is still checked against the host name (curl --resolve).
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { basename, join, resolve } from 'node:path';
const ROOT = resolve(import.meta.dir, '..');
export const SUMS = 'SHA256SUMS';
const SITE = 'https://storebound.dev';
// ---------------------------------------------------------------------------------------------------------------------
// Pure helpers (tests/forgejo-release.test.ts)
/** Forgejo's heading anchor: lowercase, every run of other characters becomes one dash ("0.6.0 · 2026-10-12" ->
* "0-6-0-2026-10-12"). */
export const anchor = (heading: string): string => heading.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
/** The CHANGELOG section of a version (`## 0.8.0 · date`), else null. */
export function changelogSection(changelog: string, version: string): { heading: string; body: string } | null {
const lines = changelog.split('\n');
const start = lines.findIndex(l => l.startsWith('## ') && l.slice(3).trim().split(/\s/)[0] === version);
if (start < 0) return null;
let end = lines.findIndex((l, i) => i > start && l.startsWith('## '));
if (end < 0) end = lines.length;
return { heading: lines[start]!.slice(3).trim(), body: lines.slice(start + 1, end).join('\n') };
}
/** The summary of a section: the paragraph before the first bullet, else the first bullet. One line of text. */
export function sectionSummary(body: string): string {
const lines = body.split('\n');
const firstBullet = lines.findIndex(l => /^\s*[-*] /.test(l));
const before = lines.slice(0, firstBullet < 0 ? lines.length : firstBullet).join(' ').replace(/\s+/g, ' ').trim();
if (before) return before;
if (firstBullet < 0) return '';
const bullet: string[] = [lines[firstBullet]!.replace(/^\s*[-*] /, '')];
for (const l of lines.slice(firstBullet + 1)) { if (!/^\s{2,}\S/.test(l) || /^\s*[-*] /.test(l)) break; bullet.push(l.trim()); }
return bullet.join(' ').replace(/\s+/g, ' ').replace(/:$/, '.').trim();
}
/** Cut at a sentence end within `max` characters (else at a word), then wrap to `width`. */
export function shorten(text: string, max = 420, width = 110): string[] {
let t = text;
if (t.length > max) {
const cut = t.slice(0, max);
const dot = Math.max(cut.lastIndexOf('. '), cut.lastIndexOf('.) '));
t = dot > max / 3 ? cut.slice(0, dot + 1) : `${cut.slice(0, cut.lastIndexOf(' '))} ...`;
}
const out: string[] = [];
let line = '';
for (const w of t.split(' ')) {
if (line && line.length + 1 + w.length > width) { out.push(line); line = w; } else line = line ? `${line} ${w}` : w;
}
if (line) out.push(line);
return out;
}
/** The release notes: summary (at most 4 lines), install line, links. */
export function shortNotes(changelog: string, version: string, opts: { tag?: string; repoUrl?: string } = {}): string {
const tag = opts.tag ?? `v${version}`;
const repoUrl = opts.repoUrl ?? 'https://git.crt.fyi/CRT/storebound';
const section = changelogSection(changelog, version) ?? changelogSection(changelog, 'Unreleased');
const summary = section ? shorten(sectionSummary(section.body)).slice(0, 4) : [];
const link = `${repoUrl}/src/tag/${tag}/CHANGELOG.md${section ? `#${anchor(section.heading)}` : ''}`;
return [
...(summary.length ? summary : [`storebound ${version}.`]),
'',
`Install: \`npx storebound@${version} init\` · desktop app: \`npx storebound@${version} desktop\``,
'',
`[Changelog](${link}) · [storebound.dev](${SITE}) · [Downloads](${SITE}/desktop/)`,
].join('\n');
}
/** Merge `sha256sum` lists: the new entries win for the same file name. Sorted, `sha256sum -c` format. */
export function mergeSums(existing: string, add: { file: string; sha256: string }[]): string {
const m = new Map<string, string>();
for (const l of existing.split('\n')) { const x = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(l.trim()); if (x) m.set(x[2]!, x[1]!); }
for (const e of add) m.set(e.file, e.sha256);
return [...m].sort((a, b) => a[0].localeCompare(b[0])).map(([f, h]) => `${h} ${f}`).join('\n') + '\n';
}
export interface Release { id: number; tag_name: string; name: string; draft: boolean; prerelease: boolean; body: string; assets: { id: number; name: string; size: number; browser_download_url: string }[] }
/** Release entries `prune --keep <tag>` deletes: every other entry, the kept one must exist and be published.
* Release candidates (rc-<sha> drafts of ci.yml, tools/release-candidate.ts) are not entries: they have their own
* pruning (`release-candidate.ts prune`), and a newer commit's candidate must survive a release. */
export function pruneList(releases: Release[], keep: string): { remove: Release[]; problem?: string } {
const kept = releases.find(r => r.tag_name === keep);
if (!kept) return { remove: [], problem: `no release for ${keep}: nothing is deleted` };
if (kept.draft) return { remove: [], problem: `the release of ${keep} is a draft: nothing is deleted` };
return { remove: releases.filter(r => r.id !== kept.id && !isCandidateTag(r.tag_name)) };
}
/** Tag names of dry runs and release rehearsals (tools/release.ts --rehearsal): only these may be deleted with
* their tag. */
export const isTestTag = (tag: string): boolean => /^test-[0-9a-f]{7,40}$/.test(tag) || /^v0\.0\.0-rehearsal\.\d+$/.test(tag);
/** The draft that holds the release candidate of a commit (ci.yml's record job). */
export const candidateTag = (sha: string): string => `rc-${sha}`;
export const isCandidateTag = (tag: string): boolean => /^rc-[0-9a-f]{40}$/.test(tag);
// ---------------------------------------------------------------------------------------------------------------------
// API
export const BASE = (process.env.FORGEJO_URL ?? 'https://git.crt.fyi').replace(/\/+$/, '');
export const REPO = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'CRT/storebound';
const API = `${BASE}/api/v1/repos/${REPO}`;
function fail(msg: string): never { console.error(`forgejo-release: ${msg}`); process.exit(1); }
let authHeader: string | null = null;
export function auth(): string {
if (authHeader) return authHeader;
const token = process.env.FORGEJO_TOKEN;
if (token) return (authHeader = `token ${token}`);
const host = new URL(BASE).host;
const r = spawnSync('git', ['credential', 'fill'], { input: `protocol=https\nhost=${host}\n\n`, encoding: 'utf8', env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } });
const get = (k: string) => new RegExp(`^${k}=(.*)$`, 'm').exec(r.stdout ?? '')?.[1];
const user = get('username'); const pass = get('password');
if (r.status !== 0 || !user || !pass) fail(`no FORGEJO_TOKEN and no git credential for ${host}`);
return (authHeader = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`);
}
export async function api<T>(method: string, path: string, body?: unknown, raw = false): Promise<T> {
const url = path.startsWith('http') ? path : `${API}${path}`;
for (let attempt = 1; ; attempt++) {
const res = await fetch(url, {
method,
headers: { Authorization: auth(), ...(body !== undefined && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}) },
body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body),
signal: AbortSignal.timeout(15 * 60_000),
});
// reads and deletes are safe to retry; creates are not (an upsert re-reads instead)
if (res.status >= 500 && attempt < 3 && (method === 'GET' || method === 'DELETE')) { await Bun.sleep(2000 * attempt); continue; }
if (!res.ok) fail(`${method} ${url.replace(BASE, '')}: ${res.status} ${(await res.text()).slice(0, 300)}`);
if (res.status === 204) return undefined as T;
return (raw ? await res.text() : await res.json()) as T;
}
}
/** Download one asset (drafts need the token) to a file; through the origin like uploads when it is set. */
export async function downloadAsset(url: string, dest: string): Promise<void> {
const ip = origin();
const dir = mkdtempSync(join(tmpdir(), 'forgejo-dl-'));
try {
writeFileSync(join(dir, 'h'), `Authorization: ${auth()}\n`, { mode: 0o600 });
const host = new URL(BASE).host;
const r = spawnSync('curl', ['-sS', '-L', '--fail', '--max-time', '1800', ...(ip ? ['--resolve', `${host}:443:${ip.includes(':') ? `[${ip}]` : ip}`] : []), '-H', `@${join(dir, 'h')}`, '-o', dest, url], { encoding: 'utf8' });
if (r.status !== 0) fail(`download ${basename(dest)}: curl exit ${r.status}: ${r.stderr.slice(0, 300)}`);
} finally { rmSync(dir, { recursive: true, force: true }); }
}
export async function listReleases(): Promise<Release[]> {
const all: Release[] = [];
for (let page = 1; page < 50; page++) {
const rs = await api<Release[]>('GET', `/releases?limit=50&page=${page}`);
all.push(...rs);
if (rs.length < 50) break;
}
return all;
}
/** Cloudflare's request body limit: bigger uploads need the origin. */
export const PROXY_BODY_LIMIT = 100e6;
export function origin(): string | null {
const f = join(homedir(), '.storebound-keys', 'forgejo-origin');
const v = (process.env.FORGEJO_ORIGIN ?? (existsSync(f) ? readFileSync(f, 'utf8') : '')).trim();
if (!v) return null;
if (!/^[0-9a-fA-F.:]+$/.test(v)) fail('FORGEJO_ORIGIN: an IP address');
return v;
}
/** A host that already holds the files (the VDS after the publish rsync): its curl uploads them to Forgejo, so the
* owner's slow uplink carries each file once (tools/release.ts). The auth header goes over ssh's stdin into a 0600
* temp file there, removed right after; never into argv or a log. */
export interface RemoteFiles { host: string; dir: string }
/** One asset upload (multipart). With an origin: curl --resolve to it (header from a 0600 file, never argv). */
async function uploadAsset(releaseId: number, name: string, file: string | null, data: string | null, remote?: RemoteFiles): Promise<void> {
const url = `${API}/releases/${releaseId}/assets?name=${encodeURIComponent(name)}`;
const ip = origin();
if (remote && file) {
const host = new URL(BASE).host;
const resolveArg = ip ? `--resolve ${host}:443:${ip.includes(':') ? `[${ip}]` : ip}` : '';
const src = `${remote.dir}/${basename(file)}`;
if (!/^[A-Za-z0-9._\/-]+$/.test(src) || !/^[A-Za-z0-9._-]+$/.test(name)) fail(`remote upload: unexpected characters in ${src}`);
const script = `set -e; h=$(mktemp); trap 'rm -f "$h"' EXIT; chmod 600 "$h"; cat > "$h"; curl -sS --fail-with-body --max-time 1800 ${resolveArg} -H @"$h" -F 'attachment=@${src};filename=${name}' '${url}' >/dev/null`;
const r = spawnSync('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=20', remote.host, script], { input: `Authorization: ${auth()}\n`, encoding: 'utf8', timeout: 1_900_000 });
if (r.status !== 0) fail(`upload ${name} (from ${remote.host}): exit ${r.status}: ${(r.stdout + r.stderr).slice(0, 300)}`);
return;
}
const size = file ? statSync(file).size : Buffer.byteLength(data ?? '');
if (!ip) {
if (size > PROXY_BODY_LIMIT) fail(`${name} is ${(size / 1e6).toFixed(0)} MB: over Cloudflare's 100 MB limit; set FORGEJO_ORIGIN (the server's IP) or ~/.storebound-keys/forgejo-origin`);
const form = new FormData();
form.append('attachment', file ? Bun.file(file) : new Blob([data!]), name);
await api('POST', url, form);
return;
}
const dir = mkdtempSync(join(tmpdir(), 'forgejo-up-'));
try {
writeFileSync(join(dir, 'h'), `Authorization: ${auth()}\n`, { mode: 0o600 });
let src = file;
if (!src) { src = join(dir, name); writeFileSync(src, data!); }
const host = new URL(BASE).host;
const r = spawnSync('curl', ['-sS', '--fail-with-body', '--max-time', '1800', '--resolve', `${host}:443:${ip.includes(':') ? `[${ip}]` : ip}`,
'-H', `@${join(dir, 'h')}`, '-F', `attachment=@${src};filename=${name}`, url], { encoding: 'utf8', maxBuffer: 1 << 24 });
if (r.status !== 0) fail(`upload ${name} (origin): curl exit ${r.status}: ${(r.stdout + r.stderr).slice(0, 300)}`);
} finally { rmSync(dir, { recursive: true, force: true }); }
}
const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
export interface UpsertOptions { tag: string; title?: string; notes?: string; target?: string; draft?: boolean; prerelease?: boolean; create?: boolean; files: string[]; yes: boolean; remote?: RemoteFiles; quiet?: boolean }
/** Create or update the release of a tag and attach files (same name = replaced), SHA256SUMS merged. */
export async function upsert(o: UpsertOptions): Promise<Release | null> {
for (const f of o.files) statSync(f);
const files = o.files.filter(f => basename(f) !== SUMS);
let rel = (await listReleases()).find(r => r.tag_name === o.tag) ?? null;
const sumsBefore = rel?.assets.find(a => a.name === SUMS);
const existingSums = sumsBefore ? await api<string>('GET', sumsBefore.browser_download_url, undefined, true) : '';
const sums = mergeSums(existingSums, files.map(f => ({ file: basename(f), sha256: sha256(f) })));
console.log(`${rel ? `update release ${rel.id}` : 'create release'} ${o.tag}${o.draft ? ' (draft)' : ''}: ${o.title ?? rel?.name ?? o.tag}`);
for (const f of files) console.log(` ${rel?.assets.some(a => a.name === basename(f)) ? 'replace' : 'attach '} ${basename(f)} (${(statSync(f).size / 1e6).toFixed(1)} MB)`);
console.log(` ${sumsBefore ? 'replace' : 'attach '} ${SUMS} (${sums.trim().split('\n').length} files)`);
if (!o.yes) { console.log('dry run: nothing changed (--yes to write)'); return rel; }
const big = files.filter(f => statSync(f).size > PROXY_BODY_LIMIT);
if (big.length && !origin()) fail(`${big.map(f => basename(f)).join(', ')} over 100 MB: Cloudflare refuses them; set FORGEJO_ORIGIN (the server's IP) or ~/.storebound-keys/forgejo-origin`);
if (!rel && o.create === false) fail(`no release for ${o.tag} yet: push the tag first, the release workflow creates it`);
const fields = { name: o.title, body: o.notes, draft: o.draft ?? false, prerelease: o.prerelease };
if (rel) rel = await api<Release>('PATCH', `/releases/${rel.id}`, Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)));
else rel = await api<Release>('POST', '/releases', { tag_name: o.tag, target_commitish: o.target, ...fields, prerelease: o.prerelease ?? false, name: o.title ?? o.tag });
const upload = async (name: string, file: string | null, data: string | null) => {
const old = rel!.assets.find(a => a.name === name);
if (old) await api('DELETE', `/releases/${rel!.id}/assets/${old.id}`);
await uploadAsset(rel!.id, name, file, data, o.remote);
console.log(` uploaded ${name}`);
};
for (const f of files) await upload(basename(f), f, null);
await upload(SUMS, null, sums);
rel = await api<Release>('GET', `/releases/${rel.id}`);
// read back: every file attached with its size
for (const f of files) {
const a = rel.assets.find(x => x.name === basename(f));
if (!a || a.size !== statSync(f).size) fail(`read back: ${basename(f)} is ${a ? `${a.size} bytes, expected ${statSync(f).size}` : 'missing'}`);
}
console.log(`release ${o.tag}: ${rel.assets.length} assets, ${BASE}/${REPO}/releases/tag/${o.tag}`);
return rel;
}
async function prune(keep: string, yes: boolean): Promise<void> {
const { remove, problem } = pruneList(await listReleases(), keep);
if (problem) fail(problem);
if (!remove.length) { console.log(`only ${keep} is left: nothing to delete`); return; }
for (const r of remove) console.log(`delete release entry ${r.tag_name} (${r.name}${r.draft ? ', draft' : ''}); the tag stays${isTestTag(r.tag_name) ? ' (test tag: deleted too)' : ''}`);
if (!yes) { console.log('dry run: nothing deleted (--yes to delete)'); return; }
for (const r of remove) {
await api('DELETE', `/releases/${r.id}`);
if (isTestTag(r.tag_name)) await deleteTag(r.tag_name);
}
console.log(`kept ${keep}; deleted ${remove.length} release entr${remove.length === 1 ? 'y' : 'ies'}`);
}
async function deleteTag(tag: string): Promise<void> {
if (isCandidateTag(tag)) return; // a candidate draft never made a tag
if (!isTestTag(tag)) fail(`refusing to delete tag ${tag}: only test-* tags of dry runs`);
const res = await fetch(`${API}/tags/${encodeURIComponent(tag)}`, { method: 'DELETE', headers: { Authorization: auth() } });
if (res.status !== 204 && res.status !== 404) fail(`DELETE tag ${tag}: ${res.status}`);
}
export async function deleteTest(tag: string, yes: boolean): Promise<void> {
if (!isTestTag(tag) && !isCandidateTag(tag)) fail(`delete is for dry-run drafts (test-<sha>), rehearsals (v0.0.0-rehearsal.<n>) and candidates (rc-<sha>), not ${tag}`);
const rel = (await listReleases()).find(r => r.tag_name === tag);
console.log(rel ? `delete draft release ${tag} (${rel.assets.length} assets) and the tag if any` : `no release ${tag}; delete the tag if any`);
if (!yes) { console.log('dry run: nothing deleted (--yes to delete)'); return; }
if (rel) await api('DELETE', `/releases/${rel.id}`);
await deleteTag(tag);
console.log(`deleted ${tag}`);
}
function args(argv: string[]) {
const flags = new Set(['--yes', '--draft', '--no-create']);
const get = (n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
const rest = argv.filter((a, i) => !a.startsWith('--') && !(i > 0 && argv[i - 1]!.startsWith('--') && !flags.has(argv[i - 1]!)));
return { get, has: (n: string) => argv.includes(`--${n}`), rest };
}
if (import.meta.main) {
const [cmd, ...argv] = process.argv.slice(2);
const a = args(argv);
if (cmd === 'notes') {
const version = a.rest[0] ?? fail('usage: notes <version>');
console.log(shortNotes(readFileSync(join(ROOT, 'CHANGELOG.md'), 'utf8'), version, { tag: a.get('tag') }));
} else if (cmd === 'upsert') {
const tag = a.get('tag') ?? fail('--tag is required');
const nf = a.get('notes-file');
await upsert({ tag, title: a.get('title'), notes: nf ? readFileSync(nf, 'utf8') : undefined, target: a.get('target'), draft: a.has('draft'), create: !a.has('no-create'), files: a.rest, yes: a.has('yes') });
} else if (cmd === 'prune') await prune(a.get('keep') ?? fail('--keep <tag> is required'), a.has('yes'));
else if (cmd === 'delete') await deleteTest(a.get('tag') ?? fail('--tag is required'), a.has('yes'));
else { console.log(readFileSync(import.meta.path, 'utf8').split('\n').filter(l => l.startsWith('//')).map(l => l.slice(3)).join('\n')); process.exit(cmd ? 2 : 0); }
}