#!/usr/bin/env node // Publish a release to Gitea and (re)write the updater's latest.json. // // Reads the built artifacts (macOS .app.tar.gz + .sig, Linux .AppImage + .sig, // plus .dmg / .deb for manual install), creates a versioned release with those // assets, then builds latest.json (Tauri v2 updater format) pointing at the // release download URLs and uploads it to a fixed-tag "updater" release so the // app's updater endpoint URL stays constant. // // Env: GITEA_URL (e.g. https://git.kgva.ch), GITEA_REPO (owner/name), // GITEA_TOKEN (or a token file at /tmp/gitea_token). import fs from 'node:fs'; import path from 'node:path'; const ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); const GITEA_URL = (process.env.GITEA_URL || 'https://git.kgva.ch').replace(/\/$/, ''); const REPO = process.env.GITEA_REPO || 'karim/xplane-cockpit'; const TOKEN = process.env.GITEA_TOKEN || (fs.existsSync('/tmp/gitea_token') ? fs.readFileSync('/tmp/gitea_token', 'utf8').trim() : ''); const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'desktop/src-tauri/tauri.conf.json'), 'utf8')).version; const UPDATER_TAG = 'updater'; if (!TOKEN) { console.error('No GITEA_TOKEN (env or /tmp/gitea_token).'); process.exit(1); } const api = (p, opts = {}) => fetch(`${GITEA_URL}/api/v1${p}`, { ...opts, headers: { Authorization: `token ${TOKEN}`, Accept: 'application/json', ...(opts.headers || {}) }, }); // Collect built artifacts that exist (mac and/or linux builds may have run). function findArtifacts() { const macBundle = path.join(ROOT, 'desktop/src-tauri/target/aarch64-apple-darwin/release/bundle'); const linBundle = path.join(ROOT, 'target-linux/x86_64-unknown-linux-gnu/release/bundle'); const out = { assets: [], updater: {} }; const add = (file, platformKey) => { if (!fs.existsSync(file)) return; out.assets.push(file); if (platformKey) { const sig = file + '.sig'; if (fs.existsSync(sig)) out.updater[platformKey] = { file, sig: fs.readFileSync(sig, 'utf8').trim() }; } }; const glob1 = (dir, re) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => re.test(f)).map((f) => path.join(dir, f)) : []; // macOS glob1(path.join(macBundle, 'macos'), /\.app\.tar\.gz$/).forEach((f) => add(f, 'darwin-aarch64')); glob1(path.join(macBundle, 'dmg'), /\.dmg$/).forEach((f) => out.assets.push(f)); // Linux glob1(path.join(linBundle, 'appimage'), /\.AppImage$/).forEach((f) => add(f, 'linux-x86_64')); glob1(path.join(linBundle, 'deb'), /\.deb$/).forEach((f) => out.assets.push(f)); return out; } async function getReleaseByTag(tag) { const r = await api(`/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`); return r.ok ? r.json() : null; } async function ensureRelease(tag, name, body) { let rel = await getReleaseByTag(tag); if (rel) return rel; const r = await api(`/repos/${REPO}/releases`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tag_name: tag, name, body, draft: false, prerelease: false }), }); if (!r.ok) throw new Error(`create release ${tag}: ${r.status} ${await r.text()}`); return r.json(); } async function uploadAsset(rel, file, asName) { const name = asName || path.basename(file); // delete an existing asset with the same name first (Gitea won't overwrite) for (const a of rel.assets || []) { if (a.name === name) await api(`/repos/${REPO}/releases/${rel.id}/assets/${a.id}`, { method: 'DELETE' }); } const blob = new Blob([fs.readFileSync(file)]); const fd = new FormData(); fd.append('attachment', blob, name); const r = await api(`/repos/${REPO}/releases/${rel.id}/assets?name=${encodeURIComponent(name)}`, { method: 'POST', body: fd }); if (!r.ok) throw new Error(`upload ${name}: ${r.status} ${await r.text()}`); console.log(' ↑', name); return r.json(); } const dlUrl = (tag, name) => `${GITEA_URL}/${REPO}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(name)}`; async function main() { const { assets, updater } = findArtifacts(); if (!assets.length) { console.error('No build artifacts found — run the builds first.'); process.exit(1); } console.log(`Release v${VERSION} → ${GITEA_URL}/${REPO}`); console.log('Artifacts:', assets.map((a) => path.basename(a)).join(', ')); const verTag = `v${VERSION}`; const rel = await ensureRelease(verTag, `X-Plane Cockpit ${verTag}`, `Automated release ${verTag}.`); for (const f of assets) await uploadAsset(rel, f); const relFresh = await getReleaseByTag(verTag); // refresh asset list // Build latest.json referencing this release's updater artifacts. const platforms = {}; for (const [key, { file, sig }] of Object.entries(updater)) { platforms[key] = { signature: sig, url: dlUrl(verTag, path.basename(file)) }; } const latest = { version: VERSION, notes: `X-Plane Cockpit ${verTag}`, pub_date: new Date().toISOString(), platforms, }; const latestPath = path.join(ROOT, 'desktop/latest.json'); fs.writeFileSync(latestPath, JSON.stringify(latest, null, 2)); console.log('latest.json platforms:', Object.keys(platforms).join(', ') || '(none)'); // Upload latest.json to the fixed "updater" release (constant endpoint URL). const upd = await ensureRelease(UPDATER_TAG, 'Updater channel', 'Rolling pointer used by the in-app updater.'); await uploadAsset(upd, latestPath, 'latest.json'); console.log('Updater endpoint:', dlUrl(UPDATER_TAG, 'latest.json')); } main().catch((e) => { console.error(e); process.exit(1); });