127 lines
7.3 KiB
TypeScript
127 lines
7.3 KiB
TypeScript
#!/usr/bin/env bun
|
|
// Dev update channel of the desktop app (docs/desktop-release.md, "Dev channel"). Every push to main builds Windows
|
|
// and Linux bundles stamped `<next patch>-dev.<UTC yyyymmddHHMMSS of the commit>` (ci.yml, linux / windows jobs), the
|
|
// `dev` job attaches them to a Forgejo draft `dev-<version>` (no git tag), and the VDS (tools/vds/storebound-desktop-dev,
|
|
// a root timer) signs them with the updater key that lives there and publishes storebound.dev/desktop/dev/latest.json.
|
|
// Installs set to the dev channel (Settings > Updates) follow it; everyone else stays on stable (the npm releases).
|
|
// bun tools/desktop-dev.ts stamp print the dev version of HEAD; refuses one not above the live dev
|
|
// bun tools/desktop-dev.ts set stamp, then write it into every version copy (CI workspace only)
|
|
// bun tools/desktop-dev.ts record --dir <d> [--yes] draft dev-<version> with the files of <d>, prune to the newest 5
|
|
// bun tools/desktop-dev.ts wait <version> [--minutes 15] until storebound.dev serves it on the dev channel
|
|
import { readdirSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { setVersion, versions } from './desktop-version';
|
|
|
|
export const DEV_URL = 'https://storebound.dev/desktop/dev/latest.json';
|
|
export const KEEP = 5;
|
|
export const DEV_TAG = /^dev-(\d+\.\d+\.\d+-dev\.\d{14})$/;
|
|
|
|
/** The next patch of a release version: dev builds of 0.8.47's successor are 0.8.48-dev.*, below 0.8.48. */
|
|
export function nextPatch(v: string): string {
|
|
const m = /^(\d+)\.(\d+)\.(\d+)(?:-.*)?$/.exec(v);
|
|
if (!m) throw new Error(`not a version: ${v}`);
|
|
// a prerelease base (0.8.48-rc.1) already stands below 0.8.48: its dev builds keep that core
|
|
return v.includes('-') ? `${m[1]}.${m[2]}.${m[3]}` : `${m[1]}.${m[2]}.${Number(m[3]) + 1}`;
|
|
}
|
|
|
|
/** UTC yyyymmddHHMMSS. */
|
|
export const utcStamp = (d: Date): string => d.toISOString().replace(/[-:T]/g, '').slice(0, 14);
|
|
|
|
export function devVersion(base: string, at: Date): string {
|
|
return `${nextPatch(base)}-dev.${utcStamp(at)}`;
|
|
}
|
|
|
|
/** semver 2.0 precedence: -1, 0, 1 (numeric identifiers as numbers, a release above its prereleases). */
|
|
export function compare(a: string, b: string): number {
|
|
const p = (v: string) => { const [core, ...pre] = v.split('+')[0]!.split('-'); return { n: core!.split('.').map(Number), pre: pre.length ? pre.join('-').split('.') : [] }; };
|
|
const x = p(a), y = p(b);
|
|
for (let i = 0; i < 3; i++) if (x.n[i] !== y.n[i]) return x.n[i]! > y.n[i]! ? 1 : -1;
|
|
if (!x.pre.length || !y.pre.length) return x.pre.length === y.pre.length ? 0 : x.pre.length ? -1 : 1;
|
|
for (let i = 0; i < Math.min(x.pre.length, y.pre.length); i++) {
|
|
const s = x.pre[i]!, t = y.pre[i]!;
|
|
if (s === t) continue;
|
|
const ns = /^\d+$/.test(s), nt = /^\d+$/.test(t);
|
|
if (ns && nt) return Number(s) > Number(t) ? 1 : -1;
|
|
if (ns !== nt) return ns ? -1 : 1;
|
|
return s > t ? 1 : -1;
|
|
}
|
|
return Math.sign(x.pre.length - y.pre.length);
|
|
}
|
|
|
|
/** The publish guard: a dev version goes out only above the live dev latest.json (null: nothing published yet). */
|
|
export function guardProblem(next: string, live: string | null): string | null {
|
|
if (!/^\d+\.\d+\.\d+-dev\.\d{14}$/.test(next)) return `${next} is not a dev version`;
|
|
if (live && compare(next, live) <= 0) return `${next} is not above the live dev build ${live}: refused (the updater installs only a newer version)`;
|
|
return null;
|
|
}
|
|
|
|
/** Dev drafts to delete: all but the newest `keep` by version (other releases are never touched). */
|
|
export function pruneDev<T extends { tag_name: string }>(releases: T[], keep = KEEP): T[] {
|
|
const dev = releases.filter(r => DEV_TAG.test(r.tag_name)).sort((a, b) => compare(b.tag_name.slice(4), a.tag_name.slice(4)));
|
|
return dev.slice(keep);
|
|
}
|
|
|
|
async function liveDev(): Promise<string | null> {
|
|
// a 5xx from the CDN / origin is often a blip: try a few times before giving up
|
|
for (let i = 0; ; i++) {
|
|
const r = await fetch(`${DEV_URL}?t=${Date.now()}`, { signal: AbortSignal.timeout(20_000) }).catch(() => null);
|
|
if (r?.status === 404) return null;
|
|
if (r?.ok) return ((await r.json()) as { version?: string }).version ?? null;
|
|
if (i >= 4) throw new Error(`${DEV_URL}: ${r ? `HTTP ${r.status}` : 'no answer'} after ${i + 1} tries`);
|
|
await new Promise(res => setTimeout(res, 5_000 * (i + 1)));
|
|
}
|
|
}
|
|
|
|
function commitDate(): Date {
|
|
const r = Bun.spawnSync(['git', 'log', '-1', '--format=%cI'], { stdout: 'pipe' });
|
|
const d = new Date(r.stdout.toString().trim());
|
|
if (r.exitCode !== 0 || Number.isNaN(d.getTime())) throw new Error('git log: no commit date');
|
|
return d;
|
|
}
|
|
|
|
async function stamp(): Promise<string> {
|
|
const { version, drift } = versions();
|
|
if (!version || drift.length) throw new Error('desktop version drift (bun tools/desktop-version.ts)');
|
|
const v = devVersion(version, commitDate());
|
|
const problem = guardProblem(v, await liveDev());
|
|
if (problem) throw new Error(problem);
|
|
return v;
|
|
}
|
|
|
|
const arg = (argv: string[], n: string) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
|
|
|
|
if (import.meta.main) {
|
|
const argv = process.argv.slice(2);
|
|
try {
|
|
if (argv[0] === 'stamp') console.log(await stamp());
|
|
else if (argv[0] === 'set') { const v = await stamp(); setVersion(v); console.log(v); }
|
|
else if (argv[0] === 'record') {
|
|
const { upsert, listReleases, api } = await import('./forgejo-release');
|
|
const dir = arg(argv, 'dir') ?? 'dev';
|
|
const files = readdirSync(dir).filter(f => /\.(exe|AppImage|deb)$/.test(f)).map(f => join(dir, f));
|
|
const vs = [...new Set(files.map(f => /_(\d+\.\d+\.\d+-dev\.\d{14})_/.exec(f)?.[1]))];
|
|
if (vs.length !== 1 || !vs[0]) throw new Error(`${dir}: expected the files of one dev version, got ${vs.join(', ') || 'none'}`);
|
|
const v = vs[0];
|
|
for (const kind of [/_x64-setup\.exe$/, /_amd64\.AppImage$/, /_amd64\.deb$/]) if (!files.some(f => kind.test(f))) throw new Error(`${dir}: no ${kind.source} of ${v}`);
|
|
const problem = guardProblem(v, await liveDev());
|
|
if (problem) throw new Error(problem);
|
|
const yes = argv.includes('--yes');
|
|
await upsert({ tag: `dev-${v}`, title: `dev ${v}`, notes: `Dev build of ${process.env.SHA ?? 'HEAD'} (signed and published by the VDS to ${DEV_URL}).`, draft: true, prerelease: true, files, yes });
|
|
const old = pruneDev(await listReleases());
|
|
for (const r of old) {
|
|
console.log(`${yes ? 'delete' : 'would delete'} draft ${r.tag_name}`);
|
|
if (yes) await api('DELETE', `/releases/${(r as { id: number }).id}`);
|
|
}
|
|
} else if (argv[0] === 'wait') {
|
|
const v = argv[1] ?? ''; const minutes = Number(arg(argv, 'minutes') ?? 15);
|
|
const t0 = Date.now();
|
|
for (;;) {
|
|
const live = await liveDev().catch(() => null);
|
|
if (live && compare(live, v) >= 0) { console.log(`live on the dev channel: ${live}`); break; }
|
|
if (Date.now() - t0 > minutes * 60_000) throw new Error(`${v} not on ${DEV_URL} after ${minutes} min (live: ${live ?? 'none'}); see journalctl -u storebound-desktop-dev on the VDS`);
|
|
console.log(`waiting for the VDS to publish ${v} (live: ${live ?? 'none'}, ${Math.round((Date.now() - t0) / 1000)} s)`);
|
|
await Bun.sleep(20_000);
|
|
}
|
|
} else { console.log('usage: stamp | set | record --dir <d> [--yes] | wait <version> [--minutes n]'); process.exit(2); }
|
|
} catch (e) { console.error(`desktop-dev: ${(e as Error).message}`); process.exit(1); }
|
|
}
|