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
Read the narration
- 0:00 The build bill moved.
- 0:01 The invoice names a total, never the job.
- 0:06 So measure each job, and round it up to the minute the way GitHub bills.
- 0:13 A matrix collapses into one row.
- 0:15 Eight shards read as one job, so the ranking stops hiding the dearest thing here.
- 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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
| Setting | Where | A sensible choice | Why it matters |
|---|---|---|---|
days | the Window select on the tab, or the days query parameter | 30 days (the default) | Bounded to 1 to 90 by the route. Past 90 GitHub has usually aged the runs out of the API. |
max_runs | the Sample select on the tab, or the max_runs query parameter | 150 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_TOKEN | the aggregator's environment | a token that can read Actions on the repository | The 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 names | the aggregator's environment | leave unset unless an older install already sets one | Fallbacks read after the stored token, kept so an install that predates the connect box keeps working. |
the stored github token | the tab's connect box, stored in provider_credentials in the local database | paste once; it is validated before any write | An 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_SECRET | the server's environment | configure it only when the server binds a public interface | Storing 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_MIN | lib/github-actions-billing.mjs, frozen in code | not a setting: 0.008 Ubuntu, 0.016 Windows, 0.08 macOS, per minute | No control changes the rate table. GitHub can change its published rates, and the modeled figure drifts until the table is edited. |
REPO_DASHBOARD_DATA_DIR | the environment | the default data directory unless you moved it | The database holding the stored token lives there, and the directory is tightened to 0700 before the first write on a POSIX machine. |
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
| Symptom | Likely cause | Next 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.
Related tasks
Maintenance evidence
- Feature id
ci-spend(spine leafci-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