desktop dev update channel: every push to main builds dev bundles (<next patch>-dev.<UTC>), the VDS signs and publishes dev/latest.json (newest 5); app channel setting stable|dev, background updater at start / 15 min / resume / channel switch, deferred while in use, no downgrade, autostart rewritten on every start, card shows version and channel
This commit is contained in:
parent
4dff7d9b2e
commit
39b2f73ad7
25 changed files with 732 additions and 47 deletions
123
tools/desktop-dev.ts
Normal file
123
tools/desktop-dev.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
#!/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> {
|
||||
const r = await fetch(`${DEV_URL}?t=${Date.now()}`, { signal: AbortSignal.timeout(20_000) }).catch(() => null);
|
||||
if (!r || r.status === 404) return null;
|
||||
if (!r.ok) throw new Error(`${DEV_URL}: HTTP ${r.status}`);
|
||||
return ((await r.json()) as { version?: string }).version ?? null;
|
||||
}
|
||||
|
||||
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); }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue