diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 7358c0f5c4..cb71142444 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -4,4 +4,4 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes. -The Desktop publication workflow routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Production jobs use the separate `desktop-production` approval Environment. +The Desktop publication workflow routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job. diff --git a/.github/workflows/desktop-publish.yml b/.github/workflows/desktop-publish.yml index 919170bdd4..8e494386e2 100644 --- a/.github/workflows/desktop-publish.yml +++ b/.github/workflows/desktop-publish.yml @@ -34,16 +34,49 @@ jobs: DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts - publish: + prepare: needs: route if: needs.route.outputs.publish == 'true' runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + timeout-minutes: 30 + outputs: + approval: ${{ steps.check.outputs.approval }} + label: ${{ steps.check.outputs.label }} + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }} + DSH_TELEMETRY_DISABLED: '1' + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: desktop-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: desktop-publisher/package.json + dest: ${{ runner.temp }}/publish-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Verify local package without upload credentials + id: check + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-ci.ts --check + + publish: + name: Approve ${{ needs.prepare.outputs.label }} + needs: [route, prepare] + if: needs.route.outputs.publish == 'true' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] environment: ${{ needs.route.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }} concurrency: group: desktop-publish-${{ needs.route.outputs.runner }}-${{ needs.route.outputs.preset }} cancel-in-progress: false timeout-minutes: 90 env: + DESKTOP_APPROVED_PACKAGE: ${{ needs.prepare.outputs.approval }} DESKTOP_PUBLISH_ROUTE: ${{ needs.route.outputs.route }} DSH_TELEMETRY_DISABLED: '1' steps: diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 2bb5f8fdf2..c88de36c66 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -15,7 +15,7 @@ on: default: false required: true publish: - description: 'Publish successful signed packages and update the feed; production requires approval' + description: 'Publish successful signed packages and update the feed; test and production require approval' type: boolean default: false required: true @@ -24,11 +24,28 @@ on: type: string default: '' required: false - build_version: - description: 'Version: auto recommended; explicit version must match the checkout product prefix' + source_ref: + description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' type: string - default: auto + default: master required: true + update_channel: + description: 'Installer update channel; automatic updates stay on this channel' + type: choice + options: [nightly, latest] + default: nightly + required: true + version_mode: + description: 'default: production follows product, test auto; manual uses build_version' + type: choice + options: [default, product, auto, manual] + default: default + required: true + build_version: + description: 'Manual version only: product version or its supported prerelease suffix' + type: string + default: '' + required: false permissions: contents: read @@ -46,6 +63,9 @@ jobs: shell: bash env: 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_PUBLISH: ${{ inputs.publish }} @@ -56,6 +76,14 @@ jobs: 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; + if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; + if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + 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 }} @@ -86,6 +114,7 @@ jobs: - 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 diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 9b3cbdb7a3..320f77265b 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -16,7 +16,7 @@ on: default: unsigned required: true publish: - description: 'Publish successful signed packages and update the feed; production requires approval' + description: 'Publish successful signed packages and update the feed; test and production require approval' type: boolean default: false required: true @@ -25,11 +25,28 @@ on: type: string default: '' required: false - build_version: - description: 'Version: auto recommended; or checkout product version / e.g. 0.2.1-alpha.1.20261009.1 (prefix must match checkout)' + source_ref: + description: 'Source: master or a dsh-v* tag containing the Desktop CI adapters' type: string - default: auto + default: master required: true + update_channel: + description: 'Installer update channel; automatic updates stay on this channel' + type: choice + options: [nightly, latest] + default: nightly + required: true + version_mode: + description: 'default: production follows product, test auto; manual uses build_version' + type: choice + options: [default, product, auto, manual] + default: default + required: true + build_version: + description: 'Manual version only: product version or its supported prerelease suffix' + type: string + default: '' + required: false permissions: contents: read @@ -46,6 +63,9 @@ jobs: run: shell: pwsh env: + 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_PUBLISH: ${{ inputs.publish }} @@ -68,6 +88,14 @@ jobs: 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; + if(ref!=='master' && !/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref)) throw Error('Select master or dsh-v*'); + const v=JSON.parse(fs.readFileSync('package.json','utf8')).version; + if(ref!=='master' && ref!=='dsh-v'+v) throw Error('Source tag must match product version'); + 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 }} @@ -102,6 +130,7 @@ jobs: - 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 diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index a4eff8cc20..40103064d6 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -18,8 +18,8 @@ en: 17c6bcdefa05a8f8 zh: 4ee18f832276144d /desktop-packaging/release-versions: - en: d80a92c02a583b80 - zh: b27f75ff29fcea1a + en: 3f38ab26ee195b39 + zh: f00eddafa8f3b9ca /desktop-packaging/configuration-and-local-builds: en: f5dc36baf414ecc5 zh: 8984741b356ac1d3 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index b6548275c5..9cf46b68e1 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -61,7 +61,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable. -Version derivation does not change the fixed update channel or `nightly.yml` / `nightly-mac.yml` filenames. SemVer orders `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`, and a stable base's test version precedes that stable release. Clients only accept a greater version: replacing a feed cannot move an installed higher version to a lower corrected version. Such clients need manual installation; keep automatic downgrade disabled. The [archived version decision](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md) explains why the channel does not supply the prerelease identifier. +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `latest`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Latest packages reject prerelease versions. A stable product can produce a nightly `-test..` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish). diff --git a/apps/desktop/packaging/README.zh.md b/apps/desktop/packaging/README.zh.md index 83df32e75c..125b480ce5 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -61,7 +61,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609 production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。 -版本派生不改变固定更新通道,也不改变 `nightly.yml` / `nightly-mac.yml` 文件名。SemVer 排序为 `0.1.6-alpha.1 < 0.1.6-alpha.1.20260916.1 < 0.1.6-alpha.2`,稳定基础版本的测试版低于该稳定版。客户端只接受更高版本:替换 feed 无法让已安装的较高版本更新到较低的纠正版。这类客户端需要手动安装;保持自动降级关闭。[已归档的版本决策](../../../.agents/notes/archived/process/2026-09-16-desktop-release-version-derivation.md)解释为什么不能用通道名替换预发布标识。 +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `latest`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。latest 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index e391f93ff5..b5f7e587e8 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -9,23 +9,23 @@ en: a610b66106849734 zh: bc1760791f56fa28 /desktop-ci-publication/summary: - en: 51eb8286f4a0ae4c - zh: 9afc15bdcf1068c5 + en: 470bdf1487a739f9 + zh: cd80330eaa8f730c /desktop-ci-publication/table-of-contents: en: 1b6a4d7146879eec zh: f2520fc412cd2d2a /desktop-ci-publication/machine-setup: - en: b130c91a6a82c71d - zh: 527206eb3bf4b4a8 + en: 990ff7e56dd6005a + zh: 18f9dc23c520dc8c /desktop-ci-publication/package-and-publish: - en: 596fa81b1033ce87 - zh: fde2729de6275764 + en: 6e29eb0dff23e2c8 + zh: 65d5d57b63b86904 /desktop-ci-publication/recovery-and-records: - en: 33e6d91a62f858f8 - zh: 4a2286c838f83682 + en: 370f21dc25fc1098 + zh: cf7f433b8eaa3dcd /desktop-ci-publication/restore-a-channel: - en: 98620fee90ea1271 - zh: 3517d565ae8d24dd + en: d82e2ccf70cce397 + zh: d93dcb77cc4b463e /desktop-ci-publication/dev-note: en: b75c8458928b72b9 zh: 003ba4e17b4b5269 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index cbc65905da..98bbb3cb3c 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -Publish a completed package from its original machine without downloading its installer again. Both test and production publish binaries followed by update YAML. Production requires GitHub Environment approval and a GitHub App tag. Real COS, CDN and administrator-permission acceptance remain deployment checks; local fixtures do not establish those permissions. +Publish a completed package from its original machine without downloading its installer again. Both test and production publish binaries followed by update YAML. Both deployments require GitHub Environment approval; only production creates a GitHub App tag. Real COS, CDN and administrator-permission acceptance remain deployment checks; local fixtures do not establish those permissions. ## Table of Contents @@ -27,25 +27,25 @@ Create `publishing/test.json` and, when authorized, `publishing/production.json` 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. -An administrator must create `desktop-production` with required reviewers and an allowed `master` deployment branch. The workflow checks that required reviewers exist before scheduling production. 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. +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. -Environment approval gates this workflow, not arbitrary code running under the same machine account. Account isolation and forced source-branch restrictions are separate operational work. Keep existing release Environments unchanged. The test job uses `desktop-test` without requiring production approval. +Environment approval gates this workflow, not arbitrary code running under the same machine account. Account isolation and forced source-branch restrictions are separate operational work. Keep existing release Environments unchanged. The test job uses its own reviewers and never creates a real release tag; local simulated transports exercise tag creation and recovery without verifying App permissions. ## Package and publish -Select the platform's `Package Desktop` workflow, preset and version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. +Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts `master` or `dsh-v*` matching the selected product version; the selected source must contain these CI adapters, so older tags fail before dependency installation. The publisher runs from the default branch while recording the actual source commit separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits. Select `publish` to request publication after packaging succeeds. Windows must be signed; macOS must finish notarization and stapling. The separate `Publish Desktop · COS` workflow reads the small request manifest from GitHub, binds it to the source run and attempt, and routes to the original machine. It uses the locally retained installers. A failed build or one without a publication request produces no upload. -Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. The production job waits for a reviewer to select **Review deployments → Approve and deploy** before running. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. +Before uploading, the publisher hashes all retained files, checks version and destination metadata, and rechecks the platform signatures. macOS verifies the DMG and extracts the ZIP to verify the app's ticket; Windows checks Authenticode, the configured signer thumbprint and the timestamp. Preparation writes an approval summary with version, channel, platform, deployment, source ref/SHA, package run/attempt, object keys and proposed tag. Both publishing jobs wait for **Review deployments → Approve and deploy**, then recheck the approved manifest and upload plan digest. Changed bytes or destinations reject. Approval cannot edit a version; reject and rebuild instead. Both deployments upload missing binaries, verify remote bytes, then update YAML. There is no files-only mode in this workflow. Production finally creates `desktop-v` at the recorded packaging commit; an existing tag must name the same commit. ## Recovery and records -Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Stable releases update both nightly and latest feeds: if nightly already contains a higher prerelease (for example 1.1.0-alpha.1), a 1.0.1 stable hotfix is rejected before any upload. Use a higher corrective version; this workflow does not implicitly downgrade nightly. +Re-run the publishing workflow after resolving a failure; do not rebuild solely to retry an upload. Its source package run and attempt remain fixed. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit. The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; raw provider responses are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate. @@ -57,7 +57,7 @@ Normal publication and manual restoration acquire the same local channel lock. A ## Restore a channel -Run the repository script `apps/desktop/scripts/rollback-desktop-channel.ts` through `node --import tsx` on the publishing machine. Keep `GITHUB_REPOSITORY`, `RUNNER_NAME`, `DSH_PACKAGING_CONFIG_DIR` and `DESKTOP_PUBLISH_ROUTE` set to the original package context. The route JSON contains `runId`, `attempt`, `commit`, `runnerName`, `target` and `preset`, available in the retained manifest's source fields. Pass `--record` with that successful publication's audit directory, `--expected-version` with the version currently online, and `--reason` with the operational reason. +Run the repository script `apps/desktop/scripts/rollback-desktop-channel.ts` through `node --import tsx` on the publishing machine. Keep `GITHUB_REPOSITORY`, `RUNNER_NAME`, `DSH_PACKAGING_CONFIG_DIR` and `DESKTOP_PUBLISH_ROUTE` set to the original package context. The route JSON contains `runId`, `attempt`, `commit`, `runnerName`, `target`, `preset`, `sourceRef` and `channel`, available in the retained manifest's source fields. Pass `--record` with that successful publication's audit directory, `--expected-version` with the version currently online, and `--reason` with the operational reason. Without `--execute`, the script reads remote objects and prints the exact YAML change without writing. Preview requires COS read credentials. With `--execute`, it verifies historical files, preserves before/after YAML in a new audit record, checks the current channel again, and restores only YAML. It neither uploads binaries nor changes tags. A channel already matching the target bytes is skipped; other channels must match the expected current version. This permits recovery after one feed was restored before an interruption. A different current version aborts the operation. Restoration is not a multi-object transaction; inspect the audit after any partial failure before continuing. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 7d801493fa..d689d2a85e 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -8,7 +8,7 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 ## 概述 -在原打包机器上发布完整安装包,无需再次下载安装包。test 和 production 都先发布二进制文件,再更新 YAML。production 需要 GitHub Environment 审批,并通过 GitHub App 创建 tag。真实 COS、CDN 和管理员权限仍需部署验收;本地 fixture 不证明这些权限可用。 +在原打包机器上发布完整安装包,无需再次下载安装包。test 和 production 都先发布二进制文件,再更新 YAML。两种环境都需要 GitHub Environment 审批;只有 production 通过 GitHub App 创建 tag。真实 COS、CDN 和管理员权限仍需部署验收;本地 fixture 不证明这些权限可用。 ## 目录 @@ -27,25 +27,25 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。 -管理员必须创建 `desktop-production`,设置必需审批人并允许 `master` 部署分支。工作流调度 production 前会检查必需审批人已配置。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。 +管理员必须创建 `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` 文件;日志屏蔽不限制本地文件访问。 -Environment 审批限制此工作流,不限制同一机器账户运行的任意代码。账户隔离和强制源分支限制属于独立运维工作。保持现有发布 Environment 不变。test job 使用 `desktop-test`,无需 production 审批。 +Environment 审批限制此工作流,不限制同一机器账户运行的任意代码。账户隔离和强制源分支限制属于独立运维工作。保持现有发布 Environment 不变。test job 使用自己的审批人,不创建真实发布 tag;本地模拟接口覆盖 tag 创建与恢复,但不验证 App 权限。 ## 打包与发布 -选择对应平台的 `Package Desktop` 工作流、preset 和版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。`source_ref` 接受 `master` 或与所选产品版本一致的 `dsh-v*`;源码必须包含这些 CI 适配脚本,因此旧 tag 会在安装依赖前失败。发布器从默认分支运行,单独记录实际源码提交。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 选中 `publish`,请求在打包成功后发布。Windows 必须签名;macOS 必须完成公证和 stapling。独立的 `Publish Desktop · COS` 工作流从 GitHub 读取小型请求 manifest,将其与原 run 和 attempt 核对,再路由到原机器。安装包使用本机留存文件。构建失败或未请求发布时不会上传。 -上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。production job 等待审批人选择 **Review deployments → Approve and deploy** 后才运行。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 +上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成审批摘要,列出版本、通道、平台、环境、源码 ref/SHA、package run/attempt、对象路径及拟创建的 tag。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 ## 恢复与记录 -解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。稳定版同时更新 nightly 与 latest:若 nightly 已有更高预发行版(例如 1.1.0-alpha.1),1.0.1 稳定热修复会在任何上传前被拒绝。应发布更高版本的修正版;此工作流不会隐式降级 nightly。 +解决故障后重跑发布工作流,不要仅为重试上传而重新打包。原 package run 和 attempt 保持不变。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。 同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断,不保存原始服务响应。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。 @@ -57,7 +57,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 ## 恢复频道 -在发布机器上通过 `node --import tsx` 运行仓库脚本 `apps/desktop/scripts/rollback-desktop-channel.ts`。保持 `GITHUB_REPOSITORY`、`RUNNER_NAME`、`DSH_PACKAGING_CONFIG_DIR` 和 `DESKTOP_PUBLISH_ROUTE` 对应原打包上下文。route JSON 包含 `runId`、`attempt`、`commit`、`runnerName`、`target` 和 `preset`,可从留存 manifest 的 source 字段取得。`--record` 指定该次成功发布的审计目录,`--expected-version` 指定当前线上版本,`--reason` 填写操作原因。 +在发布机器上通过 `node --import tsx` 运行仓库脚本 `apps/desktop/scripts/rollback-desktop-channel.ts`。保持 `GITHUB_REPOSITORY`、`RUNNER_NAME`、`DSH_PACKAGING_CONFIG_DIR` 和 `DESKTOP_PUBLISH_ROUTE` 对应原打包上下文。route JSON 包含 `runId`、`attempt`、`commit`、`runnerName`、`target`、`preset`、`sourceRef` 和 `channel`,可从留存 manifest 的 source 字段取得。`--record` 指定该次成功发布的审计目录,`--expected-version` 指定当前线上版本,`--reason` 填写操作原因。 不带 `--execute` 时,脚本读取远端对象并打印确切 YAML 变更,不执行写入。预览需要 COS 读取凭据。带 `--execute` 时,脚本校验历史文件,将前后 YAML 保存在新的审计记录中,再次检查当前频道,然后仅恢复 YAML。它不上传二进制文件,也不改变 tag。已经与目标字节完全相同的频道会跳过,其余频道必须匹配预期当前版本,因此可以恢复写完一个 feed 后中断的操作。其他当前版本会中止操作。恢复不是多对象事务;部分失败后需先检查审计记录再继续。 diff --git a/apps/desktop/scripts/desktop-auto-update-environment.d.mts b/apps/desktop/scripts/desktop-auto-update-environment.d.mts index 00ebf8c5c0..f656f9baaf 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.d.mts +++ b/apps/desktop/scripts/desktop-auto-update-environment.d.mts @@ -10,6 +10,7 @@ export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64' /** Public updater URL and object prefixes for one release target. */ export interface DesktopAutoUpdateConfig { readonly environment: DesktopAutoUpdateEnvironment + readonly channel: 'nightly' | 'latest' readonly target: DesktopAutoUpdateTarget readonly origin: string readonly publicUrl: string @@ -60,6 +61,7 @@ export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): str export function desktopUpdateMetadataFilename( version: string, platform: NodeJS.Platform, + channel?: 'nightly' | 'latest', ): string /** @@ -89,3 +91,10 @@ export function resolveDesktopUploadConfig( platform: NodeJS.Platform, arch: string, ): DesktopUploadConfig + +/** + * Resolve the packaged update channel, defaulting omitted local settings to nightly. + * @param env Packaging settings. + * @returns Validated channel. + */ +export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest' diff --git a/apps/desktop/scripts/desktop-auto-update-environment.mjs b/apps/desktop/scripts/desktop-auto-update-environment.mjs index 464d079b4d..a1973a9c64 100644 --- a/apps/desktop/scripts/desktop-auto-update-environment.mjs +++ b/apps/desktop/scripts/desktop-auto-update-environment.mjs @@ -5,6 +5,17 @@ import { valid } from 'semver' /** Environment variable that selects the Desktop update deployment. */ export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV' +/** + * Resolve the channel embedded in the installer; omitted local settings retain nightly. + * @param {NodeJS.ProcessEnv} env Packaging or upload settings. + * @returns {'nightly' | 'latest'} Validated channel. + */ +export function resolveDesktopUpdateChannel(env) { + const channel = env.DSH_DESKTOP_UPDATE_CHANNEL || 'nightly' + if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop auto-update: invalid update channel') + return channel +} + const UPDATE_ENVIRONMENTS = { test: { originEnvName: 'DOWNLOAD_TEST_ORIGIN', @@ -68,16 +79,18 @@ export function desktopBuildRecordFilename(target) { * Return the electron-builder channel metadata filename for an application version. * @param {string} version - Desktop semantic version. * @param {NodeJS.Platform} platform - Target platform. + * @param {'nightly' | 'latest'} channel - Selected channel; omitted means nightly. * @returns {string} Channel metadata filename emitted for the target. */ -export function desktopUpdateMetadataFilename(version, platform) { +export function desktopUpdateMetadataFilename(version, platform, channel = 'nightly') { if (valid(version) === null) { throw new Error(`desktop auto-update: invalid Desktop version ${JSON.stringify(version)}`) } if (platform !== 'darwin' && platform !== 'win32') { throw new Error(`desktop auto-update: unsupported metadata platform ${platform}`) } - return `nightly${platform === 'darwin' ? '-mac' : ''}.yml` + resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: channel }) + return `${channel}${platform === 'darwin' ? '-mac' : ''}.yml` } /** @@ -124,10 +137,11 @@ function httpsOrigin(value, name) { * @param {NodeJS.ProcessEnv} env - Packaging or upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration. * @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID. */ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { + const channel = resolveDesktopUpdateChannel(env) const environment = resolveDesktopAutoUpdateEnvironment(env) const target = resolveDesktopAutoUpdateTarget(platform, arch) const deployment = UPDATE_ENVIRONMENTS[environment] @@ -148,10 +162,11 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { const keyPrefix = `${releasePrefix}/feeds/${target}` return { environment, + channel, target, origin, keyPrefix, - binaryKeyPrefix: `${releasePrefix}/bin/${target}`, + binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'latest' ? '/latest' : ''}`, publicUrl: `${origin}/${keyPrefix}/`, } } @@ -161,7 +176,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) { * @param {NodeJS.ProcessEnv} env - Upload environment. * @param {NodeJS.Platform} platform - Target Node.js platform. * @param {string} arch - Target Node.js architecture. - * @returns {{ environment: 'test' | 'production', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. + * @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration. * @throws {Error} When the selected deployment lacks a bucket or valid updater configuration. */ export function resolveDesktopUploadConfig(env, platform, arch) { diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs index 294d00ef3b..8fef629287 100644 --- a/apps/desktop/scripts/desktop-ci-version.mjs +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -9,7 +9,11 @@ import { desktopBuildVersionPrefix, validateDesktopBuildVersion } from './deskto * @returns {string} Validated build version. */ export function desktopCiVersion(environment, productVersion, now = new Date()) { - const requested = environment.PACKAGE_BUILD_VERSION?.trim() + const mode = environment.PACKAGE_VERSION_MODE + const selected = mode === 'default' ? (environment.DSH_PACKAGING_PRESET === 'production' ? 'product' : 'auto') : mode + if (selected !== undefined && !['product', 'auto', 'manual'].includes(selected)) throw new Error('Invalid version mode') + const requested = selected === 'product' ? productVersion : selected === 'auto' ? 'auto' : environment.PACKAGE_BUILD_VERSION?.trim() + if (selected === 'manual' && (!requested || requested === 'auto')) throw new Error('Manual version mode requires an explicit version') const date = now.toISOString().slice(0, 10).replaceAll('-', '') const example = `${desktopBuildVersionPrefix(productVersion)}${date}.1` if (!requested) throw new Error(`Build version is required: use auto, ${productVersion}, or e.g. ${example}`) @@ -20,8 +24,10 @@ export function desktopCiVersion(environment, productVersion, now = new Date()) } candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}` } + if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'latest' && candidate.includes('-')) throw new Error('Latest channel requires a stable version; select product or manual stable version') try { - return validateDesktopBuildVersion(candidate, productVersion) + const resolved = validateDesktopBuildVersion(candidate, productVersion) + return resolved } catch { throw new Error(`Invalid build version for this checkout: use auto, ${productVersion}, or e.g. ${example}`) } diff --git a/apps/desktop/scripts/desktop-package-environment.mjs b/apps/desktop/scripts/desktop-package-environment.mjs index 89540b7363..8116dea245 100644 --- a/apps/desktop/scripts/desktop-package-environment.mjs +++ b/apps/desktop/scripts/desktop-package-environment.mjs @@ -77,6 +77,7 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en return { ...Object.fromEntries(Object.entries(environment).filter(([name]) => !AMBIENT_RELEASE_SETTING.test(name))), ...settings, + ...(environment.DSH_DESKTOP_UPDATE_CHANNEL === undefined ? {} : { DSH_DESKTOP_UPDATE_CHANNEL: environment.DSH_DESKTOP_UPDATE_CHANNEL }), ...(preset === undefined ? {} : { DSH_PACKAGING_PRESET: preset }), } } diff --git a/apps/desktop/scripts/desktop-publish-route.ts b/apps/desktop/scripts/desktop-publish-route.ts index b469ca53d9..46c869c4be 100644 --- a/apps/desktop/scripts/desktop-publish-route.ts +++ b/apps/desktop/scripts/desktop-publish-route.ts @@ -8,7 +8,9 @@ const runSchema = z.object({ repository: z.object({ full_name: z.string() }), }) const requestSchema = z.object({ source: z.object({ - repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string(), + repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u), + workflowCommit: z.string(), sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + channel: z.enum(['nightly', 'latest']), runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']), publishRequested: z.literal(true), qualification: z.enum(['signed', 'notarized']), }) }) @@ -20,6 +22,8 @@ export interface DesktopPublishRoute { readonly runId: string readonly attempt: string readonly commit: string + readonly sourceRef: string + readonly channel: 'nightly' | 'latest' readonly runnerName: string readonly runnerLabel: string readonly target: 'mac-arm64' | 'win-x64' @@ -44,7 +48,8 @@ export function resolveDesktopPublishRoute( const routes = routesSchema.parse(routesValue) const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml' if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow - || source.runId !== String(run.id) || source.attempt !== String(run.run_attempt) || source.commit !== run.head_sha + || source.runId !== String(run.id) || source.attempt !== String(run.run_attempt) + || source.workflowCommit !== run.head_sha || source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) { throw new Error('desktop publish: request does not match the successful packaging attempt') } @@ -55,6 +60,7 @@ export function resolveDesktopPublishRoute( } const matches = jobs.filter(job => job.runner_name === source.runnerName && job.conclusion === 'success') if (matches.length !== 1) throw new Error('desktop publish: original job does not establish a single successful packaging runner') - return { runId: source.runId, attempt: source.attempt, commit: source.commit, runnerName: source.runnerName, + return { runId: source.runId, attempt: source.attempt, commit: source.commit, sourceRef: source.sourceRef, channel: source.channel, + runnerName: source.runnerName, runnerLabel: route.label, target: source.target, preset: source.preset } } diff --git a/apps/desktop/scripts/desktop-release-store.ts b/apps/desktop/scripts/desktop-release-store.ts index 379ac56bb5..904d65af07 100644 --- a/apps/desktop/scripts/desktop-release-store.ts +++ b/apps/desktop/scripts/desktop-release-store.ts @@ -12,6 +12,9 @@ const sourceSchema = z.strictObject({ runId: positiveId, attempt: positiveId, commit: z.string().regex(/^[a-f0-9]{40}$/u), + workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u), + sourceRef: z.string().regex(/^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u), + channel: z.enum(['nightly', 'latest']), runnerName: z.string().min(1), // Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms. target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index a9d61d1ef5..aa05966c4a 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -5,7 +5,6 @@ import { createReadStream } from 'node:fs' import { readFile, stat } from 'node:fs/promises' import { basename, join, resolve } from 'node:path' import { dump, load } from 'js-yaml' -import { prerelease } from 'semver' import type { DesktopPackageTargetName } from './package-target.ts' import { desktopBuildRecordFilename, @@ -213,12 +212,14 @@ export async function createDesktopUploadPlan( } if (buildRecord.schemaVersion !== 1 || buildRecord.target !== targetName + || (buildRecord.channel ?? 'nightly') !== update.channel || buildRecord.environment !== update.environment || buildRecord.publicUrl !== update.publicUrl) { throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform) + if (update.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop upload: latest requires a stable version') + const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown try { @@ -276,10 +277,6 @@ export async function createDesktopUploadPlan( contents: dump(published), } artifacts.push(channelArtifact) - if (prerelease(buildVersion) === null) { - const stableFilename = metadataFilename.replace('nightly', 'latest') - artifacts.push({ ...channelArtifact, filename: stableFilename, key: `${update.keyPrefix}/${stableFilename}` }) - } const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}` const latestKey = `desktop/${latestFilename}` return { diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index 2fa3759ed3..4b9cd27a8d 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -101,12 +101,14 @@ export function createElectronBuilderConfig( // the artifact names, the update feed, and the installed app.getVersion() the updater compares against. const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version const buildVersion = resolveDesktopBuildVersion(env, productVersion) + if (update?.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop package: latest requires a stable version') const packaged = resolveDesktopBuildCommit(env) return { appId, protocols: [{ name: 'DeepSeek Harness', schemes: ['dsh'] }], extraMetadata: { dshDesktopAppId: appId, + dshDesktopUpdateChannel: update?.channel ?? 'nightly', ...(policy === undefined ? {} : { dshMandatoryUpdatePolicy: policy }), ...buildVersion === productVersion ? {} : { version: buildVersion }, ...packaged === undefined ? {} : { dshBuildCommit: packaged.commit, dshBuildDirty: packaged.dirty }, @@ -259,6 +261,6 @@ export function createElectronBuilderConfig( differentialPackage: true, }, detectUpdateChannel: false, - publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }], + publish: update === undefined ? null : [{ provider: 'generic', url: update.publicUrl, channel: update.channel }], } } diff --git a/apps/desktop/scripts/macos-app-update-config.d.mts b/apps/desktop/scripts/macos-app-update-config.d.mts index 624ab0d341..ce18266c48 100644 --- a/apps/desktop/scripts/macos-app-update-config.d.mts +++ b/apps/desktop/scripts/macos-app-update-config.d.mts @@ -1,13 +1,14 @@ /** Resolved fields required to embed a macOS updater feed. */ export interface MacOSAppUpdateFeed { readonly publicUrl: string + readonly channel?: 'nightly' | 'latest' } /** Packaged electron-updater configuration for macOS. */ export interface MacOSAppUpdateConfig { readonly provider: 'generic' readonly url: string - readonly channel: 'nightly' + readonly channel: 'nightly' | 'latest' readonly updaterCacheDirName: string } diff --git a/apps/desktop/scripts/macos-app-update-config.mjs b/apps/desktop/scripts/macos-app-update-config.mjs index 3b9e0019c6..d6b4132c71 100644 --- a/apps/desktop/scripts/macos-app-update-config.mjs +++ b/apps/desktop/scripts/macos-app-update-config.mjs @@ -5,7 +5,7 @@ import { join } from 'node:path' import { dump, load } from 'js-yaml' const CONFIG_FILENAME = 'app-update.yml' -const CHANNEL = 'nightly' +import { resolveDesktopUpdateChannel } from './desktop-auto-update-environment.mjs' function object(value, label) { if (typeof value !== 'object' || value === null || Array.isArray(value)) { @@ -24,30 +24,30 @@ function nonEmptyString(value, label) { /** * Resolve the one generic macOS feed from the final electron-builder configuration. * @param {unknown} publish - Final electron-builder publish setting. - * @returns {{ publicUrl: string }} Resolved feed used by the packaged App. + * @returns {{ publicUrl: string, channel?: 'nightly' | 'latest' }} Resolved feed used by the packaged App. */ export function resolveMacOSAppUpdateFeed(publish) { if (!Array.isArray(publish) || publish.length !== 1) { throw new Error('desktop macOS update config: publish must contain exactly one provider') } const provider = object(publish[0], 'publish provider') - if (provider.provider !== 'generic' || provider.channel !== CHANNEL) { - throw new Error('desktop macOS update config: publish provider must be generic Nightly') + if (provider.provider !== 'generic' || !['nightly', 'latest'].includes(provider.channel)) { + throw new Error('desktop macOS update config: publish provider must be generic with nightly or latest channel') } - return { publicUrl: nonEmptyString(provider.url, 'publish provider URL') } + return { publicUrl: nonEmptyString(provider.url, 'publish provider URL'), channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: provider.channel }) } } /** * Create the electron-updater configuration embedded before code signing. - * @param {{ publicUrl: string }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. - * @returns {{ provider: 'generic', url: string, channel: 'nightly', updaterCacheDirName: string }} Packaged updater fields. + * @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'latest', updaterCacheDirName: string }} Packaged updater fields. */ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { return { provider: 'generic', url: nonEmptyString(update.publicUrl, 'public URL'), - channel: CHANNEL, + channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: update.channel }), updaterCacheDirName: nonEmptyString(updaterCacheDirName, 'updater cache directory'), } } @@ -55,7 +55,7 @@ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) { /** * Write the updater configuration into an assembled App before signing. * @param {string} resourcesDir - App Contents/Resources directory. - * @param {{ publicUrl: string }} update - Resolved update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed. * @param {string} updaterCacheDirName - electron-builder application cache directory. * @returns {Promise} Resolves after the configuration is durable. */ @@ -67,7 +67,7 @@ export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCac /** * Verify the updater configuration inside an assembled macOS App. * @param {string} appPath - Application bundle path. - * @param {{ publicUrl: string }} update - Expected update feed. + * @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Expected update feed. * @param {string | undefined} updaterCacheDirName - Exact cache directory when known. * @returns {Promise} Resolves when the packaged configuration matches the release destination. */ @@ -81,7 +81,7 @@ export async function verifyMacOSAppUpdateConfig(appPath, update, updaterCacheDi throw new Error(`desktop macOS update config: cannot read ${path}: ${error instanceof Error ? error.message : String(error)}`) } const config = object(parsed, CONFIG_FILENAME) - if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== CHANNEL) { + if (config.provider !== 'generic' || config.url !== update.publicUrl || config.channel !== resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: update.channel })) { throw new Error(`desktop macOS update config: ${path} does not match ${update.publicUrl}`) } const actualCacheDirName = nonEmptyString(config.updaterCacheDirName, `${CONFIG_FILENAME}.updaterCacheDirName`) diff --git a/apps/desktop/scripts/package-macos.ts b/apps/desktop/scripts/package-macos.ts index c1a2cb5c71..4ba0fd12ca 100644 --- a/apps/desktop/scripts/package-macos.ts +++ b/apps/desktop/scripts/package-macos.ts @@ -115,7 +115,7 @@ export async function packageMacOSArtifacts( [dmgOutput, `${base}.dmg`], [zipOutput, `${base}.zip`], [zipOutput, `${base}.zip.blockmap`], - [zipOutput, desktopUpdateMetadataFilename(version, 'darwin')], + [zipOutput, desktopUpdateMetadataFilename(version, 'darwin', update.channel)], ] as const for (const [output, filename] of artifacts) { const file = join(output, filename) diff --git a/apps/desktop/scripts/package-target.ts b/apps/desktop/scripts/package-target.ts index 99b1a4c5af..be2f577e1b 100644 --- a/apps/desktop/scripts/package-target.ts +++ b/apps/desktop/scripts/package-target.ts @@ -153,6 +153,7 @@ function writeReleaseRecord( target: target.name, version: buildVersion, environment: update.environment, + channel: update.channel, publicUrl: update.publicUrl, // Upload reads this to tag the commit a production release was packaged from. ...packaged === undefined ? {} : { commit: packaged.commit, dirty: packaged.dirty }, diff --git a/apps/desktop/scripts/prepare-macos-ci.mjs b/apps/desktop/scripts/prepare-macos-ci.mjs index 6974610a8c..5b51052cb6 100644 --- a/apps/desktop/scripts/prepare-macos-ci.mjs +++ b/apps/desktop/scripts/prepare-macos-ci.mjs @@ -1,6 +1,7 @@ /** Validate machine-owned macOS signing settings and record a manual Actions build version. */ import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { execFileSync } from 'node:child_process' import { desktopCiVersion } from './desktop-ci-version.mjs' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' @@ -21,5 +22,5 @@ for (const [name, value] of Object.entries(environment)) { validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization }) if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions') appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\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`) +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`) 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/prepare-windows-ci.mjs b/apps/desktop/scripts/prepare-windows-ci.mjs index f99e313180..307d9b0fcf 100644 --- a/apps/desktop/scripts/prepare-windows-ci.mjs +++ b/apps/desktop/scripts/prepare-windows-ci.mjs @@ -1,6 +1,7 @@ /** Resolve a manual Windows build version and validate the selected machine preset. */ import { appendFileSync, readFileSync } from 'node:fs' import { resolve } from 'node:path' +import { execFileSync } from 'node:child_process' import { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' export { desktopCiVersion as windowsCiVersion } from './desktop-ci-version.mjs' import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs' @@ -24,5 +25,5 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi validateDesktopPackageEnvironment(environment, { platform: 'win32', arch: 'x64' }, { unsigned: mode === 'unsigned' }) 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_STEP_SUMMARY, `Windows x64 ${mode} installer\n\nVersion: ${version}\n\nCommit: ${process.env.GITHUB_SHA}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`) + appendFileSync(process.env.GITHUB_STEP_SUMMARY, `Windows x64 ${mode} 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`) } diff --git a/apps/desktop/scripts/publish-desktop-ci.ts b/apps/desktop/scripts/publish-desktop-ci.ts index eff6317157..0abc317463 100644 --- a/apps/desktop/scripts/publish-desktop-ci.ts +++ b/apps/desktop/scripts/publish-desktop-ci.ts @@ -1,4 +1,5 @@ /** Publish a retained package selected by the routing job; --check never reads secrets or contacts COS. */ +import { createHash } from 'node:crypto' import { appendFile } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' import { z } from 'zod' @@ -14,6 +15,7 @@ import { desktopPublicationFailure } from './desktop-publication-failure.ts' import { publishDesktopTag } from './desktop-publish-tag.ts' 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']) }) /** @@ -29,7 +31,8 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi const manifest = await verifyRetainedDesktopRelease(root, route.runId, route.attempt) const source = manifest.source if (source.repository !== env.GITHUB_REPOSITORY || source.runnerName !== route.runnerName || source.target !== route.target - || source.commit !== route.commit || source.preset !== route.preset || !source.publishRequested) { + || source.channel !== route.channel || source.sourceRef !== route.sourceRef || source.commit !== route.commit + || source.preset !== route.preset || !source.publishRequested) { throw new Error('desktop publish: local package differs from the routed request') } if (source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) throw new Error('desktop publish: debugging artifacts cannot be published') @@ -40,10 +43,12 @@ export async function prepareDesktopPublication(env: NodeJS.ProcessEnv, verifySi DOWNLOAD_TEST_RELEASE_ID: source.testReleaseId ?? '', DOWNLOAD_TEST_COS_BUCKET: config.bucket } : { DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: config.bucket } const plan = await createDesktopUploadPlan(source.target, { artifactsRoot: directory, - packagedProductVersion: source.productVersion, environment }) + packagedProductVersion: source.productVersion, environment: { ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel } }) if (plan.version !== source.version || plan.commit !== source.commit || plan.dirty !== false) throw new Error('desktop publish: completion record differs from retained source or has uncommitted changes') await verifySignatures(directory, manifest, config) - return { route, manifest, config, plan, root } + const approval = createHash('sha256').update(JSON.stringify({ manifest, plan })).digest('hex') + if (env.DESKTOP_APPROVED_PACKAGE && env.DESKTOP_APPROVED_PACKAGE !== approval) throw new Error('desktop publish: package or destination changed after approval') + return { route, manifest, config, plan, root, approval } } if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { @@ -52,8 +57,13 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const mode = process.argv[2] if (process.argv.length !== 3 || (mode !== '--check' && mode !== '--publish')) throw new Error('Usage: publish-desktop-ci.ts --check|--publish') const env = process.env - const { route, manifest, config, plan, root } = await prepareDesktopPublication(env) + const { route, manifest, config, plan, root, approval } = await prepareDesktopPublication(env) + if (mode === '--publish' && !env.DESKTOP_APPROVED_PACKAGE) throw new Error('desktop publish: approved package digest is required') if (mode === '--check') { + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `approval=${approval}\nlabel=${plan.version} → ${manifest.source.channel} / ${plan.target} / ${plan.environment}\n`) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, + `\n## Confirm Desktop publication\n\nVersion: **${plan.version}**; channel: **${manifest.source.channel}**; environment: **${plan.environment}**; platform: **${plan.target}**\n\nSource: ${manifest.source.sourceRef} @ ${route.commit}\n\nPackage: ${route.runId}, attempt ${route.attempt}\n\nTag: ${plan.environment === 'production' ? `desktop-v${plan.version}` : 'none (test)'}\n\nApproved package SHA-256: ${approval}\n\n` + + plan.artifacts.map(file => `- ${file.channelMetadata ? 'Feed' : 'Artifact'}: ${file.key}`).join('\n') + '\n') if (env.GITHUB_OUTPUT && config.githubApp) await appendFile(env.GITHUB_OUTPUT, `app-client-id=${config.githubApp.clientId}\napp-key-file=${config.githubApp.privateKeyFile}\n`) process.stdout.write(`desktop publish: verified ${plan.target} ${plan.version}; no credentials read or COS requests made\n`) @@ -61,7 +71,7 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi if (plan.environment === 'production' && !env.DESKTOP_TAG_TOKEN) throw new Error('desktop publish: GitHub App token is required before production upload') phase = 'upload credential loading' const credentials = await readDesktopUploadCredentials(config) - const destination = `${plan.bucket}/${plan.publicUrl}` + const destination = `${plan.bucket}/${plan.publicUrl}${manifest.source.channel}` const recordsRoot = join(dirname(root), 'upload-records') phase = 'channel lock and publication journal acquisition' await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { diff --git a/apps/desktop/scripts/resolve-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-desktop-publish-ci.ts index e08b01f606..cd1326dceb 100644 --- a/apps/desktop/scripts/resolve-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-desktop-publish-ci.ts @@ -35,12 +35,21 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi const jobs = z.object({ jobs: z.array(z.unknown()) }).parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`)) const route = resolveDesktopPublishRoute(repository, api(`${base}/attempts/${attempt}`), request, jobs.jobs, JSON.parse(env.DESKTOP_PUBLISH_RUNNERS || '{}')) - if (route.preset === 'production') { + const source = z.object({ sha: z.string() }).parse(api(`repos/${repository}/commits/${route.commit}`)) + if (source.sha !== route.commit) throw new Error('desktop publish: source commit not found') + if (route.sourceRef === 'master') { + const comparison = z.object({ status: z.string() }).parse(api(`repos/${repository}/compare/${route.commit}...master`)) + if (!['ahead', 'identical'].includes(comparison.status)) throw new Error('desktop publish: source is not on master') + } else { + const tagged = z.object({ sha: z.string() }).parse(api(`repos/${repository}/commits/${route.sourceRef}`)) + if (tagged.sha !== route.commit) throw new Error('desktop publish: source tag changed') + } + { const environment = z.object({ protection_rules: z.array(z.object({ type: z.string(), reviewers: z.array(z.unknown()).optional() })) }) - .parse(api(`repos/${repository}/environments/desktop-production`)) + .parse(api(`repos/${repository}/environments/desktop-${route.preset}`)) if (!environment.protection_rules.some(rule => rule.type === 'required_reviewers' && (rule.reviewers?.length ?? 0) > 0)) { - throw new Error('desktop publish: desktop-production requires configured reviewers') + throw new Error(`desktop publish: desktop-${route.preset} requires configured reviewers`) } } await appendFile(env.GITHUB_OUTPUT, `publish=true\nroute=${JSON.stringify(route)}\nrunner=${route.runnerLabel}\npreset=${route.preset}\n`) diff --git a/apps/desktop/scripts/retain-desktop-ci.ts b/apps/desktop/scripts/retain-desktop-ci.ts index feee5247cd..9fddc8d995 100644 --- a/apps/desktop/scripts/retain-desktop-ci.ts +++ b/apps/desktop/scripts/retain-desktop-ci.ts @@ -2,7 +2,7 @@ import { appendFile, readFile } from 'node:fs/promises' import { dirname, isAbsolute, join, resolve } from 'node:path' import { desktopTargetBuildPaths, resolveDesktopBuildTarget } from './desktop-build-paths.mjs' -import { desktopBuildRecordFilename, desktopUpdateMetadataFilename } from './desktop-auto-update-environment.mjs' +import { desktopBuildRecordFilename, desktopUpdateMetadataFilename, resolveDesktopUpdateChannel } from './desktop-auto-update-environment.mjs' import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs' import { retainDesktopRelease, type DesktopRetainedSource } from './desktop-release-store.ts' import { validateDesktopBuildVersion } from './desktop-build-version.mjs' @@ -51,7 +51,9 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( const version = validateDesktopBuildVersion(required(env, 'PACKAGE_VERSION'), product.version) const source: DesktopRetainedSource = { repository: required(env, 'GITHUB_REPOSITORY'), runId: required(env, 'GITHUB_RUN_ID'), - attempt: required(env, 'GITHUB_RUN_ATTEMPT'), commit: required(env, 'GITHUB_SHA'), + attempt: required(env, 'GITHUB_RUN_ATTEMPT'), commit: required(env, 'PACKAGE_SOURCE_SHA'), + workflowCommit: required(env, 'GITHUB_SHA'), sourceRef: required(env, 'PACKAGE_SOURCE_REF'), + channel: resolveDesktopUpdateChannel(settings), runnerName: required(env, 'RUNNER_NAME'), target, preset, productVersion: product.version, version, testReleaseId: preset === 'test' ? (settings.DOWNLOAD_TEST_RELEASE_ID || null) : null, publishRequested: publish, qualification, @@ -65,7 +67,7 @@ export async function retainDesktopCi(env: NodeJS.ProcessEnv, appRoot = resolve( const debugSuffix = qualification === 'unnotarized' ? '-unnotarized' : qualification === 'unsigned' ? '-unsigned' : '' const names = windows ? [`${base}${debugSuffix}.exe`] : [`${base}${debugSuffix}.dmg`, `${base}${debugSuffix}.zip`] if (qualification === 'signed' || qualification === 'notarized') { - names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform)) + names.push(`${base}.${windows ? 'exe' : 'zip'}.blockmap`, desktopBuildRecordFilename(target), desktopUpdateMetadataFilename(version, platform, source.channel)) } const retained = await retainDesktopRelease(desktopReleasesRoot(env), source, names.map(name => join(artifacts, name))) if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `directory=${retained}\n`) diff --git a/apps/desktop/scripts/rollback-desktop-channel.ts b/apps/desktop/scripts/rollback-desktop-channel.ts index b8081da077..fbefbe4f8b 100644 --- a/apps/desktop/scripts/rollback-desktop-channel.ts +++ b/apps/desktop/scripts/rollback-desktop-channel.ts @@ -25,9 +25,9 @@ if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.fi .parse(JSON.parse(await readFile(join(values.record, 'operation.json'), 'utf8'))) z.object({ status: z.enum(['complete', 'published-tag-pending']) }) .parse(JSON.parse(await readFile(join(values.record, 'result.json'), 'utf8'))) - const { plan, config, root } = await prepareDesktopPublication(process.env) + const { plan, config, root, manifest } = await prepareDesktopPublication(process.env) const route = z.object({ runId: z.string(), attempt: z.string() }).parse(JSON.parse(process.env.DESKTOP_PUBLISH_ROUTE || '{}')) - const destination = `${plan.bucket}/${plan.publicUrl}` + const destination = `${plan.bucket}/${plan.publicUrl}${manifest.source.channel}` if (operation.runId !== route.runId || operation.attempt !== route.attempt || operation.repository !== process.env.GITHUB_REPOSITORY || operation.destination !== destination) { throw new Error('desktop rollback: successful publication record does not match the selected package') diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 1202670a3e..2afdfd7194 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -33,6 +33,7 @@ import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } fro import { readDeviceInfo } from './device-info.ts' import { desktopUpdateReadyConfirmation, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale } from './locale.ts' import { claimDesktopSingleInstance } from './single-instance.ts' +import { desktopUpdateChannel } from './update-channel.ts' import { DesktopUpdateCoordinator } from './update-coordinator.ts' import { DesktopCommandManager } from './command-management.ts' import { serveWebDocument, authenticateWebHost, forwardWebRequest } from './web-document.ts' @@ -583,6 +584,8 @@ async function main(): Promise { return startup } + const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) + const updateChannel = desktopUpdateChannel(manifest) const updates = new DesktopUpdateCoordinator( publishUpdate, async () => { @@ -636,7 +639,7 @@ async function main(): Promise { }, undefined, undefined, undefined, (success, reason) => { void track('desktop_upgrade_download_result', { is_success: success, ...reason === undefined ? {} : { error_reason: reason } }) }, - + updateChannel, ) const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env)) @@ -1285,7 +1288,6 @@ async function main(): Promise { }) mainWindow = createMainWindow() - const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8')) if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop policy: invalid application manifest') const developmentPolicy = app.isPackaged ? undefined : process.env.DSH_DESKTOP_MANDATORY_UPDATE_CONFIG const policyInput: unknown = app.isPackaged @@ -1302,6 +1304,7 @@ async function main(): Promise { let wasBlocking = false mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, { platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64', + channel: updateChannel, bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(), }, (state) => { if (state.error !== 'authentication-required') policyAuthenticationQueued = false diff --git a/apps/desktop/src/mandatory-update-policy.ts b/apps/desktop/src/mandatory-update-policy.ts index 27fd51bd44..92168a7ea3 100644 --- a/apps/desktop/src/mandatory-update-policy.ts +++ b/apps/desktop/src/mandatory-update-policy.ts @@ -8,6 +8,7 @@ export interface DesktopPolicyIdentity { readonly platform: 'win32' | 'darwin' readonly bundledDshVersion: string readonly arch: 'x64' | 'arm64' + readonly channel: 'nightly' | 'latest' } /** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */ @@ -158,7 +159,7 @@ export class DesktopMandatoryUpdatePolicy { private requestHeaders(): Record { return { ...platformClientHeaders(this.identity.platform, this.client()), - 'x-client-arch': this.identity.arch, 'x-client-update-channel': 'nightly', + 'x-client-arch': this.identity.arch, 'x-client-update-channel': this.identity.channel, 'x-client-bundled-dsh-version': this.identity.bundledDshVersion, } } diff --git a/apps/desktop/src/update-channel.ts b/apps/desktop/src/update-channel.ts new file mode 100644 index 0000000000..ac767ea642 --- /dev/null +++ b/apps/desktop/src/update-channel.ts @@ -0,0 +1,17 @@ +/** Resolve the update channel sealed into the installed application manifest. */ + +/** Installer-owned channel, independent of release version and upload environment. */ +export type DesktopUpdateChannel = 'nightly' | 'latest' + +/** + * Read the packaged channel; older manifests retain their existing nightly behavior. + * @param manifest Parsed application manifest. + * @returns Validated channel. Invalid explicit values reject instead of changing feeds. + */ +export function desktopUpdateChannel(manifest: unknown): DesktopUpdateChannel { + if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop update: invalid application manifest') + if (!('dshDesktopUpdateChannel' in manifest)) return 'nightly' + const channel = manifest.dshDesktopUpdateChannel + if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop update: invalid packaged channel') + return channel +} diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 8d6eb808e7..3b83cea08f 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -45,6 +45,7 @@ export class DesktopUpdateCoordinator { * @param updater - Process-owned Electron updater, replaceable at the network/platform test boundary. * @param enabled - Whether this process has a packaged update source. * @param currentVersion - Actual installed application version. + * @param channel - Installed package channel; omitted test/local callers retain nightly. * @param downloadResult - Once per completed download attempt, including platform preparation failures. */ constructor( @@ -54,6 +55,7 @@ export class DesktopUpdateCoordinator { private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')), private readonly currentVersion: () => string = () => app.getVersion(), private readonly downloadResult?: (success: boolean, reason?: string) => void, + private readonly channel: 'nightly' | 'latest' = 'nightly', ) { if (updater === autoUpdater) { // electron-updater omits this internal transport property from its public declarations. @@ -66,8 +68,8 @@ export class DesktopUpdateCoordinator { } this.updater.autoDownload = false this.updater.autoInstallOnAppQuit = false - this.updater.channel = 'nightly' - this.updater.allowPrerelease = true + this.updater.channel = channel + this.updater.allowPrerelease = channel === 'nightly' // Selecting a channel can enable downgrade in electron-updater. this.updater.allowDowngrade = false this.updater.on('download-progress', this.onProgress) @@ -187,6 +189,7 @@ export class DesktopUpdateCoordinator { if (result === null) throw new Error('desktop update: no check result was returned') const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') + if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease') this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) } catch (error) { diff --git a/apps/desktop/tests/desktop-auto-update-environment.spec.ts b/apps/desktop/tests/desktop-auto-update-environment.spec.ts index c33a9384df..9fbe629c89 100644 --- a/apps/desktop/tests/desktop-auto-update-environment.spec.ts +++ b/apps/desktop/tests/desktop-auto-update-environment.spec.ts @@ -40,7 +40,7 @@ describe('desktop auto-update environment', () => { DOWNLOAD_TEST_ORIGIN: 'https://desktop-updates.example.com/', DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, }, 'darwin', 'arm64')).toEqual({ - environment: 'test', + environment: 'test', channel: 'nightly', target: 'mac-arm64', origin: 'https://desktop-updates.example.com', publicUrl: `https://desktop-updates.example.com/dsh-desk/${RELEASE_ID}/feeds/mac-arm64/`, diff --git a/apps/desktop/tests/desktop-publication-preflight.spec.ts b/apps/desktop/tests/desktop-publication-preflight.spec.ts index 268017a498..bf4d4e8876 100644 --- a/apps/desktop/tests/desktop-publication-preflight.spec.ts +++ b/apps/desktop/tests/desktop-publication-preflight.spec.ts @@ -39,9 +39,9 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu const env = { DSH_PACKAGING_CONFIG_DIR: configRoot, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin', DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', - PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', + PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '10', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repo', GITHUB_SHA: commit, RUNNER_NAME: 'fixture-runner', - DESKTOP_PUBLISH_ROUTE: JSON.stringify({ runId: '10', attempt: '2', commit, runnerName: 'fixture-runner', target, preset: 'test' }), + DESKTOP_PUBLISH_ROUTE: JSON.stringify({ sourceRef: 'master', channel: 'nightly', runId: '10', attempt: '2', commit, runnerName: 'fixture-runner', target, preset: 'test' }), } const retained = await retainDesktopCi(env, app) await rm(app, { recursive: true }) @@ -51,9 +51,10 @@ it.each(['mac-arm64', 'win-x64'] as const)('preflights a complete retained %s bu expect(prepared.plan.version).toBe('1.0.0') expect(prepared.plan.artifacts.every(file => file.path.startsWith(retained))).toBe(true) expect(signatureChecks).toBe(1) + await expect(prepareDesktopPublication({ ...env, DESKTOP_APPROVED_PACKAGE: 'wrong' }, signature)).rejects.toThrow(/changed after approval/u) await expect(prepareDesktopPublication({ ...env, RUNNER_NAME: 'other-machine' }, signature)).rejects.toThrow(/different machine/u) await writeFile(join(retained, `${base}.${extension}`), 'changed') await expect(prepareDesktopPublication(env, signature)).rejects.toThrow(/changed file/u) - expect(signatureChecks).toBe(1) + expect(signatureChecks).toBe(2) } finally { await rm(root, { recursive: true, force: true }) } }) diff --git a/apps/desktop/tests/desktop-publish-route.spec.ts b/apps/desktop/tests/desktop-publish-route.spec.ts index c83aa7d981..0db2cb45d6 100644 --- a/apps/desktop/tests/desktop-publish-route.spec.ts +++ b/apps/desktop/tests/desktop-publish-route.spec.ts @@ -5,6 +5,7 @@ function fixture() { const run = { id: 42, run_attempt: 2, head_sha: 'a'.repeat(40), path: '.github/workflows/macos-package.yml', conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } } const source = { repository: 'owner/repo', runId: '42', attempt: '2', commit: run.head_sha, runnerName: 'mini', + workflowCommit: run.head_sha, sourceRef: 'master', channel: 'nightly', target: 'mac-arm64', preset: 'test', publishRequested: true, qualification: 'notarized' } const jobs = [{ runner_name: 'mini', conclusion: 'success', labels: ['self-hosted', 'dsh-mini'] }] const routes = { mini: { label: 'dsh-mini', target: 'mac-arm64' } } @@ -21,7 +22,7 @@ it.each(['attempt', 'repository', 'commit', 'mode', 'request', 'runner', 'workfl const f = fixture() if (field === 'attempt') f.source.attempt = '1' if (field === 'repository') f.source.repository = 'other/repo' - if (field === 'commit') f.source.commit = 'b'.repeat(40) + if (field === 'commit') f.source.workflowCommit = 'b'.repeat(40) if (field === 'mode') f.source.qualification = 'unnotarized' if (field === 'request') f.source.publishRequested = false if (field === 'runner') f.jobs[0]!.runner_name = 'laptop' @@ -35,3 +36,12 @@ it('fails closed when a publishing route or its unique label is missing', () => const duplicates = { ...f.routes, laptop: { label: 'dsh-mini', target: 'mac-arm64' } } expect(() => resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, duplicates)).toThrow(/unique runner label/u) }) + + +it('keeps the artifact source separate from the workflow commit', () => { + const f = fixture() + f.source.commit = 'b'.repeat(40) + f.source.sourceRef = 'dsh-v1.0.0' + expect(resolveDesktopPublishRoute('owner/repo', f.run, { source: f.source }, f.jobs, f.routes)) + .toMatchObject({ commit: 'b'.repeat(40), sourceRef: 'dsh-v1.0.0', channel: 'nightly' }) +}) diff --git a/apps/desktop/tests/desktop-release-store.spec.ts b/apps/desktop/tests/desktop-release-store.spec.ts index 7ab2bb7e40..7c717aa90e 100644 --- a/apps/desktop/tests/desktop-release-store.spec.ts +++ b/apps/desktop/tests/desktop-release-store.spec.ts @@ -15,6 +15,7 @@ async function fixture() { const source: DesktopRetainedSource = { repository: 'owner/repository', runId: '100', attempt: '1', commit: 'a'.repeat(40), runnerName: 'windows-runner', target: 'win-x64', preset: 'test', productVersion: '1.0.0', version: '1.0.0', testReleaseId: 'b'.repeat(32), + workflowCommit: 'a'.repeat(40), sourceRef: 'master', channel: 'nightly', publishRequested: true, qualification: 'signed', } return { root: join(root, 'releases'), file, source } diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index fcdcb68ccf..def8cb19bf 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -38,6 +38,7 @@ async function fixture( target: DesktopPackageTargetName, version = '1.2.3', environment: 'test' | 'production' = 'test', + channel: 'nightly' | 'latest' = 'nightly', ): Promise { const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-')) temporaryDirectories.push(root) @@ -55,6 +56,7 @@ async function fixture( : 'https://download.deepseek.com' await writeFile(join(artifactsRoot, `${target}-release.json`), `${JSON.stringify({ schemaVersion: 1, + channel, target, version, environment, @@ -66,7 +68,7 @@ async function fixture( await writeFile(join(artifactsRoot, `${base}.zip`), zip) await writeFile(join(artifactsRoot, `${base}.zip.blockmap`), 'blockmap') await writeFile(join(artifactsRoot, `${base}.dmg`), 'notarized DMG fixture') - await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin')), `${JSON.stringify({ + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'darwin', channel)), `${JSON.stringify({ version, path: `${base}.zip`, files: [{ url: `${base}.zip`, size: Buffer.byteLength(zip), sha512: digest(zip) }], @@ -78,7 +80,7 @@ async function fixture( const info = await createBlockmap(join(artifactsRoot, `${base}.exe`), {}, { info: { emitArtifactBuildCompleted: async () => {} } }, `${base}.exe`) expect(Object.hasOwn(info, 'blockMapSize')).toBe(false) - await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32')), `${JSON.stringify({ + await writeFile(join(artifactsRoot, desktopUpdateMetadataFilename(version, 'win32', channel)), `${JSON.stringify({ version, path: `${base}.exe`, files: [{ @@ -93,12 +95,14 @@ async function fixture( artifactsRoot, environment: environment === 'test' ? { + DSH_DESKTOP_UPDATE_CHANNEL: channel, DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: TEST_ORIGIN, DOWNLOAD_TEST_RELEASE_ID: RELEASE_ID, DOWNLOAD_TEST_COS_BUCKET: TEST_BUCKET, } : { + DSH_DESKTOP_UPDATE_CHANNEL: channel, DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: PRODUCTION_BUCKET, }, @@ -171,6 +175,16 @@ describe('desktop upload plan', () => { .rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u) }) + it.each(['test', 'production'] as const)('isolates stable latest packages from nightly in %s', async (environment) => { + const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly')) + const latest = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'latest')) + expect(latest.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['latest.yml']) + expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml']) + expect(latest.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true) + const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'latest') + await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/latest requires a stable version/u) + }) + it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { const paths = await fixture('win-x64', '1.2.3', 'production') const plan = await createDesktopUploadPlan('win-x64', paths) @@ -178,7 +192,6 @@ describe('desktop upload plan', () => { 'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe', 'dsh-desk/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe.blockmap', 'dsh-desk/feeds/win-x64/nightly.yml', - 'dsh-desk/feeds/win-x64/latest.yml', ]) expect(load(plan.artifacts[2]!.contents!)).toMatchObject({ version: '1.2.3', @@ -187,7 +200,6 @@ describe('desktop upload plan', () => { sha512: digest('signed NSIS executable fixture'), }], }) - expect(plan.artifacts[2]!.contents).toBe(plan.artifacts[3]!.contents) expect(plan.artifacts.every(artifact => !('cacheControl' in artifact))).toBe(true) }) @@ -205,7 +217,6 @@ describe('desktop upload plan', () => { 'deepseek-harness-1.2.3-mac-arm64.zip', 'deepseek-harness-1.2.3-mac-arm64.zip.blockmap', 'nightly-mac.yml', - 'latest-mac.yml', ]) expect(plan.artifacts.at(-1)).toMatchObject({ channelMetadata: true, @@ -258,7 +269,6 @@ describe('desktop upload plan', () => { 'deepseek-harness-2.0.0-win-x64.exe', 'deepseek-harness-2.0.0-win-x64.exe.blockmap', 'nightly.yml', - 'latest.yml', ]) expect(plan).toMatchObject({ publicUrl: 'https://download.deepseek.com/dsh-desk/feeds/win-x64/', diff --git a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json index 515f0234f9..69af29ec01 100644 --- a/apps/desktop/tests/expected/test-release-upload-mac-arm64.json +++ b/apps/desktop/tests/expected/test-release-upload-mac-arm64.json @@ -11,9 +11,5 @@ { "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/nightly-mac.yml", "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" - }, - { - "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/latest-mac.yml", - "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/deepseek-harness-1.2.3-mac-arm64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" } ] diff --git a/apps/desktop/tests/expected/test-release-upload-mac-x64.json b/apps/desktop/tests/expected/test-release-upload-mac-x64.json index 5039a3aede..2c085140ce 100644 --- a/apps/desktop/tests/expected/test-release-upload-mac-x64.json +++ b/apps/desktop/tests/expected/test-release-upload-mac-x64.json @@ -11,9 +11,5 @@ { "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/nightly-mac.yml", "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" - }, - { - "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/latest-mac.yml", - "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/deepseek-harness-1.2.3-mac-x64.zip\n size: 24\n sha512: >-\n IgtiHRPFjickTxiSTW6k0ltyBNOF/gpMGj/xflzVV2iRIzUSFPY6BG45u9fR4GmAJSVtN+FMh3NZx7mddUfJLw==\n" } ] diff --git a/apps/desktop/tests/expected/test-release-upload-win-x64.json b/apps/desktop/tests/expected/test-release-upload-win-x64.json index cb60e0ce29..9c7db67400 100644 --- a/apps/desktop/tests/expected/test-release-upload-win-x64.json +++ b/apps/desktop/tests/expected/test-release-upload-win-x64.json @@ -8,9 +8,5 @@ { "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/nightly.yml", "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" - }, - { - "key": "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/latest.yml", - "contents": "version: 1.2.3\npath: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\nfiles:\n - url: >-\n https://desktop-updates.example.com/dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/deepseek-harness-1.2.3-win-x64.exe\n size: 30\n sha512: >-\n eVumVOW1yvT3fAgGKUnliEzSfhmn8jUfzpNaUPufU1yekWrwJe8wRPC+JOrHH2Hmc/eLynwxjX1yIiER8tVOTA==\n" } ] diff --git a/apps/desktop/tests/macos-app-update-config.spec.ts b/apps/desktop/tests/macos-app-update-config.spec.ts index 72a003734a..bba859fa7c 100644 --- a/apps/desktop/tests/macos-app-update-config.spec.ts +++ b/apps/desktop/tests/macos-app-update-config.spec.ts @@ -28,9 +28,9 @@ afterEach(async () => { describe('macOS packaged updater configuration', () => { it('uses the final generic Nightly provider configured for the build', () => { expect(resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'nightly' }])) - .toEqual(update) + .toEqual({ ...update, channel: 'nightly' }) for (const publish of [undefined, [], [{ provider: 'github', channel: 'nightly', url: update.publicUrl }], - [{ provider: 'generic', channel: 'latest', url: update.publicUrl }]]) { + [{ provider: 'generic', channel: 'invalid', url: update.publicUrl }]]) { expect(() => resolveMacOSAppUpdateFeed(publish)).toThrow(/macOS update config/u) } }) @@ -64,3 +64,12 @@ describe('macOS packaged updater configuration', () => { .rejects.toThrow(/expected deepseek-harness-updater/u) }) }) + + +it('seals latest into the macOS updater file and rejects a different expected channel', async () => { + const paths = await fixture() + const latest = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'latest' }]) + await writeMacOSAppUpdateConfig(paths.resourcesDir, latest, 'fixture-updater') + await expect(verifyMacOSAppUpdateConfig(paths.appPath, latest)).resolves.toBeUndefined() + await expect(verifyMacOSAppUpdateConfig(paths.appPath, update)).rejects.toThrow(/does not match/u) +}) diff --git a/apps/desktop/tests/mandatory-update-policy.spec.ts b/apps/desktop/tests/mandatory-update-policy.spec.ts index 84a584389b..a04353810d 100644 --- a/apps/desktop/tests/mandatory-update-policy.spec.ts +++ b/apps/desktop/tests/mandatory-update-policy.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { AccountClientMetadata } from '@deepseek-ai/dsh-deepseek-account/types' import { DesktopMandatoryUpdatePolicy, desktopPolicyPage, resolveDesktopPolicyConfig, type DesktopPolicyState } from '../src/mandatory-update-policy.ts' -const identity = { platform: 'win32', arch: 'x64', bundledDshVersion: '0.1.5-rc.1' } as const +const identity = { platform: 'win32', arch: 'x64', channel: 'nightly', bundledDshVersion: '0.1.5-rc.1' } as const const client: AccountClientMetadata = { version: '1.2.3', locale: 'zh-CN', timezoneOffsetSeconds: 28_800 } const force = { code: 40005, data: { show_content: { title: 'Update', detail: 'Required upgrade' }, desktop_app_link: 'https://downloads.example.com/desktop?os=win' } } diff --git a/apps/desktop/tests/prepare-windows-ci.spec.ts b/apps/desktop/tests/prepare-windows-ci.spec.ts index 67d62bbe19..f445682467 100644 --- a/apps/desktop/tests/prepare-windows-ci.spec.ts +++ b/apps/desktop/tests/prepare-windows-ci.spec.ts @@ -20,3 +20,12 @@ describe('manual Windows CI version', () => { expect(() => windowsCiVersion({ ...inputs, ...override }, '0.2.1', now)).toThrow(/positive integer/u) }) }) + + +it('resolves the selected version policy independently of the update channel', () => { + expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'production' }, '1.2.3', now)).toBe('1.2.3') + expect(windowsCiVersion({ ...inputs, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'test' }, '1.2.3', now)).toBe('1.2.3-test.20261009.42.2') + expect(windowsCiVersion({ PACKAGE_VERSION_MODE: 'product' }, '1.2.3-alpha.1', now)).toBe('1.2.3-alpha.1') + expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: 'auto' }, '1.2.3', now)).toThrow(/explicit version/u) + expect(() => windowsCiVersion({ PACKAGE_VERSION_MODE: 'invalid' }, '1.2.3', now)).toThrow(/version mode/u) +}) diff --git a/apps/desktop/tests/retain-desktop-ci.spec.ts b/apps/desktop/tests/retain-desktop-ci.spec.ts index d0e6f3ae3a..0fb2b14bc5 100644 --- a/apps/desktop/tests/retain-desktop-ci.spec.ts +++ b/apps/desktop/tests/retain-desktop-ci.spec.ts @@ -24,7 +24,7 @@ it.each(['mac-arm64', 'win-x64'] as const)('retains the complete %s release with 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_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', + DSH_DESKTOP_TARGET_ARCH: target === 'win-x64' ? 'x64' : 'arm64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0', PACKAGE_PUBLISH: 'true', PACKAGE_SIGNING: 'signed', PACKAGE_SKIP_NOTARIZATION: 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2', GITHUB_REPOSITORY: 'owner/repository', GITHUB_SHA: 'b'.repeat(40), RUNNER_NAME: 'dedicated', } diff --git a/apps/desktop/tests/update-channel.spec.ts b/apps/desktop/tests/update-channel.spec.ts new file mode 100644 index 0000000000..8ea7bb98a4 --- /dev/null +++ b/apps/desktop/tests/update-channel.spec.ts @@ -0,0 +1,10 @@ +import { expect, it } from 'vitest' +import { desktopUpdateChannel } from '../src/update-channel.ts' + +it('reads the installed channel independently from the current version', () => { + expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'nightly' })).toBe('nightly') + expect(desktopUpdateChannel({ version: '1.0.0', dshDesktopUpdateChannel: 'latest' })).toBe('latest') + expect(desktopUpdateChannel({ version: '1.0.0' })).toBe('nightly') + expect(() => desktopUpdateChannel({ dshDesktopUpdateChannel: 'beta' })).toThrow(/channel/u) + expect(() => desktopUpdateChannel(null)).toThrow(/manifest/u) +}) diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 790ad63fcf..1c6e9da1c7 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -47,7 +47,7 @@ describe('desktop release metadata', () => { const coordinators: InstanceType[] = [] afterEach(() => { for (const item of coordinators.splice(0)) item.dispose() }) -function fixture() { +function fixture(channel: 'nightly' | 'latest' = 'nightly', version = '1.1.0-alpha.1') { const events = new EventEmitter() const checkForUpdates = vi.fn(async () => ({ isUpdateAvailable: true, @@ -66,7 +66,7 @@ function fixture() { const updater = Object.assign(events, { checkForUpdates, downloadUpdate, quitAndInstall }) as unknown as AppUpdater const coordinator = new DesktopUpdateCoordinator( (state) => { states.push(state); return state }, - beforeRestart, updater, () => true, () => '1.1.0-alpha.1', downloadResult, + beforeRestart, updater, () => true, () => version, downloadResult, channel, ) coordinators.push(coordinator) return { coordinator, updater, events, states, checkForUpdates, downloadUpdate, quitAndInstall, beforeRestart, downloadResult } @@ -242,3 +242,18 @@ it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), expect(f.downloadResult).toHaveBeenCalledExactlyOnceWith(false, error instanceof DesktopUpdatePreparationError ? 'stop-failed' : 'download_failed') expect(JSON.stringify(f.downloadResult.mock.calls)).not.toContain('private') }) + + +it('keeps a stable-version nightly installation on nightly and rejects prereleases on latest', async () => { + const nightly = fixture('nightly', '1.0.0') + expect(nightly.updater.channel).toBe('nightly') + expect(nightly.updater.allowPrerelease).toBe(true) + expect((await nightly.coordinator.check()).phase).toBe('available') + expect(nightly.updater.channel).toBe('nightly') + const latest = fixture('latest', '1.0.0') + expect(latest.updater.channel).toBe('latest') + expect(latest.updater.allowPrerelease).toBe(false) + expect(latest.updater.allowDowngrade).toBe(false) + expect((await latest.coordinator.check()).phase).toBe('error') + expect(latest.downloadUpdate).not.toHaveBeenCalled() +}) diff --git a/scripts/desktop-publish-workflow.spec.ts b/scripts/desktop-publish-workflow.spec.ts index 2796f234e2..6cd65965b1 100644 --- a/scripts/desktop-publish-workflow.spec.ts +++ b/scripts/desktop-publish-workflow.spec.ts @@ -8,6 +8,8 @@ const workflow = yaml.load(readFileSync(resolve(import.meta.dirname, '../.github permissions: Record jobs: Record if?: string environment?: string 'runs-on': string | string[] @@ -36,3 +38,14 @@ it('routes only completed package requests and gates production secrets with the expect(publish.steps.find(step => step.id === 'app')?.with).toMatchObject({ 'permission-contents': 'write' }) expect(publish.steps.find(step => step.id === 'publish')?.env?.DESKTOP_TAG_TOKEN).toBe('${{ steps.app.outputs.token }}') }) + + +it('prepares an approval digest without an Environment and binds the approved job to it', () => { + const prepare = workflow.jobs.prepare! + const publish = workflow.jobs.publish! + expect(prepare.environment).toBeUndefined() + expect(prepare.steps.find(step => step.id === 'check')?.run).toContain('--check') + expect(prepare.steps.some(step => step.id === 'key' || step.id === 'app')).toBe(false) + expect(publish.needs).toEqual(['route', 'prepare']) + expect(publish.env?.DESKTOP_APPROVED_PACKAGE).toBe('${{ needs.prepare.outputs.approval }}') +})