diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml new file mode 100644 index 0000000000..cc835b8933 --- /dev/null +++ b/.github/workflows/macos-intel-package.yml @@ -0,0 +1,161 @@ +name: Package Desktop · macOS · Intel + +on: + workflow_dispatch: + inputs: + source_ref: + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' + type: string + default: master + required: true + preset: + description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' + type: choice + options: [test, production] + default: test + required: true + update_channel: + description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' + type: choice + options: [nightly, latest] + default: nightly + required: true + version_mode: + description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填' + type: choice + options: [default, product, auto, manual] + default: default + required: true + build_version: + description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀' + type: string + default: '' + required: false + test_release_id: + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' + type: string + default: '' + required: false + compression_level: + description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强' + type: choice + options: ['3', '7'] + default: '3' + required: true + skip_notarization: + description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' + type: boolean + default: false + required: true + +permissions: + contents: read + +concurrency: + group: macos-desktop-package + cancel-in-progress: false + +jobs: + package: + runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial] + timeout-minutes: 180 + defaults: + run: + shell: bash + env: + DSH_DESKTOP_TARGET_PLATFORM: darwin + DSH_DESKTOP_TARGET_ARCH: x64 + PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }} + PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} + PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} + PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} + DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }} + DSH_TELEMETRY_DISABLED: '1' + DSH_PACKAGING_PRESET: ${{ inputs.preset }} + PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }} + steps: + - uses: actions/checkout@v7.0.1 + id: checkout + with: + persist-credentials: false + clean: true + ref: ${{ inputs.source_ref }} + - name: Validate selected source and adapter support + run: >- + node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF; + const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET; + if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset'); + const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref); + if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*'); + if(tag) { + const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; + if(ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim(); + const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim(); + if(tagged!==head) throw Error('Checkout does not match source tag'); + } else { + const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim(); + if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref'); + } + if(!fs.readFileSync('apps/desktop/scripts/prepare-macos-ci.mjs','utf8').includes('resolveDesktopBuildTarget')) throw Error('Source lacks Intel macOS CI support; select a newer source'); + if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');" + - uses: pnpm/action-setup@v5.0.0 + with: + dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Configure persistent package caches + env: + DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache + run: | + echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV" + echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV" + - name: Check Rosetta execution + run: arch -x86_64 /usr/bin/true + - name: Install locked dependencies + run: pnpm install --frozen-lockfile + - name: Validate preset and resolve build version + id: settings + env: + PACKAGE_BUILD_VERSION: ${{ inputs.build_version }} + run: node apps/desktop/scripts/prepare-macos-ci.mjs + - name: Check packaging configuration and toolchain + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:x64 --check --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Build signed installers + env: + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: | + args=() + if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi + pnpm run package:desktop:mac:x64 --build-version "$PACKAGE_VERSION" "${args[@]}" + - name: Retain complete package outside the checkout + id: retained + env: + PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }} + PACKAGE_VERSION: ${{ steps.settings.outputs.version }} + run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts + - name: Retain packaging diagnostics + if: always() && steps.checkout.outcome == 'success' + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-x64-package-logs-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: apps/desktop/.desktop-build/packaging-runs/ + if-no-files-found: warn + retention-days: 14 + - name: Retain installers for the selected mode + if: success() + uses: actions/upload-artifact@v7.0.1 + with: + name: macos-x64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }} + include-hidden-files: true + path: ${{ steps.retained.outputs.directory }}/* + compression-level: 0 + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index eeb451266e..073f1e9541 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -1,35 +1,19 @@ -name: Package Desktop · macOS +name: Package Desktop · macOS · Apple Silicon on: workflow_dispatch: inputs: + source_ref: + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' + type: string + default: master + required: true preset: description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' type: choice options: [test, production] default: test required: true - zip_compression_level: - description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强' - type: choice - options: ['3', '7'] - default: '3' - required: true - skip_notarization: - description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' - type: boolean - default: false - required: true - test_release_id: - description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' - type: string - default: '' - required: false - source_ref: - description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' - type: string - default: master - required: true update_channel: description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' type: choice @@ -47,6 +31,22 @@ on: type: string default: '' required: false + test_release_id: + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' + type: string + default: '' + required: false + compression_level: + description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强' + type: choice + options: ['3', '7'] + default: '3' + required: true + skip_notarization: + description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选' + type: boolean + default: false + required: true permissions: contents: read @@ -63,7 +63,9 @@ jobs: run: shell: bash env: - PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }} + DSH_DESKTOP_TARGET_PLATFORM: darwin + DSH_DESKTOP_TARGET_ARCH: arm64 + PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }} PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }} PACKAGE_SOURCE_REF: ${{ inputs.source_ref }} PACKAGE_VERSION_MODE: ${{ inputs.version_mode }} diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index c38fd7d5a0..cbb4fd2642 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -1,30 +1,19 @@ -name: Package Desktop · Windows +name: Package Desktop · Windows · x64 on: workflow_dispatch: inputs: + source_ref: + description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' + type: string + default: master + required: true preset: description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境' type: choice options: [test, production] default: test required: true - signing: - description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)' - type: choice - options: [signed, unsigned] - default: signed - required: true - test_release_id: - description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' - type: string - default: '' - required: false - source_ref: - description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本' - type: string - default: master - required: true update_channel: description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道' type: choice @@ -42,6 +31,23 @@ on: type: string default: '' required: false + test_release_id: + description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值' + type: string + default: '' + required: false + compression_level: + description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置' + type: choice + options: ['3', '7'] + default: '3' + required: true + signing: + description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)' + type: choice + options: [signed, unsigned] + default: signed + required: true cache_namespace: description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存' type: string @@ -52,12 +58,6 @@ on: type: string default: '' required: false - compression_level: - description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置' - type: choice - options: ['3', '7'] - default: '3' - required: true permissions: contents: read diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 7a9ff55492..0cc7484e34 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -27,8 +27,8 @@ en: 364223725374d9ac zh: c4d4cc58d7cbafbd /desktop-packaging/macos-self-hosted-packaging: - en: bb1dcdfef3f3a113 - zh: 9c151800a61e2505 + en: ee628e830d849ae9 + zh: 44313f5fb842f042 /desktop-packaging/unsigned-windows-test-installer: en: f429bc0c280e2dfa zh: 4adbcc9e906bdf80 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 14c1eac90b..40606a0afb 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -127,7 +127,9 @@ Installer uploads store the already-compressed release files without ZIP recompr ## macOS self-hosted packaging -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. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. 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. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG. +The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG. + +All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing, cache measurement and isolated-workspace options follow these common fields. 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. diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 0f059c74a4..ab75c33497 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -127,7 +127,9 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s ## macOS 自托管打包 -`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 +[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。 + +三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量、独立构建目录选项位于公共字段之后。 运行 `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 构建保留无需配置的行为。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index eec80bdac1..d6940d4d14 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0478d8e35f9a3d55 zh: 0aa3fd828bc02691 /desktop-ci-publication/machine-setup: - en: f80388c2b1ffef4c - zh: f242ab2316bf38a1 + en: 6e7b928af17f4377 + zh: 7c559078306df3fd /desktop-ci-publication/package-and-publish: en: f7c12d343891e547 zh: da596ee1366c2046 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index 9835027926..02f1e1b6af 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -56,7 +56,7 @@ Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging direct Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration. -Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. +Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`. Assign that label to exactly that runner. A target may be a single name or a nonempty list; the Mac entry above authorizes both packaging workflows on the same machine. Windows uses target `win-x64`. The retained target must match the selected packaging workflow. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other. An administrator must create `desktop-test` and `desktop-production`, each with required reviewers and an allowed `master` deployment branch. The workflow checks reviewers for either deployment. Test can allow self-approval. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index f8c41b6d95..3a635b5116 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -56,7 +56,7 @@ job 排队时,先对照 job 页面请求的标签与 **Settings → Actions 在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。 -将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 +将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`。该标签只能分配给这台 runner。目标可为单个名称或非空列表;上述 Mac 条目允许同一台机器处理两种架构的打包工作流。Windows 使用目标 `win-x64`。留存包的目标必须与所选打包工作流一致。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 管理员必须创建 `desktop-test` 和 `desktop-production`,分别设置必需审批人并允许 `master` 部署分支。工作流会检查两种环境的审批人配置。test 可允许发起者自行审批。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。 diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index 303c447040..9b6fedfa42 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -1,5 +1,6 @@ /** Validate a completed packaging attempt and route publication back to its original runner. */ import { z } from 'zod' +import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts' import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs' const positive = z.number().int().positive() @@ -12,12 +13,11 @@ const requestSchema = z.object({ source: z.object({ repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), workflowCommit: z.string(), sourceRef: z.string().min(1), channel: z.enum(['nightly', 'latest']), - runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), + runnerName: z.string(), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']), qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']), }).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef), 'Production source must be master or dsh-v*') }) const jobsSchema = z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable() })) -const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) /** Validated values handed to the privileged publisher; no caller-selected shell fragments. */ export interface DesktopPublishRoute { @@ -28,7 +28,7 @@ export interface DesktopPublishRoute { readonly channel: 'nightly' | 'latest' readonly runnerName: string readonly runnerLabel: string - readonly target: 'mac-arm64' | 'win-x64' + readonly target: 'mac-arm64' | 'mac-x64' | 'win-x64' readonly preset: 'test' | 'production' } @@ -49,8 +49,8 @@ export function resolveDesktopPublishRoute( const diagnostic = desktopPublishQualificationError(source.qualification) if (diagnostic) throw new Error(diagnostic) const jobs = jobsSchema.parse(jobsValue) - const routes = routesSchema.parse(routesValue) - const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml' + const routes = desktopPublishRunnersSchema.parse(routesValue) + const workflow = desktopPackageWorkflows[source.target] if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow || source.runId !== String(run.id) || source.attempt !== String(run.run_attempt) || source.workflowCommit !== run.head_sha @@ -58,7 +58,7 @@ export function resolveDesktopPublishRoute( throw new Error('desktop publish: request does not match the successful packaging attempt') } const route = routes[source.runnerName] - if (!route || route.target !== source.target) throw new Error('desktop publish: original machine has no configured publishing route') + if (!route || !route.target.includes(source.target)) throw new Error('desktop publish: original machine has no configured publishing route') if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) { throw new Error('desktop publish: each configured machine needs its own unique runner label') } diff --git a/apps/desktop/scripts/desktop-publish-targets.ts b/apps/desktop/scripts/desktop-publish-targets.ts new file mode 100644 index 0000000000..3270b99b25 --- /dev/null +++ b/apps/desktop/scripts/desktop-publish-targets.ts @@ -0,0 +1,18 @@ +/** Approved packaging workflows and machine publication targets. */ +import { z } from 'zod' + +/** Each target is produced by its own manually dispatched workflow. */ +export const desktopPackageWorkflows = { + 'mac-arm64': '.github/workflows/macos-package.yml', + 'mac-x64': '.github/workflows/macos-intel-package.yml', + 'win-x64': '.github/workflows/windows-package.yml', +} as const + +const targetSchema = z.enum(['mac-arm64', 'mac-x64', 'win-x64']) + +/** Machine labels are unique; a scalar target is shorthand for a one-element target list. */ +export const desktopPublishRunnersSchema = z.record(z.string(), z.object({ + label: z.string().regex(/^dsh-[a-z0-9-]+$/u), + target: z.union([targetSchema, z.array(targetSchema).min(1)]) + .transform(value => typeof value === 'string' ? [value] : value), +})) diff --git a/apps/desktop/scripts/preflight-desktop-publish.mjs b/apps/desktop/scripts/preflight-desktop-publish.mjs index 3d9624b20f..dfc1dcb4a6 100644 --- a/apps/desktop/scripts/preflight-desktop-publish.mjs +++ b/apps/desktop/scripts/preflight-desktop-publish.mjs @@ -29,7 +29,7 @@ export function preflightDesktopPublish(repository, inputs, api) { for (const { name } of pages.flatMap(page => page.artifacts)) { if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned' - : name.startsWith('macos-arm64-unnotarized-debug-') ? 'unnotarized' : '' + : /^macos-(?:arm64|x64)-unnotarized-debug-/u.test(name) ? 'unnotarized' : '' const diagnostic = desktopPublishQualificationError(qualification) if (diagnostic) throw new Error(diagnostic) } diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs index fb7d6b4747..6484a038fc 100644 --- a/apps/desktop/scripts/prepare-macos-ci.mjs +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -2,9 +2,12 @@ import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' import { execFileSync } from 'node:child_process' +import { desktopTargetPlatform, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' import { desktopCiVersion } from './desktop-ci-version.mjs' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' +const target = desktopTargetPlatform(resolveDesktopBuildTarget(process.env)) +if (target.platform !== 'darwin') throw new Error('macOS CI requires a macOS target') const appRoot = resolve(import.meta.dirname, '..') const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) const version = desktopCiVersion(process.env, product.version) @@ -18,8 +21,8 @@ for (const [name, value] of Object.entries(environment)) { process.stdout.write(`::add-mask::${escaped}\n`) } } -validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization }) +validateDesktopPackageEnvironment(environment, target, { 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'}\n`) -appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) +appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS ${target.arch} signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\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/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index 4b9c3e77d1..f9348ec2eb 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -18,7 +18,7 @@ import { desktopPublishQualificationError } from './desktop-publish-qualificatio const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(), sourceRef: z.string(), channel: z.enum(['nightly', 'latest']), - target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']) }) + target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']) }) /** * Check retained bytes, original machine, destination and signatures before reading any upload secret. diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index ce39bc32bc..152369c49b 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -3,17 +3,17 @@ import { execFileSync } from 'node:child_process' import { appendFile, readFile } from 'node:fs/promises' import { resolve } from 'node:path' import { z } from 'zod' +import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts' const positiveId = z.string().regex(/^[1-9]\d*$/u) const inputsSchema = z.object({ package_run_id: positiveId, package_run_attempt: z.union([positiveId, z.literal('')]).optional() }) const runSchema = z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive(), head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.literal('success'), - event: z.literal('workflow_dispatch'), path: z.enum(['.github/workflows/macos-package.yml', '.github/workflows/windows-package.yml']), + event: z.literal('workflow_dispatch'), path: z.enum(Object.values(desktopPackageWorkflows)), repository: z.object({ full_name: z.string() }) }) const jobsSchema = z.object({ jobs: z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable(), })) }) -const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) })) /** * Read GitHub evidence for one explicit attempt, or the latest attempt without falling back after failure. @@ -37,10 +37,11 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r const packages = jobs.filter(job => job.name === 'package' && job.conclusion === 'success' && job.runner_name) if (packages.length !== 1) throw new Error('desktop publish: expected one successful package job') const runnerName = packages[0]!.runner_name! - const routes = routesSchema.parse(routesValue) + const routes = desktopPublishRunnersSchema.parse(routesValue) const route = routes[runnerName] - const target = run.path.endsWith('/macos-package.yml') ? 'mac-arm64' : 'win-x64' - if (!route || route.target !== target) throw new Error('desktop publish: original machine has no configured publishing route') + const target = (Object.keys(desktopPackageWorkflows) as Array) + .find(name => desktopPackageWorkflows[name] === run.path)! + if (!route || !route.target.includes(target)) throw new Error('desktop publish: original machine has no configured publishing route') if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) { throw new Error('desktop publish: each configured machine needs its own unique runner label') } diff --git a/apps/desktop/tests/desktop-package-selection.spec.ts b/apps/desktop/tests/desktop-package-selection.spec.ts index fa6a042775..1da8dde6e8 100644 --- a/apps/desktop/tests/desktop-package-selection.spec.ts +++ b/apps/desktop/tests/desktop-package-selection.spec.ts @@ -121,3 +121,17 @@ it('resolves an old locally retained package with publishRequested=false without await expect(resolveRetainedDesktopPublication(env, api)).rejects.toThrow() } finally { await rm(root, { recursive: true, force: true }) } }) + +it('routes both Mac architectures to one machine without authorizing an unlisted target', () => { + const f = fixture() + const routes = { mini: { label: 'dsh-mini', target: ['mac-arm64', 'mac-x64'] } } + for (const path of ['macos-package.yml', 'macos-intel-package.yml']) { + f.run.path = `.github/workflows/${path}` + expect(selectDesktopPackage('owner/repo', { package_run_id: '42' }, routes, f.api)) + .toMatchObject({ runnerName: 'mini', runnerLabel: 'dsh-mini' }) + } + expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, f.api)).toThrow(/no configured/u) + for (const target of [[], ['mac-x64', 'linux-x64']]) { + expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, { mini: { label: 'dsh-mini', target } }, f.api)).toThrow() + } +}) diff --git a/apps/desktop/tests/desktop-publish-preflight.spec.ts b/apps/desktop/tests/desktop-publish-preflight.spec.ts index 2af6f288b1..f6c99ae280 100644 --- a/apps/desktop/tests/desktop-publish-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publish-preflight.spec.ts @@ -2,6 +2,7 @@ import { expect, it } from 'vitest' import { preflightDesktopPublish } from '../scripts/preflight-desktop-publish.mjs' it.each([ + ['macos-x64-unnotarized-debug-1.0.0-42-2', '跳过了公证'], ['macos-arm64-unnotarized-debug-1.0.0-42-2', '跳过了公证'], ['windows-x64-unsigned-1.0.0-42-2', '未签名'], ])('rejects %s from paginated artifact metadata with rebuilding instructions', (name, reason) => { diff --git a/apps/desktop/tests/desktop-publish-route.spec.ts b/apps/desktop/tests/desktop-publish-route.spec.ts index 37258e26ed..4e3acc79f3 100644 --- a/apps/desktop/tests/desktop-publish-route.spec.ts +++ b/apps/desktop/tests/desktop-publish-route.spec.ts @@ -75,3 +75,16 @@ it('allows repository branch packages for test but rejects them for production', f.source.preset = 'production' expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow(/Production source/u) }) + +it('matches Intel artifacts to the Intel workflow on a machine serving both architectures', () => { + const f = fixture() + f.source.target = 'mac-x64' + const routes = { mini: { label: 'dsh-mini', target: ['mac-arm64', 'mac-x64'] } } + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)).toThrow(/does not match/u) + f.run.path = '.github/workflows/macos-intel-package.yml' + expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)) + .toMatchObject({ target: 'mac-x64', runnerLabel: 'dsh-mini' }) + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)).toThrow(/no configured/u) + f.source.target = 'mac-arm64' + expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, routes)).toThrow(/does not match/u) +}) diff --git a/apps/desktop/tests/macos-package-preset.spec.ts b/apps/desktop/tests/macos-package-preset.spec.ts index a4ab457ec0..a864b9b9bb 100644 --- a/apps/desktop/tests/macos-package-preset.spec.ts +++ b/apps/desktop/tests/macos-package-preset.spec.ts @@ -60,7 +60,7 @@ it.skipIf(process.platform !== 'darwin')('prepares private machine directories w } }) -it('records debugging artifact paths only for an explicit CI selection without notarization credentials', async () => { +it.each(['arm64', 'x64'])('records %s debugging artifact paths only for an explicit CI selection without notarization credentials', async (arch) => { const root = await mkdtemp(join(tmpdir(), 'macos-ci-settings-')) try { await mkdir(join(root, 'presets')) @@ -81,6 +81,7 @@ it('records debugging artifact paths only for an explicit CI selection without n 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', + DSH_DESKTOP_TARGET_PLATFORM: 'darwin', DSH_DESKTOP_TARGET_ARCH: arch, PACKAGE_SKIP_NOTARIZATION: skip, PACKAGE_BUILD_VERSION: 'auto', GITHUB_RUN_NUMBER: '1', GITHUB_RUN_ATTEMPT: '1', GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, } }) @@ -91,10 +92,13 @@ it('records debugging artifact paths only for an explicit CI selection without n expect(invalidSelection.status).toBe(1) expect(invalidSelection.stderr).toContain('Invalid skip notarization selection') const debugging = run('true') + expect(debugging.error).toBeUndefined() + expect(debugging.signal).toBeNull() 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') + expect(await readFile(summary, 'utf8')).toContain(`macOS ${arch} signed installer`) } finally { await rm(root, { recursive: true, force: true }) } diff --git a/apps/desktop/tests/retain-desktop-ci.spec.ts b/apps/desktop/tests/retain-desktop-ci.spec.ts index abe3565e93..f83592fa4a 100644 --- a/apps/desktop/tests/retain-desktop-ci.spec.ts +++ b/apps/desktop/tests/retain-desktop-ci.spec.ts @@ -10,7 +10,7 @@ const roots: string[] = [] afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) }) it.each([ - ['mac-arm64', 'notarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'], + ['mac-arm64', 'notarized'], ['mac-x64', 'notarized'], ['mac-x64', 'unnotarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'], ] as const)('retains the complete %s %s release without signing or upload credentials', async (target, qualification) => { const root = await mkdtemp(join(tmpdir(), 'retain-ci-')) roots.push(root) @@ -23,13 +23,13 @@ it.each([ await writeFile(join(appRoot, 'package.json'), '{"version":"1.0.0"}') await writeFile(join(config, 'presets/test.env'), `DSH_DESKTOP_AUTO_UPDATE_ENV=test\nDOWNLOAD_TEST_RELEASE_ID=${'a'.repeat(32)}\n`) const names = debug ? (target === 'win-x64' ? [`deepseek-harness-1.0.0-win-x64-${qualification}.exe`] - : [`deepseek-harness-1.0.0-mac-arm64-${qualification}.dmg`, `deepseek-harness-1.0.0-mac-arm64-${qualification}.zip`]) : target === 'win-x64' + : [`deepseek-harness-1.0.0-${target}-${qualification}.dmg`, `deepseek-harness-1.0.0-${target}-${qualification}.zip`]) : target === 'win-x64' ? ['deepseek-harness-1.0.0-win-x64.exe', 'deepseek-harness-1.0.0-win-x64.exe.blockmap', 'win-x64-release.json', 'nightly.yml'] - : ['deepseek-harness-1.0.0-mac-arm64.dmg', 'deepseek-harness-1.0.0-mac-arm64.zip', 'deepseek-harness-1.0.0-mac-arm64.zip.blockmap', 'mac-arm64-release.json', 'nightly-mac.yml'] + : [`deepseek-harness-1.0.0-${target}.dmg`, `deepseek-harness-1.0.0-${target}.zip`, `deepseek-harness-1.0.0-${target}.zip.blockmap`, `${target}-release.json`, 'nightly-mac.yml'] await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture'))) const env = { DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', - DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', + DSH_DESKTOP_TARGET_ARCH: target === 'mac-arm64' ? 'arm64' : 'x64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_SIGNING: qualification === 'unsigned' ? 'unsigned' : 'signed', PACKAGE_SKIP_NOTARIZATION: qualification === 'unnotarized' ? 'true' : 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', GITHUB_STEP_SUMMARY: join(root, 'summary.md'), GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated', diff --git a/scripts/desktop-package-forms.spec.ts b/scripts/desktop-package-forms.spec.ts new file mode 100644 index 0000000000..f1300b2cbb --- /dev/null +++ b/scripts/desktop-package-forms.spec.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import yaml from 'js-yaml' +import { expect, it } from 'vitest' + +it('presents common packaging fields in the same order with the same defaults', () => { + const workflows = ['macos-package.yml', 'macos-intel-package.yml', 'windows-package.yml'].map(name => + yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows', name), 'utf8')) as { + on: { workflow_dispatch: { inputs: Record> } } + }) + const common = ['source_ref', 'preset', 'update_channel', 'version_mode', 'build_version', 'test_release_id', 'compression_level'] + const reference = workflows[0]!.on.workflow_dispatch.inputs + for (const workflow of workflows) { + const inputs = workflow.on.workflow_dispatch.inputs + expect(Object.keys(inputs).slice(0, common.length)).toEqual(common) + for (const field of common) { + const { description: _description, ...settings } = inputs[field]! + const { description: _referenceDescription, ...expected } = reference[field]! + expect(settings).toEqual(expected) + if (field !== 'compression_level') expect(inputs[field]).toEqual(reference[field]) + } + } +}) diff --git a/scripts/macos-package-workflow.spec.ts b/scripts/macos-package-workflow.spec.ts index 6d806b0af6..a0bb2cb027 100644 --- a/scripts/macos-package-workflow.spec.ts +++ b/scripts/macos-package-workflow.spec.ts @@ -1,70 +1,88 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import yaml from 'js-yaml' -import { expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' -const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github/workflows/macos-package.yml'), 'utf8')) as { - on: Record - permissions: Record - concurrency: { group: string; 'cancel-in-progress': boolean } - jobs: Record - steps: Array<{ - id?: string - name?: string - if?: string - uses?: string - run?: string - env?: Record - with?: Record +describe.each(['arm64', 'x64'])('macOS %s packaging', (arch) => { + const filename = arch === 'arm64' ? 'macos-package.yml' : 'macos-intel-package.yml' + const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, `../.github/workflows/${filename}`), 'utf8')) as { + on: Record + permissions: Record + concurrency: { group: string; 'cancel-in-progress': boolean } + jobs: Record + steps: Array<{ + id?: string + name?: string + if?: string + uses?: string + run?: string + env?: Record + with?: Record + }> }> - }> -} + } -it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silicon runner', () => { - expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) - expect(workflow.jobs.package).not.toHaveProperty('name') - expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { test_release_id: { type: 'string', default: '', required: false } } }) - expect(workflow.on.workflow_dispatch).not.toHaveProperty('inputs.publish') - expect(workflow.permissions).toEqual({ contents: 'read' }) - expect(workflow.concurrency).toEqual({ group: 'macos-desktop-package', 'cancel-in-progress': false }) - expect(workflow.jobs.package!['runs-on']).toEqual(['self-hosted', 'macOS', 'ARM64', 'dsh-mac-package-trial']) - const steps = workflow.jobs.package!.steps - expect(steps.find(step => step.id === 'checkout')?.with).toMatchObject({ 'persist-credentials': false, clean: true }) - for (const step of steps) expect(step.run ?? '').not.toContain('${{ inputs.') - const uploads = steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) - 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('${{ steps.retained.outputs.directory }}/*') - expect(uploads[1]?.with?.['compression-level']).toBe(0) -}) + it('confines signed Mac packaging to manual dispatch and a dedicated Apple Silicon runner', () => { + expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) + expect(workflow.jobs.package).not.toHaveProperty('name') + expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { test_release_id: { type: 'string', default: '', required: false } } }) + expect(workflow.on.workflow_dispatch).not.toHaveProperty('inputs.publish') + expect(workflow.permissions).toEqual({ contents: 'read' }) + expect(workflow.concurrency).toEqual({ group: 'macos-desktop-package', 'cancel-in-progress': false }) + expect(workflow.jobs.package!['runs-on']).toEqual(['self-hosted', 'macOS', 'ARM64', 'dsh-mac-package-trial']) + const steps = workflow.jobs.package!.steps + expect(steps.find(step => step.id === 'checkout')?.with).toMatchObject({ 'persist-credentials': false, clean: true }) + for (const step of steps) expect(step.run ?? '').not.toContain('${{ inputs.') + const uploads = steps.filter(step => step.uses?.startsWith('actions/upload-artifact@')) + 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('${{ steps.retained.outputs.directory }}/*') + expect(uploads[1]?.with?.['compression-level']).toBe(0) + }) -it('defaults to notarization and labels the explicit debugging option', () => { - expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { skip_notarization: { - type: 'boolean', default: false, - description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选', - } } }) - const steps = workflow.jobs.package!.steps - expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2) - expect(steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ steps.settings.outputs.mode }}') -}) + it('defaults to notarization and labels the explicit debugging option', () => { + expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { skip_notarization: { + type: 'boolean', default: false, + description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选', + } } }) + const steps = workflow.jobs.package!.steps + expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2) + expect(steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ steps.settings.outputs.mode }}') + }) -it('retains build inputs outside checkout and run-specific pnpm installations', () => { - const env = workflow.jobs.package!.env - expect(JSON.stringify(env)).not.toContain('runner.') - const steps = workflow.jobs.package!.steps - const cache = steps.find(step => step.name === 'Configure persistent package caches')! - expect(cache.env?.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') - expect(cache.run).toContain('PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store') - expect(cache.run).toContain('DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR') - expect(steps.indexOf(cache)).toBeLessThan(steps.findIndex(step => step.name === 'Install locked dependencies')) -}) + it('retains build inputs outside checkout and run-specific pnpm installations', () => { + const env = workflow.jobs.package!.env + expect(JSON.stringify(env)).not.toContain('runner.') + const steps = workflow.jobs.package!.steps + const cache = steps.find(step => step.name === 'Configure persistent package caches')! + expect(cache.env?.DSH_DESKTOP_PACKAGE_CACHE_DIR).toBe('${{ runner.tool_cache }}/dsh-desktop-package-cache') + expect(cache.run).toContain('PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store') + expect(cache.run).toContain('DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR') + expect(steps.indexOf(cache)).toBeLessThan(steps.findIndex(step => step.name === 'Install locked dependencies')) + }) -it('offers ZIP compression choices with a fast default', () => { - expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { zip_compression_level: { - type: 'choice', options: ['3', '7'], default: '3', required: true, - } } }) - expect(workflow.jobs.package!.env.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL).toBe('${{ inputs.zip_compression_level }}') + it('offers ZIP compression choices with a fast default', () => { + expect(workflow.on.workflow_dispatch).toMatchObject({ inputs: { compression_level: { + type: 'choice', options: ['3', '7'], default: '3', required: true, + } } }) + expect(workflow.jobs.package!.env.PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL).toBe('${{ inputs.compression_level }}') + }) + + it('passes the same target to preparation, packaging and retention', () => { + const job = workflow.jobs.package! + expect(job.env.DSH_DESKTOP_TARGET_PLATFORM).toBe('darwin') + expect(job.env.DSH_DESKTOP_TARGET_ARCH).toBe(arch) + const builds = job.steps.filter(step => step.run?.includes('pnpm run package:desktop:mac:')) + expect(builds).toHaveLength(2) + for (const step of builds) expect(step.run).toContain(`package:desktop:mac:${arch}`) + expect(job.steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain(`macos-${arch}-`) + if (arch === 'x64') { + const rosetta = job.steps.findIndex(step => step.run === 'arch -x86_64 /usr/bin/true') + expect(rosetta).toBeGreaterThan(-1) + expect(rosetta).toBeLessThan(job.steps.findIndex(step => step.name === 'Install locked dependencies')) + } + }) })