Spend · CI spend

Price your build pipeline by job

RepoOps lists a repository's completed workflow runs over a window, measures every job from its own start and finish times, rounds each job up to the whole minute the way GitHub bills, and prices the result from a rate table. The figure is modeled rather than billed, and the tab says so above the number.

For: the engineer who owns a repository's build pipeline, and the person who has to explain the Actions line on the bill

What it does, and why it helps

Every other billing surface in RepoOps reads a provider's cost API. This one cannot. GitHub retired the billing endpoint for a user-owned account, which answers 410, and the per-run timing endpoint, the documented source of billable milliseconds, answers zeros. So CI spend measures instead: it reads each job's start and finish time, rounds that job up to the whole minute, and prices the minutes at 0.008 US dollars for Ubuntu, 0.016 for Windows and 0.08 for macOS. A public repository prices at zero throughout, because standard runners are free there.

The per-job rounding is the part that changes the answer. A run of sixteen jobs where ten finish in seconds still bills ten minutes for those ten, so a tool that averaged seconds across the run would under-report the pipeline and rank the wrong job first. A matrix collapses into one row carrying its job-run count, because eight shards scattered over eight rows hide that the eight together are the most expensive thing in the pipeline. Reading a run costs one extra request for its jobs, so the tab samples the most recent runs, scales the totals to the window, and shows the sample size, the window count and the multiplier beside the number.

The pain. The Actions bill is one number. It names a month, not a job, and nothing in it says which workflow moved. Attributing it by hand means throwaway scripts against the runs API before anyone can answer the question.

The point of view. A cost surface that cannot read a bill should say so above the number, then be useful anyway. Ranking jobs against each other is the question a bill jump raises, and measuring per job, with GitHub's own rounding, is what makes that ranking correct.

What gets easier. Naming the expensive job. The by-job table is sorted by modeled cost, carries job runs and failures per row, and holds a matrix as one row, so the ranking is the ranking rather than an artefact of how the jobs are named.

When it helps. A private repository on GitHub Actions whose build bill moved, read from a machine that can read Actions on that repository.

Its limits. The dollars are modeled, not billed, so they compare jobs and do not reconcile an invoice. Plan-included minutes land on the invoice and are not deducted here. Larger and self-hosted runners are priced at their standard operating-system rate, which under-reports, and the total is marked a floor when one appears. A public repository reads zero throughout. The window caps at 90 days because GitHub ages older runs out of the API.

Understand it in 30 seconds

30.1 s, captions on. Narration: Microsoft Zira Desktop (provisional voice; an approved narration source is pending).Transcript
Read the narration
  1. 0:00 The build bill moved.
  2. 0:01 The invoice names a total, never the job.
  3. 0:06 So measure each job, and round it up to the minute the way GitHub bills.
  4. 0:13 A matrix collapses into one row.
  5. 0:15 Eight shards read as one job, so the ranking stops hiding the dearest thing here.
  6. 0:23 This number is modeled, not billed, and the page says so above it.

Synthetic example. Read the guide

Where to find it

Where to find it

  • Desktop: localhost:4000, then LLM Cost Metrics in the sidebar, then CI spend under All tools, in the Billing guard and provider usage group.
  • Hosted: desktop only.
  • Keyboard: ⌘ K, then type “CI spend”.

When to use it

The bill moved and nobody can name the job

Situation. A private repository's Actions spend is up. The workflow list looks the same as last month and the run count is roughly flat.

What you do. Open CI spend for that repository, leave the window at 30 days and the sample at 150 runs, and read the by-job table before anything else.

What you see. Each row carries the job, its workflow, modeled cost, minutes, job runs and failures, sorted by cost, with a matrix held as one row. The Sample tile reads the sampled count against the runs in the window, with the multiplier beside it.

What it establishes. The ranking of jobs against each other, from a number the page states is modeled. It is not a reconciliation of the invoice, and this guide does not treat it as one.

What a merged pull request costs to ship

Situation. Someone asks what one shipped change costs in build minutes, and a scheduled nightly workflow runs on the same repository.

What you do. Read the Per merged PR tile and the Not attached to a PR tile together. Both are the sample as measured, not scaled.

What you see. Per merged PR divides all spend on pull-request runs over the pull requests that merged in the window, and the tile says that pull requests still open or abandoned raise it. Spend on pushes to the default branch and scheduled runs sits in its own tile.

What it establishes. A per-change figure that never absorbs scheduled spend. With nothing merged in the sample the tile reads n/a and says so, rather than dividing by zero.

Before you start

Supported versions
RepoOps desktop v0.3.1, the release this guide was read against. CI spend is a desktop tab; it reads the GitHub API from this machine at request time.
Where it runs
Local: LLM Cost Metrics, then CI spend under All tools, in the Billing guard and provider usage group. Hosted: repoops.ai carries a desktop-only pointer at /team/ci-spend, under All tools on LLM Cost Metrics, because the read runs on your own machine with your own token and no CI spend row reaches the team database.
Permissions
Storing a token takes a same-origin request from this machine. On a server that binds a public interface it takes an out-of-band operator confirmation instead, and answers 501 until REPOOPS_OPERATOR_CONFIRM_SECRET is configured. Removing a stored token takes the same-origin check only, so an install that stored one before the gate existed can still disconnect it.
Connections
A GitHub token that can read Actions on the repository being priced. A classic PAT needs the repo scope; a fine-grained PAT or GitHub App token needs Actions: Read-only granted on that repository specifically, which repo alone does not imply. Connect it from the tab, or set GITHUB_TOKEN in the aggregator's environment.
Plan
No plan gate. website/lib/pricing-tiers.ts has no CI spend row, and the tab reads nothing from the hosted service.

Configure it

  1. Open the tab for the repository you want to price.

    The nav opens it as /ci-spend.html?repo=id, where the id is the tracked repo id. The route resolves that id to an owner/name through the checkout's own origin remote before it reads anything, and a direct call with an explicit owner/name passes straight through.

  2. Connect GitHub from the box on the tab.

    The card reads Connect GitHub to read CI spend. Paste the token and press Connect. It is checked live against this repository's own Actions endpoint before it is stored, because Actions read is granted per repository and a token that merely authenticates is not enough. A refusal names which of the three things went wrong: the token is dead, the repository is not visible, or the token cannot read Actions there.

  3. Or set GITHUB_TOKEN in the aggregator's environment and restart.

    The environment wins over a token stored by the connect box, so an install that already exports one keeps working untouched. The connected line says the environment set it and offers no disconnect control for it.

  4. Choose the window and the sample.

    Window offers 7, 30 and 90 days; Sample offers 50, 150 and 300 runs. The sample is a rate-limit budget rather than a display cap, because each sampled run costs one extra request for its jobs.

  5. Read the modeled banner before the number.

    These dollars are modeled, not billed sits above the tiles, carrying the API response's own pricing note. It is the one thing a reader must not get wrong about this page.

  6. Check the warnings above the tiles.

    A runner label outside the rate table turns the total into a floor and names the label. Jobs with no usable start or finish time are reported as uncounted rather than counted as free.

SettingWhereA sensible choiceWhy it matters
daysthe Window select on the tab, or the days query parameter30 days (the default)Bounded to 1 to 90 by the route. Past 90 GitHub has usually aged the runs out of the API.
max_runsthe Sample select on the tab, or the max_runs query parameter150 runs (the default)Bounded to 10 to 300. Each sampled run costs one request for its jobs, so a lower sample reads less rate limit and a higher one scales less.
GITHUB_TOKENthe aggregator's environmenta token that can read Actions on the repositoryThe documented precedence: the environment wins over a token stored by the connect box. It is not listed in .env.example.
GH_TOKEN and the two legacy GitHub namesthe aggregator's environmentleave unset unless an older install already sets oneFallbacks read after the stored token, kept so an install that predates the connect box keeps working.
the stored github tokenthe tab's connect box, stored in provider_credentials in the local databasepaste once; it is validated before any writeAn invalid token is never written. The value is never returned by any endpoint; the tab sees a masked hint: the prefix GitHub issued and the last four characters.
REPOOPS_OPERATOR_CONFIRM_SECRETthe server's environmentconfigure it only when the server binds a public interfaceStoring a token answers 501 on a public bind until it is configured, because a same-origin request there is not evidence of a person at the machine.
RUNNER_RATES_USD_PER_MINlib/github-actions-billing.mjs, frozen in codenot a setting: 0.008 Ubuntu, 0.016 Windows, 0.08 macOS, per minuteNo control changes the rate table. GitHub can change its published rates, and the modeled figure drifts until the table is edited.
REPO_DASHBOARD_DATA_DIRthe environmentthe default data directory unless you moved itThe database holding the stored token lives there, and the directory is tightened to 0700 before the first write on a POSIX machine.
ⓘ
To stop or undo
Press disconnect on the connected line. That removes the stored token from this machine. A token set by GITHUB_TOKEN in the environment is untouched by it, and the connected line says so rather than offering a control that would do nothing. With no token the endpoint answers configured false, and the tab shows the connect box and no numbers.

What you should see

A private repository with a token that can read Actions

Configuration. Window 30 days, Sample 150 runs, connected from the tab or GITHUB_TOKEN set.

Expect. The modeled banner, then four tiles: modeled cost for the window with its billed minutes, per merged PR, spend not attached to a PR, and the sample against the runs in the window. Then the by-job and by-workflow tables, both sorted by modeled cost and both carrying failures.

Verify. The line under the tables reads the window, the date it starts from, and the resolved owner/name. The notes list the rounding rule and say the repository is private, so Actions minutes are billable.

A public repository

Configuration. Anything above, against a repository GitHub reports as public.

Expect. Every dollar figure reads zero, because standard runners are free on a public repository and the module prices them at zero rather than pretending otherwise.

Verify. The notes read that the repository is public, so standard runners are free and every figure is zero. Minutes and job counts are still real.

Larger or self-hosted runners in the window

Configuration. A pipeline with a runner label outside the standard set, such as one with a core count in its name.

Expect. A warning above the tiles reading that the total below is a floor, naming each label that was not in the rate table. Those jobs are priced at their operating system's standard rate, which under-reports a larger runner, and GitHub bills nothing at all for self-hosted minutes.

Verify. The named labels are the ones your workflow files set. The label match is anchored at both ends, so a standard label with a suffix is caught rather than being quietly priced as a standard runner.

Data and cost

What is captured
Nothing about the pipeline is written to disk. A read is computed from GitHub at request time and held in memory in a bounded map of at most 32 entries. The browser keeps the last response per repository, window and sample in IndexedDB, falling back to localStorage, so a reopened tab paints before the network answers. The one thing stored is the GitHub token, in the provider_credentials table of the local database in the data directory.
Who can see it
This machine only. No CI spend row reaches the team database, and the hosted page at /team/ci-spend is a desktop-only pointer. The token is never returned by any endpoint and never logged; the tab only ever sees a masked hint.
How long it is kept
The server memo expires ten minutes after a read and holds at most 32 entries. The browser cache holds the last response per key until it is overwritten. The stored token stays until you disconnect. Not provided: a retention knob, because no pipeline data is kept to age out.
What leaves the machine
Requests to the GitHub API from this machine, and nowhere else: the repository record, the completed runs in the window, one request per sampled run for its jobs, and one issue search for the pull requests that merged. Every URL is checked by lib/net/egress-guard.mjs before the fetch, and each request carries a 20-second timeout. Nothing is sent to RepoOps.
What it costs
No model call, so no LLM spend. The cost is GitHub rate limit: one request for the repository, one per page of runs, one per sampled run, and one search. At the default sample that is at most about 150 job requests per cold read, memoized for ten minutes, and the tab re-reads every five minutes only while it is the visible tab.

When the result differs

SymptomLikely causeNext action
The tab shows a connect box and no numbers.Nothing is configured. Neither GITHUB_TOKEN in the environment nor a token stored by the connect box.Paste a token into the box, or set GITHUB_TOKEN in the aggregator's environment and restart.
Connect refuses, saying the token works but cannot read Actions on this repository.GitHub answered 403 to the check against that repository's runs endpoint. A fine-grained PAT or App token without Actions: Read-only on it is the usual reason.Grant Actions: Read-only on that repository, or use a classic PAT with the repo scope, then paste it again.
Connect refuses, saying GitHub did not accept the token.A 401. The token is expired, revoked or mistyped.Issue a new token and paste it.
Connect refuses on the format check.The shape check ran before anything reached GitHub. It accepts the classic, OAuth, App and fine-grained prefixes GitHub issues, and the legacy 40-character hexadecimal form.Paste the token itself rather than a URL, a name or a truncated copy.
Could not read CI spend, with a rate-limit reset time in the hint.The REST rate limit is exhausted, or GitHub applied a secondary limit and sent a retry-after header.Lower the Sample select so the read costs fewer requests, or wait for the time the hint names. The tiles are left alone rather than rendering zeroes.
A warning that some jobs were not counted.Those jobs reported no usable start or finish time: still running, or cancelled before they started.Nothing to fix. They are excluded rather than counted as free, and the count is shown so a partial window is not read as a cheap one.
Per merged PR reads n/a with no merged PR in the sample.Nothing in the sampled runs belongs to a pull request that merged inside the window.Widen the window or raise the sample. The figure stays null rather than becoming a divide by zero.
A 400 naming a repository id that nobody tracks.The id in the URL is not a tracked repo, so it could not be resolved through an origin remote.Open the tab from LLM Cost Metrics, or call the endpoint with an explicit owner/name.
Disable
Disconnect on the tab, or unset GITHUB_TOKEN and restart. With no token the endpoint answers configured false, reads nothing and spends no rate limit.
Roll back
Not provided, and there is nothing to roll back to. Every figure is computed from GitHub at request time; no history is stored, so there is no earlier version of a reading to restore.
Revoke access
Disconnect removes the token row from provider_credentials on this machine. It does not revoke the token at GitHub: do that in your GitHub settings. A token set in the environment is not removed by disconnect, by design, and the connected line says the environment set it.
Delete
No purge route for the numbers, because none are stored. The stored token row is removed by the disconnect control on the tab. The last response cached in the browser sits in this profile's IndexedDB store repoops-tabcache, or under the RepoOps tab-cache prefix in localStorage, and clearing the site's data removes it.

Maintenance evidence

Feature id
ci-spend (spine leaf ci-spend)
Owner
CI spend, the build-pipeline pricing feature (2026-08-30), carrying the F003 and F004 fixes from the 2026-09-05 feature-completeness audit (LDG-0353). Guide: LDG-0717.
Supported product version
RepoOps v0.3.1
Last verified
2026-09-15, read against origin/main at 52366bb6d; labels read from the served tab source (public/ci-spend.html), the rates, bounds and caches read from lib/github-actions-billing.mjs and lib/routes/ci-spend.mjs, and the connect and refusal wording read from lib/provider-credentials.mjs and lib/routes/provider-credentials.mjs.
Example fixtures
No fixture file. The run and job shapes are inline in lib/github-actions-billing.test.mjs (20 cases: the rounding rule, null against zero for an untimed job, the anchored runner label, the matrix collapse, cost per merged pull request, and four network cases with fetchImpl injected) and lib/routes/ci-spend.test.mjs (three cases on resolving a tracked repo id).
Source references
lib/github-actions-billing.mjs, lib/routes/ci-spend.mjs, lib/github/resolve-tracked-slug.mjs, lib/provider-credentials.mjs, lib/routes/provider-credentials.mjs, lib/local-origin.mjs, lib/net/egress-guard.mjs, public/ci-spend.html, public/lib/repoops-tab.js, website/app/team/(home)/ci-spend/page.tsx
Documentation review
Independent review requested on the slice pull request; not yet recorded.
Video review
Narrated story rendered and published 2026-09-26 (render 29fec12531f0, LDG-1014) with the breadcrumb LLM Cost Metrics, which lists the feature under Moved here, checked against main at 7aab4cd82 with LDG-1014 part 1. Six frames, the captions and the transcript were reviewed by the authoring agent, not an independent reviewer; the audio was not listened to by a person. Narration is the provisional Windows voice until LDG-0721.

Last updated