ci-audit: the cost note names minutes on runners with no price row

They counted as $0 inside a total that read as complete; the note now gives their
billed minutes and labels and points at --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:44:30 +00:00
1 parent a68f971360
commit 90c61bc057
2 files changed
+16 -2

No files matched your search

+5 -1
View File
@@ -37,13 +37,17 @@ export function buildReport(data: AuditData, m: Measures, ls: Lesson[], st: Stat
if (!counted) for (const r of data.runs) runsPerWorkflow.set(r.path, (runsPerWorkflow.get(r.path) ?? 0) + 1);
const sampledPerWorkflow = new Map<string, Set<number>>();
for (const j of m.jobs) sampledPerWorkflow.set(j.workflow, (sampledPerWorkflow.get(j.workflow) ?? new Set()).add(j.runId));
const unpriced = new Map<string, number>();
const jobs: JobRow[] = m.groups.map(g => {
const p = priceRunner(g.labels, data.isPrivate, prices, family);
const scale = (runsPerWorkflow.get(g.workflow) ?? 0) / Math.max(1, sampledPerWorkflow.get(g.workflow)?.size ?? 1);
const billed = (g.billedMinutes * scale) / weeks;
if (p.kind === 'unknown') unpriced.set(p.label, (unpriced.get(p.label) ?? 0) + billed);
return { key: g.key, runner: p.label, vcpu: p.vcpu, n: g.n, wait: g.wait, setup: g.setup, run: g.run, billedMinutesPerWeek: Math.round(billed), usdPerMonth: round(billed * WEEKS_PER_MONTH * p.usdPerMinute), price: `${p.usdPerMinute}/min: ${p.source}${p.assumed ? ' (assumed)' : ''}` };
});
const billedMinutesPerWeek = jobs.reduce((a, j) => a + j.billedMinutesPerWeek, 0);
const unpricedMinutes = Math.round([...unpriced.values()].reduce((a, b) => a + b, 0));
const unpricedNote = unpriced.size ? `; ${unpricedMinutes} billed min/week on runners with no price row (${[...unpriced.keys()].slice(0, 5).join(', ')}) count as $0; pass --prices to price them` : '';
const familyNote = !family && jobs.some(j => /^ubicloud/.test(j.runner)) ? '; Ubicloud VM family not read from a job log, so Ubicloud jobs are priced as standard (assumed); --logs 5 reads it, --ubicloud-family sets it' : '';
return {
schema: SCHEMA, ok: true, repo: data.repo, generatedAt: new Date(now).toISOString(),
@@ -52,7 +56,7 @@ export function buildReport(data: AuditData, m: Measures, ls: Lesson[], st: Stat
pushToGreen: { ...m.cohort.total, excluded: m.cohort.excluded, bottlenecks: m.cohort.bottlenecks.slice(0, 5).map(b => ({ workflow: b.workflow, share: round(b.share) })) },
timeToFirstFailure: m.cohort.failures, preJobWait: m.preJob, sampledRuns: m.sampledRuns,
jobs, lessons: ls, fixes: rankFixes(ls),
cost: { billedMinutesPerWeek, usdPerMonth: round(jobs.reduce((a, j) => a + j.usdPerMonth, 0)), note: `billed minutes scaled from the sampled runs to ${counted ? `every run of each workflow since ${counted.since}` : 'every run in the runs list'}; each job rounded up to a whole minute; workflows with no sampled PR or push run are not priced` + familyNote },
cost: { billedMinutesPerWeek, usdPerMonth: round(jobs.reduce((a, j) => a + j.usdPerMonth, 0)), note: `billed minutes scaled from the sampled runs to ${counted ? `every run of each workflow since ${counted.since}` : 'every run in the runs list'}; each job rounded up to a whole minute; workflows with no sampled PR or push run are not priced` + unpricedNote + familyNote },
};
}
+11 -1
View File
@@ -9,7 +9,7 @@ import { concurrencyFlag, lessonRunners } from '../lib/ci-audit/lessons';
import { cohort, measure, phases } from '../lib/ci-audit/measure';
import { priceRunner } from '../lib/ci-audit/prices';
import { ISSUE_MARKER, ISSUE_TITLE, applyIssue, findIssue, planIssue } from '../lib/ci-audit/publish';
import type { AuditReport } from '../lib/ci-audit/report';
import { buildReport, type AuditReport } from '../lib/ci-audit/report';
import { CI_SHAPING, MIN_COHORT, comparable, judgeHead, staticChecks } from '../lib/ci-audit/ship-check';
import { parseWorkflow, readStatic } from '../lib/ci-audit/static';
import { scoreReply, writePlantedRepo } from './helpers/ci-audit-planted';
@@ -217,6 +217,16 @@ describe('cost accounting inputs', () => {
expect((await listJobs(client as never, 'o/n', { id: 9, run_attempt: 1 } as Run))?.length).toBe(101);
});
test('minutes on runners with no price row are disclosed in the cost note, not passed off as $0', () => {
const d = { p50: 60, p90: 60, n: 1 };
const data = { repo: 'o/n', runs: [{ path: 'w.yml' }], partial: [], notes: [], calls: 1, since: '2026-10-24T00:00:00Z', until: '2026-10-31T00:00:00Z', isPrivate: true, runCounts: null };
const m = { windowDays: 7, jobs: [{ workflow: 'w.yml', runId: 1 }], groups: [{ key: 'build', labels: 'ubuntu-latest-16-cores', workflow: 'w.yml', billedMinutes: 70, n: 1, wait: d, setup: d, run: d }],
cohort: { total: { n: 0, p50: null, p90: null }, excluded: {}, bottlenecks: [], failures: { n: 0, p50: null, p90: null } }, preJob: { n: 0, p50: null, p90: null }, sampledRuns: 1 };
const r = buildReport(data as never, m as never, [], null);
expect(r.cost.usdPerMonth).toBe(0);
expect(r.cost.note).toContain('70 billed min/week on runners with no price row (ubuntu-latest-16-cores)');
});
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);