mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-28 20:45:11 +02:00
Merge release-gate diagnostic correction from PR #3195
Source-PR: https://github.com/affaan-m/ECC/pull/3195
Source-Head: ffb69744b0
This commit is contained in:
@@ -79,6 +79,8 @@ function setting(value, fallback, maximum) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
class ReleaseGateDeadlineError extends Error {}
|
||||
|
||||
function createGithubClient(inputs, fetchImpl = fetch, options = {}) {
|
||||
validateInputs(inputs);
|
||||
const now = options.now || (() => performance.now());
|
||||
@@ -88,7 +90,7 @@ function createGithubClient(inputs, fetchImpl = fetch, options = {}) {
|
||||
|
||||
function remaining() {
|
||||
const left = deadline - now();
|
||||
if (left <= 0) throw new Error('Release gate global deadline exceeded');
|
||||
if (left <= 0) throw new ReleaseGateDeadlineError('Release gate global deadline exceeded');
|
||||
return left;
|
||||
}
|
||||
|
||||
@@ -101,7 +103,7 @@ function createGithubClient(inputs, fetchImpl = fetch, options = {}) {
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error('Release gate request or global deadline exceeded'));
|
||||
reject(new ReleaseGateDeadlineError('Release gate request or global deadline exceeded'));
|
||||
}, Math.min(limit, remaining()));
|
||||
}),
|
||||
]);
|
||||
@@ -253,8 +255,9 @@ function selectRuns(runs, inputs, trusted) {
|
||||
return { ci: select(trusted.ci, 'push'), codeql: select(trusted.codeql, 'dynamic') };
|
||||
}
|
||||
|
||||
function statusOf(result, label) {
|
||||
if (!result || result.status !== 'completed') return { state: 'pending' };
|
||||
function statusOf(result, label, pendingLabel = label) {
|
||||
if (!result) return { state: 'pending', reason: `${pendingLabel} run not found for release SHA` };
|
||||
if (result.status !== 'completed') return { state: 'pending', reason: `${pendingLabel} is ${result.status}` };
|
||||
return result.conclusion === 'success' ? { state: 'passed' }
|
||||
: { state: 'failed', reason: `${label} concluded ${result.conclusion}` };
|
||||
}
|
||||
@@ -272,7 +275,7 @@ function assessExactShaGates(selected, checks, jobs, inputs) {
|
||||
const matches = jobs.filter(job => job.name === name);
|
||||
if (matches.length > 1) throw new Error('Ambiguous required CodeQL job');
|
||||
const job = matches[0];
|
||||
if (!job) return { state: 'pending' };
|
||||
if (!job) return { state: 'pending', reason: `CodeQL job "${name}" missing from selected attempt` };
|
||||
if (job.run_id !== run.id || job.run_attempt !== run.run_attempt
|
||||
|| job.head_sha !== inputs.releaseSha || job.head_branch !== 'main') {
|
||||
throw new Error('CodeQL job does not belong to the selected run attempt');
|
||||
@@ -281,9 +284,11 @@ function assessExactShaGates(selected, checks, jobs, inputs) {
|
||||
=== `https://api.github.com/repos/${inputs.repository}/check-runs/${candidate.id}`);
|
||||
if (!check || check.name !== name || check.head_sha !== inputs.releaseSha
|
||||
|| check.check_suite.id !== run.check_suite_id || check.app.id !== ACTIONS_APP.id
|
||||
|| check.app.slug !== ACTIONS_APP.slug) return { state: 'pending' };
|
||||
for (const result of [job, check]) {
|
||||
const assessment = statusOf(result, name);
|
||||
|| check.app.slug !== ACTIONS_APP.slug) {
|
||||
return { state: 'pending', reason: `CodeQL check "${name}" missing or not bound to trusted job` };
|
||||
}
|
||||
for (const [kind, result] of [['job', job], ['check', check]]) {
|
||||
const assessment = statusOf(result, name, `CodeQL ${kind} "${name}"`);
|
||||
if (assessment.state !== 'passed') return assessment;
|
||||
}
|
||||
}
|
||||
@@ -301,30 +306,39 @@ async function waitForExactShaGates(inputs, fetchImpl = fetch, sleep = defaultSl
|
||||
const client = options.client || createGithubClient(inputs, fetchImpl, options);
|
||||
const attempts = setting(options.attempts ?? process.env.RELEASE_GATE_ATTEMPTS, DEFAULT_ATTEMPTS, DEFAULT_ATTEMPTS);
|
||||
const delay = setting(options.delayMs ?? process.env.RELEASE_GATE_DELAY_MS, DEFAULT_DELAY_MS, DEFAULT_DELAY_MS);
|
||||
const trusted = await trustedProducers(client, inputs);
|
||||
const readRuns = async () => selectRuns(await client.pages(
|
||||
`/actions/runs?head_sha=${inputs.releaseSha}&branch=main&per_page=100`, 'workflow_runs', runShape
|
||||
), inputs, trusted);
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const selected = await readRuns();
|
||||
let assessment = statusOf(selected.ci, 'CI');
|
||||
if (assessment.state === 'passed') assessment = statusOf(selected.codeql, 'CodeQL');
|
||||
if (assessment.state === 'passed') {
|
||||
const run = selected.codeql;
|
||||
const jobs = await client.pages(`/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`, 'jobs', jobShape);
|
||||
const checks = await client.pages(`/check-suites/${run.check_suite_id}/check-runs?filter=all&per_page=100`, 'check_runs', checkShape);
|
||||
assessment = assessExactShaGates(selected, checks, jobs, inputs);
|
||||
let lastReason = 'no gate assessment completed';
|
||||
try {
|
||||
const trusted = await trustedProducers(client, inputs);
|
||||
const readRuns = async () => selectRuns(await client.pages(
|
||||
`/actions/runs?head_sha=${inputs.releaseSha}&branch=main&per_page=100`, 'workflow_runs', runShape
|
||||
), inputs, trusted);
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const selected = await readRuns();
|
||||
let assessment = statusOf(selected.ci, 'CI');
|
||||
if (assessment.state === 'passed') assessment = statusOf(selected.codeql, 'CodeQL');
|
||||
if (assessment.state === 'passed') {
|
||||
// Do not approve an attempt superseded while its jobs/checks were read.
|
||||
const finalRuns = await readRuns();
|
||||
if (JSON.stringify(finalRuns) === JSON.stringify(selected)) return;
|
||||
assessment = { state: 'pending' };
|
||||
const run = selected.codeql;
|
||||
const jobs = await client.pages(`/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`, 'jobs', jobShape);
|
||||
const checks = await client.pages(`/check-suites/${run.check_suite_id}/check-runs?filter=all&per_page=100`, 'check_runs', checkShape);
|
||||
assessment = assessExactShaGates(selected, checks, jobs, inputs);
|
||||
if (assessment.state === 'passed') {
|
||||
// Do not approve an attempt superseded while its jobs/checks were read.
|
||||
const finalRuns = await readRuns();
|
||||
if (JSON.stringify(finalRuns) === JSON.stringify(selected)) return;
|
||||
assessment = { state: 'pending', reason: 'Trusted CI or CodeQL run changed during verification' };
|
||||
}
|
||||
}
|
||||
if (assessment.state === 'failed') throw new Error(assessment.reason);
|
||||
lastReason = assessment.reason;
|
||||
if (attempt < attempts) await client.pause(signal => sleep(delay, signal));
|
||||
}
|
||||
if (assessment.state === 'failed') throw new Error(assessment.reason);
|
||||
if (attempt < attempts) await client.pause(signal => sleep(delay, signal));
|
||||
} catch (error) {
|
||||
if (error instanceof ReleaseGateDeadlineError) {
|
||||
throw new Error(`${error.message}; last pending gate: ${lastReason}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Timed out waiting for successful exact-SHA CI and CodeQL checks');
|
||||
throw new Error(`Timed out waiting for successful exact-SHA CI and CodeQL checks; last pending gate: ${lastReason}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
|
||||
@@ -219,13 +219,19 @@ function fixture() {
|
||||
}));
|
||||
return { runs, checks, jobs, workflows: structuredClone(workflows), repo: { ...repoIdentity } };
|
||||
}
|
||||
function withItem(data, collection, index, changes) {
|
||||
return {
|
||||
...data,
|
||||
[collection]: data[collection].map((item, position) => position === index ? { ...item, ...changes } : item),
|
||||
};
|
||||
}
|
||||
function response(payload, link = null) {
|
||||
return { ok: true, status: 200, headers: { get: () => link }, json: async () => payload };
|
||||
}
|
||||
function fakeApi(data = fixture(), modify = () => null) {
|
||||
const calls = [];
|
||||
let calls = [];
|
||||
const fetchImpl = async (url, options) => {
|
||||
calls.push(url);
|
||||
calls = [...calls, url];
|
||||
const replacement = modify(url, options, calls);
|
||||
if (replacement) return replacement;
|
||||
const pathname = new URL(url).pathname.replace(`/repos/${repository}`, '');
|
||||
@@ -238,7 +244,7 @@ function fakeApi(data = fixture(), modify = () => null) {
|
||||
if (pathname === `/git/tags/${tagSha}`) return response({ sha: tagSha, tag: 'v1.2.3', object: { type: 'commit', sha: releaseSha }, verification: { verified: true, reason: 'valid' } });
|
||||
throw new Error(`Unexpected synthetic API path ${pathname}`);
|
||||
};
|
||||
return { fetchImpl, calls };
|
||||
return { fetchImpl, get calls() { return calls; } };
|
||||
}
|
||||
const once = { attempts: 1, timeoutMs: 1000, requestTimeoutMs: 100 };
|
||||
async function gates(data, modify) {
|
||||
@@ -263,76 +269,79 @@ test('pre-install verifier loads with built-ins only and still rejects malformed
|
||||
});
|
||||
|
||||
test('complete trusted CI and default CodeQL categories pass without display-name trust', async () => {
|
||||
const data = fixture();
|
||||
data.runs[0].name = 'Renamed CI';
|
||||
data.runs[1].name = 'Push on main';
|
||||
const data = { ...fixture(), runs: fixture().runs.map((run, index) => ({ ...run, name: index ? 'Push on main' : 'Renamed CI' })) };
|
||||
const calls = await gates(data);
|
||||
assert.ok(calls.some(url => url.includes('/attempts/1/jobs')));
|
||||
assert.ok(calls.some(url => url.includes('/check-suites/101/check-runs')));
|
||||
});
|
||||
|
||||
for (const [name, mutate] of [
|
||||
['impostor CI workflow', d => { d.runs[0].workflow_id = 999; d.runs[0].name = 'CI'; }],
|
||||
['wrong workflow path', d => { d.runs[0].path = '.github/workflows/spoof.yml'; }],
|
||||
['wrong CI event', d => { d.runs[0].event = 'pull_request'; }],
|
||||
['wrong main branch', d => { d.runs[0].head_branch = 'release/x'; }],
|
||||
['wrong run SHA', d => { d.runs[0].head_sha = 'c'.repeat(40); }],
|
||||
['foreign run repository', d => { d.runs[0].repository.id = 1; }],
|
||||
['foreign head repository', d => { d.runs[0].head_repository.full_name = 'impostor/ECC'; }],
|
||||
['untrusted check app', d => { d.checks[0].app.id = 1; }],
|
||||
['wrong app slug', d => { d.checks[0].app.slug = 'spoof'; }],
|
||||
['wrong check suite', d => { d.checks[0].check_suite.id = 999; }],
|
||||
['wrong check SHA', d => { d.checks[0].head_sha = 'c'.repeat(40); }],
|
||||
['missing required category', d => { d.jobs.pop(); }],
|
||||
['missing bound check', d => { d.checks.pop(); }],
|
||||
['new pending category', d => { d.jobs.push({ ...d.jobs[0], id: 999, name: 'Analyze (ruby)', status: 'queued', conclusion: null }); }],
|
||||
['ambiguous category jobs', d => { d.jobs.push({ ...d.jobs[0], id: 999 }); }],
|
||||
['wrong attempt job', d => { d.jobs[0].run_attempt = 2; }],
|
||||
['wrong run job', d => { d.jobs[0].run_id = 90; }],
|
||||
['wrong job branch', d => { d.jobs[0].head_branch = 'feature'; }],
|
||||
['foreign check URL', d => { d.jobs[0].check_run_url = 'https://api.github.com/repos/spoof/ECC/check-runs/200'; }],
|
||||
['job name does not match bound check', d => { d.checks[0].name = 'CodeQL'; }],
|
||||
['ambiguous workflow metadata', d => { d.workflows.push({ ...d.workflows[0], id: 999 }); }],
|
||||
['inactive trusted workflow', d => { d.workflows[0].state = 'disabled_manually'; }],
|
||||
]) {
|
||||
const rejectedFixtures = [
|
||||
['impostor CI workflow', d => withItem(d, 'runs', 0, { workflow_id: 999, name: 'CI' })],
|
||||
['wrong workflow path', d => withItem(d, 'runs', 0, { path: '.github/workflows/spoof.yml' })],
|
||||
['wrong CI event', d => withItem(d, 'runs', 0, { event: 'pull_request' })],
|
||||
['wrong main branch', d => withItem(d, 'runs', 0, { head_branch: 'release/x' })],
|
||||
['wrong run SHA', d => withItem(d, 'runs', 0, { head_sha: 'c'.repeat(40) })],
|
||||
['foreign run repository', d => withItem(d, 'runs', 0, { repository: { ...d.runs[0].repository, id: 1 } })],
|
||||
['foreign head repository', d => withItem(d, 'runs', 0, { head_repository: { ...d.runs[0].head_repository, full_name: 'impostor/ECC' } })],
|
||||
['untrusted check app', d => withItem(d, 'checks', 0, { app: { ...d.checks[0].app, id: 1 } })],
|
||||
['wrong app slug', d => withItem(d, 'checks', 0, { app: { ...d.checks[0].app, slug: 'spoof' } })],
|
||||
['wrong check suite', d => withItem(d, 'checks', 0, { check_suite: { id: 999 } })],
|
||||
['wrong check SHA', d => withItem(d, 'checks', 0, { head_sha: 'c'.repeat(40) })],
|
||||
['missing required category', d => ({ ...d, jobs: d.jobs.slice(0, -1) })],
|
||||
['missing bound check', d => ({ ...d, checks: d.checks.slice(0, -1) })],
|
||||
['new pending category', d => ({ ...d, jobs: [...d.jobs, { ...d.jobs[0], id: 999, name: 'Analyze (ruby)', status: 'queued', conclusion: null }] })],
|
||||
['ambiguous category jobs', d => ({ ...d, jobs: [...d.jobs, { ...d.jobs[0], id: 999 }] })],
|
||||
['wrong attempt job', d => withItem(d, 'jobs', 0, { run_attempt: 2 })],
|
||||
['wrong run job', d => withItem(d, 'jobs', 0, { run_id: 90 })],
|
||||
['wrong job branch', d => withItem(d, 'jobs', 0, { head_branch: 'feature' })],
|
||||
['foreign check URL', d => withItem(d, 'jobs', 0, { check_run_url: 'https://api.github.com/repos/spoof/ECC/check-runs/200' })],
|
||||
['job name does not match bound check', d => withItem(d, 'checks', 0, { name: 'CodeQL' })],
|
||||
['ambiguous workflow metadata', d => ({ ...d, workflows: [...d.workflows, { ...d.workflows[0], id: 999 }] })],
|
||||
['inactive trusted workflow', d => withItem(d, 'workflows', 0, { state: 'disabled_manually' })],
|
||||
];
|
||||
for (const [name, change] of rejectedFixtures) {
|
||||
test(`release gate rejects ${name}`, async () => {
|
||||
const data = fixture(); mutate(data);
|
||||
const original = fixture();
|
||||
const snapshot = structuredClone(original);
|
||||
const data = change(original);
|
||||
assert.deepStrictEqual(original, snapshot, 'fixture variants must leave their input unchanged');
|
||||
await assert.rejects(gates(data));
|
||||
});
|
||||
}
|
||||
|
||||
for (const conclusion of ['failure', 'cancelled', 'skipped', 'neutral', 'timed_out', 'action_required']) {
|
||||
test(`required trusted check ${conclusion} fails despite newer spoof success`, async () => {
|
||||
const data = fixture();
|
||||
data.checks[0].conclusion = conclusion;
|
||||
data.checks.push({ ...data.checks[0], id: 999, conclusion: 'success', app: { id: 1, slug: 'spoof' } });
|
||||
const base = withItem(fixture(), 'checks', 0, { conclusion });
|
||||
const data = { ...base, checks: [...base.checks, { ...base.checks[0], id: 999, conclusion: 'success', app: { id: 1, slug: 'spoof' } }] };
|
||||
await assert.rejects(gates(data), /concluded/);
|
||||
});
|
||||
}
|
||||
|
||||
test('newer display-name impostor cannot replace a failed trusted CI run', async () => {
|
||||
const data = fixture(); data.runs[0].conclusion = 'failure';
|
||||
data.runs.push({ ...data.runs[0], id: 999, workflow_id: 999, name: 'CI', conclusion: 'success' });
|
||||
const base = withItem(fixture(), 'runs', 0, { conclusion: 'failure' });
|
||||
const data = { ...base, runs: [...base.runs, { ...base.runs[0], id: 999, workflow_id: 999, name: 'CI', conclusion: 'success' }] };
|
||||
await assert.rejects(gates(data), /CI concluded failure/);
|
||||
});
|
||||
|
||||
test('workflow IDs come from exact-path metadata and unrelated spoof results do not gate', async () => {
|
||||
const data = fixture();
|
||||
data.workflows.forEach((workflow, index) => { workflow.id = 900 + index; data.runs[index].workflow_id = workflow.id; });
|
||||
data.runs.push({ ...data.runs[0], id: 999, workflow_id: 999, name: 'CI', conclusion: 'failure' });
|
||||
data.checks.push({ ...data.checks[0], id: 999, conclusion: 'failure', app: { id: 1, slug: 'spoof' } });
|
||||
const base = fixture();
|
||||
const data = {
|
||||
...base,
|
||||
workflows: base.workflows.map((workflow, index) => ({ ...workflow, id: 900 + index })),
|
||||
runs: [...base.runs.map((run, index) => ({ ...run, workflow_id: 900 + index })), { ...base.runs[0], id: 999, workflow_id: 999, name: 'CI', conclusion: 'failure' }],
|
||||
checks: [...base.checks, { ...base.checks[0], id: 999, conclusion: 'failure', app: { id: 1, slug: 'spoof' } }],
|
||||
};
|
||||
await gates(data);
|
||||
});
|
||||
|
||||
test('newer trusted pending run does not reuse older success', async () => {
|
||||
const data = fixture();
|
||||
data.runs.push({ ...data.runs[1], id: 12, status: 'queued', conclusion: null });
|
||||
const base = fixture();
|
||||
const data = { ...base, runs: [...base.runs, { ...base.runs[1], id: 12, status: 'queued', conclusion: null }] };
|
||||
await assert.rejects(gates(data), /Timed out|deadline/);
|
||||
});
|
||||
|
||||
test('newer trusted attempt cannot reuse previous attempt jobs', async () => {
|
||||
const data = fixture();
|
||||
data.runs[1].run_attempt = 2;
|
||||
const data = withItem(fixture(), 'runs', 1, { run_attempt: 2 });
|
||||
await assert.rejects(gates(data, url => url.includes('/attempts/2/jobs') ? response({ total_count: 2, jobs: data.jobs.slice(0, 2).map(job => ({ ...job, run_attempt: 2 })) }) : null));
|
||||
});
|
||||
|
||||
@@ -347,7 +356,7 @@ test('a new trusted run appearing during collection fails readiness', async () =
|
||||
});
|
||||
|
||||
test('failure on a later check page cannot be hidden', async () => {
|
||||
const data = fixture(); data.checks[2].conclusion = 'failure';
|
||||
const data = withItem(fixture(), 'checks', 2, { conclusion: 'failure' });
|
||||
await assert.rejects(gates(data, url => {
|
||||
if (!url.includes('/check-runs?')) return null;
|
||||
return url.includes('page=2')
|
||||
@@ -356,6 +365,68 @@ test('failure on a later check page cannot be hidden', async () => {
|
||||
}), /concluded failure/);
|
||||
});
|
||||
|
||||
// Pending diagnostics must identify the blocked gate without changing its decision.
|
||||
for (const [name, change, reason] of [
|
||||
['missing CI', d => ({ ...d, runs: d.runs.slice(1) }), 'CI run not found for release SHA'],
|
||||
['missing CodeQL', d => ({ ...d, runs: d.runs.slice(0, 1) }), 'CodeQL run not found for release SHA'],
|
||||
['running CI', d => withItem(d, 'runs', 0, { status: 'in_progress', conclusion: null }), 'CI is in_progress'],
|
||||
['queued CodeQL', d => withItem(d, 'runs', 1, { status: 'queued', conclusion: null }), 'CodeQL is queued'],
|
||||
['missing job', d => ({ ...d, jobs: d.jobs.slice(0, 2) }), 'CodeQL job "Analyze (python)" missing from selected attempt'],
|
||||
['missing check', d => ({ ...d, checks: d.checks.slice(0, 2) }), 'CodeQL check "Analyze (python)" missing or not bound to trusted job'],
|
||||
['untrusted check', d => withItem(d, 'checks', 0, { app: { id: 1, slug: 'spoof' } }), 'CodeQL check "Analyze (actions)" missing or not bound to trusted job'],
|
||||
['running job', d => withItem(d, 'jobs', 0, { status: 'in_progress', conclusion: null }), 'CodeQL job "Analyze (actions)" is in_progress'],
|
||||
['queued check', d => withItem(d, 'checks', 0, { status: 'queued', conclusion: null }), 'CodeQL check "Analyze (actions)" is queued'],
|
||||
]) {
|
||||
test(`attempt exhaustion diagnoses ${name}`, async () => {
|
||||
const original = fixture();
|
||||
const snapshot = structuredClone(original);
|
||||
await assert.rejects(gates(change(original)), error => {
|
||||
assert.match(error.message, /Timed out/);
|
||||
assert.ok(error.message.endsWith(`last pending gate: ${reason}`), error.message);
|
||||
assert.ok(!error.message.includes(inputs.token));
|
||||
return true;
|
||||
});
|
||||
assert.deepStrictEqual(original, snapshot);
|
||||
});
|
||||
}
|
||||
|
||||
test('attempt exhaustion reports a superseded trusted run', async () => {
|
||||
const data = fixture(); let reads = 0;
|
||||
await assert.rejects(gates(data, url => url.includes('/actions/runs?') && ++reads === 2
|
||||
? response({ total_count: 3, workflow_runs: [...data.runs, { ...data.runs[1], id: 12, status: 'queued', conclusion: null }] })
|
||||
: null), /last pending gate: Trusted CI or CodeQL run changed during verification$/);
|
||||
});
|
||||
|
||||
test('global deadline reports the latest pending gate and preserves its cause', async () => {
|
||||
const data = withItem(fixture(), 'runs', 0, { status: 'queued', conclusion: null });
|
||||
let reads = 0; let clock = 0;
|
||||
const api = fakeApi(data, url => url.includes('/actions/runs?') && ++reads > 1
|
||||
? response({ total_count: 1, workflow_runs: [{ ...data.runs[0], status: 'completed', conclusion: 'success' }] })
|
||||
: null);
|
||||
await assert.rejects(waitForExactShaGates(inputs, api.fetchImpl, async delay => { clock += delay; }, {
|
||||
attempts: 3, delayMs: 10, timeoutMs: 15, requestTimeoutMs: 10, now: () => clock,
|
||||
}), error => {
|
||||
assert.match(error.message, /deadline exceeded; last pending gate: CodeQL run not found for release SHA$/);
|
||||
assert.match(error.cause.message, /deadline exceeded$/);
|
||||
return true;
|
||||
});
|
||||
assert.strictEqual(reads, 2);
|
||||
});
|
||||
|
||||
test('request deadline before assessment reports that no gate was assessed', async () => {
|
||||
let signal;
|
||||
await assert.rejects(waitForExactShaGates(inputs, async (_url, options) => {
|
||||
signal = options.signal;
|
||||
return { ...response(null), json: () => new Promise(() => {}) };
|
||||
}, async () => {}, { ...once, requestTimeoutMs: 5 }), /deadline exceeded; last pending gate: no gate assessment completed$/);
|
||||
assert.strictEqual(signal.aborted, true);
|
||||
});
|
||||
|
||||
test('non-deadline errors retain identity even when their message mentions deadline', async () => {
|
||||
const failure = new Error('synthetic deadline text is not a timeout classification');
|
||||
await assert.rejects(gates(fixture(), () => { throw failure; }), error => error === failure);
|
||||
});
|
||||
|
||||
test('API requests reject redirects and carry abort signals without dependency loading', async () => {
|
||||
const api = fakeApi(fixture(), (_url, options) => {
|
||||
assert.strictEqual(options.redirect, 'error');
|
||||
@@ -378,38 +449,44 @@ test('release input and retry bounds reject unsafe or unbounded values', () => {
|
||||
});
|
||||
|
||||
test('an aborted global deadline covers a stalled retry sleep', async () => {
|
||||
const data = fixture(); data.runs[0].status = 'queued'; data.runs[0].conclusion = null;
|
||||
const data = withItem(fixture(), 'runs', 0, { status: 'queued', conclusion: null });
|
||||
let signal;
|
||||
await assert.rejects(waitForExactShaGates(inputs, fakeApi(data).fetchImpl, (_delay, provided) => {
|
||||
signal = provided;
|
||||
assert.ok(signal instanceof AbortSignal, 'abort signal required');
|
||||
return new Promise(() => {});
|
||||
}, { attempts: 2, timeoutMs: 20, requestTimeoutMs: 10 }), /deadline/);
|
||||
}, { attempts: 2, timeoutMs: 20, requestTimeoutMs: 10 }), /deadline exceeded; last pending gate: CI is queued$/);
|
||||
assert.strictEqual(signal.aborted, true);
|
||||
});
|
||||
|
||||
test('signed annotated tag binds full ref, object SHA, name and direct commit', async () => {
|
||||
assert.strictEqual(await verifySignedAnnotatedTag(inputs, fakeApi().fetchImpl), tagSha);
|
||||
});
|
||||
for (const [name, pathPart, mutate] of [
|
||||
['different ref', '/git/ref/', p => { p.ref = 'refs/tags/v0.0.0'; }],
|
||||
['lightweight tag', '/git/ref/', p => { p.object.type = 'commit'; }],
|
||||
['malformed object SHA', '/git/ref/', p => { p.object.sha = 'bad'; }],
|
||||
['wrong signed name', '/git/tags/', p => { p.tag = 'v0.0.0'; }],
|
||||
['wrong returned object SHA', '/git/tags/', p => { p.sha = 'c'.repeat(40); }],
|
||||
['unverified signature', '/git/tags/', p => { p.verification.verified = false; }],
|
||||
['invalid verification reason', '/git/tags/', p => { p.verification.reason = 'unsigned'; }],
|
||||
['nested tag', '/git/tags/', p => { p.object.type = 'tag'; }],
|
||||
['wrong target commit', '/git/tags/', p => { p.object.sha = 'c'.repeat(40); }],
|
||||
for (const [name, pathPart, change] of [
|
||||
['different ref', '/git/ref/', p => ({ ...p, ref: 'refs/tags/v0.0.0' })],
|
||||
['lightweight tag', '/git/ref/', p => ({ ...p, object: { ...p.object, type: 'commit' } })],
|
||||
['malformed object SHA', '/git/ref/', p => ({ ...p, object: { ...p.object, sha: 'bad' } })],
|
||||
['wrong signed name', '/git/tags/', p => ({ ...p, tag: 'v0.0.0' })],
|
||||
['wrong returned object SHA', '/git/tags/', p => ({ ...p, sha: 'c'.repeat(40) })],
|
||||
['unverified signature', '/git/tags/', p => ({ ...p, verification: { ...p.verification, verified: false } })],
|
||||
['invalid verification reason', '/git/tags/', p => ({ ...p, verification: { ...p.verification, reason: 'unsigned' } })],
|
||||
['nested tag', '/git/tags/', p => ({ ...p, object: { ...p.object, type: 'tag' } })],
|
||||
['wrong target commit', '/git/tags/', p => ({ ...p, object: { ...p.object, sha: 'c'.repeat(40) } })],
|
||||
]) {
|
||||
test(`signed tag rejects ${name}`, async () => {
|
||||
const base = fakeApi();
|
||||
let observed = [];
|
||||
await assert.rejects(verifySignedAnnotatedTag(inputs, async (url, options) => {
|
||||
const result = await base.fetchImpl(url, options);
|
||||
const payload = await result.json();
|
||||
if (url.includes(pathPart)) mutate(payload);
|
||||
return response(payload);
|
||||
const snapshot = structuredClone(payload);
|
||||
const changed = url.includes(pathPart) ? change(payload) : payload;
|
||||
observed = [...observed, { payload, snapshot }];
|
||||
return response(changed);
|
||||
}));
|
||||
for (const { payload, snapshot } of observed) {
|
||||
assert.deepStrictEqual(payload, snapshot, 'tag variants must leave their input unchanged');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -488,7 +565,7 @@ test('stalled headers and response bodies are aborted by the request deadline',
|
||||
});
|
||||
|
||||
test('global deadline includes retries and prevents further requests', async () => {
|
||||
const data = fixture(); data.runs[0].status = 'queued'; data.runs[0].conclusion = null;
|
||||
const data = withItem(fixture(), 'runs', 0, { status: 'queued', conclusion: null });
|
||||
let clock = 0; let sleeps = 0;
|
||||
await assert.rejects(waitForExactShaGates(inputs, fakeApi(data).fetchImpl, async delay => { clock += delay; sleeps += 1; }, {
|
||||
attempts: 5, delayMs: 10, timeoutMs: 15, requestTimeoutMs: 10, now: () => clock,
|
||||
|
||||
Reference in New Issue
Block a user