ci-leg-skipping-moves-minutes-it-does-not-remove-them · v1.0.0 · 2026-09-11 · sha256 3d7be73e5006e142

ci-leg-skipping-moves-minutes-it-does-not-remove-them v1.0.0A

Immutable. This exact content is served forever at /api/v1/blob/3d7be73e5006e142.

---
name: ci-leg-skipping-moves-minutes-it-does-not-remove-them
description: |
  You are about to propose, price or approve a CI cost saving that works by
  making a job or matrix leg SKIP on pull requests — a path filter, a safe
  list, a changed-files gate, an exemption list. Check first whether the
  pipeline runs a complement or backstop on merge, on push, or nightly:
  many do, and then the legs a pull request skipped are exactly the legs some
  later run executes, so a branch that goes green once and merges bills the
  same total either way and the wall clock does not move at all. Use when
  pricing a filter change, when a percentage saving is quoted with no basis,
  or when choosing between filtering legs and batching pull requests. The
  question that separates money from bookkeeping is whether the change
  removes RUNS or merely moves LEGS.
version: 1.0.0
date: 2026-09-11
author: wan-huiyan
disable-model-invocation: true
---
# Skipping a CI Leg Moves Its Minutes, It Does Not Delete Them

## Problem

The bill is too high, so someone proposes the obvious fix: this job does not
need to run on this kind of change, so filter it out and save its minutes.
The reasoning is clean, the change is small, and it is usually wrong.

**A leg removed from the pull-request run is not a leg removed from the
bill if the pipeline runs it somewhere else.** Many pipelines deliberately
carry a backstop: the merge or push run executes the COMPLEMENT of what the
pull-request run skipped, precisely so that a wrong filter cannot make a leg
stop running anywhere. Under that design the minutes are redistributed, not
deleted.

**For a branch that is made ready once and merges, the total is unchanged.**
The saving exists only from the SECOND ready run onward, and its size is:

```
saving = (ready runs − 1) × the skipped legs' minutes
```

So a change that looks like "we no longer pay for this leg" is really "we pay
for this leg once per merge instead of once per ready run", and on a branch
that lands first time those are the same number.

**And on wall clock it usually saves nothing at all.** Matrix legs run in
parallel, so removing a leg that was never the long pole changes the pull
request's wall clock by zero — while making the merge run longer. Wall-clock
minutes and billed minutes are different quantities with similar digits, and
quoting one while meaning the other is how a saving gets approved twice.

## Context / Trigger Conditions

You are in the right place if any of these is true:

- Someone proposes a path filter, safe list, changed-files gate or exemption
  list whose stated benefit is the removed job's minutes
- A saving is quoted as a percentage or a minute count with no basis named
- The pipeline has a merge, push, nightly or merge-queue run whose job set is
  derived from what the pull-request run did — a complement, a backstop, an
  unfiltered sweep
- You are choosing between two cost proposals and one of them removes whole
  runs (batching, fewer ready flips, cancelling superseded runs) while the
  other rearranges legs within a run

**The reasoning this retires**, in its exact intuitive form: *"this leg does
not need to run here, so we save its minutes."* Whether that is true is a
property of the whole pipeline, never of the leg.

## Solution

### Step 1 — Find out where the leg still runs

Before pricing anything, enumerate every trigger that can execute the leg you
are about to filter: pull request, push to the default branch, merge queue,
scheduled run, manual dispatch. Read the workflow rather than remembering it.

```bash
grep -n "^on:" -A 20 .github/workflows/<workflow>.yml
grep -rn "complement\|exempt\|backstop\|schedule:" .github/workflows/ .github/scripts/
```

A leg named in more than one trigger has not been removed from the bill by a
pull-request filter. It has been moved.

### Step 2 — Price it with the arithmetic above, per branch

You need three numbers, and all three are per branch rather than global:

1. **Ready runs to land** — how many times this branch actually fired the
   full matrix. Read it from the API, do not assume one.
2. **The skipped legs' billed minutes** — per job, rounded UP to the whole
   minute if your provider bills that way, taken from the same runs.
3. **Whether the merge or backstop run then executes them.** If yes, subtract
   one run's worth: that is the leg coming back.

Then `(ready runs − 1) × skipped minutes` is the saving for that branch. Do
this over a real sample of branches, not a representative one.

### Step 3 — Report the zeros, not just the mean

A mean over branches hides the shape. Branches that land on the first ready
run save EXACTLY ZERO, and there are usually more of them than expected. A
saving quoted as an average, on a distribution where half the cases are zero,
is a true number that misleads whoever is spending the money.

### Step 4 — Name the basis in the same sentence as the figure

Write "billed minutes" or "wall clock" beside every number. They diverge here
by design: this class of change can move billed minutes while leaving wall
clock untouched, and a reader who assumes the other basis will conclude the
pipeline got faster when it did not.

### Step 5 — Ask the question that actually separates the two kinds

> **Does this proposal remove RUNS, or does it merely move LEGS?**

- **Removing runs is money.** Landing several small pull requests in ONE run
  instead of N. Not flipping a branch ready until it is actually next to
  merge. Letting a superseded run be cancelled instead of updating a branch
  mid-run. Fixing whatever makes a branch take several attempts to land.
- **Moving legs is bookkeeping.** Path filters, safe lists, exemptions,
  matrix trimming — real work, sometimes worth doing for wall clock or for
  queue pressure, but do not book it as savings without Step 2.

**Where the money usually is:** the number of runs it takes to land one
change. If that average is well above 1, it dominates every leg-level saving,
because each extra run pays for the WHOLE matrix rather than for one leg.

### Step 6 — Keep the backstop honest if you do filter

A complement or backstop is what makes a wrong filter safe, so do not weaken
it while chasing its minutes. If a leg's only remaining home is the merge run
or the nightly sweep, that run is now load-bearing rather than
belt-and-braces: losing it stops the leg running anywhere at all, silently.

## Verification

A correctly priced proposal states, for a named sample of branches:

```
sample: 6 branches, real merged file lists, ready-run counts from the API
basis:  BILLED minutes, each job rounded up to the whole minute

before 270 billed minutes   after 254   saved 16 across the sample
per branch 45.0 -> 42.3 billed minutes, about 6%
3 of the 6 save EXACTLY ZERO (single ready run)
wall clock: unchanged — the removed legs were never the long pole
```

If the write-up has a percentage but no basis, no sample size, and no mention
of the zero cases, it has not been measured — it has been reasoned.

## Example

*Worked example from a route-generation repository whose CI bill was the
subject of an owner ruling. The pipeline's own script names are that repo's;
the pattern is not.*

That repository's merge run passes a `--complement` flag to the script that
decides which legs to run: on a push to the default branch it executes exactly
the legs a pull-request run over the same diff would have SKIPPED, and nothing
else. That design exists so a wrong safe list cannot silently stop a leg
running, and it is the reason a pull-request filter there moves minutes rather
than deleting them.

A proposal to make two legs skip on a class of pull requests was priced over
six real branches, using their actual merged file lists, their actual ready
run counts from the API, and per-leg billed minutes from those same runs:

| | billed minutes |
|---|---|
| before | 270 across six branches |
| after | 254 |
| saved | 16, about 6% |
| per branch | 45.0 → 42.3 |
| branches saving zero | **3 of 6** |

**On wall clock it saved nothing.** The matrix's long pole was a different leg
at a median 604 s; the two candidates for removal ran 185 s and 136 s, well
inside it. Removing them changed the pull request's wall clock by zero and
made the merge run longer.

**The contrast that settled where to spend the effort.** In the same
repository a pull request averaged **3.7 runs to land one change**, and four
small pull requests landed together in a single run on 2026-09-11 saved
roughly **90 billed minutes** — several times the six-branch total above, from
one act of batching. Batching deletes runs. Filtering redistributes legs.

## Notes

- **This is not an argument against filters.** A safe list that keeps a slow
  leg off unrelated pull requests can still be worth having for queue pressure
  and for feedback time. The point is to book it under the right heading and
  not to spend a decision-maker's trust on a number that is mostly zeros.
- **The same shape appears one layer up.** Moving a leg from the pull request
  to the merge run, and then from the merge run to a nightly sweep, each time
  looks like a saving and each time is a relocation — until the last hop,
  where it stops running per change at all and the trade becomes coverage
  timing rather than money.
- **Ask where a leg went, not whether it left.** "This job no longer runs on
  pull requests" is a true sentence that answers a different question from
  "this job costs less per week".
- **When the person reading the number is spending real money**, say plainly
  which cases save nothing. A range with its zeros visible survives contact
  with a sceptical reader; a flattering mean does not.
- **The worked instance of the RIGHT kind of saving** is
  [`merge-queue-thrash-stop-inflow-and-open-prs-as-drafts`](../merge-queue-thrash-stop-inflow-and-open-prs-as-drafts/SKILL.md):
  opening every pull request as a draft and promoting it only when it is next
  to land removes whole RUNS, and measured about 46 seconds against about 16
  minutes for a full one. Read the two together — that skill is what to do,
  this one is why the other lever does not pay.
- **A different ledger with the same trap** is
  [`fan-out-cost-control`](../fan-out-cost-control/SKILL.md), which prices a
  fan-out's token budget rather than CI minutes. Both go wrong the same way: a
  saving that is reasoned from one unit and never measured in the unit that is
  actually billed.
- **The correctness side of the same lever** is
  [`merged-pr-not-deployed-gate-label-missing`](../merged-pr-not-deployed-gate-label-missing/SKILL.md):
  a path filter that quietly excludes your files stops a workflow running at
  all. This skill asks what a filter costs; that one asks what it breaks. Both
  questions are owed before a filter ships.
- **Round up per job if your provider does.** Several sub-minute jobs each
  billed as a whole minute is its own line in the bill, and it is one of the
  few leg-level changes that genuinely removes cost — merging two tiny jobs
  that each pay a rounded minute for seconds of work.

## References

- GitHub docs: [About billing for GitHub Actions](https://docs.github.com/en/billing/managing-billing-for-your-products/about-billing-for-github-actions)
  — minutes are rounded up to the nearest whole minute PER JOB, which is why
  billed minutes and wall-clock minutes diverge.
- GitHub docs: [Using conditions to control job execution](https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution)
  — job-level conditions are how a leg is skipped on one trigger while still
  running on another.
- GitHub docs: [Using concurrency](https://docs.github.com/en/actions/using-jobs/using-concurrency)
  — superseded runs are billed for what they ran before being cancelled, which
  is a run-level cost and therefore the kind worth attacking.