#!/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 [--tag v] // 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 [--title ] [--notes-file ] [--target ] [--draft] // [--no-create] [--yes] // Creates or updates the release of (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 [--yes] // Deletes every release entry except 's (published releases and leftover test-* drafts); tags stay. // bun tools/forgejo-release.ts delete --tag test- [--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(); 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; uuid?: string; browser_download_url: string }[] } /** Where an asset downloads: drafts (release candidates) only by their attachment uuid; the tag URL is 404 there. */ export const assetUrl = (a: { uuid?: string; browser_download_url: string }): string => (a.uuid ? `${(process.env.FORGEJO_URL ?? "https://git.crt.fyi").replace(/\/+$/, "")}/attachments/${a.uuid}` : a.browser_download_url); /** Release entries `prune --keep ` deletes: every other entry, the kept one must exist and be published. * Release candidates (rc- 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(method: string, path: string, body?: unknown, raw = false): Promise { 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 { 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 { const all: Release[] = []; for (let page = 1; page < 50; page++) { const rs = await api('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 { 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 { 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('GET', assetUrl(sumsBefore), 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('PATCH', `/releases/${rel.id}`, Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined))); else rel = await api('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('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 { 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 { 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 { if (!isTestTag(tag) && !isCandidateTag(tag)) fail(`delete is for dry-run drafts (test-), rehearsals (v0.0.0-rehearsal.) and candidates (rc-), 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 '); 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 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); } }