Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)).

Expand Down
8 changes: 5 additions & 3 deletions resources/installer.nsh
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions resources/schema-upgrade-notice.nsh
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
; Generated by scripts/write-schema-upgrade-notice.mjs — no schema bump.
; Empty on purpose: installer.nsh includes this file unconditionally.
96 changes: 91 additions & 5 deletions scripts/write-schema-upgrade-notice.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -58,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]
Expand All @@ -68,10 +156,8 @@ 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');
writeNoBumpNoticeFiles(txtPath, nshPath, resourcesDir);
console.debug('[write-schema-upgrade-notice] No schema bump; NSIS stub written, txt cleared');
return { bumped: false, txtPath, nshPath };
}

Expand Down
84 changes: 81 additions & 3 deletions scripts/write-schema-upgrade-notice.test.mjs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
formatNsisSchemaUpgradeInclude,
formatSchemaUpgradeNoticeText,
NSIS_SCHEMA_UPGRADE_STUB,
writeSchemaUpgradeNoticeFiles,
} from './write-schema-upgrade-notice.mjs';

Expand All @@ -13,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 });
}
Expand All @@ -33,7 +36,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);

Expand All @@ -51,6 +54,81 @@ 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('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(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'resources',
'schema-upgrade-notice.nsh',
),
'utf8',
);
expect(committed).toBe(NSIS_SCHEMA_UPGRADE_STUB);
});
});