950ef5b706
electron-builder doesn't write CLI-supplied --notes into the auto-update manifest, so clients saw an empty body in UpdateToast even when the release script logged notes. After the build but before scp, patch latest.yml in place: append a block scalar (`releaseNotes: |-`) so multi-line notes survive intact. Idempotent — skips if a releaseNotes entry is already present (reruns / hand-edited manifests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
198 lines
7.0 KiB
JavaScript
198 lines
7.0 KiB
JavaScript
#!/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 <x.y.z> "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 <x.y.z> "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);
|
|
}
|
|
}
|
|
|
|
// --- Inject releaseNotes into latest.yml ----------------------------------
|
|
//
|
|
// electron-builder doesn't write the CLI-supplied notes into the
|
|
// manifest by default — clients then see an empty body in the
|
|
// UpdateToast. Patch the YAML in place: append a block scalar
|
|
// (`releaseNotes: |-`) so multi-line content survives intact.
|
|
// Idempotent — skip if a `releaseNotes:` entry is already present
|
|
// (covers reruns / hand-edited manifests).
|
|
{
|
|
let yml = readFileSync(latestYmlPath, 'utf8');
|
|
if (!/^releaseNotes:/m.test(yml)) {
|
|
const indented = notes
|
|
.split('\n')
|
|
.map((l) => ' ' + l)
|
|
.join('\n');
|
|
yml = yml.replace(/\s*$/, '') + `\nreleaseNotes: |-\n${indented}\n`;
|
|
writeFileSync(latestYmlPath, yml, 'utf8');
|
|
console.log('Injected releaseNotes into latest.yml');
|
|
} else {
|
|
console.log('latest.yml already has releaseNotes — skipping injection');
|
|
}
|
|
}
|
|
|
|
// --- 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.`);
|