From a98c6aae788a8388a62437375c203ef50e4abdab Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 12 Oct 2026 18:01:44 +0800 Subject: [PATCH 1/3] build: manage macOS runner service and optional notarization --- .github/workflows/macos-package.yml | 26 +++++--- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 16 ++++- apps/desktop/packaging/README.zh.md | 16 ++++- .../desktop/scripts/desktop-build-paths.d.mts | 1 + apps/desktop/scripts/desktop-build-paths.mjs | 3 +- .../scripts/desktop-package-environment.d.mts | 2 +- .../scripts/desktop-package-environment.mjs | 24 ++++---- .../scripts/electron-builder-config.mjs | 15 +++-- apps/desktop/scripts/macos-runner-service.sh | 61 +++++++++++++++++++ apps/desktop/scripts/package-target.ts | 39 +++++++++--- apps/desktop/scripts/prepare-macos-ci.mjs | 7 ++- .../desktop/scripts/smoke-packaged-runtime.ts | 4 +- .../scripts/start-macos-packaging-runner.sh | 13 ++-- .../desktop/tests/desktop-build-paths.spec.ts | 1 + .../tests/desktop-package-environment.spec.ts | 16 +++++ .../tests/macos-package-preset.spec.ts | 36 +++++++++++ apps/desktop/tests/macos-signature.spec.ts | 17 ++++++ .../tests/package-target-stages.spec.ts | 24 ++++++++ apps/desktop/tests/package-target.spec.ts | 9 +++ scripts/macos-package-workflow.spec.ts | 13 +++- 21 files changed, 297 insertions(+), 50 deletions(-) create mode 100644 apps/desktop/scripts/macos-runner-service.sh diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index fe1ad516c6..28dac193e0 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -9,6 +9,11 @@ on: options: [test, production] default: test required: true + skip_notarization: + description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates' + type: boolean + default: false + required: true build_version: description: 'Version: auto recommended; explicit version must match the checkout product prefix' type: string @@ -30,6 +35,7 @@ jobs: run: shell: bash env: + PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} DSH_TELEMETRY_DISABLED: '1' DSH_PACKAGING_PRESET: ${{ inputs.preset }} steps: @@ -54,11 +60,17 @@ jobs: - name: Check packaging configuration and toolchain env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} - run: pnpm run package:desktop:mac:arm64 --check --build-version "$PACKAGE_VERSION" - - name: Build signed and notarized installers + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:arm64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build signed installers env: PACKAGE_VERSION: ${{ steps.settings.outputs.version }} - run: pnpm run package:desktop:mac:arm64 --build-version "$PACKAGE_VERSION" + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:arm64 --build-version "$PACKAGE_VERSION" "${args[@]}" - name: Retain packaging diagnostics if: always() && steps.checkout.outcome == 'success' uses: actions/upload-artifact@v7.0.1 @@ -68,14 +80,14 @@ jobs: path: apps/desktop/.desktop-build/packaging-runs/ if-no-files-found: warn retention-days: 14 - - name: Retain verified installers + - name: Retain installers for the selected mode if: success() uses: actions/upload-artifact@v7.0.1 with: - name: macos-arm64-signed-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + name: macos-arm64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} include-hidden-files: true path: | - apps/desktop/.desktop-build/targets/mac-arm64/artifacts/*.dmg - apps/desktop/.desktop-build/targets/mac-arm64/artifacts/*.zip + apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.dmg + apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.zip if-no-files-found: error retention-days: 14 diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 00a03f782a..7febe0b770 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -27,8 +27,8 @@ en: e3f51e7130a0fe05 zh: 70fd6a40d5899871 /desktop-packaging/macos-self-hosted-packaging: - en: 1929f652d7654d7e - zh: bab90dfb9523ef6a + en: 7fed0945686f721c + zh: 821f0ba4ca07f9fc /desktop-packaging/unsigned-windows-test-installer: en: 6843a0fdc03ead58 zh: 957f92fc8126e23e diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 37f3b0151d..9dfff86c49 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -111,11 +111,23 @@ Open the completed run in Actions and download its Artifacts. `windows-x64-signe The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. -Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. +Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior. -Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/ci/runner" "$HOME/ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C; service installation is separate. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). +Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). +The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. + +Install the user service from the checkout as the same account that registered the runner, without `sudo`: + +```sh +bash apps/desktop/scripts/macos-runner-service.sh install "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" /opt/homebrew/opt/node@24/bin +bash apps/desktop/scripts/macos-runner-service.sh start "$HOME/dsh-ci/runner" +bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" +``` + +The installer copies its startup script into `runner/dsh-service`, sets the service PATH to Node 24 plus system tools, and uses GitHub's `svc.sh` and `runsvc.sh`. The LaunchAgent survives SSH disconnection, but requires that account's desktop login after reboot and stops on desktop logout. FileVault disk unlock remains an operator step; this does not provide unattended boot. Logs are `runner/dsh-service/stdout.log` and `stderr.log`; verify `Connected to GitHub` and `Listening for Jobs`. To update installed startup code or tool paths, wait until the runner is idle, run `stop`, then `uninstall`, and repeat installation. Neither action unregisters the runner or removes packaging credentials. + ## Unsigned Windows test installer diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 8f3aafd37c..f61aed4816 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -111,11 +111,23 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS `Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 -运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 +运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。 -运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/ci/runner" "$HOME/ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。Ctrl-C 停止,服务安装单独进行。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 +运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 +工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 + +在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: + +```sh +bash apps/desktop/scripts/macos-runner-service.sh install "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" /opt/homebrew/opt/node@24/bin +bash apps/desktop/scripts/macos-runner-service.sh start "$HOME/dsh-ci/runner" +bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" +``` + +安装脚本将启动脚本复制到 `runner/dsh-service`,将服务 PATH 设为 Node 24 和系统工具,并使用 GitHub 的 `svc.sh` 与 `runsvc.sh`。LaunchAgent 在 SSH 断开后继续运行,但重启后需要该账户登录桌面,注销桌面时停止。FileVault 磁盘解锁仍由操作人员完成,这不提供无人值守开机。日志位于 `runner/dsh-service/stdout.log` 和 `stderr.log`;验收时确认出现 `Connected to GitHub` 与 `Listening for Jobs`。更新已安装的启动脚本或工具路径时,等待 runner 空闲,依次执行 `stop`、`uninstall`,再重新安装。这两个操作都不会注销 runner 或删除打包凭据。 + ## 未签名 Windows 测试安装包 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index 9a8e408ed1..d07541f4a2 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -5,6 +5,7 @@ export interface DesktopTargetBuildPaths { readonly root: string readonly artifacts: string readonly unsignedArtifacts: string + readonly unnotarizedArtifacts: string readonly runtime: string readonly packageSet: string readonly dsh: string diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index 4f33e804c3..b11a3ea249 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -38,7 +38,7 @@ function assertSupportedTarget(target) { /** * Return the mutable preparation and artifact directories owned by one release target. * @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target - Supported Desktop target name. - * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. + * @returns {{ root: string, artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string, runtime: string, packageSet: string, dsh: string, dshPnpm: string, electron: string, packedDsh: string, packedVendor: string, packedLandlock: string, downloads: string }} Target paths plus the shared immutable download cache. */ export function desktopTargetBuildPaths(target) { assertSupportedTarget(target) @@ -48,6 +48,7 @@ export function desktopTargetBuildPaths(target) { root, artifacts: join(root, 'artifacts'), unsignedArtifacts: join(root, 'unsigned-artifacts'), + unnotarizedArtifacts: join(root, 'unnotarized-artifacts'), runtime: join(root, 'runtime'), packageSet: join(root, 'package-set'), dsh: join(root, 'dsh'), diff --git a/apps/desktop/scripts/desktop-package-environment.d.mts b/apps/desktop/scripts/desktop-package-environment.d.mts index 37931911d2..b8c6887657 100644 --- a/apps/desktop/scripts/desktop-package-environment.d.mts +++ b/apps/desktop/scripts/desktop-package-environment.d.mts @@ -32,5 +32,5 @@ export function loadDesktopPackageEnvironment( export function validateDesktopPackageEnvironment( environment: NodeJS.ProcessEnv, target: { platform: 'win32' | 'darwin', arch: string }, - options?: { unsigned?: boolean, prepareOnly?: boolean }, + options?: { unsigned?: boolean, prepareOnly?: boolean, skipNotarization?: boolean }, ): void diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index ce8357a9e6..f3522be26d 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -122,7 +122,7 @@ function requireReadableFile(environment, name) { * Validate the packaging mode before preparation without invoking a token or Apple's services. * @param {NodeJS.ProcessEnv} environment Selected local defaults or file-owned release settings. * @param {{ platform: 'win32' | 'darwin', arch: string }} target Selected release target. - * @param {{ unsigned?: boolean, prepareOnly?: boolean }} options Explicit packaging mode. + * @param {{ unsigned?: boolean, prepareOnly?: boolean, skipNotarization?: boolean }} options Explicit packaging mode. * @returns {void} */ export function validateDesktopPackageEnvironment(environment, target, options = {}) { @@ -144,17 +144,19 @@ export function validateDesktopPackageEnvironment(environment, target, options = } else { if (environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR !== undefined) validateMacOSSignatureCacheDirectory(environment.DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR) resolveMacOSSigningEnvironment(environment) - const strategies = [ - ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'], - ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'], - ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'], - ] - if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) { - throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies') + if (!options.skipNotarization) { + const strategies = [ + ['APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_TEAM_ID'], + ['APPLE_API_KEY', 'APPLE_API_KEY_ID', 'APPLE_API_ISSUER'], + ['APPLE_KEYCHAIN_PROFILE', 'APPLE_KEYCHAIN'], + ] + if (strategies.filter(names => names.some(name => environment[name] !== undefined)).length > 1) { + throw new Error('desktop package: configure exactly one macOS notarization strategy; comment out the other strategies') + } + const credentials = resolveMacOSNotarizationEnvironment(environment) + if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY') + if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN') } - const credentials = resolveMacOSNotarizationEnvironment(environment) - if ('appleApiKey' in credentials) requireReadableFile(environment, 'APPLE_API_KEY') - if ('keychain' in credentials) requireReadableFile(environment, 'APPLE_KEYCHAIN') requireReadableFile(environment, 'CSC_LINK') if (environment.CSC_KEY_PASSWORD === undefined) { throw new Error('desktop package: CSC_KEY_PASSWORD must be set to the p12 export password (use an explicit empty value for an unencrypted p12)') diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 747d20d841..73acffecdc 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -58,12 +58,17 @@ export function createElectronBuilderConfig( throw new Error('desktop package: DSH_DESKTOP_UNSIGNED must be 0 or 1') } const unsigned = env.DSH_DESKTOP_UNSIGNED === '1' + const skipNotarization = env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION === '1' + if (env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION !== undefined && !['0', '1'].includes(env.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION)) { + throw new Error('desktop package: DSH_DESKTOP_MACOS_SKIP_NOTARIZATION must be 0 or 1') + } + if (skipNotarization && (unsigned || resolvedPlatform !== 'darwin')) throw new Error('Skipping notarization requires a signed macOS build') const packagesMacOS = targetPlatform === 'darwin' || (targetPlatform === undefined && hostPlatform === 'darwin') const packagesWindows = resolvedPlatform === 'win32' const policy = packagesMacOS && unsigned ? undefined : resolveDesktopPolicyEnvironment(env) if (resolvedPlatform === 'win32') installWindowsDirectoryInstaller() const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined - if (packagesMacOS && !unsigned) resolveMacOSNotarizationEnvironment(env) + if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env) const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) let primaryRuntimeDestination let dshDestination @@ -107,8 +112,8 @@ export function createElectronBuilderConfig( }, productName: 'DeepSeek Harness', // Unsigned builds carry their own suffix so a shared file can never pass for a release artifact. - artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${unsigned ? '-unsigned' : ''}.\${ext}`, - directories: { output: unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }, + artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${unsigned ? '-unsigned' : skipNotarization ? '-unnotarized' : ''}.\${ext}`, + directories: { output: unsigned ? buildPaths.unsignedArtifacts : skipNotarization ? buildPaths.unnotarizedArtifacts : buildPaths.artifacts }, asar: true, electronDist: buildPaths.electron, electronFuses: { runAsNode: true }, @@ -162,7 +167,7 @@ export function createElectronBuilderConfig( entitlementsInherit: fileURLToPath(new URL('./macos-entitlements.plist', import.meta.url)), // Prepared runtime files retain their signatures; PAK resources are sealed by their enclosing bundle. signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], - notarize: !unsigned, + notarize: !unsigned && !skipNotarization, target: unsigned ? ['dmg'] : ['dmg', 'zip'], }, dmg: { @@ -215,7 +220,7 @@ export function createElectronBuilderConfig( verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env)) }, artifactBuildCompleted: artifact => { - if (unsigned || !artifact.file.endsWith('.dmg')) return + if (unsigned || skipNotarization || !artifact.file.endsWith('.dmg')) return return notarizeMacOSDiskImageArtifact( artifact, env, diff --git a/apps/desktop/scripts/macos-runner-service.sh b/apps/desktop/scripts/macos-runner-service.sh new file mode 100644 index 0000000000..7785fa09e1 --- /dev/null +++ b/apps/desktop/scripts/macos-runner-service.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# Manage a registered runner in the current user's logged-in macOS session. +set -euo pipefail +if [[ $# -lt 2 ]]; then + echo 'Usage: bash macos-runner-service.sh install RUNNER_ROOT CONFIG_ROOT NODE_BIN | start|stop|status|uninstall RUNNER_ROOT' >&2 + exit 1 +fi +action=$1 +runner_root=$2 +[[ $(uname -s) = Darwin && $EUID -ne 0 ]] || { echo 'Run as the registered macOS runner user, without sudo' >&2; exit 1; } +[[ "$runner_root" = /* && -f "$runner_root/.runner" && -x "$runner_root/svc.sh" ]] || { echo 'Provide an absolute registered runner directory' >&2; exit 1; } +source_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +cd "$runner_root" +case "$action" in + install) + [[ $# = 4 ]] || { echo 'install requires RUNNER_ROOT CONFIG_ROOT NODE_BIN' >&2; exit 1; } + config_root=$3 + node_bin=$4 + [[ ! -f .service ]] || { echo 'Service already installed; stop and uninstall it before reinstalling' >&2; exit 1; } + launchctl print "gui/$(id -u)" >/dev/null || { echo 'Log into the macOS desktop as this user first' >&2; exit 1; } + bash "$source_dir/start-macos-packaging-runner.sh" "$runner_root" "$config_root" "$node_bin" --check + umask 077 + service_dir="$runner_root/dsh-service" + mkdir -p "$service_dir" + cp "$source_dir/start-macos-packaging-runner.sh" "$service_dir/start.sh" + # runsvc.sh reloads .path before launching RunnerService.js. + printf '%s\n' "$node_bin:/usr/bin:/bin:/usr/sbin:/sbin" > .path + "$node_bin/node" --input-type=module - "$service_dir" "$runner_root" "$config_root" "$node_bin" <<'NODE' +import { writeFileSync } from 'node:fs' +const [directory, runner, config, node] = process.argv.slice(2) +const xml = value => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''') +// svc.sh substitutes its own placeholders with sed; prohibit template syntax in paths. +for (const value of [directory, runner, config, node]) { + if (/[\r\n]|\{\{/u.test(value)) throw new Error('Service paths cannot contain newlines or template placeholders') +} +const args = ['/bin/bash', `${directory}/start.sh`, runner, config, node, '--service'] +writeFileSync(`${directory}/service.plist.template`, ` + + +Label{{SvcName}} +ProgramArguments${args.map(arg => `${xml(arg)}`).join('')} +WorkingDirectory${xml(runner)} +RunAtLoad +StandardOutPath${xml(directory)}/stdout.log +StandardErrorPath${xml(directory)}/stderr.log +EnvironmentVariablesACTIONS_RUNNER_SVC1 +ProcessTypeInteractive +SessionCreate + +`, { mode: 0o600 }) +NODE + GITHUB_ACTIONS_RUNNER_SERVICE_TEMPLATE="$service_dir/service.plist.template" ./svc.sh install + /usr/bin/plutil -lint "$(cat .service)" + echo 'Installed. Run start, then status. This LaunchAgent requires a desktop login after reboot.' + ;; + start|stop|status|uninstall) + [[ $# = 2 && -f .service ]] || { echo 'Provide an installed runner service directory' >&2; exit 1; } + ./svc.sh "$action" + ;; + *) echo "Unknown action: $action" >&2; exit 1 ;; +esac diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 505b7c7598..bc31c091ab 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -196,6 +196,7 @@ interface DesktopPackageInvocation { readonly directory: boolean readonly prepareOnly: boolean readonly unsigned: boolean + readonly skipNotarization: boolean readonly check: boolean /** Build identifier to publish under, when this build does not publish the product version. */ readonly requestedBuildVersion: string | undefined @@ -228,6 +229,7 @@ export function parseDesktopPackageInvocation( dir: { type: 'boolean', default: false }, 'prepare-only': { type: 'boolean', default: false }, unsigned: { type: 'boolean', default: false }, + 'skip-notarization': { type: 'boolean', default: false }, check: { type: 'boolean', default: false }, 'build-version': { type: 'string' }, }, @@ -235,6 +237,9 @@ export function parseDesktopPackageInvocation( if (positionals.length > 1) throw new Error('desktop package: expected at most one target') const name = positionals[0] ?? hostTargetName(hostPlatform, hostArch) if (values.unsigned && values['prepare-only']) throw new Error('desktop package: --unsigned cannot use --prepare-only') + if (values['skip-notarization'] && (name === 'win-x64' || values.unsigned || values['prepare-only'])) { + throw new Error('desktop package: --skip-notarization requires a signed macOS build, without --prepare-only') + } const requestedBuildVersion = values['build-version']?.trim() if (values['build-version'] !== undefined && (requestedBuildVersion === undefined || requestedBuildVersion === '')) { throw new Error('desktop package: --build-version requires a value') @@ -244,6 +249,7 @@ export function parseDesktopPackageInvocation( directory: values.dir, prepareOnly: values['prepare-only'], unsigned: values.unsigned, + skipNotarization: values['skip-notarization'], check: values.check, requestedBuildVersion, } @@ -326,18 +332,23 @@ async function resolveRequestedBuildVersion( // Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts. environment: environment.DSH_PACKAGING_PRESET === undefined ? environment : {}, // Unsigned builds land beside the signed output, so numbering has to read the directory this run writes. - artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts, + artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts + : invocation.skipNotarization ? paths.unnotarizedArtifacts : paths.artifacts, }) } async function main(): Promise { const invocation = parseDesktopPackageInvocation(process.argv.slice(2)) const { target } = invocation + if (invocation.skipNotarization) { + process.stdout.write('desktop package: skipping notarization and stapling; internal debugging only, not qualified for distribution or update publishing\n') + } const environment = target.platform === 'darwin' && invocation.unsigned ? localMacOSPackageEnvironment() : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { unsigned: invocation.unsigned, prepareOnly: invocation.prepareOnly, }) + environment.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION = invocation.skipNotarization ? '1' : '0' const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') // Release settings come from the selected configuration files, so the version this run publishes is an // argument; the environment variable below only carries it to the child processes that build. @@ -354,7 +365,8 @@ async function main(): Promise { Object.assign(environment, desktopBuildCommitEnvironment(packaged)) const secrets = Object.entries(environment).filter(([name]) => /KEY|SECRET|TOKEN|PASSWORD|APPLE_ID/iu.test(name)).map(([, value]) => value ?? '') const run = createPackagingRun(join(APP_ROOT, '.desktop-build', 'packaging-runs'), { - target: target.name, unsigned: invocation.unsigned, directory: invocation.directory, prepareOnly: invocation.prepareOnly, + target: target.name, unsigned: invocation.unsigned, skipNotarization: invocation.skipNotarization, + directory: invocation.directory, prepareOnly: invocation.prepareOnly, version: buildVersion, productVersion, node: process.version, commit: packaged.commit, dirty: packaged.dirty, @@ -389,8 +401,8 @@ async function main(): Promise { } if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) { const paths = desktopTargetBuildPaths(target.name) - const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${invocation.unsigned ? '-unsigned' : ''}.dmg` - console.log(`DMG: ${join(invocation.unsigned ? paths.unsignedArtifacts : paths.artifacts, filename)}`) + const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${invocation.unsigned ? '-unsigned' : invocation.skipNotarization ? '-unnotarized' : ''}.dmg` + console.log(`DMG: ${join(invocation.unsigned ? paths.unsignedArtifacts : invocation.skipNotarization ? paths.unnotarizedArtifacts : paths.artifacts, filename)}`) } } @@ -414,14 +426,18 @@ export async function packageTarget( const packArguments = mac === undefined ? [] : ['--concurrency', String(mac.packConcurrency)] const buildPaths = desktopTargetBuildPaths(target.name) const releaseRecordPath = join(buildPaths.artifacts, desktopBuildRecordFilename(target.name)) - if (!invocation.prepareOnly && !invocation.unsigned) { + if (!invocation.prepareOnly && !invocation.unsigned && !invocation.skipNotarization) { rmSync(releaseRecordPath, { force: true }) rmSync(`${releaseRecordPath}.tmp`, { force: true }) } + const artifactsRoot = invocation.unsigned ? buildPaths.unsignedArtifacts + : invocation.skipNotarization ? buildPaths.unnotarizedArtifacts : buildPaths.artifacts + if (invocation.skipNotarization) rmSync(artifactsRoot, { recursive: true, force: true }) const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials( desktopElectronBuilderEnvironment(environment, invocation.unsigned))) const targetEnv: NodeJS.ProcessEnv = { ...buildEnv, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: invocation.skipNotarization ? '1' : '0', DSH_DESKTOP_TARGET_PLATFORM: target.platform, DSH_DESKTOP_TARGET_ARCH: target.arch, } @@ -485,7 +501,7 @@ export async function packageTarget( await execute(['run', 'prepare:dsh', ...(signPrimaryRuntime ? ['--defer-runtime-smoke'] : [])], downloadEnv) if (signPrimaryRuntime) await execute(['run', 'sign:primary-runtime', '--dsh'], electronBuilderEnv) if (invocation.prepareOnly) return - if (target.platform === 'darwin' && !invocation.unsigned && !invocation.directory) { + if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization && !invocation.directory) { await execute([ ...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false', @@ -498,7 +514,7 @@ export async function packageTarget( artifactsRoot: buildPaths.artifacts, environment: electronBuilderEnv, }, artifact => execute(desktopElectronBuilderArguments(target, false, artifact), electronBuilderEnv)), undefined, undefined, proxyEvent) - } else if (target.platform === 'darwin' && !invocation.unsigned) { + } else if (target.platform === 'darwin' && !invocation.unsigned && !invocation.skipNotarization) { await execute([...desktopElectronBuilderArguments(target, true), '--config.mac.notarize=false'], electronBuilderEnv) await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts'], targetEnv) const appPath = join(buildPaths.artifacts, target.arch === 'arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app') @@ -506,10 +522,13 @@ export async function packageTarget( () => notarizeMacOS({ appPath, ...resolveMacOSNotarizationEnvironment(environment) }), undefined, undefined, proxyEvent) } else { await signedStage('artifacts', () => execute(desktopElectronBuilderArguments(target, invocation.directory), electronBuilderEnv)) - await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', ...(invocation.unsigned ? ['--unsigned'] : [])], targetEnv) + await execute(['exec', 'tsx', 'scripts/smoke-packaged-runtime.ts', + ...(invocation.unsigned ? ['--unsigned'] : []), ...(invocation.skipNotarization ? ['--skip-notarization'] : [])], targetEnv) } - if (!invocation.directory && !invocation.unsigned) writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) - if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: invocation.unsigned ? buildPaths.unsignedArtifacts : buildPaths.artifacts }) + if (!invocation.directory && !invocation.unsigned && !invocation.skipNotarization) { + writeReleaseRecord(target, electronBuilderEnv, buildPaths.artifacts) + } + if (journal) recordPackagingEvent(journal, { type: 'artifacts', directory: artifactsRoot }) } if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) await main() diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs index 318d334828..5f6e37becb 100644 --- a/apps/desktop/scripts/prepare-macos-ci.mjs +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -8,6 +8,8 @@ const appRoot = resolve(import.meta.dirname, '..') const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) const version = desktopCiVersion(process.env, product.version) if (!process.env.DSH_PACKAGING_PRESET) throw new Error('Select a machine preset') +const skipNotarization = process.env.PACKAGE_SKIP_NOTARIZATION === 'true' +if (!['true', 'false', undefined].includes(process.env.PACKAGE_SKIP_NOTARIZATION)) throw new Error('Invalid skip notarization selection') const environment = loadDesktopPackageEnvironment('darwin', process.env, appRoot) for (const [name, value] of Object.entries(environment)) { if (/KEY|TOKEN|PASSWORD|SECRET|APPLE_ID/iu.test(name) && value) { @@ -15,7 +17,8 @@ for (const [name, value] of Object.entries(environment)) { process.stdout.write(`::add-mask::${escaped}\n`) } } -validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }) +validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') -appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`) +appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed-notarized'}\n`) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) +if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n') diff --git a/apps/desktop/scripts/smoke-packaged-runtime.ts b/apps/desktop/scripts/smoke-packaged-runtime.ts index b91030008b..3594eb6af7 100644 --- a/apps/desktop/scripts/smoke-packaged-runtime.ts +++ b/apps/desktop/scripts/smoke-packaged-runtime.ts @@ -8,10 +8,10 @@ import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' import { resolveDesktopPackageTarget } from './package-target.ts' const paths = resolveDesktopTargetBuildPaths() -const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false } }, allowPositionals: false }) +const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false }, 'skip-notarization': { type: 'boolean', default: false } }, allowPositionals: false }) const target = resolveDesktopBuildTarget() const windows = target === 'win-x64' -const artifacts = values.unsigned ? paths.unsignedArtifacts : paths.artifacts +const artifacts = values.unsigned ? paths.unsignedArtifacts : values['skip-notarization'] ? paths.unnotarizedArtifacts : paths.artifacts const application = windows ? join(artifacts, 'win-unpacked') : join(artifacts, target === 'mac-arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app', 'Contents') const resources = join(application, windows ? 'resources' : 'Resources') diff --git a/apps/desktop/scripts/start-macos-packaging-runner.sh b/apps/desktop/scripts/start-macos-packaging-runner.sh index a89c3f3ca4..a171001a4a 100644 --- a/apps/desktop/scripts/start-macos-packaging-runner.sh +++ b/apps/desktop/scripts/start-macos-packaging-runner.sh @@ -1,8 +1,8 @@ #!/bin/bash # Run an already registered Apple Silicon runner with explicit tools and machine configuration. set -euo pipefail -if [[ $# -lt 3 || $# -gt 4 || (${4:-} != '' && ${4:-} != '--check') ]]; then - echo 'Usage: bash start-macos-packaging-runner.sh RUNNER_ROOT CONFIG_ROOT NODE_BIN [--check]' >&2 +if [[ $# -lt 3 || $# -gt 4 || (${4:-} != '' && ${4:-} != '--check' && ${4:-} != '--service') ]]; then + echo 'Usage: bash start-macos-packaging-runner.sh RUNNER_ROOT CONFIG_ROOT NODE_BIN [--check|--service]' >&2 exit 1 fi runner_root=$1 @@ -29,9 +29,14 @@ if [[ ${4:-} = '--check' ]]; then exit 0 fi [[ -f "$runner_root/.runner" && -x "$runner_root/run.sh" ]] || { echo 'Register the runner before starting' >&2; exit 1; } +entry=./run.sh +if [[ ${4:-} = '--service' ]]; then + [[ -x "$runner_root/runsvc.sh" ]] || { echo 'Install the runner service first' >&2; exit 1; } + entry=./runsvc.sh +fi cd "$runner_root" exec /usr/bin/env -i HOME="$HOME" USER="$(id -un)" LOGNAME="$(id -un)" \ PATH="$node_bin:/usr/bin:/bin:/usr/sbin:/sbin" TMPDIR="${TMPDIR:-/tmp}" \ DEVELOPER_DIR="$developer_dir" SDKROOT="$sdk_root" \ - LANG=en_US.UTF-8 DSH_PACKAGING_CONFIG_DIR="$config_root" DSH_TELEMETRY_DISABLED=1 \ - /usr/bin/caffeinate -i ./run.sh + ACTIONS_RUNNER_SVC="${ACTIONS_RUNNER_SVC:-0}" LANG=en_US.UTF-8 DSH_PACKAGING_CONFIG_DIR="$config_root" DSH_TELEMETRY_DISABLED=1 \ + /usr/bin/caffeinate -i "$entry" diff --git a/apps/desktop/tests/desktop-build-paths.spec.ts b/apps/desktop/tests/desktop-build-paths.spec.ts index a5e9faf296..ede5c5e3d3 100644 --- a/apps/desktop/tests/desktop-build-paths.spec.ts +++ b/apps/desktop/tests/desktop-build-paths.spec.ts @@ -16,6 +16,7 @@ describe('desktop build paths', () => { 'root', 'artifacts', 'unsignedArtifacts', + 'unnotarizedArtifacts', 'runtime', 'packageSet', 'dsh', diff --git a/apps/desktop/tests/desktop-package-environment.spec.ts b/apps/desktop/tests/desktop-package-environment.spec.ts index 450258d4f7..1c3eb02fc0 100644 --- a/apps/desktop/tests/desktop-package-environment.spec.ts +++ b/apps/desktop/tests/desktop-package-environment.spec.ts @@ -237,3 +237,19 @@ it('rejects an explicit invalid macOS cache before signing while leaving unsigne } expect(() => { validateDesktopPackageEnvironment(environment, MACOS, { unsigned: true }) }).not.toThrow() }) + +it('skips Apple credentials but still requires signing credentials for unnotarized packages', async () => { + await withDirectory(async (directory) => { + const certificate = join(directory, 'signing.p12') + await writeFile(certificate, 'fixture') + const environment = { ...RELEASE, ...MAC_IDENTITY, CSC_LINK: certificate, CSC_KEY_PASSWORD: '' } + expect(() => { validateDesktopPackageEnvironment(environment, MACOS, { skipNotarization: true }) }).not.toThrow() + expect(() => { validateDesktopPackageEnvironment(environment, MACOS) }).toThrow() + expect(() => { + validateDesktopPackageEnvironment({ ...environment, CSC_LINK: undefined }, MACOS, { skipNotarization: true }) + }).toThrow(/CSC_LINK/u) + expect(() => { + validateDesktopPackageEnvironment({ ...environment, CSC_KEY_PASSWORD: undefined }, MACOS, { skipNotarization: true }) + }).toThrow(/CSC_KEY_PASSWORD/u) + }) +}) diff --git a/apps/desktop/tests/macos-package-preset.spec.ts b/apps/desktop/tests/macos-package-preset.spec.ts index 78eecf40d8..60d67146f0 100644 --- a/apps/desktop/tests/macos-package-preset.spec.ts +++ b/apps/desktop/tests/macos-package-preset.spec.ts @@ -59,3 +59,39 @@ it.skipIf(process.platform !== 'darwin')('prepares private machine directories w await rm(root, { recursive: true, force: true }) } }) + +it('records debugging artifact paths only for an explicit CI selection without notarization credentials', async () => { + const root = await mkdtemp(join(tmpdir(), 'macos-ci-settings-')) + try { + await mkdir(join(root, 'presets')) + await mkdir(join(root, 'secrets')) + await writeFile(join(root, 'certificate.p12'), 'fixture') + await writeFile(join(root, 'presets/test.env'), [ + 'DSH_DESKTOP_APP_ID=com.example.fixture', 'DSH_DESKTOP_AUTO_UPDATE_ENV=test', + 'DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN=https://policy.example.com', + 'DSH_DESKTOP_MANDATORY_UPDATE_CONFIG={"allowedAuthOrigins":["https://login.example.com"]}', + 'DOWNLOAD_TEST_ORIGIN=https://updates.example.com', 'DOWNLOAD_TEST_RELEASE_ID=0123456789abcdef0123456789abcdef', + ].join('\n')) + await writeFile(join(root, 'secrets/macos-signing.env'), [ + 'CSC_LINK=../certificate.p12', 'CSC_KEY_PASSWORD=fixture-password', + 'DSH_DESKTOP_MACOS_SIGNING_IDENTITY=Example Company (TEAMID1234)', 'DSH_DESKTOP_MACOS_TEAM_ID=TEAMID1234', + ].join('\n')) + const output = join(root, 'output') + const summary = join(root, 'summary') + const run = (skip: string) => spawnSync(process.execPath, + [join(import.meta.dirname, '../scripts/prepare-macos-ci.mjs')], { encoding: 'utf8', env: { + ...process.env, DSH_PACKAGING_CONFIG_DIR: root, DSH_PACKAGING_PRESET: 'test', + PACKAGE_SKIP_NOTARIZATION: skip, PACKAGE_BUILD_VERSION: 'auto', + GITHUB_RUN_NUMBER: '1', GITHUB_RUN_ATTEMPT: '1', GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, + } }) + expect(run('false').status).toBe(1) + expect(run('yes').status).toBe(1) + const debugging = run('true') + expect(debugging.stderr).toBe('') + expect(debugging.status).toBe(0) + expect(await readFile(output, 'utf8')).toContain('artifacts=unnotarized-artifacts\nmode=unnotarized-debug\n') + expect(await readFile(summary, 'utf8')).toContain('Internal debugging only') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 186b597b07..7718b6e6bf 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -255,3 +255,20 @@ describe('desktop macOS release signature', () => { expect(verified).toEqual(['/tmp/release.dmg']) }) }) + +it('signs debugging artifacts without Apple credentials and isolates them from releases', async () => { + const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs') + const config = createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, + APPLE_API_KEY: undefined, APPLE_API_KEY_ID: undefined, APPLE_API_ISSUER: undefined, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', + }, 'darwin', 'arm64') + expect(config.mac.forceCodeSigning).toBe(true) + expect(config.mac.notarize).toBe(false) + expect(config.dmg.sign).toBe(true) + expect(config.mac).toMatchObject({ target: ['dmg', 'zip'] }) + expect(config.artifactName).toContain('-unnotarized.') + expect(portablePath(config.directories.output)).toContain('/unnotarized-artifacts') + expect(config.artifactBuildCompleted({ file: 'debug.dmg' })).toBeUndefined() + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: 'yes' }, 'darwin', 'arm64')).toThrow(/must be 0 or 1/u) + expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', DSH_DESKTOP_UNSIGNED: '1' }, 'darwin', 'arm64')).toThrow(/requires a signed macOS build/u) +}) diff --git a/apps/desktop/tests/package-target-stages.spec.ts b/apps/desktop/tests/package-target-stages.spec.ts index cde1b0079d..1d8b067390 100644 --- a/apps/desktop/tests/package-target-stages.spec.ts +++ b/apps/desktop/tests/package-target-stages.spec.ts @@ -197,3 +197,27 @@ it('does not write a release completion record when Apple proxy cleanup fails', .rejects.toThrow('proxy restoration failed') expect(writeFileSync).not.toHaveBeenCalled() }) + +it.each([false, true])('keeps signing and runtime verification when notarization is skipped (directory=%s)', async (directory) => { + const { run, stages } = supervisor() + await packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', ...(directory ? ['--dir'] : [])], 'darwin', 'arm64'), { + ...environment, CSC_LINK: 'signing.p12', CSC_KEY_PASSWORD: 'fixture-password', + }, run) + expect(stages.at(-2)).toBe(`exec electron-builder --config electron-builder.config.mjs --mac --arm64 --publish never${directory ? ' --dir' : ''}`) + expect(stages.at(-1)).toBe('exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization') + expect(run.run.mock.calls.at(-2)![3].env).toMatchObject({ + DSH_DESKTOP_UNSIGNED: '0', DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', CSC_LINK: 'signing.p12', + }) + expect(withMacOSNotarizationProxy).not.toHaveBeenCalled() + expect(packageMacOSArtifacts).not.toHaveBeenCalled() + expect(writeFileSync).not.toHaveBeenCalled() +}) + +it('fails debugging packages when runtime verification fails', async () => { + const failure = 'exec tsx scripts/smoke-packaged-runtime.ts --skip-notarization' + const { run, stages } = supervisor(failure) + await expect(packageTarget(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64'), environment, run)) + .rejects.toThrow('stage refused') + expect(stages.at(-1)).toBe(failure) + expect(writeFileSync).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/package-target.spec.ts b/apps/desktop/tests/package-target.spec.ts index 5019841e84..ba647d76ac 100644 --- a/apps/desktop/tests/package-target.spec.ts +++ b/apps/desktop/tests/package-target.spec.ts @@ -148,3 +148,12 @@ describe('desktop package target', () => { }) }) }) + +it('allows skipping notarization only for signed macOS packages', () => { + expect(parseDesktopPackageInvocation(['mac-arm64'], 'darwin', 'arm64').skipNotarization).toBe(false) + expect(parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization'], 'darwin', 'arm64').skipNotarization).toBe(true) + for (const flag of ['--unsigned', '--prepare-only']) { + expect(() => parseDesktopPackageInvocation(['mac-arm64', '--skip-notarization', flag], 'darwin', 'arm64')).toThrow(/requires a signed macOS build/u) + } + expect(() => parseDesktopPackageInvocation(['win-x64', '--skip-notarization'], 'win32', 'x64')).toThrow(/requires a signed macOS build/u) +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index 66047cc3cd..b3d778e1dc 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -25,5 +25,16 @@ it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silic expect(uploads).toHaveLength(2) expect(uploads[0]?.if).toBe("always() && steps.checkout.outcome == 'success'") expect(uploads[1]?.if).toBe('success()') - expect(uploads[1]?.with?.path).toBe('apps/desktop/.desktop-build/targets/mac-arm64/artifacts/*.dmg\napps/desktop/.desktop-build/targets/mac-arm64/artifacts/*.zip\n') + expect(uploads[1]?.with?.path).toBe('apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.dmg\napps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.zip\n') +}) + + +it('defaults to notarization and labels the explicit debugging option', () => { + expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { skip_notarization: { + type: 'boolean', default: false, + description: 'Skip notarization/stapling: internal debugging only, not for distribution or updates', + } } }) + const steps = workflow.jobs.package!.steps + expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2) + expect(steps.at(-1)?.with?.name).toContain('${{ steps.settings.outputs.mode }}') }) From 01283f1fdda9b9d07a34ed6891b0c39ec1e2b63a Mon Sep 17 00:00:00 2001 From: kingwl Date: Sat, 10 Oct 2026 17:23:16 +0800 Subject: [PATCH 2/3] perf(desktop): compress macOS DMGs with LZMA --- THIRD_PARTY_NOTICES.md | 1 + apps/desktop/electron-builder.config.d.mts | 1 + apps/desktop/packaging/README.i18n.yaml | 4 +-- apps/desktop/packaging/README.md | 2 ++ apps/desktop/packaging/README.zh.md | 2 ++ .../scripts/electron-builder-config.mjs | 1 + apps/desktop/scripts/runtime-patch-policy.ts | 1 + apps/desktop/tests/macos-signature.spec.ts | 24 +++++++++++++++++ .../tests/prepare-runtime-patches.spec.ts | 2 +- patches/app-builder-lib@26.17.0.patch | 27 +++++++++++++++++++ pnpm-lock.yaml | 11 ++++---- pnpm-workspace.yaml | 1 + 12 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 patches/app-builder-lib@26.17.0.patch diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 3fd65e81a2..ede0681303 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -146,6 +146,7 @@ pnpm applies local patches to the following packages at install time, so shipped - `@fortune-sheet/core@1.0.4` — [`patches/@fortune-sheet__core@1.0.4.patch`](patches/@fortune-sheet__core@1.0.4.patch) - `@fortune-sheet/react@1.0.4` — [`patches/@fortune-sheet__react@1.0.4.patch`](patches/@fortune-sheet__react@1.0.4.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) +- `app-builder-lib@26.17.0` — [`patches/app-builder-lib@26.17.0.patch`](patches/app-builder-lib@26.17.0.patch) - `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index a4d0739791..0682e6e9ad 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -30,6 +30,7 @@ export interface DesktopElectronBuilderConfig { readonly signIgnore: readonly string[] } readonly dmg: { + readonly format: 'ULMO' readonly sign: boolean readonly writeUpdateInfo: boolean } diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 00a03f782a..2f49b09ee5 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -21,8 +21,8 @@ en: d80a92c02a583b80 zh: b27f75ff29fcea1a /desktop-packaging/configuration-and-local-builds: - en: e8b8bb1e287bff80 - zh: 09b38712c8e974bc + en: e4f526604231ed43 + zh: d412179ffdc402ba /desktop-packaging/windows-actions-packaging: en: e3f51e7130a0fe05 zh: 70fd6a40d5899871 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 37f3b0151d..6f5f5bf096 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -93,6 +93,8 @@ pnpm run package:desktop:win:x64 The macOS arm64 command requires Apple Silicon. The macOS x64 command runs on Intel macOS or Apple Silicon with Rosetta. The Windows x64 command requires Windows x64. Linux is not a supported Desktop release target. +Both release and local unsigned macOS DMGs use LZMA compression (`dmg.format: 'ULMO'`) in [the electron-builder configuration](../scripts/electron-builder-config.mjs). A [build-time patch](../../../patches/app-builder-lib@26.17.0.patch) adds this format to the pinned builder's validation and TypeScript declarations. + Each target owns its packed package inputs, prepared runtime, package set, dsh tree, pnpm preparation state, unpacked application, update metadata, and final artifacts under `apps/desktop/.desktop-build/targets//`. The Electron archive cache remains shared under `.desktop-build/downloads` because every archive name includes its version, platform, and architecture and is verified before extraction. A target build never consumes another target's mutable preparation state. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 8f3aafd37c..7e8a916dca 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -93,6 +93,8 @@ pnpm run package:desktop:win:x64 macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 或带 Rosetta 的 Apple Silicon 上运行。Windows x64 命令要求 Windows x64。Linux 不是受支持的 Desktop 发布目标。 +macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../scripts/electron-builder-config.mjs)中的 `dmg.format: 'ULMO'` 使用 LZMA 压缩。[构建时补丁](../../../patches/app-builder-lib@26.17.0.patch)为固定版本 builder 的校验规则和 TypeScript 声明添加此格式。 + 每个目标都在 `apps/desktop/.desktop-build/targets//` 下持有自己的打包输入、已准备运行时、包集合、dsh 依赖树、pnpm 准备状态、未打包应用、更新元数据和最终产物。Electron 归档缓存继续由 `.desktop-build/downloads` 共享,因为每个归档文件名都包含版本、平台和架构,并且在解包前经过验证。目标构建绝不读取其他目标的可变准备状态。 diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 747d20d841..02e906b12f 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -166,6 +166,7 @@ export function createElectronBuilderConfig( target: unsigned ? ['dmg'] : ['dmg', 'zip'], }, dmg: { + format: 'ULMO', sign: !unsigned, writeUpdateInfo: false, }, diff --git a/apps/desktop/scripts/runtime-patch-policy.ts b/apps/desktop/scripts/runtime-patch-policy.ts index f688db356d..5281f9fe35 100644 --- a/apps/desktop/scripts/runtime-patch-policy.ts +++ b/apps/desktop/scripts/runtime-patch-policy.ts @@ -13,6 +13,7 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly Promise +} + const RELEASE_ENVIRONMENT = { DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_MANDATORY_UPDATE_TEST_ORIGIN: 'https://policy.example.com', @@ -75,6 +81,7 @@ describe('desktop macOS release signature', () => { signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], }, dmg: { + format: 'ULMO', sign: true, writeUpdateInfo: false, }, @@ -87,6 +94,23 @@ describe('desktop macOS release signature', () => { expect(typeof config.artifactBuildCompleted).toBe('function') }) + it('accepts LZMA DMGs through builder validation while rejecting unknown formats', async () => { + const { createElectronBuilderConfig } = await import('../scripts/electron-builder-config.mjs') + for (const arch of ['arm64', 'x64']) { + for (const unsigned of ['0', '1']) { + const config = createElectronBuilderConfig({ + ...RELEASE_ENVIRONMENT, + DSH_DESKTOP_TARGET_ARCH: arch, + DSH_DESKTOP_UNSIGNED: unsigned, + }, 'darwin', arch) + expect(config.dmg.format).toBe('ULMO') + await expect(validateConfiguration(config, { isEnabled: false })).resolves.toBeUndefined() + } + } + await expect(validateConfiguration({ dmg: { format: 'invalid' } }, { isEnabled: false })) + .rejects.toThrow('configuration.dmg.format') + }) + it('seals PAK resources with their enclosing bundle while signing executable code', async () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') diff --git a/apps/desktop/tests/prepare-runtime-patches.spec.ts b/apps/desktop/tests/prepare-runtime-patches.spec.ts index 951724e114..ce95ca7638 100644 --- a/apps/desktop/tests/prepare-runtime-patches.spec.ts +++ b/apps/desktop/tests/prepare-runtime-patches.spec.ts @@ -43,7 +43,7 @@ it('classifies the current workspace and shares only runtime patch bytes', () => const before = readFileSync(join(repository, 'pnpm-lock.yaml')) const plan = prepareRuntimePatches(project, repository) expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/pi-ai@1.0.2', 'node-pty@1.2.0-beta.15']) - expect(plan.workspaceOnly).toHaveLength(5) + expect(plan.workspaceOnly).toHaveLength(6) const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record } expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined() expect(settings.overrides['@earendil-works/pi-ai@^1.0.2']).toBe('1.0.2') diff --git a/patches/app-builder-lib@26.17.0.patch b/patches/app-builder-lib@26.17.0.patch new file mode 100644 index 0000000000..20f71cef1a --- /dev/null +++ b/patches/app-builder-lib@26.17.0.patch @@ -0,0 +1,27 @@ +diff --git a/out/options/macOptions.d.ts b/out/options/macOptions.d.ts +index e93987d973399b17f3970c858abb49f5e4b193d6..445a73c81e1d7eb61dc318373d2331487934d3f5 100644 +--- a/out/options/macOptions.d.ts ++++ b/out/options/macOptions.d.ts +@@ -258,7 +258,7 @@ export interface DmgOptions extends TargetSpecificOptions { + * The disk image format. `ULFO` (lzfse-compressed image (OS X 10.11+ only)). + * @default UDZO + */ +- format?: "UDRW" | "UDRO" | "UDCO" | "UDZO" | "UDBZ" | "ULFO"; ++ format?: "UDRW" | "UDRO" | "UDCO" | "UDZO" | "UDBZ" | "ULFO" | "ULMO"; + /** + * The filesystem for the DMG volume (e.g. `"APFS"` or `"HFS+"`) + * This will be changed to APFS in the next major release, so it is recommended to set it explicitly to HFS+ if you want to keep using HFS+ (e.g. for better compatibility with older macOS versions). +diff --git a/scheme.json b/scheme.json +index a637bab2668ef88e7382dd4aa5db62b74fdde898..ebcafac58971bdd7c69b78ceddcd401a75ffaa35 100644 +--- a/scheme.json ++++ b/scheme.json +@@ -979,7 +979,8 @@ + "UDRO", + "UDRW", + "UDZO", +- "ULFO" ++ "ULFO", ++ "ULMO" + ], + "type": "string" + }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9624ebcac4..af4a24cee8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,6 +15,7 @@ patchedDependencies: '@fortune-sheet/core@1.0.4': 8791a230763dbf853fd65d627c68c31de37209992851377855e16cf08ea70532 '@fortune-sheet/react@1.0.4': 2932e52e79afcd4ced08cfa123b5eded786c85043da32f622f3e80af6fc96c37 '@yao-pkg/pkg@6.21.0': 75891fc22b277c145cafeab83e41baf4a70f5b8fe6dc0801c6a0b922d7ee88c7 + app-builder-lib@26.17.0: b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348 exceljs@4.4.0: c1bf8ac8f319dab382f08e3a669b715e691d46c703ac5a4163eb383fbedd1d48 node-pty@1.2.0-beta.15: 9d30053266d0f39d3debe894c675867f6782da6bcadbc94a693b3cfb9eff8ed0 @@ -722,7 +723,7 @@ importers: version: 8.18.1 app-builder-lib: specifier: 26.17.0 - version: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0) + version: 26.17.0(patch_hash=b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348)(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0) cos-nodejs-sdk-v5: specifier: 3.0.0 version: 3.0.0 @@ -26194,7 +26195,7 @@ snapshots: anynum@1.0.1: {} - app-builder-lib@26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0): + app-builder-lib@26.17.0(patch_hash=b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348)(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0): dependencies: '@electron/asar': 3.4.1 '@electron/fuses': 1.8.0 @@ -27057,7 +27058,7 @@ snapshots: dmg-builder@26.17.0(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0): dependencies: - app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0) + app-builder-lib: 26.17.0(patch_hash=b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348)(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0) builder-util: 26.16.0(supports-color@9.4.0) fs-extra: 10.1.0 js-yaml: 4.3.2 @@ -27108,7 +27109,7 @@ snapshots: electron-builder-squirrel-windows@26.17.0(dmg-builder@26.17.0)(supports-color@9.4.0): dependencies: - app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0) + app-builder-lib: 26.17.0(patch_hash=b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348)(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0) builder-util: 26.16.0(supports-color@9.4.0) electron-winstaller: 5.4.0(supports-color@9.4.0) transitivePeerDependencies: @@ -27117,7 +27118,7 @@ snapshots: electron-builder@26.17.0(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0): dependencies: - app-builder-lib: 26.17.0(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0) + app-builder-lib: 26.17.0(patch_hash=b70dc2a2a78ffabb80a4f14fafc7c103237f2b0a207e1f296fa1c1387ea29348)(dmg-builder@26.17.0)(electron-builder-squirrel-windows@26.17.0)(supports-color@9.4.0) builder-util: 26.16.0(supports-color@9.4.0) builder-util-runtime: 9.7.0(supports-color@9.4.0) chalk: 4.1.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 98e9be027b..735a5e5cf5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -111,5 +111,6 @@ patchedDependencies: '@fortune-sheet/core@1.0.4': patches/@fortune-sheet__core@1.0.4.patch '@fortune-sheet/react@1.0.4': patches/@fortune-sheet__react@1.0.4.patch '@yao-pkg/pkg@6.21.0': patches/@yao-pkg__pkg@6.21.0.patch + app-builder-lib@26.17.0: patches/app-builder-lib@26.17.0.patch exceljs@4.4.0: patches/exceljs@4.4.0.patch node-pty@1.2.0-beta.15: patches/node-pty@1.2.0-beta.15.patch From 13793942248e7165a81f338ab19edc8011525c92 Mon Sep 17 00:00:00 2001 From: winewill Date: Mon, 12 Oct 2026 19:11:01 +0800 Subject: [PATCH 3/3] fix: verify and number unnotarized macOS artifacts --- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 4 +- apps/desktop/packaging/README.zh.md | 4 +- .../desktop/scripts/desktop-build-paths.d.mts | 11 ++++ apps/desktop/scripts/desktop-build-paths.mjs | 12 ++++ .../desktop-build-version-discovery.ts | 4 +- .../scripts/electron-builder-config.mjs | 15 +++-- apps/desktop/scripts/macos-runner-service.sh | 2 +- apps/desktop/scripts/package-target.ts | 15 ++--- apps/desktop/scripts/prepare-macos-ci.mjs | 2 +- .../desktop/scripts/smoke-packaged-runtime.ts | 4 +- .../scripts/verify-macos-signature.d.mts | 7 +++ .../scripts/verify-macos-signature.mjs | 16 ++++- .../desktop-build-version-discovery.spec.ts | 10 ++++ .../tests/macos-disk-image-signature.spec.ts | 58 +++++++++++++++++++ .../tests/macos-package-preset.spec.ts | 8 ++- apps/desktop/tests/macos-signature.spec.ts | 1 - scripts/macos-package-workflow.spec.ts | 1 - 18 files changed, 145 insertions(+), 33 deletions(-) create mode 100644 apps/desktop/tests/macos-disk-image-signature.spec.ts diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 7febe0b770..c63f29afe0 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -27,8 +27,8 @@ en: e3f51e7130a0fe05 zh: 70fd6a40d5899871 /desktop-packaging/macos-self-hosted-packaging: - en: 7fed0945686f721c - zh: 821f0ba4ca07f9fc + en: a3af9523fdc60036 + zh: 5e18d4740e9b7f57 /desktop-packaging/unsigned-windows-test-installer: en: 6843a0fdc03ead58 zh: 957f92fc8126e23e diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 9dfff86c49..f92e2b1dcd 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -116,7 +116,7 @@ Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prep Run `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` to check the host tools and configuration files. The script selects the active Xcode and its macOS SDK together and compiles a small linker probe. After registration, omit `--check` to run in the foreground with a cleared inherited environment and prevent idle sleep while the runner is alive. Stop it with Ctrl-C before installing the service below. The runner owns its `_work` checkout, separate from daily development. The account can read its signing files, so run only trusted code. The existing notarization proxy restoration rules apply; after forced termination, inspect retained recovery records before another build. New setup templates set `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` to the absolute `cache/macos-signatures` directory outside checkout. For existing installations, rerun setup and add that field to each preset; existing files are preserved. This retains signatures across clean checkouts; cache verification and limits are described under [macOS signing](#macos-release-signing). -The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. +The `skip_notarization` workflow option defaults to `false`; the equivalent CLI flag is `--skip-notarization`. It retains Developer ID signing, DMG signature verification and packaged-runtime verification, but omits Apple notarization and stapling and does not require Apple notarization credentials. DMG/ZIP filenames carry `-unnotarized`, are stored under `.desktop-build/targets//unnotarized-artifacts/`, and are uploaded with an `unnotarized-debug` label. These artifacts are for internal debugging only: Gatekeeper may block downloaded copies, and they are not qualified for distribution or update publishing. No release completion record is written. The flag rejects unsigned, Windows and preparation-only builds. Install the user service from the checkout as the same account that registered the runner, without `sudo`: @@ -126,7 +126,7 @@ bash apps/desktop/scripts/macos-runner-service.sh start "$HOME/dsh-ci/runner" bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" ``` -The installer copies its startup script into `runner/dsh-service`, sets the service PATH to Node 24 plus system tools, and uses GitHub's `svc.sh` and `runsvc.sh`. The LaunchAgent survives SSH disconnection, but requires that account's desktop login after reboot and stops on desktop logout. FileVault disk unlock remains an operator step; this does not provide unattended boot. Logs are `runner/dsh-service/stdout.log` and `stderr.log`; verify `Connected to GitHub` and `Listening for Jobs`. To update installed startup code or tool paths, wait until the runner is idle, run `stop`, then `uninstall`, and repeat installation. Neither action unregisters the runner or removes packaging credentials. +The installer copies its startup script into `runner/dsh-service`, sets the service PATH to Node 24 plus system tools, and uses GitHub's `svc.sh` and `runsvc.sh`. The LaunchAgent survives SSH disconnection, but requires that account's desktop login after reboot and stops on desktop logout. FileVault disk unlock remains an operator step; this does not provide unattended boot. Logs are `runner/dsh-service/stdout.log` and `stderr.log`; verify `Connected to GitHub` and `Listening for Jobs`. To update installed startup code or tool paths, wait until the runner is idle, run `stop`, then `uninstall`, and repeat installation. Neither action unregisters the runner or removes packaging credentials. Uninstall retains `runner/dsh-service` (including logs) and the explicit `.path`; reinstall replaces the startup snapshot and PATH. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index f61aed4816..5fa36e3eb2 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -116,7 +116,7 @@ macOS arm64 命令要求 Apple Silicon。macOS x64 命令可以在 Intel macOS 运行 `bash apps/desktop/scripts/start-macos-packaging-runner.sh "$HOME/dsh-ci/runner" "$HOME/dsh-ci/packaging" --check` 检查宿主工具和配置文件。脚本同时选择当前 Xcode 及其 macOS SDK,并编译小型链接探针。注册后省略 `--check`,清理继承环境并在前台启动 runner,存活期间阻止空闲睡眠。安装下方服务前,先用 Ctrl-C 停止。runner 拥有 `_work` 下的源码目录,与日常开发分开。该账户可读取签名文件,因此只执行可信代码。沿用现有公证代理恢复规则;强制终止后,先检查保留的恢复记录再构建。新建模板将 `DSH_DESKTOP_MACOS_SIGNATURE_CACHE_DIR` 设为 checkout 外 `cache/macos-signatures` 的绝对路径。已有部署重新运行 setup 后,在各 preset 中补上该字段;已有文件保持不变。这样可跨干净检出保留签名;缓存验证和容量限制见 [macOS 签名](#macos-release-signing)。 -工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 +工作流选项 `skip_notarization` 默认为 `false`;对应 CLI 参数是 `--skip-notarization`。该模式保留 Developer ID 签名、DMG 签名验证和打包运行时验证,跳过 Apple 公证和 stapling,不要求 Apple 公证凭据。DMG/ZIP 文件名带 `-unnotarized`,存放于 `.desktop-build/targets//unnotarized-artifacts/`,上传产物标为 `unnotarized-debug`。这些产物仅供内部调试:Gatekeeper 可能阻止下载副本运行,不能视为可分发包或用于更新发布。该模式不写入发布完成记录,不能与未签名、Windows 或仅准备构建组合。 在 checkout 中,以注册 runner 的同一账户安装用户服务,不使用 `sudo`: @@ -126,7 +126,7 @@ bash apps/desktop/scripts/macos-runner-service.sh start "$HOME/dsh-ci/runner" bash apps/desktop/scripts/macos-runner-service.sh status "$HOME/dsh-ci/runner" ``` -安装脚本将启动脚本复制到 `runner/dsh-service`,将服务 PATH 设为 Node 24 和系统工具,并使用 GitHub 的 `svc.sh` 与 `runsvc.sh`。LaunchAgent 在 SSH 断开后继续运行,但重启后需要该账户登录桌面,注销桌面时停止。FileVault 磁盘解锁仍由操作人员完成,这不提供无人值守开机。日志位于 `runner/dsh-service/stdout.log` 和 `stderr.log`;验收时确认出现 `Connected to GitHub` 与 `Listening for Jobs`。更新已安装的启动脚本或工具路径时,等待 runner 空闲,依次执行 `stop`、`uninstall`,再重新安装。这两个操作都不会注销 runner 或删除打包凭据。 +安装脚本将启动脚本复制到 `runner/dsh-service`,将服务 PATH 设为 Node 24 和系统工具,并使用 GitHub 的 `svc.sh` 与 `runsvc.sh`。LaunchAgent 在 SSH 断开后继续运行,但重启后需要该账户登录桌面,注销桌面时停止。FileVault 磁盘解锁仍由操作人员完成,这不提供无人值守开机。日志位于 `runner/dsh-service/stdout.log` 和 `stderr.log`;验收时确认出现 `Connected to GitHub` 与 `Listening for Jobs`。更新已安装的启动脚本或工具路径时,等待 runner 空闲,依次执行 `stop`、`uninstall`,再重新安装。这两个操作都不会注销 runner 或删除打包凭据。卸载保留 `runner/dsh-service`(包括日志)和显式 `.path`;重新安装会替换启动快照和 PATH。 diff --git a/apps/desktop/scripts/desktop-build-paths.d.mts b/apps/desktop/scripts/desktop-build-paths.d.mts index d07541f4a2..c7b8173ca1 100644 --- a/apps/desktop/scripts/desktop-build-paths.d.mts +++ b/apps/desktop/scripts/desktop-build-paths.d.mts @@ -73,3 +73,14 @@ export function developmentRuntimeDirectory( hostPlatform?: NodeJS.Platform, hostArch?: string, ): string + +/** + * Select the output directory and filename suffix for one packaging mode. + * @param paths Target artifact directories. + * @param mode Validated packaging mode. + * @returns Matching artifact destination and filename marker. + */ +export function desktopArtifactOutput( + paths: Pick, + mode: { unsigned: boolean, skipNotarization: boolean }, +): { directory: string, suffix: string } diff --git a/apps/desktop/scripts/desktop-build-paths.mjs b/apps/desktop/scripts/desktop-build-paths.mjs index b11a3ea249..e577b1d3fe 100644 --- a/apps/desktop/scripts/desktop-build-paths.mjs +++ b/apps/desktop/scripts/desktop-build-paths.mjs @@ -106,3 +106,15 @@ export function developmentRuntimeDirectory( ) { return join(resolveDesktopTargetBuildPaths(env, hostPlatform, hostArch).runtime, 'primary-runtime') } + +/** + * Select the output directory and filename suffix for one packaging mode. + * @param {{ artifacts: string, unsignedArtifacts: string, unnotarizedArtifacts: string }} paths Target artifact directories. + * @param {{ unsigned: boolean, skipNotarization: boolean }} mode Validated packaging mode. + * @returns {{ directory: string, suffix: string }} Matching artifact destination and filename marker. + */ +export function desktopArtifactOutput(paths, mode) { + if (mode.unsigned) return { directory: paths.unsignedArtifacts, suffix: '-unsigned' } + if (mode.skipNotarization) return { directory: paths.unnotarizedArtifacts, suffix: '-unnotarized' } + return { directory: paths.artifacts, suffix: '' } +} diff --git a/apps/desktop/scripts/desktop-build-version-discovery.ts b/apps/desktop/scripts/desktop-build-version-discovery.ts index 120e41afd0..68291ca3b8 100644 --- a/apps/desktop/scripts/desktop-build-version-discovery.ts +++ b/apps/desktop/scripts/desktop-build-version-discovery.ts @@ -30,8 +30,8 @@ const LISTING_DEADLINE_MS = 8_000 /** Objects one listing page may return. */ const LISTING_PAGE_SIZE = 1000 -/** Artifact name electron-builder writes for one build, on either platform; unsigned builds add a suffix. */ -const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned)?\.(?:exe|dmg|zip)$/u +/** Artifact name electron-builder writes for one build, on either platform; unsigned and unnotarized builds add a suffix. */ +const ARTIFACT = /(?:^|\/)deepseek-harness-(?.+)-(?:mac|win)-(?:arm64|x64)(?:-unsigned|-unnotarized)?\.(?:exe|dmg|zip)$/u /** Inputs that decide which versions are already taken. */ export interface DesktopBuildVersionSuggestionOptions { diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 73acffecdc..d909d1a4e7 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -12,7 +12,7 @@ import { resolveMacOSSigningEnvironment, } from './desktop-release-environment.mjs' import { notarizeMacOSDiskImageArtifact } from './notarize-macos-disk-images.mjs' -import { verifyMacOSSignatureAfterSign } from './verify-macos-signature.mjs' +import { verifyMacOSDiskImageSignature, verifyMacOSSignatureAfterSign } from './verify-macos-signature.mjs' import { createWindowsTokenSigner, installWindowsNsisBootstrapSigner, @@ -23,7 +23,7 @@ import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environmen import { resolveDesktopBuildCommit } from './desktop-build-commit.mjs' import { resolveDesktopBuildVersion } from './desktop-build-version.mjs' import { resolveDesktopPolicyEnvironment } from './desktop-policy-environment.mjs' -import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { installWindowsDirectoryInstaller } from './windows-directory-installer.mjs' import { preserveWindowsRuntimeSignature, signWindowsCode } from './windows-runtime-signature.mjs' import { prepareWindowsAsarUnpack, verifyWindowsAsarUnpack } from './windows-asar-unpack.mjs' @@ -70,6 +70,7 @@ export function createElectronBuilderConfig( const macOSSigning = packagesMacOS && !unsigned ? resolveMacOSSigningEnvironment(env) : undefined if (packagesMacOS && !unsigned && !skipNotarization) resolveMacOSNotarizationEnvironment(env) const buildPaths = desktopTargetBuildPaths(resolveDesktopBuildTarget(env, hostPlatform, hostArch)) + const output = desktopArtifactOutput(buildPaths, { unsigned, skipNotarization }) let primaryRuntimeDestination let dshDestination let windowsCode = [] @@ -112,8 +113,8 @@ export function createElectronBuilderConfig( }, productName: 'DeepSeek Harness', // Unsigned builds carry their own suffix so a shared file can never pass for a release artifact. - artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${unsigned ? '-unsigned' : skipNotarization ? '-unnotarized' : ''}.\${ext}`, - directories: { output: unsigned ? buildPaths.unsignedArtifacts : skipNotarization ? buildPaths.unnotarizedArtifacts : buildPaths.artifacts }, + artifactName: `deepseek-harness-\${version}-\${os}-\${arch}${output.suffix}.\${ext}`, + directories: { output: output.directory }, asar: true, electronDist: buildPaths.electron, electronFuses: { runAsNode: true }, @@ -220,7 +221,11 @@ export function createElectronBuilderConfig( verifyMacOSSignatureAfterSign(context, macOSSigning ?? resolveMacOSSigningEnvironment(env)) }, artifactBuildCompleted: artifact => { - if (unsigned || skipNotarization || !artifact.file.endsWith('.dmg')) return + if (unsigned || !artifact.file.endsWith('.dmg')) return + if (skipNotarization) { + verifyMacOSDiskImageSignature(artifact.file, macOSSigning ?? resolveMacOSSigningEnvironment(env)) + return + } return notarizeMacOSDiskImageArtifact( artifact, env, diff --git a/apps/desktop/scripts/macos-runner-service.sh b/apps/desktop/scripts/macos-runner-service.sh index 7785fa09e1..f3fd74eb94 100644 --- a/apps/desktop/scripts/macos-runner-service.sh +++ b/apps/desktop/scripts/macos-runner-service.sh @@ -23,7 +23,7 @@ case "$action" in service_dir="$runner_root/dsh-service" mkdir -p "$service_dir" cp "$source_dir/start-macos-packaging-runner.sh" "$service_dir/start.sh" - # runsvc.sh reloads .path before launching RunnerService.js. + # runsvc.sh reloads .path; replace the interactive PATH saved by registration. printf '%s\n' "$node_bin:/usr/bin:/bin:/usr/sbin:/sbin" > .path "$node_bin/node" --input-type=module - "$service_dir" "$runner_root" "$config_root" "$node_bin" <<'NODE' import { writeFileSync } from 'node:fs' diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index bc31c091ab..99b1a4c5af 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -8,7 +8,7 @@ import { desktopBuildRecordFilename, resolveDesktopAutoUpdateConfig, } from './desktop-auto-update-environment.mjs' -import { desktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, desktopTargetBuildPaths } from './desktop-build-paths.mjs' import { packageMacOSArtifacts, type DesktopPrepackagedArtifact } from './package-macos.ts' import { loadDesktopPackageEnvironment, localMacOSPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { createPackagingRun, recordPackagingEvent } from './packaging-run.mjs' @@ -332,8 +332,7 @@ async function resolveRequestedBuildVersion( // Machine presets own packaging, not upload credentials; local auto numbering uses existing artifacts. environment: environment.DSH_PACKAGING_PRESET === undefined ? environment : {}, // Unsigned builds land beside the signed output, so numbering has to read the directory this run writes. - artifactsRoot: invocation.unsigned ? paths.unsignedArtifacts - : invocation.skipNotarization ? paths.unnotarizedArtifacts : paths.artifacts, + artifactsRoot: desktopArtifactOutput(paths, invocation).directory, }) } @@ -348,7 +347,6 @@ async function main(): Promise { : loadDesktopPackageEnvironment(target.platform, process.env, APP_ROOT, { unsigned: invocation.unsigned, prepareOnly: invocation.prepareOnly, }) - environment.DSH_DESKTOP_MACOS_SKIP_NOTARIZATION = invocation.skipNotarization ? '1' : '0' const productVersion = packageVersion(join(APP_ROOT, 'package.json'), 'desktop package') // Release settings come from the selected configuration files, so the version this run publishes is an // argument; the environment variable below only carries it to the child processes that build. @@ -401,8 +399,9 @@ async function main(): Promise { } if (success && target.platform === 'darwin' && !invocation.directory && !invocation.prepareOnly) { const paths = desktopTargetBuildPaths(target.name) - const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${invocation.unsigned ? '-unsigned' : invocation.skipNotarization ? '-unnotarized' : ''}.dmg` - console.log(`DMG: ${join(invocation.unsigned ? paths.unsignedArtifacts : invocation.skipNotarization ? paths.unnotarizedArtifacts : paths.artifacts, filename)}`) + const output = desktopArtifactOutput(paths, invocation) + const filename = `deepseek-harness-${buildVersion}-mac-${target.arch}${output.suffix}.dmg` + console.log(`DMG: ${join(output.directory, filename)}`) } } @@ -430,9 +429,7 @@ export async function packageTarget( rmSync(releaseRecordPath, { force: true }) rmSync(`${releaseRecordPath}.tmp`, { force: true }) } - const artifactsRoot = invocation.unsigned ? buildPaths.unsignedArtifacts - : invocation.skipNotarization ? buildPaths.unnotarizedArtifacts : buildPaths.artifacts - if (invocation.skipNotarization) rmSync(artifactsRoot, { recursive: true, force: true }) + const artifactsRoot = desktopArtifactOutput(buildPaths, invocation).directory const buildEnv = withoutWindowsSigningEnvironment(withoutDesktopUploadCredentials( desktopElectronBuilderEnvironment(environment, invocation.unsigned))) const targetEnv: NodeJS.ProcessEnv = { diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs index 5f6e37becb..4aeaef8e8a 100644 --- a/apps/desktop/scripts/prepare-macos-ci.mjs +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -19,6 +19,6 @@ for (const [name, value] of Object.entries(environment)) { } validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') -appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed-notarized'}\n`) +appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\n`) appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n') diff --git a/apps/desktop/scripts/smoke-packaged-runtime.ts b/apps/desktop/scripts/smoke-packaged-runtime.ts index 3594eb6af7..6c3c12c857 100644 --- a/apps/desktop/scripts/smoke-packaged-runtime.ts +++ b/apps/desktop/scripts/smoke-packaged-runtime.ts @@ -1,7 +1,7 @@ /** Validate the assembled application, including native Office conversion outside ASAR. */ import { join } from 'node:path' import { parseArgs } from 'node:util' -import { resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' +import { desktopArtifactOutput, resolveDesktopBuildTarget, resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs' import { readDesktopRuntime, verifyDesktopRuntime } from '../src/runtime-tree.ts' import { verifyWindowsCode } from './windows-runtime-signature.mjs' import { smokePreparedRuntime } from './smoke-prepared-runtime.ts' @@ -11,7 +11,7 @@ const paths = resolveDesktopTargetBuildPaths() const { values } = parseArgs({ options: { unsigned: { type: 'boolean', default: false }, 'skip-notarization': { type: 'boolean', default: false } }, allowPositionals: false }) const target = resolveDesktopBuildTarget() const windows = target === 'win-x64' -const artifacts = values.unsigned ? paths.unsignedArtifacts : values['skip-notarization'] ? paths.unnotarizedArtifacts : paths.artifacts +const artifacts = desktopArtifactOutput(paths, { unsigned: values.unsigned, skipNotarization: values['skip-notarization'] }).directory const application = windows ? join(artifacts, 'win-unpacked') : join(artifacts, target === 'mac-arm64' ? 'mac-arm64' : 'mac', 'DeepSeek Harness.app', 'Contents') const resources = join(application, windows ? 'resources' : 'Resources') diff --git a/apps/desktop/scripts/verify-macos-signature.d.mts b/apps/desktop/scripts/verify-macos-signature.d.mts index a33c3f4213..cf1434b2d2 100644 --- a/apps/desktop/scripts/verify-macos-signature.d.mts +++ b/apps/desktop/scripts/verify-macos-signature.d.mts @@ -80,3 +80,10 @@ export function verifyMacOSSignatureAfterSign( context: MacOSAfterSignContext, expected: MacOSSigningEnvironment, ): void + +/** + * Verify a disk image's code signature and Developer ID without requiring a notarization ticket. + * @param diskImagePath Path to the packaged `.dmg` file. + * @param expected Public signing identity. + */ +export function verifyMacOSDiskImageSignature(diskImagePath: string, expected: MacOSSigningEnvironment): void diff --git a/apps/desktop/scripts/verify-macos-signature.mjs b/apps/desktop/scripts/verify-macos-signature.mjs index 6817f0962f..a906f88ab7 100644 --- a/apps/desktop/scripts/verify-macos-signature.mjs +++ b/apps/desktop/scripts/verify-macos-signature.mjs @@ -172,9 +172,7 @@ export function verifyMacOSNotarizedApplication(appPath, expected) { * @returns {void} */ export function verifyMacOSDiskImage(diskImagePath, expected) { - runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath]) - const details = runCodeSign(['--display', '--verbose=4', diskImagePath]) - assertMacOSSignatureDetails(details, expected) + verifyMacOSDiskImageSignature(diskImagePath, expected) runAppleCommand('/usr/bin/xcrun', ['stapler', 'validate', diskImagePath], 'stapler validate') runAppleCommand('/usr/sbin/spctl', ['--assess', '--type', 'install', '--verbose=4', diskImagePath], 'spctl') } @@ -202,3 +200,15 @@ if (process.argv[1] !== undefined && import.meta.filename === resolve(process.ar verifyMacOSSignature(resolve(appPath), expected) process.stdout.write(`desktop macOS signing: verified Developer ID Application: ${expected.signingIdentity} (${expected.teamId})\n`) } + +/** + * Verify a disk image's code signature and Developer ID without requiring a notarization ticket. + * @param {string} diskImagePath Path to the packaged `.dmg` file. + * @param {{ signingIdentity: string, teamId: string }} expected Public signing identity. + * @returns {void} + */ +export function verifyMacOSDiskImageSignature(diskImagePath, expected) { + runCodeSign(['--verify', '--strict', '--verbose=2', diskImagePath]) + const details = runCodeSign(['--display', '--verbose=4', diskImagePath]) + assertMacOSSignatureDetails(details, expected) +} diff --git a/apps/desktop/tests/desktop-build-version-discovery.spec.ts b/apps/desktop/tests/desktop-build-version-discovery.spec.ts index 8438e076ad..0fe063d1e2 100644 --- a/apps/desktop/tests/desktop-build-version-discovery.spec.ts +++ b/apps/desktop/tests/desktop-build-version-discovery.spec.ts @@ -71,6 +71,16 @@ describe('desktop build version discovery', () => { .resolves.toBe(`${PRERELEASE}.${DATE}.4`) }) + it('numbers after existing unnotarized DMG and ZIP artifacts', async () => { + const artifactsRoot = await artifactsWith([ + `deepseek-harness-${PRERELEASE}.${DATE}.2-mac-arm64-unnotarized.dmg`, + `deepseek-harness-${PRERELEASE}.${DATE}.4-mac-arm64-unnotarized.zip`, + `deepseek-harness-${PRERELEASE}.${DATE}.9-mac-arm64-unnotarized.zip.blockmap`, + ]) + await expect(suggestDesktopBuildVersion({ productVersion: PRERELEASE, target: 'mac-arm64', environment: {}, date: DATE, artifactsRoot })) + .resolves.toBe(`${PRERELEASE}.${DATE}.5`) + }) + it('counts unsigned Windows artifacts written under their own suffix', async () => { const artifactsRoot = await artifactsWith([ `deepseek-harness-${PRERELEASE}.${DATE}.2-win-x64-unsigned.exe`, diff --git a/apps/desktop/tests/macos-disk-image-signature.spec.ts b/apps/desktop/tests/macos-disk-image-signature.spec.ts new file mode 100644 index 0000000000..5aaf21f969 --- /dev/null +++ b/apps/desktop/tests/macos-disk-image-signature.spec.ts @@ -0,0 +1,58 @@ +import { spawnSync } from 'node:child_process' +import { afterEach, expect, it, vi } from 'vitest' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' +import { verifyMacOSDiskImage } from '../scripts/verify-macos-signature.mjs' + +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), spawnSync: vi.fn(), +})) +afterEach(() => { vi.resetAllMocks() }) + +const signingIdentity = 'Example Company (TEAMID1234)' +const teamId = 'TEAMID1234' +const environment = { + DSH_DESKTOP_APP_ID: 'com.example.desktop', DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://policy.example.com', + DOWNLOAD_PROD_ORIGIN: 'https://updates.example.com', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: signingIdentity, DSH_DESKTOP_MACOS_TEAM_ID: teamId, + DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', +} + +function toolResult(details = `Authority=Developer ID Application: ${signingIdentity}\nTeamIdentifier=${teamId}`) { + vi.mocked(spawnSync).mockReturnValue({ + pid: 1, output: [null, '', details], stdout: '', stderr: details, status: 0, signal: null, + }) +} + +it('verifies an unnotarized DMG signature and identity without invoking Apple notarization tools', () => { + toolResult() + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toBeUndefined() + expect(vi.mocked(spawnSync).mock.calls.map(call => call.slice(0, 2))).toEqual([ + ['/usr/bin/codesign', ['--verify', '--strict', '--verbose=2', '/fixture/debug.dmg']], + ['/usr/bin/codesign', ['--display', '--verbose=4', '/fixture/debug.dmg']], + ]) +}) + +it('rejects an unnotarized DMG signed by another team', () => { + toolResult('Authority=Developer ID Application: Other (OTHERID123)\nTeamIdentifier=OTHERID123') + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(() => config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toThrow(/release identity/u) +}) + +it('rejects a corrupt unnotarized DMG before inspecting its identity', () => { + toolResult() + vi.mocked(spawnSync).mockReturnValueOnce({ pid: 1, output: [null, '', 'invalid signature'], + stdout: '', stderr: 'invalid signature', status: 1, signal: null }) + const config = createElectronBuilderConfig(environment, 'darwin', 'arm64') + expect(() => config.artifactBuildCompleted({ file: '/fixture/debug.dmg' })).toThrow(/invalid signature/u) + expect(spawnSync).toHaveBeenCalledOnce() +}) + +it('still requires a stapled ticket and Gatekeeper acceptance for release DMGs', () => { + toolResult() + verifyMacOSDiskImage('/fixture/release.dmg', { signingIdentity, teamId }) + expect(vi.mocked(spawnSync).mock.calls.map(call => call[0])).toEqual([ + '/usr/bin/codesign', '/usr/bin/codesign', '/usr/bin/xcrun', '/usr/sbin/spctl', + ]) +}) diff --git a/apps/desktop/tests/macos-package-preset.spec.ts b/apps/desktop/tests/macos-package-preset.spec.ts index 60d67146f0..a4ab457ec0 100644 --- a/apps/desktop/tests/macos-package-preset.spec.ts +++ b/apps/desktop/tests/macos-package-preset.spec.ts @@ -84,8 +84,12 @@ it('records debugging artifact paths only for an explicit CI selection without n PACKAGE_SKIP_NOTARIZATION: skip, PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '1', GITHUB_RUN_ATTEMPT: '1', GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, } }) - expect(run('false').status).toBe(1) - expect(run('yes').status).toBe(1) + const missingCredentials = run('false') + expect(missingCredentials.status).toBe(1) + expect(missingCredentials.stderr).toContain('macOS packaging requires APPLE_API_KEY') + const invalidSelection = run('yes') + expect(invalidSelection.status).toBe(1) + expect(invalidSelection.stderr).toContain('Invalid skip notarization selection') const debugging = run('true') expect(debugging.stderr).toBe('') expect(debugging.status).toBe(0) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 7718b6e6bf..7017929328 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -268,7 +268,6 @@ it('signs debugging artifacts without Apple credentials and isolates them from r expect(config.mac).toMatchObject({ target: ['dmg', 'zip'] }) expect(config.artifactName).toContain('-unnotarized.') expect(portablePath(config.directories.output)).toContain('/unnotarized-artifacts') - expect(config.artifactBuildCompleted({ file: 'debug.dmg' })).toBeUndefined() expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: 'yes' }, 'darwin', 'arm64')).toThrow(/must be 0 or 1/u) expect(() => createElectronBuilderConfig({ ...RELEASE_ENVIRONMENT, DSH_DESKTOP_MACOS_SKIP_NOTARIZATION: '1', DSH_DESKTOP_UNSIGNED: '1' }, 'darwin', 'arm64')).toThrow(/requires a signed macOS build/u) }) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index b3d778e1dc..8e02578a2c 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -28,7 +28,6 @@ it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silic expect(uploads[1]?.with?.path).toBe('apps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.dmg\napps/desktop/.desktop-build/targets/mac-arm64/${{ steps.settings.outputs.artifacts }}/*.zip\n') }) - it('defaults to notarization and labels the explicit debugging option', () => { expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { skip_notarization: { type: 'boolean', default: false,