From bef3aec24226e97b135a642868b5cc747a065313 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 11 Aug 2026 12:52:17 -0600 Subject: [PATCH 1/2] fix(ci): keep NSIS schema-upgrade stub so Windows packaging succeeds electron-builder treats NSIS warning 7000 (missing !include) as an error. Write a no-op schema-upgrade-notice.nsh when schema is not bumped instead of deleting it. --- .gitignore | 3 +-- docs/ci-cd.md | 2 +- resources/installer.nsh | 8 ++++--- resources/schema-upgrade-notice.nsh | 2 ++ scripts/write-schema-upgrade-notice.mjs | 17 ++++++++++---- scripts/write-schema-upgrade-notice.test.mjs | 24 ++++++++++++++++++-- 6 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 resources/schema-upgrade-notice.nsh diff --git a/.gitignore b/.gitignore index 9c9ee8040..02412b4b3 100644 --- a/.gitignore +++ b/.gitignore @@ -39,9 +39,8 @@ reticulum-sidecar/target/ resources/reticulum-sidecar/staged/ resources/reticulum-sidecar/mesh-client-reticulum resources/reticulum-sidecar/mesh-client-reticulum.exe -# Generated by scripts/write-schema-upgrade-notice.mjs in CI packaging +# Generated by scripts/write-schema-upgrade-notice.mjs in CI packaging (nsh stub is committed) resources/SCHEMA-UPGRADE.txt -resources/schema-upgrade-notice.nsh READ-ME-FIRST-test-build.md READ-ME-FIRST-schema.md READ-ME-FIRST-flatpak.md diff --git a/docs/ci-cd.md b/docs/ci-cd.md index ea6ff68da..ac644bc93 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -348,7 +348,7 @@ CI focuses on lint, typecheck, build, Flatpak metadata validation, and coverage 3. Uploads `READ-ME-FIRST-test-build.md` (build) / `READ-ME-FIRST-flatpak.md` (flatpak) / `READ-ME-FIRST-schema.md` (release). **Build Binaries** stages the note into `release/` before platform uploads so `upload-artifact`’s least-common-ancestor stays under `release/` (mixing `release-warnings/` nests installers as `release/release/*.exe` and breaks `packaging-smoke`). Flatpak keeps a separate per-arch `flatpak-schema-warning-*` artifact beside the bundle 4. Exposes `schema_bumped` / `curr_schema` / `prev_schema` / `prev_tag` for packaging -When schema is bumped, packaging runs `scripts/write-schema-upgrade-notice.mjs` so Windows NSIS can show a MessageBox and macOS/Linux/Flatpak bundles can include `SCHEMA-UPGRADE.txt` in app resources (`electron-builder-before-pack.mjs` / Flatpak `resources/` copy). +Packaging always runs `scripts/write-schema-upgrade-notice.mjs`. On a schema bump it writes the Windows NSIS MessageBox include and `SCHEMA-UPGRADE.txt` for macOS/Linux/Flatpak (`electron-builder-before-pack.mjs` / Flatpak `resources/` copy). With no bump it still writes a no-op `resources/schema-upgrade-notice.nsh` stub — NSIS `!include` of a missing file is warning 7000, and electron-builder treats warnings as errors. On first launch after a schema bump against an existing database, the app shows a blocking **Quit / Upgrade** dialog before mutating SQLite (see [Release Process — Database schema upgrades](release-process.md#database-schema-upgrades)). diff --git a/resources/installer.nsh b/resources/installer.nsh index d04faa28d..d7554b6e4 100644 --- a/resources/installer.nsh +++ b/resources/installer.nsh @@ -1,9 +1,11 @@ ; electron-builder NSIS include — post-extract guard for silent partial installs (WoA). ; customFinish is not invoked by electron-builder; customInstall runs after files land. -; schema-upgrade-notice.nsh is generated by scripts/write-schema-upgrade-notice.mjs when -; this build bumps CURRENT_SCHEMA_VERSION vs the last published release. +; schema-upgrade-notice.nsh is always present: a no-op stub in git, overwritten by +; scripts/write-schema-upgrade-notice.mjs when this build bumps CURRENT_SCHEMA_VERSION. +; Keep the include even when the stub is empty — a missing file is NSIS warning 7000 +; and electron-builder treats warnings as errors. -!include /NONFATAL "${BUILD_RESOURCES_DIR}\schema-upgrade-notice.nsh" +!include "${BUILD_RESOURCES_DIR}\schema-upgrade-notice.nsh" !macro customInstall IfFileExists "$INSTDIR\Mesh-client.exe" finish_ok 0 diff --git a/resources/schema-upgrade-notice.nsh b/resources/schema-upgrade-notice.nsh new file mode 100644 index 000000000..d6412d3e5 --- /dev/null +++ b/resources/schema-upgrade-notice.nsh @@ -0,0 +1,2 @@ +; Generated by scripts/write-schema-upgrade-notice.mjs — no schema bump. +; Empty on purpose: installer.nsh includes this file unconditionally. diff --git a/scripts/write-schema-upgrade-notice.mjs b/scripts/write-schema-upgrade-notice.mjs index 59c5ef17d..1e130e256 100644 --- a/scripts/write-schema-upgrade-notice.mjs +++ b/scripts/write-schema-upgrade-notice.mjs @@ -1,7 +1,9 @@ #!/usr/bin/env node /** * Write installer / package notice files when MESH_CLIENT_SCHEMA_BUMPED=1. - * No-op when schema is not bumped (removes stale notice files if present). + * When schema is not bumped: remove SCHEMA-UPGRADE.txt and write a no-op NSIS stub + * (installer.nsh includes the .nsh unconditionally; a missing file is NSIS warning 7000, + * which electron-builder treats as an error). */ import fs from 'node:fs'; import path from 'node:path'; @@ -13,6 +15,11 @@ const RESOURCES = path.join(ROOT, 'resources'); const TXT_NAME = 'SCHEMA-UPGRADE.txt'; const NSH_NAME = 'schema-upgrade-notice.nsh'; +/** Stub so NSIS `!include` always finds a file. Missing includes are warning 7000; electron-builder treats warnings as errors. */ +export const NSIS_SCHEMA_UPGRADE_STUB = + '; Generated by scripts/write-schema-upgrade-notice.mjs — no schema bump.\n' + + '; Empty on purpose: installer.nsh includes this file unconditionally.\n'; + /** * @param {{ * bumped: boolean @@ -68,10 +75,10 @@ export function writeSchemaUpgradeNoticeFiles(env = process.env, resourcesDir = const nshPath = path.join(resourcesDir, NSH_NAME); if (!bumped) { - for (const p of [txtPath, nshPath]) { - if (fs.existsSync(p)) fs.unlinkSync(p); - } - console.debug('[write-schema-upgrade-notice] No schema bump; notice files cleared'); + if (fs.existsSync(txtPath)) fs.unlinkSync(txtPath); + fs.mkdirSync(resourcesDir, { recursive: true }); + fs.writeFileSync(nshPath, NSIS_SCHEMA_UPGRADE_STUB, 'utf8'); + console.debug('[write-schema-upgrade-notice] No schema bump; NSIS stub written, txt cleared'); return { bumped: false, txtPath, nshPath }; } diff --git a/scripts/write-schema-upgrade-notice.test.mjs b/scripts/write-schema-upgrade-notice.test.mjs index 2636a97c3..e939bf586 100644 --- a/scripts/write-schema-upgrade-notice.test.mjs +++ b/scripts/write-schema-upgrade-notice.test.mjs @@ -1,10 +1,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it } from 'vitest'; import { formatNsisSchemaUpgradeInclude, formatSchemaUpgradeNoticeText, + NSIS_SCHEMA_UPGRADE_STUB, writeSchemaUpgradeNoticeFiles, } from './write-schema-upgrade-notice.mjs'; @@ -33,7 +35,7 @@ describe('write-schema-upgrade-notice', () => { expect(nsh).toContain('$\\r$\\n'); }); - it('writes notice files when bumped and clears them when not', () => { + it('writes notice files when bumped and a no-op NSIS stub when not', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'schema-notice-')); temps.push(dir); @@ -51,6 +53,24 @@ describe('write-schema-upgrade-notice', () => { writeSchemaUpgradeNoticeFiles({ MESH_CLIENT_SCHEMA_BUMPED: '0' }, dir); expect(fs.existsSync(path.join(dir, 'SCHEMA-UPGRADE.txt'))).toBe(false); - expect(fs.existsSync(path.join(dir, 'schema-upgrade-notice.nsh'))).toBe(false); + expect(fs.readFileSync(path.join(dir, 'schema-upgrade-notice.nsh'), 'utf8')).toBe( + NSIS_SCHEMA_UPGRADE_STUB, + ); + expect(fs.readFileSync(path.join(dir, 'schema-upgrade-notice.nsh'), 'utf8')).not.toContain( + 'MESH_CLIENT_SCHEMA_UPGRADE_NOTICE', + ); + }); + + it('keeps a committed NSIS stub that matches the generator', () => { + const committed = fs.readFileSync( + path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + 'resources', + 'schema-upgrade-notice.nsh', + ), + 'utf8', + ); + expect(committed).toBe(NSIS_SCHEMA_UPGRADE_STUB); }); }); From cf74d9d5bb597225f7ea93f3e34cadf996a4863c Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Tue, 11 Aug 2026 13:03:22 -0600 Subject: [PATCH 2/2] fix(ci): atomically commit NSIS schema stub before dropping upgrade txt Stage the no-bump stub, rename it into place, then remove SCHEMA-UPGRADE.txt. On any filesystem failure, restore the prior pair and log the operation plus paths so a retry stays consistent. --- scripts/write-schema-upgrade-notice.mjs | 85 +++++++++++++++++++- scripts/write-schema-upgrade-notice.test.mjs | 60 +++++++++++++- 2 files changed, 141 insertions(+), 4 deletions(-) diff --git a/scripts/write-schema-upgrade-notice.mjs b/scripts/write-schema-upgrade-notice.mjs index 1e130e256..2ab80aae2 100644 --- a/scripts/write-schema-upgrade-notice.mjs +++ b/scripts/write-schema-upgrade-notice.mjs @@ -65,6 +65,87 @@ export function formatNsisSchemaUpgradeInclude(message) { ); } +/** + * @param {string} filePath + * @returns {string | null} + */ +function readIfExists(filePath) { + if (!fs.existsSync(filePath)) return null; + return fs.readFileSync(filePath, 'utf8'); +} + +/** + * @param {string} filePath + * @param {string | null} snapshot + */ +function restoreFile(filePath, snapshot) { + if (snapshot === null) { + if (fs.existsSync(filePath)) fs.unlinkSync(filePath); + return; + } + fs.writeFileSync(filePath, snapshot, 'utf8'); +} + +/** + * @param {string} op + * @param {{ nshPath: string, txtPath: string, stagedNsh?: string }} paths + * @param {unknown} error + */ +function logNoticeFsFailure(op, paths, error) { + const detail = error instanceof Error ? error.message : String(error); + const staged = paths.stagedNsh ? ` staged=${paths.stagedNsh}` : ''; + console.error( + `[write-schema-upgrade-notice] ${op} failed; rolling back nsh=${paths.nshPath} txt=${paths.txtPath}${staged}: ${detail}`, + ); +} + +/** + * No-bump: commit the NSIS stub first, then drop SCHEMA-UPGRADE.txt. + * Failure point: any mkdir/write/rename/unlink. Fallback: restore prior nsh+txt + * (and drop the staged temp) so a retry starts from a consistent pair. + * + * @param {string} txtPath + * @param {string} nshPath + * @param {string} resourcesDir + */ +function writeNoBumpNoticeFiles(txtPath, nshPath, resourcesDir) { + fs.mkdirSync(resourcesDir, { recursive: true }); + const priorNsh = readIfExists(nshPath); + const priorTxt = readIfExists(txtPath); + const stagedNsh = `${nshPath}.${process.pid}.${Date.now()}.tmp`; + const paths = { nshPath, txtPath, stagedNsh }; + let op = 'stage NSIS stub'; + try { + fs.writeFileSync(stagedNsh, NSIS_SCHEMA_UPGRADE_STUB, 'utf8'); + op = 'commit NSIS stub'; + // OS-specific: Windows rename cannot replace an existing file. + if (process.platform === 'win32' && fs.existsSync(nshPath)) { + fs.unlinkSync(nshPath); + } + fs.renameSync(stagedNsh, nshPath); + op = 'remove SCHEMA-UPGRADE.txt'; + if (fs.existsSync(txtPath)) fs.unlinkSync(txtPath); + } catch (error) { + logNoticeFsFailure(op, paths, error); + try { + if (fs.existsSync(stagedNsh)) fs.unlinkSync(stagedNsh); + } catch { + // catch-no-log-ok best-effort staged file cleanup + } + try { + restoreFile(nshPath, priorNsh); + } catch (restoreErr) { + logNoticeFsFailure('rollback NSIS stub', paths, restoreErr); + } + try { + restoreFile(txtPath, priorTxt); + } catch (restoreErr) { + logNoticeFsFailure('rollback SCHEMA-UPGRADE.txt', paths, restoreErr); + } + throw error; + } +} + /** * @param {NodeJS.ProcessEnv} [env] * @param {string} [resourcesDir] @@ -75,9 +156,7 @@ export function writeSchemaUpgradeNoticeFiles(env = process.env, resourcesDir = const nshPath = path.join(resourcesDir, NSH_NAME); if (!bumped) { - if (fs.existsSync(txtPath)) fs.unlinkSync(txtPath); - fs.mkdirSync(resourcesDir, { recursive: true }); - fs.writeFileSync(nshPath, NSIS_SCHEMA_UPGRADE_STUB, 'utf8'); + writeNoBumpNoticeFiles(txtPath, nshPath, resourcesDir); console.debug('[write-schema-upgrade-notice] No schema bump; NSIS stub written, txt cleared'); return { bumped: false, txtPath, nshPath }; } diff --git a/scripts/write-schema-upgrade-notice.test.mjs b/scripts/write-schema-upgrade-notice.test.mjs index e939bf586..1718bb00b 100644 --- a/scripts/write-schema-upgrade-notice.test.mjs +++ b/scripts/write-schema-upgrade-notice.test.mjs @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { formatNsisSchemaUpgradeInclude, formatSchemaUpgradeNoticeText, @@ -15,6 +15,7 @@ describe('write-schema-upgrade-notice', () => { const temps = []; afterEach(() => { + vi.restoreAllMocks(); for (const dir of temps.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -61,6 +62,63 @@ describe('write-schema-upgrade-notice', () => { ); }); + it('restores both notice files when NSIS commit fails', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'schema-notice-')); + temps.push(dir); + const nshPath = path.join(dir, 'schema-upgrade-notice.nsh'); + const txtPath = path.join(dir, 'SCHEMA-UPGRADE.txt'); + fs.writeFileSync(nshPath, 'PRIOR_NSH', 'utf8'); + fs.writeFileSync(txtPath, 'PRIOR_TXT', 'utf8'); + + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const origRename = fs.renameSync.bind(fs); + vi.spyOn(fs, 'renameSync').mockImplementation((from, to) => { + if (path.resolve(String(to)) === path.resolve(nshPath)) { + throw new Error('simulated rename failure'); + } + return origRename(from, to); + }); + + expect(() => writeSchemaUpgradeNoticeFiles({ MESH_CLIENT_SCHEMA_BUMPED: '0' }, dir)).toThrow( + 'simulated rename failure', + ); + expect(fs.readFileSync(nshPath, 'utf8')).toBe('PRIOR_NSH'); + expect(fs.readFileSync(txtPath, 'utf8')).toBe('PRIOR_TXT'); + expect(fs.readdirSync(dir).filter((name) => name.includes('.tmp'))).toEqual([]); + const logText = logged.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + expect(logText).toContain('commit NSIS stub failed'); + expect(logText).toContain(nshPath); + expect(logText).toContain(txtPath); + }); + + it('restores both notice files when SCHEMA-UPGRADE.txt removal fails', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'schema-notice-')); + temps.push(dir); + const nshPath = path.join(dir, 'schema-upgrade-notice.nsh'); + const txtPath = path.join(dir, 'SCHEMA-UPGRADE.txt'); + fs.writeFileSync(nshPath, 'PRIOR_NSH', 'utf8'); + fs.writeFileSync(txtPath, 'PRIOR_TXT', 'utf8'); + + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}); + const origUnlink = fs.unlinkSync.bind(fs); + vi.spyOn(fs, 'unlinkSync').mockImplementation((p) => { + if (path.resolve(String(p)) === path.resolve(txtPath)) { + throw new Error('simulated unlink failure'); + } + return origUnlink(p); + }); + + expect(() => writeSchemaUpgradeNoticeFiles({ MESH_CLIENT_SCHEMA_BUMPED: '0' }, dir)).toThrow( + 'simulated unlink failure', + ); + expect(fs.readFileSync(nshPath, 'utf8')).toBe('PRIOR_NSH'); + expect(fs.readFileSync(txtPath, 'utf8')).toBe('PRIOR_TXT'); + const logText = logged.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); + expect(logText).toContain('remove SCHEMA-UPGRADE.txt failed'); + expect(logText).toContain(nshPath); + expect(logText).toContain(txtPath); + }); + it('keeps a committed NSIS stub that matches the generator', () => { const committed = fs.readFileSync( path.join(