#!/usr/bin/env node // Electron release script — bumps the app version, builds the Windows // installer via electron-builder, and uploads the produced artifacts // (latest.yml + .exe + .blockmap) to the update host over scp. Reads // credentials from .env.release (not committed). electron-updater on // installed clients then sees the new latest.yml on the next launch // poll and triggers the update toast. // // We deliberately push to the same host the Tauri build pushed to // (`update.netralax.cloud:/var/www/updates/windows/`) but use a // different manifest filename (`latest.yml` for electron-updater vs // `latest.json` for Tauri), so the legacy Tauri manifest can stay in // place untouched and any users still on Tauri don't get pushed a // release they can't verify. // // Usage: // pnpm release "Multi-line release notes…" import { execSync } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); const [, , versionArg, ...notesParts] = process.argv; if (!versionArg || !/^\d+\.\d+\.\d+$/.test(versionArg)) { console.error('Usage: pnpm release "release notes"'); process.exit(1); } // Bash + cmd.exe pass `\n` from CLI args as the two literal chars `\` `n`, // not as a newline. Normalize so multi-line release notes render correctly // in the in-app updater toast (which uses whitespace-pre-line) and in the // Changelog page. const notes = (notesParts.join(' ').trim() || `Release ${versionArg}`) .replace(/\\r\\n|\\n/g, '\n'); const envPath = join(ROOT, '.env.release'); if (!existsSync(envPath)) { console.error('.env.release missing. Copy .env.release.example and fill it in.'); process.exit(1); } const env = Object.fromEntries( readFileSync(envPath, 'utf8') .split('\n') .map((l) => l.trim()) .filter((l) => l && !l.startsWith('#')) .map((l) => { const i = l.indexOf('='); return [l.slice(0, i).trim(), l.slice(i + 1).trim()]; }), ); const required = ['UPDATE_HOST', 'UPDATE_SSH_USER', 'UPDATE_REMOTE_PATH']; for (const key of required) { if (!env[key]) { console.error(`Missing ${key} in .env.release`); process.exit(1); } } const gitStatus = execSync('git status --porcelain', { cwd: ROOT, encoding: 'utf8' }); if (gitStatus.trim()) { console.error('Working tree not clean. Commit or stash first.'); console.error(gitStatus); process.exit(1); } // --- Bump version -------------------------------------------------------- const pkgJsonPath = join(ROOT, 'apps/desktop/package.json'); function bumpJson(path, version) { const obj = JSON.parse(readFileSync(path, 'utf8')); obj.version = version; writeFileSync(path, JSON.stringify(obj, null, 2) + '\n', 'utf8'); } bumpJson(pkgJsonPath, versionArg); console.log(`Version -> ${versionArg}. Building NSIS bundle via electron-builder…`); // --- Build ---------------------------------------------------------------- execSync('pnpm --filter @chat-app/desktop run build:win', { cwd: ROOT, stdio: 'inherit', }); // --- Locate artifacts ----------------------------------------------------- const releaseDir = join(ROOT, 'apps/desktop/release'); const exeName = `ChatApp Setup ${versionArg}.exe`; const blockmapName = `${exeName}.blockmap`; const latestYml = 'latest.yml'; const exePath = join(releaseDir, exeName); const blockmapPath = join(releaseDir, blockmapName); const latestYmlPath = join(releaseDir, latestYml); for (const p of [exePath, blockmapPath, latestYmlPath]) { if (!existsSync(p)) { console.error(`Missing build artifact: ${p}`); process.exit(1); } } // --- scp helpers ---------------------------------------------------------- const sshTarget = `${env.UPDATE_SSH_USER}@${env.UPDATE_HOST}`; const keyFlag = env.UPDATE_SSH_KEY ? ` -i "${env.UPDATE_SSH_KEY}"` : ''; function scp(localPath) { execSync(`scp${keyFlag} "${localPath}" ${sshTarget}:${env.UPDATE_REMOTE_PATH}/`, { cwd: ROOT, stdio: 'inherit', }); } // --- Upload — manifest LAST so clients never see a stale ref -------------- console.log('Uploading artifacts (manifest last)…'); scp(exePath); scp(blockmapPath); scp(latestYmlPath); // --- Changelog feed ------------------------------------------------------- // // The in-app changelog page fetches `${UPDATE_HOST}/windows/changelog.json` // and renders the list. We fetch the existing list, prepend the new entry // (keyed/deduped by version), cap at 200 to keep the file size bounded, // then upload the merged list back. const changelogUrl = `https://${env.UPDATE_HOST}/windows/changelog.json`; let history = []; try { const res = await fetch(changelogUrl, { cache: 'no-store' }); if (res.ok) { const parsed = await res.json(); if (Array.isArray(parsed)) history = parsed; } else if (res.status !== 404) { console.warn(`changelog.json fetch returned ${res.status} — starting from empty list`); } } catch (err) { console.warn( `changelog.json fetch failed (${err.message ?? err}); starting from empty list`, ); } const newEntry = { version: versionArg, pub_date: new Date().toISOString(), notes, }; const updatedHistory = [ newEntry, ...history.filter((e) => e?.version !== versionArg), ].slice(0, 200); const changelogPath = join(releaseDir, 'changelog.json'); writeFileSync(changelogPath, JSON.stringify(updatedHistory, null, 2), 'utf8'); scp(changelogPath); // --- Commit + tag --------------------------------------------------------- execSync(`git add apps/desktop/package.json`, { cwd: ROOT, stdio: 'inherit' }); execSync(`git commit -m "chore(desktop): release v${versionArg}"`, { cwd: ROOT, stdio: 'inherit', }); execSync(`git tag v${versionArg}`, { cwd: ROOT, stdio: 'inherit' }); console.log(`\nReleased v${versionArg}`); console.log(` Manifest: https://${env.UPDATE_HOST}/windows/${latestYml}`); console.log(` Installer: https://${env.UPDATE_HOST}/windows/${encodeURIComponent(exeName)}`); console.log(` Changelog: https://${env.UPDATE_HOST}/windows/changelog.json`); console.log(` Run 'git push && git push --tags' to sync the tag to remote.`);