mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
Merge pull request #7797 from deepseek-harness/feat/cua-publish-source-reuse
feat(release): 自动复用源码一致的 CUA helper 并简化发布输入
This commit is contained in:
@@ -3,11 +3,6 @@ name: Package CUA helper · macOS
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_ref:
|
||||
description: '同仓库可信源码分支或 dsh-v* tag'
|
||||
type: string
|
||||
default: master
|
||||
required: true
|
||||
preset:
|
||||
description: '机器上的签名配置'
|
||||
type: choice
|
||||
@@ -16,19 +11,29 @@ on:
|
||||
required: true
|
||||
workflow_call:
|
||||
inputs:
|
||||
source_ref:
|
||||
reuse:
|
||||
type: boolean
|
||||
default: false
|
||||
cua_run_id:
|
||||
type: string
|
||||
required: true
|
||||
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,44 +45,65 @@ 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.build-inputs.outputs.build-key }}
|
||||
env:
|
||||
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
|
||||
DSH_TELEMETRY_DISABLED: '1'
|
||||
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.source_ref }}
|
||||
persist-credentials: false
|
||||
clean: true
|
||||
- name: Validate source selection
|
||||
- name: Validate run ref and preset
|
||||
run: |
|
||||
node -e 'const cp=require("node:child_process"); 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");
|
||||
node -e 'const fs=require("node:fs"); const ref=process.env.GITHUB_REF;
|
||||
if(!ref || (!ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/"))) 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: Record CUA helper build inputs
|
||||
id: build-inputs
|
||||
run: node scripts/cua-helper/build-inputs.ts
|
||||
- name: Find reusable CUA helper
|
||||
if: inputs.reuse
|
||||
id: retained
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CUA_RUN_ID: ${{ inputs.cua_run_id }}
|
||||
CUA_BUILD_KEY: ${{ steps.build-inputs.outputs.build-key }}
|
||||
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
|
||||
run: pnpm install --frozen-lockfile
|
||||
if: steps.retained.outputs.artifact-id == ''
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile
|
||||
- name: Build and verify notarized applications
|
||||
if: steps.retained.outputs.artifact-id == ''
|
||||
env:
|
||||
CUA_EXPECTED_BUILD_KEY: ${{ steps.build-inputs.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"
|
||||
node --import tsx/esm scripts/cua-helper/package.ts --out dist/cua-helper
|
||||
- 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.build-inputs.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,12 +9,7 @@ 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,留空复用源码逐字节一致的产物,无匹配则构建'
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
@@ -26,6 +21,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
env:
|
||||
PRIMARY_NODE_VERSION: '24'
|
||||
@@ -33,18 +29,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 }}
|
||||
reuse: true
|
||||
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 +81,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 +102,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,9 @@
|
||||
/** Read file-owned packaging settings without loading packaging dependencies. */
|
||||
|
||||
/**
|
||||
* Read an explicitly selected dotenv without exposing credential-bearing parser errors.
|
||||
* @param path - Selected file; relative file settings resolve beside it.
|
||||
* @param accepts - Whether this file may declare a setting.
|
||||
* @returns Validated file settings.
|
||||
*/
|
||||
export function readDesktopPackageSettings(path: string, accepts: (name: string) => boolean): Record<string, string>
|
||||
@@ -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 {Record<string, string>} 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,40 +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 {
|
||||
if (!statSync(environment[name]).isFile()) throw new Error('not a file')
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: e8cbc43d38786c40
|
||||
zh: 52b3ba85d98011b9
|
||||
/dsh-cua-native-helper/build-and-service:
|
||||
en: afdfde0453130386
|
||||
zh: 909ca94305262e14
|
||||
en: 1435672513328c05
|
||||
zh: 11e7a1e672410c10
|
||||
/dsh-cua-native-helper/request-protocol:
|
||||
en: fc8043c425322cdd
|
||||
zh: cf1edb9a78fda301
|
||||
|
||||
@@ -37,13 +37,15 @@ Certificate signatures include a signing time. Development builds record the gen
|
||||
|
||||
Generations in `native/.build/prepared/<arch>` and `native/bin/darwin-<arch>` are retained without automatic garbage collection. Use a fresh `--output-directory` for release staging, or clean the packaging output before building while no client uses it, so old generations do not enter the tarball. Secure signing timestamps can create distinct generations for identical sources.
|
||||
|
||||
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 manual **Package CUA helper · macOS** [workflow](../../../../.github/workflows/cua-helper-package.yml) builds both architectures on `dsh-mac-package-trial`. For manual runs, choose a trusted branch or tag with **Use workflow from** and select the machine `preset`. Checkout uses the commit recorded for that Actions run; publication calls use the caller run's commit. Manual runs always build new artifacts. Publication reuse queries the GitHub API with Node's built-in `fetch` and the workflow's `GH_TOKEN`. Signing credentials use the existing [macOS packaging configuration](../../../../apps/desktop/packaging/README.md#macos-actions). Packaging does not require a macOS desktop login or Rosetta. `node --import tsx/esm 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.
|
||||
**Release publish (dsh)** accepts an optional `cua_run_id`: enter the numeric run ID after `/actions/runs/` in the producer's GitHub URL, not its job ID. The publisher enables `reuse` and passes this selection through the helper's `workflow_call` inputs; neither reuse input appears on the manual helper form. Leave `cua_run_id` empty for automatic discovery, which searches completed runs of these two workflows created within 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 pins its latest attempt and selects its last executed helper job across the run history. A pack/publish-only rerun keeps the original helper artifact eligible; a newer failed helper execution blocks reuse of earlier successes. Missing or expired artifacts and mismatched inputs fail without choosing 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. Expiry or deletion after selection stops the download; it does not silently select or build different bytes. Follow the retry instructions below.
|
||||
|
||||
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) covers tracked helper sources, resources, CUA/Desktop packaging scripts, both workflows, package and compiler configuration, locked dependencies, pnpm settings and patches. Tracked `node_modules` entries are also compared. Package manifest version fields are excluded; other manifest fields are retained. 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. Selection independently compares the producer run's GitHub `head_sha` tree and package blobs with the current checkout, including paths, bytes and executable modes; artifact-supplied hashes cannot establish source equality. Helper dependency installation disables lifecycle scripts and pnpmfile hooks. Missing inputs or unreadable GitHub evidence fail. Artifacts without matching records require a new build. Selection still requires a Mac runner and checkout; 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.
|
||||
|
||||
|
||||
@@ -37,13 +37,15 @@ kind: "package-reference"
|
||||
|
||||
`native/.build/prepared/<arch>` 和 `native/bin/darwin-<arch>` 中的各代应用不会自动清理。正式打包时使用全新的 `--output-directory`,或确认没有客户端使用后清理打包输出再构建,避免旧应用进入 tarball。安全签名时间戳可能让相同源码生成不同代应用。
|
||||
|
||||
手动工作流 **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),包括桌面安装包开始构建前发生的失败。
|
||||
手动工作流 **Package CUA helper · macOS** 的[配置](../../../../.github/workflows/cua-helper-package.yml)在 `dsh-mac-package-trial` 构建两种架构。手动启动时,在 **Use workflow from** 中选择可信的分支或 tag,再选择机器 `preset`。checkout 使用本次 Actions 运行记录的提交;由发布工作流调用时使用调用方本次运行的提交。手动运行始终构建新产物。发布复用使用 Node 原生 `fetch` 和工作流提供的 `GH_TOKEN` 查询 GitHub API。签名凭据沿用[macOS 打包配置](../../../../apps/desktop/packaging/README.zh.md#macos-actions)。打包无需 macOS 桌面登录或 Rosetta。在仓库根目录运行 `node --import tsx/esm 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)** 接受可选的 `cua_run_id`:填写构建运行页面 URL 中 `/actions/runs/` 后的数字,不填 job ID。发布工作流开启 `reuse`,并通过 helper 的 `workflow_call` 参数传入这个选择;这两个复用参数均不出现在 helper 的手动表单中。`cua_run_id` 留空时自动查找,查询这两个工作流最近 14 天创建且已结束的运行,选择未过期的匹配产物。选择在 Mac 上执行,早于 pnpm 准备和依赖安装。命中时跳过安装、编译、签名和公证,自动查找未命中时重新构建。手动指定 run ID 时先固定最新 attempt,再选取该 run 历史中最后一次实际执行的 helper job。仅重跑 pack/publish 时仍可使用原 helper 产物;若更新的 helper 执行失败,则不复用更早的成功产物。产物缺失或过期、输入不匹配时直接失败,不改选其他运行。后续 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)覆盖受跟踪的 helper 源码、资源、CUA/Desktop 打包脚本、两份工作流、包与编译器配置、锁定依赖、pnpm 配置和补丁;受跟踪的 `node_modules` 文件也参与比较。包清单只排除版本字段,其余字段保留。构建摘要还包含 preset、Node/Swift/Clang/Python 版本、macOS SDK build、签名身份、Team ID 和证书容器摘要,不包含密码。选择时使用生产运行的 GitHub `head_sha`,独立比较对应 Git 树与包清单 blob 和当前 checkout 的路径、字节及执行权限,不能仅凭产物自报摘要认定源码一致。helper 依赖安装禁用生命周期脚本和 pnpmfile hooks。输入缺失或 GitHub 证据无法读取时失败。没有匹配记录的旧产物需要重新构建。选择仍需等待 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,295 @@
|
||||
/** Byte-level source reuse survives unrelated releases and invalidates changed build inputs. */
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { chmodSync, copyFileSync, existsSync, 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 { loadDesktopPackageEnvironment } from '../../apps/desktop/scripts/desktop-package-environment.mjs'
|
||||
import { cuaHelperBuildKey, cuaHelperSourceDigest, cuaHelperSourceMatches, 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',
|
||||
'.github/workflows/release-publish.yml': 'publish 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') }
|
||||
}
|
||||
|
||||
function copyHelperScripts(root: string): void {
|
||||
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', 'scripts/release/process.ts']) {
|
||||
mkdirSync(dirname(join(root, file)), { recursive: true })
|
||||
copyFileSync(resolve(file), join(root, file))
|
||||
}
|
||||
}
|
||||
|
||||
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 fingerprinting and selection under plain Node with no node_modules directory', () => {
|
||||
const f = fixture()
|
||||
copyHelperScripts(f.root)
|
||||
const output = execFileSync(process.execPath, ['--input-type=module', '-e',
|
||||
"const a = await import('./scripts/cua-helper/select-artifact.ts'); const b = await import('./scripts/cua-helper/build-inputs.ts'); console.log(typeof a.selectCuaHelperArtifact, typeof b.cuaHelperSourceDigest)"],
|
||||
{ cwd: f.root, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' })
|
||||
expect(output.trim()).toBe('function function')
|
||||
})
|
||||
|
||||
const repository = 'deepseek-harness/deepseek-harness'
|
||||
|
||||
function producerSnapshot(root: string) {
|
||||
const git = (...args: string[]): string => execFileSync('git', args, { cwd: root, encoding: 'utf8', stdio: 'pipe' })
|
||||
git('add', '-A')
|
||||
git('-c', `core.hooksPath=${join(root, 'no-hooks')}`, '-c', 'commit.gpgsign=false', '-c', 'user.name=Fixture',
|
||||
'-c', 'user.email=fixture@example.test', 'commit', '--quiet', '-m', 'fixture')
|
||||
const commit = git('rev-parse', 'HEAD').trim()
|
||||
const tree = git('ls-tree', '-r', '-z', 'HEAD').split('\0').filter(Boolean).map((entry) => {
|
||||
const [metadata, path] = entry.split('\t')
|
||||
const [mode, type, sha] = (metadata ?? '').split(' ')
|
||||
if (!path || !mode || !type || !sha) throw new Error('Invalid Git fixture tree entry')
|
||||
return { path, mode, type, sha }
|
||||
})
|
||||
const response = { truncated: false, tree }
|
||||
const responses: Record<string, unknown> = { [`repos/${repository}/git/trees/${commit}?recursive=1`]: response }
|
||||
for (const entry of tree.filter(entry => entry.path === 'package.json')) {
|
||||
const bytes = execFileSync('git', ['cat-file', 'blob', entry.sha], { cwd: root, stdio: 'pipe' })
|
||||
responses[`repos/${repository}/git/blobs/${entry.sha}`] = { sha: entry.sha, encoding: 'base64', content: bytes.toString('base64') }
|
||||
}
|
||||
return { commit, response, responses, api: async (path: string): Promise<unknown> => {
|
||||
if (!(path in responses)) throw new Error(`Unexpected GitHub fixture request ${path}`)
|
||||
return responses[path]
|
||||
} }
|
||||
}
|
||||
|
||||
it('compares GitHub commit objects independently of artifact claims while allowing a root version bump', async () => {
|
||||
const f = fixture()
|
||||
const previous = producerSnapshot(f.root)
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
|
||||
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')
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
|
||||
writeFileSync(f.source, 'changed native code')
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
|
||||
})
|
||||
|
||||
it.each(['helper workflow', 'publish workflow', 'packaging script', 'manifest lifecycle', 'npm config', 'patch', 'tracked dependency', 'deleted file'])(
|
||||
'rejects independent producer source proof after a changed %s', async (change) => {
|
||||
const f = fixture()
|
||||
const previous = producerSnapshot(f.root)
|
||||
const digest = cuaHelperSourceDigest(f.root)
|
||||
if (change === 'helper workflow') writeFileSync(join(f.root, '.github/workflows/cua-helper-package.yml'), 'forged uploader')
|
||||
if (change === 'publish workflow') writeFileSync(join(f.root, '.github/workflows/release-publish.yml'), 'forged caller')
|
||||
if (change === 'packaging script') writeFileSync(join(f.root, 'scripts/cua-helper/package.ts'), 'forged metadata')
|
||||
if (change === 'manifest lifecycle') writeFileSync(join(f.root, 'package.json'), JSON.stringify({
|
||||
version: '1.0.0', packageManager: 'pnpm@11.28.5', scripts: { postinstall: 'unexpected code' },
|
||||
}))
|
||||
if (change === 'npm config') writeFileSync(join(f.root, '.npmrc'), 'node-options=--require=./unexpected.cjs')
|
||||
if (change === 'patch') {
|
||||
mkdirSync(join(f.root, 'patches'))
|
||||
writeFileSync(join(f.root, 'patches/dependency.patch'), 'changed package code')
|
||||
}
|
||||
if (change === 'tracked dependency') {
|
||||
mkdirSync(join(f.root, 'apps/desktop/node_modules/unexpected'), { recursive: true })
|
||||
writeFileSync(join(f.root, 'apps/desktop/node_modules/unexpected/index.js'), 'unexpected dependency code')
|
||||
f.git('add', '--force', 'apps/desktop/node_modules')
|
||||
}
|
||||
if (change === 'deleted file') rmSync(f.source)
|
||||
f.git('add', '-A')
|
||||
expect(cuaHelperSourceDigest(f.root)).not.toBe(digest)
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('stops source comparison when GitHub returns an incomplete tree', async () => {
|
||||
const f = fixture()
|
||||
const previous = producerSnapshot(f.root)
|
||||
previous.response.truncated = true
|
||||
await expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).rejects.toThrow('incomplete commit tree')
|
||||
})
|
||||
|
||||
it.each(['test', 'production'])('computes the same %s build key through selection and packaging configuration readers', (preset) => {
|
||||
const f = fixture()
|
||||
const config = join(f.root, 'config')
|
||||
mkdirSync(join(config, 'presets'), { recursive: true })
|
||||
mkdirSync(join(config, 'secrets'))
|
||||
writeFileSync(join(config, 'presets', `${preset}.env`), `DSH_DESKTOP_AUTO_UPDATE_ENV=${preset}\n`)
|
||||
writeFileSync(join(config, 'secrets/signing.p12'), 'fixture certificate')
|
||||
writeFileSync(join(config, 'secrets/macos-signing.env'), [
|
||||
'CSC_LINK=./signing.p12', 'CSC_KEY_PASSWORD=fixture-password', 'DSH_DESKTOP_MACOS_TEAM_ID=ABCDEFGHIJ',
|
||||
'DSH_DESKTOP_MACOS_SIGNING_IDENTITY=Fixture (ABCDEFGHIJ)',
|
||||
].join('\n'))
|
||||
const environment = { DSH_PACKAGING_CONFIG_DIR: config, DSH_PACKAGING_PRESET: preset }
|
||||
const source = cuaHelperSourceDigest(f.root)
|
||||
const run = (_command: string, args: string[]): string => args.join(' ')
|
||||
expect(cuaHelperBuildKey(source, readCuaSigningSettings(environment), run))
|
||||
.toBe(cuaHelperBuildKey(source, loadDesktopPackageEnvironment('darwin', environment), run))
|
||||
})
|
||||
|
||||
// A private POSIX PATH exposes only Git, so successful CLI execution cannot depend on gh.
|
||||
it.skipIf(process.platform === 'win32').each(['match', 'miss', 'api failure', 'missing token'])(
|
||||
'runs the plain-Node selector entry with %s and without gh or node_modules', (scenario) => {
|
||||
const f = fixture()
|
||||
copyHelperScripts(f.root)
|
||||
const snapshot = producerSnapshot(f.root)
|
||||
const key = 'a'.repeat(64)
|
||||
const base = `repos/${repository}/actions/runs/123`
|
||||
Object.assign(snapshot.responses, {
|
||||
[base]: { run_attempt: 2 },
|
||||
[`${base}/attempts/2`]: { id: 123, run_attempt: 2, status: 'completed', conclusion: 'success',
|
||||
event: 'workflow_dispatch', path: '.github/workflows/cua-helper-package.yml', head_sha: snapshot.commit,
|
||||
repository: { full_name: repository }, head_repository: { full_name: repository } },
|
||||
[`${base}/jobs?filter=all&per_page=100`]: { jobs: [{ name: 'package', conclusion: 'success', run_attempt: 2 }] },
|
||||
[`${base}/artifacts?per_page=100`]: { artifacts: scenario === 'match'
|
||||
? [{ id: 456, name: `cua-helper-123-2-${key}`, expired: false, size_in_bytes: 4096 }] : [] },
|
||||
})
|
||||
const data = join(f.root, 'responses.json')
|
||||
writeFileSync(data, JSON.stringify(snapshot.responses))
|
||||
const bin = join(f.root, 'git-only-bin')
|
||||
mkdirSync(bin)
|
||||
const git = execFileSync('/usr/bin/which', ['git'], { encoding: 'utf8' }).trim()
|
||||
symlinkSync(git, join(bin, 'git'))
|
||||
const preload = join(f.root, 'mock-github.mjs')
|
||||
writeFileSync(preload, `import { readFileSync } from 'node:fs';
|
||||
const data = JSON.parse(readFileSync(process.env.FIXTURE_API_DATA, 'utf8'));
|
||||
globalThis.fetch = async (input, options) => {
|
||||
const url = new URL(input);
|
||||
if (url.origin !== 'https://api.github.com' || new Headers(options.headers).get('authorization') !== 'Bearer fixture-token') {
|
||||
throw new Error('unexpected GitHub request');
|
||||
}
|
||||
if (process.env.FIXTURE_API_FAILURE === '1') return new Response('fixture API failure', { status: 503 });
|
||||
const path = url.pathname.slice(1) + url.search;
|
||||
if (path.includes('/workflows/')) return Response.json({ workflow_runs: path.includes('cua-helper-package.yml') ? [{ id: 123 }] : [] });
|
||||
if (!(path in data)) throw new Error('unexpected GitHub path');
|
||||
return Response.json(data[path]);
|
||||
};
|
||||
`)
|
||||
const output = join(f.root, 'outputs')
|
||||
const summary = join(f.root, 'summary')
|
||||
const result = spawnSync(process.execPath, ['--import', preload, join(f.root, 'scripts/cua-helper/select-artifact.ts')], {
|
||||
cwd: f.root, encoding: 'utf8', timeout: 60_000,
|
||||
env: { ...process.env, NODE_OPTIONS: '', PATH: bin, GH_TOKEN: scenario === 'missing token' ? '' : 'fixture-token',
|
||||
GITHUB_API_URL: 'https://api.github.com', GITHUB_REPOSITORY: repository, GITHUB_OUTPUT: output,
|
||||
GITHUB_STEP_SUMMARY: summary, CUA_RUN_ID: '', CUA_BUILD_KEY: key,
|
||||
FIXTURE_API_DATA: data, FIXTURE_API_FAILURE: scenario === 'api failure' ? '1' : '0' },
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.signal).toBeNull()
|
||||
expect(existsSync(join(bin, 'gh'))).toBe(false)
|
||||
expect(existsSync(join(f.root, 'node_modules'))).toBe(false)
|
||||
if (scenario === 'api failure' || scenario === 'missing token') {
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(scenario === 'missing token' ? 'GH_TOKEN is required' : 'HTTP 503')
|
||||
expect(existsSync(output)).toBe(false)
|
||||
expect(existsSync(summary)).toBe(false)
|
||||
} else {
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(readFileSync(output, 'utf8')).toBe(scenario === 'match' ? 'artifact-id=456\nrun-id=123\n' : 'artifact-id=\nrun-id=\n')
|
||||
expect(readFileSync(summary, 'utf8')).toContain(scenario === 'match' ? '跳过依赖安装' : '未找到')
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
it('matches Git blobs when tracked attributes require CRLF checkout bytes', async () => {
|
||||
const f = fixture()
|
||||
const path = join(f.root, 'apps/desktop/scripts/fixture.cmd')
|
||||
writeFileSync(join(f.root, '.gitattributes'), '*.cmd text eol=crlf\n')
|
||||
writeFileSync(path, '@echo off\necho fixture\n')
|
||||
const previous = producerSnapshot(f.root)
|
||||
writeFileSync(path, '@echo off\r\necho fixture\r\n')
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
|
||||
writeFileSync(join(f.root, '.gitattributes'), '*.cmd text eol=lf\n')
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,169 @@
|
||||
/** Content and machine identity for reusing a signed helper before installing dependencies. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { appendFileSync, lstatSync, readFileSync } from 'node:fs'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { isEntry } from '../release/process.ts'
|
||||
import { readDesktopPackageSettings } from '../../apps/desktop/scripts/desktop-file-settings.mjs'
|
||||
import { resolveMacOSSigningEnvironment } from '../../apps/desktop/scripts/desktop-release-environment.mjs'
|
||||
|
||||
const sourceDirectories = [
|
||||
'packages/experimental/cua-macos/native', 'scripts/cua-helper', 'apps/desktop/scripts', 'patches',
|
||||
]
|
||||
const sourceFiles = new Set([
|
||||
'.github/workflows/cua-helper-package.yml', '.github/workflows/release-publish.yml',
|
||||
'package.json', 'scripts/package.json', 'apps/package.json', 'apps/desktop/package.json', 'scripts/release/process.ts',
|
||||
'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs',
|
||||
])
|
||||
|
||||
function isSourceFile(path: string): boolean {
|
||||
return sourceFiles.has(path) || path === '.gitattributes' || path.endsWith('/.gitattributes') || /^(?:(?:apps|apps\/desktop|scripts)\/)?tsconfig[^/]*\.json$/u.test(path)
|
||||
|| path.split('/').includes('node_modules')
|
||||
|| sourceDirectories.some(directory => path.startsWith(`${directory}/`))
|
||||
}
|
||||
|
||||
function trackedSources(root: string): string[] {
|
||||
const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 })
|
||||
.split('\0').filter(isSourceFile).sort()
|
||||
for (const required of ['packages/experimental/cua-macos/native/Package.swift', 'scripts/cua-helper/package.ts',
|
||||
'package.json', 'pnpm-lock.yaml']) {
|
||||
if (!files.includes(required)) throw new Error(`CUA source: missing tracked build input ${required}`)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
function manifestConfiguration(bytes: Buffer, root: boolean): Buffer {
|
||||
const manifest: unknown = JSON.parse(bytes.toString('utf8'))
|
||||
assert(manifest && typeof manifest === 'object' && !Array.isArray(manifest), 'CUA source: invalid package manifest')
|
||||
assert(!root || ('packageManager' in manifest && typeof manifest.packageManager === 'string'),
|
||||
'CUA source: package.json must declare packageManager')
|
||||
return Buffer.from(JSON.stringify(Object.fromEntries(Object.entries(manifest).filter(([name]) => name !== 'version'))))
|
||||
}
|
||||
|
||||
function sourceFile(root: string, file: string): { bytes: Buffer; executable: boolean } {
|
||||
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}`)
|
||||
return { bytes: readFileSync(path), executable: (stat.mode & 0o111) !== 0 }
|
||||
}
|
||||
|
||||
function gitBlobHash(bytes: Buffer): string {
|
||||
return createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hash tracked build inputs by path, bytes and executable mode; only package manifest version fields is excluded.
|
||||
* Missing tracked files and links fail; build outputs and untracked files never enter the digest.
|
||||
* @param root - Clean repository root containing all helper build inputs.
|
||||
* @returns SHA-256 of sources, resources, workflows, package configuration, patches and locked dependencies.
|
||||
*/
|
||||
export function cuaHelperSourceDigest(root: string): string {
|
||||
const hash = createHash('sha256')
|
||||
for (const file of trackedSources(root)) {
|
||||
const { bytes, executable } = sourceFile(root, file)
|
||||
const input = file === 'package.json' || file.endsWith('/package.json') ? manifestConfiguration(bytes, file === 'package.json') : bytes
|
||||
hash.update(`${file}\0`).update(createHash('sha256').update(input).digest()).update(Buffer.from([Number(executable)]))
|
||||
}
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare a producer's immutable GitHub commit tree with local build inputs independently of artifact metadata.
|
||||
* Incomplete trees, unreadable blobs and API failures stop selection; content differences return false.
|
||||
* @param root - Consumer repository root, before dependency installation.
|
||||
* @param repository - GitHub repository containing the authenticated producer run.
|
||||
* @param commit - Producer head SHA returned by GitHub, never an artifact-supplied revision.
|
||||
* @param api - Read-only GitHub JSON reader.
|
||||
* @returns Resolves to whether selected paths, bytes and executable modes match, excluding package manifest version fields.
|
||||
*/
|
||||
export async function cuaHelperSourceMatches(root: string, repository: string, commit: string,
|
||||
api: (path: string) => Promise<unknown>): Promise<boolean> {
|
||||
assert(/^[a-f0-9]{40}$/u.test(commit), 'CUA source: invalid producer commit')
|
||||
const response = await api(`repos/${repository}/git/trees/${commit}?recursive=1`)
|
||||
assert(response && typeof response === 'object' && 'truncated' in response && response.truncated === false
|
||||
&& 'tree' in response && Array.isArray(response.tree), 'CUA source: GitHub returned an incomplete commit tree')
|
||||
const tree = new Map<string, { sha: string; mode: string }>()
|
||||
const entries: unknown[] = response.tree
|
||||
for (const value of entries) {
|
||||
assert(value && typeof value === 'object' && 'path' in value && typeof value.path === 'string', 'CUA source: invalid tree entry')
|
||||
if (!isSourceFile(value.path) || ('type' in value && value.type === 'tree')) continue
|
||||
assert('sha' in value && typeof value.sha === 'string' && /^[a-f0-9]{40}$/u.test(value.sha)
|
||||
&& 'mode' in value && typeof value.mode === 'string' && 'type' in value, 'CUA source: invalid source entry')
|
||||
if (value.type !== 'blob' || !['100644', '100755'].includes(value.mode)) return false
|
||||
assert(!tree.has(value.path), 'CUA source: duplicate source entry')
|
||||
tree.set(value.path, { sha: value.sha, mode: value.mode })
|
||||
}
|
||||
const files = trackedSources(root)
|
||||
if (tree.size !== files.length) return false
|
||||
for (const file of files) {
|
||||
const remote = tree.get(file)
|
||||
if (!remote) return false
|
||||
const local = sourceFile(root, file)
|
||||
if (remote.mode !== (local.executable ? '100755' : '100644')) return false
|
||||
if (gitBlobHash(local.bytes) === remote.sha) continue
|
||||
const canonical = execFileSync('git', ['hash-object', `--path=${file}`, '--stdin'],
|
||||
{ cwd: root, input: local.bytes, encoding: 'utf8' }).trim()
|
||||
if (canonical === remote.sha) continue
|
||||
if (file !== 'package.json' && !file.endsWith('/package.json')) return false
|
||||
const blob = await api(`repos/${repository}/git/blobs/${remote.sha}`)
|
||||
assert(blob && typeof blob === 'object' && 'sha' in blob && blob.sha === remote.sha
|
||||
&& 'encoding' in blob && blob.encoding === 'base64' && 'content' in blob && typeof blob.content === 'string',
|
||||
'CUA source: invalid package manifest blob')
|
||||
const bytes = Buffer.from(blob.content, 'base64')
|
||||
assert(gitBlobHash(bytes) === remote.sha, 'CUA source: package manifest blob digest mismatch')
|
||||
if (!manifestConfiguration(local.bytes, file === 'package.json').equals(manifestConfiguration(bytes, file === 'package.json'))) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 query returning the active compiler, SDK and Python version strings.
|
||||
* @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')
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) {
|
||||
const env = process.env
|
||||
if (!env.GITHUB_OUTPUT || !env.GITHUB_STEP_SUMMARY) throw new Error('CUA build inputs: Actions context required')
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const sourceDigest = cuaHelperSourceDigest(root)
|
||||
const buildKey = cuaHelperBuildKey(sourceDigest, readCuaSigningSettings(env))
|
||||
appendFileSync(env.GITHUB_OUTPUT, `build-key=${buildKey}\n`)
|
||||
appendFileSync(env.GITHUB_STEP_SUMMARY, `CUA helper 源码摘要:\`${sourceDigest}\`。\n\n`)
|
||||
console.log(`CUA helper source digest: ${sourceDigest}`)
|
||||
}
|
||||
@@ -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') }),
|
||||
|
||||
@@ -3,6 +3,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { isEntry } from '../release/process.ts'
|
||||
import { parseArgs } from 'node:util'
|
||||
import { execa } from 'execa'
|
||||
import { create, extract } from 'tar'
|
||||
@@ -15,6 +16,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 +25,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 +75,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)
|
||||
@@ -98,4 +105,4 @@ async function main(): Promise<void> {
|
||||
journal.finish(true)
|
||||
}
|
||||
|
||||
await main()
|
||||
if (isEntry(import.meta.url)) await main()
|
||||
|
||||
@@ -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,94 +1,290 @@
|
||||
/** Retained CUA selection stays within one producer, attempt and immutable artifact. */
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { selectCuaHelperArtifact } from './select-artifact.ts'
|
||||
/** Discovery and explicit selection require matching inputs and successful producer evidence. */
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { createGitHubJsonReader, 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 } }
|
||||
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 api = vi.fn((path: string, paginate?: boolean) => {
|
||||
path: '.github/workflows/cua-helper-package.yml', head_sha: 'c'.repeat(40), repository: { full_name: repository }, head_repository: { full_name: repository } }
|
||||
const jobs = [{ jobs: [{ name: 'package', conclusion: 'success', run_attempt: 2 }] }]
|
||||
const matchesSource = vi.fn(async (_commit: string) => true)
|
||||
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(async (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 === `${base}/attempts/2/jobs?per_page=100`) return jobs
|
||||
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}/jobs?filter=all&per_page=100`) return jobs
|
||||
if (path === `${base}/artifacts?per_page=100`) return artifacts
|
||||
throw new Error(`Unexpected GitHub request ${path}`)
|
||||
})
|
||||
return { run, jobs, artifact, artifacts, api }
|
||||
return { run, jobs, artifact, artifacts, api, matchesSource }
|
||||
}
|
||||
|
||||
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', async (runId) => {
|
||||
const f = fixture()
|
||||
expect(selectCuaHelperArtifact(repository, { cua_run_id: '123', cua_run_attempt: attempt }, f.api))
|
||||
expect(await selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource))
|
||||
.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', async () => {
|
||||
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')
|
||||
f.jobs[0]!.jobs = [{ name: 'cua-helper / package', conclusion: 'success', run_attempt: 2 }, { name: 'Publish to npm', conclusion: 'failure', run_attempt: 2 }]
|
||||
expect((await selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource))?.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) => {
|
||||
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', async (runId) => {
|
||||
const f = fixture()
|
||||
await expect(selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource)).rejects.toThrow('numeric run ID')
|
||||
expect(f.api).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['wrong/repo/extra', '../repo', 'owner/..', 'owner/.'])('rejects invalid repository %s before reading GitHub', async (repo) => {
|
||||
const f = fixture()
|
||||
expect(() => selectCuaHelperArtifact(repository, inputs, f.api)).toThrow()
|
||||
await expect(selectCuaHelperArtifact(repo, '123', key, f.api, f.matchesSource)).rejects.toThrow('invalid repository')
|
||||
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(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', async (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'
|
||||
await expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).rejects.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) => {
|
||||
'rejects %s helper jobs without falling back to attempt 1', async (status) => {
|
||||
const f = fixture()
|
||||
f.run.path = '.github/workflows/release-publish.yml'
|
||||
f.run.conclusion = 'failure'
|
||||
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')
|
||||
? [{ name: 'cua-helper / package', conclusion: 'success', run_attempt: 2 }, { name: 'cua-helper / package', conclusion: 'success', run_attempt: 2 }]
|
||||
: [{ name: 'cua-helper / package', conclusion: status, run_attempt: 2 }]
|
||||
await expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).rejects.toThrow('last executed cua-helper / package job did not succeed')
|
||||
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', async (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(await selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined()
|
||||
await expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).rejects.toThrow('CUA reuse: run 123:')
|
||||
},
|
||||
)
|
||||
|
||||
it('selects a match across workflows and pages after a newer failed producer', async () => {
|
||||
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(async (path: string, paginate?: boolean): Promise<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/artifacts?per_page=100')) return [{ artifacts: [{ ...f.artifact, name: `cua-helper-124-1-${key}` }] }]
|
||||
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((await selectCuaHelperArtifact(repository, '', key, api, f.matchesSource, 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', async (change) => {
|
||||
const f = fixture()
|
||||
if (change === 'duplicate') f.artifacts.push({ artifacts: [{ ...f.artifact, id: 457 }] })
|
||||
const api = async (path: string, paginate?: boolean): Promise<unknown> => change === 'invalid metadata' && path.endsWith('/artifacts?per_page=100')
|
||||
? [{ artifacts: [{ ...f.artifact, expired: 'false' }] }] : f.api(path, paginate)
|
||||
await expect(selectCuaHelperArtifact(repository, '', key, api, f.matchesSource)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it.each(['', '123'])('propagates unavailable GitHub evidence with selection %j', async (runId) => {
|
||||
const api = vi.fn(async () => { throw new Error('HTTP 403') })
|
||||
await expect(selectCuaHelperArtifact(repository, runId, key, api, async () => true)).rejects.toThrow('HTTP 403')
|
||||
expect(api).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not query job or attempt details when artifact names exclude the requested build key', async () => {
|
||||
const f = fixture()
|
||||
f.artifact.name = 'cua-helper-123-2-legacy'
|
||||
f.artifacts.shift()
|
||||
expect(await selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined()
|
||||
expect(f.api.mock.calls.map(([path]) => path).filter(path => !path.includes('/workflows/')))
|
||||
.toEqual([`${base}/artifacts?per_page=100`])
|
||||
})
|
||||
|
||||
it.each(['123', ''])('retains the original helper after a publish-only rerun with selection %j', async (runId) => {
|
||||
const f = fixture()
|
||||
f.run.path = '.github/workflows/release-publish.yml'
|
||||
f.jobs[0]!.jobs = [{ name: 'cua-helper / package', conclusion: 'success', run_attempt: 1 },
|
||||
{ name: 'Publish to npm', conclusion: 'success', run_attempt: 2 }]
|
||||
f.artifacts.pop()
|
||||
expect(await selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource))
|
||||
.toEqual({ runId: '123', attempt: '1', artifactId: '455' })
|
||||
expect(f.matchesSource).toHaveBeenCalledExactlyOnceWith(f.run.head_sha)
|
||||
})
|
||||
|
||||
it('does not reuse an older successful helper after a newer helper execution failed', async () => {
|
||||
const f = fixture()
|
||||
f.run.path = '.github/workflows/release-publish.yml'
|
||||
f.jobs[0]!.jobs = [{ name: 'cua-helper / package', conclusion: 'success', run_attempt: 1 },
|
||||
{ name: 'cua-helper / package', conclusion: 'failure', run_attempt: 2 }]
|
||||
expect(await selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined()
|
||||
await expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).rejects.toThrow('did not succeed')
|
||||
expect(f.matchesSource).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects forged artifact keys when independent producer source evidence differs', async () => {
|
||||
const f = fixture()
|
||||
f.matchesSource.mockResolvedValue(false)
|
||||
expect(await selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined()
|
||||
await expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).rejects.toThrow('producer commit')
|
||||
})
|
||||
|
||||
it('stops when a new attempt appears during selection', async () => {
|
||||
const f = fixture()
|
||||
f.jobs[0]!.jobs[0]!.run_attempt = 3
|
||||
await expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).rejects.toThrow('run advanced')
|
||||
})
|
||||
|
||||
it('authenticates native HTTP requests and follows canonical GitHub pagination links', async () => {
|
||||
const request = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(Response.json({ jobs: [{ id: 1 }] }, {
|
||||
headers: { link: '<https://api.github.com/repositories/42/actions/jobs?page=2>; rel="next"' },
|
||||
}))
|
||||
.mockResolvedValueOnce(Response.json({ jobs: [{ id: 2 }] }))
|
||||
const api = createGitHubJsonReader({ GH_TOKEN: 'fixture-token' }, request)
|
||||
await expect(api('repos/owner/repo/actions/jobs?page=1', true)).resolves.toEqual([
|
||||
{ jobs: [{ id: 1 }] }, { jobs: [{ id: 2 }] },
|
||||
])
|
||||
expect(request).toHaveBeenCalledTimes(2)
|
||||
expect(request.mock.calls[1]![1]?.signal).toBe(request.mock.calls[0]![1]?.signal)
|
||||
expect(request.mock.calls[1]![0]).toHaveProperty('href', 'https://api.github.com/repositories/42/actions/jobs?page=2')
|
||||
for (const [, options] of request.mock.calls) {
|
||||
const headers = new Headers(options?.headers)
|
||||
expect(headers.get('authorization')).toBe('Bearer fixture-token')
|
||||
expect(headers.get('accept')).toBe('application/vnd.github+json')
|
||||
expect(options?.signal).toBeInstanceOf(AbortSignal)
|
||||
expect(options?.redirect).toBe('error')
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the configured GitHub API base path and leaves non-paginated JSON unwrapped', async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ run_attempt: 2 }))
|
||||
const api = createGitHubJsonReader({ GH_TOKEN: 'fixture-token', GITHUB_API_URL: 'https://github.example.test/api/v3' }, request)
|
||||
await expect(api('repos/owner/repo/actions/runs/123')).resolves.toEqual({ run_attempt: 2 })
|
||||
expect(request.mock.calls[0]![0]).toHaveProperty('href', 'https://github.example.test/api/v3/repos/owner/repo/actions/runs/123')
|
||||
})
|
||||
|
||||
it.each([401, 403, 429, 500, 503])('rejects HTTP %s without returning a partial page list or response credentials', async (status) => {
|
||||
const request = vi.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(Response.json({ jobs: [{ id: 1 }] }, {
|
||||
headers: { link: '<https://api.github.com/repos/owner/repo/jobs?page=2>; rel="next"' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response('private-response-value', { status }))
|
||||
const api = createGitHubJsonReader({ GH_TOKEN: 'fixture-token' }, request)
|
||||
const result = api('repos/owner/repo/jobs?page=1', true)
|
||||
await expect(result).rejects.toThrow(`HTTP ${status}`)
|
||||
await expect(result).rejects.not.toThrow('private-response-value')
|
||||
await expect(result).rejects.not.toThrow('fixture-token')
|
||||
})
|
||||
|
||||
it.each(['connection reset', 'request timeout'])('reports %s as a failed query', async (failure) => {
|
||||
const request = vi.fn<typeof fetch>().mockRejectedValue(new Error(failure))
|
||||
const api = createGitHubJsonReader({ GH_TOKEN: 'fixture-token' }, request)
|
||||
await expect(api('repos/owner/repo')).rejects.toThrow('failed or timed out')
|
||||
})
|
||||
|
||||
it('rejects invalid JSON without exposing its content', async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response('private-response-value'))
|
||||
const result = createGitHubJsonReader({ GH_TOKEN: 'fixture-token' }, request)('repos/owner/repo')
|
||||
await expect(result).rejects.toThrow('invalid JSON')
|
||||
await expect(result).rejects.not.toThrow('private-response-value')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'https://outside.example.test/page/2',
|
||||
'https://fixture-token@api.github.com/repos/owner/repo/jobs?page=2',
|
||||
'https://api.github.com/repos/owner/repo/jobs?page=1',
|
||||
])('rejects unsafe or cyclic pagination %s before sending another request', async (next) => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ jobs: [] }, { headers: { link: `<${next}>; rel="next"` } }))
|
||||
const api = createGitHubJsonReader({ GH_TOKEN: 'fixture-token' }, request)
|
||||
await expect(api('repos/owner/repo/jobs?page=1', true)).rejects.toThrow('CUA reuse: GitHub')
|
||||
expect(request).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects a malformed next-page header', async () => {
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(Response.json({}, { headers: { link: 'missing-url; rel="next"' } }))
|
||||
await expect(createGitHubJsonReader({ GH_TOKEN: 'fixture-token' }, request)('repos/owner/repo/jobs', true))
|
||||
.rejects.toThrow('invalid pagination link')
|
||||
})
|
||||
|
||||
it('stops reading an oversized response and cancels its stream', async () => {
|
||||
let cancelled = false
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) { controller.enqueue(new Uint8Array(32 * 1024 * 1024 + 1)) },
|
||||
cancel() { cancelled = true },
|
||||
})
|
||||
const request = vi.fn<typeof fetch>().mockResolvedValue(new Response(body))
|
||||
await expect(createGitHubJsonReader({ GH_TOKEN: 'fixture-token' }, request)('repos/owner/repo/git/trees/fixture'))
|
||||
.rejects.toThrow('exceeds 32 MiB')
|
||||
expect(cancelled).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['', 'not-a-url', 'http://api.github.com', 'https://user:private@api.github.com', 'https://api.github.com?token=private'])(
|
||||
'rejects invalid API configuration %j before requesting data', (url) => {
|
||||
const request = vi.fn<typeof fetch>()
|
||||
expect(() => createGitHubJsonReader({ GH_TOKEN: 'fixture-token', GITHUB_API_URL: url }, request)).toThrow('GITHUB_API_URL')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('requires an explicit Actions token instead of relying on gh credentials', () => {
|
||||
const request = vi.fn<typeof fetch>()
|
||||
expect(() => createGitHubJsonReader({}, request)).toThrow('GH_TOKEN is required')
|
||||
expect(request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates an asynchronous source verification failure', async () => {
|
||||
const f = fixture()
|
||||
f.matchesSource.mockRejectedValue(new Error('source evidence unavailable'))
|
||||
await expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).rejects.toThrow('source evidence unavailable')
|
||||
})
|
||||
|
||||
it('passes the 60-second abort signal to the HTTP transport', async () => {
|
||||
const controller = new AbortController()
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout').mockReturnValue(controller.signal)
|
||||
onTestFinished(() => { timeout.mockRestore() })
|
||||
const request: typeof fetch = async (_url, options) => {
|
||||
expect(options?.signal).toBe(controller.signal)
|
||||
controller.abort(new DOMException('fixture timeout', 'TimeoutError'))
|
||||
options?.signal?.throwIfAborted()
|
||||
throw new Error('Expected an aborted request')
|
||||
}
|
||||
await expect(createGitHubJsonReader({ GH_TOKEN: 'fixture-token' }, request)('repos/owner/repo')).rejects.toThrow('timed out')
|
||||
expect(timeout).toHaveBeenCalledExactlyOnceWith(60_000)
|
||||
})
|
||||
|
||||
@@ -1,74 +1,210 @@
|
||||
/** Select one retained, successful CUA packaging attempt without contacting the Mac runner. */
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { appendFileSync, readFileSync } from 'node:fs'
|
||||
/** Find a matching retained helper on the Mac runner before installing dependencies. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { z } from 'zod'
|
||||
import { isEntry } from '../release/process.ts'
|
||||
import { cuaHelperSourceMatches } 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 the last executed helper job, preserving its producer attempt across pack/publish-only reruns.
|
||||
* 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 matchesSource - Independent comparison of the producer head SHA with the consuming build inputs.
|
||||
* @param now - Discovery time; automatic search covers the artifact's 14-day retention window.
|
||||
* @returns Resolves to the 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 async function selectCuaHelperArtifact(repository: string, runId: string, buildKey: string,
|
||||
api: (path: string, paginate?: boolean) => Promise<unknown>, matchesSource: (commit: string) => Promise<boolean>,
|
||||
now = new Date()): Promise<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 = async (candidate: string, retained?: Record<string, unknown>[]): Promise<CuaHelperSelection | string> => {
|
||||
const attempt = id(record(await api(`${base}/runs/${candidate}`)).run_attempt)
|
||||
const run = record(await 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(await api(`${base}/runs/${candidate}/jobs?filter=all&per_page=100`, true), 'jobs')
|
||||
.filter(job => job.name === jobName)
|
||||
const attempts = jobs.map(job => Number(id(job.run_attempt)))
|
||||
assert(attempts.every(value => value <= Number(attempt)), 'CUA reuse: run advanced during selection; retry with the same run ID')
|
||||
const producerAttempt = Math.max(0, ...attempts)
|
||||
const producers = jobs.filter(job => job.run_attempt === producerAttempt)
|
||||
if (producers.length !== 1 || producers[0]?.conclusion !== 'success') return `the last executed ${jobName} job did not succeed`
|
||||
const name = `cua-helper-${candidate}-${producerAttempt}-${buildKey}`
|
||||
const artifacts = (retained ?? rows(await 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'
|
||||
assert(typeof run.head_sha === 'string' && /^[a-f0-9]{40}$/u.test(run.head_sha), 'CUA reuse: invalid producer head SHA')
|
||||
if (!await matchesSource(run.head_sha)) return 'the producer commit has different helper build inputs'
|
||||
return { runId: candidate, attempt: String(producerAttempt), 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 = await 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: Record<string, unknown>[] = []
|
||||
for (const workflow of workflows) {
|
||||
candidates.push(...rows(await api(
|
||||
`${base}/workflows/${workflow}/runs?event=workflow_dispatch&status=completed&created=${encodeURIComponent(`>=${since}`)}&per_page=100`,
|
||||
true), 'workflow_runs'))
|
||||
}
|
||||
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')
|
||||
candidates.sort((left, right) => Number(id(right.id)) - Number(id(left.id)))
|
||||
for (const candidate of candidates) {
|
||||
const candidateId = id(candidate.id)
|
||||
const artifacts = rows(await api(`${base}/runs/${candidateId}/artifacts?per_page=100`, true), 'artifacts')
|
||||
if (!artifacts.some(artifact => typeof artifact.name === 'string'
|
||||
&& artifact.name.startsWith(`cua-helper-${candidateId}-`) && artifact.name.endsWith(`-${buildKey}`))) continue
|
||||
const selected = await selectRun(candidateId, artifacts)
|
||||
if (typeof selected !== 'string') return selected
|
||||
}
|
||||
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) {
|
||||
/**
|
||||
* Create authenticated, read-only GitHub requests using Node's built-in fetch.
|
||||
* Each call, including pagination, has a 60-second deadline and a 32 MiB response limit.
|
||||
* Pagination stays on the configured API origin. Request or parsing failures never return partial pages.
|
||||
* @param environment - GH_TOKEN and optional GITHUB_API_URL supplied by Actions.
|
||||
* @param request - HTTP transport receiving the authenticated URL and request options.
|
||||
* @returns JSON reader; paginated calls resolve to an array of page objects.
|
||||
*/
|
||||
export function createGitHubJsonReader(environment: NodeJS.ProcessEnv, request: typeof fetch = fetch):
|
||||
(path: string, paginate?: boolean) => Promise<unknown> {
|
||||
const token = environment.GH_TOKEN?.trim()
|
||||
if (!token) throw new Error('CUA reuse: GH_TOKEN is required for GitHub API access')
|
||||
const configured = environment.GITHUB_API_URL ?? 'https://api.github.com'
|
||||
let base: URL
|
||||
try { base = new URL(configured.endsWith('/') ? configured : `${configured}/`) } catch (_error) {
|
||||
// URL parser errors may include credential-bearing configuration.
|
||||
throw new Error('CUA reuse: GITHUB_API_URL must be a valid HTTPS API URL')
|
||||
}
|
||||
assert(base.protocol === 'https:' && !base.username && !base.password && !base.search && !base.hash,
|
||||
'CUA reuse: GITHUB_API_URL must be HTTPS without credentials, query or fragment')
|
||||
return async (path, paginate = false) => {
|
||||
let target = new URL(path, base)
|
||||
const visited = new Set<string>()
|
||||
const pages: unknown[] = []
|
||||
let bytes = 0
|
||||
const signal = AbortSignal.timeout(60_000)
|
||||
while (true) {
|
||||
assert(target.origin === base.origin && target.pathname.startsWith(base.pathname)
|
||||
&& !target.username && !target.password && !target.hash, 'CUA reuse: GitHub pagination left the configured API')
|
||||
assert(!visited.has(target.href), 'CUA reuse: GitHub returned a pagination cycle')
|
||||
visited.add(target.href)
|
||||
let response: Response
|
||||
try {
|
||||
response = await request(target, {
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2026-03-10', 'User-Agent': 'deepseek-harness-cua-helper' },
|
||||
redirect: 'error', signal,
|
||||
})
|
||||
} catch (error) {
|
||||
throw new Error('CUA reuse: GitHub request failed or timed out; check connectivity and retry this workflow', { cause: error })
|
||||
}
|
||||
if (!response.ok) {
|
||||
try { await response.body?.cancel() } catch (_error) {
|
||||
// A failed body stream must not hide the HTTP failure already received.
|
||||
}
|
||||
throw new Error(`CUA reuse: GitHub request failed (HTTP ${response.status}); check token permissions, rate limits or service availability, then retry`)
|
||||
}
|
||||
const reader = response.body?.getReader()
|
||||
if (!reader) throw new Error('CUA reuse: GitHub returned an empty response')
|
||||
const chunks: Uint8Array[] = []
|
||||
try {
|
||||
while (true) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) break
|
||||
bytes += chunk.value.byteLength
|
||||
if (bytes > 32 * 1024 * 1024) {
|
||||
await reader.cancel()
|
||||
throw new Error('CUA reuse: GitHub response exceeds 32 MiB')
|
||||
}
|
||||
chunks.push(chunk.value)
|
||||
}
|
||||
} finally { reader.releaseLock() }
|
||||
let value: unknown
|
||||
try { value = JSON.parse(Buffer.concat(chunks).toString('utf8')) } catch (_error) {
|
||||
// JSON parser errors may include response bytes.
|
||||
throw new Error('CUA reuse: GitHub returned invalid JSON; retry this workflow')
|
||||
}
|
||||
if (!paginate) return value
|
||||
pages.push(value)
|
||||
const link = response.headers.get('link') ?? ''
|
||||
const next = /<([^>]+)>\s*;\s*rel="next"/u.exec(link)?.[1]
|
||||
assert(next || !link.includes('rel="next"'), 'CUA reuse: GitHub returned an invalid pagination link')
|
||||
if (!next) return pages
|
||||
target = new URL(next, target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) {
|
||||
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) =>
|
||||
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}`)
|
||||
if (!env.GITHUB_REPOSITORY || !env.GITHUB_OUTPUT || !env.GITHUB_STEP_SUMMARY || !env.CUA_BUILD_KEY) {
|
||||
throw new Error('CUA reuse: Actions context and CUA_BUILD_KEY are required')
|
||||
}
|
||||
const githubJson = createGitHubJsonReader(env)
|
||||
const repository = env.GITHUB_REPOSITORY
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const sourceMatches = new Map<string, boolean>()
|
||||
const selection = await selectCuaHelperArtifact(repository, env.CUA_RUN_ID ?? '', env.CUA_BUILD_KEY, githubJson, async (commit) => {
|
||||
let matches = sourceMatches.get(commit)
|
||||
if (matches === undefined) {
|
||||
matches = await cuaHelperSourceMatches(root, repository, commit, githubJson)
|
||||
sourceMatches.set(commit, matches)
|
||||
}
|
||||
return matches
|
||||
})
|
||||
appendFileSync(env.GITHUB_OUTPUT, `artifact-id=${selection?.artifactId ?? ''}\nrun-id=${selection?.runId ?? ''}\n`)
|
||||
const summary = selection
|
||||
? `复用 [CUA helper 构建](https://github.com/${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`)
|
||||
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}`)
|
||||
|
||||
@@ -8,10 +8,10 @@ import { verifyCuaHelperPayload } from './payload.ts'
|
||||
/**
|
||||
* Verify both helper applications in the exact provider tarball being published.
|
||||
* @param tarball - pnpm-produced provider package archive.
|
||||
* @param expected - Product version and source commit of the publishing checkout.
|
||||
* @param expected - Helper source digest 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) => {
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { setTimeout as sleep } from 'node:timers/promises'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { attempt, attemptEchoed, type CommandResult } from './process.ts'
|
||||
import { publishRelease } from './publish.ts'
|
||||
import { cuaHelperSourceDigest } from '../cua-helper/build-inputs.ts'
|
||||
import { verifyCuaHelperTarball } from '../cua-helper/verify-tarball.ts'
|
||||
import { packedIdentity, readPublishOrder } from './tarball.ts'
|
||||
|
||||
vi.mock('./process.ts', async importOriginal => ({
|
||||
@@ -23,6 +25,8 @@ vi.mock('./tarball.ts', async importOriginal => ({
|
||||
readPublishOrder: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../cua-helper/verify-tarball.ts', () => ({ verifyCuaHelperTarball: vi.fn() }))
|
||||
|
||||
vi.mock('node:timers/promises', async importOriginal => ({
|
||||
...await importOriginal<typeof import('node:timers/promises')>(),
|
||||
setTimeout: vi.fn(),
|
||||
@@ -310,3 +314,18 @@ describe('release publication channels', () => {
|
||||
expect(attemptEchoed).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('verifies helper sources from the repository root when publication is invoked from a subdirectory', async () => {
|
||||
const fixture = packedRelease(['1.0.0'])
|
||||
vi.mocked(packedIdentity).mockReturnValue({ name: '@deepseek-ai/dsh-experimental-cua-macos', version: '1.0.0' })
|
||||
vi.mocked(verifyCuaHelperTarball).mockResolvedValue(undefined)
|
||||
registry()
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const sourceDigest = cuaHelperSourceDigest(root)
|
||||
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(join(root, 'scripts'))
|
||||
try {
|
||||
await publishRelease('dsh', fixture.directory)
|
||||
expect(verifyCuaHelperTarball).toHaveBeenCalledExactlyOnceWith(fixture.packages[0]!.path, { sourceDigest })
|
||||
expect(attemptEchoed).toHaveBeenCalledOnce()
|
||||
} finally { cwd.mockRestore() }
|
||||
})
|
||||
|
||||
@@ -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(resolve(import.meta.dirname, '../..')) })
|
||||
}
|
||||
if (distTag !== undefined) {
|
||||
for (const member of order) verifyDistTag(member, distTag)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
/** Release rehearsal routing and persistent-runner isolation, without executing release builds. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { load } from 'js-yaml'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
|
||||
const root = resolve(import.meta.dirname, '../..')
|
||||
const repository = 'deepseek-harness/deepseek-harness'
|
||||
@@ -11,6 +13,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 +33,8 @@ interface Workflow {
|
||||
with?: Record<string, unknown>
|
||||
needs?: string
|
||||
permissions?: Record<string, string>
|
||||
outputs?: Record<string, string>
|
||||
env?: Record<string, string>
|
||||
}>
|
||||
}
|
||||
|
||||
@@ -42,7 +47,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 +185,141 @@ 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: { reuse: true, 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.on['workflow_dispatch']).toMatchObject({ inputs: { 'skip-tag-check': {
|
||||
description: 'Skip release tag validation for the selected ref; other publication checks still apply.',
|
||||
} } })
|
||||
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')
|
||||
expect(Boolean(evaluate(select.if!, {}))).toBe(false)
|
||||
expect(evaluate(select.if!, { 'inputs.reuse': true })).toBe(true)
|
||||
expect(select.env?.['CUA_BUILD_KEY']).toBe('${{ steps.build-inputs.outputs.build-key }}')
|
||||
const inputs = job.steps.find(step => step.name === 'Record CUA helper build inputs')!
|
||||
expect(inputs.run).toBe('node scripts/cua-helper/build-inputs.ts')
|
||||
expect(inputs.if).toBeUndefined()
|
||||
expect(job.steps.indexOf(inputs)).toBeLessThan(job.steps.indexOf(select))
|
||||
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)
|
||||
expect(job.steps.find(step => step.name === 'Install locked dependencies')?.run)
|
||||
.toBe('pnpm install --frozen-lockfile --ignore-scripts --ignore-pnpmfile')
|
||||
const build = job.steps.find(step => step.name === 'Build and verify notarized applications')!
|
||||
expect(build.run).toContain('node --import tsx/esm scripts/cua-helper/package.ts --out dist/cua-helper')
|
||||
expect(build.run).not.toContain('pnpm exec')
|
||||
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.build-inputs.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'))
|
||||
})
|
||||
|
||||
it('checks out the Actions run commit and exposes only the preset on the manual helper form', () => {
|
||||
const helper = workflow('cua-helper-package.yml')
|
||||
const dispatch = helper.on['workflow_dispatch'] as { inputs: Record<string, unknown> }
|
||||
const called = helper.on['workflow_call'] as { inputs: Record<string, unknown> }
|
||||
expect(Object.keys(dispatch.inputs)).toEqual(['preset'])
|
||||
expect(called.inputs).toEqual({ reuse: { type: 'boolean', default: false },
|
||||
cua_run_id: { type: 'string', default: '' }, preset: { type: 'string', default: 'production' } })
|
||||
const job = helper.jobs['package']!
|
||||
const checkout = job.steps.find(step => step.uses === 'actions/checkout@v7.0.1')!
|
||||
expect(checkout.with).toEqual({ 'persist-credentials': false, clean: true })
|
||||
expect(job.env).not.toHaveProperty('PACKAGE_SOURCE_REF')
|
||||
expect(JSON.stringify(helper)).not.toContain('source_ref')
|
||||
expect(workflow('release-publish.yml').jobs['cua-helper']!.with).not.toHaveProperty('source_ref')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['refs/heads/release-candidate', 'test', true, ''],
|
||||
['refs/tags/dsh-v1.2.3', 'production', true, ''],
|
||||
['refs/tags/dsh-v1.2.4', 'production', false, 'Release tag must match product version'],
|
||||
['refs/heads/master', 'invalid', false, 'Invalid preset'],
|
||||
['refs/pull/42/merge', 'test', false, 'Select a trusted repository branch or release tag'],
|
||||
['', 'test', false, 'Select a trusted repository branch or release tag'],
|
||||
] as const)('validates the run ref %s and preset %s before dependency installation', (ref, preset, accepted, diagnostic) => {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'cua-workflow-ref-'))
|
||||
onTestFinished(() => { rmSync(directory, { recursive: true, force: true }) })
|
||||
writeFileSync(join(directory, 'package.json'), JSON.stringify({ version: '1.2.3' }))
|
||||
const job = workflow('cua-helper-package.yml').jobs['package']!
|
||||
const validation = job.steps.find(step => step.name === 'Validate run ref and preset')!
|
||||
const source = /^node -e '([\s\S]+)'$/u.exec(validation.run!.trim())?.[1]
|
||||
expect(source).toBeDefined()
|
||||
const result = spawnSync(process.execPath, ['-e', source!], {
|
||||
cwd: directory, encoding: 'utf8', timeout: 60_000,
|
||||
env: { ...process.env, GITHUB_REF: ref, DSH_PACKAGING_PRESET: preset, PACKAGE_SOURCE_REF: 'refs/tags/dsh-v0.0.0' },
|
||||
})
|
||||
expect(result.error).toBeUndefined()
|
||||
expect(result.signal).toBeNull()
|
||||
expect(result.status, result.stderr).toBe(accepted ? 0 : 1)
|
||||
if (!accepted) expect(result.stderr).toContain(diagnostic)
|
||||
expect(job.steps.indexOf(validation)).toBeLessThan(job.steps.findIndex(step => step.name === 'Record CUA helper build inputs'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
[false, '', true],
|
||||
[true, '', true],
|
||||
[true, '456', false],
|
||||
] as const)('builds for reuse=%s and selected artifact=%s only when required', (reuse, artifactId, build) => {
|
||||
const steps = workflow('cua-helper-package.yml').jobs['package']!.steps
|
||||
const selection = steps.find(step => step.name === 'Find reusable CUA helper')!
|
||||
expect(Boolean(evaluate(selection.if!, reuse ? { 'inputs.reuse': true } : {}))).toBe(reuse)
|
||||
for (const step of 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(evaluate(step.if!, { 'steps.retained.outputs.artifact-id': artifactId })).toBe(build)
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user