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
@@ -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