release candidates: every push to main runs the full CI, the acceptance matrix and the Linux / Windows installer builds in parallel (ci.yml) and records the green result per commit as the draft rc-<sha> (green.json last); a version-only bump commit (proven file by file with the version writers) reuses its parent's CI and matrix and rebuilds only the installers; a v* tag only promotes the candidate (release.yml), fails fast on a red or missing commit; acceptance runs carry a CLI key; tools/release.ts does the whole release on the Mac (bump, CHANGELOG cut, fast gate, push, tag, Mac build + notarize in parallel with Forgejo, cached macOS acceptance, add, verify, publish with one upload of the Mac files, /Applications update, npm commands) and --rehearsal; DMG notarization runs while the app and updater bundle are checked; rehearsal channel on the VDS; docs

This commit is contained in:
CRTkafa 2026-10-15 00:42:39 +08:00
parent 89d94d6993
commit c3b1e8c370
14 changed files with 1438 additions and 526 deletions

View file

@ -103,27 +103,34 @@ export function mergeSums(existing: string, add: { file: string; sha256: string
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 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) };
return { remove: releases.filter(r => r.id !== kept.id && !isCandidateTag(r.tag_name)) };
}
/** Tag names of dry runs: only these may be deleted with their tag. */
export const isTestTag = (tag: string): boolean => /^test-[0-9a-f]{7,40}$/.test(tag);
/** 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
const BASE = (process.env.FORGEJO_URL ?? 'https://git.crt.fyi').replace(/\/+$/, '');
const REPO = process.env.FORGEJO_REPOSITORY ?? process.env.GITHUB_REPOSITORY ?? 'CRT/storebound';
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;
function auth(): string {
export function auth(): string {
if (authHeader) return authHeader;
const token = process.env.FORGEJO_TOKEN;
if (token) return (authHeader = `token ${token}`);
@ -135,7 +142,7 @@ function auth(): string {
return (authHeader = `Basic ${Buffer.from(`${user}:${pass}`).toString('base64')}`);
}
async function api<T>(method: string, path: string, body?: unknown, raw = false): Promise<T> {
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, {
@ -152,6 +159,18 @@ async function api<T>(method: string, path: string, body?: unknown, raw = false)
}
}
/** 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++) {
@ -165,7 +184,7 @@ export async function listReleases(): Promise<Release[]> {
/** Cloudflare's request body limit: bigger uploads need the origin. */
export const PROXY_BODY_LIMIT = 100e6;
function origin(): string | null {
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;
@ -173,10 +192,25 @@ function origin(): string | null {
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): Promise<void> {
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`);
@ -199,7 +233,7 @@ async function uploadAsset(releaseId: number, name: string, file: string | null,
const sha256 = (file: string) => createHash('sha256').update(readFileSync(file)).digest('hex');
export interface UpsertOptions { tag: string; title?: string; notes?: string; target?: string; draft?: boolean; create?: boolean; files: string[]; yes: boolean }
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> {
@ -216,13 +250,13 @@ export async function upsert(o: UpsertOptions): Promise<Release | null> {
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: false };
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, name: o.title ?? o.tag });
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);
await uploadAsset(rel!.id, name, file, data, o.remote);
console.log(` uploaded ${name}`);
};
for (const f of files) await upload(basename(f), f, null);
@ -251,13 +285,14 @@ async function prune(keep: string, yes: boolean): Promise<void> {
}
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}`);
}
async function deleteTest(tag: string, yes: boolean): Promise<void> {
if (!isTestTag(tag)) fail(`delete is for dry-run drafts (test-<sha>), not ${tag}`);
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; }