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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions .github/workflows/desktop-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 10 additions & 9 deletions docs/development/releasing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions scripts/check-github-config.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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], {
Expand All @@ -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,
Expand All @@ -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], {
Expand Down
33 changes: 32 additions & 1 deletion scripts/desktop-tauri-build.test.mjs
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
Expand All @@ -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/);
Expand Down
34 changes: 34 additions & 0 deletions scripts/windows/stage-desktop-runtime.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
Loading