From f510d2e18dc260d23ee3cdd973df87822d16deb3 Mon Sep 17 00:00:00 2001 From: BeaCox Date: Sat, 29 Aug 2026 01:06:54 +0800 Subject: [PATCH] Support prerelease versions in Windows MSI builds --- .github/workflows/release.yml | 8 +- docs/releases/v0.1.0-alpha.1.md | 1 + docs/update-strategy.md | 2 + scripts/generate-windows-release-config.mjs | 84 +++++++++++++++++++ .../generate-windows-release-config.test.js | 33 ++++++++ 5 files changed, 127 insertions(+), 1 deletion(-) create mode 100644 scripts/generate-windows-release-config.mjs create mode 100644 scripts/generate-windows-release-config.test.js diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c1f84e..f81d050 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,6 +49,7 @@ jobs: cargo metadata --format-version 1 --no-deps > "$RUNNER_TEMP/cargo-metadata.json" node --input-type=module <<'NODE' import fs from "node:fs"; + import { toMsiVersion } from "./scripts/generate-windows-release-config.mjs"; const expected = process.env.RELEASE_TAG.slice(1); const packageJson = JSON.parse(fs.readFileSync("package.json", "utf8")); @@ -69,6 +70,7 @@ jobs: } process.exit(1); } + toMsiVersion(expected); fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${expected}\n`); NODE @@ -173,10 +175,14 @@ jobs: file target/release/bundle/appimage/*.AppImage - name: Build Windows bundles if: matrix.platform == 'windows' + shell: pwsh env: TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} TAURI_SIGNING_PRIVATE_KEY_PASSWORD: "" - run: make windows CONFIG=src-tauri/tauri.updater.conf.json + RELEASE_VERSION: ${{ needs.validate.outputs.version }} + run: | + node scripts/generate-windows-release-config.mjs src-tauri/tauri.updater.conf.json "$env:RELEASE_VERSION" target/tauri.windows.release.conf.json + make windows CONFIG=target/tauri.windows.release.conf.json - name: Smoke-test Windows application if: matrix.platform == 'windows' shell: pwsh diff --git a/docs/releases/v0.1.0-alpha.1.md b/docs/releases/v0.1.0-alpha.1.md index 8f37cc3..6174875 100644 --- a/docs/releases/v0.1.0-alpha.1.md +++ b/docs/releases/v0.1.0-alpha.1.md @@ -47,6 +47,7 @@ This checklist is the evidence record for the first CleanerX alpha. Do not publi - [ ] Both macOS architecture artifacts launch on a clean supported system; Finder **Open** or System Settings approval is sufficient without disabling Gatekeeper. - [ ] The Linux AppImage and `.deb` launch on a clean supported environment; the AppImage updater path and `.deb` manual-update explanation are correct. - [ ] The Windows MSI and NSIS installers launch on Windows 10/11; SmartScreen disclosure and the passive NSIS updater path are correct. +- [ ] The MSI metadata uses numeric surrogate version `0.0.65535.10001`, while the application, NSIS installer, updater manifest, and release filenames retain `0.1.0-alpha.1`. - [ ] Read-only scan, bounded detail loading, review dialog, backup listing, settings persistence, and explicit read-only degradation are exercised on packaged builds. - [ ] Disposable Agent homes complete the applicable cleanup, optional backup, restore, interruption, and post-operation verification cycles without changing protected fixtures or source trees. - [ ] Keyboard navigation, visible focus, Chinese and English, light and dark system themes, and reduced-motion behavior receive an alpha acceptance pass. diff --git a/docs/update-strategy.md b/docs/update-strategy.md index b30f059..4bc8908 100644 --- a/docs/update-strategy.md +++ b/docs/update-strategy.md @@ -44,4 +44,6 @@ The fixed stable endpoint intentionally excludes GitHub prereleases. A future be Normal developer bundle commands remain unsigned and do not require the updater private key. Tagged releases add `src-tauri/tauri.updater.conf.json`, set `TAURI_SIGNING_PRIVATE_KEY` from GitHub Actions secrets, and ask Tauri to create update artifacts and `.sig` files. The release-draft job gathers every architecture, runs `scripts/generate-update-manifest.mjs`, and fails closed on a missing/empty signature before staging `latest.json` and checksums in a draft GitHub Release. A maintainer verifies the complete draft before publishing it. +WiX/MSI does not accept alphanumeric Semantic Versioning prerelease identifiers. The Windows release job therefore uses `scripts/generate-windows-release-config.mjs` to derive a numeric MSI-only version that sorts below the eventual stable version (`0.1.0-alpha.1` becomes `0.0.65535.10001`). The application, tag, updater manifest, NSIS installer, and release asset names retain the public Semantic Versioning value. Unsupported prerelease shapes fail during tag validation before platform builds begin. + Tauri's signature establishes continuity with the public key embedded in an installed CleanerX build. It does not establish operating-system publisher identity. Artifact names and release warnings therefore continue to say that current binaries are unsigned and not notarized. diff --git a/scripts/generate-windows-release-config.mjs b/scripts/generate-windows-release-config.mjs new file mode 100644 index 0000000..5a916ae --- /dev/null +++ b/scripts/generate-windows-release-config.mjs @@ -0,0 +1,84 @@ +import fs from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +const prereleaseOffsets = { + alpha: 10_000, + beta: 20_000, + rc: 30_000, +}; + +export function toMsiVersion(version) { + const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(alpha|beta|rc)\.(0|[1-9]\d*))?(?:\+[0-9A-Za-z.-]+)?$/.exec(version); + if (!match) { + throw new Error(`Unsupported release version for MSI: ${version}`); + } + + let major = Number(match[1]); + let minor = Number(match[2]); + let patch = Number(match[3]); + const prereleaseKind = match[4]; + const prereleaseNumber = match[5] === undefined ? undefined : Number(match[5]); + + if (major > 255 || minor > 255 || patch > 65_535) { + throw new Error(`Release version exceeds MSI numeric limits: ${version}`); + } + if (!prereleaseKind) return `${major}.${minor}.${patch}`; + if (prereleaseNumber > 9_999) { + throw new Error(`Prerelease sequence exceeds the MSI allocation: ${version}`); + } + + if (patch > 0) { + patch -= 1; + } else if (minor > 0) { + minor -= 1; + patch = 65_535; + } else if (major > 0) { + major -= 1; + minor = 255; + patch = 65_535; + } else { + throw new Error("An MSI prerelease cannot sort below version 0.0.0"); + } + + const build = prereleaseOffsets[prereleaseKind] + prereleaseNumber; + return `${major}.${minor}.${patch}.${build}`; +} + +export function buildWindowsReleaseConfig(baseConfig, version) { + return { + ...baseConfig, + bundle: { + ...baseConfig.bundle, + windows: { + ...baseConfig.bundle?.windows, + wix: { + ...baseConfig.bundle?.windows?.wix, + version: toMsiVersion(version), + }, + }, + }, + }; +} + +export function writeWindowsReleaseConfig({ baseConfigPath, version, destination }) { + const baseConfig = JSON.parse(fs.readFileSync(baseConfigPath, "utf8")); + const config = buildWindowsReleaseConfig(baseConfig, version); + fs.mkdirSync(path.dirname(destination), { recursive: true }); + fs.writeFileSync(destination, `${JSON.stringify(config, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + }); + return destination; +} + +const invokedFile = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === invokedFile) { + const [baseConfigPath, version, destination] = process.argv.slice(2); + if (!baseConfigPath || !version || !destination) { + throw new Error( + "Usage: node scripts/generate-windows-release-config.mjs ", + ); + } + writeWindowsReleaseConfig({ baseConfigPath, version, destination }); +} diff --git a/scripts/generate-windows-release-config.test.js b/scripts/generate-windows-release-config.test.js new file mode 100644 index 0000000..3640b21 --- /dev/null +++ b/scripts/generate-windows-release-config.test.js @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + buildWindowsReleaseConfig, + toMsiVersion, +} from "./generate-windows-release-config.mjs"; + +describe("Windows release configuration", () => { + it("maps ordered prereleases below their eventual stable MSI version", () => { + expect(toMsiVersion("0.1.0-alpha.1")).toBe("0.0.65535.10001"); + expect(toMsiVersion("1.2.3-beta.4")).toBe("1.2.2.20004"); + expect(toMsiVersion("1.0.0-rc.7")).toBe("0.255.65535.30007"); + expect(toMsiVersion("1.2.3")).toBe("1.2.3"); + }); + + it("preserves updater settings while adding the WiX override", () => { + expect(buildWindowsReleaseConfig({ + bundle: { createUpdaterArtifacts: true }, + }, "0.1.0-alpha.1")).toEqual({ + bundle: { + createUpdaterArtifacts: true, + windows: { + wix: { version: "0.0.65535.10001" }, + }, + }, + }); + }); + + it("fails closed for unsupported or non-representable prereleases", () => { + expect(() => toMsiVersion("0.1.0-preview.1")).toThrow(/Unsupported release version/); + expect(() => toMsiVersion("0.0.0-alpha.1")).toThrow(/cannot sort below/); + expect(() => toMsiVersion("0.1.0-alpha.10000")).toThrow(/exceeds the MSI allocation/); + }); +});