#!/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 `-dev.` (ci.yml, linux / windows jobs), the // `dev` job attaches them to a Forgejo draft `dev-` (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 [--yes] draft dev- with the files of , prune to the newest 5 // bun tools/desktop-dev.ts wait [--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(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 { // 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 { 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 [--yes] | wait [--minutes n]'); process.exit(2); } } catch (e) { console.error(`desktop-dev: ${(e as Error).message}`); process.exit(1); } }