The way it was

You write a change. You run the tests. You push, and CI runs the tests again on a fresh machine. It takes a few minutes and nobody thinks about it.

CircleCI measured 28.7 million workflows in September 2025, and the median took 2.2 minutes. Two minutes, a few times a day, per person. Cheap enough to ignore.

Then the agents showed up

CI work tripled in a year. GitHub's Octoverse 2026, published on 28 October, counts the GitHub Actions jobs run in the 12 months to 30 September:

Bar chart of GitHub Actions jobs per year to 30 September, from GitHub Octoverse 2026: 14.0 billion in 2025, 45.4 billion in 2026.

Pushes nearly tripled too, from 6.7 billion to 17.9 billion. Agents, of course, are why. By September, more than 40% of pull requests started with an agent's first draft, up from 17% in March.

The runs did not get longer. There are just a lot more of them.

And many of them go nowhere. CircleCI's Q3 2026 data shows feature-branch workflows up 40% since the first quarter, while the median team's runs on main did not move. In its September 2025 sample, the main-branch success rate was 70.8%, the lowest in five years, and the median time to get a branch green again was 72 minutes.

An agent that cannot run the suite locally does what any of us would do. It pushes, waits for red, and pushes again. You pay for every round. (Agents broke CI tells the same story from the platform side, with Anthropic's and GitHub's numbers.)

So CI is slow and expensive for the same reason: it runs every attempt from a cold start, because it has no reason to trust any machine but its own.

The first fix we tried made it worse

Dagger 1.0 went into beta on 23 October. It runs your checks in containers, so a check on a laptop and the same check in CI do the same work. That sounded like the answer, so we measured it.

The test subject was a 124-line Go library with seven tests. Setup took three commands, and the official Go module found every test on its own:

dagger init
dagger module install dagger.io/go
dagger check -l

On a laptop, with the engine on a second Mac, dagger check ran twice:

Bar chart of dagger check on a laptop with the engine on a second Mac: first run 21.7 seconds, second run with the same inputs 1.7 seconds.

Dagger keys its cache on the inputs, so unchanged inputs come back from the cache. (A cache hit is only as good as what it records; we looked at that in Trace a Test Before You Trust Its Cache Hit.)

Then we ran the same tests in GitHub Actions on ubuntu-latest, once with plain go test and once with dagger check:

Bar chart of GitHub Actions job time for a 60-second test suite on ubuntu-latest: plain go test 78 seconds, dagger check 163 seconds, 85 seconds more.

Dagger in CI cost about a minute and a half more per run than plain go test. Every GitHub runner starts a new engine with an empty cache. The cache that made the laptop fast does not exist there. If your problem is the CI bill, moving the same work into containers on the same cold machines does not help.

The test already ran

The tests had already passed on the laptop, in the same containers, with a warm cache. So why does CI run them again? And then again in the merge queue?

Elon Musk's rule for engineering teams is to delete a step before you try to make it faster, because the most common mistake a smart engineer makes is optimizing something that should not exist.

The market went the other way. In October alone, Blacksmith raised $66 million at a $1 billion valuation and Namespace raised $42 million, both to run CI faster and cheaper. Depot, RWX, StarSling and a long tail of drop-in runners sell the same thing: the same rerun on a quicker machine. GitHub cut its hosted-runner prices by up to 39% in January. Every one of them makes the second run cheaper. None of them asks whether it needs to happen.

Two launches this month come closer. Vercel CI, announced on 28 October, runs only the tasks a push affects and serves the unchanged ones from its cache. Chainguard Checks, announced on 14 October, goes furthest: it signs an attestation for every run. Both ask you to move your CI onto their system: Vercel CI runs Turborepo tasks, and Chainguard Checks runs its own configuration in its own microVMs.

The better move is not to run the tests a second time. But you can only delete a check if the next stage can trust the one that already ran.

So the problem is evidence, not speed. CI/lock (docs) wraps a command, records what ran and what it printed, and signs that record for the commit. Pushgate sits in front of the Git remote and admits a push only when signed evidence for that commit satisfies the repository's policy. On this repository the loop is:

git commit -am "fix pre-release ordering"
cilock run --step dagger-check -- dagger check --progress report
git push

Pushgate refuses a push without passing evidence and says why, in a form a machine can act on. An agent reads it, fixes the problem on its own machine, and tries again. The failed rounds never reach CI.

We tried to break it

A gate that only reads the exit code can be fooled by a run that never tested anything. So we tried three changes and asked two questions of each: what did Dagger report, and what did the policy decide?

Change Dagger Policy
None (clean code) ✔ PASSED, exit 0 admitted
A real bug: 1.0.0-a sorts before 1.0.0-9 ✘ 2 tests failed, exit 1 refused: the command failed
Tests switched off in dagger.toml ✔ PASSED, exit 0 refused: no passing Go test check

The third row is the one that matters. Two lines in dagger.toml tell the Go module to run no tests:

[modules.go.settings]
test = ["!**"]

Dagger was right to say PASSED: every check it was asked to run passed, and it was asked to run none. Any gate that reads the exit code admits this change. Our policy refused it because it doesn't ask whether the run was green. It asks whether the Go test check ran and passed.

We ran all three offline first with cilock policy prove, which signs with a throwaway key and uploads nothing, then for real through the gate (next section).

Then we ran it through the real gate

An offline proof is one thing. So we connected the repository to Pushgate, enrolled an agent identity for it (scoped to this one repository, eight hours, approved with a passkey), assigned the policy in Block mode, and pushed.

First push: a code change with no Dagger run. The gate checked the signature, the signer's scope and the commit metadata, then stopped at the policy:
x release   dagger-check: no evidence for commit 6e06e4d6: no collection for this step
            is bound to this commit; produce it with: cilock run --step dagger-check ...
=> STOPPED AT THE GATE

That answer took 2.1 seconds, and it arrives twice: as prose, and as a JSON line an agent can act on ("type":"release-step-missing", "retry_without_changes":false).

Second push: the same commit, after cilock run --step dagger-check -- dagger check --progress report. Dagger ran nine tests on the warm engine (the change added two), and the signed record was stored in 2.5 seconds. The push went through:
+ release   release ... is satisfied for 6e06e4d6
=> accepted
accepted: delivered to github.com/testifysec/dagger-pushgate-demo in 3.8s.
Third push: the tests switched off in dagger.toml. Dagger said ✔ PASSED and exited 0, and the signed record says so. The gate refused it anyway, in 2.1 seconds:
=> STOPPED AT THE GATE: ... step dagger-check: command-run rule:
   no passing go test check in the dagger report
The Pushgate push page for the tests-off push: stopped at the gate, with the rule that refused it and the command to fix it.

The record behind the accepted push is in the TestifySec Platform, bound to the commit, with the exact command that ran and its exit code:

The TestifySec Platform attestation for the accepted push: command-run, dagger check --progress report, exit code 0.

The evidence has to survive a rebase

Evidence bound to a commit does not survive a rebase. You run the suite and push, but someone landed a change a minute before you. You rebase, which makes a new commit. The evidence for the old commit does not cover the new one, so you run the whole suite again.

Dagger's own trace has the way out. Every dagger check can export an OpenTelemetry trace with each check's name, result, cache state and per-test results, and each check's inputs carry a sha256 content digest. We turned that trace into a signed record and bound the evidence to the input digest instead of the commit. With the suite padded to 30 seconds:

Bar chart, suite padded to 30 seconds: first run 31.7 seconds with 7 of 7 tests passing; rebased onto a README change, same input digest, cache hit in 1.75 seconds; rebased onto a semver code change, new input digest, tests ran again, 7 of 7 passed, 31.6 seconds.

The rebase onto an unrelated change cost 1.75 seconds, not 31. A change that touched the code ran the tests again, as it should. The same policy, rewritten to read the record's fields, admitted both good runs and refused a failing test, switched-off tests and a run with zero checks. This part is a prototype. The converter is a Python script in the demo repository; CI/lock now has a dagger attestor built on the same design, on its main branch and not yet in a release (see Try it).

What comes out of CI

Two steps come out of CI: the suite on every push to a pull request, and every red retry. Review and one full merge-queue run stay.

Before and after, one change from commit to main. Before: commit, push with nothing checked, the full suite on a cold CI runner for every push with a red retry loop back through CI, review, a full merge-queue run, main. After: dagger check on the developer's machine with a warm cache and a signed result, the push through Pushgate which refuses it without passing evidence so the retry loop stays local, a CI job that only verifies the signed result, review, one full merge-queue run, main. Removed from CI: the pull-request suite and every red retry round. Kept: review and one full run in the merge queue.

We put the measured numbers into a small model of the CI bill. Each CI run costs a fixed overhead plus the suite time. The overheads come from the runs above. Plain go test adds 18 seconds, Dagger in CI about 108 and the verify job 7 (a little higher once it really verifies). The verify job skips the suite on the pull request when the commit carries a verified result. The merge queue still runs the full suite, because the merged commit is new and never went through Pushgate.

"Pushes to green" is how many attempts a change takes. One is a person who ran the tests first. Three or more is the push-and-wait loop above. The chart prices 2,000 changes a month on GitHub's cheapest Linux runner; the bill scales with both.

Line chart, modeled monthly CI bill against pushes before a change goes green, for 2,000 changes a month, a 30-minute suite, a merge queue and $0.006 per runner-minute, local runs not billed. Plain go test rises from $727 at one push to $2,545 at six. Dagger in CI rises from $763 to $2,671. Dagger with CI/lock and Pushgate stays at $383 at every push count.

Rework costs money unless you shift it left. Every red push without a gate is another full run on a CI runner, so the bill climbs with each attempt. With Pushgate, the red attempts are refused at the push and never reach CI, so the bill stays flat however many tries a change takes. At three pushes, the model gives $383 a month against $1,454 for plain go test. On a short suite that goes green on the first push, the saving is a few dollars.

The runner bill is not the biggest cost. An agent that pushes and waits for CI keeps checking whether the run has finished, and every check is a turn that re-reads its whole context. We measured our own agents over two weeks: each CI-watching turn re-read a median of 461,000 cached tokens, while the CI log itself averaged under 400. Reading the log is cheap. Waiting for it is not.

So we simulated it, with those measured inputs, at current Claude Opus and GitHub runner prices, for the same team as the chart above:

Stacked bar chart of simulated cost per month for 2,000 changes, a 30-minute suite, 3 pushes, agent polling CI every 60 seconds. Agent fixes against CI: $38,510, of which $1,457 is runner minutes and the rest agent tokens. Pushgate with a local loop: $11,098, of which $384 is runner minutes.

The runner minutes are the same $1,457 and $384 as in the chart above; the agent tokens add more than twenty times that. Pushgate came out cheaper in every scenario we ran, by 1.9 to 5.1 times. The biggest lever is how the agent waits: one blocking wait instead of polling cuts the CI loop to $4,091 a month, against $2,138 with Pushgate. We are making that change in our own agents too.

Time to land drops only if the developer's machine is faster than the runner. On equal machines the suite takes as long before the push as it would after it. Machines are rarely equal: the same cold Go build took a median 11.6 seconds on GitHub's ubuntu-latest and 2.4 seconds on an M3 Pro Mac. We simulated one change on the same 30-minute suite with three pushes, half of it CPU-bound, against a standard GitHub runner, an 8-core larger runner and the Mac:

Stacked bar chart of simulated mean time to land one change, 30-minute suite, 3 pushes, half the suite CPU-bound. Standard 4-core GitHub runner: 123 minutes, 3.3 of them waiting for runner start, job setup and the agent noticing the result. 8-core GitHub larger runner: 103 minutes, 3.3 waiting. Mac with Pushgate, local loop on an M3 Pro: 86 minutes, 1.7 waiting.

Runner start-up was not the problem on our repository: GitHub started our jobs a median of 2 seconds after they were queued. Most of the waiting is job setup and the agent noticing the result at its next poll. That changes when an organization hits its concurrency limit; one public repository reports a median wait of 6 minutes 12 seconds, which in our model adds about 25 minutes to each GitHub row and 13 to the Mac's. The 8-core runner's speed is a proxy from RunsOn's CPU benchmark (about 1.5 times the standard runner on all cores; RunsOn sells runners). Either way, what you stop paying for is the red rounds and the second copy of the run.

Speed still needs a merge queue

Pushgate checks the commit you pushed, and that may not be the commit that lands. If someone lands a change between your test run and your push, main gets a combination of the two that nobody tested.

Without a merge queue there are two choices, and both are bad at speed. You rebase and run again, so the evidence covers the exact commit. Or you merge the untested combination and let main find out. The first costs your time. The second is one way main ends up green only 70.8% of the time.

Rebasing and running again stops working as traffic grows. In the model, the chance that another change lands while your suite runs grows with the suite's length and with how many changes land per hour. At 6 changes an hour and a 20-minute suite, a change needs about 7 local runs before it lands. At 20 changes an hour it never lands. Evidence bound to inputs helps when the other change touched different files (the 1.75-second rebase above), but not when it touched yours.

A merge queue removes the race. It lines up the changes, tests each merged result once, in order, and only that result reaches main. That queue run is the one full CI run we keep. Pushgate removes the runs before it: the suite CI would otherwise run on every push to a pull request, red attempts included. (What gets into the merge queue covers why the queue is where a failure costs most.)

What this does not give you

  • The evidence is only as good as the machine that made it. Pushed from a laptop, the signer is an agent identity that a person approved with a passkey, for a limited time. Our agent sandbox levels call that level 1: we know which agent signed, not that the laptop was clean. Level 2, where a machine whose TPM chip vouches for it signs the sandbox the run happened in, is experimental today.
  • The CI skip has not completed yet, because the run found a platform bug. The workflow that verifies the verdict and skips the suite is in the demo repository and ran on a pull request whose head commit the gate had accepted. The platform refused its read of the verdict because of a credential-matching bug on our side. The workflow failed closed and ran the full suite, which is what it should do. The fix is in review; we will add the skip timings here when it ships.
  • The red attempts still run; they run somewhere else. The failing attempts run on the developer's machine before the push. On a laptop you already own, that is free. In a paid cloud sandbox, the runner-minute saving nearly disappears; the token saving stays, because the agent is not sitting in a polling loop.
  • The cost numbers are a model. Context and log sizes come from our own agents, which run with very large contexts. A lighter agent with about 100,000 tokens of context costs roughly half in both arms ($19,748 against $5,730 a month). Poll interval, runner queue time and attempts per change are assumptions we varied, not measurements.
  • A shared Dagger cache gets you a cheap second run too. Dagger Cloud offers a distributed cache and bills repeat work at a fifth of the price, so CI rerunning a commit you already tested costs little there. It does not stop red pushes from reaching CI, and a cache hit is not evidence: the run with the tests switched off would come back from the cache as ✔ PASSED.
  • Dagger 1.0 is a beta, and the policy that reads its text report depends on that format.

Try it

The repository is testifysec/dagger-pushgate-demo: the code, dagger.toml, the three workflows, the policy, the model and the trace prototype. With Dagger 1.0 beta and CI/lock installed, run cilock policy prove -p .pushgate/policy.json in a clone. It tests the policy on your machine and nothing leaves it. Then switch the tests off in dagger.toml and run it again.

The Gate on Dagger checks guide does the same with the CI/lock dagger attestor, which reads Dagger's trace instead of its text report.