diff --git a/build/app-update.yml b/build/app-update.yml new file mode 100644 index 00000000..cc9cb6f0 --- /dev/null +++ b/build/app-update.yml @@ -0,0 +1,3 @@ +provider: generic +url: https://download.ncurator.com/updates/latest +updaterCacheDirName: ncurator-updater diff --git a/doc/release.md b/doc/release.md index 6b21e6a9..bd4a0c8f 100644 --- a/doc/release.md +++ b/doc/release.md @@ -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 时必须一起完成: diff --git a/electron-builder.yml b/electron-builder.yml index 3d3cddad..d324cbfd 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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. diff --git a/package.json b/package.json index 03cf025d..d6d0ab4f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/build-web-crawler-sidecar.mjs b/scripts/build-web-crawler-sidecar.mjs index 1ef618b1..d0ad10fc 100644 --- a/scripts/build-web-crawler-sidecar.mjs +++ b/scripts/build-web-crawler-sidecar.mjs @@ -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' diff --git a/scripts/finalize-r2-macos-release.mjs b/scripts/finalize-r2-macos-release.mjs index fc181058..5f5c7f6e 100644 --- a/scripts/finalize-r2-macos-release.mjs +++ b/scripts/finalize-r2-macos-release.mjs @@ -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() diff --git a/scripts/prepare-package-stage.mjs b/scripts/prepare-package-stage.mjs index f10d0191..611a7d80 100644 --- a/scripts/prepare-package-stage.mjs +++ b/scripts/prepare-package-stage.mjs @@ -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` ) } diff --git a/scripts/release-http.mjs b/scripts/release-http.mjs new file mode 100644 index 00000000..78ec4cd5 --- /dev/null +++ b/scripts/release-http.mjs @@ -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}`) +} diff --git a/scripts/release-macos-local.mjs b/scripts/release-macos-local.mjs index 8eb126b5..05637fbc 100644 --- a/scripts/release-macos-local.mjs +++ b/scripts/release-macos-local.mjs @@ -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', diff --git a/scripts/upload-r2-release-fragment.mjs b/scripts/upload-r2-release-fragment.mjs index 2dbf077e..6a38d09f 100644 --- a/scripts/upload-r2-release-fragment.mjs +++ b/scripts/upload-r2-release-fragment.mjs @@ -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}`) } diff --git a/scripts/verify-mac-update-signature.mjs b/scripts/verify-mac-update-signature.mjs index 796649f8..65da881d 100644 --- a/scripts/verify-mac-update-signature.mjs +++ b/scripts/verify-mac-update-signature.mjs @@ -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)) diff --git a/tests/case/settings/update/app-update-publish-config.test.ts b/tests/case/settings/update/app-update-publish-config.test.ts index cc5cbcad..c2474f87 100644 --- a/tests/case/settings/update/app-update-publish-config.test.ts +++ b/tests/case/settings/update/app-update-publish-config.test.ts @@ -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 () => {