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.
That was a fair deal for a long time. 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.
Then the agents showed up
GitHub's Octoverse 2026, published today, counts 45.4 billion GitHub Actions jobs in the year to 30 September. The year before it was 14.0 billion. Pushes went from 6.7 billion to 17.9 billion. 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.
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, the first dagger check took 21.7 seconds and the second took 1.7. Dagger keys its cache on the inputs, so unchanged inputs come back from the cache.
Then we ran the same repository in GitHub Actions, three ways, on ubuntu-latest:
| Arm | What runs | Job time, empty suite | Job time, 60 s suite |
|---|---|---|---|
| A | setup-go, go test | 18 s | 78 s |
| B | dagger check | 112 s | 163 s |
| C | look for a signed result, run dagger check only without one | 7 s to look, then 168 s | 7 s to look, then 169 s |
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.
(Arm C ran its full check here because we had not signed an admission policy yet, so its first job found nothing to verify. It fails closed: no verdict, full run. The 7 seconds is that job without the verify call; a real verify adds a lookup we have not timed.)
The test already ran
That was the turn. The suite had already passed on the laptop, in the same containers, with a warm cache. CI ran it again for one reason: it could not believe the laptop.
So the problem is evidence, not speed. CI/lock 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
cilock policy prove tests a draft policy offline: it records the step for real, signs it with a throwaway key, and checks that the policy admits it and refuses a failing run. The released CI/lock 4.6.2 passed our policy on clean code.
1.0.0-a sorted before 1.0.0-9. Dagger named both broken tests in 2.0 seconds, because only the test check ran again. The policy blocked it: wrapped command exited 1, not 0.
Tests switched off. Then we put the code back and added two lines to dagger.toml:
[modules.go.settings]
test = ["!**"]
Dagger said ✔ PASSED and exited 0. It was right. Every check it was asked to run passed, and it was asked to run none of the tests. Any CI gate that reads the exit code admits this change. Our policy refused it, because it requires the Go test check to appear and pass:
Local verify: REFUSED by dagger-check: no passing go test check in the dagger report
A green run and a run in which the tests ran are different facts. The policy has to ask what ran.
The evidence has to survive a rebase
Trunk-based development has one more trap. You run the suite, you push, and someone landed a change a minute before you. You rebase. The commit is new, so the evidence for the old commit does not cover it, and 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:
| Run | Go test check | Input digest | Time |
|---|---|---|---|
| Cold | ran, 7 of 7 passed | A | 31.70 s |
| Rebased onto a README change (new commit) | cache hit | A, unchanged | 1.75 s |
| Rebased onto a semver/ change | ran, 7 of 7 passed | B | 31.61 s |
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, not part of CI/lock yet.
What it adds up to
We put the measured numbers into a small model: fixed overheads of 18 seconds for arm A, about 108 seconds for arm B and 7 seconds for the verify job, plus the suite time. The 7 seconds for C is the job above without the verify call, so C's real number is a little higher. Arm C admits a commit with a verified result and skips the suite on the pull request. 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 is closer to the agent loop above.
| Suite | Pushes to green | CI minutes per landed change, A | B | C |
|---|---|---|---|---|
| 5 min | 1 | 10.6 | 13.6 | 6.9 |
| 5 min | 3 | 21.2 | 27.2 | 6.9 |
| 30 min | 3 | 121 | 127 | 32 |
Two things stand out. Arm C's cost does not grow with the number of attempts, because failed attempts stop at the push. And for a 2-minute suite with one push, the saving is small in dollars. GitHub's Linux 2-core runner costs $0.006 a minute. The money is in the retries and in the long suites.
Time to land does not drop. The suite still runs; it runs on your machine before the push instead of on a runner after it. What you stop paying for is the red rounds and the second copy of the run.
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. In our ALPS terms that is ALPS-1: we know which agent signed, and we do not know the laptop was clean. ALPS-2, where a TPM-attested node signs the sandbox the run happened in, is experimental today. And in the trace, the test results come from inside the test container, so the code under test can write them.
- We have not run the full loop end to end. The policy, the refusals, the GitHub Actions numbers and the rebase numbers are real. A live push through Pushgate and the CI job that skips on a verified result are the next run, and this post will say so when they are done.
- 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.

