diff --git a/.github/workflows/desktop-rollout.yml b/.github/workflows/desktop-rollout.yml new file mode 100644 index 0000000000..4efac2d4b1 --- /dev/null +++ b/.github/workflows/desktop-rollout.yml @@ -0,0 +1,256 @@ +name: Publish Desktop · Rollout & Website + +run-name: Rollout Desktop package ${{ inputs.package_run_id }} / attempt ${{ inputs.package_run_attempt }} to ${{ inputs.rollout_percentage || 'unchanged' }} + +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)' + type: boolean + default: false + required: true + package_run_attempt: + description: '已发布的原打包 attempt,必须明确填写' + 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 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_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: 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..4de43ff566 100644 --- a/apps/desktop/publishing/ci/README.i18n.yaml +++ b/apps/desktop/publishing/ci/README.i18n.yaml @@ -12,8 +12,8 @@ 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 @@ -24,8 +24,11 @@ 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: 92a9fa91af75a504 + zh: 3b001582d5adbed8 /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..4431e8c6ce 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) @@ -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 explicit attempt. Set `rollout_percentage` to 30, then 100 for progressive delivery; leave it empty for website-only publication. `upload_website` defaults to false. Enable it on the final invocation to upload the production website installer and refresh its cache. 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. 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..433b49f28a 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) @@ -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。逐步放量时将 `rollout_percentage` 分别设为 30、100;仅发布官网时留空。`upload_website` 默认为 false,最后一轮启用它即可上传生产官网安装器并刷新缓存。每个平台使用各自的打包 run。 + +准备阶段检查 GitHub 身份、保留清单、小体积更新 YAML 和已完成的首次发布记录,不重新计算安装包哈希,也不重复检查签名或公证。Environment 审批后,执行阶段读取当前 COS 更新清单,核对版本、安装包 URL、大小、哈希和其他元数据仍对应已发布包,只修改 `stagingPercentage`。更新清单缺失或已被其他包替换时拒绝执行。同一比例重复执行不写入;不允许降低比例。审批展示目标比例,当前比例在执行时从 COS 读取。 + +官网上传复用原机保留的安装器,只校验该安装器的哈希,沿用已有上传与刷新恢复记录。因此纯放量不需要安装器文件,发布官网则需要。比例变更前后的 YAML 写入独立放量审计记录。此流程必须在首次 COS 发布之后执行,不构建安装包、不上传版本化安装包,也不创建 release tag。 + ## 恢复与记录 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..c6409ca2a7 --- /dev/null +++ b/apps/desktop/scripts/desktop-rollout.ts @@ -0,0 +1,125 @@ +/** 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)) } + 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) + const contents = await readFile(join(directory, filename), 'utf8') + if (!item || 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..b88a6b932f --- /dev/null +++ b/apps/desktop/scripts/publish-desktop-rollout-ci.ts @@ -0,0 +1,132 @@ +/** 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 } from './resolve-retained-desktop-publish-ci.ts' +import { desktopPublishApi } from './resolve-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 { + 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) await appendFile(env.GITHUB_STEP_SUMMARY, + `## Desktop rollout approval\n\nPackage: ${route.runId} / ${route.attempt}; version ${manifest.source.version}; ${route.target}; ${route.preset} / ${route.channel}.\n\nTarget: ${target === undefined ? 'keep current percentage' : `${target}%`}. Current percentage is verified from COS during execution; decreases are rejected.\n\nWebsite: ${website ? desktopWebsiteInstallerUrl(route.target) + ' (upload and cache refresh)' : 'unchanged'}.\n\nInitial publication confirmed. Package binaries are not rehashed or re-signed.\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') + // Cache recovery uses its own persisted upload evidence and never writes the update feed. + if (mode === 'website-purge') { await publishWebsiteCi('purge', env); return } + 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: target, + }) + if (env.GITHUB_OUTPUT) await appendFile(env.GITHUB_OUTPUT, `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) { + const route = await resolveRetainedDesktopPublication(process.env, desktopPublishApi, readRetainedDesktopManifest) + if (!process.env.GITHUB_OUTPUT) throw new Error('desktop rollout: Actions output required') + 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..00db26eb22 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 diff --git a/apps/desktop/tests/desktop-rollout.spec.ts b/apps/desktop/tests/desktop-rollout.spec.ts new file mode 100644 index 0000000000..ba4b10f20a --- /dev/null +++ b/apps/desktop/tests/desktop-rollout.spec.ts @@ -0,0 +1,250 @@ +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 { 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 }}') +}) + + +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}`) + 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 directory = output.split('\n').find(line => line.startsWith('record='))!.slice(7) + 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 }) } +})