fix: make signed mac releases launchable
This commit is contained in:
1 parent
1da8269b56
commit
3bc06dde08
12 files changed
+170
-18
No files matched your search
@@ -0,0 +1,3 @@
|
||||
provider: generic
|
||||
url: https://download.ncurator.com/updates/latest
|
||||
updaterCacheDirName: ncurator-updater
|
||||
+6
-3
@@ -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 时必须一起完成:
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in new issue
Block a user