From ca9b18ca26b42c6609e44e8cf864a74e98b5bb85 Mon Sep 17 00:00:00 2001 From: GCWing Date: Sun, 15 Nov 2026 01:01:50 +0800 Subject: [PATCH] fix(release): stage Windows desktop resources before installer packaging --- .github/workflows/desktop-package.yml | 13 +++++++-- docs/development/releasing.md | 19 +++++++------ scripts/check-github-config.test.mjs | 13 ++++++--- scripts/desktop-tauri-build.test.mjs | 33 +++++++++++++++++++++- scripts/windows/stage-desktop-runtime.mjs | 34 +++++++++++++++++++++++ 5 files changed, 96 insertions(+), 16 deletions(-) create mode 100644 scripts/windows/stage-desktop-runtime.mjs diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index 5a793cd69f..2e2b4ca8a8 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -44,6 +44,7 @@ on: - all - macos-arm64 - macos-x64 + - windows-x64 relay_image_only: description: "Publish only the Relay Server image and signed descriptor (no Desktop packages)." required: false @@ -176,14 +177,14 @@ jobs: (!/^\d+(,\d+)*$/.test(process.env.ARTIFACT_RUN_IDS) || target !== 'all' || process.env.RELAY_IMAGE_ONLY === 'true')) { throw new Error('Artifact finalization requires run IDs, target all, and no relay_image_only'); } - if (!['all', 'macos-arm64', 'macos-x64'].includes(target)) { + if (!['all', 'macos-arm64', 'macos-x64', 'windows-x64'].includes(target)) { throw new Error(`Unsupported Desktop recovery target: ${target}`); } if (target !== 'all' && process.env.RELAY_IMAGE_ONLY === 'true') { throw new Error('A Desktop recovery target cannot be combined with relay_image_only'); } if (process.env.VERIFICATION_RECOVERY_RUN_ID && - (!/^\d+$/.test(process.env.VERIFICATION_RECOVERY_RUN_ID) || target === 'all' || + (!/^\d+$/.test(process.env.VERIFICATION_RECOVERY_RUN_ID) || !target.startsWith('macos-') || process.env.ARTIFACT_RUN_IDS || process.env.RELAY_IMAGE_ONLY === 'true' || process.env.UPLOAD_TO_RELEASE !== 'true')) { throw new Error('Verification recovery requires one Mac target, signing, and no other recovery mode'); @@ -458,6 +459,14 @@ jobs: $ErrorActionPreference = 'Stop' # The payload manifest must hash the already signed desktop executable. $env:OPENBITFUN_INSTALLER_APP_EXE = 'target/x86_64-pc-windows-msvc/release/openbitfun-desktop.exe' + # The pinned workflow supplies packaging tools even for an older source tag. + git fetch --no-tags --depth=1 origin '${{ github.workflow_sha }}' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + $stagingScript = Join-Path $env:RUNNER_TEMP 'stage-desktop-runtime.mjs' + git show '${{ github.workflow_sha }}:scripts/windows/stage-desktop-runtime.mjs' | Set-Content -Encoding utf8 $stagingScript + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node $stagingScript src/apps/desktop $env:OPENBITFUN_INSTALLER_APP_EXE + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } pnpm run installer:build:only if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } if ($env:WINDOWS_CERTIFICATE_THUMBPRINT) { diff --git a/docs/development/releasing.md b/docs/development/releasing.md index e8756c2f17..66aa149e58 100644 --- a/docs/development/releasing.md +++ b/docs/development/releasing.md @@ -37,8 +37,8 @@ publish. Keep the public `gcwing/openbitfun-relay-server` package and its existi digests available for older releases. Deploy the updated mirror script from the official checkout before the first -release in the organization namespace. The mirror accepts signed descriptors -from either official namespace and copies their bytes and signatures unchanged. +release in the organization namespace. The mirror synchronizes Desktop and CLI +downloads independently of the Relay image descriptor. Finalizing trusted Actions artifacts from before the transfer can also reuse their original signed descriptor and the Windows updater format built by the original source. The standalone Relay deploy script tries the @@ -51,20 +51,21 @@ For the optional Homebrew notification from `CLI Package`, set `HOMEBREW_TAP_DISPATCH_TOKEN`. Existing token-only configurations still target `GCWing/homebrew-tap`. -## Recovering one macOS package +## Recovering one Desktop package For an existing immutable tag, dispatch `Desktop Package` from the current -`main` workflow with `target_platform=macos-arm64` or `macos-x64`. Keep +`main` workflow with `target_platform=macos-arm64`, `macos-x64`, or `windows-x64`. Keep `release_channel` equal to the original build and enable `upload_to_release` to require release signing and produce updater signatures. This target option uploads only the selected platform's Actions artifact; it never publishes a GitHub Release, rebuilds Linux binaries, pushes the Relay image, or changes an update feed. `all`, the default, retains the complete release gate. -Recovery runs have a separate concurrency group per Mac target, so they can +Recovery runs have a separate concurrency group per target, so they can finish alongside an existing full matrix without cancelling it. Build source -still resolves to the original tag SHA. Signature verification uses the pinned -workflow revision, allowing a verification fix to apply without moving that tag. +still resolves to the original tag SHA. macOS signature verification and Windows +resource staging use the pinned workflow revision, allowing packaging fixes to +apply without moving that tag. After recovery, dispatch the same workflow with `target_platform=all`, the original tag/channel, `upload_to_release=true`, and `artifact_run_ids` containing the original and recovery run IDs separated by commas. This skips all builders @@ -247,8 +248,8 @@ Production cron must run this in-repo script from the OpenBitFun checkout. Do no create a detached copy. Host-specific Nginx and restore paths belong to your own deployment and are not published here. The sync resolves the exact release directory from the updater manifest once; -Relay and Linux metadata use that same directory to avoid mixed-version reads -when GitHub's latest-release pointer changes. Publication also compares the +Linux metadata uses that same directory to avoid mixed-version reads when +GitHub's latest-release pointer changes. GitHub publication compares the downloaded Relay descriptor and signature to the image job's signed bytes. ## Focused packaging checks diff --git a/scripts/check-github-config.test.mjs b/scripts/check-github-config.test.mjs index 8cc2529533..9dd1bc0431 100644 --- a/scripts/check-github-config.test.mjs +++ b/scripts/check-github-config.test.mjs @@ -1695,12 +1695,15 @@ test('stable and beta publication require every producer, while artifact-only ru }); -test('Desktop recovery selects only one Mac target and cannot publish an incomplete release', (t) => { +test('Desktop recovery selects one platform and cannot publish an incomplete release', (t) => { const workflow = yaml.parse(readFileSync(path.join(repoRoot, '.github/workflows/desktop-package.yml'), 'utf8')); const step = workflow.jobs.prepare.steps.find((entry) => entry.name === 'Select Desktop package platforms'); + const windows = workflow.jobs.package.steps.find((entry) => entry.name === 'Build and sign custom Windows installer'); + assert.match(windows.run, /git show '\$\{\{ github\.workflow_sha \}\}:scripts\/windows\/stage-desktop-runtime\.mjs'/); + assert.ok(windows.run.indexOf('node $stagingScript') < windows.run.indexOf('pnpm run installer:build:only')); const root = mkdtempSync(path.join(tmpdir(), 'openbitfun-platform-recovery-')); t.after(() => rmSync(root, { recursive: true, force: true })); - for (const target of ['all', 'macos-arm64', 'macos-x64', 'unknown']) { + for (const target of ['all', 'macos-arm64', 'macos-x64', 'windows-x64', 'unknown']) { for (const relay of ['true', 'false']) { const output = path.join(root, `${target}-${relay}`); const result = spawnSync('bash', ['-c', step.run], { @@ -1724,7 +1727,8 @@ test('Desktop recovery selects only one Mac target and cannot publish an incompl } for (const [target, relay, runs, success] of [ ['all', 'false', '123,456', true], ['all', 'false', 'not-a-run', false], - ['macos-arm64', 'false', '123', false], ['all', 'true', '123', false], + ['macos-arm64', 'false', '123', false], ['windows-x64', 'false', '123', false], + ['all', 'true', '123', false], ]) { const result = spawnSync('bash', ['-c', step.run], { cwd: repoRoot, encoding: 'utf8', windowsHide: true, @@ -1742,7 +1746,8 @@ test('Desktop recovery selects only one Mac target and cannot publish an incompl assert.equal(workflow.concurrency['cancel-in-progress'], false); for (const [target, upload, run, artifacts, success] of [ ['macos-x64', 'true', '123', '', true], ['macos-arm64', 'true', '123', '', true], - ['all', 'true', '123', '', false], ['macos-x64', 'false', '123', '', false], + ['all', 'true', '123', '', false], ['windows-x64', 'true', '123', '', false], + ['macos-x64', 'false', '123', '', false], ['macos-x64', 'true', 'invalid', '', false], ['macos-x64', 'true', '123', '456', false], ]) { const result = spawnSync('bash', ['-c', step.run], { diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 19a7534004..cf53d41e47 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict'; -import { mkdirSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; @@ -12,6 +12,7 @@ import { shouldRetryMacDmgBuild, } from './desktop-tauri-build.mjs'; import { resolveProductDefinition } from './product-customization/resolver.mjs'; +import { stageDesktopRuntime } from './windows/stage-desktop-runtime.mjs'; import { APPLE_SYSTEM_FONT_PROFILE, HARMONY_BUNDLED_FONT_PROFILE, @@ -22,6 +23,36 @@ const FAILED_BUILD = { status: 1 }; const DMG_ARGS = ['--target', 'x86_64-apple-darwin', '--bundles', 'app,dmg']; const ROOT = join(import.meta.dirname, '..'); +test('Windows installer stages generated Tauri resources beside the signed executable', (t) => { + const root = mkdtempSync(join(tmpdir(), 'openbitfun-windows-payload-')); + t.after(() => rmSync(root, { recursive: true, force: true })); + const desktop = join(root, 'desktop'); + const config = join(desktop, 'gen', `tauri.${'a'.repeat(64)}.generated.conf.json`); + const release = join(root, 'target', 'x86_64-pc-windows-msvc', 'release'); + const executable = join(release, 'openbitfun-desktop.exe'); + mkdirSync(join(desktop, 'gen'), { recursive: true }); + mkdirSync(join(root, 'dist', 'assets'), { recursive: true }); + mkdirSync(release, { recursive: true }); + writeFileSync(executable, 'signed desktop'); + writeFileSync(join(root, 'dist', 'index.html'), 'desktop frontend'); + writeFileSync(join(root, 'dist', 'assets', 'entry.js'), 'desktop assets'); + writeFileSync(join(root, 'worker.js'), 'worker'); + writeFileSync(config, JSON.stringify({ bundle: { resources: { + '../dist': 'frontend/dist', '../worker.js': 'resources/worker_host.js', + } } })); + + stageDesktopRuntime(desktop, executable); + assert.equal(readFileSync(executable, 'utf8'), 'signed desktop'); + assert.equal(readFileSync(join(release, 'frontend/dist/index.html'), 'utf8'), 'desktop frontend'); + assert.equal(readFileSync(join(release, 'frontend/dist/assets/entry.js'), 'utf8'), 'desktop assets'); + assert.equal(readFileSync(join(release, 'resources/worker_host.js'), 'utf8'), 'worker'); + assert.equal(existsSync(join(release, 'frontend/dist/dist')), false); + + writeFileSync(config, JSON.stringify({ bundle: { resources: { '../worker.js': '../outside.js' } } })); + assert.throws(() => stageDesktopRuntime(desktop, executable), /escapes the installer runtime/); + assert.equal(existsSync(join(release, '../outside.js')), false); +}); + test('desktop startup leaves installation registration to its maintenance owner', () => { const source = readFileSync(join(ROOT, 'src/apps/desktop/src/lib.rs'), 'utf8'); assert.doesNotMatch(source, /sync_install_location_registry|Synced Tauri install-location registry/); diff --git a/scripts/windows/stage-desktop-runtime.mjs b/scripts/windows/stage-desktop-runtime.mjs new file mode 100644 index 0000000000..0a7f0fddcb --- /dev/null +++ b/scripts/windows/stage-desktop-runtime.mjs @@ -0,0 +1,34 @@ +import { cpSync, readdirSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export function stageDesktopRuntime(desktopDir, executable) { + const generatedDir = join(desktopDir, 'gen'); + const configs = readdirSync(generatedDir).filter((name) => + /^tauri(?:\.[a-f0-9]{64})?\.generated\.conf\.json$/.test(name), + ); + if (configs.length !== 1) { + throw new Error(`Expected one generated Desktop bundle config in ${generatedDir}, found ${configs.length}`); + } + const config = JSON.parse(readFileSync(join(generatedDir, configs[0]), 'utf8')); + const resources = config.bundle?.resources; + if (!resources || Array.isArray(resources) || Object.keys(resources).length === 0) { + throw new Error('The generated Desktop bundle config must declare a resource map'); + } + + const output = dirname(resolve(executable)); + for (const [source, destination] of Object.entries(resources)) { + const target = resolve(output, destination); + const inside = relative(output, target); + if (!inside || inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { + throw new Error(`Desktop resource destination escapes the installer runtime: ${destination}`); + } + cpSync(resolve(desktopDir, source), target, { recursive: true }); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const [desktopDir, executable] = process.argv.slice(2); + if (!desktopDir || !executable) throw new Error('Usage: stage-desktop-runtime.mjs DESKTOP_DIR EXECUTABLE'); + stageDesktopRuntime(desktopDir, executable); +}