mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
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:
1 parent
7c8b53fb72
commit
c6326047bb
3 files changed
+38
-5
No files matched your search
+11
-3
@@ -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; }
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user