fix(ci): an unwritable checkout dir must not take CI down platform-wide #5598
AI Triage
(no summary)
Priority: medium Risk area: mixed
Suggested labels: (no label suggestions) Suggested reviewers: (no reviewer suggestions)
Suggestions only — nothing has been applied. The PR author stays in control.
AI review unavailable
The platform's AI balance is exhausted, so AI generation is temporarily unavailable. Nothing was lost. You can queue this as a repair for the internal agent from the repository's Health page, or try again once the balance is restored. The PR is otherwise unchanged.
A second commit landed on this branch — a separate bug found while diagnosing the incident above.
6852c73 — the REST API 404s on a repo you capitalised differently
/api/v2/repos/ccantynz/gluecron.com/... returned 404 while /ccantynz/gluecron.com in a browser rendered the repo and /api/v2/repos/ccantynz/**G**luecron.com/... worked. resolveRepo in api-v2.ts was the last resolver still using exact equality; loadRepoByPath has matched case-insensitively for months, and the web UI and MCP tools already go through it.
Names are stored lowercase going forward (2c6cd93), but rows created before that keep their capitals — Gluecron.com, Vapron — so exact matching is wrong for data that exists today, not merely for careless clients.
The failure mode is what makes this more than a casing nit: a 404 that depends on how a client capitalised a URL is indistinguishable from "this repo does not exist", and a polling client reads it as silence. That is how it was found — a CI monitor in this session watched that exact URL and reported nothing while the run it was watching finished and failed. Twice.
Named in the code, deliberately not fixed here
This resolves user-owned repos only, so an organisation's repository is unreachable through the entire v2 API. Not a one-line change: 44 call sites use resolved.owner for authorization (user.id !== resolved.owner.id), and an org id in that slot would deny every request rather than allow the wrong one.
A ratchet on the wider class
127 call sites still match repository names with eq(repositories.name, x). The sweep to route them through loadRepoByPath has been pending since 2026-08-05, and a sweep that size isn't something to do blind.
The ratchet can't fix them, but it stops the class growing — and it fails when the count drops too, so the baseline gets tightened as the sweep progresses. A ratchet nobody tightens is just a number that used to be true.
It earned its keep immediately: it caught that my own baseline of 132, taken from a grep, was wrong.
Correction to this PR's description. It said PR #5597 merging through a checkout_failed run was "the merge gate working as designed — checkout_failed is an infrastructure conclusion, and our failures never block your merge."
That was wrong, and the real answer is worse.
decideCiGate already blocks on infrastructure conclusions:
if (infra.length > 0) {
return { name: "CI", passed: false,
details: `CI did not complete — ${infra.join(", ")}. Re-run the workflow …` };
}So the gate never intended to let that through. The reason #5597 merged is the bug found afterwards and fixed in #5599: lookupRepo was exact-match on the repository name, a caller passing gluecron.com against a repo stored as Gluecron.com got null, and null was rendered as a passing CI check. The gate wasn't being lenient about infra failures — it wasn't running at all.
The open question I raised here therefore doesn't exist as posed. What replaced it is narrower and now answered by #5599: a check that could not be performed must never score as one that passed.
Verified end-to-end in production after #5599 deployed — the same lowercase call against #5600 while its CI was running:
{ "merged": false, "reason": "CI: CI still running: CI — merge when green" }Separately, docs/CI-ATTRIBUTION-GUARANTEE.md §2 is headed "Our failure never blocks your merge", which the code above contradicts. The code is right and the doc overstates the promise; fixing the doc rather than the code.
Cross-repo impact
See what breaks downstream if this PR merges.
This pull request was merged into main.
Incident, today, caused by the sibling runner that shipped an hour ago.
Within minutes of
859e3cbdeploying, every workflow run on the instance failedcheckout_failed— three runs on two branches, then main, then everything.Cause: docker creates a named volume root-owned when the mount path doesn't exist in the image.
/ci-workdidn't. The app runs as uid 1000,mkdtempreturnedEACCES, and there was nowhere to put a checkout.Production was restored by chowning the volume by hand. This makes that unnecessary.
Three fixes; the second is the one that matters
The image creates
/ci-workowned bybun, so a fresh volume inherits the right ownership rather than being created root-owned by docker.cloneAtfalls back to the temp dir when the configured workdir is unusable, says so in the log, and CI keeps running with worse isolation. Refusing to run because the preferred directory is missing turns a configuration problem into a platform outage — which is exactly what happened. A tidy failure is the wrong failure here.Execution follows the checkout.
checkoutIsSharedWithRunnergates the runner path on the checkout actually living under the shared root. The fallback is only safe if both decisions agree: a checkout in the app's private temp dir dispatched to a container that can't open it is the worst outcome available — every step failing on a path error while a working local execution path sat unused.One thing worth deciding separately
PR #5597 merged while its CI was failing this way.
That is the merge gate working as designed:
checkout_failedis an infrastructure conclusion, anddocs/CI-ATTRIBUTION-GUARANTEE.mdpromises our failures never block your merge.It is still worth asking whether "not your fault" should imply "merge it anyway" when the consequence is that nothing was verified at all. An infra failure arguably ought to leave the check unsatisfied — pending, not passed — the way a skipped AI review is credited only under one narrow, named condition.
Not changed here. Changing it needs its own thinking rather than a reflex inside an incident fix.