#!/usr/bin/env node // Release script — builds the Windows desktop installer, signs it with the // Tauri updater key, and uploads the artifacts + latest.json to the update // host over scp. Reads credentials from .env.release (not committed). // // Usage: // pnpm release 0.10.2 "Ringtone cap auf 8 MB, bugfixes" 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); } const notes = notesParts.join(' ').trim() || `Release ${versionArg}`; 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 = [ 'TAURI_SIGNING_PRIVATE_KEY_PATH', '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); } } if (!existsSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH)) { console.error(`Signing key not found at ${env.TAURI_SIGNING_PRIVATE_KEY_PATH}`); 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); } const pkgJsonPath = join(ROOT, 'apps/desktop/package.json'); const tauriConfPath = join(ROOT, 'apps/desktop/src-tauri/tauri.conf.json'); const cargoTomlPath = join(ROOT, 'apps/desktop/src-tauri/Cargo.toml'); function bumpJson(path, version) { const obj = JSON.parse(readFileSync(path, 'utf8')); obj.version = version; writeFileSync(path, JSON.stringify(obj, null, 2) + '\n', 'utf8'); } function bumpCargo(path, version) { const text = readFileSync(path, 'utf8'); const next = text.replace(/^version = "[^"]+"$/m, `version = "${version}"`); if (next === text) throw new Error(`No version line found in ${path}`); writeFileSync(path, next, 'utf8'); } bumpJson(pkgJsonPath, versionArg); bumpJson(tauriConfPath, versionArg); bumpCargo(cargoTomlPath, versionArg); console.log(`Version -> ${versionArg}. Building NSIS bundle…`); const signingKey = readFileSync(env.TAURI_SIGNING_PRIVATE_KEY_PATH, 'utf8').trim(); const buildEnv = { ...process.env, TAURI_SIGNING_PRIVATE_KEY: signingKey, TAURI_SIGNING_PRIVATE_KEY_PASSWORD: env.TAURI_SIGNING_PRIVATE_KEY_PASSWORD ?? '', }; execSync('pnpm --filter @chat-app/desktop build -- --bundles nsis', { cwd: ROOT, env: buildEnv, stdio: 'inherit', }); const bundleDir = join(ROOT, 'apps/desktop/src-tauri/target/release/bundle/nsis'); const zipName = `ChatApp_${versionArg}_x64-setup.nsis.zip`; const exeName = `ChatApp_${versionArg}_x64-setup.exe`; const sigName = `${zipName}.sig`; const zipPath = join(bundleDir, zipName); const exePath = join(bundleDir, exeName); const sigPath = join(bundleDir, sigName); for (const p of [zipPath, sigPath]) { if (!existsSync(p)) { console.error(`Missing build artifact: ${p}`); process.exit(1); } } const signature = readFileSync(sigPath, 'utf8').trim(); const latest = { version: versionArg, notes, pub_date: new Date().toISOString(), platforms: { 'windows-x86_64': { signature, url: `https://${env.UPDATE_HOST}/windows/${zipName}`, }, }, }; const latestJsonPath = join(bundleDir, 'latest.json'); writeFileSync(latestJsonPath, JSON.stringify(latest, null, 2), 'utf8'); 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', }); } console.log('Uploading artifacts (JSON last so clients never see a stale ref)…'); scp(zipPath); scp(sigPath); // Also ship the bare installer so friends can download it directly via browser // without going through the updater — useful for the very first install. if (existsSync(exePath)) scp(exePath); scp(latestJsonPath); execSync( `git add apps/desktop/package.json apps/desktop/src-tauri/tauri.conf.json apps/desktop/src-tauri/Cargo.toml`, { 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/latest.json`); console.log(` Installer: https://${env.UPDATE_HOST}/windows/${exeName}`); console.log(` Run 'git push && git push --tags' to sync to remote.`);