From 69017b5870505e13d4bc03c3aaaafebdff9353f4 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:57:26 -0400 Subject: [PATCH 01/10] Write down how an RC installs beside the live release Gear Lever holds one entry per app identity, and a candidate presents the same one as the release: same productName, same desktopName. Only one can be integrated at a time. Records the build-time overrides that give a candidate its own identity, the finding that userData comes from package.json name rather than productName (so shared settings are the default, not something to arrange), and the first-launch backup that makes sharing them safe. Also records dropping the Windows executable from what a release publishes, including the copy of it inside SpinUI-Manual.zip and the quality gate assertions that currently require it. Co-Authored-By: Claude Opus 5 (1M context) --- ...identity-and-windows-exe-removal-design.md | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-14-rc-identity-and-windows-exe-removal-design.md diff --git a/docs/superpowers/specs/2026-08-14-rc-identity-and-windows-exe-removal-design.md b/docs/superpowers/specs/2026-08-14-rc-identity-and-windows-exe-removal-design.md new file mode 100644 index 0000000..919915d --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-rc-identity-and-windows-exe-removal-design.md @@ -0,0 +1,209 @@ +# RC app identity, and dropping the Windows exe from releases + +Date: 2026-08-14 + +Two independent changes to what a release publishes. They share three files +(`.github/workflows/build-loremaster.yml`, `tools/release_quality_gate.py`, +`CHANGELOG.md`), so they are specified together and shipped as two pull +requests that can be reverted separately. + +## Part 1 — a release candidate installs alongside the live release + +### Problem + +Gear Lever refuses to hold both the RC and the live AppImage, because both +present the same identity: `productName: "Loremaster"` gives them the same +`Name=` and the root-level `desktopName: "loremaster.desktop"` gives them the +same desktop filename. Only one can be integrated at a time. + +### What decides "this is an RC" + +The version's semver prerelease component. The publish step already enforces +that a candidate carries one (`^v?\d+\.\d+\.\d+-`) and that a full release does +not, so this needs no new workflow input and cannot disagree with how the +release is actually published. + +The Linux build step already derives `version` from `RELEASE_TAG`. When that +version contains a prerelease component, it layers extra `-c.` overrides onto +the `electron-builder` call. + +### The overrides + +| Override | Live | RC | +| --- | --- | --- | +| `extraMetadata.version` | tag | tag (unchanged behaviour) | +| `productName` | `Loremaster` | `Loremaster RC` | +| `extraMetadata.desktopName` | `loremaster.desktop` | `loremaster-rc.desktop` | +| `linux.executableName` | `loremaster` | `loremaster-rc` | +| `appId` | `com.spinui.loremaster` | `com.spinui.loremaster.rc` | +| `linux.artifactName` | `Loremaster-${version}-${arch}.${ext}` | `Loremaster-RC-${version}-${arch}.${ext}` | + +`package.json` is not edited. A local `pnpm dist:linux` and every full release +keep producing exactly what they produce today; the RC identity exists only as +build-time overrides on a candidate build. + +### Why `desktopName` must be overridden, not just `executableName` + +`app-builder-lib`'s `LinuxTargetHelper.getDesktopFileName()` (26.15.7) reads +`metadata.desktopName` and only falls back to `executableName` when it is +empty. The same value becomes `StartupWMClass`, which Electron uses as its +app_id for window association. Overriding `executableName` alone would leave +the entry named `loremaster.desktop` and the collision unfixed. + +`desktopName` lives at the top level of `package.json`, so it is reached +through `extraMetadata`, not through a `-c.linux.*` key. + +Verified against +`node_modules/.pnpm/app-builder-lib@26.15.7_*/node_modules/app-builder-lib/out/targets/LinuxTargetHelper.js` +lines 184-200 and 226-250. + +### Shared settings are the default, and must stay that way + +Electron derives `userData` from `package.json` `name`, which is +`spins-loremaster` — confirmed on disk at `~/.config/spins-loremaster`, +holding `desktop-settings.json`. `productName` lives under `build` and never +reaches the packaged metadata, so renaming the product does **not** move the +data directory. + +The RC therefore shares the live settings directory with no runtime code at +all. No `app.setPath("userData", ...)` call is needed, and none should be +added. + +**Constraint:** the RC overrides must never set `extraMetadata.name` or +`extraMetadata.productName`. Either one would move `userData` and silently +split the config, which is the opposite of the intent. This is the one way +this design can be broken by a later well-meaning edit. + +### First-launch backup + +Shared settings mean an RC can damage live config. Before anything can write, +an RC build snapshots the state files. + +- **Trigger:** `app.getVersion()` has a prerelease component. A dev run + (`0.3.4`) and a live release both fail this, so the path is inert outside a + candidate build. On Windows it is harmless and equally applicable. +- **Destination:** `/spins-loremaster-rc-backups//` — a + sibling of the live directory, so the RC never writes inside the thing it is + protecting, and deleting a corrupted config does not take the backups with + it. +- **Once per release:** the destination directory's existence is the marker. + Present means skip. The snapshot therefore captures the config as it stood + before that candidate first ran, and a later launch cannot overwrite it with + already-damaged state. +- **Contents:** `desktop-settings.json`, `update-center.json`, + `spinui-update-receipts.json`, `eq-legends-tools-gear-cache.json`. Files that + do not exist are skipped rather than raising. Regenerable data + (`item-intelligence/`, `updates/`, Electron's own caches) is deliberately + excluded — it is large, slow to copy, and rebuilt automatically. +- **Synchronous**, because the payload is a few kilobytes and it removes any + chance of the app racing its own backup. +- **Never blocks launch:** wrapped so a failure is logged and startup + continues. A backup that cannot be written must not stop the app. +- **Retention:** keep every snapshot. A few kilobytes per candidate does not + justify pruning logic. Recovery is a manual copy back. + +### Structure and tests + +The copy logic goes in its own module with injectable paths, mirroring how +`portable-updater.ts` is structured, with `scripts/test-rc-backup.cjs` in the +style of the existing `test:updates` and `test:skin-updates` scripts. It runs +against `dist-electron/` like its siblings and is added to the desktop +verification step. + +Cases: skips when the snapshot directory already exists; tolerates missing +source files; survives an unwritable destination without throwing; copies only +the four named files. + +### Release notes + +The Installing section names `Loremaster-$version-x86_64.AppImage`, which is +wrong for a candidate. That line becomes conditional so a prerelease names +`Loremaster-RC-$version-x86_64.AppImage`. + +### Shell detail + +`linux.artifactName` contains `${version}` and `${arch}`, which are +electron-builder templates, not shell variables. The override must be single +quoted in the workflow so bash does not expand them to empty strings. + +## Part 2 — the Windows exe stops being published + +### Intent + +The Windows executable should not be downloadable from this repository. The +skins are staying: `spinui-updater.ts` fetches `SpinUI-UI.zip` and +`SpinUI-Update.json` from this repo's latest release, and that updater ships in +the Linux build, so removing them would break a working feature for Linux +users. + +### The exe leaks from four places + +1. Published as the standalone asset `dist-electron-release/Loremaster.exe`. +2. **Copied into `SpinUI-Manual.zip`** (`Copy-Item -Force + dist-electron-release/Loremaster.exe $manualPackage`). Removing only the + standalone asset would still ship the exe inside the manual bundle. +3. Hashed into `SHA256SUMS.txt`. +4. Named in the release notes' Installing section. + +All four go. + +`SHA256SUMS.txt` itself stays — it still covers `SpinUI-Manual.zip`, +`SpinUI-UI.zip` and `SpinUI-Update.json`, which continue to be published. + +### CI keeps building it + +`build-loremaster` still compiles and tests the Windows executable on every +qualifying run, so a shared-code change that breaks the Windows build still +fails CI. Only the publishing steps change. `package-windows-release` no longer +needs to download the `Loremaster-Windows` artifact to assemble the manual +bundle. + +### The quality gate inverts + +`tools/release_quality_gate.py` currently *requires* the exe, and will fail +this change until it is updated: + +- `COMMON_PACKAGE_TOP_LEVEL` asserts `Loremaster.exe` is in the manual package. + The same check also fails on unexpected top-level entries, so the set and the + package must move together. +- The workflow self-audit requires the literals + `dist-electron-release/Loremaster.exe` and `Copy-Item -Force + dist-electron-release/Loremaster.exe $manualPackage`. + +Those assertions move from `required` to `retired`, next to `LoremasterNext.exe` +and `dist/Loremaster.exe`. The gate then fails if the exe ever returns to the +release path, which is what makes this survive the next upstream sync instead +of being quietly undone by it. + +### Documentation + +`README.md` (6 references), `installer/INSTALL-MANUAL.md` (which ships inside +the manual bundle, so it must not instruct people to run a file that is no +longer there), and `docs/RELEASING.md`. + +### Known fallout, accepted + +`portable-updater.ts` looks for a `Loremaster.exe` asset on the latest release +and will throw when it finds none, so anyone already running the Windows build +sees an update error rather than a clean "no longer published" message. +Accepted deliberately: nobody should be running that build from this repo. +Not scoped into this work. + +## Sequencing + +Two pull requests, each independently revertable: + +1. RC identity and first-launch backup. +2. Windows exe removal. + +Both need a `CHANGELOG.md` entry under `0.4.0`, since `tools/release_notes.py` +reads that entry to build the release notes and resolves a candidate to the +entry for the release it is promoted to. + +## Out of scope + +- Any change to the skin assets or the skin updater. +- A graceful end-of-life message in the Windows portable updater. +- Repointing or disabling the Linux portable updater (a separate open question + already tracked). +- Pruning old RC backups. From 5116e57ccc34d9d137d1f3e12fe4dc3ed9230d98 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:37:56 -0400 Subject: [PATCH 02/10] Plan the candidate identity and the exe removal task by task Two pull requests: the candidate identity and its first-launch backup, then dropping the Windows executable from what a release publishes. Records the parts that have to move together -- the workflow literals the quality gate asserts on cannot be edited in a separate commit from the gate, or the tree is red in between -- and the shell quoting the artifactName override needs, which bash would otherwise expand to nothing. Co-Authored-By: Claude Opus 5 (1M context) --- ...-14-rc-identity-and-windows-exe-removal.md | 895 ++++++++++++++++++ 1 file changed, 895 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-14-rc-identity-and-windows-exe-removal.md diff --git a/docs/superpowers/plans/2026-08-14-rc-identity-and-windows-exe-removal.md b/docs/superpowers/plans/2026-08-14-rc-identity-and-windows-exe-removal.md new file mode 100644 index 0000000..566ebe3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-rc-identity-and-windows-exe-removal.md @@ -0,0 +1,895 @@ +# RC App Identity and Windows Exe Removal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a release candidate install beside the live release in Gear Lever, and stop publishing the Windows executable from this repository. + +**Architecture:** A candidate is identified by the semver prerelease component already enforced at publish time. When present, the Linux build step layers `-c.` overrides onto `electron-builder` to give the candidate its own desktop identity; `package.json` is never edited, so live builds are untouched. A candidate shares the live settings directory (that is the default, not something arranged) and snapshots the state files on first launch. Separately, the Windows executable is removed from every published path while still being built and tested in CI. + +**Tech Stack:** GitHub Actions (bash + PowerShell steps), electron-builder 26.15.7, Electron 43, TypeScript (Node16 modules, strict), Node `node:test`-free assertion scripts using `node:assert/strict`, Python 3.12 for the release quality gate. + +## Global Constraints + +- **Never override `extraMetadata.name` or `extraMetadata.productName`.** `userData` derives from `package.json` `name` (`spins-loremaster`, live at `~/.config/spins-loremaster`). Setting either would move the candidate's data directory and silently split the config, defeating the shared-settings intent. +- `package.json` is not edited for RC identity. All candidate differences are build-time `-c.` overrides. +- `-c.linux.artifactName` must be **single quoted** in bash: `${version}`, `${arch}` and `${ext}` are electron-builder templates, and bash would expand them to empty strings. +- A candidate carries a semver prerelease component; a full release does not. The publish step already enforces this. Detection regex, matching the workflow's own charset: `^[0-9]+\.[0-9]+\.[0-9]+-[0-9A-Za-z.-]+$`. +- The RC backup must never block startup. Any failure is logged and swallowed. +- The skin assets (`SpinUI-UI.zip`, `SpinUI-Update.json`, `SpinUI-Manual.zip`) and `SHA256SUMS.txt` keep being published. Only the Windows executable goes. +- `tools/release_quality_gate.py` audits the workflow's own text. Workflow edits and gate edits that concern the same literal **must land in the same commit**, or the tree is red between commits. +- Verification command for the whole repo: `python3 tools/release_quality_gate.py` (~23s, currently ALL PASS). + +--- + +## Phase 1 — PR 1: RC app identity and first-launch backup + +Branch: `feat/rc-app-identity` (already exists, carries the spec commit). + +### Task 1: RC backup module + +**Files:** +- Create: `loremaster-desktop/electron/rc-backup.ts` +- Create: `loremaster-desktop/scripts/test-rc-backup.cjs` +- Modify: `loremaster-desktop/package.json` (scripts block) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `isReleaseCandidateVersion(version: string): boolean`, `backupBeforeReleaseCandidate(request: RcBackupRequest): RcBackupResult`, and the constant `RC_BACKUP_FILES: readonly string[]`. `RcBackupRequest` is `{ version: string; userDataDir: string; backupRoot: string }`. `RcBackupResult` is a discriminated union on `status`: `{ status: "skipped"; reason: "not-a-candidate" | "already-backed-up" }`, `{ status: "created"; directory: string; files: string[] }`, `{ status: "failed"; error: string }`. Task 2 consumes `backupBeforeReleaseCandidate` only. + +- [ ] **Step 1: Add the test script entry** + +In `loremaster-desktop/package.json`, add to `"scripts"` immediately after `"test:skin-updates"`: + +```json + "test:rc-backup": "node scripts/test-rc-backup.cjs" +``` + +- [ ] **Step 2: Write the failing test** + +Create `loremaster-desktop/scripts/test-rc-backup.cjs`: + +```js +const assert = require("node:assert/strict"); +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs"); +const { mkdtemp, rm } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { + RC_BACKUP_FILES, + backupBeforeReleaseCandidate, + isReleaseCandidateVersion, +} = require("../dist-electron/rc-backup.js"); + +function seedUserData(root) { + mkdirSync(root, { recursive: true }); + writeFileSync(path.join(root, "desktop-settings.json"), '{"live":true}'); + writeFileSync(path.join(root, "update-center.json"), '{"seen":1}'); + // spinui-update-receipts.json and eq-legends-tools-gear-cache.json are + // deliberately absent: a fresh install has neither, and that must not raise. +} + +async function withTempDir(run) { + const dir = await mkdtemp(path.join(os.tmpdir(), "rc-backup-")); + try { + await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function testVersionDetection() { + assert.equal(isReleaseCandidateVersion("0.4.0-rc.1"), true); + assert.equal(isReleaseCandidateVersion("0.4.0"), false); + assert.equal(isReleaseCandidateVersion("0.3.4"), false); + // Build metadata is not a prerelease component. + assert.equal(isReleaseCandidateVersion("0.4.0+build.5"), false); + // A version that could escape the backup root is not a candidate. + assert.equal(isReleaseCandidateVersion("0.4.0-../../etc"), false); + assert.equal(isReleaseCandidateVersion(""), false); + console.log(" version detection: PASS"); +} + +async function testSkipsFullRelease() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + const result = backupBeforeReleaseCandidate({ + version: "0.4.0", + userDataDir, + backupRoot, + }); + assert.deepEqual(result, { status: "skipped", reason: "not-a-candidate" }); + assert.equal(existsSync(backupRoot), false, "a full release must not create a backup root"); + }); + console.log(" skips a full release: PASS"); +} + +async function testCopiesOnlyExistingStateFiles() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + // Regenerable data must not be copied. + mkdirSync(path.join(userDataDir, "item-intelligence"), { recursive: true }); + writeFileSync(path.join(userDataDir, "item-intelligence", "cache.bin"), "x"); + + const result = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + + assert.equal(result.status, "created"); + assert.equal(result.directory, path.join(backupRoot, "0.4.0-rc.1")); + assert.deepEqual(result.files, ["desktop-settings.json", "update-center.json"]); + assert.equal( + readFileSync(path.join(result.directory, "desktop-settings.json"), "utf8"), + '{"live":true}', + ); + assert.equal(existsSync(path.join(result.directory, "item-intelligence")), false); + assert.equal(existsSync(path.join(result.directory, "spinui-update-receipts.json")), false); + }); + console.log(" copies only existing state files: PASS"); +} + +async function testSnapshotIsTakenOnce() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + + const first = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + assert.equal(first.status, "created"); + + // The candidate has now damaged the live config. + writeFileSync(path.join(userDataDir, "desktop-settings.json"), '{"corrupt":true}'); + + const second = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + assert.deepEqual(second, { status: "skipped", reason: "already-backed-up" }); + assert.equal( + readFileSync(path.join(first.directory, "desktop-settings.json"), "utf8"), + '{"live":true}', + "the pre-candidate snapshot must survive a later launch", + ); + }); + console.log(" snapshot is taken once per version: PASS"); +} + +async function testEachCandidateGetsItsOwnSnapshot() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + + const first = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + const second = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.2", + userDataDir, + backupRoot, + }); + + assert.equal(first.status, "created"); + assert.equal(second.status, "created"); + assert.notEqual(first.directory, second.directory); + assert.equal(existsSync(path.join(backupRoot, "0.4.0-rc.1")), true); + assert.equal(existsSync(path.join(backupRoot, "0.4.0-rc.2")), true); + }); + console.log(" each candidate gets its own snapshot: PASS"); +} + +async function testFailureNeverThrows() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + seedUserData(userDataDir); + // A file where the backup root should be: mkdir cannot succeed here. + const backupRoot = path.join(dir, "blocked"); + writeFileSync(backupRoot, "not a directory"); + + const result = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + assert.equal(result.status, "failed"); + assert.equal(typeof result.error, "string"); + assert.ok(result.error.length > 0); + }); + console.log(" an unwritable destination never throws: PASS"); +} + +async function testBackupFileListIsTheStateFiles() { + assert.deepEqual([...RC_BACKUP_FILES], [ + "desktop-settings.json", + "update-center.json", + "spinui-update-receipts.json", + "eq-legends-tools-gear-cache.json", + ]); + console.log(" backup file list: PASS"); +} + +async function main() { + console.log("rc backup:"); + await testVersionDetection(); + await testBackupFileListIsTheStateFiles(); + await testSkipsFullRelease(); + await testCopiesOnlyExistingStateFiles(); + await testSnapshotIsTakenOnce(); + await testEachCandidateGetsItsOwnSnapshot(); + await testFailureNeverThrows(); + console.log("rc backup: ALL PASS"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); +``` + +- [ ] **Step 3: Run the test to verify it fails** + +```bash +cd loremaster-desktop && pnpm build && pnpm test:rc-backup +``` + +Expected: FAIL — `Cannot find module '../dist-electron/rc-backup.js'`. + +- [ ] **Step 4: Write the implementation** + +Create `loremaster-desktop/electron/rc-backup.ts`: + +```ts +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; + +// The files holding state a candidate can damage. Regenerable data +// (item-intelligence/, updates/, Electron's own caches) is deliberately +// absent: it is large, slow to copy, and rebuilt on demand. +export const RC_BACKUP_FILES = [ + "desktop-settings.json", + "update-center.json", + "spinui-update-receipts.json", + "eq-legends-tools-gear-cache.json", +] as const; + +export interface RcBackupRequest { + version: string; + userDataDir: string; + backupRoot: string; +} + +export type RcBackupResult = + | { status: "skipped"; reason: "not-a-candidate" | "already-backed-up" } + | { status: "created"; directory: string; files: string[] } + | { status: "failed"; error: string }; + +// Mirrors the charset the publish step enforces on a release tag, so a +// candidate is recognised here exactly when it was published as one. Requiring +// the full string to match also keeps the version usable as a directory name: +// nothing with a separator in it can reach the filesystem. +const RELEASE_CANDIDATE_VERSION = /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/; + +export function isReleaseCandidateVersion(version: string): boolean { + return RELEASE_CANDIDATE_VERSION.test(version.trim()); +} + +// A candidate shares the live settings directory on purpose, so it snapshots +// the state files before it can write to them. The snapshot lives beside that +// directory rather than inside it, so the candidate never writes into the +// thing it is protecting and deleting a ruined config keeps the backups. +export function backupBeforeReleaseCandidate(request: RcBackupRequest): RcBackupResult { + const version = request.version.trim(); + if (!isReleaseCandidateVersion(version)) { + return { status: "skipped", reason: "not-a-candidate" }; + } + + const directory = path.join(request.backupRoot, version); + try { + // The directory's presence is the marker, so a later launch cannot + // overwrite the pre-candidate snapshot with already-damaged state. + if (existsSync(directory)) { + return { status: "skipped", reason: "already-backed-up" }; + } + mkdirSync(directory, { recursive: true }); + + const files: string[] = []; + for (const name of RC_BACKUP_FILES) { + const source = path.join(request.userDataDir, name); + // A fresh install has none of these, which is not a failure. + if (!existsSync(source)) { + continue; + } + copyFileSync(source, path.join(directory, name)); + files.push(name); + } + return { status: "created", directory, files }; + } catch (error) { + return { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +cd loremaster-desktop && pnpm build && pnpm test:rc-backup +``` + +Expected: PASS, ending `rc backup: ALL PASS`. + +- [ ] **Step 6: Commit** + +```bash +git add loremaster-desktop/electron/rc-backup.ts loremaster-desktop/scripts/test-rc-backup.cjs loremaster-desktop/package.json +git commit -m "Snapshot the settings a release candidate is about to share" +``` + +### Task 2: Wire the backup into startup + +**Files:** +- Modify: `loremaster-desktop/electron/main.ts` (imports at line 1-31; new block after the ozone relaunch guard that ends at line 77) + +**Interfaces:** +- Consumes: `backupBeforeReleaseCandidate` from Task 1. +- Produces: nothing consumed by later tasks. + +- [ ] **Step 1: Add the import** + +In `loremaster-desktop/electron/main.ts`, add after the `./portable-updater` import block (which ends at line 23) and before the `./spinui-updater` import block, keeping the existing alphabetical-by-module grouping: + +```ts +import { backupBeforeReleaseCandidate } from "./rc-backup"; +``` + +- [ ] **Step 2: Add the startup block** + +Insert immediately after the closing `}` of the ozone relaunch guard (line 77), before the `// Which backend is actually running` comment: + +```ts +// A candidate shares the live settings directory on purpose -- bugs surface +// against real data that way -- so it snapshots the state files before any +// code can write to them. Runs before app.whenReady on purpose: both paths +// resolve this early, and nothing has opened a settings file yet. +{ + const backup = backupBeforeReleaseCandidate({ + version: app.getVersion(), + userDataDir: app.getPath("userData"), + backupRoot: path.join(app.getPath("appData"), "spins-loremaster-rc-backups"), + }); + if (backup.status === "created") { + console.info( + `[Loremaster] release candidate: backed up ${backup.files.length}` + + ` settings file(s) to ${backup.directory}`); + } else if (backup.status === "failed") { + // Logged, never fatal: a backup that cannot be written must not stop the + // application from starting. + console.warn(`[Loremaster] release candidate backup failed: ${backup.error}`); + } +} +``` + +- [ ] **Step 3: Verify it compiles and the suite still passes** + +```bash +cd loremaster-desktop && pnpm build && pnpm test:rc-backup && pnpm test:fixtures && pnpm test:gear +``` + +Expected: PASS for all four. `pnpm build` runs `tsc -p tsconfig.electron.json && tsc --noEmit && vite build`, so a type error in the new block fails here. + +- [ ] **Step 4: Verify a normal run is unaffected** + +```bash +cd loremaster-desktop && node -e "console.log(require('./dist-electron/rc-backup.js').isReleaseCandidateVersion(require('./package.json').version))" +``` + +Expected: `false` — the checked-in version is not a candidate, so a dev run and a live release never touch the backup path. + +- [ ] **Step 5: Commit** + +```bash +git add loremaster-desktop/electron/main.ts +git commit -m "Take the candidate snapshot before anything can write settings" +``` + +### Task 3: Give a candidate its own desktop identity in CI + +**Files:** +- Modify: `.github/workflows/build-loremaster.yml:377-380` (Linux desktop verification step), `:396-405` (Linux electron-builder invocation), `:560-570` (release notes Installing section) + +**Interfaces:** +- Consumes: `test:rc-backup` script from Task 1. +- Produces: RC AppImage named `Loremaster-RC--x86_64.AppImage`, consumed by the release notes text in this same task. + +- [ ] **Step 1: Add the new test to both desktop verification steps** + +In the Windows job's step (line 241), append after `pnpm test:skin-updates`: + +```yaml + pnpm test:rc-backup +``` + +In the Linux job's step (line 380), append after `pnpm test:gear`: + +```yaml + pnpm test:rc-backup +``` + +- [ ] **Step 2: Replace the Linux electron-builder invocation** + +Replace the `run:` body of the "Build Linux Electron artifacts" step (lines 396-405) with: + +```yaml + run: | + set -euo pipefail + version="${RELEASE_TAG#v}" + if ! printf '%s' "$version" \ + | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then + version="$(node -p "require('./package.json').version")" + fi + # A candidate has to install beside the live release rather than + # replace it. Gear Lever keys on the desktop entry, so productName + # (Name=) and desktopName (the .desktop filename, and StartupWMClass) + # both have to differ -- app-builder-lib reads desktopName first and + # only falls back to executableName, so overriding executableName + # alone leaves the entry named loremaster.desktop and the collision + # unfixed. desktopName sits at the top level of package.json, which + # is why it goes through extraMetadata rather than -c.linux. + # + # name is deliberately never overridden: userData comes from it, and + # a candidate shares the live settings on purpose so bugs surface + # against real data. + identity=() + if printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-'; then + identity=( + "-c.productName=Loremaster RC" + "-c.appId=com.spinui.loremaster.rc" + "-c.extraMetadata.desktopName=loremaster-rc.desktop" + "-c.linux.executableName=loremaster-rc" + # Single quoted: these are electron-builder templates, and bash + # would expand them to empty strings. + '-c.linux.artifactName=Loremaster-RC-${version}-${arch}.${ext}' + ) + echo "building release candidate identity: Loremaster RC ($version)" + fi + pnpm build + pnpm exec electron-builder --linux AppImage tar.gz --x64 \ + --publish never "-c.extraMetadata.version=$version" "${identity[@]}" +``` + +- [ ] **Step 3: Make the release notes name the file that exists** + +In the "Publish workflow-dispatch release" step, immediately after the line `$version = $tag -replace '^v', ''` (line 552), add: + +```powershell + # A candidate ships under its own name so it can sit beside the live + # release, so the notes have to point at that file, not the other one. + $appImage = if ($prerelease) { + "Loremaster-RC-$version-x86_64.AppImage" + } else { + "Loremaster-$version-x86_64.AppImage" + } +``` + +Then replace the Linux line in the `$notes` array (line 564): + +```powershell + "**Linux** -- download ``$appImage``, ``chmod +x`` it, and run it. There is no self-update on Linux, so new builds always come from this page.", +``` + +- [ ] **Step 4: Verify the workflow still parses and the gate passes** + +```bash +python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/build-loremaster.yml')); print('yaml ok')" +python3 tools/release_quality_gate.py +``` + +Expected: `yaml ok`, then `RELEASE QUALITY GATE: ALL PASS`. The gate's required literal `-c.extraMetadata.version=$version` is preserved verbatim by Step 2, so this must stay green. + +- [ ] **Step 5: Verify the override list is shell-correct** + +Nested quoting makes this unreliable to paste inline, so write it to a file first: + +```bash +cat > /tmp/rc-identity-check.sh <<'SCRIPT' +set -euo pipefail +version="$1" +identity=() +if printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-'; then + identity=( + "-c.productName=Loremaster RC" + "-c.appId=com.spinui.loremaster.rc" + "-c.extraMetadata.desktopName=loremaster-rc.desktop" + "-c.linux.executableName=loremaster-rc" + '-c.linux.artifactName=Loremaster-RC-${version}-${arch}.${ext}' + ) +fi +printf '%s\n' "${identity[@]}" +echo "exit-ok" +SCRIPT +bash /tmp/rc-identity-check.sh 0.4.0-rc.1 +echo "--- full release ---" +bash /tmp/rc-identity-check.sh 0.4.0 +``` + +Expected: for `0.4.0-rc.1`, five override lines then `exit-ok`, with the last override reading exactly `-c.linux.artifactName=Loremaster-RC-${version}-${arch}.${ext}` — braces intact and unexpanded. For `0.4.0`, only `exit-ok`, which proves the empty-array expansion is safe under `set -u`. + +Copy the array block into the workflow from this verified file, so the quoting that passed the test is the quoting that ships. + +- [ ] **Step 6: Commit** + +```bash +git add .github/workflows/build-loremaster.yml +git commit -m "Build a release candidate under its own name" +``` + +### Task 4: Changelog entry for the candidate identity + +**Files:** +- Modify: `CHANGELOG.md` (under the `## 0.4.0` heading) + +**Interfaces:** +- Consumes: nothing. +- Produces: the entry `tools/release_notes.py` reads when publishing `0.4.0` or any `0.4.0-rc.N`. + +- [ ] **Step 1: Add the entry** + +Add to the fork section under `## 0.4.0`, matching the surrounding bold-lead-in style: + +```markdown +- **Release candidates install beside the release** — a candidate now builds as + "Loremaster RC" with its own desktop entry, so a tool like Gear Lever can hold + it and the live release at the same time instead of treating them as one app. + It shares the live settings on purpose, so bugs show up against real data, and + it copies those settings aside once per candidate before it can touch them. +``` + +- [ ] **Step 2: Verify the extractor still resolves both forms** + +```bash +python3 tools/release_notes.py --version v0.4.0 | head -5 +python3 tools/release_notes.py --version v0.4.0-rc.1 | head -5 +python3 tools/release_notes.py --self-test +``` + +Expected: the first two print the same `0.4.0` entry; the self-test prints `ALL PASS`. + +- [ ] **Step 3: Commit** + +```bash +git add CHANGELOG.md +git commit -m "Say that candidates now install beside the release" +``` + +### Task 5: Phase 1 verification and pull request + +- [ ] **Step 1: Full gate** + +```bash +python3 tools/release_quality_gate.py +``` + +Expected: `RELEASE QUALITY GATE: ALL PASS`. + +- [ ] **Step 2: Full desktop suite** + +```bash +cd loremaster-desktop && pnpm install --frozen-lockfile && pnpm test:fixtures && pnpm build && pnpm test:gear && pnpm test:items && pnpm test:updates && pnpm test:skin-updates && pnpm test:rc-backup +``` + +Expected: every script passes. + +- [ ] **Step 3: Open the pull request** + +```bash +git push -u origin feat/rc-app-identity +gh pr create --title "Let a release candidate install beside the release" --body "$(cat <<'EOF' +Gear Lever holds one entry per app identity, and a candidate presented the same one as the live release: same `productName`, same `desktopName`. Only one could be integrated at a time. + +A candidate now builds as **Loremaster RC** with its own desktop entry, application id, executable name and AppImage filename. These are build-time overrides only — `package.json` is untouched, so local builds and full releases are unchanged. + +`userData` comes from `package.json` `name`, not `productName`, so a candidate shares the live settings directory by default. That is deliberate: bugs surface against real data. To make it safe, an RC snapshots the four settings files to `spins-loremaster-rc-backups//` on first launch, once per candidate, and never blocks startup if that fails. + +Note for future edits: overriding `extraMetadata.name` or `extraMetadata.productName` would move `userData` and silently split the config. The plan and spec both record this. + +Spec: `docs/superpowers/specs/2026-08-14-rc-identity-and-windows-exe-removal-design.md` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## Phase 2 — PR 2: the Windows executable stops being published + +Branch off `main` **after PR 1 merges**: `git checkout main && git pull && git checkout -b fix/stop-publishing-windows-exe`. + +### Task 6: Remove the executable from every published path + +The workflow edits and the gate edits **must be one commit**. The gate requires two literals that this task deletes, so splitting them leaves the tree red in between. + +**Files:** +- Modify: `.github/workflows/build-loremaster.yml:456-460` (artifact download), `:462-473` (manual assembly), `:486-500` (checksums), `:511-519` (tools upload), `:541-547` (publish asset list), `:566-568` (release notes Windows paragraph) +- Modify: `tools/release_quality_gate.py:218-227` (`COMMON_PACKAGE_TOP_LEVEL`), `:330-355` (`required`), `:357-362` (`retired`) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: releases carrying only `SpinUI-Manual.zip`, `SpinUI-UI.zip`, `SpinUI-Update.json`, `SHA256SUMS.txt` and the Linux assets. + +- [ ] **Step 1: Confirm the current gate assertions fail-first** + +Delete just the manual-package copy line (workflow line 473, `Copy-Item -Force dist-electron-release/Loremaster.exe $manualPackage`) and run: + +```bash +python3 tools/release_quality_gate.py 2>&1 | grep -A2 "Electron release pipeline" +``` + +Expected: FAIL — `Electron release workflow is incomplete: Copy-Item -Force dist-electron-release/Loremaster.exe $manualPackage`. This proves the gate genuinely guards this path before you invert it. + +- [ ] **Step 2: Remove the artifact download step** + +Delete this step entirely (lines 456-460) — `package-windows-release` no longer needs the executable: + +```yaml + - name: Download verified Loremaster component + uses: actions/download-artifact@v6 + with: + name: Loremaster-Windows + path: package/loremaster-component +``` + +The `Loremaster-Windows` artifact is still produced by the `build-loremaster` job's upload step, which is what keeps the gate's `"Loremaster-Windows"` required literal satisfied and keeps the Windows build verified in CI. + +- [ ] **Step 3: Stop embedding the executable in the manual bundle** + +Step 1 already deleted the last of these. In the "Assemble staged manual release" step, delete the two that remain: + +```powershell + New-Item -ItemType Directory -Force -Path dist-electron-release | Out-Null + Copy-Item -Force package/loremaster-component/Loremaster.exe dist-electron-release/Loremaster.exe +``` + +The step now ends at the `Expand-Archive` line. Confirm with: + +```bash +grep -n "Loremaster.exe" .github/workflows/build-loremaster.yml +``` + +Expected at this point: only the `$assets` entry, the checksum `foreach` entry, the tools-upload path, and the release-notes Windows paragraph — all removed in Steps 4-6. + +- [ ] **Step 4: Stop hashing the executable** + +In the "Publish SHA-256 checksums" step, remove `'dist-electron-release/Loremaster.exe'` from the `foreach` list, leaving: + +```powershell + $lines = foreach ($file in @( + 'package/SpinUI-Manual.zip', + 'package/SpinUI-UI.zip', + 'package/SpinUI-Update.json')) { +``` + +- [ ] **Step 5: Stop uploading and publishing it** + +In "Upload complete Windows tools and checksums", delete the line ` dist-electron-release/Loremaster.exe` from `path:`. + +In "Publish workflow-dispatch release", remove the last entry from `$assets` so it reads: + +```powershell + $assets = @( + 'package/SpinUI-Manual.zip', + 'package/SpinUI-UI.zip', + 'package/SpinUI-Update.json', + 'package/SHA256SUMS.txt' + ) +``` + +- [ ] **Step 6: Remove the Windows paragraph from the release notes** + +Delete these two lines from the `$notes` array: + +```powershell + "", + "**Windows** -- ``Loremaster.exe`` is the portable build. It is unsigned, so antivirus machine-learning heuristics sometimes flag it; see the README's Troubleshooting section.", +``` + +Then change the checksum sentence, which still names a Windows download, to: + +```powershell + "Check what you downloaded against ``Loremaster-Linux-SHA256SUMS.txt`` (Linux) or ``SHA256SUMS.txt`` (skins).", +``` + +- [ ] **Step 7: Invert the gate** + +In `tools/release_quality_gate.py`, remove `"Loremaster.exe",` from `COMMON_PACKAGE_TOP_LEVEL` (line 227). + +Remove these two entries from the `required` tuple: + +```python + "dist-electron-release/Loremaster.exe", + "Copy-Item -Force dist-electron-release/Loremaster.exe $manualPackage", +``` + +Add them to the `retired` tuple, which becomes: + +```python + retired = ( + "LoremasterNext.exe", + "dist/Loremaster.exe", + "--specpath build/spec loremaster/loremaster.py", + "LOREMASTER-NEXT-SHA256.txt", + # The Windows executable is built and tested in CI but never published: + # it is this repository's code compiled for a platform the fork does + # not test, and nobody should be installing it from here. Retired + # rather than deleted so an upstream sync cannot quietly restore it. + "dist-electron-release/Loremaster.exe", + "Copy-Item -Force dist-electron-release/Loremaster.exe $manualPackage", + ) +``` + +Update the failure message on the `retired` check so it still reads true — it currently says "legacy/preview GUI": + +```python + if present: + fail("release workflow publishes a retired artifact: " + ", ".join(present)) +``` + +- [ ] **Step 8: Verify the gate now passes and guards the other direction** + +```bash +python3 tools/release_quality_gate.py +``` + +Expected: `RELEASE QUALITY GATE: ALL PASS`. + +Then prove the guard bites. Temporarily re-add the copy line to the workflow, re-run the gate, and confirm it FAILS with `release workflow publishes a retired artifact: dist-electron-release/Loremaster.exe, Copy-Item ...`. Remove it again and confirm ALL PASS. + +- [ ] **Step 9: Verify the workflow still parses** + +```bash +python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/build-loremaster.yml')); print('yaml ok')" +``` + +Expected: `yaml ok`. + +- [ ] **Step 10: Commit** + +```bash +git add .github/workflows/build-loremaster.yml tools/release_quality_gate.py +git commit -m "Stop publishing the Windows executable" +``` + +### Task 7: Documentation + +**Files:** +- Modify: `README.md:377`, `:397`, `:405`, `:423`, `:492` +- Modify: `installer/INSTALL-MANUAL.md:16`, `:112`, `:149`, `:193` +- Modify: `docs/RELEASING.md:70` + +**Interfaces:** +- Consumes: nothing. +- Produces: nothing. + +`installer/INSTALL-MANUAL.md` ships **inside** `SpinUI-Manual.zip`, so it must not tell a reader to run a file that is no longer in the archive. That is the highest-priority file here. + +- [ ] **Step 1: Read each reference in context** + +```bash +grep -n -B3 -A3 "Loremaster.exe" README.md installer/INSTALL-MANUAL.md docs/RELEASING.md +``` + +- [ ] **Step 2: Rewrite each reference** + +Apply this rule per site, preserving the surrounding voice: + +- Instructions to **run or download** `Loremaster.exe` from a release (README 377, 397, 423; INSTALL-MANUAL 112, 149): rewrite for the Linux AppImage, which is what the release now carries. Where a step only makes sense on Windows, say the executable is not published from this repository and must be built from source. +- The **antivirus/SmartScreen** notes (README 405; INSTALL-MANUAL 16): these describe downloading an unsigned exe from the releases page, which no longer happens. Remove them, and drop `Get-FileHash` guidance that refers to verifying that download. +- **Descriptions of what a release contains** (README 492; RELEASING 70): state that CI builds and tests the Windows executable but does not publish it. +- INSTALL-MANUAL 193 is prose about Wine fallback that mentions the exe — reword to match, since a reader can no longer obtain it here. + +- [ ] **Step 3: Verify no stale instruction survives** + +```bash +grep -rn "Loremaster.exe" README.md installer/ docs/ | grep -vi "not published\|built from source\|does not publish" +``` + +Expected: no output, or only lines you can justify as accurate. + +- [ ] **Step 4: Run the gate** + +```bash +python3 tools/release_quality_gate.py +``` + +Expected: ALL PASS. The gate checks required README showcase media and file identity, so a careless edit surfaces here. + +- [ ] **Step 5: Commit** + +```bash +git add README.md installer/INSTALL-MANUAL.md docs/RELEASING.md +git commit -m "Stop telling people to download an executable this repo no longer ships" +``` + +### Task 8: Changelog entry and pull request + +**Files:** +- Modify: `CHANGELOG.md` (under `## 0.4.0`) + +- [ ] **Step 1: Add the entry** + +```markdown +- **The Windows executable is no longer published here** — releases carry the + Linux build and the skins. The executable is still built and tested on every + run, so a change that breaks it is still caught, but it is not offered for + download from this fork. Anyone already running the Windows build will see + its updater fail rather than find a new one. +``` + +- [ ] **Step 2: Verify the extractor** + +```bash +python3 tools/release_notes.py --version v0.4.0 | head -20 +python3 tools/release_notes.py --self-test +``` + +Expected: the entry appears; self-test prints `ALL PASS`. + +- [ ] **Step 3: Final verification** + +```bash +python3 tools/release_quality_gate.py +``` + +Expected: `RELEASE QUALITY GATE: ALL PASS`. + +- [ ] **Step 4: Commit and open the pull request** + +```bash +git add CHANGELOG.md +git commit -m "Say the Windows executable is no longer published" +git push -u origin fix/stop-publishing-windows-exe +gh pr create --title "Stop publishing the Windows executable" --body "$(cat <<'EOF' +The Windows executable should not be downloadable from this fork. It leaked out of four places, not one — the standalone release asset, a copy **inside `SpinUI-Manual.zip`**, the `SHA256SUMS.txt` listing, and the release notes. Removing only the standalone asset would still have shipped it inside the manual bundle. + +The skins stay published: `spinui-updater.ts` fetches `SpinUI-UI.zip` and `SpinUI-Update.json` from this repo's latest release, and that updater ships in the Linux build. + +CI still builds and tests the executable on every qualifying run, so a shared-code change that breaks the Windows build still fails. Only publishing changed. + +The quality gate's assertions moved from `required` to `retired`, so it now fails if the executable ever returns to the release path — which is what stops the next upstream sync from quietly undoing this. + +**Known fallout, accepted:** `portable-updater.ts` looks for a `Loremaster.exe` asset and will throw when it finds none, so anyone already running the Windows build sees an update error rather than a clean end-of-life message. Not scoped into this work. + +Spec: `docs/superpowers/specs/2026-08-14-rc-identity-and-windows-exe-removal-design.md` + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +EOF +)" +``` + +--- + +## What this plan does not verify + +The RC identity overrides cannot be fully proven locally — `electron-builder --linux` on this machine builds an AppImage, but the published naming and the `.desktop` contents are only truly confirmed by a real candidate build. After PR 1 merges, the first `workflow_dispatch` with a `-rc.1` tag should be checked for: + +- an asset named `Loremaster-RC--x86_64.AppImage` +- `Name=Loremaster RC` and `StartupWMClass=loremaster-rc` inside the AppImage's `loremaster-rc.desktop` +- Gear Lever accepting it alongside the live entry +- `~/.config/spins-loremaster-rc-backups//` appearing on first launch, with the live settings unmoved + +If a local dry run is wanted first, `pnpm exec electron-builder --linux AppImage --x64 --publish never` with the same override list produces the file to inspect without touching a release. From 7ee737daffa330fb8ec1c2b95e2b8c0340fca067 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:43:11 -0400 Subject: [PATCH 03/10] Snapshot the settings a release candidate is about to share --- loremaster-desktop/electron/rc-backup.ts | 71 +++++++ loremaster-desktop/package.json | 3 +- loremaster-desktop/scripts/test-rc-backup.cjs | 188 ++++++++++++++++++ 3 files changed, 261 insertions(+), 1 deletion(-) create mode 100644 loremaster-desktop/electron/rc-backup.ts create mode 100644 loremaster-desktop/scripts/test-rc-backup.cjs diff --git a/loremaster-desktop/electron/rc-backup.ts b/loremaster-desktop/electron/rc-backup.ts new file mode 100644 index 0000000..acc9f9f --- /dev/null +++ b/loremaster-desktop/electron/rc-backup.ts @@ -0,0 +1,71 @@ +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import path from "node:path"; + +// The files holding state a candidate can damage. Regenerable data +// (item-intelligence/, updates/, Electron's own caches) is deliberately +// absent: it is large, slow to copy, and rebuilt on demand. +export const RC_BACKUP_FILES = [ + "desktop-settings.json", + "update-center.json", + "spinui-update-receipts.json", + "eq-legends-tools-gear-cache.json", +] as const; + +export interface RcBackupRequest { + version: string; + userDataDir: string; + backupRoot: string; +} + +export type RcBackupResult = + | { status: "skipped"; reason: "not-a-candidate" | "already-backed-up" } + | { status: "created"; directory: string; files: string[] } + | { status: "failed"; error: string }; + +// Mirrors the charset the publish step enforces on a release tag, so a +// candidate is recognised here exactly when it was published as one. Requiring +// the full string to match also keeps the version usable as a directory name: +// nothing with a separator in it can reach the filesystem. +const RELEASE_CANDIDATE_VERSION = /^\d+\.\d+\.\d+-[0-9A-Za-z.-]+$/; + +export function isReleaseCandidateVersion(version: string): boolean { + return RELEASE_CANDIDATE_VERSION.test(version.trim()); +} + +// A candidate shares the live settings directory on purpose, so it snapshots +// the state files before it can write to them. The snapshot lives beside that +// directory rather than inside it, so the candidate never writes into the +// thing it is protecting and deleting a ruined config keeps the backups. +export function backupBeforeReleaseCandidate(request: RcBackupRequest): RcBackupResult { + const version = request.version.trim(); + if (!isReleaseCandidateVersion(version)) { + return { status: "skipped", reason: "not-a-candidate" }; + } + + const directory = path.join(request.backupRoot, version); + try { + // The directory's presence is the marker, so a later launch cannot + // overwrite the pre-candidate snapshot with already-damaged state. + if (existsSync(directory)) { + return { status: "skipped", reason: "already-backed-up" }; + } + mkdirSync(directory, { recursive: true }); + + const files: string[] = []; + for (const name of RC_BACKUP_FILES) { + const source = path.join(request.userDataDir, name); + // A fresh install has none of these, which is not a failure. + if (!existsSync(source)) { + continue; + } + copyFileSync(source, path.join(directory, name)); + files.push(name); + } + return { status: "created", directory, files }; + } catch (error) { + return { + status: "failed", + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/loremaster-desktop/package.json b/loremaster-desktop/package.json index b297eac..f59093e 100644 --- a/loremaster-desktop/package.json +++ b/loremaster-desktop/package.json @@ -16,7 +16,8 @@ "test:gear": "node scripts/test-gear-plan.cjs", "test:items": "node scripts/test-item-intelligence.cjs", "test:updates": "node scripts/test-portable-updater.cjs", - "test:skin-updates": "node scripts/test-spinui-updater.cjs" + "test:skin-updates": "node scripts/test-spinui-updater.cjs", + "test:rc-backup": "node scripts/test-rc-backup.cjs" }, "dependencies": { "@electron-internal/extract-zip": "1.0.5", diff --git a/loremaster-desktop/scripts/test-rc-backup.cjs b/loremaster-desktop/scripts/test-rc-backup.cjs new file mode 100644 index 0000000..0e056c1 --- /dev/null +++ b/loremaster-desktop/scripts/test-rc-backup.cjs @@ -0,0 +1,188 @@ +const assert = require("node:assert/strict"); +const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs"); +const { mkdtemp, rm } = require("node:fs/promises"); +const os = require("node:os"); +const path = require("node:path"); + +const { + RC_BACKUP_FILES, + backupBeforeReleaseCandidate, + isReleaseCandidateVersion, +} = require("../dist-electron/rc-backup.js"); + +function seedUserData(root) { + mkdirSync(root, { recursive: true }); + writeFileSync(path.join(root, "desktop-settings.json"), '{"live":true}'); + writeFileSync(path.join(root, "update-center.json"), '{"seen":1}'); + // spinui-update-receipts.json and eq-legends-tools-gear-cache.json are + // deliberately absent: a fresh install has neither, and that must not raise. +} + +async function withTempDir(run) { + const dir = await mkdtemp(path.join(os.tmpdir(), "rc-backup-")); + try { + await run(dir); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +async function testVersionDetection() { + assert.equal(isReleaseCandidateVersion("0.4.0-rc.1"), true); + assert.equal(isReleaseCandidateVersion("0.4.0"), false); + assert.equal(isReleaseCandidateVersion("0.3.4"), false); + // Build metadata is not a prerelease component. + assert.equal(isReleaseCandidateVersion("0.4.0+build.5"), false); + // A version that could escape the backup root is not a candidate. + assert.equal(isReleaseCandidateVersion("0.4.0-../../etc"), false); + assert.equal(isReleaseCandidateVersion(""), false); + console.log(" version detection: PASS"); +} + +async function testSkipsFullRelease() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + const result = backupBeforeReleaseCandidate({ + version: "0.4.0", + userDataDir, + backupRoot, + }); + assert.deepEqual(result, { status: "skipped", reason: "not-a-candidate" }); + assert.equal(existsSync(backupRoot), false, "a full release must not create a backup root"); + }); + console.log(" skips a full release: PASS"); +} + +async function testCopiesOnlyExistingStateFiles() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + // Regenerable data must not be copied. + mkdirSync(path.join(userDataDir, "item-intelligence"), { recursive: true }); + writeFileSync(path.join(userDataDir, "item-intelligence", "cache.bin"), "x"); + + const result = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + + assert.equal(result.status, "created"); + assert.equal(result.directory, path.join(backupRoot, "0.4.0-rc.1")); + assert.deepEqual(result.files, ["desktop-settings.json", "update-center.json"]); + assert.equal( + readFileSync(path.join(result.directory, "desktop-settings.json"), "utf8"), + '{"live":true}', + ); + assert.equal(existsSync(path.join(result.directory, "item-intelligence")), false); + assert.equal(existsSync(path.join(result.directory, "spinui-update-receipts.json")), false); + }); + console.log(" copies only existing state files: PASS"); +} + +async function testSnapshotIsTakenOnce() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + + const first = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + assert.equal(first.status, "created"); + + // The candidate has now damaged the live config. + writeFileSync(path.join(userDataDir, "desktop-settings.json"), '{"corrupt":true}'); + + const second = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + assert.deepEqual(second, { status: "skipped", reason: "already-backed-up" }); + assert.equal( + readFileSync(path.join(first.directory, "desktop-settings.json"), "utf8"), + '{"live":true}', + "the pre-candidate snapshot must survive a later launch", + ); + }); + console.log(" snapshot is taken once per version: PASS"); +} + +async function testEachCandidateGetsItsOwnSnapshot() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + + const first = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + const second = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.2", + userDataDir, + backupRoot, + }); + + assert.equal(first.status, "created"); + assert.equal(second.status, "created"); + assert.notEqual(first.directory, second.directory); + assert.equal(existsSync(path.join(backupRoot, "0.4.0-rc.1")), true); + assert.equal(existsSync(path.join(backupRoot, "0.4.0-rc.2")), true); + }); + console.log(" each candidate gets its own snapshot: PASS"); +} + +async function testFailureNeverThrows() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + seedUserData(userDataDir); + // A file where the backup root should be: mkdir cannot succeed here. + const backupRoot = path.join(dir, "blocked"); + writeFileSync(backupRoot, "not a directory"); + + const result = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + assert.equal(result.status, "failed"); + assert.equal(typeof result.error, "string"); + assert.ok(result.error.length > 0); + }); + console.log(" an unwritable destination never throws: PASS"); +} + +async function testBackupFileListIsTheStateFiles() { + assert.deepEqual([...RC_BACKUP_FILES], [ + "desktop-settings.json", + "update-center.json", + "spinui-update-receipts.json", + "eq-legends-tools-gear-cache.json", + ]); + console.log(" backup file list: PASS"); +} + +async function main() { + console.log("rc backup:"); + await testVersionDetection(); + await testBackupFileListIsTheStateFiles(); + await testSkipsFullRelease(); + await testCopiesOnlyExistingStateFiles(); + await testSnapshotIsTakenOnce(); + await testEachCandidateGetsItsOwnSnapshot(); + await testFailureNeverThrows(); + console.log("rc backup: ALL PASS"); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); From c578eca0bb0e7ae6389ba2582cfb48fadb9ec037 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:48:49 -0400 Subject: [PATCH 04/10] Make the RC backup marker atomic so a failed copy retries instead of masking A partial copy left mkdirSync's target directory behind, and that same directory doubled as the already-backed-up marker, so a crashed attempt permanently masked the fact the real snapshot never completed. Stage copies in a sibling .partial directory and rename it into place only once every file has copied. --- loremaster-desktop/electron/rc-backup.ts | 15 +++++-- loremaster-desktop/scripts/test-rc-backup.cjs | 40 +++++++++++++++++++ 2 files changed, 52 insertions(+), 3 deletions(-) diff --git a/loremaster-desktop/electron/rc-backup.ts b/loremaster-desktop/electron/rc-backup.ts index acc9f9f..ce5abad 100644 --- a/loremaster-desktop/electron/rc-backup.ts +++ b/loremaster-desktop/electron/rc-backup.ts @@ -1,4 +1,4 @@ -import { copyFileSync, existsSync, mkdirSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs"; import path from "node:path"; // The files holding state a candidate can damage. Regenerable data @@ -43,13 +43,21 @@ export function backupBeforeReleaseCandidate(request: RcBackupRequest): RcBackup } const directory = path.join(request.backupRoot, version); + // Copies land here first and only become the marker directory once every + // file has been copied, via an atomic rename. If a copy fails partway, + // `directory` never comes into existence, so a later launch retries + // instead of trusting an incomplete snapshot. + const staging = path.join(request.backupRoot, `${version}.partial`); try { // The directory's presence is the marker, so a later launch cannot // overwrite the pre-candidate snapshot with already-damaged state. if (existsSync(directory)) { return { status: "skipped", reason: "already-backed-up" }; } - mkdirSync(directory, { recursive: true }); + // A crashed earlier attempt may have left a partial staging directory + // behind; it must not poison this retry. + rmSync(staging, { recursive: true, force: true }); + mkdirSync(staging, { recursive: true }); const files: string[] = []; for (const name of RC_BACKUP_FILES) { @@ -58,9 +66,10 @@ export function backupBeforeReleaseCandidate(request: RcBackupRequest): RcBackup if (!existsSync(source)) { continue; } - copyFileSync(source, path.join(directory, name)); + copyFileSync(source, path.join(staging, name)); files.push(name); } + renameSync(staging, directory); return { status: "created", directory, files }; } catch (error) { return { diff --git a/loremaster-desktop/scripts/test-rc-backup.cjs b/loremaster-desktop/scripts/test-rc-backup.cjs index 0e056c1..7d84442 100644 --- a/loremaster-desktop/scripts/test-rc-backup.cjs +++ b/loremaster-desktop/scripts/test-rc-backup.cjs @@ -156,10 +156,49 @@ async function testFailureNeverThrows() { assert.equal(result.status, "failed"); assert.equal(typeof result.error, "string"); assert.ok(result.error.length > 0); + assert.equal( + existsSync(path.join(dir, "blocked", "0.4.0-rc.1")), + false, + "a failed attempt must not leave the final snapshot directory behind", + ); }); console.log(" an unwritable destination never throws: PASS"); } +async function testStalePartialDoesNotBlockRetry() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + + // Simulate a crashed earlier attempt that left a partial staging + // directory behind, with junk in it. + const staging = path.join(backupRoot, "0.4.0-rc.1.partial"); + mkdirSync(staging, { recursive: true }); + writeFileSync(path.join(staging, "junk.txt"), "leftover from a crash"); + + const result = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + + assert.equal(result.status, "created"); + assert.equal(result.directory, path.join(backupRoot, "0.4.0-rc.1")); + assert.deepEqual(result.files, ["desktop-settings.json", "update-center.json"]); + assert.equal( + readFileSync(path.join(result.directory, "desktop-settings.json"), "utf8"), + '{"live":true}', + ); + assert.equal( + existsSync(path.join(result.directory, "junk.txt")), + false, + "a stale partial's leftovers must not survive into the completed snapshot", + ); + }); + console.log(" a stale partial does not block or corrupt a retry: PASS"); +} + async function testBackupFileListIsTheStateFiles() { assert.deepEqual([...RC_BACKUP_FILES], [ "desktop-settings.json", @@ -179,6 +218,7 @@ async function main() { await testSnapshotIsTakenOnce(); await testEachCandidateGetsItsOwnSnapshot(); await testFailureNeverThrows(); + await testStalePartialDoesNotBlockRetry(); console.log("rc backup: ALL PASS"); } From efb453f058dcaaf55726436ec1f9b5a9a5d065df Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:53:27 -0400 Subject: [PATCH 05/10] Prove the RC backup mid-copy fix with a test that actually fails without it The round-1 regression tests both passed against the pre-fix code, so neither proved anything: one never triggered a copy failure at all, and the other forced mkdirSync to throw before any directory could exist. Add a test that makes the second of four source files a directory so copyFileSync throws EISDIR after one file has already copied, then assert the marker directory does not exist and a repaired retry succeeds. Verified by temporarily reverting rc-backup.ts to its pre-fix state and confirming this test fails there. --- loremaster-desktop/scripts/test-rc-backup.cjs | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/loremaster-desktop/scripts/test-rc-backup.cjs b/loremaster-desktop/scripts/test-rc-backup.cjs index 7d84442..96d2797 100644 --- a/loremaster-desktop/scripts/test-rc-backup.cjs +++ b/loremaster-desktop/scripts/test-rc-backup.cjs @@ -1,5 +1,5 @@ const assert = require("node:assert/strict"); -const { existsSync, mkdirSync, readFileSync, writeFileSync } = require("node:fs"); +const { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } = require("node:fs"); const { mkdtemp, rm } = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); @@ -199,6 +199,50 @@ async function testStalePartialDoesNotBlockRetry() { console.log(" a stale partial does not block or corrupt a retry: PASS"); } +async function testMidCopyFailureLeavesNoMarkerAndRetriesCleanly() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + const version = "0.4.0-rc.1"; + mkdirSync(userDataDir, { recursive: true }); + // File 1 of 4 (RC_BACKUP_FILES order): copies fine. + writeFileSync(path.join(userDataDir, "desktop-settings.json"), '{"live":true}'); + // File 2 of 4: a directory in place of the expected file. copyFileSync + // throws EISDIR on it, so the failure happens genuinely mid-copy, after + // one file has already landed in the staging directory. + mkdirSync(path.join(userDataDir, "update-center.json"), { recursive: true }); + + const result = backupBeforeReleaseCandidate({ version, userDataDir, backupRoot }); + + assert.equal(result.status, "failed"); + assert.equal( + existsSync(path.join(backupRoot, version)), + false, + "a mid-copy failure must not leave the marker directory behind, even though one file copied", + ); + + // Repair the source and retry: the failed attempt must not be a + // permanent lockout. + rmSync(path.join(userDataDir, "update-center.json"), { recursive: true, force: true }); + writeFileSync(path.join(userDataDir, "update-center.json"), '{"seen":1}'); + + const retry = backupBeforeReleaseCandidate({ version, userDataDir, backupRoot }); + + assert.equal(retry.status, "created"); + assert.equal(retry.directory, path.join(backupRoot, version)); + assert.deepEqual(retry.files, ["desktop-settings.json", "update-center.json"]); + assert.equal( + readFileSync(path.join(retry.directory, "desktop-settings.json"), "utf8"), + '{"live":true}', + ); + assert.equal( + readFileSync(path.join(retry.directory, "update-center.json"), "utf8"), + '{"seen":1}', + ); + }); + console.log(" a mid-copy failure leaves no marker and retries cleanly: PASS"); +} + async function testBackupFileListIsTheStateFiles() { assert.deepEqual([...RC_BACKUP_FILES], [ "desktop-settings.json", @@ -219,6 +263,7 @@ async function main() { await testEachCandidateGetsItsOwnSnapshot(); await testFailureNeverThrows(); await testStalePartialDoesNotBlockRetry(); + await testMidCopyFailureLeavesNoMarkerAndRetriesCleanly(); console.log("rc backup: ALL PASS"); } From f0663e998ad8b39afcac6aabf7edac2528273fef Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:56:19 -0400 Subject: [PATCH 06/10] Take the candidate snapshot before anything can write settings --- loremaster-desktop/electron/main.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/loremaster-desktop/electron/main.ts b/loremaster-desktop/electron/main.ts index 7815809..529f0b8 100644 --- a/loremaster-desktop/electron/main.ts +++ b/loremaster-desktop/electron/main.ts @@ -21,6 +21,7 @@ import { type UpdateCheckResult, type UpdateProgress, } from "./portable-updater"; +import { backupBeforeReleaseCandidate } from "./rc-backup"; import { EverQuestRunningError, SPINUI_SKINS, @@ -77,6 +78,27 @@ if (process.platform === "linux" } } +// A candidate shares the live settings directory on purpose -- bugs surface +// against real data that way -- so it snapshots the state files before any +// code can write to them. Runs before app.whenReady on purpose: both paths +// resolve this early, and nothing has opened a settings file yet. +{ + const backup = backupBeforeReleaseCandidate({ + version: app.getVersion(), + userDataDir: app.getPath("userData"), + backupRoot: path.join(app.getPath("appData"), "spins-loremaster-rc-backups"), + }); + if (backup.status === "created") { + console.info( + `[Loremaster] release candidate: backed up ${backup.files.length}` + + ` settings file(s) to ${backup.directory}`); + } else if (backup.status === "failed") { + // Logged, never fatal: a backup that cannot be written must not stop the + // application from starting. + console.warn(`[Loremaster] release candidate backup failed: ${backup.error}`); + } +} + // Which backend is actually running, not which one was asked for. The flag // usually arrives on the command line -- the AppImage launcher supplies it -- // so reading only LOREMASTER_OZONE concluded "Wayland" while X11 was running, From 0d708d12ef88172426736cbe6ddf07ccf074f7c4 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:06:36 -0400 Subject: [PATCH 07/10] Move RC backup after the userData override so it observes the real path --- loremaster-desktop/electron/main.ts | 46 ++++++++++++++++------------- 1 file changed, 25 insertions(+), 21 deletions(-) diff --git a/loremaster-desktop/electron/main.ts b/loremaster-desktop/electron/main.ts index 529f0b8..f230745 100644 --- a/loremaster-desktop/electron/main.ts +++ b/loremaster-desktop/electron/main.ts @@ -78,27 +78,6 @@ if (process.platform === "linux" } } -// A candidate shares the live settings directory on purpose -- bugs surface -// against real data that way -- so it snapshots the state files before any -// code can write to them. Runs before app.whenReady on purpose: both paths -// resolve this early, and nothing has opened a settings file yet. -{ - const backup = backupBeforeReleaseCandidate({ - version: app.getVersion(), - userDataDir: app.getPath("userData"), - backupRoot: path.join(app.getPath("appData"), "spins-loremaster-rc-backups"), - }); - if (backup.status === "created") { - console.info( - `[Loremaster] release candidate: backed up ${backup.files.length}` - + ` settings file(s) to ${backup.directory}`); - } else if (backup.status === "failed") { - // Logged, never fatal: a backup that cannot be written must not stop the - // application from starting. - console.warn(`[Loremaster] release candidate backup failed: ${backup.error}`); - } -} - // Which backend is actually running, not which one was asked for. The flag // usually arrives on the command line -- the AppImage launcher supplies it -- // so reading only LOREMASTER_OZONE concluded "Wayland" while X11 was running, @@ -122,6 +101,31 @@ const windowPositioningIsReliable = (() => { if (process.env.LOREMASTER_DESKTOP_DATA_DIR) { app.setPath("userData", path.resolve(process.env.LOREMASTER_DESKTOP_DATA_DIR)); } + +// A candidate shares the live settings directory on purpose -- bugs surface +// against real data that way -- so it snapshots the state files before any +// code can write to them. Placed after the LOREMASTER_DESKTOP_DATA_DIR +// override above so it always observes the final userData path, never the +// OS default the override replaces. Still runs well before app.whenReady: +// nothing between here and there reads or writes a settings file. +{ + const userDataDir = app.getPath("userData"); + const backup = backupBeforeReleaseCandidate({ + version: app.getVersion(), + userDataDir, + backupRoot: path.join(path.dirname(userDataDir), "spins-loremaster-rc-backups"), + }); + if (backup.status === "created") { + console.info( + `[Loremaster] release candidate: backed up ${backup.files.length}` + + ` settings file(s) to ${backup.directory}`); + } else if (backup.status === "failed") { + // Logged, never fatal: a backup that cannot be written must not stop the + // application from starting. + console.warn(`[Loremaster] release candidate backup failed: ${backup.error}`); + } +} + const ownsSingleInstance = app.requestSingleInstanceLock(); if (!ownsSingleInstance) app.quit(); let mainWindow: BrowserWindow | null = null; From 4f3ee947716018493170baacbf51992456d6564b Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:09:42 -0400 Subject: [PATCH 08/10] Build a release candidate under its own name --- .github/workflows/build-loremaster.yml | 38 ++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-loremaster.yml b/.github/workflows/build-loremaster.yml index 337da79..d7bc644 100644 --- a/.github/workflows/build-loremaster.yml +++ b/.github/workflows/build-loremaster.yml @@ -239,6 +239,7 @@ jobs: pnpm test:items pnpm test:updates pnpm test:skin-updates + pnpm test:rc-backup - name: Fetch stock-layout reference commit shell: bash @@ -378,6 +379,7 @@ jobs: pnpm test:fixtures pnpm build pnpm test:gear + pnpm test:rc-backup - name: Verify installer selftest run: python3 installer/spinui_installer.py --selftest @@ -397,9 +399,34 @@ jobs: | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+([-+][0-9A-Za-z.-]+)?$'; then version="$(node -p "require('./package.json').version")" fi + # A candidate has to install beside the live release rather than + # replace it. Gear Lever keys on the desktop entry, so productName + # (Name=) and desktopName (the .desktop filename, and StartupWMClass) + # both have to differ -- app-builder-lib reads desktopName first and + # only falls back to executableName, so overriding executableName + # alone leaves the entry named loremaster.desktop and the collision + # unfixed. desktopName sits at the top level of package.json, which + # is why it goes through extraMetadata rather than -c.linux. + # + # name is deliberately never overridden: userData comes from it, and + # a candidate shares the live settings on purpose so bugs surface + # against real data. + identity=() + if printf '%s' "$version" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+-'; then + identity=( + "-c.productName=Loremaster RC" + "-c.appId=com.spinui.loremaster.rc" + "-c.extraMetadata.desktopName=loremaster-rc.desktop" + "-c.linux.executableName=loremaster-rc" + # Single quoted: these are electron-builder templates, and bash + # would expand them to empty strings. + '-c.linux.artifactName=Loremaster-RC-${version}-${arch}.${ext}' + ) + echo "building release candidate identity: Loremaster RC ($version)" + fi pnpm build pnpm exec electron-builder --linux AppImage tar.gz --x64 \ - --publish never "-c.extraMetadata.version=$version" + --publish never "-c.extraMetadata.version=$version" "${identity[@]}" env: RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }} @@ -549,6 +576,13 @@ jobs: # marker has to sit at column zero and so cannot be indented inside # this step. $version = $tag -replace '^v', '' + # A candidate ships under its own name so it can sit beside the live + # release, so the notes have to point at that file, not the other one. + $appImage = if ($prerelease) { + "Loremaster-RC-$version-x86_64.AppImage" + } else { + "Loremaster-$version-x86_64.AppImage" + } $changelog = python tools/release_notes.py --version $tag if ($LASTEXITCODE -ne 0) { throw "no changelog entry for $tag" } $notes = @() @@ -561,7 +595,7 @@ jobs: "", "## Installing", "", - "**Linux** -- download ``Loremaster-$version-x86_64.AppImage``, ``chmod +x`` it, and run it. There is no self-update on Linux, so new builds always come from this page.", + "**Linux** -- download ``$appImage``, ``chmod +x`` it, and run it. There is no self-update on Linux, so new builds always come from this page.", "", "**Windows** -- ``Loremaster.exe`` is the portable build. It is unsigned, so antivirus machine-learning heuristics sometimes flag it; see the README's Troubleshooting section.", "", From 62a880a6e9d9c154f877b05cd61829265786a639 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:14:27 -0400 Subject: [PATCH 09/10] Say that candidates now install beside the release --- CHANGELOG.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e143758..e999676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,11 @@ that came with it. discarded the first kill when two raid targets died before you confirmed a difficulty, which is exactly what happens when a raid clears several at once. Each kill now keeps its own zone, character and clear time. +- **Release candidates install beside the release** — a candidate now builds as + "Loremaster RC" with its own desktop entry, so a tool like Gear Lever can hold + it and the live release at the same time instead of treating them as one app. + It shares the live settings on purpose, so bugs show up against real data, and + it copies those settings aside once per candidate before it can touch them. ### Removed From 435797213f19a51af15597e6a85f95d76e4e36c4 Mon Sep 17 00:00:00 2001 From: JDS300 <70587798+JDS300@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:38:03 -0400 Subject: [PATCH 10/10] Snapshot the engine's raid progress too, not just Electron settings The RC backup only covered four Electron-owned JSON files, but the Python engine writes weekly_boss_kills.json and adventure_journal.sqlite3 into the same shared userData directory -- irreplaceable raid progress and kill history that a release candidate could silently corrupt with zero protection. Extend RC_BACKUP_FILES to also cover the journal's WAL/SHM sidecars, since the engine runs in WAL mode and the bare .sqlite3 file can miss the tail of an unclean shutdown. Also close two smaller gaps found in the same review: - Scope the staging directory by pid so two simultaneous launches (this runs before requestSingleInstanceLock) can no longer interleave and produce a snapshot that's missing a file but reported as complete. - Use statSync().isDirectory() instead of existsSync() for the already-backed-up marker, so a stray file left at that path can't permanently suppress the backup. Document all of this in RELEASING.md so whoever cuts a release knows a candidate shares live data and how to recover if it goes wrong. Co-Authored-By: Claude Opus 5 (1M context) --- docs/RELEASING.md | 11 +++ loremaster-desktop/electron/rc-backup.ts | 29 ++++--- loremaster-desktop/scripts/test-rc-backup.cjs | 78 +++++++++++++++++-- 3 files changed, 104 insertions(+), 14 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index cf6d038..f4846a6 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -72,3 +72,14 @@ a manual download. The SpinUI **skin** updater does run on Linux, and it follows full releases only, exactly like the app updater. That is why testing a candidate on Linux is a manual install by design. + +## Installing a candidate alongside the live release + +A candidate builds and installs as "Loremaster RC", with its own desktop +entry, so it sits beside the live release instead of replacing it. It shares +the live settings directory on purpose -- bugs surface against real data that +way -- so before it can write to anything, it snapshots settings and progress +once per candidate to `/spins-loremaster-rc-backups//` (on +Linux, `~/.config/spins-loremaster-rc-backups//`). If a candidate +damages something, recovery is a manual copy of that snapshot back into +`~/.config/spins-loremaster`. diff --git a/loremaster-desktop/electron/rc-backup.ts b/loremaster-desktop/electron/rc-backup.ts index ce5abad..628b189 100644 --- a/loremaster-desktop/electron/rc-backup.ts +++ b/loremaster-desktop/electron/rc-backup.ts @@ -1,14 +1,20 @@ -import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, statSync } from "node:fs"; import path from "node:path"; -// The files holding state a candidate can damage. Regenerable data -// (item-intelligence/, updates/, Electron's own caches) is deliberately -// absent: it is large, slow to copy, and rebuilt on demand. +// The files holding state a candidate can damage: Electron's own settings +// and caches, plus the engine-owned progress data (weekly raid kills and the +// adventure journal) that the Python worker writes into the same directory. +// Regenerable data (item-intelligence/, updates/, Electron's own caches) is +// deliberately absent: it is large, slow to copy, and rebuilt on demand. export const RC_BACKUP_FILES = [ "desktop-settings.json", "update-center.json", "spinui-update-receipts.json", "eq-legends-tools-gear-cache.json", + "weekly_boss_kills.json", + "adventure_journal.sqlite3", + "adventure_journal.sqlite3-wal", + "adventure_journal.sqlite3-shm", ] as const; export interface RcBackupRequest { @@ -47,15 +53,20 @@ export function backupBeforeReleaseCandidate(request: RcBackupRequest): RcBackup // file has been copied, via an atomic rename. If a copy fails partway, // `directory` never comes into existence, so a later launch retries // instead of trusting an incomplete snapshot. - const staging = path.join(request.backupRoot, `${version}.partial`); + // Unique per process so two simultaneous launches (this runs before + // app.requestSingleInstanceLock()) never share a staging directory: each + // process's rmSync/mkdirSync below only ever touches its own leftovers. + const staging = path.join(request.backupRoot, `${version}.${process.pid}.partial`); try { // The directory's presence is the marker, so a later launch cannot - // overwrite the pre-candidate snapshot with already-damaged state. - if (existsSync(directory)) { + // overwrite the pre-candidate snapshot with already-damaged state. Only + // a genuine directory counts: a stray file at this path must not + // permanently suppress the backup. + if (statSync(directory, { throwIfNoEntry: false })?.isDirectory()) { return { status: "skipped", reason: "already-backed-up" }; } - // A crashed earlier attempt may have left a partial staging directory - // behind; it must not poison this retry. + // A crashed earlier attempt may have left this process's own partial + // staging directory behind; it must not poison this retry. rmSync(staging, { recursive: true, force: true }); mkdirSync(staging, { recursive: true }); diff --git a/loremaster-desktop/scripts/test-rc-backup.cjs b/loremaster-desktop/scripts/test-rc-backup.cjs index 96d2797..76b0da7 100644 --- a/loremaster-desktop/scripts/test-rc-backup.cjs +++ b/loremaster-desktop/scripts/test-rc-backup.cjs @@ -14,8 +14,9 @@ function seedUserData(root) { mkdirSync(root, { recursive: true }); writeFileSync(path.join(root, "desktop-settings.json"), '{"live":true}'); writeFileSync(path.join(root, "update-center.json"), '{"seen":1}'); - // spinui-update-receipts.json and eq-legends-tools-gear-cache.json are - // deliberately absent: a fresh install has neither, and that must not raise. + // spinui-update-receipts.json, eq-legends-tools-gear-cache.json, and the + // engine-owned files below are deliberately absent: a fresh install has + // none of them, and that must not raise. } async function withTempDir(run) { @@ -171,9 +172,10 @@ async function testStalePartialDoesNotBlockRetry() { const backupRoot = path.join(dir, "backups"); seedUserData(userDataDir); - // Simulate a crashed earlier attempt that left a partial staging - // directory behind, with junk in it. - const staging = path.join(backupRoot, "0.4.0-rc.1.partial"); + // Simulate a crashed earlier attempt by this same process that left a + // partial staging directory behind, with junk in it. The staging path is + // scoped by pid, so this must match the pid the retry below will use. + const staging = path.join(backupRoot, `0.4.0-rc.1.${process.pid}.partial`); mkdirSync(staging, { recursive: true }); writeFileSync(path.join(staging, "junk.txt"), "leftover from a crash"); @@ -249,10 +251,74 @@ async function testBackupFileListIsTheStateFiles() { "update-center.json", "spinui-update-receipts.json", "eq-legends-tools-gear-cache.json", + "weekly_boss_kills.json", + "adventure_journal.sqlite3", + "adventure_journal.sqlite3-wal", + "adventure_journal.sqlite3-shm", ]); console.log(" backup file list: PASS"); } +async function testCopiesEngineOwnedProgressData() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + writeFileSync(path.join(userDataDir, "weekly_boss_kills.json"), '{"week":1}'); + writeFileSync(path.join(userDataDir, "adventure_journal.sqlite3"), "sqlite-bytes"); + + const result = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + + assert.equal(result.status, "created"); + assert.ok( + result.files.includes("weekly_boss_kills.json"), + "weekly_boss_kills.json must be reported as copied", + ); + assert.ok( + result.files.includes("adventure_journal.sqlite3"), + "adventure_journal.sqlite3 must be reported as copied", + ); + assert.equal( + readFileSync(path.join(result.directory, "weekly_boss_kills.json"), "utf8"), + '{"week":1}', + ); + assert.equal( + readFileSync(path.join(result.directory, "adventure_journal.sqlite3"), "utf8"), + "sqlite-bytes", + ); + }); + console.log(" copies engine-owned progress data: PASS"); +} + +async function testFileAtMarkerPathDoesNotSuppressBackupForever() { + await withTempDir(async (dir) => { + const userDataDir = path.join(dir, "userdata"); + const backupRoot = path.join(dir, "backups"); + seedUserData(userDataDir); + + // A stray plain file sitting at the marker path, instead of a directory. + mkdirSync(backupRoot, { recursive: true }); + writeFileSync(path.join(backupRoot, "0.4.0-rc.1"), "not a directory"); + + const result = backupBeforeReleaseCandidate({ + version: "0.4.0-rc.1", + userDataDir, + backupRoot, + }); + + assert.notEqual( + result.status, + "skipped", + "a plain file at the marker path must not be treated as an already-completed backup", + ); + }); + console.log(" a stray file at the marker path does not suppress the backup forever: PASS"); +} + async function main() { console.log("rc backup:"); await testVersionDetection(); @@ -264,6 +330,8 @@ async function main() { await testFailureNeverThrows(); await testStalePartialDoesNotBlockRetry(); await testMidCopyFailureLeavesNoMarkerAndRetriesCleanly(); + await testCopiesEngineOwnedProgressData(); + await testFileAtMarkerPathDoesNotSuppressBackupForever(); console.log("rc backup: ALL PASS"); }