fix: make signed mac releases launchable

This commit is contained in:
hy committed 2026-07-15 13:31:00 +08:00
1 parent 1da8269b56
commit 3bc06dde08
12 files changed
+170 -18

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
provider: generic
url: https://download.ncurator.com/updates/latest
updaterCacheDirName: ncurator-updater
+6 -3
View File
@@ -75,8 +75,9 @@ R2 凭据、GitHub Token 和签名证书只能保存在本机安全环境或 Git
6. 删除旧 `dist` 和旧 staging;
7. 根据编译产物生成最小生产 `package.json`;
8. 使用主锁文件离线安装运行时依赖;
9. 删除测试、文档、源码、Source Map 和错误平台二进制;
10. 执行 staging 安全审计。
9. 根据实际安装结果写入完整的生产依赖闭包,防止打包器漏掉被提升的间接依赖;
10. 删除测试、文档、源码、Source Map 和错误平台二进制;
11. 执行 staging 安全审计。
electron-builder 完成后,`pnpm run package:audit` 检查真实 `.app` 或 `win-unpacked`。最终审计会验证:
@@ -89,6 +90,8 @@ electron-builder 完成后,`pnpm run package:audit` 检查真实 `.app` 或 `w
- ONNX Runtime 和 native prebuild 只保留当前平台与架构;
- 生成 `dist/package-audit.json`,记录安装目录、`app.asar` 和安装包体积。
macOS 正式产物还必须真实启动到窗口和 preload API 就绪。只通过文件审计、`codesign --verify` 或存在进程都不算可发布。
所有正式构建命令都包含最终审计。体积硬预算暂不启用,等 macOS arm64、macOS x64、Windows x64 都完成实测后再设置。
常用构建命令:
@@ -233,7 +236,7 @@ pnpm release:signing:setup
当前 macOS 使用项目自建的 `Ncurator Update Signing`,并设置 `timestamp: none`,因为 Apple 时间戳服务不接受该证书。这套签名用于保证两个架构更新包来自同一发布方,不等同于 Apple Developer ID 公证。
自建证书没有 Apple Team ID。sidecar 需要加载内置 Python Framework,因此子进程使用 `build/entitlements.mac.inherit.plist` 关闭 library validation;顶层 App 不使用这个例外。修改签名后必须真实运行打包后的 sidecar,不能只看 `codesign --verify`。
自建证书没有 Apple Team ID。macOS 会把不同签名对象视为不同 Team,因此顶层 App 和子进程都必须使用 `build/entitlements.mac.inherit.plist` 关闭 library validation,否则 dyld 会拒绝加载 Electron Framework 或 sidecar 的 Python Framework。正式发布在生成 fragment 前必须真实启动打包后的 App;不能只看 `codesign --verify`。
以后迁移 Apple Developer ID 时必须一起完成:
+5
View File
@@ -19,6 +19,8 @@ extraResources:
to: native
filter:
- '**/*'
- from: build/app-update.yml
to: app-update.yml
asarUnpack:
- '**/node_modules/onnxruntime-node/bin/napi-v3/**/*'
win:
@@ -49,6 +51,9 @@ mac:
target:
- dmg
- zip
# 自建证书没有 Apple Team ID。顶层进程和子进程都必须关闭 library validation,
# 否则 dyld 会拒绝加载已经签名的 Electron Framework。
entitlements: build/entitlements.mac.inherit.plist
entitlementsInherit: build/entitlements.mac.inherit.plist
extendInfo:
- NSCameraUsageDescription: Application requests access to the device's camera.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ncurator",
"version": "0.1.4",
"version": "0.1.5",
"description": "An Electron application with React and TypeScript",
"main": "./out/main/index.js",
"author": "example.com",
+4 -1
View File
@@ -19,7 +19,10 @@ const VENV_DIR = path.join(
)
const STAMP_PATH = path.join(VENV_DIR, '.requirements.sha256')
const PLAYWRIGHT_BROWSER_STAMP_PATH = path.join(VENV_DIR, '.playwright-browser.sha256')
const PIP_CACHE_DIR = path.join(ROOT, '.pip-cache', 'web_crawler_sidecar')
const PIP_CACHE_DIR = path.resolve(
String(process.env.NCURATOR_SIDECAR_PIP_CACHE || '').trim() ||
path.join(ROOT, '.pip-cache', 'web_crawler_sidecar')
)
const BIN_DIR = path.join(SIDECAR_DIR, 'bin')
const SOURCE_STAMP_PATH = path.join(BIN_DIR, '.source.sha256')
const SIDECAR_RUNTIME_LAYOUT_VERSION = 'headless-shell-v1'
+4 -2
View File
@@ -7,6 +7,7 @@ import process from 'node:process'
import { promisify } from 'node:util'
import { loadReleaseEnvironment } from './release-environment.mjs'
import { assertReleaseFragment } from './release-fragment-contract.mjs'
import { fetchWithRetry } from './release-http.mjs'
import { resolveAwsExecutable } from './windows-release-common.mjs'
const execFileAsync = promisify(execFile)
@@ -121,8 +122,9 @@ try {
'--no-progress'
])
const response = await fetch(
`${publicBaseUrl}/updates/latest/latest-mac.yml?release=${Date.now()}`
const response = await fetchWithRetry(
`${publicBaseUrl}/updates/latest/latest-mac.yml?release=${Date.now()}`,
{ cache: 'no-store' }
)
if (!response.ok) throw new Error(`latest-mac.yml verification failed: ${response.status}`)
const publicMetadata = await response.text()
+42 -5
View File
@@ -80,6 +80,38 @@ const installRuntimeDependencies = () => {
}
}
const collectInstalledTopLevelDependencies = () => {
const nodeModules = path.join(STAGE_DIR, 'node_modules')
const packageDirectories = []
for (const entry of fs.readdirSync(nodeModules, { withFileTypes: true })) {
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
const entryPath = path.join(nodeModules, entry.name)
if (!entry.name.startsWith('@')) {
packageDirectories.push(entryPath)
continue
}
for (const scopedEntry of fs.readdirSync(entryPath, { withFileTypes: true })) {
if (scopedEntry.isDirectory()) packageDirectories.push(path.join(entryPath, scopedEntry.name))
}
}
const dependencies = {}
for (const directory of packageDirectories) {
const packageJsonPath = path.join(directory, 'package.json')
if (!fs.existsSync(packageJsonPath)) continue
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))
const name = String(packageJson.name || '').trim()
const version = String(packageJson.version || '').trim()
if (!name || !version) {
throw new Error(`installed runtime package has invalid metadata: ${directory}`)
}
dependencies[name] = version
}
return Object.fromEntries(
Object.entries(dependencies).sort(([left], [right]) => left.localeCompare(right))
)
}
const REMOVE_DIRECTORY_NAMES = new Set([
'.changeset',
'.circleci',
@@ -159,6 +191,9 @@ const isLegalNotice = (name) =>
/^(?:license|licence|notice|copying)(?:[._-].*)?$/u.test(name) ||
/^(?:apache2?|bsd|mit)$/u.test(name)
const isDocumentationFile = (name) =>
/^(?:readme|changelog|history)(?:\.(?:adoc|md|markdown|rst|txt))?$/u.test(name)
const shouldRemoveFile = (filePath) => {
const name = path.basename(filePath).toLowerCase()
if (isLegalNotice(name)) return false
@@ -166,10 +201,7 @@ const shouldRemoveFile = (filePath) => {
return (
/\.(?:spec|test)\.[^.]+$/u.test(name) ||
/^tsconfig(?:\.[^.]+)?\.json$/u.test(name) ||
name === 'readme' ||
name.startsWith('readme.') ||
name.startsWith('changelog.') ||
name.startsWith('history.') ||
isDocumentationFile(name) ||
name === '.npmignore' ||
name === '.ds_store' ||
name === '.editorconfig' ||
@@ -401,6 +433,11 @@ const main = () => {
writeStageLockfile(runtimeDependencies)
installRuntimeDependencies()
const packagedDependencies = collectInstalledTopLevelDependencies()
fs.writeFileSync(
path.join(STAGE_DIR, 'package.json'),
`${JSON.stringify({ ...stagePackage, dependencies: packagedDependencies }, null, 2)}\n`
)
fs.rmSync(path.join(STAGE_DIR, 'pnpm-lock.yaml'), { force: true })
pruneCompiledOutput()
pruneNodeModules()
@@ -416,7 +453,7 @@ const main = () => {
if (audit.status !== 0) throw new Error('staging package security audit failed')
process.stdout.write(
`[package] staging ready for ${process.platform}-${process.arch}; runtime roots: ${runtimePackageNames.join(', ')}\n`
`[package] staging ready for ${process.platform}-${process.arch}; runtime roots: ${runtimePackageNames.join(', ')}; packaged dependencies: ${String(Object.keys(packagedDependencies).length)}\n`
)
}
+25
View File
@@ -0,0 +1,25 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
export const fetchWithRetry = async (url, options = {}, retryOptions = {}) => {
const attempts = Number(retryOptions.attempts || 5)
const delayMs = Number(retryOptions.delayMs || 2_000)
let lastError
let lastResponse
for (let attempt = 1; attempt <= attempts; attempt += 1) {
try {
const response = await fetch(url, options)
lastResponse = response
if (response.ok || response.status < 500 || attempt === attempts) return response
} catch (error) {
lastError = error
if (attempt === attempts) throw error
}
await delay(delayMs * attempt)
}
if (lastResponse) return lastResponse
throw lastError || new Error(`request failed: ${url}`)
}
+14 -4
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { execFile, spawn } from 'node:child_process'
import { access, mkdir, readdir, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
@@ -77,6 +78,7 @@ const validatePreflight = async () => {
}
const defaultToolRoot = path.join(process.env.HOME || '', '.ncurator', 'release-tools')
const sidecarBrowserCacheRoot = path.join(defaultToolRoot, 'sidecar-browser-cache')
const sidecarPipCacheRoot = path.join(defaultToolRoot, 'sidecar-pip-cache')
const awsExecutable =
String(process.env.NCURATOR_AWS_CLI || '').trim() ||
path.join(defaultToolRoot, 'awscli', 'bin', 'aws')
@@ -135,7 +137,8 @@ const validatePreflight = async () => {
x64Node,
x64Python,
pnpmPath: pnpmPath.trim(),
sidecarBrowserCacheRoot
sidecarBrowserCacheRoot,
sidecarPipCacheRoot
}
}
@@ -168,7 +171,11 @@ const createAndUploadFragment = async (sourceDirectory, platform) => {
}
const preflight = await validatePreflight()
const workDirectory = path.join(ROOT, '.codex-tmp', 'desktop-release', releaseTag)
const workRoot = path.resolve(
String(process.env.NCURATOR_RELEASE_WORK_ROOT || '').trim() ||
path.join(os.tmpdir(), 'ncurator-macos-release')
)
const workDirectory = path.join(workRoot, releaseTag)
const archivePath = path.join(workDirectory, 'source.tar')
const armSource = path.join(workDirectory, 'arm64')
const x64Source = path.join(workDirectory, 'x64')
@@ -187,7 +194,8 @@ try {
cwd: armSource,
env: {
...process.env,
NCURATOR_SIDECAR_BROWSER_CACHE: path.join(preflight.sidecarBrowserCacheRoot, 'darwin-arm64')
NCURATOR_SIDECAR_BROWSER_CACHE: path.join(preflight.sidecarBrowserCacheRoot, 'darwin-arm64'),
NCURATOR_SIDECAR_PIP_CACHE: path.join(preflight.sidecarPipCacheRoot, 'darwin-arm64')
}
})
await createAndUploadFragment(armSource, 'macos-arm64')
@@ -196,8 +204,10 @@ try {
const x64Environment = {
...process.env,
PATH: `${path.dirname(preflight.x64Node)}:${process.env.PATH || ''}`,
PIP_CERT: String(process.env.PIP_CERT || '').trim() || '/private/etc/ssl/cert.pem',
NCURATOR_SIDECAR_BUILD_PYTHON: preflight.x64Python,
NCURATOR_SIDECAR_BROWSER_CACHE: path.join(preflight.sidecarBrowserCacheRoot, 'darwin-x64')
NCURATOR_SIDECAR_BROWSER_CACHE: path.join(preflight.sidecarBrowserCacheRoot, 'darwin-x64'),
NCURATOR_SIDECAR_PIP_CACHE: path.join(preflight.sidecarPipCacheRoot, 'darwin-x64')
}
await run(
'/usr/bin/arch',
+5 -1
View File
@@ -8,6 +8,7 @@ import process from 'node:process'
import { promisify } from 'node:util'
import { loadReleaseEnvironment } from './release-environment.mjs'
import { assertReleaseFragment } from './release-fragment-contract.mjs'
import { fetchWithRetry } from './release-http.mjs'
const execFileAsync = promisify(execFile)
await loadReleaseEnvironment(process.cwd())
@@ -85,7 +86,10 @@ for (const file of fragment.files) {
throw new Error(`R2 verification failed: ${key}`)
}
const publicResponse = await fetch(`${publicBaseUrl}/${key}`, { method: 'HEAD' })
const publicResponse = await fetchWithRetry(`${publicBaseUrl}/${key}`, {
method: 'HEAD',
cache: 'no-store'
})
if (!publicResponse.ok || Number(publicResponse.headers.get('content-length')) !== file.size) {
throw new Error(`public R2 verification failed: ${key}`)
}
+47 -1
View File
@@ -1,7 +1,11 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { execFile } from 'node:child_process'
import { writeFile } from 'node:fs/promises'
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import process from 'node:process'
import { promisify } from 'node:util'
import { _electron as electron } from 'playwright'
const execFileAsync = promisify(execFile)
const [appPath = '', outputPath = ''] = process.argv.slice(2)
@@ -34,6 +38,48 @@ if (!/designated => .*identifier "com\.yoan\.ncurator"/mu.test(signatureDetails)
throw new Error('macOS release has an unexpected designated requirement')
}
const verifyPackagedAppLaunch = async () => {
const runtimeDirectory = await mkdtemp(path.join(os.tmpdir(), 'ncurator-launch-check-'))
const userDataDirectory = path.join(runtimeDirectory, 'user-data')
const homeDirectory = path.join(runtimeDirectory, 'home')
await mkdir(userDataDirectory, { recursive: true })
await mkdir(homeDirectory, { recursive: true })
const executable = path.join(appPath, 'Contents', 'MacOS', 'Ncurator')
let output = ''
let electronApp
try {
electronApp = await electron.launch({
executablePath: executable,
env: {
...process.env,
HOME: homeDirectory,
NCURATOR_E2E_OBSERVER: '1',
NCURATOR_E2E_WINDOW_MODE: 'background',
NCURATOR_USER_DATA_PATH: userDataDirectory
},
timeout: 60_000
})
const child = electronApp.process()
child?.stdout?.on('data', (chunk) => {
output += String(chunk)
})
child?.stderr?.on('data', (chunk) => {
output += String(chunk)
})
const page = await electronApp.firstWindow({ timeout: 60_000 })
await page.waitForFunction(() => Boolean(window.api), undefined, { timeout: 60_000 })
process.stdout.write('[release] packaged macOS app launch verification passed\n')
} catch (error) {
throw new Error(`packaged macOS app did not become ready: ${String(error)}\n${output}`)
} finally {
await electronApp?.close().catch(() => undefined)
await rm(runtimeDirectory, { recursive: true, force: true })
}
}
await verifyPackagedAppLaunch()
const identity = signatureDetails
.split('\n')
.filter((line) => /^(Authority=|designated =>)/u.test(line))
@@ -85,7 +85,16 @@ describe('app update publish config', () => {
expect(prepareRelease).not.toContain('test:update:e2e')
})
it('keeps the complete installed production dependency closure', async () => {
const packageStage = await readFile(path.resolve('scripts/prepare-package-stage.mjs'), 'utf8')
expect(packageStage).toContain('collectInstalledTopLevelDependencies')
expect(packageStage).toContain('dependencies: packagedDependencies')
expect(packageStage).not.toContain("name.startsWith('history.')")
})
it('requires local Rosetta builds and stable macOS signatures', async () => {
const builderConfig = await readFile(path.resolve('electron-builder.yml'), 'utf8')
const localRelease = await readFile(path.resolve('scripts/release-macos-local.mjs'), 'utf8')
const signatureVerifier = await readFile(
path.resolve('scripts/verify-mac-update-signature.mjs'),
@@ -95,8 +104,13 @@ describe('app update publish config', () => {
expect(localRelease).toContain('NCURATOR_X64_NODE')
expect(localRelease).toContain('NCURATOR_X64_PYTHON')
expect(localRelease).toContain("'-x86_64'")
expect(localRelease).toContain("path.join(os.tmpdir(), 'ncurator-macos-release')")
expect(localRelease).toContain('NCURATOR_SIDECAR_PIP_CACHE')
expect(builderConfig).toContain('entitlements: build/entitlements.mac.inherit.plist')
expect(builderConfig).toContain('from: build/app-update.yml')
expect(signatureVerifier).toContain('macOS release must not use an ad-hoc signature')
expect(signatureVerifier).toContain('macOS designated requirement must remain stable')
expect(signatureVerifier).toContain('packaged macOS app launch verification passed')
})
it('keeps only R2 as the release binary source', async () => {