fix(release): query helper artifacts with native Node fetch
This commit is contained in:
1 parent
9e761119bb
commit
72b3dfdc6f
7 files changed
+310
-113
No files matched your search
@@ -15,8 +15,8 @@
|
||||
en: e8cbc43d38786c40
|
||||
zh: 52b3ba85d98011b9
|
||||
/dsh-cua-native-helper/build-and-service:
|
||||
en: bbc9d596d2b4bd20
|
||||
zh: 3b13695a23818237
|
||||
en: 1435672513328c05
|
||||
zh: 11e7a1e672410c10
|
||||
/dsh-cua-native-helper/request-protocol:
|
||||
en: fc8043c425322cdd
|
||||
zh: cf1edb9a78fda301
|
||||
|
||||
@@ -37,7 +37,7 @@ 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`. 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 requires GitHub CLI (`gh`) on the runner's `PATH`; 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 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`. 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.
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ 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` 构建两种架构。手动启动时,在 **Use workflow from** 中选择可信的分支或 tag,再选择机器 `preset`。checkout 使用本次 Actions 运行记录的提交;由发布工作流调用时使用调用方本次运行的提交。手动运行始终构建新产物。发布复用要求 runner 的 `PATH` 中有 GitHub CLI(`gh`);凭据沿用[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),包括桌面安装包开始构建前发生的失败。
|
||||
手动工作流 **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)始终调用该工作流,再按返回的 run ID 和 artifact ID 下载所选产物,生成 provider 的 npm 包。准备步骤校验当前 helper 的源码摘要、选定的构建摘要、两种架构、应用目录、文件字节和执行权限。npm 发布在写入注册表前重新检查实际 provider tarball。无关 commit 或产品版本变化时,复用保留原始清单和已签名字节。普通 PR 发布检查不使用签名凭据。
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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 { delimiter, dirname, join, resolve } from 'node:path'
|
||||
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'
|
||||
@@ -148,25 +148,25 @@ function producerSnapshot(root: string) {
|
||||
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: (path: string): unknown => {
|
||||
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', () => {
|
||||
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(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
|
||||
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: '[email protected]' }))
|
||||
writeFileSync(join(f.root, 'packages/client/unrelated/src/index.ts'), 'another unrelated feature')
|
||||
expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
|
||||
writeFileSync(f.source, 'changed native code')
|
||||
expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
|
||||
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', (change) => {
|
||||
'rejects independent producer source proof after a changed %s', async (change) => {
|
||||
const f = fixture()
|
||||
const previous = producerSnapshot(f.root)
|
||||
const digest = cuaHelperSourceDigest(f.root)
|
||||
@@ -189,15 +189,15 @@ it.each(['helper workflow', 'publish workflow', 'packaging script', 'manifest li
|
||||
if (change === 'deleted file') rmSync(f.source)
|
||||
f.git('add', '-A')
|
||||
expect(cuaHelperSourceDigest(f.root)).not.toBe(digest)
|
||||
expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('stops source comparison when GitHub returns an incomplete tree', () => {
|
||||
it('stops source comparison when GitHub returns an incomplete tree', async () => {
|
||||
const f = fixture()
|
||||
const previous = producerSnapshot(f.root)
|
||||
previous.response.truncated = true
|
||||
expect(() => cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toThrow('incomplete commit tree')
|
||||
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) => {
|
||||
@@ -218,9 +218,9 @@ it.each(['test', 'production'])('computes the same %s build key through selectio
|
||||
.toBe(cuaHelperBuildKey(source, loadDesktopPackageEnvironment('darwin', environment), run))
|
||||
})
|
||||
|
||||
// The fake gh uses a POSIX executable shim; the production workflow runs on macOS.
|
||||
it.skipIf(process.platform === 'win32').each(['match', 'miss', 'api failure', 'missing gh'])(
|
||||
'runs the plain-Node selector entry with %s and no node_modules directory', (scenario) => {
|
||||
// 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)
|
||||
@@ -231,35 +231,47 @@ it.skipIf(process.platform === 'win32').each(['match', 'miss', 'api failure', 'm
|
||||
[`${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 }] : [] }],
|
||||
[`${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, 'fake-bin')
|
||||
const bin = join(f.root, 'git-only-bin')
|
||||
mkdirSync(bin)
|
||||
if (scenario !== 'missing gh') {
|
||||
writeFileSync(join(bin, 'gh'), `#!/usr/bin/env node\nconst fs=require('node:fs');
|
||||
if(process.env.FIXTURE_API_FAILURE==='1') { console.error('fixture API failure'); process.exit(1); }
|
||||
const path=process.argv.at(-1); const data=JSON.parse(fs.readFileSync(process.env.FIXTURE_API_DATA,'utf8'));
|
||||
if(path.includes('/workflows/')) console.log(JSON.stringify([{workflow_runs:path.includes('cua-helper-package.yml')?[{id:123}]:[]}]));
|
||||
else if(path in data) console.log(JSON.stringify(data[path])); else {console.error('unexpected path '+path);process.exit(1);}
|
||||
`, { mode: 0o755 })
|
||||
}
|
||||
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, [join(f.root, 'scripts/cua-helper/select-artifact.ts')], {
|
||||
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: scenario === 'missing gh' ? bin : `${bin}${delimiter}${process.env.PATH ?? ''}`,
|
||||
GITHUB_REPOSITORY: repository, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, CUA_RUN_ID: '', CUA_BUILD_KEY: key,
|
||||
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()
|
||||
if (scenario === 'api failure' || scenario === 'missing gh') {
|
||||
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 gh' ? 'install GitHub CLI (gh)' : 'check token permissions')
|
||||
expect(result.stderr).toContain(scenario === 'missing token' ? 'GH_TOKEN is required' : 'HTTP 503')
|
||||
expect(existsSync(output)).toBe(false)
|
||||
expect(existsSync(summary)).toBe(false)
|
||||
} else {
|
||||
@@ -270,14 +282,14 @@ else if(path in data) console.log(JSON.stringify(data[path])); else {console.err
|
||||
},
|
||||
)
|
||||
|
||||
it('matches Git blobs when tracked attributes require CRLF checkout bytes', () => {
|
||||
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(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(true)
|
||||
writeFileSync(join(f.root, '.gitattributes'), '*.cmd text eol=lf\n')
|
||||
expect(cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
|
||||
expect(await cuaHelperSourceMatches(f.root, repository, previous.commit, previous.api)).toBe(false)
|
||||
})
|
||||
@@ -75,12 +75,12 @@ export function cuaHelperSourceDigest(root: string): string {
|
||||
* @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 Whether all selected file paths, bytes and executable modes match, excluding product version fields in package manifests.
|
||||
* @returns Resolves to whether selected paths, bytes and executable modes match, excluding package manifest version fields.
|
||||
*/
|
||||
export function cuaHelperSourceMatches(root: string, repository: string, commit: string,
|
||||
api: (path: string) => unknown): boolean {
|
||||
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 = api(`repos/${repository}/git/trees/${commit}?recursive=1`)
|
||||
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 }>()
|
||||
@@ -106,7 +106,7 @@ export function cuaHelperSourceMatches(root: string, repository: string, commit:
|
||||
{ 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 = api(`repos/${repository}/git/blobs/${remote.sha}`)
|
||||
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')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Discovery and explicit selection require matching inputs and successful producer evidence. */
|
||||
import { expect, it, vi } from 'vitest'
|
||||
import { selectCuaHelperArtifact } from './select-artifact.ts'
|
||||
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`
|
||||
@@ -10,10 +10,10 @@ function fixture() {
|
||||
const run = { id: 123, run_attempt: 2, status: 'completed', conclusion: 'success', event: 'workflow_dispatch',
|
||||
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((_commit: string) => true)
|
||||
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((path: string, paginate?: boolean) => {
|
||||
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)
|
||||
@@ -26,38 +26,38 @@ function fixture() {
|
||||
return { run, jobs, artifact, artifacts, api, matchesSource }
|
||||
}
|
||||
|
||||
it.each(['123', ''])('selects a matching artifact with run ID %j and pins the latest attempt', (runId) => {
|
||||
it.each(['123', ''])('selects a matching artifact with run ID %j and pins the latest attempt', async (runId) => {
|
||||
const f = fixture()
|
||||
expect(selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource))
|
||||
expect(await selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource))
|
||||
.toEqual({ runId: '123', attempt: '2', artifactId: '456' })
|
||||
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 helper packaging when later npm publication 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', run_attempt: 2 }, { name: 'Publish to npm', conclusion: 'failure', run_attempt: 2 }]
|
||||
expect(selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)?.artifactId).toBe('456')
|
||||
expect((await selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource))?.artifactId).toBe('456')
|
||||
})
|
||||
|
||||
it.each(['123/other', ' 123', '0', '-1', '2\nartifact-id=9', 'https://github.com/owner/repo/actions/runs/123'])(
|
||||
'rejects invalid run ID %j before reading GitHub', (runId) => {
|
||||
'rejects invalid run ID %j before reading GitHub', async (runId) => {
|
||||
const f = fixture()
|
||||
expect(() => selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource)).toThrow('numeric run ID')
|
||||
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', (repo) => {
|
||||
it.each(['wrong/repo/extra', '../repo', 'owner/..', 'owner/.'])('rejects invalid repository %s before reading GitHub', async (repo) => {
|
||||
const f = fixture()
|
||||
expect(() => selectCuaHelperArtifact(repo, '123', key, f.api, f.matchesSource)).toThrow('invalid repository')
|
||||
await expect(selectCuaHelperArtifact(repo, '123', key, f.api, f.matchesSource)).rejects.toThrow('invalid repository')
|
||||
expect(f.api).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['workflow', 'event', 'repository', 'head_repository', 'id', 'attempt', 'running', 'failed'] as const)(
|
||||
'rejects explicit %s run evidence', (change) => {
|
||||
'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'
|
||||
@@ -67,40 +67,40 @@ it.each(['workflow', 'event', 'repository', 'head_repository', 'id', 'attempt',
|
||||
if (change === 'attempt') f.run.run_attempt = 1
|
||||
if (change === 'running') f.run.status = 'in_progress'
|
||||
if (change === 'failed') f.run.conclusion = 'failure'
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow()
|
||||
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', run_attempt: 2 }, { name: 'cua-helper / package', conclusion: 'success', run_attempt: 2 }]
|
||||
: [{ name: 'cua-helper / package', conclusion: status, run_attempt: 2 }]
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('last executed cua-helper / package job did not succeed')
|
||||
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', 'mismatch', 'legacy'] as const)(
|
||||
'rebuilds on automatic %s but rejects the same explicit selection', (change) => {
|
||||
'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(selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined()
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('CUA reuse: run 123:')
|
||||
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', () => {
|
||||
it('selects a match across workflows and pages after a newer failed producer', async () => {
|
||||
const f = fixture()
|
||||
const api = vi.fn((path: string, paginate?: boolean): unknown => {
|
||||
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}` }] }]
|
||||
@@ -108,65 +108,183 @@ it('selects a match across workflows and pages after a newer failed producer', (
|
||||
if (path.endsWith('/runs/124/attempts/1')) return { ...f.run, id: 124, run_attempt: 1, conclusion: 'failure' }
|
||||
return f.api(path, paginate)
|
||||
})
|
||||
expect(selectCuaHelperArtifact(repository, '', key, api, f.matchesSource, new Date('2026-11-03T00:00:00Z'))?.runId).toBe('123')
|
||||
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.each(['duplicate', 'invalid metadata'] as const)('stops automatic discovery on %s evidence', (change) => {
|
||||
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 = (path: string, paginate?: boolean): unknown => change === 'invalid metadata' && path.endsWith('/artifacts?per_page=100')
|
||||
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)
|
||||
expect(() => selectCuaHelperArtifact(repository, '', key, api, f.matchesSource)).toThrow()
|
||||
await expect(selectCuaHelperArtifact(repository, '', key, api, f.matchesSource)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it.each(['', '123'])('propagates unavailable GitHub evidence with selection %j', (runId) => {
|
||||
const api = vi.fn(() => { throw new Error('HTTP 403') })
|
||||
expect(() => selectCuaHelperArtifact(repository, runId, key, api, () => true)).toThrow('HTTP 403')
|
||||
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', () => {
|
||||
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(selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined()
|
||||
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', (runId) => {
|
||||
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(selectCuaHelperArtifact(repository, runId, key, f.api, f.matchesSource))
|
||||
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', () => {
|
||||
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(selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined()
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('did not succeed')
|
||||
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', () => {
|
||||
it('rejects forged artifact keys when independent producer source evidence differs', async () => {
|
||||
const f = fixture()
|
||||
f.matchesSource.mockReturnValue(false)
|
||||
expect(selectCuaHelperArtifact(repository, '', key, f.api, f.matchesSource)).toBeUndefined()
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('producer commit')
|
||||
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', () => {
|
||||
it('stops when a new attempt appears during selection', async () => {
|
||||
const f = fixture()
|
||||
f.jobs[0]!.jobs[0]!.run_attempt = 3
|
||||
expect(() => selectCuaHelperArtifact(repository, '123', key, f.api, f.matchesSource)).toThrow('run advanced')
|
||||
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(String(request.mock.calls[1]![0])).toBe('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(String(request.mock.calls[0]![0])).toBe('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://[email protected]/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:[email protected]', '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,6 +1,5 @@
|
||||
/** Find a matching retained helper on the Mac runner before installing dependencies. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { isEntry } from '../release/process.ts'
|
||||
@@ -43,19 +42,19 @@ export interface CuaHelperSelection {
|
||||
* @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 Matching producer artifact, or undefined when automatic discovery has no match.
|
||||
* @returns Resolves to the matching producer artifact, or undefined when automatic discovery has no match.
|
||||
*/
|
||||
export function selectCuaHelperArtifact(repository: string, runId: string, buildKey: string,
|
||||
api: (path: string, paginate?: boolean) => unknown, matchesSource: (commit: string) => boolean,
|
||||
now = new Date()): CuaHelperSelection | undefined {
|
||||
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 = (candidate: string, retained?: Record<string, unknown>[]): CuaHelperSelection | string => {
|
||||
const attempt = id(record(api(`${base}/runs/${candidate}`)).run_attempt)
|
||||
const run = record(api(`${base}/runs/${candidate}/attempts/${attempt}`))
|
||||
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')
|
||||
@@ -64,7 +63,7 @@ export function selectCuaHelperArtifact(repository: string, runId: string, build
|
||||
if (run.status !== 'completed') return 'the latest attempt has not completed'
|
||||
if (workflow === 'cua-helper-package.yml' && run.conclusion !== 'success') return 'the latest packaging attempt did not succeed'
|
||||
const jobName = workflow === 'cua-helper-package.yml' ? 'package' : 'cua-helper / package'
|
||||
const jobs = rows(api(`${base}/runs/${candidate}/jobs?filter=all&per_page=100`, true), 'jobs')
|
||||
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')
|
||||
@@ -72,7 +71,7 @@ export function selectCuaHelperArtifact(repository: string, runId: string, build
|
||||
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(api(`${base}/runs/${candidate}/artifacts?per_page=100`, true), 'artifacts'))
|
||||
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
|
||||
@@ -80,42 +79,109 @@ export function selectCuaHelperArtifact(repository: string, runId: string, build
|
||||
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 (!matchesSource(run.head_sha)) return 'the producer commit has different helper build inputs'
|
||||
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) }
|
||||
}
|
||||
if (runId !== '') {
|
||||
const selected = selectRun(runId)
|
||||
const selected = await selectRun(runId)
|
||||
if (typeof selected === 'string') throw new Error(`CUA reuse: run ${runId}: ${selected}`)
|
||||
return selected
|
||||
}
|
||||
const since = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000).toISOString()
|
||||
const candidates = workflows.flatMap(workflow => rows(api(
|
||||
`${base}/workflows/${workflow}/runs?event=workflow_dispatch&status=completed&created=${encodeURIComponent(`>=${since}`)}&per_page=100`,
|
||||
true), 'workflow_runs'))
|
||||
.sort((left, right) => Number(id(right.id)) - Number(id(left.id)))
|
||||
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'))
|
||||
}
|
||||
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(api(`${base}/runs/${candidateId}/artifacts?per_page=100`, true), 'artifacts')
|
||||
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 = selectRun(candidateId, artifacts)
|
||||
const selected = await selectRun(candidateId, artifacts)
|
||||
if (typeof selected !== 'string') return selected
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function githubJson(path: string, paginate = false): unknown {
|
||||
let output: string
|
||||
try {
|
||||
output = execFileSync('gh', ['api', ...(paginate ? ['--paginate', '--slurp'] : []), path],
|
||||
{ encoding: 'utf8', timeout: 60_000, maxBuffer: 32 * 1024 * 1024 })
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
||||
throw new Error('CUA reuse: install GitHub CLI (gh) on the Mac runner PATH, then retry this workflow')
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
throw new Error('CUA reuse: GitHub lookup failed; check token permissions, rate limits and connectivity, then retry this workflow', { cause: error })
|
||||
}
|
||||
return JSON.parse(output)
|
||||
}
|
||||
|
||||
if (isEntry(import.meta.url)) {
|
||||
@@ -123,13 +189,14 @@ if (isEntry(import.meta.url)) {
|
||||
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 = selectCuaHelperArtifact(repository, env.CUA_RUN_ID ?? '', env.CUA_BUILD_KEY, githubJson, (commit) => {
|
||||
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 = cuaHelperSourceMatches(root, repository, commit, githubJson)
|
||||
matches = await cuaHelperSourceMatches(root, repository, commit, githubJson)
|
||||
sourceMatches.set(commit, matches)
|
||||
}
|
||||
return matches
|
||||
|
||||
Reference in new issue
Block a user