diff --git a/.github/AGENTS.md b/.github/AGENTS.md index 2d18763770..733b45dac2 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -7,3 +7,5 @@ The manual [Windows packaging workflow](workflows/windows-package.yml) uses the The merge checks run from three workflows named `Policy`, which run on `pull_request_target` and cover pull requests into master or a `release/dsh-x.y.z` branch, so their definitions come from the base branch, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check`, which also publishes the review verdict as the check run `Policy / Reviewer Check`, and `pr-check-on-base-branch.yml` on pushes to master or a release branch re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each job reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, and `MERGE_POLICY_HEAD_DIFF`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `MERGE_POLICY_HEAD_DIFF` decides whether `Policy / Approve Check` runs and fails; while it runs, `Policy / Reviewer Check` is published whatever that mode says, with `success` when every file that needs review has one and `action_required` with the reviewers to ask otherwise. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node with the merged base commit in `BASE_SHA` and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, rejects upgrade guides added outside the root version's directory, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates. The manual Desktop publication workflow selects the original successful `package` job attempt independently of `upload-artifact` archival status, then 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. + +`Publish Desktop · Rollout & Website` reuses the same original-machine routing and approval Environments. Its preparation reads retained metadata and initial publication evidence without revalidating package binaries; it does not create release tags. Cache-only recovery uses the existing website approval and upload journal. diff --git a/.github/workflows/desktop-rollout.yml b/.github/workflows/desktop-rollout.yml new file mode 100644 index 0000000000..13549da6b4 --- /dev/null +++ b/.github/workflows/desktop-rollout.yml @@ -0,0 +1,263 @@ +name: Publish Desktop · Rollout & Website + +run-name: Rollout Desktop package ${{ inputs.package_run_id }} / attempt ${{ inputs.package_run_attempt }} · ${{ inputs.rollout_percentage && format('{0}%', inputs.rollout_percentage) || 'website only' }} + +on: + workflow_dispatch: + inputs: + package_run_id: + description: '已经完成首次 COS 发布的 Package Desktop run ID' + type: string + required: true + rollout_percentage: + description: '目标放量比例:0–100 整数,只允许保持或增加;留空只发布官网' + type: string + required: false + default: '' + upload_website: + description: '上传已发布版本的官网安装包并刷新缓存(仅 production,不要求放量达到 100%)' + type: boolean + default: false + required: true + package_run_attempt: + description: '已发布的原打包 attempt;默认 1,请核对原 run' + type: string + required: true + default: '1' + +permissions: + contents: read + actions: read + +jobs: + route: + runs-on: ubuntu-latest + outputs: + selection: ${{ steps.route.outputs.selection }} + runner: ${{ steps.route.outputs.runner }} + steps: + - name: Require master for publication + run: node -e "if (process.env.GITHUB_REF !== 'refs/heads/master') throw new Error('Desktop publication must be dispatched from master')" + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + - name: Reject debugging packages before dependency installation + id: preflight + env: + GH_TOKEN: ${{ github.token }} + run: node apps/desktop/scripts/preflight-desktop-publish.mjs + - uses: pnpm/action-setup@v5.0.0 + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - run: pnpm install --frozen-lockfile + - name: Resolve original package and machine + id: route + env: + GH_TOKEN: ${{ github.token }} + DESKTOP_PUBLISH_RUNNERS: ${{ vars.DESKTOP_PUBLISH_RUNNERS }} + DESKTOP_PACKAGE_ATTEMPT: ${{ steps.preflight.outputs.attempt }} + run: node --import tsx apps/desktop/scripts/resolve-desktop-publish-ci.ts + + prepare: + needs: route + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + timeout-minutes: 30 + outputs: + route: ${{ steps.local.outputs.route }} + preset: ${{ steps.local.outputs.preset }} + approval: ${{ steps.check.outputs.approval }} + website-approval: ${{ steps.check.outputs.website-approval }} + website-destination: ${{ steps.check.outputs.website-destination }} + label: ${{ steps.check.outputs.label }} + env: + DESKTOP_ROLLOUT_PERCENTAGE: ${{ inputs.rollout_percentage }} + DESKTOP_PACKAGE_SELECTION: ${{ needs.route.outputs.selection }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + 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' + - name: Check local publication configuration before dependency installation + working-directory: desktop-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Resolve retained package and approval environment + id: local + working-directory: desktop-publisher + env: + GH_TOKEN: ${{ github.token }} + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts route + - name: Verify initial publication and metadata without upload credentials + id: check + working-directory: desktop-publisher + env: + DESKTOP_PUBLISH_ROUTE: ${{ steps.local.outputs.route }} + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts check + + publish: + name: Approve ${{ needs.prepare.outputs.label }} + needs: [route, prepare] + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: ${{ needs.prepare.outputs.preset == 'production' && 'desktop-production' || 'desktop-test' }} + concurrency: + group: desktop-publish-${{ needs.route.outputs.runner }}-${{ needs.prepare.outputs.preset }} + cancel-in-progress: false + timeout-minutes: 90 + env: + DESKTOP_ROLLOUT_PERCENTAGE: ${{ inputs.rollout_percentage }} + DESKTOP_ROLLOUT_APPROVAL: ${{ needs.prepare.outputs.approval }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.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' + - name: Check local publication configuration before dependency installation + working-directory: desktop-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: desktop-publisher + - name: Verify initial publication and metadata without upload credentials + id: check + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts check + - name: Apply rollout to the published feed + id: publish + working-directory: desktop-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts apply + - name: Retain publication audit + continue-on-error: true + if: always() && steps.publish.outputs.record != '' + uses: actions/upload-artifact@v7.0.1 + with: + name: desktop-publication-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.publish.outputs.record }}/ + if-no-files-found: error + retention-days: 30 + + upload-website-installer: + needs: [route, prepare, publish] + if: inputs.upload_website && needs.prepare.outputs.preset != 'test' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: desktop-production + timeout-minutes: 60 + concurrency: + group: desktop-website-upload-${{ needs.prepare.outputs.website-destination }} + cancel-in-progress: false + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }} + DESKTOP_ROLLOUT_APPROVAL: ${{ needs.prepare.outputs.approval }} + DESKTOP_ROLLOUT_PERCENTAGE: ${{ inputs.rollout_percentage }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: website-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: website-publisher/package.json + dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Check local publication configuration + working-directory: website-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: website-publisher + - name: Upload website installer + id: website + working-directory: website-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts website-upload + - name: Archive website feed verification + if: always() && steps.website.outputs.rollout-record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-feed-verification-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.rollout-record }}/ + if-no-files-found: warn + retention-days: 30 + - name: Archive website publication diagnostics + if: always() && steps.website.outputs.record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-upload-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.record }}/ + if-no-files-found: warn + retention-days: 30 + + purge-website-installer: + needs: [route, prepare, upload-website-installer] + if: inputs.upload_website && needs.prepare.outputs.preset != 'test' + runs-on: [self-hosted, '${{ needs.route.outputs.runner }}'] + environment: desktop-production + timeout-minutes: 60 + concurrency: + group: desktop-website-purge-${{ needs.prepare.outputs.website-destination }} + cancel-in-progress: false + env: + DESKTOP_PUBLISH_ROUTE: ${{ needs.prepare.outputs.route }} + DESKTOP_WEBSITE_APPROVAL: ${{ needs.prepare.outputs.website-approval }} + DESKTOP_UPLOAD_WEBSITE: ${{ inputs.upload_website }} + DOWNLOAD_PROD_EO_ZONE_ID: ${{ vars.DOWNLOAD_PROD_EO_ZONE_ID }} + steps: + - uses: actions/checkout@v7.0.1 + with: + persist-credentials: false + clean: false + path: website-publisher + - uses: pnpm/action-setup@v5.0.0 + with: + package_json_file: website-publisher/package.json + dest: ${{ runner.temp }}/website-pnpm-${{ github.run_id }}-${{ github.run_attempt }} + - uses: actions/setup-node@v7.0.0 + with: + node-version: '24' + - name: Check local publication configuration + working-directory: website-publisher + run: node apps/desktop/scripts/preflight-desktop-publish-local.mjs + - run: pnpm install --frozen-lockfile + working-directory: website-publisher + - name: Recover and observe website cache purge + id: website + working-directory: website-publisher + run: node --import tsx apps/desktop/scripts/publish-desktop-rollout-ci.ts website-purge + - name: Archive website publication diagnostics + if: always() && steps.website.outputs.record != '' + continue-on-error: true + uses: actions/upload-artifact@v7.0.1 + with: + name: website-purge-${{ github.run_id }}-${{ github.run_attempt }} + path: ${{ steps.website.outputs.record }}/ + if-no-files-found: warn + retention-days: 30 diff --git a/apps/desktop/publishing/ci/README.i18n.yaml b/apps/desktop/publishing/ci/README.i18n.yaml index fa8c69d60b..d240ef646f 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -12,20 +12,23 @@ en: 470bdf1487a739f9 zh: cd80330eaa8f730c /desktop-ci-publication/table-of-contents: - en: 34283bf8397f6836 - zh: 1798bd921c3adf6c + en: 37d76f8473b967b7 + zh: d211cc54af96672d /desktop-ci-publication/setup-sequence: en: 2be541ef35a092a4 zh: 77d87c00deb45758 /desktop-ci-publication/runner-labels-and-queue-diagnosis: - en: 0478d8e35f9a3d55 - zh: 0aa3fd828bc02691 + en: d2195ee7581b74ec + zh: 0b9847afe9a2a373 /desktop-ci-publication/machine-setup: en: 0f43f2e193e95043 zh: 1c1efb062c06f7b8 /desktop-ci-publication/package-and-publish: - en: 0437ba83ef3d5921 - zh: 395c5efe332cff78 + en: 6325c71df74a9915 + zh: edaa8a4502ed80a1 +/desktop-ci-publication/rollout-and-website-publication: + en: ee7a9ab8c66df979 + zh: c355668bc9bdb635 /desktop-ci-publication/recovery-and-records: en: d6eb9cbac5b819a0 zh: 0de1ec24055d1007 diff --git a/apps/desktop/publishing/ci/README.md b/apps/desktop/publishing/ci/README.md index d69f5ad5bc..7841dcb406 100644 --- a/apps/desktop/publishing/ci/README.md +++ b/apps/desktop/publishing/ci/README.md @@ -16,6 +16,7 @@ Publish a completed package from its original machine without downloading its in - [Runner labels and queue diagnosis](#runner-labels) - [Machine setup](#machine-setup) - [Package and publish](#package-and-publish) +- [Rollout and website publication](#rollout-and-website-publication) - [Recovery and records](#recovery-and-records) - [Restore a channel](#restore-a-channel) @@ -42,7 +43,7 @@ GitHub matches every label in a job's `runs-on` list. The [default-label rules]( | --- | --- | | Windows packaging | `dsh-win-package-trial` | | macOS packaging | `self-hosted`, `macOS`, `ARM64`, `dsh-mac-package-trial` | -| Publication `prepare` and `publish` | `self-hosted` and the selected `DESKTOP_PUBLISH_RUNNERS` entry's `label` | +| All original-machine publication and rollout jobs | `self-hosted` and the selected `DESKTOP_PUBLISH_RUNNERS` entry's `label` | A runner with only `dsh-win-package-trial` can package Windows but cannot accept publication. Its routing entry does not add labels to its GitHub registration. Have a runner administrator correct the registered labels; changing the route's `target` or restarting a healthy listener does not supply a missing label. @@ -78,6 +79,16 @@ Packaging only retains files. After success, open **Publish Desktop · COS → R Before installing dependencies on the original runner, preparation and the approved publish job check that the selected `publishing/.json` is readable JSON. Missing configuration reports its path; restore it and retry publication with the same package. Full configuration and signature validation still run afterward. 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 a Chinese approval summary with separate version, channel, platform and deployment fields, the retained test release ID, full installer/feed URLs, a link to the original packaging attempt, source ref/SHA, platform verification results and proposed tag. Folded details include the COS bucket and file sizes/SHA-256 of the upload bytes, including rewritten YAML. Public-repository summaries expose the test prefix and download URLs; the prefix is not authentication. 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. + + +## Rollout and website publication + +For a 10% → 30% → 100% rollout, first publish through COS with `staging_percentage=10` and `upload_website=false`. Then dispatch **Publish Desktop · Rollout & Website** from `master` with the original package run ID and selected attempt (defaults to 1; verify it against the package run). Set `rollout_percentage` to 30, then 100 for progressive delivery; leave it empty for website-only publication. `upload_website` defaults to false; select a percentage or enable website publication. For this sequence, enable it on the final invocation to upload the production website installer and refresh its cache. Website publication is independent of the rollout percentage and does not require 100%; the installer is available to all website download users. In website-only mode, the publish job verifies the feed without changing its percentage. Each platform uses its own package run. + +Preparation checks GitHub identity, the retained manifest, the small update YAML and a completed initial publication record; it does not rehash package binaries or repeat signature/notarization checks. After Environment approval, execution reads the current COS feed, verifies that its version, payload URL, size, hash and other metadata still match the published package, and changes only `stagingPercentage`. Missing or replaced feeds reject the operation. Repeating the same percentage is a no-op; lowering the percentage is rejected. The approval shows the requested percentage; the current percentage is read from COS during execution. + +Website upload reuses the original retained installer, hashes that installer alone and uses the existing upload/purge recovery records. Cache-only retries use the existing website approval and upload journal without requiring the retained update YAML or initial feed journal. The website upload holds the feed channel lock through installer verification and upload so a newer feed cannot replace the selected version midway; competing publications on that channel fail while the lock is held. A metadata-only rollout therefore does not require installer files, but website publication requires its installer. Percentage changes retain before/after YAML in separate rollout audit records. Initial COS publication remains required; this workflow never builds packages, uploads versioned binaries or creates release tags. + ## Recovery and records diff --git a/apps/desktop/publishing/ci/README.zh.md b/apps/desktop/publishing/ci/README.zh.md index 43e892b561..249db63816 100644 --- a/apps/desktop/publishing/ci/README.zh.md +++ b/apps/desktop/publishing/ci/README.zh.md @@ -16,6 +16,7 @@ description: "在专用 macOS 和 Windows runner 上发布留存的 Desktop 安 - [Runner 标签与排队排查](#runner-labels) - [机器配置](#machine-setup) - [打包与发布](#package-and-publish) +- [放量与官网发布](#rollout-and-website-publication) - [恢复与记录](#recovery-and-records) - [恢复频道](#restore-a-channel) @@ -42,7 +43,7 @@ GitHub 要求 runner 同时匹配 job 的 `runs-on` 列表中的全部标签。[ | --- | --- | | Windows 打包 | `dsh-win-package-trial` | | macOS 打包 | `self-hosted`、`macOS`、`ARM64`、`dsh-mac-package-trial` | -| 发布 `prepare` 和 `publish` | `self-hosted`,以及选中的 `DESKTOP_PUBLISH_RUNNERS` 条目中的 `label` | +| 所有原机发布和放量任务 | `self-hosted`,以及选中的 `DESKTOP_PUBLISH_RUNNERS` 条目中的 `label` | 只有 `dsh-win-package-trial` 的 runner 可以打包 Windows,却不能接发布任务。路由条目不会给 GitHub 中注册的 runner 添加标签。需由 runner 管理员修正注册标签;修改路由的 `target` 或重启正常监听的进程都不能补齐缺失标签。 @@ -78,6 +79,16 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任 在原 runner 安装依赖前,准备阶段和审批后的发布 job 都会检查所选 `publishing/.json` 是否为可读取的 JSON。配置缺失时会报告路径;补齐后可使用同一个包重试发布。之后仍会执行完整配置与签名校验。上传前,发布器校验全部留存文件的哈希、版本及目标信息,再复核平台签名。macOS 校验 DMG,并解压 ZIP 校验 app 票据;Windows 校验 Authenticode、配置的签名证书指纹及时间戳。准备阶段生成中文审批摘要,分别展示版本、通道、平台和环境,以及留存的 test release ID、安装包与升级 YAML 的完整 URL、原打包尝试链接、源码 ref/SHA、平台校验结果及拟创建的 tag。折叠详情包含 COS 存储桶,以及实际上传字节的大小和 SHA-256(包括改写后的 YAML)。公开仓库的摘要会公开测试前缀和下载 URL;前缀不提供访问鉴权。两种发布 job 均等待 **Review deployments → Approve and deploy**,随后重新核对已批准的 manifest 与上传计划摘要。文件或目标变化会被拒绝。审批不能修改版本;应拒绝并重新打包。两种环境都上传缺失文件、校验远端字节,再更新 YAML。此工作流没有只上传文件的模式。production 最后为记录的打包提交创建 `desktop-v`;已有 tag 必须指向同一提交。 + + +## 放量与官网发布 + +按 10% → 30% → 100% 放量时,先通过 COS 流程以 `staging_percentage=10`、`upload_website=false` 完成首次发布,再从 `master` 发起 **Publish Desktop · Rollout & Website**,填写原打包 run ID 并选择 attempt(默认 1,请与原打包 run 核对)。逐步放量时将 `rollout_percentage` 分别设为 30、100;仅发布官网时留空。`upload_website` 默认为 false;必须填写比例或启用官网发布。在此示例中,最后一轮启用它即可上传生产官网安装器并刷新缓存。官网发布独立于放量比例,不要求达到 100%;官网安装包面向所有下载用户。仅发布官网时,publish job 只校验 feed,不修改放量比例。每个平台使用各自的打包 run。 + +准备阶段检查 GitHub 身份、保留清单、小体积更新 YAML 和已完成的首次发布记录,不重新计算安装包哈希,也不重复检查签名或公证。Environment 审批后,执行阶段读取当前 COS 更新清单,核对版本、安装包 URL、大小、哈希和其他元数据仍对应已发布包,只修改 `stagingPercentage`。更新清单缺失或已被其他包替换时拒绝执行。同一比例重复执行不写入;不允许降低比例。审批展示目标比例,当前比例在执行时从 COS 读取。 + +官网上传复用原机保留的安装器,只校验该安装器的哈希,沿用已有上传与刷新恢复记录。仅重试缓存刷新时沿用官网审批和上传记录,不再依赖原更新 YAML 或首次 feed 发布记录。官网上传在安装器校验、上传期间保持频道锁,防止中途被更新的 feed 替换;此时竞争该频道的发布会失败。因此纯放量不需要安装器文件,发布官网则需要。比例变更前后的 YAML 写入独立放量审计记录。此流程必须在首次 COS 发布之后执行,不构建安装包、不上传版本化安装包,也不创建 release tag。 + ## 恢复与记录 diff --git a/apps/desktop/scripts/desktop-publication-failure.ts b/apps/desktop/scripts/desktop-publication-failure.ts index 3772fb0b67..2806a720f0 100644 --- a/apps/desktop/scripts/desktop-publication-failure.ts +++ b/apps/desktop/scripts/desktop-publication-failure.ts @@ -46,7 +46,7 @@ export function desktopPublicationFailure(error: unknown, phase: string): { message: `Invalid fields: ${error.issues.map(issue => `${issue.path.join('.') || '(root)'} (${issue.code})`).join(', ')}` } if (error instanceof SyntaxError) return { phase, code: 'invalid-json', message: 'Invalid JSON; check the configuration or retained record for this stage.' } const ownedMessage = - /^(desktop (?:publish|archival|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u + /^(desktop (?:publish|rollout|archival|release store|retention|rollback|tag|upload|auto-update|macOS signing|build)[^\n]*:|Usage:)/u const websiteMessage = /^website (?:publication|installer|cache purge):/u if (error instanceof Error && (ownedMessage.test(error.message) || websiteMessage.test(error.message))) { return { phase, code: 'validation', message: error.message } diff --git a/apps/desktop/scripts/desktop-publish-record.ts b/apps/desktop/scripts/desktop-publish-record.ts index 7ef28cac0c..718146b86b 100644 --- a/apps/desktop/scripts/desktop-publish-record.ts +++ b/apps/desktop/scripts/desktop-publish-record.ts @@ -40,7 +40,7 @@ export interface DesktopPublicationRecord { */ export async function createDesktopPublicationRecord( root: string, - identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback'; reason: string; stagingPercentage?: number | undefined }, + identity: { repository: string; runId: string; attempt: string; actor: string; destination: string; operation: 'publish' | 'rollback' | 'rollout'; reason: string; stagingPercentage?: number | undefined }, ): Promise { await mkdir(root, { recursive: true, mode: 0o700 }) const staging = await mkdtemp(join(root, '.initializing-')) @@ -80,9 +80,29 @@ export async function wasDesktopAttemptPublished(root: string, expected: { destination: string stagingPercentage?: number | undefined }): Promise { + const evidence = await findDesktopPublication(root, expected) + if (!evidence) return false + if (evidence.stagingPercentage !== expected.stagingPercentage) { + throw new Error('desktop publish: staging percentage differs from the completed publication') + } + return true +} + +/** + * Find initial feed-publication evidence independently of later rollout percentages. + * @param root Original machine's publication journal directory. + * @param expected Immutable packaging identity and destination. + * @returns Original publication settings, or null when no completed feed publication exists. + */ +export async function findDesktopPublication(root: string, expected: { + repository: string + runId: string + attempt: string + destination: string +}): Promise<{ stagingPercentage?: number | undefined } | null> { let entries try { entries = await readdir(root, { withFileTypes: true }) } catch (error) { - if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return false + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return null throw error } for (const entry of entries) { @@ -105,12 +125,9 @@ export async function wasDesktopAttemptPublished(root: string, expected: { // Only newline-terminated entries were completely appended before an interruption. for (const line of events.split('\n').slice(0, -1)) { if (z.object({ type: z.string() }).parse(JSON.parse(line)).type === 'feeds-complete') { - if (parsed.stagingPercentage !== expected.stagingPercentage) { - throw new Error('desktop publish: staging percentage differs from the completed publication') - } - return true + return { stagingPercentage: parsed.stagingPercentage } } } } - return false + return null } diff --git a/apps/desktop/scripts/desktop-rollout.ts b/apps/desktop/scripts/desktop-rollout.ts new file mode 100644 index 0000000000..50750ef1e8 --- /dev/null +++ b/apps/desktop/scripts/desktop-rollout.ts @@ -0,0 +1,127 @@ +/** Change only the rollout percentage of an already published Desktop update feed. */ +import { createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { isDeepStrictEqual } from 'node:util' +import { dump, load } from 'js-yaml' +import { z } from 'zod' +import { desktopUpdateMetadataFilename, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs' +import type { DesktopRetainedManifest } from './desktop-release-store.ts' +import type { DesktopUploadPlan } from './desktop-upload-plan.ts' +import { desktopArtifactDigest, type DesktopPublishOperations, type DesktopRemoteObject } from './desktop-publish.ts' + +const percentage = z.number().int().min(0).max(100) +const metadataSchema = z.object({ + version: z.string(), + files: z.array(z.object({ url: z.string(), size: z.number().int().positive(), sha512: z.string() }).passthrough()).length(1), + path: z.string().optional(), sha512: z.string().optional(), stagingPercentage: percentage.optional(), +}).passthrough() + +function metadata(contents: string) { + if (Buffer.byteLength(contents) > 1024 * 1024) throw new Error('desktop rollout: metadata exceeds 1 MiB') + try { return metadataSchema.parse(load(contents)) } + // YAML parser details may include raw remote contents; keep them out of publication diagnostics. + catch { throw new Error('desktop rollout: invalid update metadata') } +} + +/** Validated immutable update identity; no installer bytes are read. */ +export interface DesktopRolloutPlan { + readonly upload: DesktopUploadPlan + readonly expected: string +} + +/** + * Read only the retained update YAML and bind it to its manifest and upload destination. + * @param directory Original attempt's retained directory. + * @param manifest Inventory verified against GitHub identity. + * @param environment Deployment settings loaded from the original machine. + * @returns A plan permitting only this channel metadata object. + */ +export async function prepareDesktopRollout(directory: string, manifest: DesktopRetainedManifest, + environment: NodeJS.ProcessEnv): Promise { + const source = manifest.source + const windows = source.target === 'win-x64' + const platform = windows ? 'win32' : 'darwin' + const update = resolveDesktopUploadConfig({ ...environment, DSH_DESKTOP_UPDATE_CHANNEL: source.channel }, + platform, source.target === 'mac-arm64' ? 'arm64' : 'x64') + if (update.environment !== source.preset) throw new Error('desktop rollout: environment differs from original package') + const filename = desktopUpdateMetadataFilename(source.version, platform, source.channel) + const item = manifest.files.find(file => file.name === filename) + if (!item) throw new Error(`desktop rollout: manifest has no update metadata ${filename}`) + const contents = await readFile(join(directory, filename), 'utf8') + if (item.size !== Buffer.byteLength(contents) + || item.sha512 !== createHash('sha512').update(contents).digest('base64')) { + throw new Error('desktop rollout: retained update metadata differs from manifest') + } + const parsed = metadata(contents) + const payload = `deepseek-harness-${source.version}-${source.target}.${windows ? 'exe' : 'zip'}` + const file = manifest.files.find(entry => entry.name === payload) + const entry = parsed.files[0]! + if (parsed.version !== source.version || entry.url !== payload || !file + || entry.size !== file.size || entry.sha512 !== file.sha512 + || (parsed.path !== undefined && parsed.path !== payload) + || (parsed.sha512 !== undefined && parsed.sha512 !== file.sha512)) { + throw new Error('desktop rollout: update metadata does not identify the retained payload') + } + const url = `${update.origin}/${update.binaryKeyPrefix}/${payload}` + const expected = dump({ ...parsed, files: [{ ...entry, url }], + ...(parsed.path === undefined ? {} : { path: url }) }) + return { expected, upload: { + environment: source.preset, target: source.target, version: source.version, commit: source.commit, dirty: false, + publicUrl: update.publicUrl, bucket: update.bucket, + secretIdEnvName: update.secretIdEnvName, secretKeyEnvName: update.secretKeyEnvName, + artifacts: [{ path: join(directory, filename), filename, key: `${update.keyPrefix}/${filename}`, + contents: expected, contentType: 'application/yaml', channelMetadata: true }], + } } +} + +/** + * Verify current remote metadata against the package, allowing only a different rollout percentage. + * @param plan Approved package identity and channel destination. + * @param remote Current COS metadata object. + * @returns Parsed metadata and effective percentage (100 when absent). + */ +export function verifyDesktopRolloutFeed(plan: DesktopRolloutPlan, remote: DesktopRemoteObject | null) { + if (!remote?.contents) throw new Error('desktop rollout: published update feed is missing') + const actual = metadata(remote.contents) + const expected = metadata(plan.expected) + const { stagingPercentage: current = 100, ...identity } = actual + const { stagingPercentage: _original, ...expectedIdentity } = expected + if (!isDeepStrictEqual(identity, expectedIdentity)) { + throw new Error('desktop rollout: current feed no longer identifies the selected published package') + } + return { metadata: actual, percentage: current } +} + +/** + * Update a published feed under the existing channel lock, or verify it for website-only publication. + * @param plan Metadata-only plan approved for the selected original package. + * @param target Target percentage; undefined only verifies current publication. + * @param operations COS transport and durable audit sink; no binary reads or writes are issued. + * @returns Current and requested percentage; identical requests perform no write. + */ +export async function rolloutDesktopRelease(plan: DesktopRolloutPlan, target: number | undefined, + operations: Pick) { + if (target !== undefined) percentage.parse(target) + const artifact = plan.upload.artifacts[0]! + const before = await operations.transport.read(artifact.key, true) + const current = verifyDesktopRolloutFeed(plan, before) + const next = target ?? current.percentage + if (next < current.percentage) throw new Error('desktop rollout: percentage cannot decrease; inspect the current rollout') + if (next === current.percentage) { + await operations.record({ type: 'object-present', key: artifact.key }) + return { before: current.percentage, after: next } + } + const updated = { ...artifact, contents: dump({ ...current.metadata, stagingPercentage: next }) } + const digest = await desktopArtifactDigest(updated) + const confirmed = await operations.transport.read(artifact.key, true) + if (confirmed?.contents !== before?.contents) throw new Error('desktop rollout: feed changed before write; inspect and retry') + await operations.record({ type: 'put-intent', key: artifact.key, before: before!.contents!, after: updated.contents }) + await operations.transport.put(updated, digest, false) + const actual = await operations.transport.read(artifact.key, true) + if (actual?.size !== digest.size || actual.sha512 !== digest.sha512) { + throw new Error('desktop rollout: written feed is unconfirmed; inspect before retrying') + } + await operations.record({ type: 'put-verified', key: artifact.key }) + return { before: current.percentage, after: next } +} diff --git a/apps/desktop/scripts/publish-desktop-rollout-ci.ts b/apps/desktop/scripts/publish-desktop-rollout-ci.ts new file mode 100644 index 0000000000..4ab553b3c3 --- /dev/null +++ b/apps/desktop/scripts/publish-desktop-rollout-ci.ts @@ -0,0 +1,136 @@ +/** Actions entrypoint for promoting an already published Desktop package without full package verification. */ +import { createHash } from 'node:crypto' +import { appendFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { z } from 'zod' +import { readDesktopPublishConfig, readDesktopUploadCredentials } from './desktop-publish-config.ts' +import { desktopReleasesRoot } from './retain-desktop-ci.ts' +import { readRetainedDesktopManifest, retainedDesktopPath } from './desktop-release-store.ts' +import { resolveRetainedDesktopPublication, retainedDesktopPublishApi } from './resolve-retained-desktop-publish-ci.ts' +import { createDesktopPublicationRecord, findDesktopPublication, withDesktopChannelLock } from './desktop-publish-record.ts' +import { prepareDesktopRollout, rolloutDesktopRelease } from './desktop-rollout.ts' +import { desktopPublishCos } from './desktop-publish-cos.ts' +import { publishWebsiteCi, websiteApproval } from './publish-website-ci.ts' +import { desktopPublicationFailure } from './desktop-publication-failure.ts' +import { desktopWebsiteInstallerUrl } from './desktop-auto-update-environment.mjs' + +const routeSchema = z.object({ + runId: z.string().regex(/^[1-9]\d*$/u), attempt: z.string().regex(/^[1-9]\d*$/u), + commit: z.string().regex(/^[a-f0-9]{40}$/u), sourceRef: z.string(), runnerName: z.string(), runnerLabel: z.string(), + target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), channel: z.enum(['nightly', 'stable']), + preset: z.enum(['test', 'production']), +}) + +/** + * Prepare approval using only identity, initial publication evidence and retained update metadata. + * @param env Original machine settings, resolved route and manual rollout inputs. + * @returns Metadata-only plan and approval digest; no upload credentials are read. + */ +export async function prepareDesktopRolloutCi(env: NodeJS.ProcessEnv) { + const route = routeSchema.parse(JSON.parse(env.DESKTOP_PUBLISH_ROUTE || '{}')) + if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop rollout: original machine required') + const input = env.DESKTOP_ROLLOUT_PERCENTAGE?.trim() || '' + if (input !== '' && (!/^\d+$/u.test(input) || Number(input) > 100)) { + throw new Error('desktop rollout: percentage must be an integer from 0 to 100') + } + const target = input === '' ? undefined : Number(input) + const website = env.DESKTOP_UPLOAD_WEBSITE === 'true' + if (target === undefined && !website) throw new Error('desktop rollout: select a percentage or website publication') + if (website && (route.preset !== 'production' || !/^zone-[a-z0-9]+$/u.test(env.DOWNLOAD_PROD_EO_ZONE_ID || ''))) { + throw new Error('desktop rollout: website publication requires production and DOWNLOAD_PROD_EO_ZONE_ID') + } + const root = desktopReleasesRoot(env) + const manifest = await readRetainedDesktopManifest(root, route.runId, route.attempt) + const source = manifest.source + for (const field of ['runId', 'attempt', 'commit', 'sourceRef', 'runnerName', 'target', 'channel', 'preset'] as const) { + if (source[field] !== route[field]) throw new Error('desktop rollout: route differs from retained package') + } + if (source.repository !== env.GITHUB_REPOSITORY + || source.qualification !== (source.target === 'win-x64' ? 'signed' : 'notarized')) { + throw new Error('desktop rollout: repository or release qualification mismatch') + } + const config = await readDesktopPublishConfig(env.DSH_PACKAGING_CONFIG_DIR!, source.preset) + const environment = source.preset === 'test' + ? { DSH_DESKTOP_AUTO_UPDATE_ENV: 'test', DOWNLOAD_TEST_ORIGIN: config.origin, + 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 prepareDesktopRollout(retainedDesktopPath(root, route.runId, route.attempt), manifest, environment) + const destination = `${config.bucket}/${plan.upload.publicUrl}${source.channel}` + const recordsRoot = join(dirname(root), 'upload-records') + if (!await findDesktopPublication(recordsRoot, { repository: source.repository, + runId: route.runId, attempt: route.attempt, destination })) { + throw new Error('desktop rollout: initial COS feed publication is not confirmed for this package') + } + const approval = createHash('sha256').update(JSON.stringify({ manifest, plan, target, website, + zoneId: website ? env.DOWNLOAD_PROD_EO_ZONE_ID : undefined })).digest('hex') + return { route, root, recordsRoot, config, manifest, plan, target, website, destination, approval } +} + +/** + * Dispatch a lightweight check, rollout, website upload or website cache recovery. + * @param mode Explicit operation from the trusted workflow. + * @param env Actions environment with approved original package identity. + * @returns Resolves after the selected operation and its durable audit complete. + */ +export async function publishDesktopRolloutCi(mode: 'check' | 'apply' | 'website-upload' | 'website-purge', + env: NodeJS.ProcessEnv): Promise { + if (mode === 'website-purge') { + if (env.DESKTOP_UPLOAD_WEBSITE !== 'true') throw new Error('desktop rollout: website publication was not selected') + // The existing website approval and uploaded journal authorize cache recovery without retained feed files. + await publishWebsiteCi('purge', env) + return + } + const context = await prepareDesktopRolloutCi(env) + const { route, root, recordsRoot, config, manifest, plan, target, website, destination, approval } = context + if (mode === 'check') { + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, + `approval=${approval}\nwebsite-approval=${websiteApproval(manifest, config.bucket)}\nwebsite-destination=${createHash('sha256').update(`${config.bucket}/${route.target}`).digest('hex')}\nlabel=${manifest.source.version} / ${route.channel} / ${route.target} → ${target === undefined ? 'website only' : `${target}%`}\n`) + if (env.GITHUB_STEP_SUMMARY && !env.DESKTOP_ROLLOUT_APPROVAL) await appendFile(env.GITHUB_STEP_SUMMARY, + `## 桌面放量与官网发布确认\n\n打包运行:${route.runId} / 第 ${route.attempt} 次;版本:${manifest.source.version};平台:${route.target};环境 / 渠道:${route.preset} / ${route.channel}。\n\n目标放量比例:${target === undefined ? '保持当前比例' : `${target}%`}。执行时从 COS 核对当前比例,不允许降低比例。\n\n官网:${website ? desktopWebsiteInstallerUrl(route.target) + '(上传安装包并刷新缓存)' : '不变'}。${website ? '官网安装包面向所有下载用户,不受自动更新放量比例限制,也不要求放量达到 100%。固定下载地址无防降级保护,发布较早版本会替换较新安装包。' : ''}\n\n已确认首次 COS 发布记录;不重新计算全部安装包哈希或校验签名。\n`) + return + } + if (env.DESKTOP_ROLLOUT_APPROVAL !== approval) throw new Error('desktop rollout: approved inputs or retained metadata changed') + if (mode.startsWith('website-') && !website) throw new Error('desktop rollout: website publication was not selected') + const credentials = await readDesktopUploadCredentials(config) + await withDesktopChannelLock(join(dirname(root), 'publish-locks'), destination, async () => { + const journal = await createDesktopPublicationRecord(recordsRoot, { + repository: manifest.source.repository, runId: route.runId, attempt: route.attempt, + actor: env.GITHUB_TRIGGERING_ACTOR || env.GITHUB_ACTOR || 'unknown', destination, + operation: 'rollout', reason: mode, stagingPercentage: mode === 'apply' ? target : undefined, + }) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `${mode === 'website-upload' ? 'rollout-record' : 'record'}=${journal.directory}\n`) + try { + const result = await rolloutDesktopRelease(plan, mode === 'apply' ? target : undefined, + { transport: desktopPublishCos(plan.upload, credentials), record: event => journal.record(event) }) + if (mode === 'website-upload') await publishWebsiteCi('upload', env) + await journal.record({ type: 'complete' }) + await journal.finish('complete') + const summary = `Desktop rollout: ${manifest.source.version} / ${route.channel} / ${route.target}: ${result.before}% → ${result.after}%\n` + process.stdout.write(summary) + if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, summary) + } catch (error) { + await journal.record({ type: 'failed', failure: desktopPublicationFailure(error, 'desktop rollout') }) + await journal.finish('failed') + throw error + } + }) +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + try { + const mode = process.argv[2] + if (mode === 'route' && process.argv.length === 3) { + if (!process.env.GITHUB_OUTPUT) throw new Error('desktop rollout: Actions output required') + const route = await resolveRetainedDesktopPublication( + process.env, retainedDesktopPublishApi(process.env), readRetainedDesktopManifest) + await appendFile(process.env.GITHUB_OUTPUT, `route=${JSON.stringify(route)}\npreset=${route.preset}\n`) + } else { + const parsed = z.enum(['check', 'apply', 'website-upload', 'website-purge']).parse(mode) + if (process.argv.length !== 3) throw new Error('desktop rollout: expected one operation') + await publishDesktopRolloutCi(parsed, process.env) + } + } catch (error) { + process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'desktop rollout'))}\n`) + process.exitCode = 1 + } +} diff --git a/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts b/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts index d892fe6906..1f2526e78a 100644 --- a/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts +++ b/apps/desktop/scripts/resolve-retained-desktop-publish-ci.ts @@ -10,13 +10,15 @@ import { resolveDesktopPublishRoute } from './desktop-publish-route.ts' * Verify a retained package against GitHub selection evidence, source history and required reviewers. * @param env Selected attempt and current runner context; no signing or upload credentials are needed. * @param api Read-only GitHub API used for source and Environment checks. + * @param readManifest Inventory verifier; metadata-only consumers supply the manifest reader. * @returns Complete route whose deployment environment comes from the retained manifest. */ -export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv, api: (path: string) => unknown) { +export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv, api: (path: string) => unknown, + readManifest = verifyRetainedDesktopRelease) { const selection = z.object({ run: z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive() }).passthrough(), jobs: z.array(z.unknown()), routes: z.unknown(), runnerName: z.string() }).parse(JSON.parse(env.DESKTOP_PACKAGE_SELECTION || '{}')) if (!env.GITHUB_REPOSITORY || env.RUNNER_NAME !== selection.runnerName) throw new Error('desktop publish: task was dispatched to a different machine') - const manifest = await verifyRetainedDesktopRelease(desktopReleasesRoot(env), String(selection.run.id), String(selection.run.run_attempt)) + const manifest = await readManifest(desktopReleasesRoot(env), String(selection.run.id), String(selection.run.run_attempt)) const route = resolveDesktopPublishRoute(env.GITHUB_REPOSITORY, selection.run, manifest, selection.jobs, selection.routes) if (route.runnerName !== env.RUNNER_NAME) throw new Error('desktop publish: retained package belongs to another machine') const repository = env.GITHUB_REPOSITORY @@ -37,16 +39,26 @@ export async function resolveRetainedDesktopPublication(env: NodeJS.ProcessEnv, return route } -if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { - const env = process.env - if (!env.GITHUB_OUTPUT || !env.GH_TOKEN) throw new Error('desktop publish: Actions output and API token required') - const route = await resolveRetainedDesktopPublication(env, async (path) => { +/** + * Create the read-only GitHub verifier used on self-hosted publication machines. + * @param env Actions API origin and token; no gh installation is required. + * @returns A bounded JSON reader whose responses are validated by the route resolver. + */ +export function retainedDesktopPublishApi(env: NodeJS.ProcessEnv): (path: string) => Promise { + if (!env.GH_TOKEN) throw new Error('desktop publish: API token required') + return async (path) => { const response = await fetch(`${env.GITHUB_API_URL || 'https://api.github.com'}/${path}`, { headers: { Authorization: `Bearer ${env.GH_TOKEN}`, Accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(60_000), }) if (!response.ok) throw new Error(`desktop publish: GitHub verification request failed (HTTP ${response.status})`) return response.json() - }) + } +} + +if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) { + const env = process.env + if (!env.GITHUB_OUTPUT || !env.GH_TOKEN) throw new Error('desktop publish: Actions output and API token required') + const route = await resolveRetainedDesktopPublication(env, retainedDesktopPublishApi(env)) await appendFile(env.GITHUB_OUTPUT, `route=${JSON.stringify(route)}\npreset=${route.preset}\n`) } diff --git a/apps/desktop/tests/desktop-publication-failure.spec.ts b/apps/desktop/tests/desktop-publication-failure.spec.ts index d557c28e98..721b839ba8 100644 --- a/apps/desktop/tests/desktop-publication-failure.spec.ts +++ b/apps/desktop/tests/desktop-publication-failure.spec.ts @@ -56,3 +56,21 @@ it('prints structured archival failure through the real CLI', () => { expect(JSON.parse(result.stderr)).toEqual({ phase: 'artifact archival', code: 'validation', message: 'desktop archival: invalid ZIP output settings' }) }) + + +it.each(['percentage cannot decrease; inspect the current rollout', 'feed changed before write; inspect and retry', + 'written feed is unconfirmed; inspect before retrying'])('retains rollout recovery instructions: %s', (message) => { + expect(desktopPublicationFailure(new Error(`desktop rollout: ${message}`), 'desktop rollout')) + .toEqual({ phase: 'desktop rollout', code: 'validation', message: `desktop rollout: ${message}` }) +}) + +it.each([['check', 'extra'], ['route']])('prints actionable rollout CLI failures for %s', (...args) => { + const result = spawnSync(process.execPath, ['--import', 'tsx/esm', + fileURLToPath(new URL('../scripts/publish-desktop-rollout-ci.ts', import.meta.url)), ...args], { + encoding: 'utf8', timeout: 30_000, env: { ...process.env, GITHUB_OUTPUT: '', GH_TOKEN: '' }, + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(1) + expect(JSON.parse(result.stderr)).toMatchObject({ phase: 'desktop rollout', code: 'validation', + message: args[0] === 'route' ? 'desktop rollout: Actions output required' : 'desktop rollout: expected one operation' }) +}) diff --git a/apps/desktop/tests/desktop-rollout.spec.ts b/apps/desktop/tests/desktop-rollout.spec.ts new file mode 100644 index 0000000000..8109327090 --- /dev/null +++ b/apps/desktop/tests/desktop-rollout.spec.ts @@ -0,0 +1,408 @@ +import { execFile, spawnSync } from 'node:child_process' +import { createServer } from 'node:http' +import { promisify } from 'node:util' +import { createHash } from 'node:crypto' +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { teo } from 'tencentcloud-sdk-nodejs-teo' +import { createDesktopUploadPlan } from '../scripts/desktop-upload-plan.ts' +import { desktopBuildRecordFilename, desktopWebsiteInstallerUrl } from '../scripts/desktop-auto-update-environment.mjs' +import { dump, load } from 'js-yaml' +import { expect, it, vi } from 'vitest' +import { prepareDesktopRollout, rolloutDesktopRelease } from '../scripts/desktop-rollout.ts' +import * as cosTransport from '../scripts/desktop-publish-cos.ts' +import * as websitePublisher from '../scripts/publish-website-ci.ts' +import { prepareDesktopRolloutCi, publishDesktopRolloutCi } from '../scripts/publish-desktop-rollout-ci.ts' +import { createDesktopPublicationRecord } from '../scripts/desktop-publish-record.ts' +import { readRetainedDesktopManifest, type DesktopRetainedManifest } from '../scripts/desktop-release-store.ts' +import { resolveRetainedDesktopPublication } from '../scripts/resolve-retained-desktop-publish-ci.ts' +import type { DesktopPublishEvent, DesktopRemoteObject, DesktopPublishTransport } from '../scripts/desktop-publish.ts' + +const digest = (contents: string) => ({ + size: Buffer.byteLength(contents), sha512: createHash('sha512').update(contents).digest('base64'), +}) + +async function fixture(target: 'mac-arm64' | 'mac-x64' | 'win-x64' = 'mac-arm64', + preset: 'production' | 'test' = 'production', channel: 'nightly' | 'stable' = 'nightly') { + const root = await mkdtemp(join(tmpdir(), 'desktop-rollout-')) + try { + const directory = join(root, 'releases', '42', '1') + await mkdir(directory, { recursive: true }) + const version = '0.2.2-rc.1' + const payload = `deepseek-harness-${version}-${target}.${target === 'win-x64' ? 'exe' : 'zip'}` + const contents = dump({ version, files: [{ url: payload, ...digest('installer') }], + path: payload, sha512: digest('installer').sha512, releaseDate: '2026-11-04T00:00:00.000Z' }) + const filename = `${channel}${target === 'win-x64' ? '' : '-mac'}.yml` + const manifest: DesktopRetainedManifest = { schemaVersion: 1, source: { + repository: 'owner/repo', runId: '42', attempt: '1', commit: 'a'.repeat(40), workflowCommit: 'b'.repeat(40), + sourceRef: 'dsh-v0.2.2-rc.1', runnerName: 'runner', target, preset, channel, + productVersion: version, version, testReleaseId: preset === 'test' ? 'a'.repeat(32) : null, qualification: target === 'win-x64' ? 'signed' : 'notarized', + }, files: [{ name: filename, ...digest(contents) }, { name: payload, ...digest('installer') }] } + await writeFile(join(directory, filename), contents) + await writeFile(join(directory, 'manifest.json'), JSON.stringify(manifest)) + const environment = { DSH_DESKTOP_AUTO_UPDATE_ENV: preset, DOWNLOAD_PROD_COS_BUCKET: 'bucket-123', + DOWNLOAD_TEST_ORIGIN: 'https://test.example.com', DOWNLOAD_TEST_COS_BUCKET: 'test-123', DOWNLOAD_TEST_RELEASE_ID: 'a'.repeat(32) } + const plan = await prepareDesktopRollout(directory, manifest, environment) + return { root, directory, manifest, plan, filename, environment } + } catch (error) { await rm(root, { recursive: true, force: true }); throw error } +} + +function transport(contents: string) { + let remote: DesktopRemoteObject | null = { contents, ...digest(contents) } + const reads: string[] = [] + const writes: string[] = [] + const events: DesktopPublishEvent[] = [] + const api: DesktopPublishTransport = { + async read(key, channel) { expect(channel).toBe(true); reads.push(key); return remote }, + async put(artifact, expected, forbidden) { + expect(forbidden).toBe(false) + expect(artifact.channelMetadata).toBe(true) + expect(digest(artifact.contents!)).toEqual(expected) + writes.push(artifact.contents!) + remote = { contents: artifact.contents!, ...expected } + }, + } + return { api, reads, writes, events, record: async (event: DesktopPublishEvent) => { events.push(event) }, + replace(value: DesktopRemoteObject | null) { remote = value }, + } +} + +it.each([ + ['mac-arm64', 'production', 'nightly'], ['win-x64', 'production', 'nightly'], + ['mac-x64', 'production', 'stable'], ['mac-arm64', 'test', 'nightly'], ['win-x64', 'test', 'stable'], +] as const)('promotes %s %s %s from 10 to 30 to 100 without installer files or binary requests', async (target, preset, channel) => { + const f = await fixture(target, preset, channel) + try { + const original = load(f.plan.expected) as Record + const mock = transport(dump({ ...original, stagingPercentage: 10 })) + const operations = { transport: mock.api, record: mock.record } + expect(await rolloutDesktopRelease(f.plan, 30, operations)).toEqual({ before: 10, after: 30 }) + expect(await rolloutDesktopRelease(f.plan, 100, operations)).toEqual({ before: 30, after: 100 }) + expect(await rolloutDesktopRelease(f.plan, 100, operations)).toEqual({ before: 100, after: 100 }) + expect(await rolloutDesktopRelease(f.plan, undefined, operations)).toEqual({ before: 100, after: 100 }) + expect(mock.writes).toHaveLength(2) + expect(new Set(mock.reads)).toEqual(new Set([f.plan.upload.artifacts[0]!.key])) + for (const [index, value] of mock.writes.entries()) { + expect(load(value)).toEqual({ ...original, stagingPercentage: index === 0 ? 30 : 100 }) + } + expect(mock.events.filter(event => event.type === 'put-intent')).toHaveLength(2) + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it.each(['missing', 'version', 'url', 'hash', 'size', 'policy', 'percentage', 'decrease', 'race', 'unconfirmed'] as const)( + 'rejects %s without blindly retrying a write', async (scenario) => { + const f = await fixture() + try { + const metadata = load(f.plan.expected) as { + version: string + files: Array<{ url: string; sha512: string; size: number }> + stagingPercentage?: number + [key: string]: unknown + } + metadata.stagingPercentage = 10 + if (scenario === 'version') metadata.version = '9.0.0' + if (scenario === 'url') metadata.files[0]!.url = 'https://example.com/other.zip' + if (scenario === 'hash') metadata.files[0]!.sha512 = digest('different').sha512 + if (scenario === 'size') metadata.files[0]!.size++ + if (scenario === 'policy') metadata.minimumVersion = '9.0.0' + if (scenario === 'percentage') metadata.stagingPercentage = 101 + const mock = transport(dump(metadata)) + if (scenario === 'missing') mock.replace(null) + let reads = 0 + const api = { ...mock.api, async read(key: string, channel: boolean) { + reads++ + if (scenario === 'race' && reads === 2) mock.replace(null) + if (scenario === 'unconfirmed' && reads === 3) mock.replace(null) + return mock.api.read(key, channel) + } } + await expect(rolloutDesktopRelease(f.plan, scenario === 'decrease' ? 5 : 30, + { transport: api, record: mock.record })).rejects.toThrow() + expect(mock.writes).toHaveLength(scenario === 'unconfirmed' ? 1 : 0) + } finally { await rm(f.root, { recursive: true, force: true }) } + }) + +it('rejects changed retained YAML before reading remote metadata', async () => { + const f = await fixture() + try { + await writeFile(join(f.directory, f.filename), 'version: 9.0.0') + await expect(prepareDesktopRollout(f.directory, f.manifest, f.environment)).rejects.toThrow(/differs from manifest/u) + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it('requires initial publication evidence and prepares approval without installer or credential files', async () => { + const f = await fixture() + try { + const config = join(f.root, 'config') + await mkdir(join(config, 'publishing'), { recursive: true }) + await writeFile(join(config, 'publishing/production.json'), JSON.stringify({ + bucket: 'bucket-123', origin: 'https://download.deepseek.com', credentialsFile: join(f.root, 'absent-secret'), + githubApp: { clientId: 'app', privateKeyFile: join(f.root, 'absent-key') }, + })) + const route = { ...f.manifest.source, runnerLabel: 'dsh-runner' } + const env = { RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', DSH_PACKAGING_CONFIG_DIR: config, + DESKTOP_PUBLISH_ROUTE: JSON.stringify(route), DESKTOP_ROLLOUT_PERCENTAGE: '30', DESKTOP_UPLOAD_WEBSITE: 'false' } + await expect(prepareDesktopRolloutCi(env)).rejects.toThrow(/initial COS/u) + const record = await createDesktopPublicationRecord(join(f.root, 'upload-records'), { + repository: 'owner/repo', runId: '42', attempt: '1', + destination: `bucket-123/${f.plan.upload.publicUrl}nightly`, actor: 'operator', + operation: 'publish', reason: 'fixture', stagingPercentage: 10, + }) + await record.record({ type: 'feeds-complete' }) + const context = await prepareDesktopRolloutCi(env) + expect(context.target).toBe(30) + expect((await prepareDesktopRolloutCi({ ...env, DESKTOP_ROLLOUT_PERCENTAGE: '100' })).approval).not.toBe(context.approval) + await expect(prepareDesktopRolloutCi({ ...env, DESKTOP_ROLLOUT_PERCENTAGE: '' })).rejects.toThrow(/select a percentage/u) + await expect(prepareDesktopRolloutCi({ ...env, DESKTOP_ROLLOUT_PERCENTAGE: 'NaN' })).rejects.toThrow(/integer/u) + await expect(prepareDesktopRolloutCi({ ...env, RUNNER_NAME: 'other' })).rejects.toThrow(/original machine/u) + await expect(prepareDesktopRolloutCi({ ...env, DESKTOP_UPLOAD_WEBSITE: 'true' })).rejects.toThrow(/production and/u) + expect((await prepareDesktopRolloutCi({ ...env, DESKTOP_ROLLOUT_PERCENTAGE: '', + DESKTOP_UPLOAD_WEBSITE: 'true', DOWNLOAD_PROD_EO_ZONE_ID: 'zone-example' })).website).toBe(true) + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it('keeps rollout approval and website recovery separate without tag creation or full package verification', async () => { + const text = await readFile('.github/workflows/desktop-rollout.yml', 'utf8') + const workflow = load(text) as { + name: string + on: { workflow_dispatch: { inputs: Record } } + jobs: Record; needs?: string[] }> + } + expect(workflow.name).toBe('Publish Desktop · Rollout & Website') + expect(workflow.on.workflow_dispatch.inputs.package_run_attempt!.required).toBe(true) + expect(workflow.on.workflow_dispatch.inputs.upload_website!.default).toBe(false) + expect(workflow.on.workflow_dispatch.inputs.rollout_percentage!.default).toBe('') + expect(workflow.jobs.publish!.environment).toContain('desktop-production') + expect(workflow.jobs['upload-website-installer']!.needs).toContain('publish') + expect(workflow.jobs['purge-website-installer']!.needs).toContain('upload-website-installer') + expect(text).not.toMatch(/create-github-app-token|publish-desktop-ci.ts|resolve-retained-desktop-publish-ci.ts/u) + expect(text).toContain('publish-desktop-rollout-ci.ts route') + expect(workflow.jobs.publish!.env!.DESKTOP_UPLOAD_WEBSITE).toBe('${{ inputs.upload_website }}') + expect(text).toContain('steps.website.outputs.rollout-record') +}) + +it.each(['apply', 'changed-approval', 'replaced-feed', 'website'] as const)('checks approval, current feed and audit at the %s entrypoint', async (scenario) => { + const f = await fixture() + try { + const config = join(f.root, 'config') + const credentialsFile = join(f.root, 'credentials.json') + await mkdir(join(config, 'publishing'), { recursive: true }) + await writeFile(credentialsFile, JSON.stringify({ secretId: 'fixture', secretKey: 'fixture' })) + await writeFile(join(config, 'publishing/production.json'), JSON.stringify({ + bucket: 'bucket-123', origin: 'https://download.deepseek.com', credentialsFile, + githubApp: { clientId: 'app', privateKeyFile: join(f.root, 'absent-key') }, + })) + const record = await createDesktopPublicationRecord(join(f.root, 'upload-records'), { + repository: 'owner/repo', runId: '42', attempt: '1', actor: 'operator', + destination: `bucket-123/${f.plan.upload.publicUrl}nightly`, operation: 'publish', reason: 'fixture', + }) + await record.record({ type: 'feeds-complete' }) + const env = { RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', DSH_PACKAGING_CONFIG_DIR: config, + DESKTOP_PUBLISH_ROUTE: JSON.stringify({ ...f.manifest.source, runnerLabel: 'dsh-runner' }), + DESKTOP_ROLLOUT_PERCENTAGE: scenario === 'website' ? '' : '30', DESKTOP_UPLOAD_WEBSITE: scenario === 'website' ? 'true' : 'false', + DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture', GITHUB_OUTPUT: join(f.root, 'output'), GITHUB_STEP_SUMMARY: join(f.root, 'summary') } + const context = await prepareDesktopRolloutCi(env) + await publishDesktopRolloutCi('check', env) + expect(await readFile(env.GITHUB_OUTPUT, 'utf8')).toContain(`approval=${context.approval}`) + if (scenario === 'website') { + const summary = await readFile(env.GITHUB_STEP_SUMMARY, 'utf8') + expect(summary).toContain('官网安装包面向所有下载用户,不受自动更新放量比例限制') + expect(summary).toContain('固定下载地址无防降级保护,发布较早版本会替换较新安装包') + } + const mock = transport(dump({ ...(load(f.plan.expected) as object), stagingPercentage: 10 })) + if (scenario === 'replaced-feed') mock.replace(null) + const createTransport = vi.spyOn(cosTransport, 'desktopPublishCos').mockReturnValue(mock.api) + const website = vi.spyOn(websitePublisher, 'publishWebsiteCi').mockImplementation(async (mode) => { + expect(mode).toBe('upload') + expect(mock.reads).toHaveLength(1) + expect(mock.writes).toHaveLength(0) + }) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const approved = { ...env, DESKTOP_ROLLOUT_APPROVAL: scenario === 'changed-approval' ? 'changed' : context.approval } + const execution = publishDesktopRolloutCi(scenario === 'website' ? 'website-upload' : 'apply', approved) + if (scenario === 'changed-approval' || scenario === 'replaced-feed') { + await expect(execution).rejects.toThrow(scenario === 'changed-approval' ? 'approved inputs' : 'feed is missing') + expect(mock.writes).toHaveLength(0) + expect(website).not.toHaveBeenCalled() + if (scenario === 'changed-approval') expect(createTransport).not.toHaveBeenCalled() + } else { + await execution + expect(mock.writes).toHaveLength(scenario === 'apply' ? 1 : 0) + expect(website).toHaveBeenCalledTimes(scenario === 'website' ? 1 : 0) + const output = await readFile(env.GITHUB_OUTPUT, 'utf8') + const key = scenario === 'website' ? 'rollout-record=' : 'record=' + const directory = output.split('\n').find(line => line.startsWith(key))!.slice(key.length) + expect(JSON.parse(await readFile(join(directory, 'result.json'), 'utf8'))).toMatchObject({ status: 'complete' }) + expect(await readFile(join(directory, 'events.jsonl'), 'utf8')).toContain(scenario === 'apply' ? 'put-verified' : 'object-present') + expect(await readFile(env.GITHUB_STEP_SUMMARY, 'utf8')).toContain(scenario === 'apply' ? '10% → 30%' : '10% → 10%') + } + } finally { vi.restoreAllMocks(); await rm(f.root, { recursive: true, force: true }) } +}) + +it('routes without package binaries only for the explicit metadata-only reader and keeps reviewer checks', async () => { + const f = await fixture() + try { + const env = { RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', DSH_PACKAGING_CONFIG_DIR: join(f.root, 'config'), + DESKTOP_PACKAGE_SELECTION: JSON.stringify({ runnerName: 'runner', + run: { id: 42, run_attempt: 1, head_sha: f.manifest.source.workflowCommit, path: '.github/workflows/macos-package.yml', + conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } }, + jobs: [{ name: 'package', runner_name: 'runner', conclusion: 'success', labels: ['self-hosted', 'dsh-runner'] }], + routes: { runner: { label: 'dsh-runner', target: 'mac-arm64' } }, + }) } + const api = async (path: string) => path.includes('/environments/') + ? { protection_rules: [{ type: 'required_reviewers', reviewers: [{}] }] } : { sha: f.manifest.source.commit } + await expect(resolveRetainedDesktopPublication(env, api)).rejects.toThrow() + expect(await resolveRetainedDesktopPublication(env, api, readRetainedDesktopManifest)).toMatchObject({ + runId: '42', attempt: '1', runnerName: 'runner', preset: 'production', + }) + await expect(resolveRetainedDesktopPublication(env, async path => path.includes('/environments/') + ? { protection_rules: [] } : api(path), readRetainedDesktopManifest)).rejects.toThrow('requires configured reviewers') + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it('runs the real route CLI without gh and emits the selected route and preset', async () => { + const f = await fixture() + const requests: Array<{ url: string | undefined; token: string | undefined }> = [] + const server = createServer((request, response) => { + requests.push({ url: request.url, token: request.headers.authorization }) + response.writeHead(200, { 'content-type': 'application/json', connection: 'close' }) + response.end(JSON.stringify(request.url?.includes('/environments/') + ? { protection_rules: [{ type: 'required_reviewers', reviewers: [{}] }] } : { sha: f.manifest.source.commit })) + }) + try { + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve() }) + }) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('expected TCP listener') + const output = join(f.root, 'route-output') + const selection = { runnerName: 'runner', + run: { id: 42, run_attempt: 1, head_sha: f.manifest.source.workflowCommit, path: '.github/workflows/macos-package.yml', + conclusion: 'success', event: 'workflow_dispatch', repository: { full_name: 'owner/repo' } }, + jobs: [{ name: 'package', runner_name: 'runner', conclusion: 'success', labels: ['self-hosted', 'dsh-runner'] }], + routes: { runner: { label: 'dsh-runner', target: 'mac-arm64' } }, + } + const result = await promisify(execFile)(process.execPath, + ['--import', 'tsx/esm', 'apps/desktop/scripts/publish-desktop-rollout-ci.ts', 'route'], { + timeout: 30_000, env: { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => key.toLowerCase() !== 'path')), PATH: '', + GH_TOKEN: 'fixture-token', GITHUB_API_URL: `http://127.0.0.1:${address.port}`, GITHUB_OUTPUT: output, + RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', DSH_PACKAGING_CONFIG_DIR: join(f.root, 'config'), + DESKTOP_PACKAGE_SELECTION: JSON.stringify(selection), + }, + }) + expect(result.stderr).toBe('') + const lines = (await readFile(output, 'utf8')).trim().split('\n') + expect(lines[1]).toBe('preset=production') + expect(JSON.parse(lines[0]!.slice('route='.length))).toMatchObject({ runId: '42', attempt: '1', target: 'mac-arm64' }) + expect(requests.map(request => request.url)).toEqual([ + `/repos/owner/repo/commits/${f.manifest.source.commit}`, + `/repos/owner/repo/commits/${encodeURIComponent(`refs/tags/${f.manifest.source.sourceRef}`)}`, + '/repos/owner/repo/environments/desktop-production', + ]) + expect(requests.every(request => request.token === 'Bearer fixture-token')).toBe(true) + } finally { + server.closeAllConnections() + if (server.listening) { + await new Promise((resolve, reject) => server.close((error) => { if (error) reject(error); else resolve() })) + } + await rm(f.root, { recursive: true, force: true }) + } +}) + +it.each(['mac-arm64', 'win-x64'] as const)('accepts %s metadata produced by the original full upload planner', async (target) => { + const f = await fixture(target) + try { + const base = `deepseek-harness-${f.manifest.source.version}-${target}` + const extension = target === 'win-x64' ? 'exe' : 'zip' + await writeFile(join(f.directory, `${base}.${extension}`), 'installer') + await writeFile(join(f.directory, `${base}.${extension}.blockmap`), 'blockmap') + if (target !== 'win-x64') await writeFile(join(f.directory, `${base}.dmg`), 'installer') + await writeFile(join(f.directory, desktopBuildRecordFilename(target)), JSON.stringify({ + schemaVersion: 1, target, version: f.manifest.source.version, channel: 'nightly', + environment: 'production', publicUrl: f.plan.upload.publicUrl, commit: f.manifest.source.commit, dirty: false, + })) + const original = await createDesktopUploadPlan(target, { artifactsRoot: f.directory, environment: f.environment, + packagedProductVersion: f.manifest.source.version, stagingPercentage: 10 }) + const feed = original.artifacts.find(artifact => artifact.channelMetadata)! + const mock = transport(feed.contents!) + expect(await rolloutDesktopRelease(f.plan, 30, { transport: mock.api, record: mock.record })) + .toEqual({ before: 10, after: 30 }) + expect(load(mock.writes[0]!)).toEqual({ ...(load(feed.contents!) as object), stagingPercentage: 30 }) + } finally { await rm(f.root, { recursive: true, force: true }) } +}) + +it.each(['missing-yaml', 'wrong-approval', 'not-uploaded', 'wrong-zone', 'not-selected'] as const)( + 'preserves independent website purge recovery for %s', async (scenario) => { + const f = await fixture() + try { + const config = join(f.root, 'config') + const credentialsFile = join(f.root, 'credentials.json') + await mkdir(join(config, 'publishing'), { recursive: true }) + await writeFile(credentialsFile, JSON.stringify({ secretId: 'fixture-id', secretKey: 'fixture-key' })) + await writeFile(join(config, 'publishing/production.json'), JSON.stringify({ + bucket: 'bucket-123', origin: 'https://download.deepseek.com', credentialsFile, + githubApp: { clientId: 'app', privateKeyFile: join(f.root, 'absent-key') }, + })) + await rm(join(f.directory, f.filename)) + const directory = join(f.root, 'upload-records', 'website-99-mac-arm64') + await mkdir(directory, { recursive: true }) + const approval = websitePublisher.websiteApproval(await readRetainedDesktopManifest(join(f.root, 'releases'), '42', '1'), 'bucket-123') + const publicUrl = desktopWebsiteInstallerUrl('mac-arm64') + await writeFile(join(directory, 'state.json'), JSON.stringify({ approval, + upload: scenario === 'not-uploaded' ? 'pending' : 'uploaded', purgeAttempt: '1', + purge: { zoneId: 'zone-fixture', publicUrl, submittedAt: '2026-11-04T00:00:00.000Z', + jobId: 'existing-job', status: 'processing' }, + })) + const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask').mockRejectedValue(new Error('unexpected purge submission')) + const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockResolvedValue({ + TotalCount: 1, Tasks: [{ JobId: 'existing-job', Target: publicUrl, Type: 'purge_url', Status: 'success' }], + }) + vi.spyOn(process.stdout, 'write').mockReturnValue(true) + const execution = publishDesktopRolloutCi('website-purge', { + RUNNER_NAME: 'runner', GITHUB_REPOSITORY: 'owner/repo', GITHUB_RUN_ID: '99', GITHUB_RUN_ATTEMPT: '2', + DSH_PACKAGING_CONFIG_DIR: config, DESKTOP_PUBLISH_ROUTE: JSON.stringify({ ...f.manifest.source, runnerLabel: 'dsh-runner' }), + DESKTOP_UPLOAD_WEBSITE: scenario === 'not-selected' ? 'false' : 'true', + DESKTOP_WEBSITE_APPROVAL: scenario === 'wrong-approval' ? 'changed' : approval, + DOWNLOAD_PROD_EO_ZONE_ID: scenario === 'wrong-zone' ? 'zone-changed' : 'zone-fixture', + }) + if (scenario === 'missing-yaml') { + await execution + expect(query).toHaveBeenCalledOnce() + expect(JSON.parse(await readFile(join(directory, 'state.json'), 'utf8'))).toMatchObject({ purge: { status: 'success' } }) + } else { + await expect(execution).rejects.toThrow({ + 'wrong-approval': 'approved build or destination changed', 'not-uploaded': 'upload completion is not confirmed', + 'wrong-zone': 'saved destination differs', 'not-selected': 'website publication was not selected', + }[scenario]) + expect(query).not.toHaveBeenCalled() + } + expect(submit).not.toHaveBeenCalled() + } finally { vi.restoreAllMocks(); await rm(f.root, { recursive: true, force: true }) } + }) + +it('executes the master guard and keeps all rollout preflights ahead of dependency installation', async () => { + const workflow = load(await readFile('.github/workflows/desktop-rollout.yml', 'utf8')) as { + jobs: Record }> }> + } + const route = workflow.jobs.route!.steps + const command = route[0]!.run! + for (const ref of ['refs/heads/master', 'refs/heads/stale-feature']) { + const result = spawnSync(process.execPath, ['-e', command.slice('node -e "'.length, -1)], { + env: { ...process.env, GITHUB_REF: ref }, encoding: 'utf8', timeout: 30_000, + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(ref === 'refs/heads/master' ? 0 : 1) + } + const preflight = route.findIndex(step => step.id === 'preflight') + expect(preflight).toBeGreaterThan(0) + expect(preflight).toBeLessThan(route.findIndex(step => step.uses?.startsWith('pnpm/'))) + expect(route.find(step => step.id === 'route')?.env?.DESKTOP_PACKAGE_ATTEMPT).toBe('${{ steps.preflight.outputs.attempt }}') + for (const [name, job] of Object.entries(workflow.jobs)) { + if (name === 'route') continue + const local = job.steps.findIndex(step => step.run?.includes('preflight-desktop-publish-local.mjs')) + expect(local).toBeGreaterThanOrEqual(0) + expect(local).toBeLessThan(job.steps.findIndex(step => step.run === 'pnpm install --frozen-lockfile')) + } +})