Every finding ciledger emits, what triggers it, and how to fix it.
Configured by id in ciledger.config.json:
{ "rules": { "CL004": "off", "CL007": "warning" } }Settings: "error", "warning", "info", "off".
On severity. error is reserved for findings that cause open-ended, unbounded spend — the ones that produce the bill nobody can explain. Everything else is a warning or an observation, so a default CI gate blocks on runaway cost and nothing else. A tool that fails builds over opinions gets switched off.
A workflow triggered by pull_request with no cancel-in-progress.
Push three commits to a branch in quick succession and you bill three complete runs. The first two are already irrelevant by the time they finish.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueQuantified: the finding reports the full billable cost of one run, because that is what each superseded run wastes.
Only fires on pull-request workflows. Cancelling in-progress runs on main is usually wrong — you want every merge built.
Both push and pull_request fire without a branch filter on push.
Every commit to a branch with an open pull request runs the workflow twice: once for the push, once for the pull-request update. Exactly half of that is waste.
on:
push:
branches: [main] # only the branch you merge into
pull_request:Restricting push to a release branch is the correct pattern and is not flagged.
A job runs a dependency install with no cache step in sight.
Usually the largest single avoidable cost in a workflow, because it is paid by every matrix job on every run. A nine-job matrix downloading node_modules nine times per run, twenty runs a day, is a lot of minutes spent on bytes that have not changed.
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm # one lineRecognises npm, yarn, pnpm, bun, pip, poetry, uv, bundler, go modules, cargo, maven, gradle and composer. Satisfied by actions/cache, Swatinem/rust-cache, or a setup-* action with cache: set — a setup action only caches when explicitly asked to, which is a common oversight.
An expensive workflow (default: over 30 billable minutes) with no path filter.
It runs in full for a typo fix in a README.
on:
pull_request:
paths-ignore: ['**.md', 'docs/**', 'LICENSE']info rather than warning because path filters interact with required status checks: a workflow skipped by a path filter never reports, and a branch protection rule waiting for it will block the merge forever. Use paths-ignore on the whole workflow only when it is not a required check; otherwise filter inside the job with if:.
A job with no timeout-minutes.
GitHub's default is six hours. A job that hangs — a hung test, a prompt waiting on stdin, a network call with no timeout — bills all of it. This is the most common cause of a bill nobody can explain, and it is the one finding here that is genuinely unbounded, which is why it is an error.
jobs:
test:
timeout-minutes: 15Set it a little above the job's normal duration. On a matrix the exposure multiplies: nine jobs at six hours is 54 hours, and on macOS that bills as 540.
A macOS job with no sign of platform-specific work.
macOS bills at ten times the Linux rate. A job that could run on ubuntu-latest and does not is a tenfold overcharge on every run.
The check looks for Xcode, xcodebuild, Swift, iOS/watchOS/tvOS, code signing, notarisation, CocoaPods, Carthage and Fastlane, in the job id, name, step names, uses and run bodies. Deliberately generous: a false negative costs nothing, while telling someone their iOS build does not need macOS would discredit the whole tool.
Quantified: the finding reports the difference between what the job costs on macOS and what it would cost on Linux.
A job expanding to 12 or more instances (configurable).
Not wrong, just easy to lose track of — expansion is multiplicative, and one more value on one axis adds a whole row.
A common structure:
strategy:
matrix:
os: [ubuntu-latest]
node: [20, 22]
include:
# full matrix only on the default branch
- os: ${{ github.ref == 'refs/heads/main' && 'macos-latest' || '' }}or simply a separate scheduled workflow that runs the exhaustive matrix nightly, while pull requests run a representative subset.
fail-fast: false on a matrix of four or more.
When one job fails, every sibling runs to completion and bills in full. That is the right trade-off when you need the whole failure picture at once, and pure waste otherwise. info because it is a judgement call, not a defect.
A runs-on label not in the rate catalogue.
The job is priced as a standard Linux runner, which may be wrong in either direction. Reported so the assumption is visible rather than folded silently into the total.
If it is self-hosted, add self-hosted to its labels and it will be costed as free. If it is a GitHub-hosted label the catalogue does not know, please open an issue — the catalogue is data and easy to update.
A larger runner (ubuntu-latest-8-cores and friends).
These bill from the first minute on every plan and never draw on included minutes — including on public repositories, where standard runners are free. Switching to one for speed turns a free workflow into a paid one, and that transition is invisible in a diff.
Worth confirming the speed is real. A job that is not CPU-bound runs no faster on more cores, and a four-minute job on 8 cores costs the same as a sixteen-minute job on 2.
A schedule trigger firing more often than hourly (configurable).
Scheduled runs bill whether or not anything changed, and nobody sees them in a pull request. */15 * * * * is 96 runs a day, 2,880 a month.
The finding reports the daily cost — in minutes, or in dollars where the workflow uses runners that draw no included minutes.
A needs chain four or more jobs deep.
Serialising does not reduce billed minutes; it only delays feedback. Jobs that do not consume a previous job's output can drop the needs and run in parallel for the same money.
Sometimes the chain is deliberate — a gate that avoids running expensive jobs after a cheap one fails genuinely saves money. info because the tool cannot tell which.
actions/checkout with fetch-depth: 0 and no step that appears to need history.
Full history is cloned on every matrix job. On a large repository that is a substantial download repeated many times per run.
Suppressed when a step looks like it reads history: git log/describe/rev-list/blame, changelog generation, semantic-release, Nx or Turbo affected-graph commands, or coverage tools that diff against the base.
A matrix built from an expression, usually fromJSON of a previous job's output.
It cannot be expanded statically, so the job count for that job — and every total containing it — is a lower bound. Reported so the number is never mistaken for exact.
If the JSON comes from a fixed file rather than genuinely dynamic computation, inlining it makes the cost visible.
A workflow that does not parse.
The jobs it contains are missing from every total, so the reported cost understates the real bill. An error because a silently incomplete cost report is worse than no report.
The parser covers the YAML subset workflows use and refuses anchors, aliases, merge keys and tags rather than mis-parsing them. If a valid workflow is rejected, that is a bug worth reporting.
Per repository:
{ "rules": { "CL004": "off" } }There are no inline suppression comments. Several findings are workflow-level rather than line-level, and a comment on one line could not express which. If a rule is systematically wrong for your setup, switch it off — and please open an issue, since the thresholds are only as good as the workflows they have seen.