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 }}') })