A number in CI is the only budget that holds
A budget that lives in a shared understanding has already moved. A baseline works because changing it leaves a reviewable diff with somebody's name on it.
Every size regression I have seen arrived two megabytes at a time, attached to a change that was worth making.
That is why budgets expressed as agreements fail. There is never a moment where somebody proposes the regression.
The case this post is about
One dependency change, and the check that refuses to let it through silently:
$ ./gradlew dependencyBaseline
dependency set changed and the baseline was not updated:
+ com.squareup.okio:okio:3.9.0
- com.squareup.okio:okio:3.6.0
./gradlew dependencyBaseline --regenerate <- leaves a reviewable diff
there is no --skip: a skipped check records nothing.
That is one transitive bump, two megabytes, entirely defensible. So was the one before it, and the three after.
Nobody adds thirty megabytes to an app. This post follows that one bump, because the thing that makes the check work is not the threshold — it is that moving the recorded number is a deliberate act that lands in a diff with somebody's name on it.
The symptom
A quarterly conversation that starts with someone noticing the app is much larger than they remember, followed by an archaeology exercise through sixty merges to find where it went.
The archaeology is unpleasant because there is no culprit. Each commit added a little, each had a reason, and no single one would have been rejected if anybody had been asked. The increase is real and it is nobody's decision — which means there is no decision to revisit, only an accumulation to be unhappy about.
The same shape applies to anything that grows monotonically and is nobody's explicit job: method counts, dependency trees, build times, permissions in a manifest. The common property is that the per-change delta sits below the threshold at which anyone looks.
Why the obvious fix failed
The obvious fix is a documented limit. Keep the app under N megabytes.
A number in a document is checked when somebody remembers to check it, which is after the problem. It also has no defined response — exceeding it is not an event, it is a fact someone notices later, and by then the work that caused it is merged and shipped.
The second attempt is a warning in CI. Better, because it fires at the right moment, and it decays in a specific way: a warning that appears on every build stops being read. Within a few weeks it is part of the scenery, and a genuine jump produces exactly the same yellow text as the steady creep.
The third is a threshold that fails the build — closer, and still wrong if the threshold is a constant someone picked. A constant is either so loose it never fires or so tight it fires on legitimate changes, and the second case teaches people to raise it reflexively.
The actual mechanism
Record the current state as a committed artifact, and fail when reality diverges from the record.
The difference from a threshold is the thing that makes it work. A threshold asks is this under the limit? A baseline asks is this what we last agreed? The second question has a defensible answer for every value, including the ones nobody would have picked deliberately.
Three properties make it hold:
The baseline is in version control. Not in CI config, not in a dashboard. A file that appears in diffs.
Changing it is a deliberate act with output. Regenerating produces a file change that a reviewer sees next to the change that caused it. That adjacency is the entire mechanism — the size increase and the reason for it arrive in the same pull request.
Exceeding it fails, not warns. Because the failure is rare — it only happens when something actually moved — it does not become scenery.
The rarity is doing real work in that last point, and it is worth being explicit about why. A check that fires constantly trains people to clear it without reading; a check that fires twice a quarter still has their attention when it does. So the value of a baseline is partly that it is quiet, and anything that makes it noisy — a threshold set too tight, a metric that fluctuates for reasons nobody controls — destroys the mechanism while leaving the machinery in place.
That rules out some candidates. Build time, for instance, is tempting and wrong: it varies with machine load, cache state and what else is running, so a baseline on it fires for reasons unrelated to any change. The quantities this works for are the ones that only move when somebody moves them.
The fix
The detail that decides whether this survives contact with a deadline: what the escape hatch is.
There must be one. A gate with no way past it on a bad afternoon gets disabled, and a disabled gate is worse than none because everyone believes it is still running.
The right escape hatch is regenerate the baseline, not skip the check. Both unblock the build in about the same time. The difference is that one leaves a diff with a name and a timestamp on it, and the other leaves nothing. Over a year, the first produces a readable history of every time the number moved and why; the second produces the quarterly archaeology this article opened with.
The task, with exactly one way out and no --skip:
abstract class DependencyBaselineTask : DefaultTask() {
@get:InputFile abstract val baseline: RegularFileProperty
@get:Input abstract val resolved: ListProperty<String>
@get:Input abstract val regenerate: Property<Boolean>
@TaskAction
fun check() {
val want = baseline.get().asFile.readLines().filter { it.isNotBlank() }.sorted()
val got = resolved.get().sorted()
if (want == got) return
if (regenerate.get()) {
baseline.get().asFile.writeText(got.joinToString("\n") + "\n")
logger.lifecycle("baseline regenerated — COMMIT IT; the diff is the record")
return
}
throw GradleException(buildString {
appendLine("dependency set changed and the baseline was not updated:")
(got - want.toSet()).forEach { appendLine(" + $it") }
(want - got.toSet()).forEach { appendLine(" - $it") }
appendLine("")
appendLine(" ./gradlew dependencyBaseline --regenerate <- leaves a reviewable diff")
appendLine(" there is no --skip: a skipped check records nothing.")
})
}
}
A transitive bump arriving through an unrelated upgrade:
$ ./gradlew dependencyBaseline
dependency set changed and the baseline was not updated:
+ com.squareup.okio:okio:3.9.0
- com.squareup.okio:okio:3.6.0
./gradlew dependencyBaseline --regenerate <- leaves a reviewable diff
there is no --skip: a skipped check records nothing.
Both routes unblock the build in about the same time. Only one leaves a line in the history saying okio moved, on this date, in this change.
Two details in that task are doing more than they look. resolved is an @Input of resolved
coordinates rather than a reference to the configuration, because a task that resolves dependencies
at execution time is a task that cannot be cached and will quietly re-resolve on every build. And
the regenerate path writes and returns rather than writing and then failing — a gate that rewrites
the file and fails the build teaches people that regeneration does not work, which is how the
--skip flag gets added two weeks later.
What the baseline cannot tell you is whether a change is good. It reports that okio moved from 3.6.0 to 3.9.0 and has no opinion about whether that was wise; the reviewing is still a person reading a diff. What it removes is the case where nobody knew the number moved at all — which was most of them, because a transitive bump arrives through a line nobody edited.
That is the honest scope of this gate, and it is worth being clear about: it converts an invisible change into a visible one. Everything after that is still judgement, and the judgement is now being applied to something somebody can see.
The failure mode I would watch for next is the baseline that is regenerated reflexively. Once the command is well known and the diff is routine, "run regenerate, commit, move on" becomes a reflex, and the record is being maintained without being read. The gate is still doing its job — the change is visible — but nobody is looking at the thing it made visible, which is most of the value gone.
I do not have a mechanism for that. What I have is the observation that a baseline diff in a pull request is at least reviewable by somebody who cares, where a silent transitive bump is not reviewable by anyone — and that the asymmetry is worth the occasional reflexive regeneration.
That is also the argument for making regeneration a single well-known command. If updating the record is harder than bypassing the check, people will bypass the check, and you will have built an incentive gradient pointing at the outcome you were trying to prevent.

How we proved it
The pattern is in use here on dependency state rather than on size, and it behaves exactly as described.
A committed baseline records the expected dependency set. CI compares and fails on divergence. Updating it is an explicit task whose output is a file change that lands in the same pull request as the dependency that caused it.
baseline committed, diffable
check fails the build on divergence
update an explicit task, output is a reviewable diff
bypass none — the hatch IS the update
The honest part: I have been on the wrong side of it. A failing baseline check mid-task is a real interruption, and the temptation is to regenerate without reading what changed — which converts the mechanism into ceremony. The gate cannot stop that. What it does is make the unread change visible to a reviewer, which is a weaker guarantee than I would like and still far better than a number nobody records.
What I have not measured: whether the adjacency actually changes reviewer behaviour. I believe a size bump sitting next to the dependency that caused it gets questioned more often than one discovered a quarter later, and I have not instrumented that. It is a plausible mechanism, not a finding.
One thing the baseline cannot do, and it took a while to accept: it does not tell you whether the new value is good. It tells you it changed, and who changed it. A dependency that doubles your artifact may be entirely correct — the feature needs it, the alternative is worse, the trade was considered. The gate has no opinion about that and should not pretend to.
What it removes is the specific failure where nobody got to have an opinion. That is a smaller claim than "the gate controls size", and it is the one that survives contact with reality. Teams that expect the first claim end up resenting the check when it fires on a change they already decided was worth it; teams that expect the second use the two minutes it costs to write down why, and the next person reading that line gets the reasoning instead of a number with no history.
What this generalizes to
For any quantity that only ever grows, and where each increment is individually defensible, the useful artifact is a recorded state rather than a limit.
Commit it, fail on divergence, and make the only way forward an update that leaves a diff. Then check the incentive: if bypassing is easier than updating, the gradient defeats the gate regardless of how the rule is written. The goal is not to prevent the number from moving — it is to ensure that every time it moves, somebody moved it on purpose and left their name on it.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".