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:
parent
89d94d6993
commit
c3b1e8c370
14 changed files with 1438 additions and 526 deletions
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue