-
-
One run, start to finish
-
Intent ledger: every promise, its test, its fix
-
Run timeline: hands-off and supervised runs, listed honestly
-
A real run: the Builder commit, then the Breaker commit
-
Four gates and the report, all green
-
616 tests, 0 failures
-
The guard: what agents cannot touch
-
Agents propose, the pipeline decides
Why I made this
Every README makes promises. "Orders over $100 get 10% off." "Only admins can edit products." "Bad input never gives a 500 error." Nobody checks these once the code starts changing. And when an AI agent is the one changing the code, it gets worse, because now nobody checks the agent either.
So I asked a simple question. What if the promises in the README were the tests? And what if the thing that decides what gets merged was not an AI at all, but the CI pipeline?
That is Hold Water for READMEs. The idea is in the name: does the README hold water? The whole path is README claims, then failing tests, then gated merges, and all of it runs inside GitLab.
What it does
You write a normal issue in plain English. After that, the loop runs without you.
Issue created
|
v
Builder agent opens a merge request (builder/ branch)
|
v
CI gates: guard_protected, unit_tests, repro_tests, security_gate
|-- all green, no reproduction tests yet --> Breaker commits failing tests
|-- a gate is red before the Breaker ran --> label "Needs human review"
v
CI gates run again
|-- a gate is red --> Fixer patches the branch (2 calls at most) --> gates again
|-- guard fails, or two failed agent calls --> label "Needs human review"
v
All green: GitLab merges the merge request
|
v
main: package_image --> smoke_test (staging soak, 100 s) --> promote_image (tag "stable")
|-- unhealthy after the soak: not promoted, postmortem issue opened
The three agents are custom flows on the GitLab Duo Agent Platform. They never call each other. A small CI job called call_agents decides who runs next and posts one mention on the merge request. The decision is a plain function, so it can be unit tested, and it is.
| Part | What it is | Its job |
|---|---|---|
| Builder | Duo flow | Turns an issue into a merge request |
| Breaker | Duo flow | Reads the README claims, starts the real app, commits one failing test per broken claim |
| Fixer | Duo flow | Fixes only what has a failing test, on the branch, never on main |
| call_agents | CI script | Decides who runs next, max 2 calls per agent |
| guard_protected | CI script | Stops agents from touching pipeline config, scripts, the dashboard, or existing tests |
| unit_tests, repro_tests, security_gate | CI jobs | Decide whether the code may merge |
| auto_merge | CI script | Turns on merge when green |
| score.py | CI script | Computes the Ship-Readiness Score from CI results |
After the merge, the build goes into the GitLab container registry. It runs as a staging container inside CI, gets checked, sits for 100 seconds, and gets checked again. Only a healthy build is tagged stable.
The demo app and its promises
The app is called ShopNest. It is a small store API (FastAPI and SQLite) that I wrote for this. Its README makes nine promises, and the Breaker turns each one into tests.
| ID | Promise |
|---|---|
| C1 | Orders with a subtotal over $100 get a 10% discount. Smaller orders get none. |
| C2 | Stock never goes below zero. An order that cannot be filled gets a 409. |
| C3 | Only admins can create, edit or delete products. |
| C4 | A user can only see their own orders. |
| C5 | Invalid input returns a 4xx, never a 500. |
| C6 | At most one coupon per order. More than one gets a 400. |
| C7 | Money values are rounded to 2 decimals. |
| C8 | Search input is treated as data, never as SQL. |
| C9 | No secrets are exposed over HTTP. |
What happened when I ran it
Every number below comes from CI, the GitLab API, or a file in the repository. Where the history does not say, the dashboard shows [not recorded] instead of a guess.
| What I measured | Result |
|---|---|
| First run on the untouched app (18 Oct) | 8 of 9 claims broken, 17 failing reproduction tests, score 45 out of 100 |
| 10 benchmark bugs, 4 of them held out from the agents | Original app 0 of 10. After the loop 10 of 10, but I count it as 9 of 10 because the Builder fixed one of them by accident while building a feature |
| Builder merge requests merged | 15 in total, 11 hands-off and 4 supervised |
| Ship-Readiness Score on main | 100 out of 100, computed by a script, never by a model |
The Ship-Readiness Score is out of 100: intent claims passing (40), security (25), tests (20), deploy health (15). If a part has no data, it is left out and the rest are re-weighted, so the score never shows something the build did not earn.
Seven fresh issues, one at a time
On 22 October I gave the system five new issues, and on 25 October two more. I logged every outcome, including the ones that needed a person. A run counts as hands-off only if nobody touched the merge request.
| Run | Issue | Asked for | Result | Hands-off | Minutes to merge |
|---|---|---|---|---|---|
| R1 | #16 | Optional sku field on products |
Merged (!18) | yes | 15.9 |
| R2 | #17 | min_price and max_price filters |
Merged (!19) | yes | 13.3 |
| R3 | #18 | GET /orders/{id}/receipt |
Merged (!20) | yes | 9.9 |
| R4 | #19 | Admin stock adjustment endpoint | Merged (!21) | yes | 15.5 |
| R5 | #20 | Product tags with a tag filter |
Merged after a human step (!22) | no | not comparable |
| R6 | #22 | GET /products/count |
Merged by hand before the Breaker finished (!24) | no | not comparable |
| R7 | #23 | Optional rating field (title only, no description) |
Merged (!25) | yes | 10.8 |
R5 got stuck. The Builder added a tags field, and that broke an old test that checked the exact list of response fields. Existing tests are protected, so no agent was allowed to edit it, and the loop just waited. I had to edit the test myself and add the allow-protected label. That showed me two real problems, which I fixed (see the table of mistakes below).
Two bad deploys
I wrote a patch that makes /health start failing about 90 seconds after the app starts. Every unit and reproduction test still passes, so the merge request merges. Only the staging soak can catch it.
| Bad build | What happened on main | Recovery | |
|---|---|---|---|
| Test 1 | MR !16 | Pipeline #104: smoke_test failed after the soak, image not promoted, issue #14 opened. The pipeline looked green only because those jobs were still allowed to fail | Revert, pipeline #106 healthy and promoted. Then I made the three staging jobs required |
| Test 2 | MR !17 | Pipeline #120 went red: smoke_test failed, promote_image skipped, issue #15 opened | Revert, pipeline #122 green, staging healthy, promoted |
Test 2 matters because it shows a failed staging check really does turn main red now. The revert in both cases was done by hand. I call it a verified revert, not an automatic rollback.
How it keeps agents honest
README text, issue text and code are all treated as untrusted. The rules live in the pipeline, not in prompts.
- Agents cannot change
.gitlab-ci.yml,.gitlab/,scripts/,dashboard/,Dockerfile,CODEOWNERS,pytest.iniorconftest.pyat any depth. - Existing files in
tests/reproductions/andtests/unit/are append-only. The guard checks commit by commit, so a later commit cannot quietly rewrite a test that an earlier commit added. - The label
allow-protectedlifts the guard, but only if a human with Maintainer access (or the project owner) added it. The pipeline asks the API who added the label. Bots are refused, and if the check fails, there is no bypass. security_gateblocks the merge while a reproduced exploit is still unfixed.- Each agent gets at most two calls per merge request. After that, a person is asked.
- I tested prompt injection three times, once per agent (a hidden instruction in the README, in an issue, and in a Breaker report). That is three samples, not a proof.
How I built it
I used the GitLab Duo Agent Platform for the three flows, with triggers on "work item created" (Builder) and on mention (Breaker and Fixer). The rest is GitLab CI/CD: the guard, the test gates, packaging with kaniko into the GitLab container registry, the staging check as a CI service, and promotion with crane. The dashboard is a single HTML file on GitLab Pages, and its numbers are rebuilt from CI data on every merge. The flows are frozen at the tag v1.0-freeze, so the prompts did not change after that. Tests, docs and scripts did.
What went wrong, and what I changed
| What happened | What I changed |
|---|---|
| Pipeline-event triggers were unreliable and agents did not always start | A CI job now posts the mention itself, in a way that always behaves the same |
| A bad deploy showed a green pipeline (#104) | The three staging jobs are required, and I proved it with a second bad deploy (#120) |
| R5 stalled silently when a gate went red before the Breaker ran | call_agents now adds the label Needs human review |
| The override label did not work in R5, because I am only a Developer in my own project | The guard also accepts the project owner by user id, and prints why a bypass was refused |
| The Breaker writes tests that assert an exact list of response fields, so any new field breaks them | Written down as a finding. I did not change it because the prompts were frozen |
| One benchmark bug (C6) was fixed by the Builder, not by the loop | The benchmark is quoted as 9 of 10 |
| A cleanup commit removed a script the staging job needed, and main went red | Restored the script, closed the postmortem issue (#21) |
What I learned
The more decisions I moved out of the agents and into the pipeline, the more I could trust the results. The agents write and repair. The pipeline decides. I also learned that small honest numbers are worth more than big vague ones, which is why the failed run (R5) and the two bad builds are on the dashboard and not hidden.
Honest limits
- I wrote the demo app and its bugs, and the 10 benchmark bugs too. This is not an independent benchmark.
- It has only run on one stack, Python and FastAPI. The docs explain how to adapt it, but I have not tried another stack.
- The samples are small: five fresh runs, two bad deploys, one benchmark run per checkout.
- Agent mentions are posted by a CI script using the project owner's token, because GitLab does not let bot accounts start flows. A person's token is behind every mention.
- Deployment means a staging container inside CI plus the GitLab container registry. Nothing serves real traffic. I drafted a Cloud Run path, never ran it, and removed it, so there is no Google Cloud deployment.
- I do not have token cost per run, so I do not report it.
- The score only measures what the tests measure. A promise that nobody wrote a test for is not in it.
What I would do next
Deploy to a real cloud target, try a second stack, ask someone else to write the benchmark bugs, make the soak longer than 100 seconds, and make the Breaker write more tolerant assertions so new fields do not break old tests.
The live dashboard has every number above, and each one links back to its source. docs/JUDGES.md in the repository is a five minute path to check the claims yourself.
Log in or sign up for Devpost to join the conversation.