Merge pull request #7777 from deepseek-harness/fix/desktop-artifact-zip-mtime

fix(desktop): 保留 Actions 安装包 ZIP 中的文件修改时间
This commit is contained in:
winewill
2026-11-04 15:10:19 +08:00
committed by GitHub
17 changed files with 141 additions and 65 deletions
+6 -5
View File
@@ -187,6 +187,7 @@ jobs:
timeout-minutes: 30
env:
DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }}
DESKTOP_ARCHIVE_NAME: macos-x64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}.zip
DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }}
DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }}
steps:
@@ -209,17 +210,17 @@ jobs:
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
working-directory: artifact-archiver
- name: Verify original retained build before GitHub archival
- name: Archive verified retained build
id: archive
working-directory: artifact-archiver
run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts
- name: Retain installers for the selected mode
if: success()
uses: actions/upload-artifact@v7.0.1
with:
name: macos-x64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}
include-hidden-files: true
path: ${{ needs.package.outputs.directory }}/*
compression-level: 0
name: ${{ env.DESKTOP_ARCHIVE_NAME }}
path: ${{ steps.archive.outputs.path }}
archive: false
if-no-files-found: error
retention-days: 14
overwrite: true
+6 -5
View File
@@ -184,6 +184,7 @@ jobs:
timeout-minutes: 30
env:
DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }}
DESKTOP_ARCHIVE_NAME: macos-arm64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}.zip
DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }}
DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }}
steps:
@@ -206,17 +207,17 @@ jobs:
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
- run: pnpm install --frozen-lockfile
working-directory: artifact-archiver
- name: Verify original retained build before GitHub archival
- name: Archive verified retained build
id: archive
working-directory: artifact-archiver
run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts
- name: Retain installers for the selected mode
if: success()
uses: actions/upload-artifact@v7.0.1
with:
name: macos-arm64-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}
include-hidden-files: true
path: ${{ needs.package.outputs.directory }}/*
compression-level: 0
name: ${{ env.DESKTOP_ARCHIVE_NAME }}
path: ${{ steps.archive.outputs.path }}
archive: false
if-no-files-found: error
retention-days: 14
overwrite: true
+8 -18
View File
@@ -220,6 +220,7 @@ jobs:
env:
PACKAGE_CACHE_NAMESPACE: ${{ inputs.cache_namespace }}
DESKTOP_RETAINED_DIRECTORY: ${{ needs.package.outputs.directory }}
DESKTOP_ARCHIVE_NAME: windows-x64-${{ inputs.signing }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}.zip
DESKTOP_PACKAGE_ATTEMPT: ${{ needs.package.outputs.attempt }}
DESKTOP_PACKAGE_RUNNER: ${{ needs.package.outputs.runner-name }}
steps:
@@ -251,28 +252,17 @@ jobs:
"PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV
- run: pnpm install --frozen-lockfile
working-directory: artifact-archiver
- name: Verify original retained build before GitHub archival
- name: Archive verified retained build
id: archive
working-directory: artifact-archiver
run: node --import tsx apps/desktop/scripts/archive-desktop-ci.ts
- name: Retain verified unsigned installer
if: success() && inputs.signing == 'unsigned'
- name: Retain installers for the selected mode
if: success()
uses: actions/upload-artifact@v7.0.1
with:
name: windows-x64-unsigned-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}
compression-level: 0
include-hidden-files: true
path: ${{ needs.package.outputs.directory }}/*
if-no-files-found: error
retention-days: 14
overwrite: true
- name: Retain verified signed installer
if: success() && inputs.signing == 'signed'
uses: actions/upload-artifact@v7.0.1
with:
name: windows-x64-signed-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}
compression-level: 0
include-hidden-files: true
path: ${{ needs.package.outputs.directory }}/*
name: ${{ env.DESKTOP_ARCHIVE_NAME }}
path: ${{ steps.archive.outputs.path }}
archive: false
if-no-files-found: error
retention-days: 14
overwrite: true
+4 -4
View File
@@ -24,11 +24,11 @@
en: 5b4f0f80a117f202
zh: ff05be196c181087
/desktop-packaging/windows-actions-packaging:
en: e0a852fdda949c40
zh: b96c613022f6189e
en: 52f0d14ee575ad1a
zh: 94a7f76f4c15031e
/desktop-packaging/macos-self-hosted-packaging:
en: 239d5ab433020745
zh: e8916ba0f4682ae1
en: 08619f0e26782636
zh: 7c236cbe46be4a47
/desktop-packaging/unsigned-windows-test-installer:
en: f429bc0c280e2dfa
zh: 4adbcc9e906bdf80
+2 -2
View File
@@ -115,7 +115,7 @@ Select the workflow in Actions, choose the source branch, preset, signing mode a
All Windows packaging modes, including test and unsigned, require the repository variable `DESKTOP_PUBLISH_RUNNERS`; configure the [original-machine route](../publishing/ci/README.md#machine-setup) before dispatch.
Open the completed run in Actions and download its Artifacts from the independent `upload-artifact` job. Installer names use the original `package_run_attempt`; diagnostic names use the current `run_attempt`. If archival fails after packaging succeeds, rerun only `upload-artifact` to reuse the retained installers. `windows-x64-signed-<version>-<run_id>-<package_run_attempt>` and `windows-x64-unsigned-<version>-<run_id>-<package_run_attempt>` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. The upload steps are `Retain verified signed installer`, `Retain verified unsigned installer`, and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
Open the completed run in Actions and download its Artifacts from the independent `upload-artifact` job. Installer names use the original `package_run_attempt`; diagnostic names use the current `run_attempt`. If archival fails after packaging succeeds, fix the [archival prerequisites](../publishing/ci/README.md#recovery-and-records) and rerun only `upload-artifact` to reuse the retained installers. `windows-x64-signed-<version>-<run_id>-<package_run_attempt>.zip` and `windows-x64-unsigned-<version>-<run_id>-<package_run_attempt>.zip` contain the corresponding EXE; `windows-package-logs-<run_id>-<run_attempt>` contains packaging diagnostics. Installer ZIPs record each retained copy’s modification time as local time in the DOS field and absolute time in the Unix field; this is not a guarantee of the original build time. The upload steps are `Retain installers for the selected mode` and `Retain packaging diagnostics`; their step durations and timestamped logs show upload time. These artifacts are not a Release or download-site publication.
For cache measurements, `cache_namespace` selects a retained subdirectory for both dependency stores and upstream downloads; leave it empty for normal shared caches. Use a new name for a cold sample and repeat that name for warm samples. Names contain 1–64 lowercase letters, digits or hyphens, start with a letter or digit, and cannot be Windows device names. This does not reset the separate signature cache or OS file cache; report those states and Job start-to-completion time alongside phase timings and artifact size. Before dependency installation, the workflow records the Node version, available CPU parallelism, logical CPU count, and total/free memory.
@@ -127,7 +127,7 @@ Installer uploads store the already-compressed release files without ZIP recompr
## macOS self-hosted packaging
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. When the selected source's dsh ships the Computer Use bundle, each workflow builds, notarizes and stages the helper for both architectures with `scripts/cua-helper/package.ts` and `stage.ts` after the configuration check, the steps npm publication uses; `skip_notarization` does not skip the helper's notarization. A source that predates helper packaging only emits a warning, and Computer Use cannot prepare its helper in that package. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. Artifacts are named `macos-<arch>-<mode>-<version>-<run_id>-<package_run_attempt>.zip`, with `arch` set to `arm64` or `x64`; entry timestamps record each retained copy’s modification time as local time in the DOS field and absolute time in the Unix field; this is not a guarantee of the original build time. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
Both macOS workflows require the repository variable `DESKTOP_PUBLISH_RUNNERS`, including test and unnotarized modes. Their independent `upload-artifact` job retains installer names with the original build attempt; if archival fails after packaging succeeds, rerun only that job. See [original-machine routing and recovery](../publishing/ci/README.md#recovery-and-records).
+2 -2
View File
@@ -115,7 +115,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
所有 Windows 打包模式(含 test 和 unsigned)都要求仓库变量 `DESKTOP_PUBLISH_RUNNERS`;触发前配置[原机器路由](../publishing/ci/README.zh.md#machine-setup)。
在 Actions 打开完成的运行,下载独立 `upload-artifact` job 生成的 Artifacts。安装包名称使用原构建的 `package_run_attempt`,诊断名称使用当前 `run_attempt`。打包成功但归档失败时,仅重跑 `upload-artifact` 以复用保留的安装包。`windows-x64-signed-<version>-<run_id>-<package_run_attempt>` 与 `windows-x64-unsigned-<version>-<run_id>-<package_run_attempt>` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。上传步骤名为 `Retain verified signed installer`、`Retain verified unsigned installer` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
在 Actions 打开完成的运行,下载独立 `upload-artifact` job 生成的 Artifacts。安装包名称使用原构建的 `package_run_attempt`,诊断名称使用当前 `run_attempt`。打包成功但归档失败时,修复[归档前置条件](../publishing/ci/README.zh.md#recovery-and-records)后仅重跑 `upload-artifact`,复用保留的安装包。`windows-x64-signed-<version>-<run_id>-<package_run_attempt>.zip` 与 `windows-x64-unsigned-<version>-<run_id>-<package_run_attempt>.zip` 包含对应 EXE;`windows-package-logs-<run_id>-<run_attempt>` 包含打包诊断。安装包 ZIP 用 DOS 字段的本地时间和 Unix 字段的绝对时间记录各留存副本的修改时间,不保证是原始构建时间。上传步骤名为 `Retain installers for the selected mode` 和 `Retain packaging diagnostics`;步骤耗时与带时间戳的日志显示上传用时。这些产物不等于发布 Release 或下载站内容。
测量缓存时,`cache_namespace` 为两个依赖 store 和上游下载选择保留的子目录;留空使用正常共享缓存。新名称用于冷缓存采样,再次使用同名用于热缓存采样。名称由 1–64 个小写字母、数字或连字符组成,以字母或数字开头,且不能为 Windows 设备名。这不会重置独立的签名缓存或操作系统文件缓存;应将其状态、Job 开始至完成的总耗时与阶段耗时和产物体积一并报告。workflow 在依赖安装前记录 Node 版本、可用 CPU 并行数、逻辑 CPU 数及总内存和空闲内存。
@@ -127,7 +127,7 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
## macOS 自托管打包
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。所选源码的 dsh 随附 Computer Use 组合包时,两者都在配置检查之后用 `scripts/cua-helper/package.ts` 和 `stage.ts` 构建、公证并放入两种架构的 helper,与 npm 发布的步骤相同;`skip_notarization` 不会跳过 helper 的公证。源码早于 helper 打包支持时只输出警告,该安装包中的 Computer Use 无法准备 helper。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。产物名为 `macos-<arch>-<mode>-<version>-<run_id>-<package_run_attempt>.zip`,其中 `arch` 为 `arm64` 或 `x64`;条目时间以 DOS 字段的本地时间和 Unix 字段的绝对时间记录留存副本的修改时间,不保证是原始构建时间。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
两个 macOS 工作流都要求仓库变量 `DESKTOP_PUBLISH_RUNNERS`,包括 test 和未公证模式。独立 `upload-artifact` job 的安装包名称保留原构建 attempt;打包成功但归档失败时,仅重跑该 job。参见[原机器路由与恢复](../publishing/ci/README.zh.md#recovery-and-records)。
+2 -2
View File
@@ -27,8 +27,8 @@
en: 3f58698708671202
zh: be0ba215a67168d6
/desktop-ci-publication/recovery-and-records:
en: 266afba2adeeadef
zh: 063114103585dae8
en: d6eb9cbac5b819a0
zh: 0de1ec24055d1007
/desktop-ci-publication/restore-a-channel:
en: d82e2ccf70cce397
zh: d93dcb77cc4b463e
+2
View File
@@ -80,6 +80,8 @@ Before installing dependencies on the original runner, preparation and the appro
## Recovery and records
The archival job creates a store-mode ZIP with system bsdtar: `%SystemRoot%\System32\tar.exe` on Windows, or `/usr/bin/tar` on macOS. `invalid ZIP output settings` rejects a missing or malformed archive name and missing retained-directory, temporary-directory or `GITHUB_OUTPUT` settings before file verification. `desktop archival: SystemRoot is required to locate Windows bsdtar` reports a missing Windows system directory setting. `ZIP creation failed with <tar>` identifies the failed archive tool; inspect its stderr, tool availability and free space in `RUNNER_TEMP`.
The packaging workflows separate `package` from `upload-artifact` (GitHub artifact archival). All packaging modes, including test and unsigned builds, require `DESKTOP_PUBLISH_RUNNERS` before packaging; it routes archival to the original machine. Rerun only `upload-artifact` after an archival failure; it verifies the original manifest and hashes without rebuilding, signing or notarizing. Packaging diagnostics are best effort and do not change package success. If an archival retry stays queued without a runner, inspect the resolved runner label and upstream package outputs before treating the runner as offline. Missing outputs require operator recovery of the original retained directory and manifest; rerunning the whole workflow rebuilds the package and is not archival-only recovery. Verify output reuse in the Actions acceptance test.
Production COS publication enables website installer upload and cache refresh by default (`upload_website=true`). Clear it to publish only versioned resources and automatic-update feeds; test publication never changes the production website. `upload-website-installer` uploads the fixed installer and `purge-website-installer` refreshes its URL. The website installer validator needs only the final DMG/EXE and retained manifest. It matches production qualification, build identity and target, hashes the installer once and uploads through the same open file handle. Both production nightly and stable packages can replace the fixed website installer. Operators select the intended version; the fixed object has no downgrade protection, so rerunning an older publication can replace a newer installer. The pre-approval summary names the fixed website URL, selected version and lack of downgrade protection when website upload is selected. The existing version/update publication retains its own checks.
+2
View File
@@ -80,6 +80,8 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
## 恢复与记录
归档 job 使用系统 bsdtar 创建 store 模式 ZIP:Windows 为 `%SystemRoot%\System32\tar.exe`,macOS 为 `/usr/bin/tar`。`invalid ZIP output settings` 表示归档名称缺失或格式非法,或保留目录、临时目录、`GITHUB_OUTPUT` 缺失,会在校验文件前拒绝。`desktop archival: SystemRoot is required to locate Windows bsdtar` 表示缺失 Windows 系统目录设置。`ZIP creation failed with <tar>` 指明失败的归档工具;检查其 stderr、工具是否可用,以及 `RUNNER_TEMP` 剩余空间。
打包 workflow 将 `package` 与 `upload-artifact`(GitHub 产物归档)分开。所有打包模式(包括 test 和 unsigned)都要求在打包前配置 `DESKTOP_PUBLISH_RUNNERS`,用于将归档路由到原机器。归档失败后仅重跑 `upload-artifact`;它校验原始 manifest 和 hash,不重新构建、签名或公证。打包诊断归档尽力执行,不改变构建成功状态。归档重跑若一直排队且未分配 runner,应先检查解析出的 runner 标签和上游 package 输出,不能直接认定机器离线。输出缺失时需由操作者从原留存目录和 manifest 恢复文件;重跑整个 workflow 会重新构建,不属于仅归档恢复。Actions 验收须验证输出复用。
生产 COS 发布默认启用官网安装包上传和缓存刷新(`upload_website=true`)。取消后仅发布版本化资源及自动更新清单;test 发布不修改生产官网。`upload-website-installer` 上传固定安装包,`purge-website-installer` 刷新该 URL。官网安装包校验器只需要最终 DMG/EXE 和保留的 manifest,匹配生产资格、构建身份及平台,只计算一次安装包 hash,并通过同一打开的文件句柄上传。production nightly 和 stable 包都可以替换官网固定安装包。操作者负责选择预期版本;固定对象没有防降级保护,重跑旧发布可能覆盖新安装包。勾选官网上传时,审批前摘要展示固定官网 URL、选定版本和无防降级保护的说明。原有版本与自动更新发布保留自身校验。
+24 -6
View File
@@ -1,5 +1,7 @@
/** Validate the original machine and immutable inventory before GitHub artifact archival. */
import { resolve } from 'node:path'
/** Create the verified installer ZIP and expose its path to the Actions upload step. */
import { execFileSync } from 'node:child_process'
import { appendFile, mkdtemp } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { desktopPublicationFailure } from './desktop-publication-failure.ts'
import { desktopReleasesRoot } from './retain-desktop-ci.ts'
import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-release-store.ts'
@@ -7,9 +9,9 @@ import { retainedDesktopPath, verifyRetainedDesktopRelease } from './desktop-rel
/**
* Verify an original build without interpreting the archive job's attempt as a new build.
* @param env Original build outputs and current Actions machine identity.
* @returns Resolves after the retained inventory passes all digest checks.
* @returns Verified file names to include alongside the manifest.
*/
export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise<void> {
export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise<string[]> {
if (!env.RUNNER_NAME || env.RUNNER_NAME !== env.DESKTOP_PACKAGE_RUNNER) throw new Error('desktop archival: wrong original machine')
const root = desktopReleasesRoot(env)
const runId = env.GITHUB_RUN_ID || ''
@@ -20,11 +22,27 @@ export async function verifyDesktopArchive(env: NodeJS.ProcessEnv): Promise<void
if (manifest.source.repository !== env.GITHUB_REPOSITORY || manifest.source.runnerName !== env.RUNNER_NAME) {
throw new Error('desktop archival: retained source does not match repository or machine')
}
return ['manifest.json', ...manifest.files.map(file => file.name)]
}
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
try { await verifyDesktopArchive(process.env) }
catch (error) {
try {
const { DESKTOP_ARCHIVE_NAME: name, DESKTOP_RETAINED_DIRECTORY: directory, RUNNER_TEMP: temp, GITHUB_OUTPUT: output } = process.env
if (!name || !/^[a-zA-Z0-9][a-zA-Z0-9.+_-]*\.zip$/u.test(name) || !directory || !temp || !output) throw new Error('desktop archival: invalid ZIP output settings')
const files = await verifyDesktopArchive(process.env)
// archive: false uses the file basename; keep it equal to the upload step's name used for overwrite deletion.
const path = join(await mkdtemp(join(temp, 'desktop-archive-')), name)
const windows = process.platform === 'win32'
if (windows && !process.env.SystemRoot) throw new Error('desktop archival: SystemRoot is required to locate Windows bsdtar')
const tar = windows ? join(process.env.SystemRoot!, 'System32', 'tar.exe') : '/usr/bin/tar'
const metadata = process.platform === 'darwin' ? ['--no-mac-metadata', '--no-xattrs'] : []
try {
execFileSync(tar, ['--format', 'zip', '--options', 'zip:compression=store', ...metadata, '-cf', path, '--', ...files], { cwd: directory, stdio: 'inherit' })
} catch (cause) {
throw new Error(`desktop archival: ZIP creation failed with ${tar}. Check system bsdtar availability, its stderr and free space in RUNNER_TEMP.`, { cause })
}
await appendFile(output, `path=${path}\n`)
} catch (error) {
process.stderr.write(`${JSON.stringify(desktopPublicationFailure(error, 'artifact archival'))}\n`)
process.exitCode = 1
}
@@ -27,7 +27,7 @@ export function preflightDesktopPublish(repository, inputs, api) {
throw new Error('desktop publish: GitHub returned invalid artifact metadata')
}
for (const { name } of pages.flatMap(page => page.artifacts)) {
if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue
if (!name.replace(/\.zip$/u, '').endsWith(`-${inputs.package_run_id}-${attempt}`)) continue
const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned'
: /^macos-(?:arm64|x64)-unnotarized-debug-/u.test(name) ? 'unnotarized' : ''
const diagnostic = desktopPublishQualificationError(qualification)
@@ -48,11 +48,11 @@ it('omits malformed provider receipt fields and never copies raw errors into COS
it('prints structured archival failure through the real CLI', () => {
const result = spawnSync(process.execPath, ['--import', 'tsx/esm',
fileURLToPath(new URL('../scripts/archive-desktop-ci.ts', import.meta.url))], {
encoding: 'utf8', timeout: 30_000, env: { ...process.env, RUNNER_NAME: '', DESKTOP_PACKAGE_RUNNER: '' },
encoding: 'utf8', timeout: 30_000, env: { ...process.env, DESKTOP_ARCHIVE_NAME: '', GITHUB_OUTPUT: '' },
})
expect(result.error).toBeUndefined()
expect(result.signal).toBeNull()
expect(result.status).toBe(1)
expect(JSON.parse(result.stderr)).toEqual({ phase: 'artifact archival', code: 'validation',
message: 'desktop archival: wrong original machine' })
message: 'desktop archival: invalid ZIP output settings' })
})
@@ -4,6 +4,8 @@ import { preflightDesktopPublish } from '../scripts/preflight-desktop-publish.mj
it.each([
['macos-x64-unnotarized-debug-1.0.0-42-2', '跳过了公证'],
['macos-arm64-unnotarized-debug-1.0.0-42-2', '跳过了公证'],
['macos-arm64-unnotarized-debug-1.0.0-42-2.zip', '跳过了公证'],
['windows-x64-unsigned-1.0.0-42-2.zip', '未签名'],
['windows-x64-unsigned-1.0.0-42-2', '未签名'],
])('rejects %s from paginated artifact metadata with rebuilding instructions', (name, reason) => {
const paths: string[] = []
+65 -5
View File
@@ -1,6 +1,8 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { unzipSync } from 'fflate'
import { afterEach, expect, it } from 'vitest'
import { desktopReleasesRoot, retainDesktopCi, resolveDesktopRetentionRunner } from '../scripts/retain-desktop-ci.ts'
import { verifyRetainedDesktopRelease } from '../scripts/desktop-release-store.ts'
@@ -10,9 +12,7 @@ import { prepareDesktopPublication } from '../scripts/publish-desktop-ci.ts'
const roots: string[] = []
afterEach(async () => { await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) })
it.each([
['mac-arm64', 'notarized'], ['mac-x64', 'notarized'], ['mac-x64', 'unnotarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'],
] as const)('retains the complete %s %s release without signing or upload credentials', async (target, qualification) => {
async function retainedFixture(target: 'win-x64' | 'mac-x64' | 'mac-arm64', qualification: 'unsigned' | 'signed' | 'notarized' | 'unnotarized') {
const root = await mkdtemp(join(tmpdir(), 'retain-ci-'))
roots.push(root)
const appRoot = join(root, 'checkout')
@@ -29,7 +29,7 @@ it.each([
: [`deepseek-harness-1.0.0-${target}.dmg`, `deepseek-harness-1.0.0-${target}.zip`, `deepseek-harness-1.0.0-${target}.zip.blockmap`, `${target}-release.json`, 'nightly-mac.yml']
await Promise.all(names.map(name => writeFile(join(artifacts, name), 'fixture')))
const env = {
DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin',
DSH_DESKTOP_RELEASES_DIR: '', DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'test', DSH_DESKTOP_TARGET_PLATFORM: target === 'win-x64' ? 'win32' : 'darwin',
DSH_DESKTOP_TARGET_ARCH: target === 'mac-arm64' ? 'arm64' : 'x64', PACKAGE_SOURCE_REF: 'master', PACKAGE_SOURCE_SHA: 'b'.repeat(40), PACKAGE_VERSION: '1.0.0',
PACKAGE_SIGNING: qualification === 'unsigned' ? 'unsigned' : 'signed', PACKAGE_SKIP_NOTARIZATION: qualification === 'unnotarized' ? 'true' : 'false', GITHUB_RUN_ID: '123', GITHUB_RUN_ATTEMPT: '2',
GITHUB_STEP_SUMMARY: join(root, 'summary.md'),
@@ -39,6 +39,66 @@ it.each([
const directory = await retainDesktopCi(env, appRoot)
const archiveEnv = { ...env, GITHUB_RUN_ATTEMPT: '3', DESKTOP_RETAINED_DIRECTORY: directory,
DESKTOP_PACKAGE_ATTEMPT: '2', DESKTOP_PACKAGE_RUNNER: env.RUNNER_NAME }
return { root, debug, names, env, directory, archiveEnv }
}
const archiveScript = join(import.meta.dirname, '../scripts/archive-desktop-ci.ts')
it.runIf(process.platform === 'darwin' || process.platform === 'win32')('stores installer bytes with local DOS and absolute Unix modification times', async () => {
const { root, directory, archiveEnv, names } = await retainedFixture('win-x64', 'signed')
const timestamp = Date.UTC(2026, 9, 23, 8, 2, 43) / 1000
await utimes(join(directory, 'manifest.json'), timestamp, timestamp)
const output = join(root, 'archive-output')
execFileSync(process.execPath, ['--import', 'tsx', archiveScript], {
env: { ...process.env, ...archiveEnv, TZ: 'TST-3', DESKTOP_ARCHIVE_NAME: 'fixture.zip', GITHUB_OUTPUT: output, RUNNER_TEMP: root },
})
const path = (await readFile(output, 'utf8')).trim().slice('path='.length)
const bytes = await readFile(path)
const entries = unzipSync(bytes)
expect(Object.keys(entries)).toEqual(['manifest.json', ...names])
for (const [name, content] of Object.entries(entries)) expect(Buffer.from(content)).toEqual(await readFile(join(directory, name)))
// UTC+3 is 11:02:43 on every test host.
const directoryOffset = bytes.readUInt32LE(bytes.length - 22 + 16)
expect(bytes.readUInt32LE(directoryOffset)).toBe(0x02014b50)
const nameLength = bytes.readUInt16LE(directoryOffset + 28)
expect(bytes.subarray(directoryOffset + 46, directoryOffset + 46 + nameLength).toString('utf8')).toBe('manifest.json')
expect(bytes.readUInt16LE(directoryOffset + 10)).toBe(0)
const dosTime = bytes.readUInt16LE(directoryOffset + 12)
expect([dosTime >>> 11, (dosTime >>> 5) & 63, (dosTime & 31) * 2]).toEqual([11, 2, 42])
const start = directoryOffset + 46 + nameLength
const end = start + bytes.readUInt16LE(directoryOffset + 30)
let unixTime: number | undefined
for (let offset = start; offset < end; offset += 4 + bytes.readUInt16LE(offset + 2)) {
if (bytes.readUInt16LE(offset) === 0x5455 && (bytes[offset + 4]! & 1)) unixTime = bytes.readUInt32LE(offset + 5)
}
expect(unixTime).toBe(timestamp)
})
it.runIf(process.platform === 'darwin' || process.platform === 'win32')('reports a ZIP creation failure without emitting an upload path', async () => {
const { root, archiveEnv } = await retainedFixture('win-x64', 'signed')
const output = join(root, 'archive-output')
const result = spawnSync(process.execPath, ['--import', 'tsx', archiveScript], { encoding: 'utf8',
env: { ...process.env, ...archiveEnv, RUNNER_TEMP: root, GITHUB_OUTPUT: output, DESKTOP_ARCHIVE_NAME: 'a'.repeat(256) + '.zip' } })
expect(result.status).toBe(1)
expect(result.stderr).toContain('desktop archival: ZIP creation failed')
await expect(readFile(output)).rejects.toMatchObject({ code: 'ENOENT' })
})
it.each(['../escape.zip', '-option.zip', 'missing-extension', 'missing-output'])('rejects invalid archive settings %s without emitting an upload path', async (name) => {
const { root, archiveEnv } = await retainedFixture('win-x64', 'signed')
const output = join(root, 'archive-output')
const result = spawnSync(process.execPath, ['--import', 'tsx', archiveScript], { encoding: 'utf8',
env: { ...process.env, ...archiveEnv, RUNNER_TEMP: root, GITHUB_OUTPUT: name === 'missing-output' ? '' : output,
DESKTOP_ARCHIVE_NAME: name === 'missing-output' ? 'fixture.zip' : name, RUNNER_NAME: 'wrong-machine' } })
expect(result.status).toBe(1)
expect(result.stderr).toContain('desktop archival: invalid ZIP output settings')
await expect(readFile(output)).rejects.toMatchObject({ code: 'ENOENT' })
})
it.each([
['mac-arm64', 'notarized'], ['mac-x64', 'notarized'], ['mac-x64', 'unnotarized'], ['win-x64', 'signed'], ['mac-arm64', 'unnotarized'], ['win-x64', 'unsigned'],
] as const)('retains the complete %s %s release without signing or upload credentials', async (target, qualification) => {
const { debug, names, env, archiveEnv } = await retainedFixture(target, qualification)
await verifyDesktopArchive(archiveEnv)
await expect(verifyDesktopArchive({ ...archiveEnv, RUNNER_NAME: 'other' })).rejects.toThrow('wrong original machine')
await expect(verifyDesktopArchive({ ...archiveEnv, DESKTOP_PACKAGE_ATTEMPT: '3' })).rejects.toThrow('original build')
+4 -3
View File
@@ -19,9 +19,10 @@ export function expectDesktopArchive<S extends { uses?: string; with?: Record<st
const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))
expect(uploads).toHaveLength(count)
for (const step of uploads) {
expect(step.with?.name).toContain('${{ needs.package.outputs.version }}')
expect(step.with?.name).toContain('${{ needs.package.outputs.attempt }}')
expect(step.with?.name).not.toContain('github.run_attempt')
expect(step.with?.name).toBe('${{ env.DESKTOP_ARCHIVE_NAME }}')
expect(job.env.DESKTOP_ARCHIVE_NAME).toContain('${{ needs.package.outputs.version }}')
expect(job.env.DESKTOP_ARCHIVE_NAME).toContain('${{ needs.package.outputs.attempt }}')
expect(job.env.DESKTOP_ARCHIVE_NAME).not.toContain('github.run_attempt')
}
return uploads
}
+5 -5
View File
@@ -49,8 +49,8 @@ describe.each(['arm64', 'x64'])('macOS %s packaging', (arch) => {
const installer = archive.steps.find(step => step.uses?.startsWith('actions/upload-artifact@'))!
expect(uploads[0]?.if).toBe("always() && steps.checkout.outcome == 'success'")
expect(installer.if).toBe('success()')
expect(installer.with?.path).toBe('${{ needs.package.outputs.directory }}/*')
expect(installer.with?.['compression-level']).toBe(0)
expect(installer.with?.path).toBe('${{ steps.archive.outputs.path }}')
expect(installer.with).toMatchObject({ archive: false, overwrite: true, name: '${{ env.DESKTOP_ARCHIVE_NAME }}' })
})
it('defaults to notarization and labels the explicit debugging option', () => {
@@ -60,7 +60,6 @@ describe.each(['arm64', 'x64'])('macOS %s packaging', (arch) => {
} } })
const steps = workflow.jobs.package!.steps
expect(steps.filter(step => step.run?.includes('args+=(--skip-notarization)'))).toHaveLength(2)
expect(workflow.jobs['upload-artifact']!.steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain('${{ needs.package.outputs.mode }}')
})
it('retains build inputs outside checkout and run-specific pnpm installations', () => {
@@ -95,14 +94,15 @@ describe.each(['arm64', 'x64'])('macOS %s packaging', (arch) => {
})
it('passes the same target to preparation, packaging and retention', () => {
expectDesktopArchive(workflow.jobs['upload-artifact']!, 1)
const archive = workflow.jobs['upload-artifact']!
expectDesktopArchive(archive, 1)
const job = workflow.jobs.package!
expect(job.env.DSH_DESKTOP_TARGET_PLATFORM).toBe('darwin')
expect(job.env.DSH_DESKTOP_TARGET_ARCH).toBe(arch)
const builds = job.steps.filter(step => step.run?.includes('pnpm run package:desktop:mac:'))
expect(builds).toHaveLength(2)
for (const step of builds) expect(step.run).toContain(`package:desktop:mac:${arch}`)
expect(workflow.jobs['upload-artifact']!.steps.find(step => step.name === 'Retain installers for the selected mode')?.with?.name).toContain(`macos-${arch}-`)
expect(archive.env.DESKTOP_ARCHIVE_NAME).toBe('macos-' + arch + '-${{ needs.package.outputs.mode }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}.zip')
if (arch === 'x64') {
const rosetta = job.steps.findIndex(step => step.run === 'arch -x86_64 /usr/bin/true')
expect(rosetta).toBeGreaterThan(-1)
+4 -5
View File
@@ -108,16 +108,15 @@ it('validates before building and passes the same mode and version to checks and
it('retains failure diagnostics and uploads only the selected successful installer', () => {
const uploads = job.steps.filter(step => step.uses?.startsWith('actions/upload-artifact@'))
expect(uploads).toHaveLength(1)
const archival = expectDesktopArchive(workflow.jobs['upload-artifact']!, 2)
const archive = workflow.jobs['upload-artifact']!
const archival = expectDesktopArchive(archive, 1)
expect(uploads[0]!.if).toBe("always() && steps.checkout.outcome == 'success'")
expect(uploads[0]!.with).toMatchObject({
path: '${{ github.workspace }}/apps/desktop/.desktop-build/packaging-runs/',
'include-hidden-files': true,
})
for (const mode of ['unsigned', 'signed']) {
const upload = archival.find(step => step.if === `success() && inputs.signing == '${mode}'`)!
expect(upload.with).toMatchObject({ path: '${{ needs.package.outputs.directory }}/*', 'if-no-files-found': 'error', 'compression-level': 0 })
}
expect(archive.env.DESKTOP_ARCHIVE_NAME).toBe('windows-x64-${{ inputs.signing }}-${{ needs.package.outputs.version }}-${{ github.run_id }}-${{ needs.package.outputs.attempt }}.zip')
expect(archival[0]!.with).toMatchObject({ path: '${{ steps.archive.outputs.path }}', 'if-no-files-found': 'error', archive: false, overwrite: true, name: '${{ env.DESKTOP_ARCHIVE_NAME }}' })
})
it('normalizes priority in the dependency and packaging shells before launching their children', () => {