From 152d0294c2e491c3ba8e3cf2fe410ef5ea63f85e Mon Sep 17 00:00:00 2001 From: winewill Date: Tue, 3 Nov 2026 19:10:11 +0800 Subject: [PATCH] fix(desktop): decouple update channels from version suffixes --- .github/workflows/macos-intel-package.yml | 2 +- .github/workflows/macos-package.yml | 2 +- .github/workflows/windows-package.yml | 2 +- apps/desktop/packaging/README.i18n.yaml | 4 +- apps/desktop/packaging/README.md | 2 +- apps/desktop/packaging/README.zh.md | 2 +- apps/desktop/publishing/ci/README.i18n.yaml | 4 +- apps/desktop/publishing/ci/README.md | 2 +- apps/desktop/publishing/ci/README.zh.md | 2 +- apps/desktop/scripts/desktop-ci-version.mjs | 1 - apps/desktop/scripts/desktop-upload-plan.ts | 1 - .../scripts/electron-builder-config.mjs | 1 - apps/desktop/src/update-coordinator.ts | 1 - .../src/updates/electron-updater/channel.ts | 4 +- .../tests/desktop-channel-version.spec.ts | 36 ++++++++++++++++ .../desktop/tests/desktop-upload-plan.spec.ts | 21 +++++++++- .../expected/stable-prerelease-mac-arm64.json | 42 +++++++++++++++++++ .../expected/stable-prerelease-mac-x64.json | 42 +++++++++++++++++++ .../expected/stable-prerelease-win-x64.json | 38 +++++++++++++++++ apps/desktop/tests/update-coordinator.spec.ts | 34 +++++++++------ .../electron-updater/qualification.mjs | 14 +++++-- .../desktop-stable-channel/guide.i18n.yaml | 4 +- .../desktop-stable-channel/guide.md | 2 +- .../desktop-stable-channel/guide.zh.md | 2 +- 24 files changed, 226 insertions(+), 39 deletions(-) create mode 100644 apps/desktop/tests/desktop-channel-version.spec.ts create mode 100644 apps/desktop/tests/expected/stable-prerelease-mac-arm64.json create mode 100644 apps/desktop/tests/expected/stable-prerelease-mac-x64.json create mode 100644 apps/desktop/tests/expected/stable-prerelease-win-x64.json diff --git a/.github/workflows/macos-intel-package.yml b/.github/workflows/macos-intel-package.yml index 01308037fe..77672e2c51 100644 --- a/.github/workflows/macos-intel-package.yml +++ b/.github/workflows/macos-intel-package.yml @@ -15,7 +15,7 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' + description: '升级通道:人工选择 nightly 或 stable,与版本后缀无关;默认通道由此打包输入确定' type: choice options: [nightly, stable] default: nightly diff --git a/.github/workflows/macos-package.yml b/.github/workflows/macos-package.yml index 4b8fd840b2..73401cf2c7 100644 --- a/.github/workflows/macos-package.yml +++ b/.github/workflows/macos-package.yml @@ -15,7 +15,7 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' + description: '升级通道:人工选择 nightly 或 stable,与版本后缀无关;默认通道由此打包输入确定' type: choice options: [nightly, stable] default: nightly diff --git a/.github/workflows/windows-package.yml b/.github/workflows/windows-package.yml index 7a6ec241d5..2f01d18706 100644 --- a/.github/workflows/windows-package.yml +++ b/.github/workflows/windows-package.yml @@ -15,7 +15,7 @@ on: default: test required: true update_channel: - description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定' + description: '升级通道:人工选择 nightly 或 stable,与版本后缀无关;默认通道由此打包输入确定' type: choice options: [nightly, stable] default: nightly diff --git a/apps/desktop/packaging/README.i18n.yaml b/apps/desktop/packaging/README.i18n.yaml index 0589a36c5e..292a1fcb2e 100644 --- a/apps/desktop/packaging/README.i18n.yaml +++ b/apps/desktop/packaging/README.i18n.yaml @@ -18,8 +18,8 @@ en: 0b35c7471c09b27a zh: cc4a382b99fc3210 /desktop-packaging/release-versions: - en: 8cc17b1e5ae3e422 - zh: 7de3e4f1e76be3e3 + en: 0ce6f56a2e94f687 + zh: a878a5db6a405c68 /desktop-packaging/configuration-and-local-builds: en: 5b4f0f80a117f202 zh: ff05be196c181087 diff --git a/apps/desktop/packaging/README.md b/apps/desktop/packaging/README.md index 93214bd994..86d7447938 100644 --- a/apps/desktop/packaging/README.md +++ b/apps/desktop/packaging/README.md @@ -63,7 +63,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. -The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. This value supplies the default channel; the saved selection in [Desktop settings](../README.md#local-desktop-settings) takes precedence, including after installing another package. Stable-channel 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). +The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. This value supplies the default channel; the saved selection in [Desktop settings](../README.md#local-desktop-settings) takes precedence, including after installing another package. Operators select the channel independently of version suffixes; both channels accept release and prerelease versions. A stable product can produce a `-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 383e342637..3ec108ac0c 100644 --- a/apps/desktop/packaging/README.zh.md +++ b/apps/desktop/packaging/README.zh.md @@ -63,7 +63,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`,直接分发的构建同样可溯源。 -安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。该值提供默认渠道;[桌面设置](../README.zh.md#local-desktop-settings)中保存的选择优先,包括安装另一个包之后。stable 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test..` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。 +安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。该值提供默认渠道;[桌面设置](../README.zh.md#local-desktop-settings)中保存的选择优先,包括安装另一个包之后。操作人员独立于版本后缀选择渠道;两个渠道均接受正式版本和预发布版本。稳定产品可生成 `-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 70c5d0e62b..f17d53f1d6 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -24,8 +24,8 @@ en: 0f43f2e193e95043 zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: - en: 3f58698708671202 - zh: be0ba215a67168d6 + en: 041fde801a4dc1ae + zh: 447e3966241cd266 /desktop-ci-publication/recovery-and-records: en: 0d87b7a9c073993c zh: 9f135e8c1a4b7d8e diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index eb21407702..5503d71628 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -70,7 +70,7 @@ Environment approval gates this workflow, not arbitrary code running under the s For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. An archive-only rerun retains the original build attempt in artifact names, so this early check can miss it; local manifest qualification still rejects debugging builds before publication. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes. -Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded 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 stable-channel package requires a stable version, so test/stable 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 the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded 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`. The operator-selected channel accepts any supported build version, including alpha and RC versions; it does not change version precedence or permit client downgrades. 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. Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt and its successful package job. An archival-only retry retains the original build attempt; a failed newer package job never falls back to an older success. An explicit attempt selects that build even when its separate GitHub archival failed. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication. diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 91e2ac717d..041bb73c4b 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -70,7 +70,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。仅重跑归档时,artifact 名称保留原构建 attempt,早期检查可能未识别该包;本地清单资格校验仍会在发布前拒绝调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。 -选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 +选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。操作人员选择的渠道接受任意受支持的构建版本,包括 alpha 和 RC 版本;渠道不改变版本优先级,也不允许客户端降级。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。 打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试及其成功的 package job;归档重跑继续使用原始构建 attempt,新的构建失败时不回退到较早成功构建。显式填写则选择对应构建,独立的 GitHub 归档失败不取消其资格。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。 diff --git a/apps/desktop/scripts/desktop-ci-version.mjs b/apps/desktop/scripts/desktop-ci-version.mjs index 3822b8329f..49a21baee2 100644 --- a/apps/desktop/scripts/desktop-ci-version.mjs +++ b/apps/desktop/scripts/desktop-ci-version.mjs @@ -24,7 +24,6 @@ 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 === 'stable' && candidate.includes('-')) throw new Error('Stable channel requires a stable version; select product or manual stable version') try { const resolved = validateDesktopBuildVersion(candidate, productVersion) return resolved diff --git a/apps/desktop/scripts/desktop-upload-plan.ts b/apps/desktop/scripts/desktop-upload-plan.ts index e6a8d4c7f0..e94c24d18a 100644 --- a/apps/desktop/scripts/desktop-upload-plan.ts +++ b/apps/desktop/scripts/desktop-upload-plan.ts @@ -221,7 +221,6 @@ export async function createDesktopUploadPlan( throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`) } - if (update.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop upload: stable requires a stable version') const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel) const metadataPath = join(artifactsRoot, metadataFilename) let metadataValue: unknown diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index c2563a0edf..d5522e3da9 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -105,7 +105,6 @@ 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 === 'stable' && buildVersion.includes('-')) throw new Error('desktop package: stable requires a stable version') const packaged = resolveDesktopBuildCommit(env) return { appId, diff --git a/apps/desktop/src/update-coordinator.ts b/apps/desktop/src/update-coordinator.ts index 29cf2d88f3..475a560667 100644 --- a/apps/desktop/src/update-coordinator.ts +++ b/apps/desktop/src/update-coordinator.ts @@ -276,7 +276,6 @@ export class DesktopUpdateCoordinator { this.cancellation = result.cancellationToken const version = result.updateInfo.version if (valid(version) === null) throw new Error('desktop update: feed version is invalid') - if (this.channel === 'stable' && version.includes('-')) throw new Error('desktop update: stable feed contains a prerelease') this.release = result.isUpdateAvailable && gt(version, this.currentVersion()) ? { kind: 'checked', version } : { kind: 'none' } return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version }) diff --git a/apps/desktop/src/updates/electron-updater/channel.ts b/apps/desktop/src/updates/electron-updater/channel.ts index 06f07627b2..a67dfdacde 100644 --- a/apps/desktop/src/updates/electron-updater/channel.ts +++ b/apps/desktop/src/updates/electron-updater/channel.ts @@ -2,7 +2,7 @@ import type { AppUpdater } from 'electron-updater' /** - * Select a feed without permitting downgrades or prereleases on stable. + * Select the operator-managed feed, accepting prereleases while preventing downgrades. * @param updater - Updater whose prior operations have settled. * @param channel - Selected distribution channel. */ @@ -10,5 +10,5 @@ export function selectUpdateChannel(updater: AppUpdater, channel: 'nightly' | 's updater.channel = channel // The library channel setter enables downgrades. updater.allowDowngrade = false - updater.allowPrerelease = channel === 'nightly' + updater.allowPrerelease = true } diff --git a/apps/desktop/tests/desktop-channel-version.spec.ts b/apps/desktop/tests/desktop-channel-version.spec.ts new file mode 100644 index 0000000000..f720a58ac6 --- /dev/null +++ b/apps/desktop/tests/desktop-channel-version.spec.ts @@ -0,0 +1,36 @@ +import { readFileSync } from 'node:fs' +import { expect, it } from 'vitest' +import { desktopCiVersion } from '../scripts/desktop-ci-version.mjs' +import { desktopBuildVersionPrefix } from '../scripts/desktop-build-version.mjs' +import { createElectronBuilderConfig } from '../scripts/electron-builder-config.mjs' + +it.each(['nightly', 'stable'] as const)('resolves every CI version mode independently of %s', (channel) => { + const now = new Date('2026-11-03T00:00:00Z') + for (const version of ['1.2.3', '1.2.3-alpha.1', '1.2.3-rc.1']) { + const env = { DSH_DESKTOP_UPDATE_CHANNEL: channel, GITHUB_RUN_NUMBER: '7', GITHUB_RUN_ATTEMPT: '2' } + const generated = `${desktopBuildVersionPrefix(version)}20261103.7.2` + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'product' }, version, now)).toBe(version) + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'auto' }, version, now)).toBe(generated) + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: generated }, version, now)).toBe(generated) + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'production' }, version, now)).toBe(version) + expect(desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'default', DSH_PACKAGING_PRESET: 'test' }, version, now)).toBe(generated) + expect(() => desktopCiVersion({ ...env, PACKAGE_VERSION_MODE: 'manual', PACKAGE_BUILD_VERSION: 'invalid' }, version, now)).toThrow('Invalid build version') + } +}) + +it.each(['arm64', 'x64'] as const)('embeds stable in a prerelease macOS %s package and provider', (arch) => { + const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } + const version = `${desktopBuildVersionPrefix(manifest.version)}20261103.1` + const config = createElectronBuilderConfig({ + DSH_DESKTOP_APP_ID: 'com.example.channel-test', + DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', + DSH_DESKTOP_UPDATE_CHANNEL: 'stable', + DSH_DESKTOP_BUILD_VERSION: version, + DSH_DESKTOP_MANDATORY_UPDATE_PROD_ORIGIN: 'https://policy.example.com', + DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Example Company (TEAMID1234)', + DSH_DESKTOP_MACOS_TEAM_ID: 'TEAMID1234', + APPLE_KEYCHAIN_PROFILE: 'channel-test', + }, 'darwin', arch) + expect(config.extraMetadata).toMatchObject({ version, dshDesktopUpdateChannel: 'stable' }) + expect(config.publish).toEqual([{ provider: 'generic', url: `https://download.deepseek.com/dsh-desk/feeds/mac-${arch}/`, channel: 'stable' }]) +}) diff --git a/apps/desktop/tests/desktop-upload-plan.spec.ts b/apps/desktop/tests/desktop-upload-plan.spec.ts index 18f3fa18fa..33b7f989ef 100644 --- a/apps/desktop/tests/desktop-upload-plan.spec.ts +++ b/apps/desktop/tests/desktop-upload-plan.spec.ts @@ -183,8 +183,25 @@ describe('desktop upload plan', () => { expect(stable.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['stable.yml']) expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml']) expect(stable.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, 'stable') - await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/stable requires a stable version/u) + }) + + it.each(['mac-arm64', 'mac-x64', 'win-x64'] as const)('publishes manually selected prereleases on stable for %s', async (target) => { + const summaries: { environment: string; version: string; keys: string[] }[] = [] + for (const environment of ['test', 'production'] as const) { + for (const version of ['1.2.3-alpha.1', '1.2.3-rc.1']) { + const plan = await createDesktopUploadPlan(target, await fixture(target, version, environment, 'stable')) + expect(plan.version).toBe(version) + const feed = plan.artifacts.find(file => file.channelMetadata)! + expect(feed.filename).toBe(target === 'win-x64' ? 'stable.yml' : 'stable-mac.yml') + const payload = plan.artifacts.find(file => file.filename.endsWith(target === 'win-x64' ? '.exe' : '.zip'))! + expect(payload.key).toContain(`/bin/${target}/stable/`) + expect(load(feed.contents!)).toMatchObject({ version, files: [{ + url: `${environment === 'test' ? TEST_ORIGIN : 'https://download.deepseek.com'}/${payload.key}`, + }] }) + summaries.push({ environment, version, keys: plan.artifacts.map(file => file.key) }) + } + } + await expect(`${JSON.stringify(summaries, null, 2)}\n`).toMatchFileSnapshot(`./expected/stable-prerelease-${target}.json`) }) it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => { diff --git a/apps/desktop/tests/expected/stable-prerelease-mac-arm64.json b/apps/desktop/tests/expected/stable-prerelease-mac-arm64.json new file mode 100644 index 0000000000..97afcbe70e --- /dev/null +++ b/apps/desktop/tests/expected/stable-prerelease-mac-arm64.json @@ -0,0 +1,42 @@ +[ + { + "environment": "test", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.dmg", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.zip", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.zip.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/stable-mac.yml" + ] + }, + { + "environment": "test", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.dmg", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.zip", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.zip.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-arm64/stable-mac.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.dmg", + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.zip", + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-alpha.1-mac-arm64.zip.blockmap", + "dsh-desk/feeds/mac-arm64/stable-mac.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.dmg", + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.zip", + "dsh-desk/bin/mac-arm64/stable/deepseek-harness-1.2.3-rc.1-mac-arm64.zip.blockmap", + "dsh-desk/feeds/mac-arm64/stable-mac.yml" + ] + } +] diff --git a/apps/desktop/tests/expected/stable-prerelease-mac-x64.json b/apps/desktop/tests/expected/stable-prerelease-mac-x64.json new file mode 100644 index 0000000000..45e103bb57 --- /dev/null +++ b/apps/desktop/tests/expected/stable-prerelease-mac-x64.json @@ -0,0 +1,42 @@ +[ + { + "environment": "test", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.dmg", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.zip", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.zip.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/stable-mac.yml" + ] + }, + { + "environment": "test", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.dmg", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.zip", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.zip.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/mac-x64/stable-mac.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.dmg", + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.zip", + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-alpha.1-mac-x64.zip.blockmap", + "dsh-desk/feeds/mac-x64/stable-mac.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.dmg", + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.zip", + "dsh-desk/bin/mac-x64/stable/deepseek-harness-1.2.3-rc.1-mac-x64.zip.blockmap", + "dsh-desk/feeds/mac-x64/stable-mac.yml" + ] + } +] diff --git a/apps/desktop/tests/expected/stable-prerelease-win-x64.json b/apps/desktop/tests/expected/stable-prerelease-win-x64.json new file mode 100644 index 0000000000..f1174feee9 --- /dev/null +++ b/apps/desktop/tests/expected/stable-prerelease-win-x64.json @@ -0,0 +1,38 @@ +[ + { + "environment": "test", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/stable/deepseek-harness-1.2.3-alpha.1-win-x64.exe", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/stable/deepseek-harness-1.2.3-alpha.1-win-x64.exe.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/stable.yml" + ] + }, + { + "environment": "test", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/stable/deepseek-harness-1.2.3-rc.1-win-x64.exe", + "dsh-desk/0123456789abcdef0123456789abcdef/bin/win-x64/stable/deepseek-harness-1.2.3-rc.1-win-x64.exe.blockmap", + "dsh-desk/0123456789abcdef0123456789abcdef/feeds/win-x64/stable.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-alpha.1", + "keys": [ + "dsh-desk/bin/win-x64/stable/deepseek-harness-1.2.3-alpha.1-win-x64.exe", + "dsh-desk/bin/win-x64/stable/deepseek-harness-1.2.3-alpha.1-win-x64.exe.blockmap", + "dsh-desk/feeds/win-x64/stable.yml" + ] + }, + { + "environment": "production", + "version": "1.2.3-rc.1", + "keys": [ + "dsh-desk/bin/win-x64/stable/deepseek-harness-1.2.3-rc.1-win-x64.exe", + "dsh-desk/bin/win-x64/stable/deepseek-harness-1.2.3-rc.1-win-x64.exe.blockmap", + "dsh-desk/feeds/win-x64/stable.yml" + ] + } +] diff --git a/apps/desktop/tests/update-coordinator.spec.ts b/apps/desktop/tests/update-coordinator.spec.ts index 75521799f4..621a00fb6c 100644 --- a/apps/desktop/tests/update-coordinator.spec.ts +++ b/apps/desktop/tests/update-coordinator.spec.ts @@ -244,18 +244,28 @@ it.each([new DesktopUpdatePreparationError('stop-failed', 'private diagnostic'), }) -it('keeps a stable-version nightly installation on nightly and rejects prereleases on stable', 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 stable = fixture('stable', '1.0.0') - expect(stable.updater.channel).toBe('stable') - expect(stable.updater.allowPrerelease).toBe(false) - expect(stable.updater.allowDowngrade).toBe(false) - expect((await stable.coordinator.check()).phase).toBe('error') - expect(stable.downloadUpdate).not.toHaveBeenCalled() +it.each(['nightly', 'stable'] as const)('accepts newer prereleases on the selected %s channel', async (channel) => { + const f = fixture(channel, '1.0.0') + expect(f.updater.channel).toBe(channel) + expect(f.updater.allowPrerelease).toBe(true) + expect(f.updater.allowDowngrade).toBe(false) + for (const version of ['1.1.0-alpha.1', '1.1.0-rc.2', '1.1.0']) { + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version } }) + expect(await f.coordinator.check()).toMatchObject({ phase: 'available', version }) + expect(f.updater.channel).toBe(channel) + } +}) + +it.each(['nightly', 'stable'] as const)('rejects older, equal and malformed versions on %s', async (channel) => { + for (const version of ['0.9.0', '1.0.0-rc.1', '1.0.0', 'invalid']) { + const f = fixture(channel, '1.0.0') + f.checkForUpdates.mockResolvedValueOnce({ isUpdateAvailable: true, updateInfo: { version } }) + expect((await f.coordinator.check()).phase).toBe(version === 'invalid' ? 'error' : 'idle') + await expect(f.coordinator.download(version)).rejects.toThrow() + await expect(f.coordinator.install(version)).rejects.toThrow() + expect(f.downloadUpdate).not.toHaveBeenCalled() + expect(f.quitAndInstall).not.toHaveBeenCalled() + } }) diff --git a/apps/desktop/tests/updates/electron-updater/qualification.mjs b/apps/desktop/tests/updates/electron-updater/qualification.mjs index c8243d5480..e5bb26ab47 100644 --- a/apps/desktop/tests/updates/electron-updater/qualification.mjs +++ b/apps/desktop/tests/updates/electron-updater/qualification.mjs @@ -60,8 +60,14 @@ async function main() { await target.checkForUpdates() assert.equal(requests.at(-1), `/stable${process.platform === 'darwin' ? '-mac' : ''}.yml`) assert.equal(target.allowDowngrade, false) - assert.equal(target.allowPrerelease, false) - version = '0.9.0' + assert.equal(target.allowPrerelease, true) + for (const candidate of ['2.0.0-alpha.1', '2.0.0-rc.1', '2.0.0']) { + version = candidate + await discardDesktopUpdate(target) + assert.equal((await target.checkForUpdates()).isUpdateAvailable, true) + assert.equal(target.updateInfoAndProvider.info.version, candidate) + } + version = '0.9.0-rc.1' await discardDesktopUpdate(target) await target.checkForUpdates() assert.equal(target.updateInfoAndProvider, null) @@ -98,7 +104,7 @@ async function main() { // A later process can encounter files that a previous process could not remove. await writeFile(file, oldPayload) await writeFile(join(pending, 'update-info.json'), cachedInfo) - version = '2.1.0' + version = '2.1.0-rc.1' payload = Buffer.from('different release after obsolete cache survived') sha512 = createHash('sha512').update(payload).digest('base64') const recovered = new Target(undefined, adapter) @@ -115,7 +121,7 @@ async function main() { await recovered.checkForUpdates() await recovered.downloadUpdate() assert.deepEqual(await readFile(recovered.downloadedUpdateHelper.file), payload) - assert.equal(recovered.downloadedUpdateHelper.versionInfo.version, '2.1.0') + assert.equal(recovered.downloadedUpdateHelper.versionInfo.version, '2.1.0-rc.1') console.log('PASS: obsolete cache rejected by a new updater instance; current payload verified') console.log('PASS: real provider channel switching, rollout eligibility, real downloads, native platform cache and proxy retirement') } finally { diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml index ce81afec05..49ca2a4270 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.i18n.yaml @@ -12,5 +12,5 @@ en: bca4377ec9032e3b zh: 6da9f3be04f1cd39 /desktop-stable-channel-name/migration: - en: 8442b24f420f6be2 - zh: b2517178eccbf8d5 + en: a7f4cf090fde3729 + zh: 3dd080660f49e7c6 diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md index 9261aa2960..2514bf3020 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.md @@ -13,6 +13,6 @@ Desktop packages, update feeds, and channel configuration use `stable` instead o ## Migration -1. Select `stable` in packaging workflows or set `DSH_DESKTOP_UPDATE_CHANNEL=stable` when building a stable-channel package. Build a stable version, without a prerelease suffix. +1. Select `stable` in packaging workflows or set `DSH_DESKTOP_UPDATE_CHANNEL=stable` when building a stable-channel package. Select the channel independently of the version suffix; stable also accepts alpha and RC versions. 2. If a local Desktop settings file contains `updates.channel: "latest"`, change the value to `"stable"` and restart Desktop. Omitting the field uses the package default. 3. Publish with the updated tooling: it writes `stable.yml` on Windows and `stable-mac.yml` on macOS within each architecture's feed directory, with stable-channel binaries under `stable/`. Verify the selected channel and requested feed in the test deployment before production publication. Old packages still requesting `latest` feeds do not migrate automatically. diff --git a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md index 441693811b..9eb899526a 100644 --- a/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md +++ b/docs/upgrade-guide/v0.2.2-alpha.2/desktop-stable-channel/guide.zh.md @@ -13,6 +13,6 @@ description: "桌面正式更新渠道由 latest 更名为 stable。" ## 迁移 -1. 在打包工作流中选择 `stable`,或在构建正式渠道包时设置 `DSH_DESKTOP_UPDATE_CHANNEL=stable`。使用不含预发布后缀的稳定版本号。 +1. 在打包工作流中选择 `stable`,或在构建正式渠道包时设置 `DSH_DESKTOP_UPDATE_CHANNEL=stable`。渠道选择独立于版本后缀;stable 也接受 alpha 和 RC 版本。 2. 如果本地桌面配置文件包含 `updates.channel: "latest"`,将该值改为 `"stable"` 并重启桌面应用。省略该字段时使用安装包默认值。 3. 使用更新后的工具发布:在各架构的清单目录内,Windows 写入 `stable.yml`,macOS 写入 `stable-mac.yml`;正式渠道二进制位于 `stable/` 下。在正式发布前,通过测试部署核对选定渠道和请求的清单。仍请求 `latest` 清单的旧包不会自动迁移。