ci-audit: cost accounting fixes from the outside voice

- A failed per-workflow run-count request no longer counts that workflow as
  zero runs: the counts are dropped as partial and cost scales from the runs list.
- Job lists page past 100 jobs (large matrices).
- Only standard macOS labels are free on public repos; larger macOS runners
  fall to the unpriced row (pass --prices).

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 19:26:03 +00:00
1 parent 7c8b53fb72
commit c6326047bb
3 files changed
+38 -5

No files matched your search

+11 -3
View File
@@ -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<Job[] | null> {
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; }
+1 -1
View File
@@ -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)` }],
+26 -1
View File
@@ -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);