diff --git a/lib/ci-audit/collect.ts b/lib/ci-audit/collect.ts index 7595d0d9f..5c300832b 100644 --- a/lib/ci-audit/collect.ts +++ b/lib/ci-audit/collect.ts @@ -58,9 +58,15 @@ export async function listRuns(client: ApiClient, repo: string, query: string, m } export async function listJobs(client: ApiClient, repo: string, run: Run): Promise { - const res = await client.get(`/repos/${repo}/actions/runs/${run.id}/attempts/${run.run_attempt || 1}/jobs?per_page=100`); - if (res.status !== 200) return null; - return ((res.json as any)?.jobs ?? []) as Job[]; + const jobs: Job[] = []; + for (let page = 1; page <= 10; page++) { + const res = await client.get(`/repos/${repo}/actions/runs/${run.id}/attempts/${run.run_attempt || 1}/jobs?per_page=100${page > 1 ? `&page=${page}` : ''}`); + if (res.status !== 200) return page === 1 ? null : jobs; + const got = ((res.json as any)?.jobs ?? []) as Job[]; + jobs.push(...got); + if (got.length < 100 || jobs.length >= Number((res.json as any)?.total_count ?? 0)) return jobs; + } + return jobs; } /** Newest completed runs per workflow file, the sample whose jobs get fetched. */ @@ -143,6 +149,8 @@ export async function countRuns(client: ApiClient, repo: string, since: string, if (r.status === 200 && Number.isFinite(n)) byPath[p] = n; }); if (got.error) { partial.push(`run counts stopped early (${got.error.code}); cost scaled from the runs list`); return null; } + const missing = paths.filter(p => !(p in byPath)); + if (missing.length) { partial.push(`run counts missing for ${missing.length} workflow(s) (${missing.slice(0, 3).join(', ')}); cost scaled from the runs list`); return null; } if (Object.values(byPath).some(n => n >= 2500)) partial.push('a workflow reached GitHub\'s 2,500 run-count limit; its cost is a floor'); return { since, byPath }; } catch (e) { if (!(e instanceof AuditError)) throw e; partial.push(`run counts skipped (${e.code})`); return null; } diff --git a/lib/ci-audit/prices.ts b/lib/ci-audit/prices.ts index 9a69a578a..edfba9a14 100644 --- a/lib/ci-audit/prices.ts +++ b/lib/ci-audit/prices.ts @@ -13,7 +13,7 @@ export const PRICE_ROWS: [RegExp, PriceRow][] = [ [/^ubuntu-(latest|\d{2}\.\d{2})(-arm)?$/, { usd: 0.006, vcpu: 4, source: `${GH} (Linux 2-core; 4 vCPU on public repos)`, freeOnPublic: true }], [/^ubuntu-.*-slim$/, { usd: 0.002, vcpu: 1, source: `${GH} (Linux 1-core)`, freeOnPublic: true }], [/^windows-(latest|\d{4})(-arm)?$/, { usd: 0.010, vcpu: 4, source: `${GH} (Windows 2-core; 4 vCPU on public repos)`, freeOnPublic: true }], - [/^macos-/, { usd: 0.062, vcpu: 3, source: `${GH} (macOS)`, freeOnPublic: true }], + [/^macos-(latest|\d+)(-arm64)?$/, { usd: 0.062, vcpu: 3, source: `${GH} (macOS)`, freeOnPublic: true }], [/^ubicloud-standard-(\d+)/, { usd: 0.000625, vcpu: null, source: `${CIB} (Ubicloud standard, $0.000625 per vCPU-minute)` }], [/^ubicloud-premium-(\d+)/, { usd: 0.001, vcpu: null, source: `${CIB} (Ubicloud premium, $0.001 per vCPU-minute)` }], [/^ubicloud$/, { usd: 0.00125, vcpu: 2, source: `${CIB} (Ubicloud standard-2)` }], diff --git a/test/ci-audit.test.ts b/test/ci-audit.test.ts index 3f6d46379..d414198a0 100644 --- a/test/ci-audit.test.ts +++ b/test/ci-audit.test.ts @@ -3,7 +3,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { EXIT, main } from '../lib/ci-audit/cli'; -import { preflight, type Run } from '../lib/ci-audit/collect'; +import { countRuns, listJobs, preflight, type Run } from '../lib/ci-audit/collect'; import { Budget } from '../lib/ci-audit/github'; import { concurrencyFlag, lessonRunners } from '../lib/ci-audit/lessons'; import { cohort, measure, phases } from '../lib/ci-audit/measure'; @@ -200,6 +200,31 @@ describe('prices', () => { }); }); +describe('cost accounting inputs', () => { + const json = (status: number, body: unknown) => ({ status, json: body, headers: {} }); + test('a failed run-count request makes the whole count partial instead of zero runs', async () => { + const client = { text: async () => null, get: async (p: string) => + p.includes('/workflows?') ? json(200, { workflows: [{ path: '.github/workflows/a.yml', id: 1 }, { path: '.github/workflows/b.yml', id: 2 }] }) + : p.includes('/workflows/1/') ? json(200, { total_count: 5 }) : json(500, {}) }; + const partial: string[] = []; + expect(await countRuns(client as never, 'o/n', '2026-10-24T00:00:00Z', 'created=>=2026-10-24', ['.github/workflows/a.yml', '.github/workflows/b.yml'], partial)).toBeNull(); + expect(partial.join()).toContain('run counts'); + }); + + test('a matrix past 100 jobs is read to its last page', async () => { + const jobs = (n: number, from: number) => Array.from({ length: n }, (_, i) => ({ id: from + i })); + const client = { text: async () => null, get: async (p: string) => json(200, { total_count: 101, jobs: /[?&]page=2/.test(p) ? jobs(1, 100) : jobs(100, 0) }) }; + expect((await listJobs(client as never, 'o/n', { id: 9, run_attempt: 1 } as Run))?.length).toBe(101); + }); + + test('larger macOS runners are not priced as free standard runners', () => { + expect(priceRunner('macos-14', false).usdPerMinute).toBe(0); + const xl = priceRunner('macos-14-xlarge', false); + expect(xl.kind).not.toBe('github-hosted'); + expect(xl.assumed).toBe(true); + }); +}); + describe('ship-check static rules', () => { test('CI-shaping files include workflows, package.json, hook managers and linter configs, not ordinary source', () => { for (const f of ['.github/workflows/ci.yml', 'package.json', 'web/package.json', 'bunfig.toml', 'tsconfig.json', '.husky/pre-push', 'lefthook.yml', 'biome.json', 'vitest.config.ts', '.gstack/pregate.json']) expect(CI_SHAPING.test(f)).toBe(true);