diff --git a/lib/ci-audit/report.ts b/lib/ci-audit/report.ts index f65a2a6b6..435fbe3ae 100644 --- a/lib/ci-audit/report.ts +++ b/lib/ci-audit/report.ts @@ -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>(); for (const j of m.jobs) sampledPerWorkflow.set(j.workflow, (sampledPerWorkflow.get(j.workflow) ?? new Set()).add(j.runId)); + const unpriced = new Map(); 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 }, }; } diff --git a/test/ci-audit.test.ts b/test/ci-audit.test.ts index dad0ef5c0..ea097cb50 100644 --- a/test/ci-audit.test.ts +++ b/test/ci-audit.test.ts @@ -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);