mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
feat(release): reuse CUA helper builds with matching source bytes
This commit is contained in:
@@ -8,6 +8,13 @@ on:
|
||||
type: string
|
||||
default: master
|
||||
required: true
|
||||
cua_run_id:
|
||||
description: >-
|
||||
填写要复用的 CUA helper 构建运行编号(run ID,即运行页面 URL 中 /actions/runs/ 后的数字)。
|
||||
留空自动查找逐字节一致的源码构建出来的产物,找不到时重新构建。
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
preset:
|
||||
description: '机器上的签名配置'
|
||||
type: choice
|
||||
@@ -19,16 +26,26 @@ on:
|
||||
source_ref:
|
||||
type: string
|
||||
required: true
|
||||
cua_run_id:
|
||||
type: string
|
||||
default: ''
|
||||
preset:
|
||||
type: string
|
||||
default: production
|
||||
outputs:
|
||||
artifact:
|
||||
description: '包含两个架构的已签名、公证 helper 归档'
|
||||
value: ${{ jobs.package.outputs.artifact }}
|
||||
artifact-id:
|
||||
description: '包含两个架构的已签名、公证 helper 的产物编号'
|
||||
value: ${{ jobs.package.outputs.artifact-id }}
|
||||
run-id:
|
||||
description: '产物所属的构建运行编号'
|
||||
value: ${{ jobs.package.outputs.run-id }}
|
||||
build-key:
|
||||
description: '源码和构建配置的摘要'
|
||||
value: ${{ jobs.package.outputs.build-key }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
# Desktop uses the same keychain search list and optional system proxy transaction.
|
||||
concurrency:
|
||||
@@ -40,7 +57,9 @@ jobs:
|
||||
runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial]
|
||||
timeout-minutes: 60
|
||||
outputs:
|
||||
artifact: ${{ steps.artifact.outputs.name }}
|
||||
artifact-id: ${{ steps.retained.outputs.artifact-id || steps.upload.outputs.artifact-id }}
|
||||
run-id: ${{ steps.retained.outputs.run-id || github.run_id }}
|
||||
build-key: ${{ steps.retained.outputs.build-key }}
|
||||
env:
|
||||
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
@@ -53,31 +72,47 @@ jobs:
|
||||
clean: true
|
||||
- name: Validate source selection
|
||||
run: |
|
||||
node -e 'const cp=require("node:child_process"); const fs=require("node:fs"); const ref=process.env.PACKAGE_SOURCE_REF;
|
||||
node -e 'const fs=require("node:fs"); const ref=process.env.PACKAGE_SOURCE_REF;
|
||||
if(ref.startsWith("refs/pull/") || /^[a-f0-9]{40}$/i.test(ref)) throw Error("Select a trusted repository branch or release tag");
|
||||
const tag=ref.replace(/^refs\/tags\//, "");
|
||||
if(tag.startsWith("dsh-v") && tag!=="dsh-v"+JSON.parse(fs.readFileSync("package.json","utf8")).version) throw Error("Release tag must match product version");
|
||||
if(!["test","production"].includes(process.env.DSH_PACKAGING_PRESET)) throw Error("Invalid preset");'
|
||||
- uses: pnpm/action-setup@v5.0.0
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
- uses: actions/setup-node@v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
- name: Find reusable CUA helper
|
||||
id: retained
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CUA_RUN_ID: ${{ inputs.cua_run_id }}
|
||||
run: node scripts/cua-helper/select-artifact.ts
|
||||
- uses: pnpm/action-setup@v5.0.0
|
||||
if: steps.retained.outputs.artifact-id == ''
|
||||
with:
|
||||
dest: ${{ runner.temp }}/setup-pnpm-cua-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
- name: Install locked dependencies
|
||||
if: steps.retained.outputs.artifact-id == ''
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Build and verify notarized applications
|
||||
if: steps.retained.outputs.artifact-id == ''
|
||||
env:
|
||||
CUA_EXPECTED_BUILD_KEY: ${{ steps.retained.outputs.build-key }}
|
||||
run: |
|
||||
mkdir -p dist
|
||||
pnpm exec tsx scripts/cua-helper/package.ts --out dist/cua-helper
|
||||
- name: Name retained artifact
|
||||
id: artifact
|
||||
run: echo "name=cua-helper-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" >> "$GITHUB_OUTPUT"
|
||||
- uses: actions/upload-artifact@v7.0.1
|
||||
if: steps.retained.outputs.artifact-id == ''
|
||||
id: upload
|
||||
with:
|
||||
name: ${{ steps.artifact.outputs.name }}
|
||||
name: cua-helper-${{ github.run_id }}-${{ github.run_attempt }}-${{ steps.retained.outputs.build-key }}
|
||||
path: dist/cua-helper/*
|
||||
if-no-files-found: error
|
||||
compression-level: 0
|
||||
retention-days: 14
|
||||
- name: Record newly built artifact
|
||||
if: steps.retained.outputs.artifact-id == ''
|
||||
env:
|
||||
CUA_ARTIFACT_ID: ${{ steps.upload.outputs.artifact-id }}
|
||||
run: |
|
||||
printf '已构建 CUA helper,run ID `%s`,artifact ID `%s`。\n' "$GITHUB_RUN_ID" "$CUA_ARTIFACT_ID" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -9,23 +9,21 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cua_run_id:
|
||||
description: '已有 CUA 打包 run ID:留空重新打包;填写时复用同一源码和版本的已验证产物'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
cua_run_attempt:
|
||||
description: 'CUA 打包 attempt:仅与 run ID 一起使用;留空选择最新一次,不回退到更早的成功构建'
|
||||
description: >-
|
||||
填写要复用的 CUA helper 构建运行编号(run ID,即运行页面 URL 中 /actions/runs/ 后的数字)。
|
||||
留空自动查找逐字节一致的源码构建出来的产物,找不到时重新构建。
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
skip-tag-check:
|
||||
description: Skip release tag validation for the selected ref; other publication checks still apply.
|
||||
description: '跳过所选分支或 tag 的发布 tag 校验,仍执行其余发布检查。'
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
env:
|
||||
PRIMARY_NODE_VERSION: '24'
|
||||
@@ -33,18 +31,16 @@ env:
|
||||
|
||||
jobs:
|
||||
cua-helper:
|
||||
if: (startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check) && inputs.cua_run_id == '' && inputs.cua_run_attempt == ''
|
||||
if: startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check
|
||||
uses: ./.github/workflows/cua-helper-package.yml
|
||||
with:
|
||||
source_ref: ${{ github.ref }}
|
||||
preset: production
|
||||
cua_run_id: ${{ inputs.cua_run_id }}
|
||||
|
||||
pack:
|
||||
name: Pack npm tarballs
|
||||
needs: cua-helper
|
||||
if: >-
|
||||
${{ !cancelled() && (needs.cua-helper.result == 'success'
|
||||
|| (needs.cua-helper.result == 'skipped' && (inputs.cua_run_id != '' || inputs.cua_run_attempt != ''))) }}
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -87,33 +83,20 @@ jobs:
|
||||
RELEASE_PUBLISH: 'true'
|
||||
run: pnpm run release:verify --family dsh ${{ inputs.skip-tag-check && '--skip-tag-check' || '' }}
|
||||
|
||||
- name: Select retained CUA helper
|
||||
if: inputs.cua_run_id != '' || inputs.cua_run_attempt != ''
|
||||
id: retained-cua
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: pnpm exec tsx scripts/cua-helper/select-artifact.ts
|
||||
|
||||
- name: Download retained CUA helper
|
||||
if: inputs.cua_run_id != '' || inputs.cua_run_attempt != ''
|
||||
- name: Download selected CUA helper
|
||||
uses: actions/download-artifact@v8.0.1
|
||||
with:
|
||||
artifact-ids: ${{ steps.retained-cua.outputs.artifact-id }}
|
||||
run-id: ${{ inputs.cua_run_id }}
|
||||
artifact-ids: ${{ needs.cua-helper.outputs.artifact-id }}
|
||||
run-id: ${{ needs.cua-helper.outputs.run-id }}
|
||||
github-token: ${{ github.token }}
|
||||
path: dist/cua-helper
|
||||
|
||||
- name: Build
|
||||
run: pnpm run build:official --serial
|
||||
|
||||
- name: Download freshly built CUA helper
|
||||
if: inputs.cua_run_id == '' && inputs.cua_run_attempt == ''
|
||||
uses: actions/download-artifact@v8.0.1
|
||||
with:
|
||||
name: ${{ needs.cua-helper.outputs.artifact }}
|
||||
path: dist/cua-helper
|
||||
|
||||
- name: Stage verified native helper applications
|
||||
env:
|
||||
CUA_EXPECTED_BUILD_KEY: ${{ needs.cua-helper.outputs.build-key }}
|
||||
run: |
|
||||
mkdir dist/cua-helper/extracted
|
||||
tar -xzf dist/cua-helper/cua-helper.tgz -C dist/cua-helper/extracted
|
||||
@@ -121,13 +104,13 @@ jobs:
|
||||
|
||||
- name: Record CUA source and publication retry instructions
|
||||
env:
|
||||
CUA_RUN_ID: ${{ inputs.cua_run_id || github.run_id }}
|
||||
CUA_RUN_ATTEMPT: ${{ steps.retained-cua.outputs.attempt || github.run_attempt }}
|
||||
CUA_RUN_ID: ${{ needs.cua-helper.outputs.run-id }}
|
||||
CUA_ARTIFACT_ID: ${{ needs.cua-helper.outputs.artifact-id }}
|
||||
run: |
|
||||
{
|
||||
printf 'CUA helper: run `%s`, attempt `%s`.\n\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT"
|
||||
printf '采用 CUA helper 构建 run ID `%s`,artifact ID `%s`。\n\n' "$CUA_RUN_ID" "$CUA_ARTIFACT_ID"
|
||||
printf '%s\n\n' 'npm 部分发布失败时,只重跑 Publish to npm job,复用原 dsh-npm-tarballs;不要重跑全部 job。'
|
||||
printf '若需新建发布运行,填写 `cua_run_id=%s`、`cua_run_attempt=%s`,并使用相同源码 commit 和版本。\n' "$CUA_RUN_ID" "$CUA_RUN_ATTEMPT"
|
||||
printf '需要重新打包时,可留空自动查找,或填写 `cua_run_id=%s` 指定该 helper;源码和构建配置必须匹配。\n' "$CUA_RUN_ID"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Pack release tarballs
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/** Read file-owned packaging settings without loading packaging dependencies. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { parseEnv } from 'node:util'
|
||||
|
||||
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
|
||||
|
||||
/**
|
||||
* Read an explicitly selected dotenv without exposing credential-bearing parser errors.
|
||||
* @param {string} path Selected file; relative file settings resolve beside it.
|
||||
* @param {(name: string) => boolean} accepts Whether this file may declare a setting.
|
||||
* @returns {NodeJS.ProcessEnv} Validated file settings.
|
||||
*/
|
||||
export function readDesktopPackageSettings(path, accepts) {
|
||||
let contents
|
||||
try {
|
||||
contents = readFileSync(path, 'utf8')
|
||||
}
|
||||
catch {
|
||||
throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`)
|
||||
}
|
||||
let settings
|
||||
try {
|
||||
settings = parseEnv(contents.replace(/^\uFEFF/u, ''))
|
||||
}
|
||||
catch {
|
||||
// Parser diagnostics can contain credential-bearing input.
|
||||
throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
|
||||
}
|
||||
for (const name of Object.keys(settings)) {
|
||||
if (!accepts(name)) {
|
||||
throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
|
||||
}
|
||||
if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
|
||||
}
|
||||
for (const name of FILE_SETTINGS) {
|
||||
if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
|
||||
}
|
||||
return settings
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/** Select local test defaults or platform release settings without changing the caller's environment. */
|
||||
|
||||
import { accessSync, constants, readFileSync, statSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { parseEnv } from 'node:util'
|
||||
import { readDesktopPackageSettings as readSettings } from './desktop-file-settings.mjs'
|
||||
import { resolveDesktopAppId, resolveMacOSNotarizationEnvironment, resolveMacOSSigningEnvironment, resolveNpmRegistry } from './desktop-release-environment.mjs'
|
||||
import { resolveDesktopAutoUpdateConfig } from './desktop-auto-update-environment.mjs'
|
||||
import { createWindowsTokenSigner } from './windows-sign.mjs'
|
||||
@@ -19,7 +19,6 @@ const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|M
|
||||
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
|
||||
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
|
||||
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
|
||||
const FILE_SETTINGS = ['DSH_DESKTOP_WINDOWS_CER_FILE', 'DSH_DESKTOP_WINDOWS_SIGNTOOL', 'APPLE_API_KEY', 'APPLE_KEYCHAIN', 'CSC_LINK']
|
||||
|
||||
/**
|
||||
* Select local macOS test defaults without reading dotenv files or inheriting release settings.
|
||||
@@ -101,39 +100,6 @@ export function loadDesktopPackageEnvironment(platform, environment = process.en
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read an explicitly selected dotenv without exposing credential-bearing parser errors.
|
||||
* @param {string} path Selected file; relative file settings resolve beside it.
|
||||
* @param {(name: string) => boolean} accepts Whether this file may declare a setting.
|
||||
* @returns {NodeJS.ProcessEnv} Validated file settings.
|
||||
*/
|
||||
function readSettings(path, accepts) {
|
||||
let contents
|
||||
try {
|
||||
contents = readFileSync(path, 'utf8')
|
||||
}
|
||||
catch {
|
||||
throw new Error(`desktop package: cannot read ${path}; copy ${path}.example and fill in the local settings`)
|
||||
}
|
||||
let settings
|
||||
try {
|
||||
settings = parseEnv(contents.replace(/^\uFEFF/u, ''))
|
||||
}
|
||||
catch {
|
||||
// Parser diagnostics can contain credential-bearing input.
|
||||
throw new Error(`desktop package: invalid dotenv syntax in ${path}`)
|
||||
}
|
||||
for (const name of Object.keys(settings)) {
|
||||
if (!accepts(name)) {
|
||||
throw new Error(`desktop package: unsupported setting ${name} in ${path}; use the platform template`)
|
||||
}
|
||||
if (settings[name].includes('\0')) throw new Error(`desktop package: ${name} cannot contain a NUL character`)
|
||||
}
|
||||
for (const name of FILE_SETTINGS) {
|
||||
if (settings[name]?.trim()) settings[name] = resolve(dirname(path), settings[name].trim())
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
function requireReadableFile(environment, name) {
|
||||
try {
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: e8cbc43d38786c40
|
||||
zh: 52b3ba85d98011b9
|
||||
/dsh-cua-native-helper/build-and-service:
|
||||
en: afdfde0453130386
|
||||
zh: 909ca94305262e14
|
||||
en: 4098a39d9a7cb236
|
||||
zh: e6d3d2cb5ca3a18d
|
||||
/dsh-cua-native-helper/request-protocol:
|
||||
en: fc8043c425322cdd
|
||||
zh: cf1edb9a78fda301
|
||||
|
||||
@@ -39,11 +39,13 @@ Generations in `native/.build/prepared/<arch>` and `native/bin/darwin-<arch>` ar
|
||||
|
||||
The manual **Package CUA helper · macOS** [workflow](../../../../.github/workflows/cua-helper-package.yml) builds both architectures on `dsh-mac-package-trial`. Select a trusted `source_ref` and machine `preset`; credentials use the existing [macOS packaging configuration](../../../../apps/desktop/packaging/README.md#macos-actions). Packaging does not require a macOS desktop login or Rosetta. `pnpm exec tsx scripts/cua-helper/package.ts --out <new-directory>` runs the same preparation from the repository root. It signs, notarizes and staples each complete application before selecting its final content digest, then verifies architectures, signatures, tickets, Gatekeeper and file integrity from the extracted `cua-helper.tgz`. Packaging does not launch the helper. Run `python3 scripts/cua-helper/smoke.py <helper-executable>` separately in a logged-in desktop session to check capabilities, helper identity and cleanup; x64 execution on Apple Silicon requires Rosetta. The smoke does not request permissions or inspect applications. Failed or timed-out commands and unreadable startup replies report captured stdout and stderr; invalid encoding is replaced only in diagnostic text, while protocol parsing remains strict. A successful package build does not certify runtime behavior. Helper packaging retains its own [diagnostic record](../../../../apps/desktop/packaging/README.md#macos-release-signing), including failures before desktop installer startup.
|
||||
|
||||
The workflow retains `cua-helper.tgz` and `cua-helper.json`, which binds the product version, source commit, Team ID and both application digests. [Release publish](../../../../.github/workflows/release-publish.yml) consumes this artifact before packing the provider for npm; ordinary PR release rehearsals have no signing credentials. Staging rejects a source/version mismatch, incomplete architectures, stale generations, links, changed bytes and missing executable permissions. npm publication rechecks the exact provider tarball before any registry write. A re-signed release has different bytes; preserve the retained artifact when retrying a partially published version.
|
||||
The workflow retains `cua-helper.tgz` and `cua-helper.json`. The manifest records the original build's product version, commit and Team ID, both application digests, the source digest and the build key. [Release publish](../../../../.github/workflows/release-publish.yml) always calls this workflow, then downloads its selected run ID and artifact ID before packing the provider for npm. Staging verifies the current helper source digest and selected build key, both architectures, application generations, file bytes and executable permissions. npm publication rechecks the exact provider tarball before any registry write. Reuse preserves the original manifest and signed bytes across unrelated commits or product versions. Ordinary PR release rehearsals have no signing credentials.
|
||||
|
||||
In **Release publish (dsh)**, leave `cua_run_id` and `cua_run_attempt` empty to build a new helper. To reuse an existing artifact, set `cua_run_id` to a run of **Package CUA helper · macOS** or **Release publish (dsh)** in this repository. `cua_run_attempt` selects an exact attempt; empty selects the latest attempt once. The selected attempt must have completed with a successful CUA packaging job and an unexpired artifact; a later npm publication failure does not invalidate that job. Failed packaging, missing or expired artifacts fail without choosing an older attempt. The hosted pack job downloads the selected artifact by ID, then applies the same source commit, product version, architecture and file-integrity checks as a fresh build. This does not support reuse across commits or product versions and does not contact the Mac runner. Artifacts are retained for 14 days.
|
||||
Both **Release publish (dsh)** and **Package CUA helper · macOS** accept an optional `cua_run_id`: enter the numeric run ID after `/actions/runs/` in the producer's GitHub URL, not its job ID. Leave it empty to search completed runs of these two workflows from the last 14 days for an unexpired matching artifact. Selection runs on the Mac before pnpm setup or dependency installation. A match skips installation, compilation, signing and notarization; an automatic miss rebuilds. An explicit run selects its latest attempt once and fails on unsuccessful helper packaging, missing or expired artifacts, or mismatched inputs. It never chooses an older attempt or another run. A later npm failure does not invalidate a successful helper job. GitHub API failures stop selection. The summary links the actual producer; a run that only reuses an artifact does not upload another copy, so use its linked producer run for manual selection. Artifacts are retained for 14 days.
|
||||
|
||||
If npm publication fails after uploading some packages, rerun only the **Publish to npm** job while its `dsh-npm-tarballs` artifact is retained (7 days). It reuses the exact tarballs without rebuilding or signing; already published identical bytes are skipped. Do not rerun all jobs for that retry. If a new dispatch must repack the same commit and version, copy the original `cua_run_id` and `cua_run_attempt` from the pack job summary so the signed helper bytes stay unchanged. Matching commit and version alone do not make a newly signed helper byte-identical. If original artifacts are unavailable or any rebuilt package differs, stop: an existing npm version cannot be replaced with different content.
|
||||
The [source digest](../../../../scripts/cua-helper/build-inputs.ts) includes tracked file paths, raw bytes and executable bits under `native/`, `scripts/cua-helper/` and `apps/desktop/scripts/`, plus the helper workflow, lockfile, workspace configuration, root TypeScript configurations and package-manager version. Product version and unrelated package sources are excluded. The build key additionally includes the preset, Node/Swift/Clang/Python versions, macOS SDK build, signing identity, Team ID and certificate-container digest; passwords are excluded. Missing inputs or unreadable configuration fail. Artifacts without these records require a new build. Source comparison still requires a Mac runner and checkout; matching inputs establish reuse eligibility, while the consuming pack job validates the downloaded application bytes.
|
||||
|
||||
If npm publication fails after uploading some packages, rerun only **Publish to npm** while its `dsh-npm-tarballs` artifact is retained (7 days). It uploads the original tarballs and skips already published identical bytes. Do not rerun all jobs for this retry. For a new dispatch that must repack the same commit and version, use the original producer `cua_run_id` from the pack summary. If original artifacts are unavailable or any rebuilt package differs, stop: an existing npm version cannot be replaced with different content.
|
||||
|
||||
Startup errors retain their code and message through an owner-only, per-attempt failure file and the launcher’s JSON error frame. Launch-lock and readiness waits share one deadline; operating-system launch time consumes that budget. Readiness must name the newly launched PID. A failed new instance is force-terminated and awaited before the launcher reports failure; this cleanup has a separate bound equal to the startup timeout. Existing shared services are not terminated by a failed attaching launcher.
|
||||
|
||||
|
||||
@@ -39,11 +39,13 @@ kind: "package-reference"
|
||||
|
||||
手动工作流 **Package CUA helper · macOS** 的[配置](../../../../.github/workflows/cua-helper-package.yml)在 `dsh-mac-package-trial` 构建两种架构。选择可信的 `source_ref` 和机器 `preset`;凭据沿用[macOS 打包配置](../../../../apps/desktop/packaging/README.zh.md#macos-actions)。打包无需 macOS 桌面登录或 Rosetta。在仓库根目录运行 `pnpm exec tsx scripts/cua-helper/package.ts --out <new-directory>` 可执行相同准备。完整应用签名、公证和贴票后才确定最终内容摘要,再从解包后的 `cua-helper.tgz` 验证架构、签名、票据、Gatekeeper 和文件完整性。打包不启动 helper。在已登录的桌面会话中单独运行 `python3 scripts/cua-helper/smoke.py <helper-executable>` 检查能力、helper 身份和退出清理;Apple Silicon 上执行 x64 版本需要 Rosetta。该检查不申请权限或观察应用。命令失败、超时或启动回复无法解析时,会输出捕获的 stdout 和 stderr;无效编码只在诊断文本中替换,协议解析仍严格校验。打包成功不代表运行验收通过。 helper 打包保留独立的[诊断记录](../../../../apps/desktop/packaging/README.zh.md#macos-release-signing),包括桌面安装包开始构建前发生的失败。
|
||||
|
||||
工作流保留 `cua-helper.tgz` 和 `cua-helper.json`,后者记录产品版本、源码 commit、Team ID 和两份应用摘要。[Release publish](../../../../.github/workflows/release-publish.yml)在生成 provider 的 npm 包前接入该产物;普通 PR 发布检查不使用签名凭据。准备步骤拒绝源码或版本不匹配、架构不全、旧应用目录、链接、字节变化和执行权限缺失。npm 发布在写入注册表前重新检查实际 provider tarball。重新签名会改变产物字节,重试部分发布的版本时需保留原产物。
|
||||
工作流保留 `cua-helper.tgz` 和 `cua-helper.json`。清单记录原始构建的产品版本、commit、Team ID、两份应用摘要、源码摘要和构建摘要。[Release publish](../../../../.github/workflows/release-publish.yml)始终调用该工作流,再按返回的 run ID 和 artifact ID 下载所选产物,生成 provider 的 npm 包。准备步骤校验当前 helper 的源码摘要、选定的构建摘要、两种架构、应用目录、文件字节和执行权限。npm 发布在写入注册表前重新检查实际 provider tarball。无关 commit 或产品版本变化时,复用保留原始清单和已签名字节。普通 PR 发布检查不使用签名凭据。
|
||||
|
||||
在 **Release publish (dsh)** 中,`cua_run_id` 和 `cua_run_attempt` 都留空时重新构建 helper。复用已有产物时,将 `cua_run_id` 填为本仓库 **Package CUA helper · macOS** 或 **Release publish (dsh)** 的运行 ID。`cua_run_attempt` 指定准确的 attempt;留空时选定最新一次。所选 attempt 必须已结束,CUA 打包 job 成功,且产物未过期;后续 npm 发布失败不影响该打包 job 的结果。打包失败、产物缺失或过期时直接失败,不选择更早的 attempt。托管 runner 上的 pack job 按产物 ID 下载,再执行与新构建相同的源码 commit、产品版本、架构及文件完整性校验。此方式不支持跨 commit 或产品版本复用,也不访问 Mac runner。产物保留 14 天。
|
||||
**Release publish (dsh)** 和 **Package CUA helper · macOS** 都接受可选的 `cua_run_id`:填写构建运行页面 URL 中 `/actions/runs/` 后的数字,不填 job ID。留空时查询这两个工作流最近 14 天内已结束的运行,查找未过期的匹配产物。选择在 Mac 上执行,早于 pnpm 准备和依赖安装。命中时跳过安装、编译、签名和公证,自动查找未命中时重新构建。手动指定 run ID 时选定最新 attempt;helper 打包未成功、产物缺失或过期、输入不匹配时直接失败,不选择更早的 attempt 或其他运行。后续 npm 失败不影响成功的 helper job。GitHub API 读取失败会停止选择。运行摘要链接实际构建者;只复用产物的运行不重新上传副本,手动选择时填写摘要所链接的构建 run ID。产物保留 14 天。
|
||||
|
||||
npm 发布在上传部分包后失败时,只重跑 **Publish to npm** job,复用仍在保留期内的 `dsh-npm-tarballs`(7 天)。该 job 不构建或签名,直接复用原 tarball;已发布且字节相同的包会被跳过。此次重试不要重跑全部 job。若必须新建发布运行,以同一 commit 和版本重新打包,应从 pack job 的摘要复制原 `cua_run_id` 和 `cua_run_attempt`,保持已签名 helper 的字节不变。commit 和版本一致不代表重新签名后字节也相同。原产物不可用或任何重新打包的内容不同时,应停止发布;不能用不同内容替换已有 npm 版本。
|
||||
[源码摘要](../../../../scripts/cua-helper/build-inputs.ts)覆盖 `native/`、`scripts/cua-helper/`、`apps/desktop/scripts/` 下受 Git 跟踪文件的路径、原始字节和执行权限,以及 helper 工作流、锁文件、workspace 配置、根目录 TypeScript 配置和包管理器版本。产品版本及无关包的源码不参与比较。构建摘要还包含 preset、Node/Swift/Clang/Python 版本、macOS SDK build、签名身份、Team ID 和证书容器摘要,不包含密码。输入缺失或配置无法读取时失败。没有这些记录的旧产物需要重新构建。源码比较仍需等待 Mac runner 并检出代码;输入匹配确定可复用性,消费产物的 pack job 再校验下载的应用字节。
|
||||
|
||||
npm 发布在上传部分包后失败时,只重跑 **Publish to npm**,复用保留期内的 `dsh-npm-tarballs`(7 天)。该 job 上传原 tarball,跳过已发布且字节相同的包。此次重试不要重跑全部 job。若必须新建运行,以同一 commit 和版本重新打包,填写 pack 摘要中的原始构建 `cua_run_id`。原产物不可用或任何重新打包的内容不同时,应停止发布;不能用不同内容替换已有 npm 版本。
|
||||
|
||||
启动错误通过当前用户专用、每次启动独立的错误文件和 launcher 的 JSON 错误帧保留错误码与消息。等待启动锁和就绪状态共用一个期限,系统启动应用的耗时计入该预算。就绪记录必须指向本次启动的 PID。新实例启动失败后,launcher 强制终止并等待其退出,再报告失败;清理另有与启动超时等长的期限。连接已有服务失败时不会终止该服务。
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/** Byte-level source reuse survives unrelated releases and invalidates changed build inputs. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, symlinkSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
import { cuaHelperBuildKey, cuaHelperSourceDigest, readCuaSigningSettings } from './build-inputs.ts'
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cua-inputs-'))
|
||||
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
const git = (...args: string[]): void => { execFileSync('git', args, { cwd: root, stdio: 'pipe' }) }
|
||||
const files = {
|
||||
'package.json': JSON.stringify({ version: '1.0.0', packageManager: 'pnpm@11.28.5' }),
|
||||
'pnpm-lock.yaml': 'lockfileVersion: 9.0\n',
|
||||
'packages/experimental/cua-macos/native/Package.swift': '// swift package\n',
|
||||
'packages/experimental/cua-macos/native/Sources/Main.swift': 'print("one")\n',
|
||||
'packages/experimental/cua-macos/native/Info.plist': 'application identity',
|
||||
'scripts/cua-helper/package.ts': 'build helper',
|
||||
'apps/desktop/scripts/notarize-macos.mjs': 'notarize helper',
|
||||
'.github/workflows/cua-helper-package.yml': 'build configuration',
|
||||
'packages/client/unrelated/src/index.ts': 'unrelated feature',
|
||||
}
|
||||
for (const [path, bytes] of Object.entries(files)) {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true })
|
||||
writeFileSync(join(root, path), bytes)
|
||||
}
|
||||
git('init', '--quiet')
|
||||
git('add', '.')
|
||||
return { root, git, source: join(root, 'packages/experimental/cua-macos/native/Sources/Main.swift') }
|
||||
}
|
||||
|
||||
it('keeps identical source bytes across product versions and unrelated package edits', () => {
|
||||
const f = fixture()
|
||||
const before = cuaHelperSourceDigest(f.root)
|
||||
writeFileSync(join(f.root, 'package.json'), JSON.stringify({ version: '2.0.0', packageManager: 'pnpm@11.28.5' }))
|
||||
writeFileSync(join(f.root, 'packages/client/unrelated/src/index.ts'), 'another unrelated feature')
|
||||
utimesSync(f.source, new Date('2001-01-01'), new Date('2001-01-01'))
|
||||
expect(cuaHelperSourceDigest(f.root)).toBe(before)
|
||||
writeFileSync(f.source, readFileSync(f.source).toString().replace('one', 'two'))
|
||||
utimesSync(f.source, new Date('2001-01-01'), new Date('2001-01-01'))
|
||||
expect(cuaHelperSourceDigest(f.root)).not.toBe(before)
|
||||
})
|
||||
|
||||
it.each(['add', 'delete', 'rename', 'resource', 'dependency', 'script', 'workflow', 'package manager'])(
|
||||
'invalidates %s changes', (change) => {
|
||||
const f = fixture()
|
||||
const before = cuaHelperSourceDigest(f.root)
|
||||
if (change === 'add') writeFileSync(join(dirname(f.source), 'Added.swift'), 'new source')
|
||||
if (change === 'delete') rmSync(f.source)
|
||||
if (change === 'rename') renameSync(f.source, join(dirname(f.source), 'Renamed.swift'))
|
||||
if (change === 'resource') writeFileSync(join(f.root, 'packages/experimental/cua-macos/native/Info.plist'), 'changed identity')
|
||||
if (change === 'dependency') writeFileSync(join(f.root, 'pnpm-lock.yaml'), 'changed dependency')
|
||||
if (change === 'script') writeFileSync(join(f.root, 'apps/desktop/scripts/notarize-macos.mjs'), 'changed notarization')
|
||||
if (change === 'workflow') writeFileSync(join(f.root, '.github/workflows/cua-helper-package.yml'), 'changed build configuration')
|
||||
if (change === 'package manager') writeFileSync(join(f.root, 'package.json'), JSON.stringify({ packageManager: 'pnpm@12.0.0' }))
|
||||
f.git('add', '-A')
|
||||
expect(cuaHelperSourceDigest(f.root)).not.toBe(before)
|
||||
},
|
||||
)
|
||||
|
||||
it.skipIf(process.platform === 'win32')('includes executable mode and rejects source links', () => {
|
||||
const f = fixture()
|
||||
const before = cuaHelperSourceDigest(f.root)
|
||||
chmodSync(f.source, 0o755)
|
||||
expect(cuaHelperSourceDigest(f.root)).not.toBe(before)
|
||||
rmSync(f.source)
|
||||
symlinkSync(join(f.root, 'pnpm-lock.yaml'), f.source)
|
||||
expect(() => cuaHelperSourceDigest(f.root)).toThrow('regular file')
|
||||
})
|
||||
|
||||
it('fails on missing required tracked build inputs', () => {
|
||||
const f = fixture()
|
||||
f.git('rm', '--cached', 'scripts/cua-helper/package.ts')
|
||||
expect(() => cuaHelperSourceDigest(f.root)).toThrow('missing tracked build input')
|
||||
})
|
||||
|
||||
it.each(['preset', 'identity', 'team', 'certificate', 'toolchain', 'source'])(
|
||||
'invalidates changed %s while credentials stay outside the build key', (change) => {
|
||||
const f = fixture()
|
||||
const certificate = join(f.root, 'signing.p12')
|
||||
writeFileSync(certificate, 'fixture encrypted certificate')
|
||||
const environment = { CSC_LINK: certificate, DSH_PACKAGING_PRESET: 'production',
|
||||
DSH_DESKTOP_MACOS_SIGNING_IDENTITY: 'Fixture (ABCDEFGHIJ)', DSH_DESKTOP_MACOS_TEAM_ID: 'ABCDEFGHIJ',
|
||||
APPLE_APP_SPECIFIC_PASSWORD: 'private-password' }
|
||||
const source = cuaHelperSourceDigest(f.root)
|
||||
const run = (_command: string, args: string[]): string => args.join(' ')
|
||||
const before = cuaHelperBuildKey(source, environment, run)
|
||||
environment.APPLE_APP_SPECIFIC_PASSWORD = 'new-private-password'
|
||||
expect(cuaHelperBuildKey(source, environment, run)).toBe(before)
|
||||
if (change === 'preset') environment.DSH_PACKAGING_PRESET = 'test'
|
||||
if (change === 'identity') environment.DSH_DESKTOP_MACOS_SIGNING_IDENTITY = 'Other (ABCDEFGHIJ)'
|
||||
if (change === 'team') environment.DSH_DESKTOP_MACOS_TEAM_ID = '1234567890'
|
||||
if (change === 'certificate') writeFileSync(certificate, 'another certificate')
|
||||
const after = cuaHelperBuildKey(change === 'source' ? 'b'.repeat(64) : source, environment,
|
||||
change === 'toolchain' ? () => 'another toolchain' : run)
|
||||
expect(after).not.toBe(before)
|
||||
},
|
||||
)
|
||||
|
||||
it('resolves the configured signing file without installing desktop dependencies or exposing parser errors', () => {
|
||||
const f = fixture()
|
||||
const config = join(f.root, 'config')
|
||||
mkdirSync(join(config, 'secrets'), { recursive: true })
|
||||
writeFileSync(join(config, 'secrets/macos-signing.env'), 'CSC_LINK=./signing.p12\nDSH_DESKTOP_MACOS_TEAM_ID=ABCDEFGHIJ\n')
|
||||
const environment = { DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: 'production' }
|
||||
expect(readCuaSigningSettings(environment)).toMatchObject({ CSC_LINK: join(config, 'secrets/signing.p12'), DSH_PACKAGING_PRESET: 'production' })
|
||||
expect(() => readCuaSigningSettings({ ...environment, DSH_PACKAGING_PRESET: 'invalid' })).toThrow('preset')
|
||||
expect(() => readCuaSigningSettings({ ...environment, DSH_PACKAGING_CONFIG_DIR: 'relative' })).toThrow('CONFIG_DIR')
|
||||
})
|
||||
|
||||
it('loads the selector under plain Node with no node_modules directory', () => {
|
||||
const f = fixture()
|
||||
for (const file of ['scripts/cua-helper/build-inputs.ts', 'scripts/cua-helper/select-artifact.ts',
|
||||
'apps/desktop/scripts/desktop-file-settings.mjs', 'apps/desktop/scripts/desktop-release-environment.mjs']) {
|
||||
mkdirSync(dirname(join(f.root, file)), { recursive: true })
|
||||
copyFileSync(resolve(file), join(f.root, file))
|
||||
}
|
||||
const output = execFileSync(process.execPath, ['--input-type=module', '-e',
|
||||
"const { selectCuaHelperArtifact } = await import('./scripts/cua-helper/select-artifact.ts'); console.log(typeof selectCuaHelperArtifact)"],
|
||||
{ cwd: f.root, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' })
|
||||
expect(output.trim()).toBe('function')
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
/** Content and machine identity for reusing a signed helper before installing dependencies. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { lstatSync, readFileSync } from 'node:fs'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
import { readDesktopPackageSettings } from '../../apps/desktop/scripts/desktop-file-settings.mjs'
|
||||
import { resolveMacOSSigningEnvironment } from '../../apps/desktop/scripts/desktop-release-environment.mjs'
|
||||
|
||||
const sourcePaths = [
|
||||
'packages/experimental/cua-macos/native', 'scripts/cua-helper', 'apps/desktop/scripts',
|
||||
'.github/workflows/cua-helper-package.yml', 'pnpm-lock.yaml', 'pnpm-workspace.yaml',
|
||||
':(glob)tsconfig*.json',
|
||||
]
|
||||
|
||||
/**
|
||||
* Hash tracked build inputs by path, raw bytes and executable mode; product version and unrelated packages are excluded.
|
||||
* Missing tracked files and links fail; build outputs and untracked files never enter the digest.
|
||||
* @param root - Clean repository checkout containing all helper build inputs.
|
||||
* @returns SHA-256 of the helper sources, resources, packaging scripts and locked dependencies.
|
||||
*/
|
||||
export function cuaHelperSourceDigest(root: string): string {
|
||||
const files = execFileSync('git', ['ls-files', '-z', '--', ...sourcePaths], { cwd: root, encoding: 'utf8' })
|
||||
.split('\0').filter(Boolean).sort()
|
||||
for (const required of ['packages/experimental/cua-macos/native/Package.swift', 'scripts/cua-helper/package.ts', 'pnpm-lock.yaml']) {
|
||||
if (!files.includes(required)) throw new Error(`CUA source: missing tracked build input ${required}`)
|
||||
}
|
||||
const hash = createHash('sha256')
|
||||
for (const file of files) {
|
||||
const path = join(root, file)
|
||||
const stat = lstatSync(path)
|
||||
if (!stat.isFile()) throw new Error(`CUA source: build input must be a regular file: ${file}`)
|
||||
hash.update(`${file}\0`).update(createHash('sha256').update(readFileSync(path)).digest())
|
||||
.update(Buffer.from([(stat.mode & 0o111) === 0 ? 0 : 1]))
|
||||
}
|
||||
const manifest: unknown = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'))
|
||||
if (!manifest || typeof manifest !== 'object' || !('packageManager' in manifest) || typeof manifest.packageManager !== 'string') {
|
||||
throw new Error('CUA source: package.json must declare packageManager')
|
||||
}
|
||||
return hash.update(`packageManager\0${manifest.packageManager}`).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the signing preset's public identity and certificate location without installing desktop dependencies.
|
||||
* @param environment - Actions environment with the machine configuration directory and selected preset.
|
||||
* @returns File-owned signing settings and the selected preset; no values are logged.
|
||||
*/
|
||||
export function readCuaSigningSettings(environment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
||||
const root = environment.DSH_PACKAGING_CONFIG_DIR
|
||||
const preset = environment.DSH_PACKAGING_PRESET
|
||||
if (!root || !isAbsolute(root) || !['test', 'production'].includes(preset ?? '')) {
|
||||
throw new Error('CUA reuse: DSH_PACKAGING_CONFIG_DIR and a test or production preset are required')
|
||||
}
|
||||
const settings = readDesktopPackageSettings(join(root, 'secrets/macos-signing.env'), () => true)
|
||||
return { ...settings, DSH_PACKAGING_PRESET: preset }
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind source bytes to the active toolchain, preset and signing certificate container.
|
||||
* @param source - Current source digest.
|
||||
* @param environment - File-owned signing settings including CSC_LINK and the selected preset.
|
||||
* @param run - Synchronous toolchain query; injected tests never invoke signing or Apple's services.
|
||||
* @returns SHA-256 build key; credential values and certificate bytes are not recorded.
|
||||
*/
|
||||
export function cuaHelperBuildKey(source: string, environment: NodeJS.ProcessEnv,
|
||||
run: (command: string, args: string[]) => string = (command, args) =>
|
||||
execFileSync(command, args, { encoding: 'utf8', timeout: 60_000, stdio: 'pipe' }).trim()): string {
|
||||
const signing = resolveMacOSSigningEnvironment(environment)
|
||||
const certificate = environment.CSC_LINK
|
||||
if (!certificate || !isAbsolute(certificate)) throw new Error('CUA reuse: CSC_LINK must identify the signing certificate file')
|
||||
const configuration = {
|
||||
source, preset: environment.DSH_PACKAGING_PRESET ?? 'local', ...signing,
|
||||
certificate: createHash('sha256').update(readFileSync(certificate)).digest('hex'),
|
||||
node: process.version,
|
||||
swift: run('/usr/bin/xcrun', ['swift', '--version']),
|
||||
clang: run('/usr/bin/xcrun', ['clang', '--version']),
|
||||
sdk: run('/usr/bin/xcrun', ['--sdk', 'macosx', '--show-sdk-build-version']),
|
||||
python: run('/usr/bin/xcrun', ['python3', '--version']),
|
||||
}
|
||||
return createHash('sha256').update(JSON.stringify(configuration)).digest('hex')
|
||||
}
|
||||
@@ -48,6 +48,7 @@ vi.mock('tar', () => ({
|
||||
create: async ({ file }: { file: string }) => writeFile(file, 'fixture archive'),
|
||||
extract: async () => {},
|
||||
}))
|
||||
vi.mock('./build-inputs.ts', () => ({ cuaHelperSourceDigest: () => 'a'.repeat(64), cuaHelperBuildKey: () => 'b'.repeat(64) }))
|
||||
vi.mock('./payload.ts', async importOriginal => ({
|
||||
...await importOriginal<typeof import('./payload.ts')>(),
|
||||
readCuaApplication: (directory: string) => ({ application: join(directory, 'fixture/DSH Computer Use.app') }),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { notarizeMacOS } from '../../apps/desktop/scripts/notarize-macos.mjs'
|
||||
import { createPackagingRun } from '../../apps/desktop/scripts/packaging-run.mjs'
|
||||
import { packagingErrorDetails, packagingStep } from '../../apps/desktop/scripts/packaging-step.mjs'
|
||||
import { verifyMacOSRuntimeCode } from '../../apps/desktop/scripts/verify-macos-signature.mjs'
|
||||
import { cuaHelperBuildKey, cuaHelperSourceDigest } from './build-inputs.ts'
|
||||
import { CUA_APPLICATION, CUA_ARCHITECTURES, CUA_MANIFEST, readCuaApplication, sealCuaApplication, verifyCuaHelperPayload, type CuaHelperManifest } from './payload.ts'
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -23,6 +24,11 @@ async function main(): Promise<void> {
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const output = resolve(values.out)
|
||||
const environment = loadDesktopPackageEnvironment('darwin')
|
||||
const sourceDigest = cuaHelperSourceDigest(root)
|
||||
const buildKey = cuaHelperBuildKey(sourceDigest, environment)
|
||||
if (process.env.CUA_EXPECTED_BUILD_KEY !== undefined && process.env.CUA_EXPECTED_BUILD_KEY !== buildKey) {
|
||||
throw new Error('CUA build inputs changed after artifact selection; restart the helper workflow')
|
||||
}
|
||||
const signing = resolveMacOSSigningEnvironment(environment)
|
||||
const notary = resolveMacOSNotarizationEnvironment(environment)
|
||||
const settings = resolveMacOSPackageSettings(environment)
|
||||
@@ -68,15 +74,15 @@ async function main(): Promise<void> {
|
||||
applications.push({ architecture, bundle: sealCuaApplication(directory, application) })
|
||||
}
|
||||
})
|
||||
const manifest: CuaHelperManifest = { schemaVersion: 1, version, commit, teamId: signing.teamId, applications }
|
||||
const manifest: CuaHelperManifest = { schemaVersion: 2, sourceDigest, buildKey, version, commit, teamId: signing.teamId, applications }
|
||||
writeFileSync(join(payload, CUA_MANIFEST), `${JSON.stringify(manifest, null, 2)}\n`)
|
||||
verifyCuaHelperPayload(payload, { version, commit })
|
||||
verifyCuaHelperPayload(payload, { sourceDigest, buildKey })
|
||||
const archive = join(output, 'cua-helper.tgz')
|
||||
await create({ cwd: payload, file: archive, gzip: true, portable: true }, [CUA_MANIFEST, 'native'])
|
||||
const extracted = join(scratch, 'extracted')
|
||||
mkdirSync(extracted)
|
||||
await extract({ cwd: extracted, file: archive, strict: true })
|
||||
verifyCuaHelperPayload(extracted, { version, commit })
|
||||
verifyCuaHelperPayload(extracted, { sourceDigest, buildKey })
|
||||
for (const { architecture, bundle } of applications) {
|
||||
const application = join(extracted, 'native/bin', `darwin-${architecture}`, bundle, CUA_APPLICATION)
|
||||
await verify(application, architecture)
|
||||
|
||||
@@ -10,7 +10,7 @@ import { expect, it, onTestFinished } from 'vitest'
|
||||
import { CUA_APPLICATION, CUA_ARCHITECTURES, CUA_MANIFEST, cuaApplicationDigest, readCuaApplication, sealCuaApplication, stageCuaHelperPayload, verifyCuaHelperPayload } from './payload.ts'
|
||||
import { verifyCuaHelperTarball } from './verify-tarball.ts'
|
||||
|
||||
const identity = { version: '1.0.0', commit: 'a'.repeat(40) }
|
||||
const identity = { sourceDigest: 'a'.repeat(64), buildKey: 'b'.repeat(64) }
|
||||
function fixture() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cua-payload-test-'))
|
||||
onTestFinished(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
@@ -28,7 +28,7 @@ function fixture() {
|
||||
chmodSync(join(application, 'Contents/MacOS/dsh-cua-native'), 0o755)
|
||||
return { architecture, bundle: sealCuaApplication(directory, application) }
|
||||
})
|
||||
const manifest = { schemaVersion: 1, ...identity, teamId: 'ABCDEFGHIJ', applications }
|
||||
const manifest = { schemaVersion: 2, ...identity, version: '1.0.0', commit: 'a'.repeat(40), teamId: 'ABCDEFGHIJ', applications }
|
||||
writeFileSync(join(payload, CUA_MANIFEST), JSON.stringify(manifest))
|
||||
return { root, payload, manifest, directory: join(payload, 'native/bin/darwin-arm64') }
|
||||
}
|
||||
@@ -72,7 +72,7 @@ it.skipIf(process.platform === 'win32')('keeps npm tarball bytes when reusing a
|
||||
mkdirSync(output)
|
||||
stageCuaHelperPayload(f.payload, packageRoot, identity)
|
||||
writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({
|
||||
name: '@deepseek-ai/dsh-experimental-cua-macos', version: identity.version, type: 'module',
|
||||
name: '@deepseek-ai/dsh-experimental-cua-macos', version: f.manifest.version, type: 'module',
|
||||
files: ['lib/index.js', 'native/bin', CUA_MANIFEST],
|
||||
}))
|
||||
writeFileSync(join(packageRoot, 'lib/index.js'), 'export const provider = "fixture";\n')
|
||||
@@ -103,11 +103,11 @@ it.skipIf(process.platform === 'win32')('keeps npm tarball bytes when reusing a
|
||||
expect(rebuilt.equals(first)).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32').each(['version', 'commit', 'architecture', 'missing', 'extra', 'permissions', 'symlink'] as const)('rejects %s damage before staging', (damage) => {
|
||||
it.skipIf(process.platform === 'win32').each(['source', 'configuration', 'architecture', 'missing', 'extra', 'permissions', 'symlink'] as const)('rejects %s damage before staging', (damage) => {
|
||||
const f = fixture()
|
||||
const before = readCuaApplication(f.directory)
|
||||
if (damage === 'version') f.manifest.version = '2.0.0'
|
||||
if (damage === 'commit') f.manifest.commit = 'b'.repeat(40)
|
||||
if (damage === 'source') f.manifest.sourceDigest = 'c'.repeat(64)
|
||||
if (damage === 'configuration') f.manifest.buildKey = 'd'.repeat(64)
|
||||
if (damage === 'architecture') f.manifest.applications[1] = f.manifest.applications[0]!
|
||||
if (damage === 'missing') rmSync(join(before.application, 'Contents/Info.plist'))
|
||||
if (damage === 'extra') cpSync(before.application, join(f.directory, 'old-generation', CUA_APPLICATION), { recursive: true })
|
||||
@@ -128,3 +128,13 @@ it('keeps company signing outside pull-request release rehearsals', () => {
|
||||
expect(release).toContain('scripts/cua-helper/stage.ts')
|
||||
expect(readFileSync(resolve('.github/workflows/release.yml'), 'utf8')).not.toContain('cua-helper-package.yml')
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('retains the original build commit and version when consuming identical helper sources', () => {
|
||||
const f = fixture()
|
||||
f.manifest.version = '0.9.0'
|
||||
f.manifest.commit = 'c'.repeat(40)
|
||||
writeFileSync(join(f.payload, CUA_MANIFEST), JSON.stringify(f.manifest))
|
||||
const consumer = join(f.root, 'consumer')
|
||||
stageCuaHelperPayload(f.payload, consumer, identity)
|
||||
expect(JSON.parse(readFileSync(join(consumer, CUA_MANIFEST), 'utf8'))).toEqual(f.manifest)
|
||||
})
|
||||
|
||||
@@ -12,7 +12,8 @@ export const CUA_APPLICATION = 'DSH Computer Use.app'
|
||||
export const CUA_MANIFEST = 'cua-helper.json'
|
||||
const Digest = z.string().regex(/^[a-f0-9]{64}$/u)
|
||||
const Manifest = z.object({
|
||||
schemaVersion: z.literal(1), version: z.string().min(1), commit: z.string().regex(/^[a-f0-9]{40}$/u),
|
||||
schemaVersion: z.literal(2), sourceDigest: Digest, buildKey: Digest,
|
||||
version: z.string().min(1), commit: z.string().regex(/^[a-f0-9]{40}$/u),
|
||||
teamId: z.string().regex(/^[A-Z0-9]{10}$/u),
|
||||
applications: z.array(z.object({ architecture: z.enum(CUA_ARCHITECTURES), bundle: Digest }).strict()).length(2),
|
||||
}).strict()
|
||||
@@ -90,12 +91,13 @@ export function sealCuaApplication(directory: string, application: string): stri
|
||||
/**
|
||||
* Verify the producer record and every final application before copying or packing.
|
||||
* @param root - Extracted helper artifact containing native/bin and cua-helper.json.
|
||||
* @param expected - Source commit and product version of the consuming checkout.
|
||||
* @param expected - Source digest and, on the Mac-to-pack transfer, the selected build key.
|
||||
* @returns Validated producer metadata.
|
||||
*/
|
||||
export function verifyCuaHelperPayload(root: string, expected: { version: string; commit: string }): CuaHelperManifest {
|
||||
export function verifyCuaHelperPayload(root: string, expected: { sourceDigest: string; buildKey?: string }): CuaHelperManifest {
|
||||
const manifest = Manifest.parse(JSON.parse(readFileSync(join(root, CUA_MANIFEST), 'utf8')))
|
||||
if (manifest.version !== expected.version || manifest.commit !== expected.commit) throw new Error('CUA artifact does not match the release source and version')
|
||||
if (manifest.sourceDigest !== expected.sourceDigest) throw new Error('CUA artifact does not match the current helper source bytes')
|
||||
if (expected.buildKey !== undefined && manifest.buildKey !== expected.buildKey) throw new Error('CUA artifact does not match the selected build configuration')
|
||||
if (new Set(manifest.applications.map(entry => entry.architecture)).size !== 2) throw new Error('CUA artifact must contain arm64 and x64')
|
||||
const bin = join(root, 'native/bin')
|
||||
if (readdirSync(bin).sort().join(',') !== 'darwin-arm64,darwin-x64') throw new Error('CUA artifact has unexpected architecture directories')
|
||||
@@ -112,7 +114,7 @@ export function verifyCuaHelperPayload(root: string, expected: { version: string
|
||||
* @param packageRoot - Provider package to receive native/bin.
|
||||
* @param expected - Source identity required by the release.
|
||||
*/
|
||||
export function stageCuaHelperPayload(root: string, packageRoot: string, expected: { version: string; commit: string }): void {
|
||||
export function stageCuaHelperPayload(root: string, packageRoot: string, expected: { sourceDigest: string; buildKey?: string }): void {
|
||||
verifyCuaHelperPayload(root, expected)
|
||||
const destination = join(packageRoot, 'native/bin')
|
||||
if (existsSync(destination)) throw new Error('CUA staging requires an empty native/bin; use a clean release checkout')
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
/** Retained CUA selection stays within one producer, attempt and immutable artifact. */
|
||||
/** Discovery and explicit selection require matching inputs and successful producer evidence. */
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { selectCuaHelperArtifact } from './select-artifact.ts'
|
||||
|
||||
const repository = 'deepseek-harness/deepseek-harness'
|
||||
const base = `repos/${repository}/actions/runs/123`
|
||||
const key = 'a'.repeat(64)
|
||||
|
||||
function fixture() {
|
||||
const run = { id: 123, run_attempt: 2, status: 'completed', conclusion: 'success', event: 'workflow_dispatch',
|
||||
path: '.github/workflows/cua-helper-package.yml', repository: { full_name: repository } }
|
||||
path: '.github/workflows/cua-helper-package.yml', repository: { full_name: repository }, head_repository: { full_name: repository } }
|
||||
const jobs = [{ jobs: [{ name: 'package', conclusion: 'success' }] }]
|
||||
const artifact = { id: 456, name: 'cua-helper-123-2', expired: false, size_in_bytes: 4096 }
|
||||
const artifacts = [{ artifacts: [{ ...artifact, id: 455, name: 'cua-helper-123-1' }] }, { artifacts: [artifact] }]
|
||||
const artifact = { id: 456, name: `cua-helper-123-2-${key}`, expired: false, size_in_bytes: 4096 }
|
||||
const artifacts = [{ artifacts: [{ ...artifact, id: 455, name: `cua-helper-123-1-${key}` }] }, { artifacts: [artifact] }]
|
||||
const api = vi.fn((path: string, paginate?: boolean) => {
|
||||
if (path === base) { expect(paginate).toBeUndefined(); return { run_attempt: 2 } }
|
||||
if (path === `${base}/attempts/2`) { expect(paginate).toBeUndefined(); return run }
|
||||
expect(paginate).toBe(true)
|
||||
if (path.includes('/workflows/cua-helper-package.yml/runs?')) return [{ workflow_runs: [{ id: 123 }] }]
|
||||
if (path.includes('/workflows/release-publish.yml/runs?')) return [{ workflow_runs: [] }]
|
||||
if (path === `${base}/attempts/2/jobs?per_page=100`) return jobs
|
||||
if (path === `${base}/artifacts?per_page=100`) return artifacts
|
||||
throw new Error(`Unexpected GitHub request ${path}`)
|
||||
@@ -22,48 +25,51 @@ function fixture() {
|
||||
return { run, jobs, artifact, artifacts, api }
|
||||
}
|
||||
|
||||
it.each([undefined, '', '2'])('pins attempt %s and finds its artifact across pages', (attempt) => {
|
||||
it.each(['123', ''])('selects a matching artifact with run ID %j and pins the latest attempt', (runId) => {
|
||||
const f = fixture()
|
||||
expect(selectCuaHelperArtifact(repository, { cua_run_id: '123', cua_run_attempt: attempt }, f.api))
|
||||
expect(selectCuaHelperArtifact(repository, runId, key, f.api))
|
||||
.toEqual({ runId: '123', attempt: '2', artifactId: '456' })
|
||||
expect(f.api.mock.calls.some(([path]) => path === base)).toBe(attempt !== '2')
|
||||
expect(f.api.mock.calls.filter(([path]) => path === base)).toHaveLength(1)
|
||||
expect(f.api.mock.calls.some(([path]) => path.includes('/workflows/'))).toBe(runId === '')
|
||||
})
|
||||
|
||||
it('allows successful CUA packaging when later publication jobs failed', () => {
|
||||
it('allows successful helper packaging when later npm publication failed', () => {
|
||||
const f = fixture()
|
||||
f.run.path = '.github/workflows/release-publish.yml'
|
||||
f.run.conclusion = 'failure'
|
||||
f.jobs[0]!.jobs = [{ name: 'cua-helper / package', conclusion: 'success' }, { name: 'Publish to npm', conclusion: 'failure' }]
|
||||
expect(selectCuaHelperArtifact(repository, { cua_run_id: '123' }, f.api).artifactId).toBe('456')
|
||||
expect(selectCuaHelperArtifact(repository, '123', key, f.api)?.artifactId).toBe('456')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{}, { cua_run_attempt: '2' }, { cua_run_id: '' }, { cua_run_id: '123/other' }, { cua_run_id: ' 123' },
|
||||
{ cua_run_id: '0' }, { cua_run_id: '123', cua_run_attempt: '-1' }, { cua_run_id: '123', cua_run_attempt: '2\nartifact-id=9' },
|
||||
])('rejects invalid selection fields before reading GitHub: %j', (inputs) => {
|
||||
const f = fixture()
|
||||
expect(() => selectCuaHelperArtifact(repository, inputs, f.api)).toThrow()
|
||||
expect(f.api).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['123/other', ' 123', '0', '-1', '2\nartifact-id=9', 'https://github.com/owner/repo/actions/runs/123'])(
|
||||
'rejects invalid run ID %j before reading GitHub', (runId) => {
|
||||
const f = fixture()
|
||||
expect(() => selectCuaHelperArtifact(repository, runId, key, f.api)).toThrow('numeric run ID')
|
||||
expect(f.api).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['wrong/repo/extra', '../repo', 'owner/..', 'owner/.'])('rejects invalid repository %s before reading GitHub', (repo) => {
|
||||
const f = fixture()
|
||||
expect(() => selectCuaHelperArtifact(repo, { cua_run_id: '123' }, f.api)).toThrow('invalid repository')
|
||||
expect(() => selectCuaHelperArtifact(repo, '123', key, f.api)).toThrow('invalid repository')
|
||||
expect(f.api).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['workflow', 'event', 'repository', 'id', 'attempt', 'running', 'failed'] as const)('rejects %s run evidence', (change) => {
|
||||
const f = fixture()
|
||||
if (change === 'workflow') f.run.path = '.github/workflows/ci.yml'
|
||||
if (change === 'event') f.run.event = 'pull_request'
|
||||
if (change === 'repository') f.run.repository.full_name = 'outsider/fork'
|
||||
if (change === 'id') f.run.id = 124
|
||||
if (change === 'attempt') f.run.run_attempt = 1
|
||||
if (change === 'running') f.run.status = 'in_progress'
|
||||
if (change === 'failed') f.run.conclusion = 'failure'
|
||||
expect(() => selectCuaHelperArtifact(repository, { cua_run_id: '123' }, f.api)).toThrow()
|
||||
expect(f.api.mock.calls.some(([path]) => path.includes('/artifacts'))).toBe(false)
|
||||
})
|
||||
it.each(['workflow', 'event', 'repository', 'head_repository', 'id', 'attempt', 'running', 'failed'] as const)(
|
||||
'rejects explicit %s run evidence', (change) => {
|
||||
const f = fixture()
|
||||
if (change === 'workflow') f.run.path = '.github/workflows/ci.yml'
|
||||
if (change === 'event') f.run.event = 'pull_request'
|
||||
if (change === 'repository') f.run.repository.full_name = 'outsider/fork'
|
||||
if (change === 'head_repository') f.run.head_repository.full_name = 'outsider/fork'
|
||||
if (change === 'id') f.run.id = 124
|
||||
if (change === 'attempt') f.run.run_attempt = 1
|
||||
if (change === 'running') f.run.status = 'in_progress'
|
||||
if (change === 'failed') f.run.conclusion = 'failure'
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api)).toThrow()
|
||||
expect(f.api.mock.calls.some(([path]) => path.includes('/artifacts'))).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['failure', 'cancelled', 'skipped', 'missing', 'duplicate'])(
|
||||
'rejects %s helper jobs without falling back to attempt 1', (status) => {
|
||||
@@ -73,22 +79,49 @@ it.each(['failure', 'cancelled', 'skipped', 'missing', 'duplicate'])(
|
||||
f.jobs[0]!.jobs = status === 'missing' ? [] : status === 'duplicate'
|
||||
? [{ name: 'cua-helper / package', conclusion: 'success' }, { name: 'cua-helper / package', conclusion: 'success' }]
|
||||
: [{ name: 'cua-helper / package', conclusion: status }]
|
||||
expect(() => selectCuaHelperArtifact(repository, { cua_run_id: '123' }, f.api)).toThrow('successful cua-helper / package job')
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api)).toThrow('successful cua-helper / package job')
|
||||
expect(f.api.mock.calls.some(([path]) => path.includes('/attempts/1') || path.includes('/artifacts'))).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['missing', 'expired', 'empty', 'duplicate'] as const)('rejects %s artifacts instead of another attempt', (change) => {
|
||||
it.each(['missing', 'expired', 'empty', 'mismatch', 'legacy'] as const)(
|
||||
'rebuilds on automatic %s but rejects the same explicit selection', (change) => {
|
||||
const f = fixture()
|
||||
if (change === 'missing') f.artifacts.pop()
|
||||
if (change === 'expired') f.artifact.expired = true
|
||||
if (change === 'empty') f.artifact.size_in_bytes = 0
|
||||
if (change === 'mismatch') f.artifact.name = `cua-helper-123-2-${'b'.repeat(64)}`
|
||||
if (change === 'legacy') f.artifact.name = 'cua-helper-123-2'
|
||||
expect(selectCuaHelperArtifact(repository, '', key, f.api)).toBeUndefined()
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api)).toThrow('CUA reuse: run 123:')
|
||||
},
|
||||
)
|
||||
|
||||
it('selects a match across workflows and pages after a newer failed producer', () => {
|
||||
const f = fixture()
|
||||
if (change === 'missing') f.artifacts.pop()
|
||||
if (change === 'expired') f.artifact.expired = true
|
||||
if (change === 'empty') f.artifact.size_in_bytes = 0
|
||||
if (change === 'duplicate') f.artifacts.push({ artifacts: [{ ...f.artifact, id: 457 }] })
|
||||
expect(() => selectCuaHelperArtifact(repository, { cua_run_id: '123' }, f.api)).toThrow('select an available build or rebuild')
|
||||
const api = vi.fn((path: string, paginate?: boolean): unknown => {
|
||||
if (path.includes('/workflows/cua-helper-package.yml/runs?')) return [{ workflow_runs: [] }]
|
||||
if (path.includes('/workflows/release-publish.yml/runs?')) return [{ workflow_runs: [{ id: 124 }] }, { workflow_runs: [{ id: 123 }] }]
|
||||
if (path.endsWith('/runs/124')) return { run_attempt: 1 }
|
||||
if (path.endsWith('/runs/124/attempts/1')) return { ...f.run, id: 124, run_attempt: 1, conclusion: 'failure' }
|
||||
return f.api(path, paginate)
|
||||
})
|
||||
expect(selectCuaHelperArtifact(repository, '', key, api, new Date('2026-11-03T00:00:00Z'))?.runId).toBe('123')
|
||||
const paths = api.mock.calls.map(([path]) => path)
|
||||
expect(paths.find(path => path.includes('/workflows/'))).toContain(encodeURIComponent('>=2026-10-20T00:00:00.000Z'))
|
||||
expect(paths.indexOf(`${base.replace('123', '124')}/attempts/1`)).toBeLessThan(paths.indexOf(base))
|
||||
})
|
||||
|
||||
it('propagates unavailable GitHub evidence without returning a selection', () => {
|
||||
const api = vi.fn(() => { throw new Error('HTTP 404') })
|
||||
expect(() => selectCuaHelperArtifact(repository, { cua_run_id: '123' }, api)).toThrow('HTTP 404')
|
||||
it.each(['duplicate', 'invalid metadata'] as const)('stops automatic discovery on %s evidence', (change) => {
|
||||
const f = fixture()
|
||||
if (change === 'duplicate') f.artifacts.push({ artifacts: [{ ...f.artifact, id: 457 }] })
|
||||
const api = (path: string, paginate?: boolean): unknown => change === 'invalid metadata' && path.endsWith('/artifacts?per_page=100')
|
||||
? [{ artifacts: [{ ...f.artifact, expired: 'false' }] }] : f.api(path, paginate)
|
||||
expect(() => selectCuaHelperArtifact(repository, '', key, api)).toThrow()
|
||||
})
|
||||
|
||||
it.each(['', '123'])('propagates unavailable GitHub evidence with selection %j', (runId) => {
|
||||
const api = vi.fn(() => { throw new Error('HTTP 403') })
|
||||
expect(() => selectCuaHelperArtifact(repository, runId, key, api)).toThrow('HTTP 403')
|
||||
expect(api).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -1,74 +1,109 @@
|
||||
/** Select one retained, successful CUA packaging attempt without contacting the Mac runner. */
|
||||
/** Find a matching retained helper on the Mac runner before installing dependencies. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { cuaHelperBuildKey, cuaHelperSourceDigest, readCuaSigningSettings } from './build-inputs.ts'
|
||||
|
||||
const positiveId = z.string().regex(/^[1-9]\d*$/u)
|
||||
const inputsSchema = z.object({
|
||||
cua_run_id: positiveId,
|
||||
cua_run_attempt: z.union([positiveId, z.literal('')]).optional(),
|
||||
})
|
||||
const runSchema = z.object({
|
||||
id: z.number().int().positive(), run_attempt: z.number().int().positive(),
|
||||
status: z.literal('completed'), conclusion: z.string().nullable(), event: z.literal('workflow_dispatch'),
|
||||
path: z.enum(['.github/workflows/cua-helper-package.yml', '.github/workflows/release-publish.yml']),
|
||||
repository: z.object({ full_name: z.string() }),
|
||||
})
|
||||
const jobsSchema = z.array(z.object({ jobs: z.array(z.object({ name: z.string(), conclusion: z.string().nullable() })) }))
|
||||
const artifactsSchema = z.array(z.object({ artifacts: z.array(z.object({
|
||||
id: z.number().int().positive(), name: z.string(), expired: z.boolean(), size_in_bytes: z.number().int().nonnegative(),
|
||||
})) }))
|
||||
const workflows = ['cua-helper-package.yml', 'release-publish.yml'] as const
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
assert(value !== null && typeof value === 'object' && !Array.isArray(value), 'CUA reuse: invalid GitHub object')
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
|
||||
function id(value: unknown): string {
|
||||
assert(typeof value === 'number' && Number.isSafeInteger(value) && value > 0, 'CUA reuse: invalid GitHub identifier')
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function rows(value: unknown, key: string): Record<string, unknown>[] {
|
||||
assert(Array.isArray(value), 'CUA reuse: expected paginated GitHub response')
|
||||
return value.flatMap((page: unknown) => {
|
||||
const entries = record(page)[key]
|
||||
assert(Array.isArray(entries), `CUA reuse: invalid GitHub ${key}`)
|
||||
return entries.map(record)
|
||||
})
|
||||
}
|
||||
|
||||
/** Exact producer attempt and immutable artifact selected by the Mac workflow. */
|
||||
export interface CuaHelperSelection {
|
||||
readonly runId: string
|
||||
readonly attempt: string
|
||||
readonly artifactId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin an explicit attempt or the latest attempt once, then select its immutable artifact ID.
|
||||
* A successful helper job in a completed publication run is reusable even if later npm jobs failed.
|
||||
* Failure returns no selection; there is no fallback to another attempt. Payload validation remains with stage.ts.
|
||||
* @param repository - Repository containing the producer and consuming release.
|
||||
* @param inputsValue - Untrusted dispatch fields cua_run_id and optional cua_run_attempt.
|
||||
* @param api - Read-only GitHub JSON reader; paginated responses must be arrays of pages.
|
||||
* @returns Exact run, attempt and artifact to download before source/version and application validation.
|
||||
* Select a matching helper from the latest attempt of an explicit run, or recent completed producer runs.
|
||||
* Automatic misses rebuild; explicit misses and unreadable GitHub evidence fail without selecting another run.
|
||||
* @param repository - Same repository that owns the producer and consumer.
|
||||
* @param runId - Optional decimal run ID; empty selects automatic discovery.
|
||||
* @param buildKey - Source and machine configuration digest required by the consumer.
|
||||
* @param api - Read-only GitHub JSON reader; paginated responses are arrays of pages.
|
||||
* @param now - Discovery time; automatic search covers the artifact's 14-day retention window.
|
||||
* @returns Matching producer artifact, or undefined when automatic discovery has no match.
|
||||
*/
|
||||
export function selectCuaHelperArtifact(repository: string, inputsValue: unknown,
|
||||
api: (path: string, paginate?: boolean) => unknown): { runId: string; attempt: string; artifactId: string } {
|
||||
if (!/^[\w.-]+\/[\w.-]+$/u.test(repository) || repository.split('/').some(part => part === '.' || part === '..')) {
|
||||
throw new Error('CUA reuse: invalid repository')
|
||||
export function selectCuaHelperArtifact(repository: string, runId: string, buildKey: string,
|
||||
api: (path: string, paginate?: boolean) => unknown, now = new Date()): CuaHelperSelection | undefined {
|
||||
assert(/^[\w.-]+\/[\w.-]+$/u.test(repository) && !repository.split('/').some(part => part === '.' || part === '..'),
|
||||
'CUA reuse: invalid repository')
|
||||
assert(runId === '' || /^[1-9]\d*$/u.test(runId), 'CUA reuse: enter the numeric run ID after /actions/runs/ in the run URL')
|
||||
assert(/^[a-f0-9]{64}$/u.test(buildKey), 'CUA reuse: invalid build key')
|
||||
const base = `repos/${repository}/actions`
|
||||
const selectRun = (candidate: string): CuaHelperSelection | string => {
|
||||
const attempt = id(record(api(`${base}/runs/${candidate}`)).run_attempt)
|
||||
const run = record(api(`${base}/runs/${candidate}/attempts/${attempt}`))
|
||||
assert(id(run.id) === candidate && id(run.run_attempt) === attempt
|
||||
&& record(run.repository).full_name === repository && record(run.head_repository).full_name === repository,
|
||||
'CUA reuse: selected run, attempt or repository does not match GitHub evidence')
|
||||
const workflow = workflows.find(name => run.path === `.github/workflows/${name}`)
|
||||
assert(workflow && run.event === 'workflow_dispatch', 'CUA reuse: select a supported repository packaging or release run')
|
||||
if (run.status !== 'completed') return 'the latest attempt has not completed'
|
||||
if (workflow === 'cua-helper-package.yml' && run.conclusion !== 'success') return 'the latest packaging attempt did not succeed'
|
||||
const jobName = workflow === 'cua-helper-package.yml' ? 'package' : 'cua-helper / package'
|
||||
const jobs = rows(api(`${base}/runs/${candidate}/attempts/${attempt}/jobs?per_page=100`, true), 'jobs')
|
||||
.filter(job => job.name === jobName)
|
||||
if (jobs.length !== 1 || jobs[0]?.conclusion !== 'success') return `the latest attempt has no successful ${jobName} job`
|
||||
const name = `cua-helper-${candidate}-${attempt}-${buildKey}`
|
||||
const artifacts = rows(api(`${base}/runs/${candidate}/artifacts?per_page=100`, true), 'artifacts')
|
||||
.filter(artifact => artifact.name === name)
|
||||
if (artifacts.length === 0) return 'no artifact matches the current source bytes and build configuration; use the producer run linked in its summary or rebuild'
|
||||
const [artifact] = artifacts
|
||||
assert(artifacts.length === 1 && artifact !== undefined, 'CUA reuse: matching artifact is ambiguous')
|
||||
assert(typeof artifact.expired === 'boolean' && typeof artifact.size_in_bytes === 'number', 'CUA reuse: invalid artifact metadata')
|
||||
if (artifact.expired || artifact.size_in_bytes <= 0) return 'the matching artifact is expired or empty'
|
||||
return { runId: candidate, attempt, artifactId: id(artifact.id) }
|
||||
}
|
||||
const inputs = inputsSchema.parse(inputsValue)
|
||||
const base = `repos/${repository}/actions/runs/${inputs.cua_run_id}`
|
||||
const attempt = inputs.cua_run_attempt
|
||||
|| String(z.object({ run_attempt: z.number().int().positive() }).parse(api(base)).run_attempt)
|
||||
const run = runSchema.parse(api(`${base}/attempts/${attempt}`))
|
||||
if (String(run.id) !== inputs.cua_run_id || String(run.run_attempt) !== attempt || run.repository.full_name !== repository) {
|
||||
throw new Error('CUA reuse: selected run, attempt or repository does not match GitHub evidence')
|
||||
if (runId !== '') {
|
||||
const selected = selectRun(runId)
|
||||
if (typeof selected === 'string') throw new Error(`CUA reuse: run ${runId}: ${selected}`)
|
||||
return selected
|
||||
}
|
||||
const jobName = run.path === '.github/workflows/cua-helper-package.yml' ? 'package' : 'cua-helper / package'
|
||||
if (jobName === 'package' && run.conclusion !== 'success') {
|
||||
throw new Error('CUA reuse: standalone packaging did not succeed; select a successful attempt')
|
||||
const since = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const candidates = workflows.flatMap(workflow => rows(api(
|
||||
`${base}/workflows/${workflow}/runs?event=workflow_dispatch&status=completed&created=${encodeURIComponent(`>=${since}`)}&per_page=100`,
|
||||
true), 'workflow_runs'))
|
||||
.sort((left, right) => Number(id(right.id)) - Number(id(left.id)))
|
||||
for (const candidate of candidates) {
|
||||
const selected = selectRun(id(candidate.id))
|
||||
if (typeof selected !== 'string') return selected
|
||||
}
|
||||
const jobs = jobsSchema.parse(api(`${base}/attempts/${attempt}/jobs?per_page=100`, true))
|
||||
.flatMap(page => page.jobs).filter(job => job.name === jobName)
|
||||
if (jobs.length !== 1 || jobs[0]?.conclusion !== 'success') {
|
||||
throw new Error(`CUA reuse: run ${inputs.cua_run_id}, attempt ${attempt} requires one successful ${jobName} job;`
|
||||
+ ' select a successful attempt')
|
||||
}
|
||||
const name = `cua-helper-${inputs.cua_run_id}-${attempt}`
|
||||
const artifacts = artifactsSchema.parse(api(`${base}/artifacts?per_page=100`, true))
|
||||
.flatMap(page => page.artifacts).filter(artifact => artifact.name === name)
|
||||
const [artifact] = artifacts
|
||||
if (artifacts.length !== 1 || artifact === undefined || artifact.expired || artifact.size_in_bytes === 0) {
|
||||
throw new Error(`CUA reuse: ${name} is missing, expired, empty or ambiguous; select an available build or rebuild`)
|
||||
}
|
||||
return { runId: inputs.cua_run_id, attempt, artifactId: String(artifact.id) }
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (process.argv[1] !== undefined && resolve(process.argv[1]) === import.meta.filename) {
|
||||
const env = process.env
|
||||
if (!env.GITHUB_EVENT_PATH || !env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT) throw new Error('CUA reuse: Actions context required')
|
||||
const event = z.object({ inputs: inputsSchema }).parse(JSON.parse(readFileSync(env.GITHUB_EVENT_PATH, 'utf8')))
|
||||
const selection = selectCuaHelperArtifact(env.GITHUB_REPOSITORY, event.inputs, (path, paginate) =>
|
||||
if (!env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT || !env.GITHUB_STEP_SUMMARY) throw new Error('CUA reuse: Actions context required')
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const sourceDigest = cuaHelperSourceDigest(root)
|
||||
const buildKey = cuaHelperBuildKey(sourceDigest, readCuaSigningSettings(env))
|
||||
const selection = selectCuaHelperArtifact(env.GITHUB_REPOSITORY, env.CUA_RUN_ID ?? '', buildKey, (path, paginate) =>
|
||||
JSON.parse(execFileSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path],
|
||||
{ encoding: 'utf8', timeout: 60_000, maxBuffer: 8 * 1024 * 1024 })))
|
||||
appendFileSync(env.GITHUB_OUTPUT, `artifact-id=${selection.artifactId}\nattempt=${selection.attempt}\n`)
|
||||
console.log(`CUA helper: run ${selection.runId}, attempt ${selection.attempt}, artifact ${selection.artifactId}`)
|
||||
appendFileSync(env.GITHUB_OUTPUT, `source-digest=${sourceDigest}\nbuild-key=${buildKey}\nartifact-id=${selection?.artifactId ?? ''}\nrun-id=${selection?.runId ?? ''}\nattempt=${selection?.attempt ?? ''}\n`)
|
||||
const summary = selection
|
||||
? `复用 [CUA helper 构建](https://github.com/${env.GITHUB_REPOSITORY}/actions/runs/${selection.runId}/attempts/${selection.attempt}),run ID \`${selection.runId}\`,artifact ID \`${selection.artifactId}\`。跳过依赖安装、编译、签名和公证。`
|
||||
: '未找到源码逐字节一致且构建配置匹配的可用 CUA helper 产物,执行依赖安装和重新构建。'
|
||||
appendFileSync(env.GITHUB_STEP_SUMMARY, `${summary}\n\n源码摘要:\`${sourceDigest}\`。\n`)
|
||||
console.log(summary)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
/** Stage the signed helper artifact selected by the credential-free npm pack job. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { cuaHelperSourceDigest } from './build-inputs.ts'
|
||||
import { stageCuaHelperPayload } from './payload.ts'
|
||||
|
||||
const { values } = parseArgs({ options: { from: { type: 'string' } }, allowPositionals: false })
|
||||
if (!values.from) throw new Error('Usage: stage.ts --from <extracted-helper-artifact>')
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const { version } = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { version: string }
|
||||
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim()
|
||||
stageCuaHelperPayload(resolve(values.from), resolve(root, 'packages/experimental/cua-macos'), { version, commit })
|
||||
console.log(`CUA helper staged for ${version} (${commit})`)
|
||||
const expected = { sourceDigest: cuaHelperSourceDigest(root),
|
||||
...(process.env.CUA_EXPECTED_BUILD_KEY === undefined ? {} : { buildKey: process.env.CUA_EXPECTED_BUILD_KEY }) }
|
||||
stageCuaHelperPayload(resolve(values.from), resolve(root, 'packages/experimental/cua-macos'), expected)
|
||||
console.log(`CUA helper staged for source digest ${expected.sourceDigest}`)
|
||||
|
||||
@@ -11,7 +11,7 @@ import { verifyCuaHelperPayload } from './payload.ts'
|
||||
* @param expected - Product version and source commit of the publishing checkout.
|
||||
* @returns Resolves only when the archive has complete, digest-verified application generations.
|
||||
*/
|
||||
export async function verifyCuaHelperTarball(tarball: string, expected: { version: string; commit: string }): Promise<void> {
|
||||
export async function verifyCuaHelperTarball(tarball: string, expected: { sourceDigest: string }): Promise<void> {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cua-publish-verify-'))
|
||||
try {
|
||||
await extract({ file: tarball, cwd: root, strict: true, filter: (path, entry) => {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
* the same artifact safe.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { cuaHelperSourceDigest } from '../cua-helper/build-inputs.ts'
|
||||
import { verifyCuaHelperTarball } from '../cua-helper/verify-tarball.ts'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -188,8 +188,7 @@ export async function publishRelease(familyId: string, directory: string, distTa
|
||||
})
|
||||
for (const member of order) {
|
||||
if (member.name !== '@deepseek-ai/dsh-experimental-cua-macos') continue
|
||||
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()
|
||||
await verifyCuaHelperTarball(member.tarball, { version: member.version, commit })
|
||||
await verifyCuaHelperTarball(member.tarball, { sourceDigest: cuaHelperSourceDigest(process.cwd()) })
|
||||
}
|
||||
if (distTag !== undefined) {
|
||||
for (const member of order) verifyDistTag(member, distTag)
|
||||
|
||||
@@ -11,6 +11,7 @@ const selfhosted = ['self-hosted', 'linux', 'x64', 'vm-backup']
|
||||
const hosted = 'ubuntu-24.04'
|
||||
|
||||
interface Step {
|
||||
env?: Record<string, string>
|
||||
name?: string
|
||||
uses?: string
|
||||
run?: string
|
||||
@@ -30,6 +31,7 @@ interface Workflow {
|
||||
with?: Record<string, unknown>
|
||||
needs?: string
|
||||
permissions?: Record<string, string>
|
||||
outputs?: Record<string, string>
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -42,7 +44,7 @@ function workflow(file: string): Workflow {
|
||||
// Missing context properties use the Actions empty-string value.
|
||||
function evaluate(expression: string, context: Record<string, string | boolean>): unknown {
|
||||
const source = expression.trim().replace(/^\$\{\{|\}\}$/g, '')
|
||||
.replace(/\b(?:github|vars|runner|inputs|needs)(?:\.[a-zA-Z_][a-zA-Z_0-9-]*)+/g,
|
||||
.replace(/\b(?:github|vars|runner|inputs|needs|steps)(?:\.[a-zA-Z_][a-zA-Z_0-9-]*)+/g,
|
||||
key => JSON.stringify(context[key] ?? ''))
|
||||
return runInNewContext(source, {
|
||||
fromJSON: JSON.parse, startsWith: (value: string, prefix: string) => value.startsWith(prefix),
|
||||
@@ -180,75 +182,70 @@ it('keeps vendor publication manual and entirely hosted', () => {
|
||||
expect(Object.keys(publish.on)).toEqual(['workflow_dispatch'])
|
||||
for (const job of Object.values(publish.jobs)) expect(job['runs-on']).toBe(hosted)
|
||||
})
|
||||
it('keeps DSH publication manual with hosted npm jobs and an explicit signed-helper producer', () => {
|
||||
it('keeps DSH publication manual and delegates all helper selections to the Mac workflow', () => {
|
||||
const publish = workflow('release-publish.yml')
|
||||
expect(Object.keys(publish.on)).toEqual(['workflow_dispatch'])
|
||||
const helper = publish.jobs['cua-helper']!
|
||||
expect(helper).toEqual({
|
||||
if: "startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check",
|
||||
uses: './.github/workflows/cua-helper-package.yml',
|
||||
with: { source_ref: '${{ github.ref }}', preset: 'production', cua_run_id: '${{ inputs.cua_run_id }}' },
|
||||
})
|
||||
for (const [name, job] of Object.entries(publish.jobs)) {
|
||||
if (name === 'cua-helper') {
|
||||
expect(job).toEqual({
|
||||
if: "(startsWith(github.ref, 'refs/tags/dsh-v') || inputs.skip-tag-check) && inputs.cua_run_id == '' && inputs.cua_run_attempt == ''",
|
||||
uses: './.github/workflows/cua-helper-package.yml',
|
||||
with: { source_ref: '${{ github.ref }}', preset: 'production' },
|
||||
})
|
||||
} else expect(job['runs-on']).toBe(hosted)
|
||||
if (name !== 'cua-helper') expect(job['runs-on']).toBe(hosted)
|
||||
}
|
||||
expect(JSON.stringify(publish)).not.toContain('cua_run_attempt')
|
||||
expect(publish.jobs['pack']!.needs).toBe('cua-helper')
|
||||
expect(publish.jobs['pack']!.if).toBeUndefined()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['refs/tags/dsh-v0.2.1-rc.4', false, '', '', true],
|
||||
['refs/heads/master', false, '', '', false],
|
||||
['refs/heads/master', true, '', '', true],
|
||||
['refs/tags/dsh-v0.2.1-rc.4', false, '123', '', false],
|
||||
['refs/heads/master', true, '123', '2', false],
|
||||
['refs/tags/dsh-v0.2.1-rc.4', false, '', '2', false],
|
||||
] as const)('selects the helper producer for ref %s, skip=%s, run=%s, attempt=%s', (ref, skip, runId, attempt, expected) => {
|
||||
['refs/tags/dsh-v0.2.1-rc.4', false, true],
|
||||
['refs/heads/master', false, false],
|
||||
['refs/heads/master', true, true],
|
||||
] as const)('gates helper selection for ref %s, skip=%s', (ref, skip, expected) => {
|
||||
const expression = workflow('release-publish.yml').jobs['cua-helper']!.if!
|
||||
expect(evaluate(expression, { 'github.ref': ref, 'inputs.skip-tag-check': skip,
|
||||
'inputs.cua_run_id': runId, 'inputs.cua_run_attempt': attempt })).toBe(expected)
|
||||
expect(evaluate(expression, { 'github.ref': ref, 'inputs.skip-tag-check': skip })).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['success', '', '', false, true],
|
||||
['failure', '', '', false, false],
|
||||
['failure', '123', '', false, false],
|
||||
['cancelled', '', '', false, false],
|
||||
['skipped', '', '', false, false],
|
||||
['skipped', '123', '', false, true],
|
||||
['skipped', '123', '2', false, true],
|
||||
['skipped', '', '2', false, true],
|
||||
['skipped', '123', '2', true, false],
|
||||
] as const)('gates npm packing with helper=%s, run=%s, attempt=%s, cancelled=%s', (result, runId, attempt, cancelled, expected) => {
|
||||
const pack = workflow('release-publish.yml').jobs['pack']!
|
||||
expect(pack.needs).toBe('cua-helper')
|
||||
expect(evaluate(pack.if!, { 'needs.cua-helper.result': result,
|
||||
'inputs.cua_run_id': runId, 'inputs.cua_run_attempt': attempt, cancelled })).toBe(expected)
|
||||
it('skips installation and signing on a helper match and exports the original producer identifiers', () => {
|
||||
const helper = workflow('cua-helper-package.yml')
|
||||
expect(helper.permissions).toEqual({ contents: 'read', actions: 'read' })
|
||||
const job = helper.jobs['package']!
|
||||
const select = job.steps.find(step => step.name === 'Find reusable CUA helper')!
|
||||
expect(select.run).toBe('node scripts/cua-helper/select-artifact.ts')
|
||||
const expensive = job.steps.filter(step => step.uses === 'pnpm/action-setup@v5.0.0'
|
||||
|| step.name === 'Install locked dependencies' || step.name === 'Build and verify notarized applications'
|
||||
|| step.uses === 'actions/upload-artifact@v7.0.1')
|
||||
expect(expensive).toHaveLength(4)
|
||||
for (const step of expensive) {
|
||||
expect(job.steps.indexOf(select)).toBeLessThan(job.steps.indexOf(step))
|
||||
expect(evaluate(step.if!, { 'steps.retained.outputs.artifact-id': '456' })).toBe(false)
|
||||
expect(evaluate(step.if!, { 'steps.retained.outputs.artifact-id': '' })).toBe(true)
|
||||
}
|
||||
expect(job.outputs).toMatchObject({
|
||||
'artifact-id': '${{ steps.retained.outputs.artifact-id || steps.upload.outputs.artifact-id }}',
|
||||
'run-id': '${{ steps.retained.outputs.run-id || github.run_id }}',
|
||||
'build-key': '${{ steps.retained.outputs.build-key }}',
|
||||
})
|
||||
})
|
||||
|
||||
it('validates retained attempts before downloading exactly one artifact with read-only Actions access', () => {
|
||||
it('downloads the selected producer artifact and verifies its build key before packing', () => {
|
||||
const publish = workflow('release-publish.yml')
|
||||
expect(publish.permissions).toEqual({ contents: 'read' })
|
||||
expect(publish.permissions).toEqual({ contents: 'read', actions: 'read' })
|
||||
const pack = publish.jobs['pack']!
|
||||
expect(pack.permissions).toEqual({ contents: 'read', actions: 'read' })
|
||||
const select = pack.steps.find(step => step.name === 'Select retained CUA helper')!
|
||||
const retained = pack.steps.find(step => step.name === 'Download retained CUA helper')!
|
||||
const fresh = pack.steps.find(step => step.name === 'Download freshly built CUA helper')!
|
||||
expect(select.run).toBe('pnpm exec tsx scripts/cua-helper/select-artifact.ts')
|
||||
expect(pack.steps.indexOf(select)).toBeLessThan(pack.steps.indexOf(retained))
|
||||
expect(retained).toMatchObject({ uses: 'actions/download-artifact@v8.0.1', with: {
|
||||
'artifact-ids': '${{ steps.retained-cua.outputs.artifact-id }}', 'run-id': '${{ inputs.cua_run_id }}',
|
||||
const download = pack.steps.find(step => step.name === 'Download selected CUA helper')!
|
||||
expect(download).toMatchObject({ uses: 'actions/download-artifact@v8.0.1', with: {
|
||||
'artifact-ids': '${{ needs.cua-helper.outputs.artifact-id }}', 'run-id': '${{ needs.cua-helper.outputs.run-id }}',
|
||||
'github-token': '${{ github.token }}', path: 'dist/cua-helper',
|
||||
} })
|
||||
expect(retained.with?.name).toBeUndefined()
|
||||
expect(fresh.with).toEqual({ name: '${{ needs.cua-helper.outputs.artifact }}', path: 'dist/cua-helper' })
|
||||
for (const [runId, attempt, reuse] of [['', '', false], ['123', '', true], ['123', '2', true], ['', '2', true]] as const) {
|
||||
const context = { 'inputs.cua_run_id': runId, 'inputs.cua_run_attempt': attempt }
|
||||
expect(evaluate(select.if!, context)).toBe(reuse)
|
||||
expect(evaluate(retained.if!, context)).toBe(reuse)
|
||||
expect(evaluate(fresh.if!, context)).toBe(!reuse)
|
||||
}
|
||||
expect(download.if).toBeUndefined()
|
||||
expect(download.with?.name).toBeUndefined()
|
||||
const stage = pack.steps.find(step => step.name === 'Stage verified native helper applications')!
|
||||
expect(stage.if).toBeUndefined()
|
||||
expect(stage.env).toEqual({ CUA_EXPECTED_BUILD_KEY: '${{ needs.cua-helper.outputs.build-key }}' })
|
||||
expect(stage.run).toContain('scripts/cua-helper/stage.ts --from dist/cua-helper/extracted')
|
||||
expect(pack.steps.indexOf(stage)).toBeGreaterThan(pack.steps.indexOf(retained))
|
||||
expect(pack.steps.indexOf(stage)).toBeGreaterThan(pack.steps.indexOf(fresh))
|
||||
expect(pack.steps.indexOf(stage)).toBeGreaterThan(pack.steps.indexOf(download))
|
||||
expect(pack.steps.indexOf(stage)).toBeLessThan(pack.steps.findIndex(step => step.name === 'Pack release tarballs'))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user