Inspiration
Every guide to greener software says the same thing: run your pipelines when the grid is clean, move the compute to the windy hours. I wanted to see how much that was worth, so I measured it on five real CI jobs.
It is worth almost nothing. 93.0 % of a CI job's carbon footprint is not electricity — it is amortised hardware, the server that was built and is wearing out while the job runs. Shifting the job to a clean hour acts on the other 7 %.
That number turned the project around. The lever everyone recommends is the small one. The large one is not running the job at all.
And one more thing decided the shape of the tool: the first version of that measurement was wrong. I had entered an energy figure by hand — 0.0008 kWh, an order of magnitude picked by feel — and it said time-shifting paid ten times more than skipping jobs. The measured energy is a thousand times smaller and the conclusion inverts. A plausible hand-entered number is worth less than no number, because you defend it.
What it does
Carbon Gate generates the pipeline instead of labelling it. Before any job runs, it looks at what changed, decides which jobs are still necessary, and emits a child pipeline containing only those. What is not in it does not exist.
Then it prices the decision under ISO/IEC 21031:2024 (Software Carbon Intensity) and reports what skipping saved — as an interval, with its sources, and with an explicit refusal when the interval contains zero:
1 job(s) skipped. **No net saving is announced**: [-0.311 ; 0.311] gCO2e
contains zero — what the gate saves ([0.0902 ; 0.401]) cannot be told apart
from what it costs ([0.0902 ; 0.401]). The jobs stay skipped: it is the
change that justifies it, not the carbon.
Two rules the tool cannot break:
Correctness decides, carbon measures. A job is never skipped because it
is expensive — only because nothing it depends on changed. There is no
emission threshold at which it becomes reasonable to skip the security scan
of a release, so that job is marked toujours and a test keeps it that way.
Make any job absurdly expensive and the decision does not move an inch.
A gain indistinguishable from zero is not announced. Energy cannot be measured on a shared runner — the counters are not exposed — and published coefficients vary by a factor of three. So every figure is an interval, and the gate counts itself in it: deciding costs machine time like anything else. When what it saves cannot be told apart from what it costs, it says so instead of publishing a number.
Measured on the same pipeline: shifting from 19:00 to 23:00 saves
[0.0000212 ; 0.0000983] gCO2e, where skipping three jobs that did not need
to run saves [0.00351 ; 0.0161] — about 160 times more. Not running
beats waiting, because not running erases the hardware as well.
How we built it
Python 3.12, standard library only, no runtime dependency. 191 tests.
The pipeline. rules: is evaluated at pipeline creation, so a decision
taken during the pipeline can never reach the rules that decide whether jobs
exist. This was not reasoned out in advance — the first version wrote its
decision to a dotenv artifact, the pipeline went green, and the gate had
announced CARBONGATE_VERIFIER_README=false while that very job was running.
A green pipeline can hide that the whole tool is disconnected. So the gate
generates a child pipeline, and the generated file contains only names: the
job bodies live in .gitlab/jobs.yml and are extended, because a generator
that writes shell is a generator you must re-read every run.
Duo Agent Platform. .gitlab/duo/flows/apres-le-code.yaml runs the
post-code loop on merge request creation: read the diff, review correctness,
review security, price the carbon, write the verdict.
Declared Supervised, and that is a correction made against my own
interest. It was aimed at Hands-off until choosing the toolsets posed the
only question that matters — who merges? accept_merge_request exists in
the toolbox, and giving it to the verdict component would have made the
flow fully autonomous in one line. But verdict is an AgentComponent, and
letting a model decide a merge is exactly what this repository spends its
time refusing. No component holds it, and the declared level follows the real
tooling rather than the reverse.
What decides here is not an agent. The pipeline has already settled what runs, with deterministic code that looks at what changed. The agents read, judge and write. None of them can skip a job, and none can silence the security scan.
Challenges we ran into
Four red pipelines, none of them findable locally. The YAML was valid, its tests passed, so did the code's.
python:3.12-slimships without git.subprocess.runraised instead of returning a non-zero code, so the fallback — run everything — was never reached. A gate that breaks must run everything, not make everything disappear.- A broken gate erased the verification. Without the dotenv, guarded jobs were not skipped, they were absent.
- The measurement swallowed the job's output. A tool whose entire argument is that measurement must be visible was hiding the thing it measured.
- The security scan found a real vulnerability on its first readable log —
pip 25.0.1, PYSEC-2026-1795 — in the toolchain, not in this code, which has
no runtime dependencies. We upgraded.
--ignore-vulnwould have made the job green in one line, and a scan you silence the first time it speaks will never be read again.
Interval arithmetic does not forgive a shortcut. The gross saving is the
sum of the skipped jobs, not full − retained. The two are equal in ordinary
arithmetic and not in interval arithmetic: the subtraction forgets that the
jobs common to both totals are the same ones, so their uncertainty adds
instead of cancelling. On a pipeline dominated by one large job it returned
[-3.59 ; 3.60] g where the real saving is a thousandth of a gram and
perfectly certain in its sign. Hiding a real saving is the symmetric fault of
inventing one.
"Nothing changed" and "we don't know what changed" are not the same
thing. The first version signalled the unknown by passing ["*"] as the
file list. But "*" is a filename, and fnmatch("*", "*.py") is false, so
every pattern-matched job was skipped and only the unconditional ones
survived. On a first commit or a shallow clone — two ordinary CI situations —
the whole pipeline vanished in silence, while the code comment claimed the
opposite.
Accomplishments that we're proud of
The tool refuses to flatter itself. It counts its own cost, and when that cost cannot be told apart from what it saves, it publishes the refusal rather than a number. That path is not an edge case bolted on at the end — it has its own tests, and it is the sentence the demo video spends the most time on.
Every published figure is guarded. verifier_readme.py recomputes what
the README claims and fails the pipeline when the text no longer matches the
code. It is the most boring file in the repository and it has caught the most
drift, including its own: it used to watch only one file, so the test count
was wrong in three others.
Nothing on screen is a mock-up. The demo video's console blocks are regenerated from the code by a script; the refusal shown was produced by running the gate, not copied from the documentation.
What we learned
A green pipeline proves that nothing crashed, not that anything worked. The gate announced a job would be skipped while that job was running, and every check was green.
A measurement that is not reproducible is an opinion. Hand-entered numbers survive because you defend them. The whole uncertainty machinery exists because one invented figure nearly published the opposite of the real result.
A tool must be readable by the person it refuses. Carbon Gate's output was French, hard-coded. It took watching the demo video to see the cost: the refusal — the one sentence the whole project rests on — was on screen in a language the reader could not read. The phrases now live in one catalogue, English by default, French preserved, with a test that fails if a sentence is translated on one side only. An honest message nobody can read is not honest yet.
What's next for Carbon Gate
Measure the machine share instead of assuming it. Everything rests on what fraction of a runner a job occupies, and today that is a published range ([0.02 ; 0.25]), not a measurement. The result survives it — hardware dominates from 2.1 % of the machine upward — but an assumption that load bearing deserves a meter.
Hourly grid intensity instead of an annual average. The code for the
"when to run" lever exists (reseau.py); what it lacks is a real regional
feed. It will stay the small lever, and saying so is part of the point.
Make the refusal machine-readable. The verdict is prose today. A pipeline should be able to consume it — a report artifact that states the interval, the coverage, and whether anything is claimed at all — so that a dashboard built on top cannot round a refusal into a number.
What will not change: no carbon threshold will ever skip a job, and no
agent will ever hold accept_merge_request.
Built With
- cloud-carbon-footprint
- gitlab
- gitlab-ci-cd
- gitlab-duo-agent-platform-flows
- iso/iec-21031:2024
- parent-child-pipelines
- pytest
- python-3.12
- standard-library-only
Log in or sign up for Devpost to join the conversation.