CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Model-provider portability, the nav/dashboard/security fixes, and a readiness gate that can't pass by skipping #5564

Merged⚡ AI-generatedXSccantynz wants to mergefeat/model-provider-portabilitymainopened 3d ago
49 changed files+4222−405
Modified.env.example+27−0View fileUnifiedSplit
409409# delivers. Owner directive: every notification travels our own rails.
410410EMAIL_HTTP_URL=
411411EMAIL_HTTP_TOKEN=
412
413# ── Who may execute workflow steps (CONTAINMENT, 2026-08-29) ─────────────
414#
415# REQUIRED. Unset means NO workflow runs execute on this instance.
416#
417# A `run:` step is `bash -c <whatever the user wrote>`, spawned by the app,
418# inside the app container, as the same uid as PID 1. Compose starts PID 1
419# with `env_file: .env`, so its environment holds every secret here and is
420# readable at /proc/1/environ by any process with the same uid — which
421# defeats buildRunnerEnv's allowlist. /data/repos is mounted read-write, so
422# a step can also read and rewrite every account's repositories.
423#
424# Registration is open and a push enqueues a run, so before this gate any
425# person who signed up could run code in the production container.
426#
427# This LIMITS WHO CAN REACH THAT. It does not fix it. Everyone listed still
428# has unsandboxed execution, so the list means "already trusted with
429# production" — not "allowed to use CI". The fix is per-job isolation:
430# docs/AUDIT-CI-RUNNER-ISOLATION.md. Delete this variable when that lands.
431#
432# Values: comma-separated usernames (repo OWNER, not the pusher) ·
433# `none` to disable execution entirely ·
434# `all` to deliberately accept the risk (spelled out on purpose, so
435# that turning the control off shows up in a diff).
436#
437# Production: WORKFLOW_EXEC_ALLOWLIST=ccantynz
438WORKFLOW_EXEC_ALLOWLIST=
Modified.gluecron/workflows/deploy.yml+36−0View fileUnifiedSplit
4141 echo "DEPLOY NOT VERIFIED after 6 minutes — the gluecron-update timer may be stuck."
4242 echo "On the host: systemctl list-timers 'gluecron-*' && journalctl -u gluecron-update"
4343 exit 1
44
45 # The step above proves the right BYTES shipped. It says nothing about
46 # whether the product works — a deploy that serves a 500 on every page
47 # passes it, because /api/version is the one route that still answers.
48 #
49 # So: run the readiness gate against the build we just confirmed live.
50 # Browser-free by necessity (the runner is inside the app container,
51 # which ships no Chromium), which still leaves the gates that catch the
52 # faults we have actually shipped: JSON on /api/*, no anonymous render
53 # of authenticated pages, and no dead end behind an onboarding CTA.
54 #
55 # Exit 3 is INCONCLUSIVE, not failure: the credential-bearing gates
56 # (privacy, authz-matrix) cannot run here, because buildRunnerEnv
57 # strips tokens from the runner by design and that is the right call.
58 # Failing the deploy over a gap we deliberately created would train
59 # everyone to ignore a red deploy — so it prints the gap and passes.
60 # Only a real defect (exit 1) turns this job red.
61 - name: Readiness gates against the live build
62 run: |
63 set +e
64 bun scripts/production-readiness.mjs --base https://gluecron.com --no-browser
65 CODE=$?
66 set -e
67 if [ "$CODE" = "1" ]; then
68 echo "READINESS FAILED — a hard gate broke on the build now serving production."
69 exit 1
70 fi
71 if [ "$CODE" = "3" ]; then
72 echo "Readiness INCONCLUSIVE — every gate this runner can execute passed;"
73 echo "the browser and credentialed gates need 'bun run readiness:full' from a workstation."
74 exit 0
75 fi
76 if [ "$CODE" != "0" ]; then
77 echo "READINESS CRASHED (exit $CODE)"
78 exit 1
79 fi
ModifiedDockerfile+44−0View fileUnifiedSplit
1# Donor stage — nothing from here ships except the files COPYed below.
2# See the "Node.js" block further down for why it is a copy and not apt.
3FROM node:22-bookworm-slim AS nodejs
4
15FROM oven/bun:1.3 AS base
26WORKDIR /app
37
1317 && git --version \
1418 && wget --version | head -1
1519
20# Node.js — for the CI runner, not for the app. The app runs on Bun and
21# never shells out to node; the workflow runner, which executes INSIDE this
22# container (see docker-compose.standalone.yml), does.
23#
24# The defect this closes is the worst shape a check can take: a test gate
25# structurally incapable of failing. Measured — `vitest` invoked with no
26# `node` on PATH exits 0 having executed ZERO tests: "Test Files no tests",
27# "Tests no tests", identically across all three pools (forks, threads,
28# vmThreads). Not a crash, not a 127 anyone would notice — a green tick over
29# an empty run. Every repo on this platform whose CI is vitest has been
30# reporting a suite that never ran. (eslint and `next build` were measured
31# under Bun without node and DO work; vitest is the one that lies.)
32#
33# Why a copy from the official image rather than apt:
34# - Debian's own `nodejs` package trails hard — 18.19 on bookworm, EOL
35# since April 2025 — and drags in libnode plus its dependency tree for
36# roughly 170 MB installed. Under-versioned AND heavier than the copy.
37# - NodeSource means trusting a third-party apt key + repo on every single
38# build, for a binary we can simply take.
39# - `COPY --from` is ~110 MB for a major WE pinned rather than one Debian
40# froze, and adds no new network dependency to the build. Node 22 clears
41# what modern tooling actually asks for (vitest 3, Next 15).
42# npm/npx ride along (~12 MB) because the platform generates `npx vitest run`
43# and `npx tsc --noEmit` for non-Bun repos (src/lib/intelligence.ts,
44# src/lib/repo-onboarding.ts) — without npx those pipelines die at 127 the
45# way the design-audit gate once did.
46#
47# bookworm-slim as the donor on purpose: its glibc floor is at or below the
48# Bun base's, so the binary runs whichever Debian oven/bun is sitting on.
49#
50# Verified below for exactly the reason git is: an image that quietly ships
51# without node puts the silently-green vitest run straight back, and this
52# time nobody would be looking for it.
53COPY --from=nodejs /usr/local/bin/node /usr/local/bin/node
54COPY --from=nodejs /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm
55RUN ln -s ../lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
56 && ln -s ../lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx \
57 && node --version \
58 && npm --version
59
1660# Install dependencies
1761COPY package.json bun.lock ./
1862RUN bun install --frozen-lockfile --production
Modifieddocker-compose.standalone.yml+12−0View fileUnifiedSplit
129129 #
130130 # Raising this is a stopgap. The real fix is moving CI out of the app
131131 # container, so the running site's memory budget stops bounding a build.
132 #
133 # Symptom to recognise if it recurs: CI red, "exit 137", and NO test
134 # failures reported — the process was killed before it could report any.
135 # scripts/ci-typecheck.sh now says that out loud on the typecheck step
136 # instead of leaving a bare exit code.
137 #
138 # Watch this number again now that the image carries node (added so the
139 # vitest gate stops exiting 0 with zero tests run — see the Dockerfile).
140 # Vitest's default pool forks one worker per core; those are real node
141 # processes in this same cgroup, and the 3,349 MB measured above had to
142 # hold only ONE tsc. A repo whose CI is vitest is the likeliest thing to
143 # find even 6g too low.
132144 mem_limit: ${GLUECRON_MEM_LIMIT:-6g}
133145 memswap_limit: ${GLUECRON_MEM_LIMIT:-6g}
134146 ulimits:
Addeddocs/AUDIT-CI-RUNNER-ISOLATION.md+689−0View fileUnifiedSplit
1# Audit — CI runner isolation
2
3**Date:** 2026-08-29 · **Type:** read-only architecture audit · **Scope:**
4`src/lib/workflow-runner.ts`, `src/lib/gate.ts`, `Dockerfile`,
5`docker-compose.standalone.yml`, `.gluecron/workflows/*`, `scripts/ci-tests.sh`
6**Changes made:** none. This file is the only artifact.
7
8> **Note — concurrent work in the tree.** This audit was performed against
9> committed `HEAD` (`4134284`). Partway through, the working tree acquired
10> uncommitted changes from a concurrent session that already address two
11> findings below, and they should be credited rather than re-reported:
12> `Dockerfile` now copies a `node` binary from `node:22-bookworm-slim`
13> specifically because *"vitest invoked with no `node` on PATH exits 0 having
14> executed ZERO tests … identically across all three pools"* — an independent
15> confirmation of R11's peer measurement, now recorded in-tree; and
16> `src/lib/gate.ts` splits the zero-runs fail-open so that "a workflow exists
17> but has no run for this sha" is **not passed**, while "no workflows
18> configured" stays skipped — which is R10's first bullet, fixed. The compose
19> file also gains a note that 4 GB may now be too low, since vitest forks one
20> node worker per core *inside the app's cgroup* — i.e. the fix makes R6 worse,
21> which is itself the argument for Stage 2. Everything else below stands.
22
23---
24
25## Verdict
26
27The workflow runner is not a runner — it is `Bun.spawn(["nice","-n","10","bash","-c", userSuppliedString])`
28inside the live production application container, as the same UID as the app,
29in the same PID namespace, with every hosted repository mounted read-write at
30`/data/repos` and no sandbox, cgroup, seccomp profile, or filesystem boundary
31of any kind (`src/lib/workflow-runner.ts:547-563`, `Dockerfile:47-48`,
32`docker-compose.standalone.yml:77-78`). Registration is open
33(`src/routes/auth.tsx:251`) and any push to any repo containing
34`.gluecron/workflows/*.yml` enqueues a run (`src/lib/push-workflow-sync.ts:36`),
35so the correct description of the current state is: *arbitrary remote code
36execution in the production container is a documented product feature, offered
37to anonymous signups, and `buildRunnerEnv`'s secret allowlist is defeated by
38`cat /proc/1/environ`.* Separately and independently, the gate that is supposed
39to make CI mean something fails open on zero runs, and the runner marks a job
40`success` when every one of its steps did nothing — so "CI green" is not
41evidence that any code was executed, let alone judged.
42
43---
44
45## Risk table
46
47| # | Risk | Evidence (file:line) | Severity | Who it hurts |
48|---|------|----------------------|----------|--------------|
49| R1 | Any registered user gets arbitrary shell in the production container | `src/routes/auth.tsx:251` (open registration, no invite gate); `src/lib/push-workflow-sync.ts:36`; `src/lib/workflow-runner.ts:547-563` | **Critical** | Every tenant, the owner, the business |
50| R2 | `buildRunnerEnv`'s secret stripping is defeated by `/proc/1/environ` — same UID, same PID namespace as PID 1, which holds the entire `.env` | `Dockerfile:47-48` (`USER bun`); `docker-compose.standalone.yml:39-40` (`env_file: .env`*whole* env into PID 1); `src/lib/workflow-runner.ts:169-182` | **Critical** | Every secret on the box: `DATABASE_URL`, `WORKFLOW_SECRETS_KEY`, `SERVER_TARGETS_KEY`, `ANTHROPIC_API_KEY`, OAuth secrets |
51| R3 | Every hosted repository — including every private one — is readable AND writable from inside a CI step | `docker-compose.standalone.yml:77-78` (`git-repos:/data/repos`); `Dockerfile:47` (`chown -R bun:bun /data/repos`); `src/git/repository.ts:100-112` | **Critical** | Every tenant with a private repo |
52| R4 | A CI step can rewrite the bare repo the production deploy pulls from → code execution as **root on the host** on the next 60s timer tick | R3 + `scripts/auto-update.sh:112-131` (`git fetch origin main``git reset --hard` → rebuild). *Conditional: depends on `/opt/gluecron`'s `origin` being the self-hosted repo — **not verified**, see §Unverified* | **Critical (if confirmed)** | The host and its five co-tenants |
53| R5 | A CI step can signal PID 1 (`kill 1`) — same UID, same PID namespace, no `--cap-drop`, no PID isolation | `Dockerfile:48`; `docker-compose.standalone.yml` (no `pid:`, no `cap_drop`, no `security_opt`) | **High** | Platform availability |
54| R6 | CI memory competes with the app's 4 GB cgroup; a CI OOM is an app OOM and vice versa | `docker-compose.standalone.yml:102-117`, commit `9d26051` (`bunx tsc --noEmit` → exit 137 at 2 GB) | **High** | Platform availability, every user |
55| R7 | Workflow secrets are written **in plaintext** into job logs and the `steps` JSON: the substituted command string is what gets persisted and rendered | `src/lib/workflow-runner.ts:518-521` (substitute), `:607` (substituted string returned as `run`), `:808` (`$ ${result.run}` into logs), `:870` (`steps: JSON.stringify(stepResults)`); no masking exists in `workflow-secrets.ts` or the runner | **High** | Any repo using `${{ secrets.X }}`; public repos leak to the world |
56| R8 | A job whose steps are all `uses:` is reported **success** having executed nothing | `src/lib/workflow-runner.ts:66` ("`uses` … tolerated but ignored"), `:524-535` (no `run:``skipped`), `:812-821` (`anyFailed` stays false → `status = "success"`) | **High** | Anyone trusting a green check; the merge gate |
57| R9 | Multi-line `run:` blocks use `bash -c` with **no `-e`** — GitHub Actions uses `bash -e {0}`. Every line but the last can fail silently | `src/lib/workflow-runner.ts:547-550` | **High** | Every imported GitHub workflow: same YAML, different semantics, quietly |
58| R10 | `checkCiWorkflows` fails OPEN on zero runs, on stale runs, and on any DB error | `src/lib/gate.ts:500-507`, `:561-568`, `:574-581` | **High** | Merge safety |
59| R11 | A node-free image turns whole tool families into no-op passes (vitest exits 0 with zero tests — peer-measured, not reproduced here) | `Dockerfile:10-14` (installs git, ca-certs, zip, wget — no node, no npm, no Chromium); `scripts/ci-tests.sh:21` (`node: command not found`, exit 127); `.gluecron/workflows/deploy.yml:31,51` | **High** | Anyone porting a Node repo onto Gluecron |
60| R12 | Unrestricted network egress from CI: no policy, no allowlist; `bun install` pulls arbitrary npm packages and runs them | `.gluecron/workflows/ci.yml:23` (`bun install --frozen-lockfile`); nothing in the compose file constrains the network | **High** | Exfiltration path for R2/R3; supply chain |
61| R13 | No disk quota on the CI checkout; `mkdtemp(tmpdir())` writes to the container layer on a disk shared with five other platforms | `src/lib/workflow-runner.ts:643`; commit `d087cf6` (that disk hit 96% full) | **Medium** | Host and co-tenants |
62| R14 | `runs-on` is parsed, defaulted, and stored — and dispatches nothing. `ubuntu-latest` in the platform's own docs runs on the production container | `src/lib/workflow-runner.ts:753-756`, `:766`; `src/db/schema.ts:1559`; `src/routes/docs.tsx:673,732,738,781,813,861,879` | **Medium** | User expectations; every imported workflow |
63| R15 | `workflow_runner_pool` is queried as a liveness truth-claim ("A CI runner is alive right now") and **nothing ever writes to it** | `src/lib/truth-ledger.ts:266-275`; grep for writers finds only `src/db/schema.ts:3120` and `drizzle/0037_workflow_engine_v2.sql:80` | **Low** | Operator trust in the ledger |
64| R16 | A CI step can reach the app on loopback and any host the box can reach (SSRF-from-inside) | No egress policy; the app listens on `:3000` in the same netns (`src/index.ts:148-159`) | **Medium** | Internal API surface |
65
66---
67
68## How it actually works
69
70### 1. Where and how jobs execute
71
72- **The worker is the app.** `src/index.ts:5,60``startWorker()` is called
73 in the application entrypoint. There is no separate runner process, service,
74 or container. The Actions worker, the webhook worker, autopilot, the SSH
75 server and the HTTP server are one Bun process.
76- **The step primitive.** `src/lib/workflow-runner.ts:551-563`:
77 ```ts
78 const stepCmd = process.platform === "win32"
79 ? ["bash", "-c", run]
80 : ["nice", "-n", "10", "bash", "-c", run];
81 proc = Bun.spawn(stepCmd, { cwd: checkoutDir, stdout: "pipe", stderr: "pipe",
82 env: buildRunnerEnv({ CI: "true", GLUECRON_RUN: runId, GLUECRON_CI: "1" }) });
83 ```
84 That is the entire isolation story: a nice level and a curated env map.
85- **Working directory.** A fresh `mkdtemp(join(tmpdir(), "gluecron-run-"))`
86 (`:643`) → `<tmp>/checkout`, populated by a **full local clone of the bare
87 repo path** (`:655-657`, `git clone --quiet <repoRow.diskPath> <checkoutDir>`).
88 No smart-HTTP, no auth, no depth limit — a direct filesystem read of
89 `/data/repos/<owner>/<repo>.git`.
90- **User.** `Dockerfile:48` `USER bun` (uid 1000 — corroborated by the core-dump
91 filenames in commit `d087cf6`: `core._usr_local_bin_bun.1000.*`). The step
92 inherits that uid. It is the *same uid as PID 1*, and `/app` and `/data/repos`
93 are both `chown bun:bun` (`Dockerfile:47`).
94- **Isolation.** None. No namespace, no cgroup of its own, no seccomp profile,
95 no `cap_drop`, no read-only mounts, no user separation, no chroot. A repo-wide
96 grep for `unshare|bwrap|firejail|nsjail|seccomp|cap-drop|cgroup|setuid|chroot`
97 in `src/` returns **zero** matches. (Negative grep — it proves no such code is
98 referenced in the tree, which together with the compose file having no
99 `security_opt`/`cap_drop`/`pid`/`userns_mode` keys is sufficient here, because
100 those are the only two places such a control could be declared.)
101- **Admission control** (the only resource protection that exists):
102 - `resolveWorkflowConcurrency()` (`:1257-1263`) — default **1**, clamped to 16.
103 The comment records why: a default of 3 "took production down three times in
104 two hours" on 2026-08-22.
105 - `shouldDeferForLoad()` (`:1107-1116`) — refuses to claim a run while 1-minute
106 load exceeds 2× cores. Backpressure, not isolation.
107 - `nice -n 10` (`:547-550`) — CPU priority only. Does nothing for memory, disk,
108 file descriptors, PIDs, or the network.
109 - `STEP_TIMEOUT_MS = 600_000` (`:42`), SIGTERM→SIGKILL with a 5s grace (`:565-579`).
110 Per-*step*; there is no per-run wall clock. A workflow with 50 steps can hold
111 the single serial slot for 8 hours.
112- **Does `runs-on` do anything?** No. It is parsed (`workflow-parser.ts:598-599`),
113 defaulted to `"default"`, carried through the extended parser, resolved at
114 `workflow-runner.ts:753-756`, and written to `workflow_jobs.runs_on`
115 (`:766`, `src/db/schema.ts:1559`). No code reads it back to select an executor.
116 `runs-on: self`, `runs-on: default` and `runs-on: ubuntu-latest` — the last of
117 which the platform's own documentation recommends at
118 `src/routes/docs.tsx:673` and scaffolds into every new repo at
119 `src/lib/first-repo-scaffold.ts:59` — are byte-for-byte identical in effect:
120 all three run `bash -c` in the production container.
121
122### 2. Blast radius
123
124Taking each question in turn.
125
126**Can a step read the app's environment?** `buildRunnerEnv`
127(`:169-182`) copies only 17 names (`:127-147`) and screens them against a
128credential-shaped denylist (`:157-158`). Within its own frame, it is correct and
129carefully done. It is also irrelevant, because the step runs as uid 1000 in the
130same PID namespace as PID 1, which is a uid-1000 process holding the **entire**
131`.env``docker-compose.standalone.yml:39-40` deliberately switched from an
132allowlist to `env_file: .env` so every documented variable reaches the app.
133Linux exposes that at `/proc/1/environ`, mode 0400, owned by the process owner;
134Docker mounts `/proc` without `hidepid`. `tr '\0' '\n' < /proc/1/environ` is
135therefore a one-line, no-exploit read of `DATABASE_URL`, `WORKFLOW_SECRETS_KEY`
136(the master key for *every tenant's* workflow secrets), `SERVER_TARGETS_KEY`,
137`ANTHROPIC_API_KEY`, `GOOGLE_OAUTH_CLIENT_SECRET`, `DEPLOY_EVENT_TOKEN`, and any
138`GLUECRON_PAT` present. **Not empirically executed** (I have no shell on the
139box); it follows from `USER bun` + default `/proc` + `env_file`, and the
140verification is one command — see §Unverified.
141
142**Its filesystem?** Yes. `/app` is `chown bun:bun` (`Dockerfile:47`), so a step
143can read the entire source tree *and write to it*. Writes survive a container
144**restart** (autoheal restarts, it does not rebuild) though not a redeploy — so
145a step can patch `/app/src/**` and have the modified app serve traffic for up
146to a deploy cycle.
147
148**The bare git repos?** Yes, read **and write**. `git-repos:/data/repos`
149(`docker-compose.standalone.yml:77-78`) is the live store for every repository
150on the platform, laid out `/data/repos/<owner>/<name>.git`
151(`src/git/repository.ts:100-112`), owned by `bun` (`Dockerfile:47`). A CI step in
152any throwaway repo can `git log`/`git cat-file` every private repo of every
153user, and can force-update their refs. There is no per-repo boundary of any
154kind, because there is no boundary at all.
155
156**The database?** Not directly — `DATABASE_URL` is stripped from the step env
157and the Neon connection is over the public internet. But via `/proc/1/environ`
158(above) plus unrestricted egress (below), yes, completely: read/write access to
159all 161 tables, from a network the DB's IP allowlist already trusts.
160
161**The network?** Unrestricted. Nothing in the compose file, the Dockerfile, or
162the runner constrains egress; `.gluecron/workflows/ci.yml:23` runs `bun install`
163against the public npm registry as normal operation. `wget` is present by
164design (`Dockerfile:10-14`); `curl` is not, which the deploy workflow discovered
165the honest way (`.gluecron/workflows/deploy.yml:28-31`). Loopback is reachable,
166so a step can also call the app's own API from inside the trust boundary.
167
168**Kill or OOM the app?** Yes, both. Same UID + same PID namespace + no
169`cap_drop` means `kill -9 1` is available to any step; the container then
170restarts (`restart: unless-stopped`) and autoheal papers over the rest. OOM is
171easier and needs no malice — see §3.
172
173**What is mounted / what limits apply** (`docker-compose.standalone.yml`):
174- Mounts: `git-repos:/data/repos` only (`:77-78`). **The docker socket is NOT
175 mounted into the `gluecron` service** — only into `autoheal` (`:160`). That
176 matters for the design below: today there is no path from CI to the Docker API,
177 and any per-job-container design would create one.
178- Limits: `mem_limit`/`memswap_limit` `4g` (`:116-117`), `ulimits.core: 0`
179 (`:118-121`), `stop_grace_period: 20s` (`:125`). **No** `cpus`, **no**
180 `pids_limit`, **no** disk quota, **no** `read_only`, **no** `cap_drop`, **no**
181 `security_opt`, **no** `userns_mode`, **no** `tmpfs` for `/tmp`.
182
183### 3. The OOM evidence — can CI degrade or kill the live app?
184
185Yes, and it already has. The chain is recorded in two commits, one week old.
186
187`d087cf6` *"bound the container's memory, and stop it writing 13 GB core dumps"*:
188the container ran with `HostConfig.Memory=0` — no limit — and leaked steadily
189until a **shared 7.9 GB box hit 96% full**, having written **five bun core dumps
190of 10.7–13.3 GB each** between 25–27 Aug, on a disk five other platforms depend
191on. The commit adds `mem_limit: 2g` and `ulimits.core: 0`. Its own closing note
192is worth quoting because it names the failure mode that hides all of this: the
193autohealer beside the app restarted it each time, so "an autohealer plus no
194limit converts a visible crash into a silent cycle."
195
196`9d26051` *"raise the memory ceiling above what the build needs"* — one merge
197later:
198
199```
200==> Typecheck
201$ bunx tsc --noEmit
202[exit 137 in 6689ms]
203```
204
205137 = 128+9 = SIGKILL. The 2 GB cgroup the app was given killed the typecheck
2066.7 seconds in. The commit message states the mechanism plainly: *"the workflow
207runner executes INSIDE this container, so the app's memory bound also bounds
208every build and test run."* The fix was to raise the app's ceiling to 4 GB
209(`docker-compose.standalone.yml:116`) — i.e. **the production app's memory limit
210is now set by what `tsc` needs, not by what the app needs**, and the in-tree
211comment at `:102-112` says so: *"THE CEILING MUST CLEAR THE BUILD, NOT JUST THE
212APP … do not drop below what tsc needs unless CI moves out of this container."*
213
214So the coupling is bidirectional and both directions are evidenced:
215
2161. **App → CI.** A memory bound sized for the app kills builds (exit 137, with
217 *no test failures reported*, because the process died before it could report
218 any — `9d26051`).
2192. **CI → app.** A CI run's allocation comes out of the same 4 GB cgroup the app
220 is serving traffic from. A test suite that peaks 2 GB leaves the app 2 GB; a
221 run that peaks 4 GB gets the whole container OOM-killed — app included — and
222 autoheal restarts it, which is exactly the silent cycle `d087cf6` describes.
2233. **CI → app, via CPU.** Independently evidenced at
224 `src/lib/workflow-runner.ts:1247-1256`: three concurrent runs "starved the
225 box's CPU until new TCP connections timed out platform-wide while kernel ICMP
226 still answered — which masqueraded convincingly as a firewall wedge." Three
227 production outages in two hours, 2026-08-22. The mitigations that came out of
228 it — concurrency 1, `nice -n 10`, load-defer — are all rationing of a shared
229 resource, which is what you do when you cannot isolate.
230
231There is a fourth, structural one: the deploy timer recreates the container
232roughly every 60 seconds when commits land, killing any in-flight run. The
233runner needs a boot sweep (`requeueRunsOrphanedByRestart`, `:358-436`) and an
234hourly reaper (`reapStuckRuns`, `:302-328`) purely to clean up after the app it
235lives inside being restarted. The `verify-deploy` workflow is "killed BY the
236very deploy it verifies on every push to main" (`:344-346`). That entire class of
237code exists only because CI shares a lifecycle with the app.
238
239### 4. Correctness risk — silently green gates
240
241Two independent failure modes. The second is worse than the one the question asks
242about, and it is verifiable from the source rather than by measurement.
243
244**(a) Structural: a job can be green having executed nothing.**
245`runStep` returns `status: "skipped"` for any step without a `run:`
246(`:524-535`); `uses:` is explicitly "tolerated but ignored" (`:66`).
247`executeJob` sets `anyFailed` only on a `failure` result (`:812-817`) and
248concludes `status = anyFailed ? "failure" : "success"` (`:821`). Therefore:
249
250```yaml
251jobs:
252 test:
253 runs-on: ubuntu-latest
254 steps:
255 - uses: actions/checkout@v4
256 - uses: actions/setup-node@v4
257 - uses: ./.github/actions/run-tests
258```
259
260…is recorded as **success**, with three skipped steps, zero commands run. This is
261not a hypothetical shape: it is roughly what `src/routes/docs.tsx:673-690` tells
262users to write, and `actions/checkout@v4` appears in the placeholder the importer
263shows on screen (`src/routes/actions-importer.tsx:1082`). Every GitHub repo
264imported onto this platform whose test invocation lives behind a composite
265action gets a permanent green check for a suite that never ran. An empty
266`steps: []` is green for the same reason.
267
268**(b) Structural: `bash -c` without `-e`.** `:547-550` spawns
269`bash -c "$run"`. GitHub Actions' default Linux shell is `bash -e {0}` — fail on
270first error. So for every multi-line `run:` block, GitHub fails at the first bad
271command and Gluecron reports the exit status of the **last** one:
272
273```yaml
274- run: |
275 npm ci
276 npm test # fails
277 echo done # exit 0 → step is GREEN
278```
279
280This affects every imported workflow, silently, with no error anywhere. It is the
281single highest-yield one-line fix in this audit (see Stage 0).
282
283**(c) Tool families that exit 0 having done nothing in a node-free image.**
284The image ships `git`, `ca-certificates`, `zip`, `wget` on `oven/bun`
285(`Dockerfile:10-14`) — no `node`, no `npm`, no `npx`, no Chromium, no `curl`.
286Three in-tree corroborations: `scripts/ci-tests.sh:21` records the design-audit
287gate's first firing failing with `node: command not found` (exit 127);
288`.gluecron/workflows/deploy.yml:31` records 36 polls of `curl: command not
289found`; `:51` records "the runner is inside the app container, which ships no
290Chromium".
291
292Sorting by whether the failure is *loud*:
293
294| Tool | Behaviour without `node` | Loud? |
295|---|---|---|
296| `bunx vitest` | **exit 0, "Test Files no tests"** — across forks/threads/vmThreads (peer measurement, *not reproduced here*) | **Silent** |
297| `jest --passWithNoTests`, or any runner whose worker spawn failure is read as "no tests discovered" | exit 0, zero tests | **Silent** |
298| `eslint .` where the plugin resolution fails to load rules, or the glob matches nothing | exit 0, zero findings | **Silent** |
299| Any step that is `uses:` only (§4a) | exit 0, zero commands | **Silent** |
300| Any non-final line of a multi-line `run:` (§4b) | ignored | **Silent** |
301| `npm ci`, `npm test`, `npx …` | exit 127 `command not found` | Loud |
302| `node script.js`, `tsx`, `ts-node`, `#!/usr/bin/env node` bin shims | exit 127 | Loud |
303| `playwright test` | fails — no browsers, and `@playwright/test` is a devDependency the production image does not install (`Dockerfile:18` `--production`) | Loud |
304| npm lifecycle scripts (`postinstall`) | not reached — `bun install` is the only installer present | Loud/absent |
305
306The honest summary: the node-free environment produces *mostly loud* failures
307(127 is hard to miss), and the genuinely silent class is narrower than "every
308Node tool" — but it contains **the test runners**, which is the only category
309that matters for a gate. And Gluecron adds two silent classes of its own (a and
310b) that have nothing to do with Node and affect Bun-native workflows equally.
311
312**(d) `checkCiWorkflows` fails open.** `src/lib/gate.ts:474-582`. Three
313fail-open paths:
314
315- **zero runs for the sha** → `{ passed: true, skipped: true, details: "No CI
316 workflows ran for this commit" }` (`:500-507`). The comment justifies it —
317 "no CI configured is not a failure" — which is right for a repo with no
318 workflows and wrong for a repo whose workflow *failed to enqueue*. The gate
319 cannot distinguish those two, because both look like an empty result set.
320- **stale** (running > 30 min) → `passed: true, skipped: true` (`:561-568`).
321- **any DB error**`passed: true, skipped: true` (`:574-581`).
322
323What this means for enforcement is more nuanced than "the gate is useless", and
324the nuance is in the platform's favour:
325
326- The **merge gate** (`runAllGateChecks`, `:672-702`) treats `skipped` as
327 passing, so all three paths let a merge through.
328- The **required-status-checks matrix** does *not*: `passingCheckNames`
329 (`src/lib/branch-protection.ts:233-256`) collects `gate_runs` rows with status
330 `passed`/`repaired`, and a skipped check is persisted as `"skipped"`
331 (`src/lib/gate.ts:756-762`). So a branch rule with a required check named
332 `CI` **does** block a PR with zero runs. That is correct behaviour and it
333 should be said plainly.
334- But `passingCheckNames` also credits a required check from a
335 `workflow_runs` row with `status = 'success'`
336 (`src/lib/branch-protection.ts:215-224`). Combined with §4a/§4b/§4c, a run
337 that executed nothing satisfies the required check by name. The strict path
338 and the silently-green path meet here.
339- And `branch_required_checks` is empty unless someone configured it
340 (`listRequiredChecks`, `:202-213`, returns `[]` on absence *and* on error), so
341 the default posture across the platform is the fail-open one.
342
343**(e) A corroborating dead check.** The truth ledger asserts *"A CI runner is
344alive right now"* by counting heartbeating rows in `workflow_runner_pool`
345(`src/lib/truth-ledger.ts:266-275`). Nothing in the codebase ever inserts or
346updates that table — the only other references are the schema definition
347(`src/db/schema.ts:3120`) and the migration that created it
348(`drizzle/0037_workflow_engine_v2.sql:80`). The claim can only ever report zero.
349It is a check that cannot pass, sitting beside a gate that cannot fail.
350
351---
352
353## The design
354
355Target: **ephemeral per-job containers on the same box**, orchestrated by the
356app, with the app's Docker access mediated by a tiny privileged broker rather
357than a raw socket mount.
358
359### Why per-job, not a persistent pool
360
361A persistent runner pool (the shape `workflow_runner_pool` was schemad for)
362buys warm caches and lower startup latency, and costs the thing this audit is
363about: state carried between jobs, hence between tenants. On a single box with a
364serial queue, the pool's advantage is small — one warm container is one job's
365worth of latency saved — and the isolation regression is total. Choose
366**ephemeral per job**, and recover the cache benefit with a *named volume per
367repo* mounted only into that repo's jobs (`bun install` cache, nothing else).
368
369### Runner image
370
371Separate image, built once, `gluecron-runner:<tag>`, never the app image:
372
373```dockerfile
374FROM debian:bookworm-slim
375RUN apt-get update && apt-get install -y --no-install-recommends \
376 git ca-certificates curl wget xz-utils unzip bash \
377 && rm -rf /var/lib/apt/lists/*
378COPY --from=oven/bun:1.3 /usr/local/bin/bun /usr/local/bin/bun
379COPY --from=node:22-slim /usr/local/bin/node /usr/local/bin/node
380# npm/npx via the node image's lib/node_modules, or install nodejs from apt
381RUN useradd -u 2000 -m runner
382USER runner
383```
384
385Size estimate: Debian slim ~75 MB + git ~50 MB + bun ~95 MB + node+npm ~120 MB ≈
386**330–380 MB**. A `chromium` variant adds ~400 MB plus fonts and shared libs
387(≈900 MB–1.1 GB total) — build it as a *second tag* (`gluecron-runner:browser`)
388selected by `runs-on`, so the common case stays small. On a 7.9 GB shared box,
389image size is disk, not RAM; the disk is the constrained resource (see the 96%
390incident), so two tags is the ceiling — do not build a matrix.
391
392**This is also where `runs-on` finally becomes real:** `default`/`self`
393`gluecron-runner:<tag>`; `browser`/`ubuntu-latest-browser` → the chromium tag;
394anything unrecognised → fail the job with a clear message rather than silently
395running it somewhere else (today's behaviour). Mapping `ubuntu-latest` to the
396base tag is the pragmatic choice for imported workflows, and it should be
397*documented as an alias*, not left to be inferred.
398
399### Getting the repo into the job
400
401Two options; take the second.
402
4031. **Volume-mount the bare repo read-only** (`/data/repos/<o>/<r>.git:ro`) and
404 clone from it inside the job. Fast, no auth, no network. But it re-creates the
405 thing being fixed — the job's mount namespace touches the live store, and one
406 `:ro` typo in a future edit is a full regression.
4072. **Clone over loopback smart-HTTP with a scoped, single-use token.** The
408 runner container joins a dedicated docker network with the app reachable at
409 `http://gluecron:3000`, and the job clones
410 `http://x:<job-token>@gluecron:3000/<owner>/<repo>.git`. The token is minted
411 per run, scoped to *read that one repo*, and expires when the run finishes.
412 Costs a protocol round-trip and needs a new short-lived-token type; buys the
413 property that the runner has **no filesystem path to any repository**, which
414 is R3 closed by construction rather than by mount flags.
415
416Take (2). It also gives `actions/checkout`-equivalent semantics for free, and
417makes the eventual second box a configuration change rather than a redesign.
418
419### Resource limits per job
420
421Per-container, enforced by the daemon, not by politeness:
422
423| Limit | Value | Why |
424|---|---|---|
425| `--memory` | 1.5g (`--memory-swap` equal — no swap) | Fits beside a 4 GB app on a 7.9 GB shared box. `tsc` on ~400 files needs >2 GB in the *current* shape, but that measurement includes the app's resident set; budget 1.5g and raise per-repo if a real workload proves it short. **Unverified** — this number needs one measured run. |
426| `--cpus` | 1.0 (of 4) | Replaces `nice -n 10` with an actual ceiling. The 2026-08-22 outage was CPU starvation. |
427| `--pids-limit` | 512 | Fork-bomb containment — nothing today provides this. |
428| `--storage-opt size=10G` *(overlay2+xfs only)* or a `--mount type=volume` with a quota'd backing store | 10 GB | R13. If the box's storage driver does not support `--storage-opt`, fall back to a per-job volume on a size-capped filesystem, or a `du` watchdog. **Unverified: the box's storage driver is unknown.** |
429| `--read-only` + `--tmpfs /tmp:size=1g` | on | Workspace on an explicit writable mount; everything else immutable. |
430| `--cap-drop=ALL`, `--security-opt=no-new-privileges` | on | Removes the capability set entirely. |
431| `--user 2000:2000` | non-root, **and a different uid than the app's 1000** | Even a mount misconfiguration then fails on permissions. |
432| Wall clock | per-step 10 min (keep) **+ per-run 30 min** | There is no per-run ceiling today; add one. |
433| `--network gluecron-ci` | see below | |
434
435### Network egress policy
436
437Default **deny-all except the app on loopback-equivalent** is the correct
438posture and is not achievable for most real workflows, because `bun install` /
439`npm ci` need a registry. Staged:
440
441- **Phase A:** attach jobs to an internal docker network carrying the app only
442 (`internal: true`), plus an explicit egress allowlist implemented as an
443 HTTP(S) proxy container (`HTTP_PROXY`/`HTTPS_PROXY` injected into the job env)
444 that permits `registry.npmjs.org`, `github.com`, and per-repo additions.
445 Everything else fails closed and *is logged*, which is how you discover what
446 workflows actually reach for.
447- **Phase B (default for untrusted/fork runs):** `--network none` plus a
448 pre-populated dependency volume, for repos that opt into hermetic builds.
449
450Even Phase A alone closes the exfiltration half of R2/R3: a secret read is
451useless if it cannot leave.
452
453### Logs and exit codes
454
455Keep the current contract, change the transport. Today the runner reads
456`proc.stdout`/`proc.stderr` to completion and then writes one capped blob
457(`workflow-runner.ts:581-603`, `:820`, `:862-878`) — no streaming
458(`:8-10` says so explicitly). With containers:
459
460- `docker run --rm` with stdout/stderr piped back to the orchestrating process,
461 read incrementally; append to the existing SSE topic
462 (`_ssePublish`, `:1381-1396`) so the Actions UI streams live — a feature the
463 current design lists as a v1 omission.
464- Exit code from `docker wait` (or the `docker run` exit status), mapped to the
465 same `StepResult.exitCode` field. The 137-is-OOM case becomes *legible*:
466 inspect `State.OOMKilled` and record `conclusion: "oom"` rather than a bare
467 exit 137 with no failures listed — which is exactly the confusion `9d26051`
468 describes.
469- Keep `JOB_LOG_CAP_BYTES`/`STEP_STREAM_CAP_BYTES` (`:51,:54`) as the DB-row
470 bound; stream the full log to a file under a per-run directory if full logs
471 are wanted later.
472
473### Secrets
474
475Fix the current model while moving. Today secrets are **text-substituted into
476the command string** (`:518-521`) and that substituted string is persisted to
477`workflow_jobs.logs` and `steps` (`:607`, `:808`, `:870`) with no masking — R7.
478In the new design:
479
480- Write the secret map to a `tmpfs`-backed env file that is mounted into the
481 job at start (`--env-file` reads it at create time; the file never lands in
482 the image and is unlinked immediately after), or pass via `--env` on the
483 create call. **Never** into the image, never into a bind-mounted path that
484 outlives the job.
485- Stop substituting into the command string. Substitute into the *environment*,
486 and expand `${{ secrets.X }}` to `$X` in the script. The step's `run` text then
487 contains no plaintext, so the existing logging path is safe by construction.
488- Add an output filter regardless (belt and braces): replace any exact secret
489 value in captured stdout/stderr with `***` before persisting. Cheap, and it
490 catches the case where a user's own script echoes a secret.
491
492### Concurrency and queueing
493
494Keep the existing DB queue (`workflow_runs` + the atomic
495`queued→running` claim at `:1157-1176`) — it is already multi-worker-safe and
496the claim is correct. Changes:
497
498- `WORKFLOW_CONCURRENCY` becomes meaningful: with hard per-job limits, 2
499 concurrent jobs at 1.5g/1 CPU is a *bounded* 3 GB / 2 CPUs, which the box can
500 answer for. Today's default of 1 exists precisely because the cost was
501 unbounded.
502- Keep `shouldDeferForLoad` (`:1107-1116`) as a second line of defence.
503- Add a **per-repo concurrency cap** (1 by default) so one busy repo cannot hold
504 every slot — the current serial queue hides this problem by having one slot.
505- Add the missing **per-run wall clock**, and make the reaper reconcile against
506 the Docker API (`docker ps` by label) rather than inferring death from a
507 timestamp. That deletes the guesswork in `reapStuckRuns`/
508 `requeueRunsOrphanedByRestart`, and — because the job container survives the
509 app container being recreated — the "deploy kills its own verify-deploy run"
510 pathology disappears.
511
512### The docker socket question
513
514**Yes, something must reach the Docker API, and that is the design's principal
515new risk.** Mounting `/var/run/docker.sock` into the app container is
516*equivalent to root on the host*: anyone with code execution in the app could
517`docker run -v /:/host --privileged`. Given that R1 is "arbitrary code execution
518in the app container is a product feature", mounting the raw socket there would
519convert today's container-scoped compromise into a host-scoped one. **Do not
520mount the socket into the app container.**
521
522Options, best first:
523
5241. **A broker sidecar.** A small container that *does* hold the socket and
525 exposes an HTTP API with exactly four verbs — `create-job(image-tag, repo,
526 run-id)`, `attach-logs`, `wait`, `kill` — with the image tag chosen from a
527 server-side allowlist and every flag (limits, network, user, caps) hardcoded
528 in the broker, not passed by the caller. The app can then only ask for jobs
529 it is permitted to ask for. ~200 lines. This is the recommended shape.
5302. **Rootless Docker or Podman** for the runner daemon, so even a broker
531 compromise is uid-scoped rather than root.
5323. **`docker-socket-proxy`** (Tecnativa) in front of the socket with only
533 `CONTAINERS=1, POST=1` — coarser than (1), but off-the-shelf.
534
535Note the box *already* mounts the socket into `autoheal`
536(`docker-compose.standalone.yml:160`), so the host's exposure is not zero today;
537that is an argument for keeping the new surface tight, not for relaxing.
538
539---
540
541## Staged migration
542
543No flag day. Each stage is independently shippable and independently valuable.
544
545### Stage 0 — this week, hours, no architecture change
546
547Cheapest meaningful safety improvement, in priority order:
548
5491. **`bash -e`** (R9). `["nice","-n","10","bash","-ec", run]` at
550 `workflow-runner.ts:549-550`. One character. Restores GitHub-Actions
551 fail-fast semantics for every multi-line step.
552 *Buys:* the largest single class of false-green. *Costs:* nothing.
553 *Breaks:* workflows that currently rely on a failing line being ignored —
554 they were already broken and will now say so. Announce it.
5552. **Fail a job whose steps all skipped** (R8). In `executeJob`
556 (`:820-821`): if `stepResults.length > 0 && stepResults.every(s => s.status
557 === "skipped")`, conclude `failure` with `no_executable_steps`. Same for
558 `steps: []`.
559 *Buys:* `uses:`-only workflows stop reporting green. *Costs:* nothing.
560 *Breaks:* every imported GitHub workflow that is `uses:`-only goes red —
561 which is the correct signal and will be unwelcome. Consider `conclusion:
562 "unsupported"` and a distinct UI treatment so it reads as "this platform
563 can't run this" rather than "your code is broken".
5643. **Mask secrets in logs** (R7). Substitute into env instead of the command
565 string, and add an output filter. Half a day.
5664. **Split the fail-open** (R10). `checkCiWorkflows` returning `skipped` for
567 *zero runs* is defensible; returning it for a **DB error** (`:574-581`) is
568 not — that is "the check could not run", which the codebase already has a
569 vocabulary for (see `181f2e6`, "a gate that never ran is not a gate that
570 passed"). Give it a third state and let branch protection decide.
5715. **`pids_limit`, `cpus`, and a `tmpfs` for `/tmp` on the app container.**
572 Three compose lines. Bounds fork bombs and CPU starvation, and moves CI
573 scratch off the shared disk. *Costs:* `/tmp` tmpfs consumes RAM — size it
574 at 1g and subtract from the 4g ceiling.
5756. **Fix the dead truth-claim** (R15): either write heartbeats to
576 `workflow_runner_pool` from `startWorker`, or replace the claim with one
577 that can pass. A permanently-zero liveness claim trains people to ignore
578 the board.
579
580Stage 0 buys **correctness**, not isolation. R1–R5 are untouched. Say so
581plainly when reporting it.
582
583### Stage 1 — the security floor, ~1 week
584
585Runs before any container work and is worth shipping even if Stage 2 never
586happens:
587
588- **Gate workflow execution on trust.** Only run workflows for repos whose
589 owner is on an allowlist (start: the owner's own account) — everyone else's
590 runs are enqueued and immediately concluded `not_permitted` with an honest
591 message. This converts R1 from "anonymous RCE" to "owner-only RCE" for the
592 cost of a config table.
593- **Close `/proc`** (R2): add `--pid=host`-free… no — the correct control here
594 is running the *step* as a different uid than the app. `nice -n 10 setpriv
595 --reuid=2000 --regid=2000 --clear-groups bash -ec` requires the app to be
596 able to drop to another uid, which it cannot as a non-root user. So the
597 realistic Stage-1 mitigation is: run the container as root with
598 `--cap-add=SETUID,SETGID` and have the app drop privileges per step —
599 distasteful — **or** accept that R2 is not fixable in-container and let it be
600 the argument for Stage 2. *I recommend the latter: do not contort the app
601 container. R2 is the reason isolation is not optional.*
602- **Egress allowlist via proxy env vars** — even without containers, setting
603 `HTTP_PROXY`/`HTTPS_PROXY` in `buildRunnerEnv` to a filtering proxy stops the
604 well-behaved 90% of exfiltration and, more importantly, produces the log of
605 what workflows actually reach for, which Stage 2's policy needs.
606
607### Stage 2 — the broker and the first isolated job, ~1–2 weeks
608
609- Build `gluecron-runner:base`, publish to a local registry or `docker save` on
610 the box.
611- Write the broker sidecar (§docker socket, option 1). It holds the socket; the
612 app holds an HTTP client and no socket.
613- Add an `ISOLATED_RUNNER=1` env flag. When set, `runStep` posts the whole
614 *job* (not step) to the broker; when unset, today's path runs unchanged. Both
615 code paths live side by side — this is what removes the flag day.
616- Migrate **one** workflow first: Gluecron's own `.gluecron/workflows/ci.yml`.
617 It is the highest-value target (it is what gates merges), the failure mode is
618 visible immediately, and it is the workflow whose memory profile forced the
619 4 GB ceiling. Success criterion: `bunx tsc --noEmit` and
620 `bash scripts/ci-tests.sh` pass inside a 1.5 GB job container, and the app's
621 `mem_limit` can go *back down* to something sized for the app.
622- *Breaks:* the checkout path changes (loopback clone with a scoped token), so
623 the short-lived-token type must land first. Cache-action paths
624 (`src/lib/actions/cache-action.ts`) assume a host-local `checkoutDir` and will
625 need to move to the per-repo volume.
626
627### Stage 3 — default on, ~1 week after Stage 2 is green
628
629- Flip `ISOLATED_RUNNER` to default-on; keep the in-container path behind
630 `LEGACY_RUNNER=1` for one release, then delete it.
631- Reopen workflow execution to all users (undo Stage 1's allowlist) — this is
632 the stage where multi-tenant CI becomes a product you can honestly sell.
633- Raise `WORKFLOW_CONCURRENCY` to 2 and add the per-repo cap.
634- Drop the app's `mem_limit` back to an app-sized number and record the new
635 ceiling in the compose comment, replacing the "must clear the build" note at
636 `:102-112`.
637- Make `runs-on` mean something: `default` → base image, `browser` → chromium
638 image, unknown → fail loudly.
639- Add per-job disk quota and the egress policy's deny-by-default mode for repos
640 that opt in.
641
642### Stage 4 — optional, later
643
644- Second box for CI only, once the loopback-clone design makes "which host" a
645 config value. Per the owner's provider preferences that is DigitalOcean or
646 Linode, not Hetzner.
647- gVisor (`--runtime=runsc`) for untrusted/fork PRs — the kernel boundary that
648 containers do not provide.
649
650---
651
652## What I could not verify
653
654Stated explicitly, per the standing rule that a negative grep proves nothing.
655
6561. **`/proc/1/environ` readability on the live container.** Inferred from
657 `USER bun` (`Dockerfile:48`), `env_file: .env`
658 (`docker-compose.standalone.yml:39-40`), and Docker's default `/proc` mount
659 (no `hidepid`). I have no shell on `100.109.131.122`. **One command settles
660 it:** `docker exec gluecron-gluecron-1 sh -c 'tr "\0" "\n" < /proc/1/environ | wc -l'`
661 — if that returns a count rather than "Permission denied", R2 is confirmed.
6622. **Whether `/opt/gluecron`'s `origin` is the self-hosted bare repo** (R4).
663 `scripts/auto-update.sh:112` fetches `origin main`; the remote URL is not in
664 the repo. **Settles it:** `git -C /opt/gluecron remote -v`. If it points at
665 `gluecron.com/ccantynz/Gluecron.com.git`, then R3's write access to
666 `/data/repos` is a path to root on the host within 60 seconds, and R4 is
667 Critical rather than conditional.
6683. **The vitest zero-test measurement.** Reported by a peer team; not
669 reproduced here (no node-free container available to me, and vitest is not a
670 dependency of this repo — `package.json:32-48`). Everything in §4c about
671 *this* codebase's tooling is verified in-tree; the vitest row is cited as
672 received.
6734. **`oven/bun:1.3` containing no `node` binary.** Strongly corroborated by
674 `scripts/ci-tests.sh:21` (`node: command not found`, exit 127, observed in a
675 real run) and by `Dockerfile:10-14` not installing it. I did not pull and
676 inspect the image.
6775. **Current free RAM and disk on the box, and the Docker storage driver.**
678 The 7.9 GB / 96%-full figures come from commit `d087cf6` (27 Aug), not from
679 a live reading. The 1.5 GB per-job memory figure in the design is a proposal
680 that needs one measured run of `bunx tsc --noEmit` in isolation; it is not a
681 measurement.
6826. **Whether any repo other than this one currently has workflows.** I did not
683 query the `workflows` table. The blast-radius argument does not depend on it
684 — R1 is about what is *possible*, not what has happened — but the urgency
685 ranking would change if the answer is "several tenants".
6867. **Whether the `nice -n 10` and load-defer mitigations are actually
687 effective.** They are the documented response to the 2026-08-22 outage and no
688 recurrence appears in the log, but "no recurrence in one week" is not
689 evidence of sufficiency.
Addeddocs/ops/SECRET-ROTATION-2026-08-29.md+141−0View fileUnifiedSplit
1# Secret rotation — CI runner exposure, 2026-08-29
2
3**Status:** owner action required. Nothing here has been executed.
4
5Claude does not handle live secret values. Every step below is written to be
6run by the owner on the box or in the provider console; the assistant can
7verify health afterwards, not move credentials.
8
9---
10
11## 1. Why rotate
12
13Workflow `run:` steps executed as `bash -c <user-supplied string>` inside the
14production application container, as the same uid as PID 1. Compose starts
15PID 1 with `env_file: .env`, so PID 1's environment holds every secret on the
16instance and is readable at `/proc/1/environ` by any process sharing that uid.
17`buildRunnerEnv`'s allowlist governs what a step is *handed*, not what it can
18*read*.
19
20Registration is open and a push enqueues a run, so the capability was
21reachable by anyone who signed up.
22
23**This is a rotation for possible exposure, not confirmed compromise.** No
24evidence of exploitation has been established. The window is: from whenever
25the workflow runner first executed third-party steps, until the containment
26gate (`WORKFLOW_EXEC_ALLOWLIST`) is deployed.
27
28Full analysis: `docs/AUDIT-CI-RUNNER-ISOLATION.md`.
29
30---
31
32## 2. Before rotating — close the door first
33
34Rotating while the hole is open just donates fresh credentials.
35
36- [ ] Deploy the containment gate (`workflow-exec-policy.ts`).
37- [ ] **Add `WORKFLOW_EXEC_ALLOWLIST=ccantynz` to `/opt/gluecron/.env`.**
38 The gate fails closed: without this line **no workflow runs at all**,
39 including this platform's own CI and the deploy-verification job.
40 Set it in the same maintenance window as the deploy.
41- [ ] Confirm: push a trivial commit, watch `/admin/deploys`, then check a
42 run at `/ccantynz/Gluecron.com/actions` reaches `success` and not
43 `blocked`.
44
45---
46
47## 3. Rotation order
48
49Ordered by blast radius. Each is independent — do them in sequence and
50verify between, rather than rotating everything and debugging a dark site.
51
52### 3.1 `DATABASE_URL` (Neon) — highest
53
54Full read/write on production data.
55
56- [ ] Neon console → reset the role password (or create a new role and
57 repoint).
58- [ ] Update `DATABASE_URL` in `/opt/gluecron/.env`.
59- [ ] `docker compose -f docker-compose.standalone.yml up -d` to restart.
60- [ ] Verify: `/api/health`, then load `/explore` and confirm repositories
61 still list (a page that renders its shell with no data is the failure
62 mode here, not a 500).
63- [ ] Neon → check active connections drop off the old role before deleting it.
64
65### 3.2 `WORKFLOW_SECRETS_KEY`
66
67Decrypts every stored per-repo workflow secret (AES-GCM).
68
69- [ ] **Rotating this invalidates every stored workflow secret** — they are
70 encrypted at rest with it. Plan to re-enter them, and list what they
71 are first from `/admin` or the repo settings pages.
72- [ ] Generate a new key, update `/opt/gluecron/.env`, restart.
73- [ ] Re-enter each repo's secrets.
74- [ ] Verify a workflow that consumes one still runs green.
75
76### 3.3 Personal access tokens
77
78Anything a step could have read from the environment or from the database.
79
80- [ ] Revoke and reissue the PAT in every agent `.mcp.json`
81 (`GLUECRON_PAT`). Pick "never expires" — see the PAT-expiry note in
82 CLAUDE.md.
83- [ ] Audit `/settings/tokens` for tokens you do not recognise, and
84 `/admin/oauth-clients` for authorised apps you did not authorise.
85- [ ] Reissue the credential the box-158 Vapron instance uses.
86
87### 3.4 OAuth client secrets
88
89- [ ] GitHub OAuth app secret (`GITHUB_OAUTH_CLIENT_SECRET`).
90- [ ] Google OAuth client secret (`GOOGLE_OAUTH_CLIENT_SECRET`).
91- [ ] Any SSO/SAML signing material if configured.
92- [ ] Verify each login path after rotating — these break silently and only
93 at the moment a real user tries to sign in.
94
95### 3.5 Third-party API keys
96
97- [ ] `ANTHROPIC_API_KEY` / any model-provider keys.
98- [ ] `STRIPE_SECRET_KEY` (and re-point the webhook signing secret).
99- [ ] `GATETEST_API_KEY`.
100- [ ] `EMAIL_HTTP_TOKEN`.
101- [ ] `GITHUB_TOKEN` / mirror push credentials.
102
103### 3.6 Git repository integrity
104
105The volume was mounted read-write, so a step could have rewritten refs in
106any repository — not only its own.
107
108- [ ] For repos that matter, verify the tip of each protected branch against
109 a known-good clone or the GitHub mirror.
110- [ ] `git fsck` on the bare repos in the `gluecron_git-repos` volume.
111- [ ] Check `/admin/errors` and the audit log for ref updates you cannot
112 account for.
113
114---
115
116## 4. Evidence check (optional, but cheap)
117
118Worth doing before assuming the worst — and worth doing regardless, because
119it bounds the window.
120
121- [ ] `workflow_runs`: any run whose repository is owned by an account other
122 than yours. If there are none, exposure was never externally reachable
123 in practice and this rotation is precautionary only.
124- [ ] Step logs for anything reading `/proc`, writing outside the checkout,
125 or making outbound network calls.
126- [ ] The `users` table for accounts you did not expect, and when they
127 registered.
128
129If that query returns zero third-party runs, record the result here — it
130converts this from "assume exposed" to "verified not reachable", which is a
131much better thing to have written down.
132
133---
134
135## 5. After
136
137- [ ] Note the rotation date and what was rotated in `docs/ops/OPERATIONS.md`.
138- [ ] Move the remaining secrets off the box (this is already on the standing
139 owner list as "off-box secrets").
140- [ ] Track the real fix — per-job isolation — rather than leaving the
141 allowlist as the permanent answer. It is a tourniquet.
Modifiedpackage.json+3−0View fileUnifiedSplit
2323 "doctor": "bun scripts/doctor.ts",
2424 "nav-audit": "bun scripts/nav-audit.ts",
2525 "selfcheck": "bun scripts/selfcheck.ts",
26 "readiness": "bun scripts/production-readiness.mjs",
27 "readiness:full": "bun scripts/production-readiness.mjs --private-repo ccantynz/Vapron --public-repo ccantynz/Gluecron.com",
28 "first-run": "bun scripts/first-run-journey.mjs",
2629 "e2e": "playwright test --config=e2e/playwright.config.ts"
2730 },
2831 "dependencies": {
Modifiedscripts/ci-tests.sh+7−3View fileUnifiedSplit
1717# Design-language gate — no purple, ever. The audit exits 1 when any
1818# purple/indigo/violet literal exists outside exemptions; it reached zero
1919# on 2026-08-27 and this line keeps it there. Cheap: no deps, static file
20# scan (~200 files, well under a second). MUST be `bun`, not `node` — the
21# CI container ships Bun only, and the gate's very first firing failed
22# with `node: command not found` (exit 127) instead of auditing anything.
20# scan (~200 files, well under a second). Stays `bun`, not `node`: the
21# gate's very first firing failed with `node: command not found` (exit 127)
22# instead of auditing anything, because the image shipped Bun alone. The
23# image now carries a node too (added for the CI runner — vitest without it
24# exits 0 having run zero tests; see the Dockerfile), so this line would no
25# longer 127. It stays on bun anyway — one runtime for our own scripts,
26# and bun is the one already loaded.
2327echo "--- design audit ---"
2428bun scripts/design-audit.mjs --top 15
2529
Addedscripts/production-readiness.d.mts+53−0View fileUnifiedSplit
1/**
2 * Types for the pure, testable half of the readiness gate.
3 *
4 * The gate stays plain .mjs so it runs under bare node on a machine with no
5 * toolchain — that portability is the point, and is why the deploy workflow
6 * can invoke it from inside the app container. These declarations exist so
7 * its verdict arithmetic can still be unit-tested from TypeScript instead of
8 * only being exercised by a live run against production.
9 */
10
11/** A gate ran and held, ran and broke, or could not be run at all. */
12export type GateState = "pass" | "fail" | "unverified";
13
14export interface GateResult {
15 gate: string;
16 hard: boolean;
17 state: GateState;
18 /** Retained for older readers of the --json output. */
19 ok?: boolean;
20 detail: string;
21}
22
23export interface Verdict {
24 /** 0 READY · 1 NOT_READY (a gate failed) · 3 INCONCLUSIVE (a gate never ran). */
25 code: 0 | 1 | 3;
26 label: "READY" | "NOT_READY" | "INCONCLUSIVE";
27 hardTotal: number;
28 /** Hard gates that ran AND passed — never includes an unverified row. */
29 hardPassed: number;
30 /** Hard gates that actually executed, whatever the outcome. */
31 hardVerified: number;
32 hardFails: GateResult[];
33 hardUnverified: GateResult[];
34 softFails: GateResult[];
35 softUnverified: GateResult[];
36}
37
38export function verdict(rows: GateResult[]): Verdict;
39
40/** Destinations the onboarding surface offers, read from source. */
41export function onboardingLinkPaths(file?: string): string[];
42
43/** Repo-scoped GET routes, read from the router registrations. */
44export function repoScopedGetRoutes(root?: string): string[];
45
46/**
47 * GET pages the app protects, read from source — inline middleware, the
48 * admin `await gate(c)` prologue, and file-level `.use(pattern, ...guards)`.
49 */
50export function authGuardedGetRoutes(root?: string): string[];
51
52/** Fill :owner/:repo and give any remaining params a placeholder. */
53export function materialize(routePath: string, owner: string, repo: string): string;
Modifiedscripts/production-readiness.mjs+384−54View fileUnifiedSplit
1717 * overflow /docs/agents was 3406px wide in a 1440 viewport; /login
1818 * overflowed at 390px.
1919 * auth-gate Authenticated-only paths must not render to anonymous users.
20 * onboarding Every destination the getting-started page offers must
21 * resolve. A 404 behind "Create repo" is terminal for the one
22 * visitor with no bookmarks to route around it.
2023 *
2124 * SOFT gates warn but do not block.
2225 *
26 * Every gate reports pass, fail, or UNVERIFIED — see the `push` helper. A
27 * gate that could not run is never scored as a pass, and never as a fail
28 * either; it blocks READY on its own terms and names the missing input.
29 *
2330 * Usage:
2431 * node scripts/production-readiness.mjs [--base https://gluecron.com]
2532 * [--expect-sha <sha>]
2633 * [--private-repo owner/name]
34 * [--public-repo owner/name]
35 * [--no-browser]
2736 * [--json out.json]
2837 *
29 * Exit codes: 0 = all HARD gates pass · 1 = a HARD gate failed · 2 = crashed.
38 * Exit codes:
39 * 0 READY every HARD gate ran and passed
40 * 1 NOT READY a HARD gate failed — a defect in the product
41 * 3 INCONCLUSIVE nothing failed, but a HARD gate never ran — a missing
42 * input in the caller, which is a different problem with
43 * a different owner, so it gets a different code
44 * 2 crashed
3045 *
3146 * Deliberately does NOT authenticate. Everything here must hold for an
3247 * anonymous visitor, which is exactly the threat model for opening signups.
3348 */
3449
35import { chromium } from '@playwright/test';
50// Playwright is imported lazily, inside the browser branch. A top-level
51// import makes --no-browser require the very dependency it exists to avoid,
52// which kept this gate off the one machine that should run it on every
53// deploy: the CI runner executes inside the app container, where the
54// production image carries no browser and no reason to.
3655import { writeFileSync, readdirSync, readFileSync, statSync } from 'fs';
37import { join } from 'path';
56import { join, resolve } from 'path';
57import { fileURLToPath } from 'url';
3858
3959const argv = process.argv.slice(2);
4060const arg = (name, fallback = null) => {
92112 return [...paths].sort();
93113}
94114
115/**
116 * Every GET page the app protects, read from source.
117 *
118 * The hand-written list this replaces had six entries. There are seventy.
119 * `/admin/security` shipped ungated to production precisely because the
120 * literal list was the only record of what needed checking, and a glued
121 * "/admin/security*" wildcard that never matched was easier to write than
122 * to notice. A list maintained by hand is a list that documents the day it
123 * was written.
124 *
125 * Guards are declared two different ways in this codebase, and reading only
126 * one of them is worse than reading neither — it produces a confident,
127 * wrong answer:
128 *
129 * 1. in the registration or handler body — `requireAuth` as middleware,
130 * or the `const g = await gate(c)` prologue the admin pages use
131 * 2. at file level — `settings.use("/settings/*", requireAuth)`,
132 * `adminSecurity.use("/admin/security", securityGuard)`
133 *
134 * Scanning only (1) marks all 29 `/settings/*` and `/admin/*` pages
135 * unguarded, because their guard is a `.use()` line hundreds of lines above
136 * the route. That misreading is a gate that cries wolf on its whole
137 * admin surface, which is a gate someone turns off.
138 *
139 * softAuth is deliberately NOT a guard. It populates the user when there is
140 * one and waves everyone else through; treating it as protection is how an
141 * unguarded page passes for a guarded one.
142 */
143const GUARD_IN_BODY = /requireAuth|requireAdmin|requireSiteAdmin|requireOwner|\bgate\(c\)/;
144const GUARD_NAME = /^(requireAuth|requireAdmin|requireSiteAdmin|requireOwner|\w*[Gg]uard)$/;
145
146/**
147 * One handler's source, from its registration to the next one.
148 *
149 * A fixed-size window does not work here, and the failure is silent in the
150 * dangerous direction: scanning a flat 600 characters forward from
151 * `admin.get("/demo", ...)` — a three-line public redirect — runs straight
152 * into the `admin.post("/admin/autopilot/run")` below it and finds that
153 * handler's `await gate(c)`. Both `/demo` and `/pwa/vapid-public-key` were
154 * reported as leaking to anonymous callers on the first run for exactly
155 * that reason. They are public on purpose.
156 *
157 * Two false positives is all it takes for an operator to stop reading a
158 * gate, so the window stops at the next registration on any verb.
159 */
160function handlerBody(src, from) {
161 const rest = src.slice(from + 1, from + 4000);
162 const next = rest.search(/\.(get|post|put|patch|delete|use|all)\s*\(/);
163 return rest.slice(0, next === -1 ? 600 : next);
164}
165
166function authGuardedGetRoutes(root = 'src/routes') {
167 const files = [];
168 const walk = (d) => {
169 for (const e of readdirSync(d)) {
170 const p = join(d, e);
171 if (statSync(p).isDirectory()) walk(p);
172 else if (e.endsWith('.ts') || e.endsWith('.tsx')) files.push(p);
173 }
174 };
175 try { walk(root); } catch { return []; }
176
177 const guarded = new Set();
178 for (const f of files) {
179 const src = readFileSync(f, 'utf8');
180
181 // File-level guards: `x.use("<pattern>", ...middleware)`.
182 //
183 // Every middleware in the chain is checked, not just the first. The
184 // real registrations stack them — `tokens.use("/settings/tokens",
185 // softAuth, requireAuth)` — and reading only argument one finds
186 // softAuth, which is explicitly not a guard, so the mount is discarded
187 // and the page reads as public. /settings/tokens, the page that mints
188 // credentials, was missed exactly this way.
189 const mounts = [];
190 const useRe = /\.use\(\s*(["'`])([^"'`\n]+)\1\s*,([^)\n]*)\)/g;
191 let u;
192 while ((u = useRe.exec(src))) {
193 const chain = u[3].split(',').map((s) => s.trim()).filter(Boolean);
194 if (chain.some((name) => GUARD_NAME.test(name))) mounts.push(u[2]);
195 }
196 const mountCovers = (path) => mounts.some((pat) =>
197 pat.endsWith('*') ? path.startsWith(pat.slice(0, -1)) : pat === path);
198
199 const getRe = /\.get\(\s*(["'`])(\/[^"'`\n]*)\1/g;
200 let m;
201 while ((m = getRe.exec(src))) {
202 const p = m[2];
203 // Skip API, parameterised, wildcard and machine-readable surfaces:
204 // this gate asks "does an HTML page render to a stranger", and a JSON
205 // endpoint answering 200 is not the same question.
206 if (p.startsWith('/api') || p.includes(':') || p.includes('*') ||
207 p.includes('.git') || p.startsWith('/.well-known') || p.endsWith('.json')) continue;
208 if (GUARD_IN_BODY.test(handlerBody(src, m.index)) || mountCovers(p)) {
209 guarded.add(p);
210 }
211 }
212 }
213 return [...guarded].sort();
214}
215
216/**
217 * Every destination the onboarding surface offers, read from source.
218 *
219 * A brand-new account is the one visitor with no way to route around a dead
220 * end: they have no bookmarks, no muscle memory, and no reason to assume the
221 * 404 is our fault rather than theirs. The getting-started page is a list of
222 * promises — "Create repo", "Import repo", "Connect an agent" — and nothing
223 * checked that any of them lead somewhere. Two of the five step CTAs point at
224 * routes mounted in a different file than the one that renders them, so the
225 * link and its target can drift apart without a single test noticing.
226 *
227 * Source-derived for the same reason repoScopedGetRoutes is: a literal list
228 * here would be a list someone forgets to extend on the day they add step 6.
229 *
230 * Matches both JSX (`href="/x"`) and the step-object form (`href: "/x"`).
231 * Interpolated hrefs are deliberately skipped — they depend on runtime state
232 * this gate has no session to produce, and a guess would report coverage the
233 * gate does not have.
234 */
235function onboardingLinkPaths(file = 'src/routes/onboarding.tsx') {
236 let src;
237 try { src = readFileSync(file, 'utf8'); } catch { return []; }
238 const paths = new Set();
239 for (const re of [/href="(\/[^"{}]*)"/g, /href:\s*"(\/[^"{}]*)"/g]) {
240 let m;
241 while ((m = re.exec(src))) paths.add(m[1]);
242 }
243 return [...paths].sort();
244}
245
95246/** Fill :owner/:repo, and give any remaining params a placeholder. */
96247function materialize(routePath, owner, repo) {
97248 return routePath
106257 '/', '/explore', '/pricing', '/docs', '/docs/agents', '/docs/api',
107258 '/login', '/register', '/marketplace', '/enterprise',
108259];
109// Must never render for an anonymous caller.
110// `/admin` alone does not cover the admin sub-pages: each one registers its
111// own guard, and `/admin/security` shipped ungated to production for exactly
112// that reason (a glued "/admin/security*" wildcard that never matched). Any
113// admin page that renders sensitive state to an anonymous caller belongs here.
114const AUTH_ONLY = [
260// A floor, not the list. authGuardedGetRoutes() derives the real set from
261// source; these stay named so that if the derivation ever silently stops
262// matching — a refactor to a new router helper, say — the gate still checks
263// the six pages whose exposure would be worst, instead of checking nothing
264// and reporting a clean run over an empty set.
265const AUTH_ONLY_FLOOR = [
115266 '/dashboard',
116267 '/settings',
117268 '/admin',
121272];
122273const PERF_BUDGET_MS = 3000; // soft
123274
275/**
276 * Run `fn` over `items` with at most `limit` in flight.
277 *
278 * Capped low on purpose: hammering the server produces spurious slow-render
279 * timeouts in the perf gate that runs after these.
280 */
281async function runBounded(items, limit, fn) {
282 let cursor = 0;
283 const worker = async () => {
284 for (;;) {
285 const i = cursor++;
286 if (i >= items.length) return;
287 await fn(items[i]);
288 }
289 };
290 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
291}
292
124293const results = [];
125const add = (gate, hard, ok, detail) => results.push({ gate, hard, ok, detail });
294
295/**
296 * Every gate lands in one of THREE states, never two.
297 *
298 * pass we ran it and it held
299 * fail we ran it and it broke
300 * unverified we could not run it
301 *
302 * The two-state version of this script is what shipped the defect it was
303 * written to prevent: a gate with no inputs recorded `ok: true` and the
304 * summary line said "all hard gates passed", so the run that checked the
305 * least looked exactly like the run that checked the most. The browser
306 * gates were patched to report FAIL-as-skipped, which stops the lie but
307 * tells the next lie — an unrun gate is not a broken one, and a board that
308 * cries FAIL for missing inputs gets ignored just as fast as one that
309 * cries PASS.
310 *
311 * So: unverified is its own state, it blocks READY, and it carries the
312 * exact input that would settle it.
313 */
314const push = (gate, hard, state, detail) =>
315 results.push({ gate, hard, state, ok: state === 'pass', detail });
316const add = (gate, hard, ok, detail) => push(gate, hard, ok ? 'pass' : 'fail', detail);
317const unverified = (gate, hard, detail) => push(gate, hard, 'unverified', detail);
126318
127319async function main() {
128320 console.log(`[readiness] target: ${BASE}\n`);
178370 add('privacy', true, leaks.length === 0,
179371 leaks.length ? leaks.join('; ') : `no anonymous disclosure across ${surfaces.length} surfaces`);
180372 } else {
181 add('privacy', true, true, 'SKIPPED — pass --private-repo owner/name to enable (strongly recommended)');
373 unverified('privacy', true,
374 'no --private-repo owner/name given — anonymous disclosure of a private repo was NOT tested');
182375 }
183376
184377 // ── HARD: authorization matrix over EVERY repo-scoped route ───────────
197390 const routes = repoScopedGetRoutes();
198391 const leaks = [];
199392 const overblocked = [];
393 const sessionOnly = [];
200394 const errors = [];
201395
202396 const probe = async (path, token) => {
214408 // runs the gate, and a gate nobody runs is worth nothing. Capped low
215409 // because hammering the server produces spurious slow-render timeouts in
216410 // the perf gate below.
217 const LIMIT = 6;
218 let cursor = 0;
219 const worker = async () => {
220 for (;;) {
221 const i = cursor++;
222 if (i >= routes.length) return;
223 await checkRoute(routes[i]);
224 }
225 };
226
411 // The pool this replaces built its workers and never called them. No
412 // Promise.all, no await — `worker` was a closure nobody ran, so the loop
413 // body below never executed once. The gate then reported "N repo routes
414 // x 3 identities, no leaks or over-blocking" having probed exactly zero
415 // of them, and quoted the route count while doing it, which is what made
416 // it look like the most thorough line on the board.
417 //
418 // A hard gate that cannot fail is worse than no gate: it occupies the
419 // slot where a real check would go.
227420 async function checkRoute(route) {
228421 // 1. Private repo must not render to an anonymous caller.
229422 const anon = await probe(materialize(route, po, pn), null);
240433 // require a session (the AI surfaces — /ask, /chat, /claude — all
241434 // 302 to login by design). Over-blocking means the owner is shut
242435 // out, which is what a too-aggressive privacy gate actually breaks.
436 //
437 // The identity here is a PAT on the Authorization header, which is
438 // NOT a browser session, so the two denials mean different things:
439 //
440 // 401/403 the route considered this caller and refused them.
441 // That is over-blocking, and it fails the gate.
442 // 302 the route wants a session cookie. The AI surfaces
443 // (/ask, /chat, /claude) redirect to login by design,
444 // and a PAT was never going to satisfy them.
445 //
446 // Scoring 302 as over-blocking makes this gate permanently red for
447 // a reason nobody can fix — five routes did exactly that on the
448 // first run after the worker pool was repaired. A hard gate that is
449 // always red is a hard gate people stop reading, which costs more
450 // than the five findings were worth. They are counted and reported
451 // instead: unproven, not broken.
243452 if (OWNER_PAT) {
244453 const own = await probe(materialize(route, uo, un), OWNER_PAT);
245 if (own === 302 || own === 401 || own === 403) {
246 overblocked.push(`owner ${route} -> ${own}`);
247 }
454 if (own === 401 || own === 403) overblocked.push(`owner ${route} -> ${own}`);
455 else if (own === 302) sessionOnly.push(route);
248456 // A placeholder id must 404, never crash the handler.
249457 if (own >= 500) errors.push(`${route} -> ${own}`);
250458 }
251459 }
252460
461 await runBounded(routes, 6, checkRoute);
462
253463 const detail = [];
254464 if (leaks.length) detail.push(`LEAK: ${leaks.slice(0, 8).join(', ')}`);
255465 if (overblocked.length) detail.push(`OVER-BLOCKED: ${overblocked.slice(0, 8).join(', ')}`);
256466 if (errors.length) detail.push(`5xx: ${errors.slice(0, 8).join(', ')}`);
257467 const covered = `${routes.length} repo routes x ${NONMEMBER_PAT ? 3 : 2} identities`;
468 // Reported on the pass line too: "no leaks" over a surface where a
469 // third of the routes could not be exercised by this identity is a
470 // narrower claim than it looks, and the number is how anyone tells.
471 const sessionNote = sessionOnly.length
472 ? `; ${sessionOnly.length} session-only route(s) unproven by a PAT`
473 : '';
258474 add('authz-matrix', true, leaks.length === 0 && overblocked.length === 0 && errors.length === 0,
259 detail.length ? detail.join(' | ') : `${covered}, no leaks or over-blocking`
475 detail.length ? detail.join(' | ') : `${covered}, no leaks or over-blocking${sessionNote}`
260476 + (NONMEMBER_PAT ? '' : ' (set NONMEMBER_PAT for the non-member row)'));
261477 } else {
262 add('authz-matrix', true, true,
263 'SKIPPED — needs --private-repo AND --public-repo (this is the gate that catches leaks; enable it)');
478 unverified('authz-matrix', true,
479 'needs --private-repo AND --public-repo — the gate that catches leaks across every repo-scoped route did not run');
264480 }
265481
266482 // ── HARD: auth-only pages must not render anonymously ─────────────────
483 const derivedGuarded = authGuardedGetRoutes();
484 const AUTH_ONLY = [...new Set([...AUTH_ONLY_FLOOR, ...derivedGuarded])].sort();
267485 const rendered = [];
268 for (const p of AUTH_ONLY) {
486 const probeAuthOnly = async (p) => {
269487 try {
270488 const r = await fetch(`${BASE}${p}`, { redirect: 'manual' });
271489 // 2xx that actually renders the page is the failure. 3xx/401/404 are fine.
274492 if (!/sign in|log ?in|password/i.test(body.slice(0, 4000))) rendered.push(`${p} -> ${r.status}`);
275493 }
276494 } catch { /* ignore */ }
277 }
495 };
496 // Bounded, for the same reason authz-matrix is: this list went from 6 to
497 // ~70 paths, and serialising it turns a fast gate into one nobody waits for.
498 await runBounded(AUTH_ONLY, 6, probeAuthOnly);
278499 add('auth-gate', true, rendered.length === 0,
279 rendered.length ? `rendered to anonymous caller: ${rendered.join(', ')}` : `${AUTH_ONLY.length} paths correctly gated`);
500 rendered.length
501 ? `rendered to anonymous caller: ${rendered.join(', ')}`
502 : `${AUTH_ONLY.length} guarded paths correctly gated (${derivedGuarded.length} derived from source)`);
503
504 // ── HARD: onboarding must not offer a dead end ────────────────────────
505 //
506 // Checked anonymously on purpose. Most of these targets require a session
507 // and will 302 to login — that is a correct answer and passes. What cannot
508 // happen is 404 (the promise leads nowhere) or 5xx (it leads to a crash),
509 // because both are terminal for the only visitor who cannot route around
510 // them. 401/403 pass for the same reason a 302 does: the route exists and
511 // is doing its job.
512 const onboardingLinks = onboardingLinkPaths();
513 if (onboardingLinks.length === 0) {
514 unverified('onboarding', true,
515 'could not read src/routes/onboarding.tsx — run from the repo root so the CTA list can be derived from source');
516 } else {
517 const dead = [];
518 for (const path of onboardingLinks) {
519 try {
520 const r = await fetch(`${BASE}${path}`, { redirect: 'manual' });
521 if (r.status === 404 || r.status >= 500) dead.push(`${path} -> ${r.status}`);
522 } catch (e) {
523 dead.push(`${path} -> unreachable (${String(e.message).slice(0, 60)})`);
524 }
525 }
526 add('onboarding', true, dead.length === 0,
527 dead.length
528 ? `dead end on the getting-started page: ${dead.join(', ')}`
529 : `${onboardingLinks.length} onboarding destinations all resolve`);
530 }
280531
281532 // ── Render + overflow gates (real browser) ────────────────────────────
282 // --no-browser: the browser gates report FAIL-as-SKIPPED, never PASS.
283 // A machine whose Chromium can't launch (2026-08-25: 180s launch timeout
284 // on the owner's Windows box) must not convert "unverified" into "green" —
285 // the two-hard-gates-pass-by-skipping trap this script itself once had.
533 // --no-browser: every browser gate reports UNVERIFIED, never PASS and no
534 // longer FAIL. A machine whose Chromium can't launch (2026-08-25: 180s
535 // launch timeout on the owner's Windows box) must not convert "unverified"
536 // into "green" — the pass-by-skipping trap this script itself once had.
537 //
538 // The soft browser gates are listed too. They used to just not appear, and
539 // an absent row reads as "nothing to report" when it means "never looked" —
540 // the same lie in a quieter font.
286541 if (argv.includes('--no-browser')) {
287 add('render', true, false, 'SKIPPED (--no-browser) — unverified, not passed; run on a machine with working Chromium for the full verdict');
288 add('overflow', true, false, 'SKIPPED (--no-browser) — unverified, not passed');
542 for (const [gate, hard] of [['render', true], ['overflow', true], ['images', false], ['headings', false], ['perf', false]]) {
543 unverified(gate, hard, 'ran with --no-browser — needs a working Chromium for the full verdict');
544 }
289545 } else {
546 // A missing or unlaunchable browser is UNVERIFIED, not a crash and not a
547 // pass. Exiting 2 here would have thrown away the eight credential-free
548 // gates that already ran and hold.
549 let chromium;
550 try {
551 ({ chromium } = await import('@playwright/test'));
552 } catch (e) {
553 for (const [gate, hard] of [['render', true], ['overflow', true], ['images', false], ['headings', false], ['perf', false]]) {
554 unverified(gate, hard, `@playwright/test unavailable (${String(e.message).slice(0, 80)}) — pass --no-browser to acknowledge, or install it`);
555 }
556 return finish();
557 }
290558 const browser = await chromium.launch();
291559 const jsErrors = [], overflows = [], brokenImgs = [], slow = [], noH1 = [];
292560 // First-pass timings over budget. NOT reported directly — see the
389657 slow.length ? `over ${PERF_BUDGET_MS}ms: ${slow.join(', ')}` : `all sampled pages under ${PERF_BUDGET_MS}ms`);
390658 } // end --no-browser else
391659
660 return finish();
661}
662
663/**
664 * Print the board, write the JSON, exit with the verdict's code.
665 *
666 * Separate from main() so an early return — a browser that will not load,
667 * say — still reports every gate that did run. Bailing out of main()
668 * instead would discard eight credential-free results that were already
669 * measured, and reporting nothing is worse than reporting a coverage gap.
670 */
671function finish() {
392672 // ── Report ────────────────────────────────────────────────────────────
393673 const pad = (s, n) => String(s).padEnd(n);
394 console.log(pad('GATE', 13) + pad('KIND', 7) + pad('RESULT', 8) + 'DETAIL');
395 console.log('-'.repeat(100));
674 const LABEL = { pass: 'PASS', fail: 'FAIL', unverified: 'UNVERIFIED' };
675 console.log(pad('GATE', 14) + pad('KIND', 7) + pad('RESULT', 12) + 'DETAIL');
676 console.log('-'.repeat(110));
396677 for (const r of results) {
397678 console.log(
398 pad(r.gate, 13) + pad(r.hard ? 'HARD' : 'soft', 7) +
399 pad(r.ok ? 'PASS' : 'FAIL', 8) + r.detail
679 pad(r.gate, 14) + pad(r.hard ? 'HARD' : 'soft', 7) +
680 pad(LABEL[r.state], 12) + r.detail
400681 );
401682 }
402683
403 const hardFails = results.filter((r) => r.hard && !r.ok);
404 const softFails = results.filter((r) => !r.hard && !r.ok);
684 const v = verdict(results);
405685 console.log('');
406 console.log(`[readiness] HARD ${results.filter(r => r.hard && r.ok).length}/${results.filter(r => r.hard).length} passed · soft warnings: ${softFails.length}`);
686 console.log(`[readiness] HARD ${v.hardPassed}/${v.hardTotal} verified passing · ${v.hardUnverified.length} unverified · soft warnings: ${v.softFails.length}`);
407687
408688 if (JSON_OUT) {
409 writeFileSync(JSON_OUT, JSON.stringify({ base: BASE, results }, null, 2));
689 writeFileSync(JSON_OUT, JSON.stringify({
690 base: BASE,
691 ranAt: new Date().toISOString(),
692 verdict: v.label,
693 coverage: { hardTotal: v.hardTotal, hardVerified: v.hardVerified },
694 results,
695 }, null, 2));
410696 console.log(`[readiness] wrote ${JSON_OUT}`);
411697 }
412698
413 if (hardFails.length) {
414 console.error(`\n[readiness] NOT READY — ${hardFails.length} hard gate(s) failed:`);
415 for (const f of hardFails) console.error(` - ${f.gate}: ${f.detail}`);
416 process.exit(1);
699 if (v.hardFails.length) {
700 console.error(`\n[readiness] NOT READY — ${v.hardFails.length} hard gate(s) failed:`);
701 for (const f of v.hardFails) console.error(` - ${f.gate}: ${f.detail}`);
702 }
703 if (v.hardUnverified.length) {
704 console.error(`\n[readiness] COVERAGE GAP — ${v.hardUnverified.length} hard gate(s) never ran:`);
705 for (const f of v.hardUnverified) console.error(` - ${f.gate}: ${f.detail}`);
417706 }
418 console.log('\n[readiness] all hard gates passed');
419 process.exit(0);
707 if (v.code === 0) console.log('\n[readiness] READY — every hard gate ran and passed');
708 process.exit(v.code);
709}
710
711/**
712 * Turn the gate rows into one answer, with the third answer allowed.
713 *
714 * Exit codes are the contract other tooling reads, so they distinguish the
715 * two ways of not being ready — they are different problems with different
716 * owners. A hard FAIL is a defect in the product; a hard UNVERIFIED is a
717 * missing input in whoever invoked the gate. Collapsing them into one code
718 * is how "we ran the checks" came to mean "we ran whichever checks the
719 * environment happened to permit".
720 *
721 * 0 READY every hard gate ran and passed
722 * 1 NOT READY a hard gate failed
723 * 3 INCONCLUSIVE nothing failed, but a hard gate never ran
724 */
725export function verdict(rows) {
726 const hard = rows.filter((r) => r.hard);
727 const hardFails = hard.filter((r) => r.state === 'fail');
728 const hardUnverified = hard.filter((r) => r.state === 'unverified');
729 const hardPassed = hard.filter((r) => r.state === 'pass').length;
730 const code = hardFails.length ? 1 : hardUnverified.length ? 3 : 0;
731 return {
732 code,
733 label: code === 0 ? 'READY' : code === 1 ? 'NOT_READY' : 'INCONCLUSIVE',
734 hardTotal: hard.length,
735 hardPassed,
736 hardVerified: hard.length - hardUnverified.length,
737 hardFails,
738 hardUnverified,
739 softFails: rows.filter((r) => !r.hard && r.state === 'fail'),
740 softUnverified: rows.filter((r) => !r.hard && r.state === 'unverified'),
741 };
420742}
421743
422main().catch((e) => {
423 console.error('[readiness] crashed:', e);
424 process.exit(2);
425});
744export { repoScopedGetRoutes, onboardingLinkPaths, authGuardedGetRoutes, materialize };
745
746// Only drive the network when invoked as a command. Importing this file for
747// its pure helpers (the unit tests do) must not fire a live probe run.
748const invokedDirectly =
749 process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
750if (invokedDirectly) {
751 main().catch((e) => {
752 console.error('[readiness] crashed:', e);
753 process.exit(2);
754 });
755}
Addedsrc/__tests__/first-run-journey-task.test.ts+133−0View fileUnifiedSplit
1/**
2 * The daily first-run journey's result mapping.
3 *
4 * Tested through injected deps rather than by running the real script: a
5 * genuine run registers an account, pushes commits and merges a PR against
6 * a live instance, which is the right thing to do once a day and the wrong
7 * thing to do in a test suite.
8 *
9 * The mapping is what matters here. A scheduled check earns its place only
10 * if a failure is distinguishable from a non-run, and the yellow case below
11 * is the one that would otherwise quietly become "green" the day someone
12 * slims the container image and drops scripts/.
13 */
14
15import { describe, expect, it } from "bun:test";
16import {
17 classifyJourneyRun,
18 firstFailureLine,
19 frictionLines,
20 runFirstRunJourneyOnce,
21 FIRST_RUN_CHECK_NAME,
22 type JourneyRun,
23} from "../lib/first-run-journey-task";
24
25const run = (over: Partial<JourneyRun> = {}): JourneyRun => ({
26 exitCode: 0,
27 stdout: "",
28 stderr: "",
29 timedOut: false,
30 ...over,
31});
32
33describe("classifyJourneyRun", () => {
34 it("green when every step passed", () => {
35 const r = classifyJourneyRun(run(), 1000);
36 expect(r.status).toBe("green");
37 expect(r.name).toBe(FIRST_RUN_CHECK_NAME);
38 });
39
40 it("red on a dead end, carrying the failing step forward", () => {
41 // "exit 1" sends an operator to read a log to learn what one line
42 // already knew.
43 const r = classifyJourneyRun(
44 run({ exitCode: 1, stdout: "5. push code over git HTTPS DEAD END: 403" }),
45 2000
46 );
47 expect(r.status).toBe("red");
48 expect(r.error).toContain("dead end");
49 expect(r.error).toContain("push code over git HTTPS");
50 });
51
52 it("distinguishes a crash from a dead end", () => {
53 const r = classifyJourneyRun(run({ exitCode: 2, stderr: "Error: ECONNREFUSED" }), 500);
54 expect(r.status).toBe("red");
55 expect(r.error).toContain("crashed");
56 });
57
58 it("reports a timeout as its own failure, not a generic one", () => {
59 // A slow-but-working platform is a different fact from a broken one.
60 const r = classifyJourneyRun(run({ timedOut: true, exitCode: 143 }), 480_000);
61 expect(r.status).toBe("red");
62 expect(r.error).toContain("still be waiting");
63 });
64
65 it("never reports green for a non-zero exit, whatever the output says", () => {
66 const r = classifyJourneyRun(run({ exitCode: 1, stdout: "all steps passed" }), 10);
67 expect(r.status).toBe("red");
68 });
69});
70
71describe("firstFailureLine / frictionLines", () => {
72 it("picks the failing line out of the transcript", () => {
73 const out = "1. register OK\n2. dashboard OK\n3. create repo DEAD END: 500\n";
74 expect(firstFailureLine(out, "")).toContain("create repo");
75 });
76
77 it("returns empty rather than throwing on silent output", () => {
78 expect(firstFailureLine("", "")).toBe("");
79 });
80
81 it("surfaces the friction ledger even from a passing run", () => {
82 // The script's own header: a green run with a long friction ledger is
83 // not a good onboarding. Only reading the exit code loses that half.
84 const out =
85 "step ok\nfriction: no obvious create-repository call to action\nfriction: dashboard mentions email verification\n";
86 expect(frictionLines(out).length).toBe(2);
87 });
88});
89
90describe("runFirstRunJourneyOnce", () => {
91 it("records yellow — not green — when the script is not in the image", async () => {
92 // The failure mode this guards: a slimmed build drops scripts/, the
93 // task can no longer run, and a two-state result would file that as
94 // a pass forever.
95 const persisted: unknown[] = [];
96 const r = await runFirstRunJourneyOnce({
97 cwd: "/definitely/not/a/real/path",
98 persist: async (rows) => { persisted.push(...rows); },
99 });
100 expect(r.status).toBe("yellow");
101 expect(r.error).toContain("did not run");
102 expect(persisted.length).toBe(1);
103 });
104
105 it("passes the configured base URL through to the script", async () => {
106 let received: string[] = [];
107 await runFirstRunJourneyOnce({
108 baseUrl: "https://probe.invalid",
109 spawn: async (args) => { received = args; return run(); },
110 persist: async () => {},
111 });
112 expect(received).toContain("--base");
113 expect(received).toContain("https://probe.invalid");
114 });
115
116 it("does not throw when persistence fails", async () => {
117 // Called from the autopilot loop, where an exception is a stalled tick.
118 const r = await runFirstRunJourneyOnce({
119 spawn: async () => run(),
120 persist: async () => { throw new Error("db down"); },
121 });
122 expect(r.status).toBe("green");
123 });
124
125 it("turns a thrown spawn into a red result rather than propagating it", async () => {
126 const r = await runFirstRunJourneyOnce({
127 spawn: async () => { throw new Error("bun not found"); },
128 persist: async () => {},
129 });
130 expect(r.status).toBe("red");
131 expect(r.error).toContain("bun not found");
132 });
133});
Modifiedsrc/__tests__/gate-ci-check.test.ts+208−37View fileUnifiedSplit
22 * The CI gate — workflow results finally enforce something.
33 *
44 * Until 2026-08-09 the platform ran workflows and enforced none of them: a
5 * PR's CI could be red and the merge button neither knew nor cared. These
6 * pin the semantics of checkCiWorkflows via the structural seams (the
7 * function reads the DB, so behavior tests live at the seam of its inputs:
8 * no-runs, running, stale, failed, green are all decided from row shapes).
5 * PR's CI could be red and the merge button neither knew nor cared.
6 *
7 * Then, until 2026-08-29, it enforced them only when they existed: zero
8 * runs for the head sha returned `{passed: true, skipped: true}`, so a PR
9 * whose CI had never run — runner down, enqueue silently lost, commit
10 * pushed before the workflow existed — scored exactly the same as a PR
11 * whose CI ran and went green. A check that could not run must never be
12 * scored the same as a check that ran and passed.
13 *
14 * These used to be source greps because the decision was buried inside a
15 * DB-reading function. `decideCiGate` is that decision, extracted pure, so
16 * the states below are exercised for real rather than pattern-matched.
917 */
1018
1119import { describe, expect, it } from "bun:test";
12import { CI_GATE_STALE_MS } from "../lib/gate";
20import {
21 CI_GATE_STALE_MS,
22 decideCiGate,
23 isCiWorkflowExpectedOnBranch,
24 type CiRunRow,
25 type ExpectedCiWorkflow,
26} from "../lib/gate";
1327
14describe("CI gate wiring", () => {
15 it("runAllGateChecks includes the CI check in its checks array", async () => {
16 const src = await Bun.file("src/lib/gate.ts").text();
17 expect(src).toContain("checkCiWorkflows(repoRow.id, headSha)");
18 expect(src).toContain("checks.push(ciResult)");
28const NOW = Date.UTC(2026, 7, 29, 12, 0, 0);
29
30function run(overrides: Partial<CiRunRow> & { workflowId: string }): CiRunRow {
31 return {
32 name: overrides.workflowId,
33 status: "success",
34 conclusion: "success",
35 startedAt: new Date(NOW - 60_000),
36 createdAt: new Date(NOW - 60_000),
37 ...overrides,
38 };
39}
40
41const ci: ExpectedCiWorkflow = { id: "wf-ci", name: "CI" };
42const deploy: ExpectedCiWorkflow = { id: "wf-deploy", name: "Deploy" };
43
44describe("decideCiGate — a check that could not run is not a check that passed", () => {
45 it("BLOCKS when a configured workflow has no run for this commit", () => {
46 const result = decideCiGate([], [ci], NOW);
47 expect(result.passed).toBe(false);
48 expect(result.skipped).toBeFalsy();
49 expect(result.details).toContain("has not run for this commit");
50 // Must name the workflow, and must not read as a red build.
51 expect(result.details).toContain("CI");
52 expect(result.details).toContain("Nothing failed; nothing was checked");
53 // Names what would satisfy it.
54 expect(result.details).toContain("re-run the workflow");
1955 });
2056
21 it("no runs for the sha is SKIPPED, not failed — repos without CI merge as before", async () => {
22 const src = await Bun.file("src/lib/gate.ts").text();
23 const fn = src.slice(
24 src.indexOf("export async function checkCiWorkflows"),
25 src.indexOf("export async function runAllGateChecks")
57 it("still SKIPS when there are no runs and no workflows configured — a repo with no CI has nothing to wait for", () => {
58 const result = decideCiGate([], [], NOW);
59 expect(result.passed).toBe(true);
60 expect(result.skipped).toBe(true);
61 expect(result.details).toContain("No CI workflows are configured");
62 });
63
64 it("BLOCKS on the workflow that never ran even when a sibling workflow is green", () => {
65 const result = decideCiGate([run({ workflowId: "wf-ci" })], [ci, deploy], NOW);
66 expect(result.passed).toBe(false);
67 expect(result.details).toContain("Deploy");
68 expect(result.details).not.toContain("CI green");
69 });
70
71 it("does not invent a missing check it cannot prove — an empty expected list judges only the runs it can see", () => {
72 // expectedCiWorkflows() returns [] on any lookup failure. That must
73 // degrade to the old run-only judgement, never to a phantom block.
74 const result = decideCiGate([run({ workflowId: "wf-ci" })], [], NOW);
75 expect(result.passed).toBe(true);
76 expect(result.details).toContain("CI green");
77 });
78});
79
80describe("decideCiGate — the states that already worked keep working", () => {
81 it("a red run FAILS, naming the workflow", () => {
82 const result = decideCiGate(
83 [run({ workflowId: "wf-ci", status: "failure", conclusion: "failure" })],
84 [ci],
85 NOW
2686 );
27 const noRuns = fn.indexOf("No CI workflows ran for this commit");
28 expect(noRuns).toBeGreaterThan(-1);
29 expect(fn.slice(0, noRuns)).toContain("skipped: true");
87 expect(result.passed).toBe(false);
88 expect(result.details).toContain("CI failed");
89 expect(result.details).toContain("CI");
3090 });
3191
32 it("a running run BLOCKS (passed=false, not skipped) — merge when green", async () => {
33 const src = await Bun.file("src/lib/gate.ts").text();
34 const fn = src.slice(src.indexOf("export async function checkCiWorkflows"));
35 const running = fn.indexOf("CI still running");
36 expect(running).toBeGreaterThan(-1);
37 // The nearest preceding passed: must be false.
38 const before = fn.slice(0, running);
39 const lastPassed = before.lastIndexOf("passed: false");
40 expect(lastPassed).toBeGreaterThan(-1);
41 expect(before.slice(lastPassed)).not.toContain("skipped: true");
92 it("a running run BLOCKS (not skipped) — merge when green", () => {
93 const result = decideCiGate(
94 [run({ workflowId: "wf-ci", status: "running", conclusion: null })],
95 [ci],
96 NOW
97 );
98 expect(result.passed).toBe(false);
99 expect(result.skipped).toBeFalsy();
100 expect(result.details).toContain("CI still running");
101 });
102
103 it("a green run PASSES", () => {
104 const result = decideCiGate([run({ workflowId: "wf-ci" })], [ci], NOW);
105 expect(result.passed).toBe(true);
106 expect(result.skipped).toBeFalsy();
107 expect(result.details).toContain("CI green: 1 workflow");
42108 });
43109
44 it("stale (>30 min) runs degrade honestly instead of blocking forever", async () => {
110 it("an infra conclusion blocks but says the code was never judged, not that it failed", () => {
111 const result = decideCiGate(
112 [
113 run({
114 workflowId: "wf-ci",
115 status: "failure",
116 conclusion: "runner_restarted",
117 }),
118 ],
119 [ci],
120 NOW
121 );
122 expect(result.passed).toBe(false);
123 expect(result.details).toContain("CI did not complete");
124 expect(result.details).not.toContain("CI failed");
125 });
126
127 it("a stale (>30 min) run degrades honestly instead of blocking forever", () => {
45128 expect(CI_GATE_STALE_MS).toBe(30 * 60_000);
46 const src = await Bun.file("src/lib/gate.ts").text();
47 const fn = src.slice(src.indexOf("export async function checkCiWorkflows"));
48 expect(fn).toContain("stale");
49 expect(fn).toContain("not blocking");
129 const result = decideCiGate(
130 [
131 run({
132 workflowId: "wf-ci",
133 status: "running",
134 conclusion: null,
135 startedAt: new Date(NOW - CI_GATE_STALE_MS - 1000),
136 createdAt: new Date(NOW - CI_GATE_STALE_MS - 1000),
137 }),
138 ],
139 [ci],
140 NOW
141 );
142 expect(result.passed).toBe(true);
143 expect(result.skipped).toBe(true);
144 expect(result.details).toContain("not blocking");
50145 });
51146
52 it("only the LATEST run per workflow counts — a requeued retry supersedes its runner_restarted parent", async () => {
147 it("only the LATEST run per workflow counts — a requeued retry supersedes its runner_restarted parent", () => {
148 // Callers pass rows newest-first (ORDER BY created_at DESC).
149 const result = decideCiGate(
150 [
151 run({ workflowId: "wf-ci", name: "CI" }),
152 run({
153 workflowId: "wf-ci",
154 name: "CI",
155 status: "failure",
156 conclusion: "runner_restarted",
157 createdAt: new Date(NOW - 600_000),
158 }),
159 ],
160 [ci],
161 NOW
162 );
163 expect(result.passed).toBe(true);
164 expect(result.details).toContain("CI green: 1 workflow");
165 });
166
167 it("a red run outranks a never-ran sibling — the worst honest news first", () => {
168 const result = decideCiGate(
169 [run({ workflowId: "wf-ci", status: "failure", conclusion: "failure" })],
170 [ci, deploy],
171 NOW
172 );
173 expect(result.details).toContain("CI failed");
174 });
175});
176
177describe("isCiWorkflowExpectedOnBranch — what the gate is allowed to demand", () => {
178 const on = (...events: string[]) => JSON.stringify(events);
179
180 it("expects an on: push workflow with no branch filter, on any branch", () => {
181 expect(
182 isCiWorkflowExpectedOnBranch(on("push", "workflow_dispatch"), "{}", "feat/x")
183 ).toBe(true);
184 });
185
186 it("does NOT expect a branches:[main] workflow on a feature branch", () => {
187 // The deploy workflow is pinned to main. push-workflow-sync applies the
188 // same filter when enqueueing, so demanding it on a feature branch would
189 // block every PR on the platform — the exact false positive this avoids.
190 const parsed = JSON.stringify({ pushBranches: ["main"] });
191 expect(isCiWorkflowExpectedOnBranch(on("push"), parsed, "feat/x")).toBe(false);
192 expect(isCiWorkflowExpectedOnBranch(on("push"), parsed, "main")).toBe(true);
193 });
194
195 it("does NOT expect a workflow_dispatch- or schedule-only workflow", () => {
196 expect(isCiWorkflowExpectedOnBranch(on("workflow_dispatch"), "{}", "main")).toBe(
197 false
198 );
199 expect(isCiWorkflowExpectedOnBranch(on("schedule"), "{}", "main")).toBe(false);
200 });
201
202 it("does NOT expect an on: pull_request-only workflow", () => {
203 // Deliberate: pr-workflow-sync enqueues those at PR OPEN only, and
204 // re-run pins the original sha — so a PR that gained a commit has no
205 // way to produce one. A block with no remedy is worse than the hole.
206 expect(isCiWorkflowExpectedOnBranch(on("pull_request"), "{}", "feat/x")).toBe(
207 false
208 );
209 });
210
211 it("does not claim a row it cannot read", () => {
212 expect(isCiWorkflowExpectedOnBranch("not json", "{}", "main")).toBe(false);
213 expect(isCiWorkflowExpectedOnBranch(null, null, "main")).toBe(false);
214 });
215
216 it("treats an unreadable parsed blob as 'no branch filter', not 'no workflow'", () => {
217 expect(isCiWorkflowExpectedOnBranch(on("push"), "not json", "feat/x")).toBe(true);
218 });
219});
220
221describe("CI gate wiring", () => {
222 it("runAllGateChecks includes the CI check, and passes the head branch", async () => {
53223 const src = await Bun.file("src/lib/gate.ts").text();
54 const fn = src.slice(src.indexOf("export async function checkCiWorkflows"));
55 expect(fn).toContain("desc(workflowRuns.createdAt)");
56 expect(fn).toContain("latest.has");
224 // The branch is what lets the gate tell "no run because nothing was
225 // supposed to run" from "no run because nothing ran".
226 expect(src).toContain("checkCiWorkflows(repoRow.id, headSha, headBranch)");
227 expect(src).toContain("checks.push(ciResult)");
57228 });
58229
59230 it("a DB failure degrades to skipped — never blocks a merge on a lookup error", async () => {
Modifiedsrc/__tests__/merge-fires-push-workflows.test.ts+24−14View fileUnifiedSplit
88 * `on: push: branches: [main]` effectively never fired.
99 *
1010 * Structural, like sweepers-are-wired.test.ts: every merge execution path
11 * must reference the shared helper. There are FIVE merge sites (the
12 * "two sources of truth" pattern, times two and a half):
11 * must reference the shared helper. There are THREE merge sites left (the
12 * "two sources of truth" pattern, shrinking):
1313 * 1. lib/pr-merge.ts performMerge — ai-loop, auto-merge, autopilot
14 * 2. lib/pr-merge-gated.ts — v2 endpoint + MCP merge
14 * 2. lib/pr-merge-gated.ts — v2 endpoint + MCP merge + queue
1515 * 3. routes/pulls.tsx — the merge button
16 * 4. routes/merge-queue.tsxTWO success exits (non-ff + ff)
16 *
17 * routes/merge-queue.tsx used to be a fourth, with TWO success exits of its
18 * own. On 2026-08-29 it was routed through `performGatedMerge` — it no
19 * longer moves a ref, so it no longer needs its own call to this helper,
20 * and it inherits the guarantee from site 2 instead. One fewer copy to keep
21 * in step is the point; the assertion moved rather than being dropped.
1722 */
1823
1924import { describe, expect, test } from "bun:test";
5156 expect(src).toContain(HELPER);
5257 });
5358
54 test("the merge queue calls it on BOTH success exits", async () => {
59 test("the merge queue inherits it — it delegates instead of merging itself", async () => {
5560 const src = await read("src/routes/merge-queue.tsx");
56 const first = src.indexOf(HELPER);
57 expect(first).toBeGreaterThan(-1);
58 expect(src.indexOf(HELPER, first + 1)).toBeGreaterThan(first);
61 // It must not have grown its own copy back...
62 expect(src).not.toContain(HELPER);
63 // ...because it hands the whole merge to the chain that has one.
64 expect(src).toContain("await performGatedMerge({");
5965 });
6066});
6167
6571 // discards every commit merged since the branch diverged. The guard was
6672 // added to pr-merge.ts and merge-queue.tsx on incident day; the gated
6773 // chain (the path MCP merges take) was found still missing it later the
68 // same day. This pins all three.
69 for (const path of [
70 "src/lib/pr-merge-gated.ts",
71 "src/lib/pr-merge.ts",
72 "src/routes/merge-queue.tsx",
73 ]) {
74 // same day. Every file that moves a ref must carry it.
75 for (const path of ["src/lib/pr-merge-gated.ts", "src/lib/pr-merge.ts"]) {
7476 const src = await read(path);
7577 const guard = src.indexOf("--is-ancestor");
7678 const updateRef = src.indexOf('"update-ref"');
7880 expect(updateRef).toBeGreaterThan(guard);
7981 }
8082 });
83
84 test("the merge queue moves no ref of its own, so it needs no guard of its own", async () => {
85 // It carried a third copy of the guard until 2026-08-29. The safest
86 // copy of a dangerous operation is the one that does not exist.
87 const src = await read("src/routes/merge-queue.tsx");
88 expect(src).not.toContain('"update-ref"');
89 expect(src).not.toContain("--is-ancestor");
90 });
8191});
Addedsrc/__tests__/merge-queue-gated-chain.test.ts+103−0View fileUnifiedSplit
1/**
2 * The merge queue must enforce what the merge button enforces.
3 *
4 * Until 2026-08-29 `POST /:owner/:repo/queue/process-next` ran
5 * `runAllGateChecks` and then moved the base ref itself. Everything the
6 * other merge paths apply between those two steps was simply absent on
7 * that route:
8 *
9 * - branch protection (required approvals, required status checks,
10 * require-green-gates, require-AI-approval)
11 * - CODEOWNERS approval
12 * - the draft guard
13 * - the M3 pre-merge risk band
14 *
15 * It also passed a hardcoded `true` as the AI-review verdict instead of
16 * reading `aiReviewGateState`, so a PR the AI had blocked merged clean
17 * through the queue. A protected branch was only as protected as the route
18 * a merge happened to take.
19 *
20 * The fix routes process-next through `performGatedMerge` — the shared
21 * chain CLAUDE.md forbids re-inlining. These assertions are structural on
22 * purpose: the invariant being protected IS structural ("there is exactly
23 * one copy of the chain, and the queue calls it"), and the integration
24 * behaviour needs a seeded DB plus a real bare repo, which the neighbouring
25 * merge-queue.test.ts documents as out of scope.
26 */
27
28import { describe, expect, it } from "bun:test";
29
30const ROUTE = "src/routes/merge-queue.tsx";
31const CHAIN = "src/lib/pr-merge-gated.ts";
32
33describe("merge queue — routes through the shared gated-merge chain", () => {
34 it("process-next calls performGatedMerge", async () => {
35 const src = await Bun.file(ROUTE).text();
36 expect(src).toContain('from "../lib/pr-merge-gated"');
37 expect(src).toContain("await performGatedMerge({");
38 });
39
40 it("identifies itself as the queue so an incident can tell the paths apart", async () => {
41 const src = await Bun.file(ROUTE).text();
42 expect(src).toContain('source: "merge_queue"');
43 const chain = await Bun.file(CHAIN).text();
44 expect(chain).toContain('source: "mcp" | "api" | "merge_queue"');
45 });
46
47 it("holds no second copy of the merge mechanics the chain owns", async () => {
48 const src = await Bun.file(ROUTE).text();
49 // Each of these WAS imported and called in this file, inline, doing the
50 // chain's job. Asserting on the imports rather than on the identifiers
51 // keeps the historical note in the file's comments legal.
52 for (const mod of [
53 "../lib/gate",
54 "../lib/merge-resolver",
55 "../lib/merge-verifier",
56 ]) {
57 expect(src).not.toContain(`from "${mod}"`);
58 }
59 expect(src).not.toContain("await runAllGateChecks(");
60 expect(src).not.toContain("await mergeWithAutoResolve(");
61 expect(src).not.toContain('"update-ref"');
62 expect(src).not.toContain('"merge-base"');
63 expect(src).not.toContain("verifyAndRecord({");
64 });
65
66 it("does not flip the PR to merged itself — the chain owns that transition", async () => {
67 const src = await Bun.file(ROUTE).text();
68 expect(src).not.toContain('state: "merged"');
69 });
70
71 it("records a blocked entry with the chain's own reason, on the PR and on the queue", async () => {
72 const src = await Bun.file(ROUTE).text();
73 expect(src).toContain("if (!result.merged)");
74 expect(src).toContain('completeEntry(started.id, "failed", msg)');
75 // The reason has to reach the PR, not just the redirect of whoever
76 // clicked — otherwise the next person finds a failed entry and no why.
77 expect(src).toContain("**Merge queue:** blocked on latest base");
78 });
79});
80
81describe("the shared chain still enforces every protection the queue inherits", () => {
82 it("applies branch protection, required checks and CODEOWNERS", async () => {
83 const chain = await Bun.file(CHAIN).text();
84 expect(chain).toContain("matchProtection(repoId, pr.baseBranch)");
85 expect(chain).toContain("listRequiredChecks(protectionRule.id)");
86 expect(chain).toContain("passingCheckNames(repoId, headSha)");
87 expect(chain).toContain("evaluateProtection(");
88 expect(chain).toContain("requiredOwnersApproved(");
89 });
90
91 it("reads the AI-review verdict rather than assuming it", async () => {
92 const chain = await Bun.file(CHAIN).text();
93 expect(chain).toContain("await aiReviewGateState(pr.id)");
94 const route = await Bun.file(ROUTE).text();
95 // The queue used to pass a literal `true` here.
96 expect(route).not.toContain("aiReviewApproved");
97 });
98
99 it("blocks drafts", async () => {
100 const chain = await Bun.file(CHAIN).text();
101 expect(chain).toContain("if (pr.isDraft)");
102 });
103});
Addedsrc/__tests__/production-readiness-verdict.test.ts+161−0View fileUnifiedSplit
1/**
2 * The readiness gate's verdict arithmetic and its source-derived coverage.
3 *
4 * Pinned here because both have already failed silently once. The gate that
5 * "passed" while never running is the whole reason the third state exists,
6 * and a two-state verdict function is exactly the kind of code that looks
7 * correct in review — `!ok` reads as "failed" right up until a row appears
8 * that is neither.
9 *
10 * The extractors are pinned for the opposite reason: they are regexes over
11 * source files, so they degrade quietly. A regex that stops matching returns
12 * an empty list, and an empty list of routes is a gate that probes nothing
13 * while reporting a clean run.
14 */
15
16import { describe, expect, it } from "bun:test";
17import {
18 verdict,
19 onboardingLinkPaths,
20 repoScopedGetRoutes,
21 authGuardedGetRoutes,
22 materialize,
23} from "../../scripts/production-readiness.mjs";
24import type { GateResult, GateState } from "../../scripts/production-readiness.mjs";
25
26const row = (gate: string, hard: boolean, state: GateState): GateResult =>
27 ({ gate, hard, state, detail: "" });
28
29describe("verdict — unverified is not a pass", () => {
30 it("READY only when every hard gate actually ran and passed", () => {
31 const v = verdict([row("a", true, "pass"), row("b", true, "pass")]);
32 expect(v.code).toBe(0);
33 expect(v.label).toBe("READY");
34 });
35
36 it("a hard gate that never ran blocks READY", () => {
37 const v = verdict([row("a", true, "pass"), row("authz-matrix", true, "unverified")]);
38 expect(v.code).not.toBe(0);
39 expect(v.label).toBe("INCONCLUSIVE");
40 expect(v.hardUnverified.map((r: GateResult) => r.gate)).toEqual(["authz-matrix"]);
41 });
42
43 it("does not count an unverified gate as passed", () => {
44 const v = verdict([row("a", true, "pass"), row("b", true, "unverified")]);
45 expect(v.hardPassed).toBe(1);
46 expect(v.hardVerified).toBe(1);
47 expect(v.hardTotal).toBe(2);
48 });
49
50 it("separates 'the product is broken' from 'you did not run the check'", () => {
51 // Different exit codes on purpose: a FAIL is ours to fix, an UNVERIFIED
52 // is the caller's to supply. Collapsing them is the bug this replaces.
53 expect(verdict([row("a", true, "fail")]).code).toBe(1);
54 expect(verdict([row("a", true, "unverified")]).code).toBe(3);
55 });
56
57 it("reports NOT_READY when a gate both failed and another never ran", () => {
58 // A real failure outranks a coverage gap — fix the defect first.
59 const v = verdict([row("a", true, "fail"), row("b", true, "unverified")]);
60 expect(v.label).toBe("NOT_READY");
61 expect(v.code).toBe(1);
62 expect(v.hardUnverified.length).toBe(1);
63 });
64
65 it("soft gates never change the verdict", () => {
66 const v = verdict([
67 row("a", true, "pass"),
68 row("perf", false, "fail"),
69 row("images", false, "unverified"),
70 ]);
71 expect(v.code).toBe(0);
72 expect(v.softFails.length).toBe(1);
73 expect(v.softUnverified.length).toBe(1);
74 });
75});
76
77describe("onboardingLinkPaths — the newest user's dead ends", () => {
78 const links = onboardingLinkPaths("src/routes/onboarding.tsx");
79
80 it("finds the getting-started destinations in both source forms", () => {
81 // JSX (`href="/x"`) and the step-object form (`href: "/x"`) both appear
82 // in that file; matching only one silently halves the coverage.
83 expect(links.length).toBeGreaterThan(4);
84 expect(links).toContain("/new");
85 expect(links).toContain("/dashboard");
86 });
87
88 it("returns only absolute in-app paths, never an interpolated href", () => {
89 for (const l of links) {
90 expect(l.startsWith("/")).toBe(true);
91 expect(l).not.toContain("{");
92 }
93 });
94
95 it("returns empty rather than throwing when the file moves", () => {
96 // The gate turns empty into UNVERIFIED. It must not turn it into a
97 // clean run over zero links.
98 expect(onboardingLinkPaths("src/routes/does-not-exist.tsx")).toEqual([]);
99 });
100});
101
102describe("repoScopedGetRoutes — the authz matrix probes a real surface", () => {
103 const routes = repoScopedGetRoutes("src/routes");
104
105 it("enumerates repo-scoped GET routes from the router registrations", () => {
106 expect(routes.length).toBeGreaterThan(20);
107 for (const r of routes) expect(r.startsWith("/:owner/:repo")).toBe(true);
108 });
109
110 it("excludes the git Smart HTTP surface, which authenticates separately", () => {
111 expect(routes.some((r: string) => r.includes(".git"))).toBe(false);
112 });
113
114 it("materializes a probeable URL with no parameters left over", () => {
115 const url = materialize("/:owner/:repo/pulls/:number/files", "o", "n");
116 expect(url).toBe("/o/n/pulls/_probe/files");
117 expect(url).not.toContain(":");
118 });
119});
120
121describe("authGuardedGetRoutes — the auth-gate list is no longer hand-written", () => {
122 const guarded = authGuardedGetRoutes("src/routes");
123
124 it("derives far more than the six paths the literal list held", () => {
125 // The literal list documented the day it was written. /admin/security
126 // shipped ungated because it was never added to it.
127 expect(guarded.length).toBeGreaterThan(50);
128 });
129
130 it("finds pages guarded by a file-level .use(), not just inline middleware", () => {
131 // settings.use("/settings/*", requireAuth) sits hundreds of lines above
132 // the routes it protects. A scan that only reads the registration line
133 // marks all 29 settings pages unguarded.
134 expect(guarded).toContain("/settings/tokens");
135 expect(guarded).toContain("/settings/keys");
136 });
137
138 it("finds pages guarded by the admin `await gate(c)` prologue", () => {
139 expect(guarded).toContain("/admin");
140 });
141
142 it("does not claim deliberately public routes are guarded", () => {
143 // Both were reported as leaking on the first run: a fixed-size scan
144 // window ran past these short public handlers into the guarded handler
145 // registered below them. Two false positives is all it takes for an
146 // operator to stop reading the gate.
147 expect(guarded).not.toContain("/demo");
148 expect(guarded).not.toContain("/pwa/vapid-public-key");
149 });
150
151 it("excludes API and machine-readable surfaces", () => {
152 // The gate asks "does an HTML page render to a stranger". A JSON
153 // endpoint answering 200 is a different question with a different
154 // correct answer.
155 for (const p of guarded) {
156 expect(p.startsWith("/api")).toBe(false);
157 expect(p.endsWith(".json")).toBe(false);
158 expect(p.startsWith("/.well-known")).toBe(false);
159 }
160 });
161});
Addedsrc/__tests__/repo-freshness.test.ts+63−0View fileUnifiedSplit
1/**
2 * "Updated N ago" must not be able to contradict the repository.
3 *
4 * repositories.pushed_at only advances on pushes that go THROUGH the app.
5 * A ref written straight into the bare repo — system sshd, a push run on the
6 * box, an admin fixing something by hand — changes the repository and never
7 * tells the database. On this deployment that is not an edge case: the in-app
8 * SSH server is off (SSH_PORT=0 since the 2026-08-22 incident), so it is the
9 * only SSH path there is.
10 *
11 * The owner lost a day to it: the page read "Updated 6d ago" while ~30
12 * branches merged in. These pin the precedence rule that stops it recurring.
13 */
14
15import { describe, it, expect } from "bun:test";
16import { reconcileFreshness } from "../lib/repo-freshness";
17
18const d = (iso: string) => new Date(iso);
19
20describe("reconcileFreshness", () => {
21 it("prefers git when git is newer, and reports that it healed", () => {
22 // The exact case that misled the owner.
23 const r = reconcileFreshness(d("2026-08-21T00:00:00Z"), d("2026-08-27T21:00:00Z"));
24 expect(r.value?.toISOString()).toBe("2026-08-27T21:00:00.000Z");
25 expect(r.healed).toBe(true);
26 });
27
28 it("keeps the stored value when it is newer, and does not heal", () => {
29 // A repo can hold commits older than its last push — a force-push back to
30 // an earlier commit, or an import of old history. The stored push time is
31 // then the truer answer to "when did something last happen here", and
32 // overwriting it would move the date BACKWARDS on a live repo.
33 const r = reconcileFreshness(d("2026-08-27T21:00:00Z"), d("2026-01-01T00:00:00Z"));
34 expect(r.value?.toISOString()).toBe("2026-08-27T21:00:00.000Z");
35 expect(r.healed).toBe(false);
36 });
37
38 it("adopts git when nothing is stored", () => {
39 const r = reconcileFreshness(null, d("2026-08-27T21:00:00Z"));
40 expect(r.value?.toISOString()).toBe("2026-08-27T21:00:00.000Z");
41 expect(r.healed).toBe(true);
42 });
43
44 it("keeps the stored value when git has nothing to say", () => {
45 // An empty repo, or a git call that failed. A freshness read must never
46 // blank a date the database legitimately holds.
47 const stored = d("2026-08-20T00:00:00Z");
48 const r = reconcileFreshness(stored, null);
49 expect(r.value).toBe(stored);
50 expect(r.healed).toBe(false);
51 });
52
53 it("returns null only when neither source knows", () => {
54 expect(reconcileFreshness(null, null)).toEqual({ value: null, healed: false });
55 });
56
57 it("does not heal on an exact tie", () => {
58 // Equal timestamps mean the column is already correct; writing again
59 // would be a database round trip per page view for no change.
60 const t = d("2026-08-27T21:00:00Z");
61 expect(reconcileFreshness(t, new Date(t.getTime())).healed).toBe(false);
62 });
63});
Addedsrc/__tests__/repo-name-lowercase-on-create.test.ts+283−0View fileUnifiedSplit
1/**
2 * New repositories are stored with lowercase names, at every path that can
3 * create one.
4 *
5 * Case-insensitivity used to live entirely on the READ side: `lower()` in
6 * namespace.ts and repo-access.ts, a readdir scan in git/repository.ts, a
7 * canonicalising resolver in front of the MCP tools. A 2026-08-29 sweep across
8 * src/ counted 128 non-test lookups still comparing with
9 * `eq(repositories.name, x)` — among them api-v2.ts's `resolveRepo` helper
10 * (exact on BOTH username and name, behind 42 call sites, i.e. most of the v2
11 * REST surface), `lib/graphql.ts`, the SSH git path in `lib/ssh-server.ts`,
12 * and the push hook's fan-out through cloud-deploy and ai-auto-issues. Handed
13 * a repo whose row carries mixed case, each of those returns nothing — and
14 * nothing is indistinguishable from "no such repository". Every retrofit of a
15 * lookup is one more place the next surface can forget.
16 *
17 * Storing lowercase at creation ends the divergence at its source. It does not
18 * remove the need for the case-insensitive lookups — `ccantynz/Gluecron.com`
19 * and `ccantynz/Vapron` were created before this rule and still carry their
20 * original casing (see repo-name-mixed-case-still-resolves.test.ts).
21 *
22 * The per-call-site assertions below are structural, which is a deliberate
23 * choice and not laziness. What must not regress is that EVERY creation path
24 * normalizes — running one handler end-to-end would prove one path and say
25 * nothing about the other ten, which is exactly the shape of the bug being
26 * fixed. The last test in this file closes that gap from the other side: it
27 * enumerates every `.insert(repositories)` in src/ and fails on one this file
28 * does not know about, so a creation path added later cannot ship unnormalized
29 * while the suite still reads green.
30 */
31
32import { describe, it, expect } from "bun:test";
33import { readFileSync, readdirSync, statSync } from "fs";
34import { join } from "path";
35
36import { normalizeRepoName, REPO_NAME_PATTERN } from "../lib/repo-name";
37import { sanitizeRepoName } from "../lib/import-helper";
38
39describe("normalizeRepoName", () => {
40 it("lowercases the name that gets stored", () => {
41 expect(normalizeRepoName("MyRepo")).toBe("myrepo");
42 expect(normalizeRepoName("Vapron")).toBe("vapron");
43 expect(normalizeRepoName("Gluecron.com")).toBe("gluecron.com");
44 expect(normalizeRepoName("BookARide")).toBe("bookaride");
45 });
46
47 it("leaves an already-canonical name untouched", () => {
48 for (const name of ["gluecron.com", "todo-api", "hello_world", "x1", "a.b-c_d"]) {
49 expect(normalizeRepoName(name)).toBe(name);
50 }
51 });
52
53 it("trims surrounding whitespace, which a pasted name carries", () => {
54 expect(normalizeRepoName(" MyRepo\n")).toBe("myrepo");
55 expect(normalizeRepoName("\tspaced ")).toBe("spaced");
56 });
57
58 it("is idempotent — normalizing a stored name is a no-op", () => {
59 const once = normalizeRepoName(" MixedCase.Repo ");
60 expect(normalizeRepoName(once)).toBe(once);
61 });
62
63 it("collapses non-string input to empty rather than throwing", () => {
64 // Handlers forward request bodies straight in; an absent field must hit
65 // their existing "name is required" branch, not a TypeError mid-handler.
66 expect(normalizeRepoName(undefined)).toBe("");
67 expect(normalizeRepoName(null)).toBe("");
68 expect(normalizeRepoName(42)).toBe("");
69 expect(normalizeRepoName({})).toBe("");
70 });
71});
72
73describe("REPO_NAME_PATTERN — validation is unchanged", () => {
74 // The pattern is character-for-character the inline regex the four creation
75 // handlers each carried before it was hoisted. Uppercase must stay ACCEPTED:
76 // normalizing is the point, rejecting would turn a working form into an
77 // error for anyone who capitalises.
78 it("is the same expression the handlers validated with before", () => {
79 expect(REPO_NAME_PATTERN.source).toBe("^[a-zA-Z0-9._-]+$");
80 });
81
82 it("still accepts every character class it accepted before", () => {
83 for (const name of [
84 "MyRepo",
85 "myrepo",
86 "MY-REPO",
87 "Gluecron.com",
88 "hello_world",
89 "v2.0.1",
90 "a",
91 "0",
92 "_leading",
93 "-dashes-",
94 "dots.and-dashes_and0123",
95 ]) {
96 expect(REPO_NAME_PATTERN.test(name)).toBe(true);
97 }
98 });
99
100 it("still rejects everything it rejected before", () => {
101 for (const name of ["", "has space", "slash/name", "back\\slash", "e^mail", "café", "a b"]) {
102 expect(REPO_NAME_PATTERN.test(name)).toBe(false);
103 }
104 });
105
106 it("accepts anything that survives normalization, and vice versa", () => {
107 // If normalizing could turn a valid name invalid, validate-then-store
108 // would write a name the platform refuses to accept back.
109 for (const name of ["MyRepo", "Gluecron.com", "V2.0-RC_1"]) {
110 expect(REPO_NAME_PATTERN.test(name)).toBe(true);
111 expect(REPO_NAME_PATTERN.test(normalizeRepoName(name))).toBe(true);
112 }
113 });
114});
115
116describe("sanitizeRepoName — the import path's repairing variant", () => {
117 // Import cannot reject: a GitHub repo may legally hold characters gluecron
118 // does not. It repairs, then delegates the lowercase half to
119 // normalizeRepoName so the two definitions of "canonical" cannot drift.
120 it("lowercases like every other creation path", () => {
121 expect(sanitizeRepoName("Vapron")).toBe("vapron");
122 expect(sanitizeRepoName("Gluecron.com")).toBe("gluecron.com");
123 });
124
125 it("still replaces disallowed characters and strips edge hyphens", () => {
126 expect(sanitizeRepoName("My Repo!")).toBe("my-repo");
127 expect(sanitizeRepoName("--Weird--")).toBe("weird");
128 });
129
130 it("still falls back rather than producing an empty name", () => {
131 expect(sanitizeRepoName("!!!")).toBe("imported-repo");
132 });
133});
134
135/** Source of a file under the repo root. */
136function src(path: string): string {
137 return readFileSync(path, "utf8");
138}
139
140describe("every creation path normalizes before it inserts", () => {
141 it("web POST /new stores the normalized name", () => {
142 const s = src("src/routes/web.tsx");
143 expect(s).toContain("const name = normalizeRepoName(body.name);");
144 expect(s).toContain("REPO_NAME_PATTERN.test(name)");
145 });
146
147 it("the /new form tells the user names are lowercased", () => {
148 // The hint claimed "Lowercase, numbers, dots..." while the handler stored
149 // the name verbatim — the copy described a rule the code did not enforce.
150 const s = src("src/routes/web.tsx");
151 expect(s).toContain("Names are stored");
152 expect(s).toContain("lowercase");
153 });
154
155 it("REST v1 POST /api/repos and POST /api/setup both normalize", () => {
156 const s = src("src/routes/api.ts");
157 expect(s).toContain("body.name = normalizeRepoName(body.name);");
158 expect(s).toContain("body.repoName = normalizeRepoName(body.repoName);");
159 });
160
161 it("REST v2 POST /repos inserts the normalized local, not body.name", () => {
162 const s = src("src/routes/api-v2.ts");
163 expect(s).toContain("const repoName = normalizeRepoName(body.name);");
164 const insert = s.slice(s.indexOf(".insert(repositories)"));
165 expect(insert.slice(0, 300)).toContain("name: repoName,");
166 // The bare repo on disk must carry the same spelling as the row.
167 expect(s).toContain("initBareRepo(user.username, repoName)");
168 });
169
170 it("the Claude integration auto-create normalizes", () => {
171 const s = src("src/routes/claude-integration.ts");
172 expect(s).toContain("normalizeRepoName(body.repoName)");
173 expect(s).toContain("REPO_NAME_PATTERN.test(repoName)");
174 });
175
176 it("fork names the copy off the source ROW, lowercased", () => {
177 // Not off the URL param: forking /ccantynz/Vapron must produce you/vapron,
178 // and the new directory must match the new row.
179 const s = src("src/routes/fork.tsx");
180 expect(s).toContain("const forkName = normalizeRepoName(sourceRepo.name);");
181 expect(s).toContain("`${forkName}.git`");
182 const insert = s.slice(s.indexOf(".insert(repositories)"));
183 expect(insert.slice(0, 300)).toContain("name: forkName,");
184 // And sends the user to the fork's own URL, not the source's casing.
185 expect(s).toContain("c.redirect(`/${user.username}/${forkName}`)");
186 });
187
188 it("use-this-template normalizes the requested name", () => {
189 const s = src("src/routes/templates.ts");
190 expect(s).toContain("const newName = normalizeRepoName(body.name);");
191 expect(s).toContain("REPO_NAME_PATTERN.test(newName)");
192 });
193
194 it("the MCP fork tool normalizes, on disk and in the row", () => {
195 const s = src("src/lib/mcp-tools-expanded.ts");
196 expect(s).toContain("const forkName = normalizeRepoName(srcName);");
197 expect(s).toContain("`${forkName}.git`");
198 const insert = s.slice(s.indexOf(".insert(repositories)"));
199 expect(insert.slice(0, 300)).toContain("name: forkName,");
200 // And reports the fork it made, not the source argument it was given.
201 expect(s).toContain("repo: forkName,");
202 });
203
204 it("GitHub import and bulk migration go through sanitizeRepoName", () => {
205 const imp = src("src/routes/import.tsx");
206 expect(imp).toContain("const safeName = sanitizeRepoName(ghRepo.name);");
207 expect(imp.slice(imp.indexOf("db.insert(repositories)"), imp.indexOf("db.insert(repositories)") + 300)).toContain(
208 "name: safeName,"
209 );
210
211 const mig = src("src/routes/migrate.tsx");
212 expect(mig).toContain("const safeName = sanitizeRepoName(repo.name);");
213 expect(mig.slice(mig.indexOf(".insert(repositories)"), mig.indexOf(".insert(repositories)") + 300)).toContain(
214 "name: finalName,"
215 );
216 });
217
218 it("the demo seeder and the playground sandbox normalize too", () => {
219 // Both currently pass literal lowercase constants, so this asserts the
220 // guard rather than a behaviour change — a spec renamed to "Design-Docs"
221 // later must not make a seeder the last producer of mixed-case rows.
222 expect(src("src/lib/demo-seed.ts")).toContain("name: normalizeRepoName(spec.name),");
223 expect(src("src/lib/playground.ts")).toContain("name: normalizeRepoName(args.repoName),");
224 });
225});
226
227/** Every .ts/.tsx file under src/, excluding the test suite itself. */
228function sourceFiles(dir: string, out: string[] = []): string[] {
229 for (const entry of readdirSync(dir)) {
230 const full = join(dir, entry);
231 if (statSync(full).isDirectory()) {
232 if (entry === "__tests__") continue;
233 sourceFiles(full, out);
234 } else if (/\.tsx?$/.test(entry) && !/\.test\.tsx?$/.test(entry)) {
235 out.push(full.replaceAll("\\", "/"));
236 }
237 }
238 return out;
239}
240
241describe("no creation path escapes this file's coverage", () => {
242 /**
243 * The modules asserted above, each of which was read and confirmed to
244 * normalize. A new entry here is a promise that the corresponding
245 * assertion exists above — adding the path without the assertion is the
246 * one way to defeat this guard, and it is a deliberate act rather than an
247 * oversight.
248 */
249 const KNOWN = new Set([
250 "src/routes/web.tsx",
251 "src/routes/api.ts",
252 "src/routes/api-v2.ts",
253 "src/routes/claude-integration.ts",
254 "src/routes/fork.tsx",
255 "src/routes/templates.ts",
256 "src/routes/import.tsx",
257 "src/routes/migrate.tsx",
258 "src/lib/import-helper.ts",
259 "src/lib/mcp-tools-expanded.ts",
260 "src/lib/demo-seed.ts",
261 "src/lib/playground.ts",
262 ]);
263
264 it("finds no repository insert outside the known creation paths", () => {
265 const found = sourceFiles("src").filter((f) =>
266 src(f).includes("insert(repositories)")
267 );
268 const unknown = found.filter((f) => !KNOWN.has(f));
269 expect(unknown).toEqual([]);
270 });
271
272 it("still finds every known path — the guard is not passing by finding nothing", () => {
273 // A negative result proves nothing about the world unless the search is
274 // known to work. If the scan silently stopped matching, the test above
275 // would go green with zero findings.
276 const found = new Set(
277 sourceFiles("src").filter((f) => src(f).includes("insert(repositories)"))
278 );
279 for (const known of KNOWN) {
280 expect(found.has(known)).toBe(true);
281 }
282 });
283});
Addedsrc/__tests__/repo-name-mixed-case-still-resolves.test.ts+122−0View fileUnifiedSplit
1/**
2 * Storing new repositories lowercase must NOT be read as permission to drop
3 * the case-insensitive lookups.
4 *
5 * Two rows on production predate the rule and were deliberately left alone:
6 *
7 * ccantynz/Gluecron.com — the self-host repo. Its URL is baked into
8 * CLAUDE.md, scripts/auto-update.sh, /opt/gluecron/.env and every agent's
9 * .mcp.json. Renaming it breaks the deploy pipeline.
10 * ccantynz/Vapron — private, hosts its own downstream instance.
11 *
12 * Both must keep resolving from any casing a caller types. The assertions run
13 * against the drizzle SQL the resolvers actually emit, not against source
14 * text, so they fail if a query changes shape rather than merely if a comment
15 * moves — and they mean the same thing on the case-insensitive Windows dev box
16 * as on the case-sensitive Linux host, which a filesystem-level test would
17 * not.
18 *
19 * Only `../db` is stubbed, and it is restored in afterAll. That is the same
20 * choice mcp-write-repo-case-insensitive.test.ts makes and for the same
21 * reason: mock.module is process-global in Bun, so mocking the middleware or
22 * the schema instead bleeds into every later suite.
23 */
24
25import { describe, it, expect, mock, afterAll } from "bun:test";
26
27const _real_db = await import("../db");
28
29/** WHERE arguments captured off the select chain, in call order. */
30const _wheres: any[] = [];
31
32/** A row carrying the casing production actually stores. */
33const _row = {
34 id: "repo-1",
35 ownerId: "user-1",
36 name: "Gluecron.com",
37 isPrivate: false,
38 defaultBranch: "main",
39 diskPath: "/data/repos/ccantynz/Gluecron.com.git",
40 orgId: null,
41 slug: "ccantynz",
42};
43
44const _chain: any = {
45 from: () => _chain,
46 innerJoin: () => _chain,
47 where: (clause: any) => {
48 _wheres.push(clause);
49 return _chain;
50 },
51 limit: async () => [_row],
52};
53
54mock.module("../db", () => ({
55 db: { select: () => _chain },
56}));
57
58const { loadRepoByPath, resolveNamespace } = await import("../lib/namespace");
59
60afterAll(() => {
61 mock.module("../db", () => _real_db);
62});
63
64/** Flatten a drizzle SQL object's chunks down to the literal text it emits. */
65function sqlText(node: any, depth = 0): string {
66 if (node == null || depth > 12) return "";
67 if (typeof node === "string") return node;
68 if (Array.isArray(node)) return node.map((n) => sqlText(n, depth + 1)).join("");
69 if (typeof node !== "object") return "";
70 if (Array.isArray(node.value)) return node.value.join("");
71 if (Array.isArray(node.queryChunks))
72 return node.queryChunks.map((c: any) => sqlText(c, depth + 1)).join("");
73 return "";
74}
75
76describe("loadRepoByPath still finds a mixed-case row", () => {
77 it("lowers both sides of the repo-name comparison", async () => {
78 _wheres.length = 0;
79 await loadRepoByPath("ccantynz", "gluecron.com");
80
81 // _wheres[0] is resolveNamespace's username lookup; _wheres[1] is the
82 // repository lookup this test is about.
83 expect(_wheres.length).toBeGreaterThan(1);
84 const text = sqlText(_wheres[1]);
85 // lower(col) = lower(param) is two calls; a bare eq() would be zero.
86 expect((text.match(/lower\(/g) ?? []).length).toBeGreaterThanOrEqual(2);
87 });
88
89 it("returns the row whatever casing the URL carried", async () => {
90 for (const asked of [
91 "gluecron.com",
92 "Gluecron.com",
93 "GLUECRON.COM",
94 "gLuEcRoN.cOm",
95 ]) {
96 const repo = await loadRepoByPath("ccantynz", asked);
97 expect(repo?.id).toBe("repo-1");
98 // The CANONICAL stored spelling comes back, not the caller's — every
99 // disk path and clone URL downstream is built from this.
100 expect(repo?.name).toBe("Gluecron.com");
101 }
102 });
103
104 it("resolves the owner slug case-insensitively too", async () => {
105 _wheres.length = 0;
106 const ns = await resolveNamespace("CCANTYNZ");
107 expect(ns?.kind).toBe("user");
108 expect((sqlText(_wheres[0]).match(/lower\(/g) ?? []).length).toBeGreaterThanOrEqual(2);
109 });
110});
111
112describe("the repo-access middleware keeps its case-insensitive resolve", () => {
113 // requireRepoAccess is the gate in front of the repo-write routes. If it
114 // regressed to eq(), ccantynz/Vapron would 404 for its own owner on every
115 // surface behind it, while the read paths kept working — the split-brain
116 // failure this whole area exists to prevent.
117 it("lowers both slugs when it looks the repository up", async () => {
118 const source = await Bun.file("src/middleware/repo-access.ts").text();
119 expect(source).toContain("lower(${users.username}) = lower(${ownerName})");
120 expect(source).toContain("lower(${repositories.name}) = lower(${repoName})");
121 });
122});
Modifiedsrc/__tests__/synthetic-journeys.test.ts+54−1View fileUnifiedSplit
88 */
99
1010import { describe, expect, it } from "bun:test";
11import { runJourneyChecks } from "../lib/synthetic-journeys";
11import { runJourneyChecks, classifyRepoCreate } from "../lib/synthetic-journeys";
1212
1313function fakeFetch(
1414 responder: (url: string, init?: RequestInit) => Response
106106 expect(src).toContain('startsWith("!unloginable!")');
107107 });
108108});
109
110describe("journey:repo-write — a 302 is not proof the repo was made", () => {
111 // POST /new answers 302 to BOTH outcomes: the repo page on success, and
112 // /new?error=... on refusal. Every naive success test (`res.ok`,
113 // `status < 400`, "it redirected") scores a refusal as a healthy write
114 // path, which would keep this journey green through the exact failures it
115 // was added to catch.
116 const OWNER = "spine-probe";
117 const REPO = "spine-probe-abc123";
118
119 it("green when the redirect lands on the new repo's page", () => {
120 const v = classifyRepoCreate(302, `/${OWNER}/${REPO}`, OWNER, REPO);
121 expect(v.ok).toBe(true);
122 });
123
124 it("red when the quota gate refuses, even though the status is a 302", () => {
125 const v = classifyRepoCreate(
126 302,
127 "/new?error=Repository+limit+reached+on+your+plan",
128 OWNER,
129 REPO
130 );
131 expect(v.ok).toBe(false);
132 // The operator needs the reason, not "creation failed" — the refusals
133 // have different owners (billing, validation, a name collision).
134 expect(v.reason).toContain("Repository limit reached on your plan");
135 });
136
137 it("red when the name was rejected", () => {
138 const v = classifyRepoCreate(302, "/new?error=Invalid+repository+name", OWNER, REPO);
139 expect(v.ok).toBe(false);
140 expect(v.reason).toContain("Invalid repository name");
141 });
142
143 it("red on a 5xx from the handler", () => {
144 const v = classifyRepoCreate(500, null, OWNER, REPO);
145 expect(v.ok).toBe(false);
146 expect(v.reason).toContain("500");
147 });
148
149 it("red when there is no Location header at all", () => {
150 const v = classifyRepoCreate(302, null, OWNER, REPO);
151 expect(v.ok).toBe(false);
152 expect(v.reason).toContain("no location");
153 });
154
155 it("does not accept a redirect to a DIFFERENT repo", () => {
156 // A stale-session or wrong-owner bug that lands the probe on somebody
157 // else's repo page must not read as a successful write.
158 const v = classifyRepoCreate(302, "/someone-else/their-repo", OWNER, REPO);
159 expect(v.ok).toBe(false);
160 });
161});
Addedsrc/__tests__/uuid-param.test.ts+68−0View fileUnifiedSplit
1/**
2 * Path-parameter id validation.
3 *
4 * Four repo-scoped pages answered 500 to a malformed id — milestone detail,
5 * milestone edit, agent session, ruleset detail — because Postgres raises
6 * 22P02 ("invalid input syntax for type uuid") rather than returning no
7 * rows, so the `if (!row) return 404` line beneath each query never ran.
8 *
9 * Found by the authz-matrix readiness gate on the first run after its
10 * worker pool was fixed; that gate had been reporting "no leaks or
11 * over-blocking" over zero probed routes.
12 *
13 * The regex is the whole fix, so it is pinned here rather than trusted:
14 * too loose and the 500s come back, too strict and real ids start 404ing,
15 * which is the worse failure because it is silent.
16 */
17
18import { describe, expect, it } from "bun:test";
19import { isUuid } from "../lib/uuid-param";
20
21describe("isUuid", () => {
22 it("accepts a real generated uuid", () => {
23 expect(isUuid(crypto.randomUUID())).toBe(true);
24 });
25
26 it("accepts uppercase, which Postgres also accepts", () => {
27 // Rejecting these would 404 ids that resolve perfectly well — a
28 // stricter gate than the database's own is its own bug.
29 expect(isUuid("A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")).toBe(true);
30 });
31
32 it("rejects the probe placeholder that produced the 500s", () => {
33 expect(isUuid("_probe")).toBe(false);
34 });
35
36 it("rejects the near-misses that would still raise 22P02", () => {
37 for (const bad of [
38 "",
39 "not-a-uuid",
40 "123",
41 // right shape, wrong length
42 "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5",
43 "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5dd",
44 // non-hex character in an otherwise perfect uuid
45 "g1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d",
46 // no dashes
47 "a1b2c3d4e5f64a5b8c9d0e1f2a3b4c5d",
48 // SQL-ish input, which must be refused on shape long before it is a
49 // question of escaping
50 "1' OR '1'='1",
51 ]) {
52 expect(isUuid(bad)).toBe(false);
53 }
54 });
55
56 it("rejects null and undefined without throwing", () => {
57 // Called on `c.req.param(...)`, which is typed as possibly undefined.
58 expect(isUuid(undefined)).toBe(false);
59 expect(isUuid(null)).toBe(false);
60 });
61
62 it("rejects a uuid with surrounding whitespace or a suffix", () => {
63 const u = crypto.randomUUID();
64 expect(isUuid(` ${u}`)).toBe(false);
65 expect(isUuid(`${u}\n`)).toBe(false);
66 expect(isUuid(`${u}/edit`)).toBe(false);
67 });
68});
Addedsrc/__tests__/workflow-exec-policy.test.ts+112−0View fileUnifiedSplit
1/**
2 * Who may execute workflow steps.
3 *
4 * The control this pins is containment for a live exposure: a `run:` step
5 * is `bash -c <user string>` spawned by the app, in the app container, as
6 * the same uid as PID 1 — which holds every secret via compose's
7 * `env_file: .env`, and therefore leaks through /proc/1/environ regardless
8 * of what buildRunnerEnv hands the step. Registration is open and a push
9 * enqueues a run.
10 *
11 * So the direction of every default below is deliberate and is the point of
12 * the file: unset refuses, unknown refuses, a failed lookup refuses. A CI
13 * system's instinct is the opposite — never break a build — and that
14 * instinct is exactly what makes a control useless, because the conditions
15 * under which it would fail open are the conditions an attacker arranges.
16 */
17
18import { describe, expect, it } from "bun:test";
19import {
20 checkExecutionAllowed,
21 parseAllowlist,
22} from "../lib/workflow-exec-policy";
23
24const env = (v?: string) =>
25 (v === undefined ? {} : { WORKFLOW_EXEC_ALLOWLIST: v }) as NodeJS.ProcessEnv;
26
27describe("checkExecutionAllowed — fails closed", () => {
28 it("refuses when the allowlist is unset", () => {
29 // Unset is indistinguishable from a .env that lost the line.
30 const d = checkExecutionAllowed("ccantynz", env());
31 expect(d.allowed).toBe(false);
32 expect(d.reason).toContain("not configured");
33 });
34
35 it("refuses when the allowlist is empty or whitespace", () => {
36 expect(checkExecutionAllowed("ccantynz", env("")).allowed).toBe(false);
37 expect(checkExecutionAllowed("ccantynz", env(" ")).allowed).toBe(false);
38 });
39
40 it("refuses an account that is not listed", () => {
41 const d = checkExecutionAllowed("attacker", env("ccantynz"));
42 expect(d.allowed).toBe(false);
43 expect(d.reason).toContain("attacker");
44 });
45
46 it("refuses when the owning account could not be determined", () => {
47 // A database blip during owner lookup must not become an open door.
48 expect(checkExecutionAllowed(null, env("ccantynz")).allowed).toBe(false);
49 expect(checkExecutionAllowed(undefined, env("ccantynz")).allowed).toBe(false);
50 expect(checkExecutionAllowed("", env("ccantynz")).allowed).toBe(false);
51 });
52
53 it("refuses everyone when set to none", () => {
54 const d = checkExecutionAllowed("ccantynz", env("none"));
55 expect(d.allowed).toBe(false);
56 expect(d.reason).toContain("disabled");
57 });
58});
59
60describe("checkExecutionAllowed — allows exactly who it should", () => {
61 it("allows a listed account", () => {
62 expect(checkExecutionAllowed("ccantynz", env("ccantynz")).allowed).toBe(true);
63 });
64
65 it("matches case-insensitively, both sides", () => {
66 // Usernames arrive from a DB row and the list is hand-edited in a .env
67 // at 3am. A casing mismatch must not read as "not trusted".
68 expect(checkExecutionAllowed("CCantyNZ", env("ccantynz")).allowed).toBe(true);
69 expect(checkExecutionAllowed("ccantynz", env("CCantyNZ")).allowed).toBe(true);
70 });
71
72 it("tolerates the whitespace a hand-edited list collects", () => {
73 const d = checkExecutionAllowed("bob", env(" alice , bob ,carol "));
74 expect(d.allowed).toBe(true);
75 });
76
77 it("opens up only on the explicit word, never on an empty value", () => {
78 // Turning the control off must be something somebody typed on purpose
79 // and can be found in a diff.
80 expect(checkExecutionAllowed("anyone-at-all", env("all")).allowed).toBe(true);
81 expect(checkExecutionAllowed("anyone-at-all", env("")).allowed).toBe(false);
82 });
83
84 it("does not let a substring of a listed name through", () => {
85 // "ccantynz-evil" must not match on "ccantynz".
86 expect(checkExecutionAllowed("ccantynz-evil", env("ccantynz")).allowed).toBe(false);
87 expect(checkExecutionAllowed("ccanty", env("ccantynz")).allowed).toBe(false);
88 });
89
90 it("gives a reason on every decision, including the allows", () => {
91 // The reason is rendered on the run; a blank one sends an operator to
92 // read source to learn why their build did not start.
93 for (const d of [
94 checkExecutionAllowed("ccantynz", env("ccantynz")),
95 checkExecutionAllowed("nope", env("ccantynz")),
96 checkExecutionAllowed("nope", env()),
97 ]) {
98 expect(d.reason.length).toBeGreaterThan(0);
99 }
100 });
101});
102
103describe("parseAllowlist", () => {
104 it("drops empty entries from trailing and doubled commas", () => {
105 expect(parseAllowlist("a,,b,").size).toBe(2);
106 });
107
108 it("returns an empty set for null and undefined without throwing", () => {
109 expect(parseAllowlist(null).size).toBe(0);
110 expect(parseAllowlist(undefined).size).toBe(0);
111 });
112});
Addedsrc/__tests__/workflow-silent-steps.test.ts+132−0View fileUnifiedSplit
1/**
2 * "Succeeded but did no work" detection.
3 *
4 * A required status check can be pointed at a command that is structurally
5 * incapable of failing — `vitest` with no `node` binary on PATH exits 0
6 * having run zero tests. The run is green, the required check is satisfied,
7 * and nothing was tested. Same class of defect as a gate scoring a check it
8 * could not run as passed.
9 *
10 * The heuristic is deliberately the narrowest honest one the runner has
11 * data for: exited 0, wrote nothing to either stream. It is surfaced as a
12 * warning on the run and never blocks — a false positive that blocked
13 * merges would be worse than the disease.
14 */
15
16import { describe, expect, it } from "bun:test";
17import { detectSilentSuccessSteps } from "../lib/workflow-silent-steps";
18
19describe("detectSilentSuccessSteps", () => {
20 it("flags a step that exited 0 and produced no output at all", () => {
21 expect(
22 detectSilentSuccessSteps([
23 {
24 name: "Run tests",
25 run: "vitest run",
26 status: "success",
27 exitCode: 0,
28 stdout: "",
29 stderr: "",
30 },
31 ])
32 ).toEqual(["Run tests"]);
33 });
34
35 it("does not flag a step that produced output on stdout", () => {
36 expect(
37 detectSilentSuccessSteps([
38 {
39 name: "Run tests",
40 run: "bun test",
41 status: "success",
42 exitCode: 0,
43 stdout: "42 pass 0 fail",
44 stderr: "",
45 },
46 ])
47 ).toEqual([]);
48 });
49
50 it("does not flag a step whose only output was on stderr", () => {
51 expect(
52 detectSilentSuccessSteps([
53 {
54 name: "Lint",
55 run: "eslint .",
56 status: "success",
57 exitCode: 0,
58 stdout: "",
59 stderr: "warning: deprecated rule",
60 },
61 ])
62 ).toEqual([]);
63 });
64
65 it("does not flag failures — a red step is already saying something", () => {
66 expect(
67 detectSilentSuccessSteps([
68 {
69 name: "Run tests",
70 run: "bun test",
71 status: "failure",
72 exitCode: 1,
73 stdout: "",
74 stderr: "",
75 },
76 ])
77 ).toEqual([]);
78 });
79
80 it("does not flag a v1 no-run placeholder — it never had a chance to produce anything", () => {
81 expect(
82 detectSilentSuccessSteps([
83 {
84 name: "actions/checkout@v4",
85 run: "",
86 status: "success",
87 exitCode: 0,
88 stdout: "",
89 stderr: "",
90 },
91 ])
92 ).toEqual([]);
93 });
94
95 it("treats whitespace-only output as no output", () => {
96 expect(
97 detectSilentSuccessSteps([
98 {
99 name: "Run tests",
100 run: "vitest run",
101 status: "success",
102 exitCode: 0,
103 stdout: "\n \n",
104 stderr: " ",
105 },
106 ])
107 ).toEqual(["Run tests"]);
108 });
109
110 it("falls back to the step index when a step has no name", () => {
111 expect(
112 detectSilentSuccessSteps([
113 { run: "true", status: "success", exitCode: 0, stdout: "", stderr: "" },
114 ])
115 ).toEqual(["Step 1"]);
116 });
117
118 it("returns nothing for an empty job", () => {
119 expect(detectSilentSuccessSteps([])).toEqual([]);
120 });
121});
122
123describe("the warning is surfaced on the run, and only as a warning", () => {
124 it("the run detail page renders it without changing the run's verdict", async () => {
125 const src = await Bun.file("src/routes/workflows.tsx").text();
126 expect(src).toContain("detectSilentSuccessSteps");
127 expect(src).toContain("Succeeded without producing any output");
128 expect(src).toContain("This is a warning, not a failure");
129 // It must not touch the pass/fail decision anywhere.
130 expect(src).not.toContain("silentSteps.length > 0 && failed");
131 });
132});
Modifiedsrc/lib/autopilot.ts+53−0View fileUnifiedSplit
205205 */
206206const SURFACE_MONITOR_INTERVAL_MS = 20 * 60 * 1000;
207207let _lastSurfaceMonitorAt = 0;
208/**
209 * First-run journey cadence. Daily, and it is the most expensive check we
210 * run: a real throwaway account, a real repository, real git subprocesses
211 * pushing over HTTPS, a real PR and a real merge — the whole path a new
212 * team walks, which nothing else covers end to end.
213 *
214 * Everything else that runs continuously only reads, or at most creates a
215 * repo (journey:repo-write). None of it pushes a commit, so "git push
216 * works" — the one claim this product cannot afford to be wrong about —
217 * was previously proven only when a human remembered to run the script.
218 *
219 * Daily is the cost/benefit line: a push or merge regression surfaces the
220 * next day instead of the next time a customer trips over it, and one
221 * throwaway account per day is a rounding error the script cleans up after
222 * itself.
223 */
224const FIRST_RUN_JOURNEY_INTERVAL_MS = 24 * 60 * 60 * 1000;
225let _lastFirstRunJourneyAt = 0;
208226/**
209227 * Dependency-probe cadence. These are calls to other people's APIs, so
210228 * the cadence is a courtesy as much as a cost control: 10 minutes is
638656 }
639657 },
640658 },
659 {
660 // First-run journey — the path a brand-new team actually walks.
661 //
662 // Register, create a repo, mint a PAT, push over git HTTPS, branch,
663 // open a PR, merge, clean up. Results land on the same
664 // synthetic_checks pipeline as the five-minute journeys, so a red
665 // here alerts and shows on /status like any other user-facing break.
666 name: "first-run-journey",
667 run: async () => {
668 const now = Date.now();
669 if (now - _lastFirstRunJourneyAt < FIRST_RUN_JOURNEY_INTERVAL_MS) return;
670 // Stamped BEFORE the run, not after. This task can take minutes;
671 // stamping on completion would let a second tick start a parallel
672 // run — two throwaway accounts racing through repo creation and
673 // merge against the same instance.
674 _lastFirstRunJourneyAt = now;
675 try {
676 const { runFirstRunJourneyOnce } = await import(
677 "./first-run-journey-task"
678 );
679 const r = await runFirstRunJourneyOnce();
680 const took = `${Math.round(r.durationMs / 1000)}s`;
681 if (r.status === "green") {
682 console.log(`[autopilot] first-run-journey: green in ${took}`);
683 } else {
684 console.error(
685 `[autopilot] first-run-journey: ${r.status.toUpperCase()} after ${took} — ${r.error ?? "no detail"}`
686 );
687 }
688 } catch (err) {
689 console.error("[autopilot] first-run-journey: threw:", err);
690 throw err;
691 }
692 },
693 },
641694 {
642695 // Dependency probes — do our upstreams actually answer?
643696 //
Modifiedsrc/lib/demo-seed.ts+5−1View fileUnifiedSplit
3030import { hashPassword } from "./auth";
3131import { initBareRepo, getRepoPath } from "../git/repository";
3232import { bootstrapRepository } from "./repo-bootstrap";
33import { normalizeRepoName } from "./repo-name";
3334
3435export const DEMO_USERNAME = "demo" as const;
3536const DEMO_EMAIL = "demo@gluecron.local";
566567 const [inserted] = await db
567568 .insert(repositories)
568569 .values({
569 name: spec.name,
570 // The DEMO_REPOS specs are already lowercase; routed through the
571 // shared normalizer anyway so adding a spec named "Design-Docs"
572 // later cannot make the seeder the last source of mixed-case rows.
573 name: normalizeRepoName(spec.name),
570574 ownerId: demoUser.id,
571575 description: spec.description,
572576 isPrivate: false,
Addedsrc/lib/first-run-journey-task.ts+196−0View fileUnifiedSplit
1/**
2 * The brand-new-human journey, on a schedule.
3 *
4 * scripts/first-run-journey.mjs is the best check in this repo and, until
5 * now, the least used: it registers a throwaway account, creates a repo,
6 * mints a PAT, pushes over git HTTPS, branches, opens a PR, merges it, and
7 * cleans up — the exact path every team arriving here walks, and the one
8 * nothing else covers end to end. It ran only when somebody remembered,
9 * which over a month meant it ran a handful of times, always after someone
10 * already suspected a problem.
11 *
12 * Everything continuous we have watches reads. The five-minute journeys
13 * check that pages render; `journey:repo-write` checks that a repo can be
14 * created. Neither pushes a commit, and "git push works" is the one claim
15 * this product cannot afford to be wrong about. The gap between them is
16 * where a broken push lives for as long as nobody tries.
17 *
18 * Daily, not per-tick, because a full run costs a real account, a real
19 * repo, several git subprocesses and up to a few minutes of wall clock.
20 * That cadence is chosen against the thing being protected: a regression in
21 * push or merge is caught the next day rather than the next time a customer
22 * finds it.
23 *
24 * A run that could not start is YELLOW, never green. The whole point of
25 * this file is that an unrun check must never be indistinguishable from a
26 * passing one — that confusion is what made the readiness gate report "all
27 * hard gates passed" while skipping the two that mattered.
28 */
29
30import { existsSync } from "node:fs";
31import { join } from "node:path";
32import { config } from "./config";
33import { persistChecks, type SyntheticCheckResult } from "./synthetic-monitor";
34
35export const FIRST_RUN_CHECK_NAME = "journey:first-run";
36
37/**
38 * Generous, and deliberately so. The journey polls a PR page waiting for AI
39 * review and gate results to appear, and a slow-but-working platform is a
40 * different fact from a broken one. A timeout here is reported as its own
41 * failure text rather than being folded into "the journey failed".
42 */
43export const FIRST_RUN_TIMEOUT_MS = 8 * 60 * 1000;
44
45const SCRIPT_REL = "scripts/first-run-journey.mjs";
46
47export interface JourneyRun {
48 exitCode: number;
49 stdout: string;
50 stderr: string;
51 timedOut: boolean;
52}
53
54/**
55 * Turn a completed run into a check result.
56 *
57 * Split out from the spawning so the mapping can be tested without a
58 * subprocess. The exit codes come from the script's own contract:
59 * 0 = every step passed, 1 = a dead end, 2 = it crashed.
60 */
61export function classifyJourneyRun(run: JourneyRun, durationMs: number): SyntheticCheckResult {
62 if (run.timedOut) {
63 return {
64 name: FIRST_RUN_CHECK_NAME,
65 status: "red",
66 durationMs,
67 error: `first-run journey exceeded ${Math.round(FIRST_RUN_TIMEOUT_MS / 1000)}s — a newcomer would still be waiting`,
68 };
69 }
70 if (run.exitCode === 0) {
71 return { name: FIRST_RUN_CHECK_NAME, status: "green", durationMs };
72 }
73 // The script prints the step that dead-ended. Surface that line rather
74 // than "exit 1", which sends an operator to read a log to learn what a
75 // one-line error already knew.
76 const failure = firstFailureLine(run.stdout, run.stderr);
77 return {
78 name: FIRST_RUN_CHECK_NAME,
79 status: "red",
80 durationMs,
81 error:
82 run.exitCode === 2
83 ? `first-run journey crashed: ${failure || "no output"}`
84 : `first-run journey dead end: ${failure || "no output"}`,
85 };
86}
87
88/** The most specific failure line the script printed, if any. */
89export function firstFailureLine(stdout: string, stderr: string): string {
90 const lines = `${stdout}\n${stderr}`.split(/\r?\n/);
91 const hit = lines.find((l) => /DEAD END|FAIL|Error:|crashed/i.test(l));
92 return (hit ?? "").trim().slice(0, 300);
93}
94
95/**
96 * Friction the run recorded — steps a newcomer tripped on that were not
97 * outright failures.
98 *
99 * Logged even on a green run, because the script's own header says it: a
100 * green run with a long friction ledger is not a good onboarding, and the
101 * ledger is the half that never reaches anyone if only the exit code is
102 * read.
103 */
104export function frictionLines(stdout: string): string[] {
105 return stdout
106 .split(/\r?\n/)
107 .filter((l) => /friction/i.test(l))
108 .map((l) => l.trim())
109 .filter(Boolean)
110 .slice(0, 20);
111}
112
113export interface FirstRunDeps {
114 spawn?: (args: string[]) => Promise<JourneyRun>;
115 persist?: (results: SyntheticCheckResult[]) => Promise<void>;
116 cwd?: string;
117 baseUrl?: string;
118}
119
120async function defaultSpawn(args: string[], cwd: string): Promise<JourneyRun> {
121 const proc = Bun.spawn(["bun", ...args], {
122 cwd,
123 stdout: "pipe",
124 stderr: "pipe",
125 // The journey mints a PAT of its own and needs no ambient credentials.
126 // Passing the process env would hand a throwaway account the platform's
127 // own tokens for no reason.
128 env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "/tmp" },
129 });
130
131 let timedOut = false;
132 const timer = setTimeout(() => {
133 timedOut = true;
134 proc.kill();
135 }, FIRST_RUN_TIMEOUT_MS);
136
137 try {
138 const [stdout, stderr, exitCode] = await Promise.all([
139 new Response(proc.stdout).text(),
140 new Response(proc.stderr).text(),
141 proc.exited,
142 ]);
143 return { exitCode, stdout, stderr, timedOut };
144 } finally {
145 clearTimeout(timer);
146 }
147}
148
149/**
150 * Run the journey once and record the result. Never throws — this is called
151 * from the autopilot loop, where an exception is a stalled tick.
152 */
153export async function runFirstRunJourneyOnce(
154 deps: FirstRunDeps = {}
155): Promise<SyntheticCheckResult> {
156 const t0 = Date.now();
157 const cwd = deps.cwd ?? process.cwd();
158 const baseUrl = deps.baseUrl ?? config.appBaseUrl;
159 const persist = deps.persist ?? persistChecks;
160
161 let result: SyntheticCheckResult;
162 try {
163 // The script ships in the image (Dockerfile COPYs scripts/), but a
164 // slimmed build or a self-host layout could drop it. Yellow, not green
165 // and not red: nothing is broken, nothing was proven either.
166 if (!deps.spawn && !existsSync(join(cwd, SCRIPT_REL))) {
167 result = {
168 name: FIRST_RUN_CHECK_NAME,
169 status: "yellow",
170 durationMs: Date.now() - t0,
171 error: `${SCRIPT_REL} not found under ${cwd} — the journey did not run`,
172 };
173 } else {
174 const args = [SCRIPT_REL, "--base", baseUrl];
175 const run = deps.spawn
176 ? await deps.spawn(args)
177 : await defaultSpawn(args, cwd);
178 result = classifyJourneyRun(run, Date.now() - t0);
179 for (const line of frictionLines(run.stdout)) {
180 console.log(`[first-run-journey] friction: ${line}`);
181 }
182 }
183 } catch (err) {
184 result = {
185 name: FIRST_RUN_CHECK_NAME,
186 status: "red",
187 durationMs: Date.now() - t0,
188 error: err instanceof Error ? err.message : String(err),
189 };
190 }
191
192 await persist([result]).catch((err) => {
193 console.error("[first-run-journey] could not persist result:", err);
194 });
195 return result;
196}
Modifiedsrc/lib/gate.ts+224−68View fileUnifiedSplit
464464 * Until 2026-08-09 the platform ran workflows and enforced none of them:
465465 * a PR's CI could be red and the merge button neither knew nor cared. The
466466 * test suite was a seatbelt in the trunk. Semantics:
467 * - no runs recorded for the sha → skipped ("no CI configured" is not
468 * a failure — repos without workflows merge as before)
467 * - no run for a workflow that WOULD have run → NOT passed ("CI has not
468 * run"), a state of its own — see decideCiGate
469 * - no runs AND no workflows configured → skipped (a repo with no CI has
470 * nothing to wait for; repos without workflows merge as before)
469471 * - any latest run queued/running → NOT passed ("CI still running"),
470472 * unless stale (see CI_GATE_STALE_MS) — stale runs report + skip
471473 * - any latest run failed → failed, naming the workflow
472474 * - all latest runs success → passed
475 *
476 * `headBranch` is what lets the gate tell "no run because nothing was
477 * supposed to run" apart from "no run because nothing ran": an
478 * `on: push` workflow filtered to `branches: [main]` is not expected on a
479 * feature branch, and demanding it would block every PR forever.
473480 */
474481export async function checkCiWorkflows(
475482 repositoryId: string,
476483 headSha: string,
484 headBranch: string | null = null,
477485 now: number = Date.now()
478486): Promise<GateCheckResult> {
479487 try {
497505 .orderBy(desc(workflowRuns.createdAt))
498506 .limit(50);
499507
500 if (rows.length === 0) {
501 return {
502 name: "CI",
503 passed: true,
504 skipped: true,
505 details: "No CI workflows ran for this commit",
506 };
507 }
508 const expected = await expectedCiWorkflows(repositoryId, headBranch);
509 return decideCiGate(rows, expected, now);
510 } catch (err) {
511 return {
512 name: "CI",
513 passed: true,
514 skipped: true,
515 details: `CI status unavailable (${err instanceof Error ? err.message : "lookup failed"}) — not blocking`,
516 };
517 }
518}
508519
509 // Latest run per workflow — rows are newest-first, first one wins.
510 const latest = new Map<string, (typeof rows)[number]>();
511 for (const r of rows) {
512 if (!latest.has(r.workflowId)) latest.set(r.workflowId, r);
513 }
520/** The run-row shape the CI gate decision reads. */
521export interface CiRunRow {
522 workflowId: string;
523 name: string;
524 status: string | null;
525 conclusion: string | null;
526 startedAt: Date | null;
527 createdAt: Date | null;
528}
514529
515 const failed: string[] = [];
516 const infra: string[] = [];
517 const running: string[] = [];
518 const stale: string[] = [];
519 for (const r of latest.values()) {
520 if (r.status === "queued" || r.status === "running") {
521 const startedMs = (r.startedAt ?? r.createdAt)?.getTime() ?? now;
522 if (now - startedMs > CI_GATE_STALE_MS) stale.push(r.name);
523 else running.push(r.name);
524 } else if (r.status !== "success") {
525 if (isInfraConclusion(r.conclusion)) {
526 // The platform did not finish this run; the code was never judged.
527 infra.push(`${r.name} (${infraFailureLabel(r.conclusion)})`);
528 } else {
529 failed.push(
530 r.conclusion && r.conclusion !== "failure"
531 ? `${r.name} (${r.conclusion})`
532 : r.name
533 );
534 }
530/** A workflow that a commit on this branch is expected to trigger. */
531export interface ExpectedCiWorkflow {
532 id: string;
533 name: string;
534}
535
536/**
537 * Which of a repo's workflows a commit on `headBranch` is expected to run.
538 *
539 * Scoped to non-disabled `on: push` workflows, honouring the `branches:`
540 * filter stored in `workflows.parsed` — the same filter push-workflow-sync
541 * applies when it enqueues. Two exclusions, both load-bearing:
542 *
543 * - `workflow_dispatch` / `schedule`-only workflows never fire for a
544 * commit, so their absence is not a missing check.
545 * - `branches:`-filtered workflows on a non-matching branch. The deploy
546 * workflow pinned to `branches: [main]` legitimately produces no run on
547 * a feature branch; demanding it would block every PR on the platform.
548 *
549 * `on: pull_request`-only workflows are deliberately NOT expected, and this
550 * is the honest limitation of this gate rather than an oversight.
551 * pr-workflow-sync.ts enqueues those at PR **open** only (synchronize is
552 * documented there as unwired), and the re-run button pins the original sha
553 * (routes/workflows.tsx), so a PR that gained a commit after opening has no
554 * way to produce a pull_request run for its new head. Blocking on that
555 * would be a merge block with no remedy — blaming the user for a platform
556 * gap. Wire synchronize, then add `pull_request` here.
557 *
558 * Returns [] on ANY error or when the branch is unknown. That is a
559 * deliberate degradation, not a shrug: the gate can only honestly say "a
560 * check has not run" when it knows which checks exist, so when the lookup
561 * fails it falls back to judging the runs it can see.
562 */
563async function expectedCiWorkflows(
564 repositoryId: string,
565 headBranch: string | null
566): Promise<ExpectedCiWorkflow[]> {
567 if (!headBranch) return [];
568 try {
569 const rows = await db
570 .select({
571 id: workflows.id,
572 name: workflows.name,
573 onEvents: workflows.onEvents,
574 parsed: workflows.parsed,
575 })
576 .from(workflows)
577 .where(
578 and(
579 eq(workflows.repositoryId, repositoryId),
580 eq(workflows.disabled, false)
581 )
582 )
583 .limit(100);
584
585 const out: ExpectedCiWorkflow[] = [];
586 for (const row of rows) {
587 if (!isCiWorkflowExpectedOnBranch(row.onEvents, row.parsed, headBranch)) {
588 continue;
535589 }
590 out.push({ id: row.id, name: row.name });
536591 }
592 return out;
593 } catch {
594 return [];
595 }
596}
537597
538 if (failed.length > 0) {
539 return {
540 name: "CI",
541 passed: false,
542 details: `CI failed: ${failed.join(", ")}`,
543 };
544 }
545 if (infra.length > 0) {
546 // Still not green — but say why honestly, and point at the fix
547 // (re-run), instead of reporting the code as failed.
548 return {
549 name: "CI",
550 passed: false,
551 details: `CI did not complete — ${infra.join(", ")}. Re-run the workflow (it is normally re-queued automatically after a restart).`,
552 };
553 }
554 if (running.length > 0) {
555 return {
556 name: "CI",
557 passed: false,
558 details: `CI still running: ${running.join(", ")} — merge when green`,
559 };
598/**
599 * Per-row half of `expectedCiWorkflows`, split out so the exclusions can be
600 * tested without a database. See that function's comment for why each
601 * exclusion exists — every one of them is a merge block avoided.
602 *
603 * `onEvents` and `parsed` are the raw JSON strings as stored on the
604 * `workflows` row. Malformed JSON returns false: a row we cannot read
605 * cannot be claimed as a check that failed to run.
606 */
607export function isCiWorkflowExpectedOnBranch(
608 onEvents: string | null,
609 parsed: string | null,
610 headBranch: string
611): boolean {
612 let events: unknown;
613 try {
614 events = JSON.parse(onEvents || "[]");
615 } catch {
616 return false;
617 }
618 if (!Array.isArray(events) || !events.includes("push")) return false;
619
620 let branches: string[] = [];
621 try {
622 const doc = JSON.parse(parsed || "{}") as { pushBranches?: unknown };
623 if (Array.isArray(doc.pushBranches)) {
624 branches = doc.pushBranches.filter((b): b is string => typeof b === "string");
560625 }
561 if (stale.length > 0) {
562 return {
563 name: "CI",
564 passed: true,
565 skipped: true,
566 details: `CI run stale (running >30 min, likely orphaned): ${stale.join(", ")} — not blocking`,
567 };
626 } catch {
627 branches = []; // no filter recorded = fires on every branch
628 }
629 return branches.length === 0 || branches.includes(headBranch);
630}
631
632/**
633 * Pure CI-gate decision. Split out of `checkCiWorkflows` so the states can
634 * be tested for real rather than by grepping the source of the function
635 * that decides them.
636 *
637 * THE DEFECT THIS CLOSES: zero runs for the head sha returned
638 * `{passed: true, skipped: true}`. A PR whose CI had not run — because the
639 * runner was down, the enqueue silently failed, or the commit was pushed
640 * before the workflow existed — scored identically to a PR whose CI had
641 * run and gone green. A check that could not run must never be scored the
642 * same as a check that ran and passed.
643 *
644 * `expected` is what makes the distinction honest: no runs AND nothing
645 * configured is still a skip (a repo with no CI has nothing to wait for),
646 * while no runs with workflows configured is a blocking state of its own,
647 * worded so nobody reads it as "the tests are red".
648 */
649export function decideCiGate(
650 runs: CiRunRow[],
651 expected: ExpectedCiWorkflow[],
652 now: number = Date.now()
653): GateCheckResult {
654 // Latest run per workflow — rows are newest-first, first one wins.
655 const latest = new Map<string, CiRunRow>();
656 for (const r of runs) {
657 if (!latest.has(r.workflowId)) latest.set(r.workflowId, r);
658 }
659
660 const failed: string[] = [];
661 const infra: string[] = [];
662 const running: string[] = [];
663 const stale: string[] = [];
664 for (const r of latest.values()) {
665 if (r.status === "queued" || r.status === "running") {
666 const startedMs = (r.startedAt ?? r.createdAt)?.getTime() ?? now;
667 if (now - startedMs > CI_GATE_STALE_MS) stale.push(r.name);
668 else running.push(r.name);
669 } else if (r.status !== "success") {
670 if (isInfraConclusion(r.conclusion)) {
671 // The platform did not finish this run; the code was never judged.
672 infra.push(`${r.name} (${infraFailureLabel(r.conclusion)})`);
673 } else {
674 failed.push(
675 r.conclusion && r.conclusion !== "failure"
676 ? `${r.name} (${r.conclusion})`
677 : r.name
678 );
679 }
568680 }
681 }
682
683 // Configured workflows with no run at all for this commit.
684 const notRun = expected.filter((w) => !latest.has(w.id)).map((w) => w.name);
685
686 if (failed.length > 0) {
687 return {
688 name: "CI",
689 passed: false,
690 details: `CI failed: ${failed.join(", ")}`,
691 };
692 }
693 if (infra.length > 0) {
694 // Still not green — but say why honestly, and point at the fix
695 // (re-run), instead of reporting the code as failed.
696 return {
697 name: "CI",
698 passed: false,
699 details: `CI did not complete — ${infra.join(", ")}. Re-run the workflow (it is normally re-queued automatically after a restart).`,
700 };
701 }
702 if (notRun.length > 0) {
703 // Blocking, but explicitly NOT a red build: nothing has been judged.
704 return {
705 name: "CI",
706 passed: false,
707 details: `CI has not run for this commit — no run exists for ${notRun.join(", ")}. Nothing failed; nothing was checked. Push the branch again or re-run the workflow, and this clears once a run for this commit finishes green.`,
708 };
709 }
710 if (running.length > 0) {
711 return {
712 name: "CI",
713 passed: false,
714 details: `CI still running: ${running.join(", ")} — merge when green`,
715 };
716 }
717 if (stale.length > 0) {
569718 return {
570719 name: "CI",
571720 passed: true,
572 details: `CI green: ${latest.size} workflow${latest.size === 1 ? "" : "s"}`,
721 skipped: true,
722 details: `CI run stale (running >30 min, likely orphaned): ${stale.join(", ")} — not blocking`,
573723 };
574 } catch (err) {
724 }
725 if (latest.size === 0) {
575726 return {
576727 name: "CI",
577728 passed: true,
578729 skipped: true,
579 details: `CI status unavailable (${err instanceof Error ? err.message : "lookup failed"}) — not blocking`,
730 details: "No CI workflows are configured for this repository",
580731 };
581732 }
733 return {
734 name: "CI",
735 passed: true,
736 details: `CI green: ${latest.size} workflow${latest.size === 1 ? "" : "s"}`,
737 };
582738}
583739
584740/**
686842 diffText,
687843 }),
688844 repoRow
689 ? checkCiWorkflows(repoRow.id, headSha)
845 ? checkCiWorkflows(repoRow.id, headSha, headBranch)
690846 : Promise.resolve<GateCheckResult>({
691847 name: "CI",
692848 passed: true,
Modifiedsrc/lib/import-helper.ts+49−7View fileUnifiedSplit
1313import { db } from "../db";
1414import { repositories } from "../db/schema";
1515import { config } from "../lib/config";
16import { normalizeRepoName } from "./repo-name";
1617import { removeTempDir } from "./tmp-cleanup";
1718
1819/**
9495 * else with a hyphen so an imported repo is always addressable.
9596 */
9697export function sanitizeRepoName(name: string): string {
97 // Lowercase on create — repo slugs are canonically lowercase to avoid the
98 // "Vapron" vs "vapron" confusion. Imported names are normalized here.
99 const cleaned = name
100 .replace(/[^A-Za-z0-9._-]/g, "-")
101 .replace(/^-+|-+$/g, "")
102 .toLowerCase();
98 // Import is the one creation path that has to REPAIR a name rather than
99 // reject it — a GitHub repo may contain characters gluecron does not allow.
100 // The lowercase half is delegated to normalizeRepoName so import and the
101 // hand-typed creation paths cannot drift apart on what "canonical" means.
102 const cleaned = normalizeRepoName(
103 name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "")
104 );
103105 return cleaned || "imported-repo";
104106}
105107
271273 // the violation reach the catch below reported "failed" and left that
272274 // directory behind — invisible, and enough of it to matter on a bulk
273275 // import.
276 // Read the branch the clone actually landed on. Falls back to what the
277 // caller supplied, then "main" — but only when git itself cannot say.
278 let resolvedDefaultBranch = defaultBranch || "main";
279 try {
280 const proc = Bun.spawn(
281 ["git", "-C", destPath, "symbolic-ref", "--short", "HEAD"],
282 {
283 stdout: "pipe",
284 stderr: "pipe",
285 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
286 }
287 );
288 const head = (await new Response(proc.stdout).text()).trim();
289 if ((await proc.exited) === 0 && head) resolvedDefaultBranch = head;
290 } catch {
291 // Keep the caller's value; a failed read must not fail an import whose
292 // objects are already on disk.
293 }
294
295 // `defaultBranch` takes the CLONE's own HEAD, over anything the caller
296 // told us.
297 //
298 // It defaulted to the literal "main" and was otherwise taken on trust
299 // from the caller. A repo whose default branch is "Main" therefore
300 // landed with a row pointing at a branch that DOES NOT EXIST — `git
301 // rev-parse main` fails, so every read resolved nothing and the repo
302 // rendered as empty or frozen. No error anywhere; the symptom is a repo
303 // that looks dead. It cost the owner a day of believing a live
304 // repository had stopped receiving pushes.
305 //
306 // Two sources of truth for one fact, and the wrong one was stored.
307 // After a clone the bare repo's symbolic HEAD is authoritative and free
308 // to read, so ask it rather than believe a parameter.
309 //
310 // Kept ABOVE the statement rather than beside the field on purpose:
311 // check-then-insert-race reads 400 characters past `.insert()` looking
312 // for the conflict clause, and this comment sitting inside `.values({})`
313 // pushed `.onConflictDoNothing` out of that window. The insert was
314 // guarded the whole time; the rule could not see it. A false positive on
315 // a rule held at zero costs more than the comment's placement is worth.
274316 const [created] = await db
275317 .insert(repositories)
276318 .values({
278320 ownerId,
279321 description,
280322 isPrivate,
281 defaultBranch: defaultBranch || "main",
323 defaultBranch: resolvedDefaultBranch,
282324 diskPath: destPath,
283325 starCount: 0,
284326 })
Modifiedsrc/lib/mcp-tools-expanded.ts+16−6View fileUnifiedSplit
7272import { McpError, ERR_INVALID_PARAMS, ERR_METHOD_NOT_FOUND } from "./mcp";
7373import type { McpContext } from "./mcp";
7474import type { McpToolHandler } from "./mcp-tools";
75import { normalizeRepoName } from "./repo-name";
7576import { removeTempDir, removeTempFile } from "./tmp-cleanup";
7677import { ensureLabel } from "./ensure-label";
7778import {
199200 if (me.username.toLowerCase() === srcOwner.toLowerCase()) {
200201 throw new McpError(ERR_INVALID_PARAMS, "cannot fork your own repository");
201202 }
202 if (await repoExists(me.username, srcName)) {
203 // The fork lands under the canonical lowercase spelling even when the
204 // source predates that rule (ccantynz/Vapron), so a fork of a mixed-case
205 // repo is not itself a new mixed-case row. See src/lib/repo-name.ts.
206 const forkName = normalizeRepoName(srcName);
207
208 if (await repoExists(me.username, forkName)) {
203209 throw new McpError(
204210 ERR_INVALID_PARAMS,
205 `${me.username}/${srcName} already exists`
211 `${me.username}/${forkName} already exists`
206212 );
207213 }
208214
209215 const sourcePath = getRepoPath(srcOwner, srcName);
210 const destPath = join(config.gitReposPath, me.username, `${srcName}.git`);
216 const destPath = join(config.gitReposPath, me.username, `${forkName}.git`);
211217
212218 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], {
213219 stdout: "pipe",
228234 const [newRepo] = await db
229235 .insert(repositories)
230236 .values({
231 name: srcName,
237 name: forkName,
232238 ownerId: me.id,
233239 description: sourceRepo.description
234240 ? `Fork of ${srcOwner}/${srcName}${sourceRepo.description}`
263269 /* non-fatal */
264270 }
265271
272 // Report the fork that was created, not the argument that was asked for.
273 // `repo` is the caller's spelling of the SOURCE; the row and the directory
274 // both carry `forkName`, so echoing `repo` handed the agent a URL for a
275 // repository under its own account that does not exist under that name.
266276 return {
267277 owner: me.username,
268 repo,
269 url: `/${me.username}/${repo}`,
278 repo: forkName,
279 url: `/${me.username}/${forkName}`,
270280 };
271281 },
272282};
Modifiedsrc/lib/playground.ts+5−1View fileUnifiedSplit
5454import { absoluteUrl } from "./email";
5555import { normalizeEmail } from "./email-normalize";
5656import { ensureLabel } from "./ensure-label";
57import { normalizeRepoName } from "./repo-name";
5758
5859/** Playground accounts live for exactly this long. */
5960export const PLAYGROUND_TTL_MS = 24 * 60 * 60 * 1000;
508509 const [inserted] = await db
509510 .insert(repositories)
510511 .values({
511 name: args.repoName,
512 // Already lowercase today (SANDBOX_REPO_NAME), routed through the
513 // shared normalizer so a future rename of that constant cannot quietly
514 // become the one creation path that writes mixed case.
515 name: normalizeRepoName(args.repoName),
512516 ownerId: args.userId,
513517 description:
514518 "Your 24-hour Gluecron playground sandbox. Push, open issues, watch Claude.",
Modifiedsrc/lib/pr-merge-gated.ts+11−4View fileUnifiedSplit
2828 * Relationship to `pr-merge.ts` (`performMerge`): that module is the
2929 * POST-decision mechanics used by the autopilot's auto-merge sweep, whose
3030 * gating happens upstream in `evaluateAutoMerge` (K2). This module is the
31 * interactive-caller chain (MCP tool + REST API): it evaluates the gates
32 * itself. Keep both interactive callers on this functiondo not inline a
33 * third copy of the chain.
31 * interactive-caller chain (MCP tool + REST API + the merge queue's
32 * process-next): it evaluates the gates itself. Keep every interactive
33 * caller on this functiondo not inline another copy of the chain.
34 *
35 * The merge queue joined this chain on 2026-08-29. Until then it ran
36 * `runAllGateChecks` and moved the ref itself, so steps 1 (draft guard),
37 * 2 (risk band) and 5 (branch protection + required checks + CODEOWNERS)
38 * simply did not exist on that route — and it passed a hardcoded `true`
39 * for the AI-review verdict rather than reading it. That is the shape of
40 * drift this module was extracted to prevent.
3441 */
3542
3643import { and, eq } from "drizzle-orm";
8592 /** When true, bypass the M3 critical-band soft-block. */
8693 confirmHighRisk?: boolean;
8794 /** Recorded in audit/activity metadata so incidents can tell paths apart. */
88 source: "mcp" | "api";
95 source: "mcp" | "api" | "merge_queue";
8996}
9097
9198/**
Addedsrc/lib/repo-freshness.ts+128−0View fileUnifiedSplit
1/**
2 * "Updated N ago" must come from the repository, not from a column.
3 *
4 * THE BUG. `repositories.pushed_at` is written by the post-receive hook, which
5 * runs on the paths that go THROUGH the app: HTTP Smart HTTP, and the in-app
6 * SSH server. Anything that writes refs directly to the bare repo on disk —
7 * system sshd, a `git push` run on the box, an admin fixing something by hand,
8 * a migration script — changes the repository and never tells the database.
9 *
10 * That is not hypothetical and it is not cheap. The owner spent a day
11 * believing Vapron had stopped receiving pushes: the page said "Updated 6d
12 * ago" while roughly thirty branches merged into it, because every one of
13 * those merges arrived over a path the hook does not see. "Last pushed 3 days
14 * ago" is precisely the signal someone uses to decide whether a system is
15 * alive, and it was lying.
16 *
17 * Note the platform's own SSH server is currently OFF (SSH_PORT=0, disabled
18 * after the 2026-08-22 inbound-TCP incident), so on this deployment the
19 * direct-to-disk path is not an edge case — it is the only SSH path there is.
20 *
21 * THE FIX, and why it is not another hook. A hook can only cover the paths
22 * that reach the app, and the path that caused this does not. Two sources of
23 * truth for one fact — the git repo and the DB row both claim to know when
24 * the last push was — and the renderer trusted the one that can silently go
25 * stale. So: ask git, take whichever is newer, and heal the column on the way
26 * past. Whatever writes the refs, the page can no longer report a date the
27 * repository itself contradicts.
28 */
29
30import { db } from "../db";
31import { repositories } from "../db/schema";
32import { eq } from "drizzle-orm";
33import { cached, gitCache } from "./cache";
34import { getRepoPath } from "../git/repository";
35
36/** Newest commit time across ALL refs, or null if the repo has none. */
37export async function newestCommitDate(
38 owner: string,
39 name: string
40): Promise<Date | null> {
41 // Cached under the repo's own prefix, so `invalidateRepoCache` on push
42 // clears it and an app-path push is reflected immediately rather than
43 // waiting out a TTL.
44 const iso = await cached(
45 gitCache as unknown as import("./cache").LRUCache<string>,
46 `${owner}/${name}:newest-commit-date`,
47 async () => {
48 try {
49 const proc = Bun.spawn(
50 [
51 "git",
52 "-C",
53 getRepoPath(owner, name),
54 "for-each-ref",
55 "--sort=-committerdate",
56 "--count=1",
57 "--format=%(committerdate:iso-strict)",
58 "refs/heads",
59 ],
60 {
61 stdout: "pipe",
62 stderr: "pipe",
63 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
64 }
65 );
66 const out = (await new Response(proc.stdout).text()).trim();
67 return (await proc.exited) === 0 ? out : "";
68 } catch {
69 return "";
70 }
71 }
72 );
73 if (!iso) return null;
74 const d = new Date(iso);
75 return Number.isNaN(d.getTime()) ? null : d;
76}
77
78/**
79 * Pure half, so the precedence rule is testable without a repo or a database.
80 *
81 * Git wins only when it is NEWER. A repo can legitimately hold commits older
82 * than its last push (a force-push to an earlier commit, an import of old
83 * history), and in those cases the stored push time is the more truthful
84 * answer to "when did something last happen here".
85 */
86export function reconcileFreshness(
87 stored: Date | null,
88 fromGit: Date | null
89): { value: Date | null; healed: boolean } {
90 if (!fromGit) return { value: stored, healed: false };
91 if (!stored) return { value: fromGit, healed: true };
92 return fromGit.getTime() > stored.getTime()
93 ? { value: fromGit, healed: true }
94 : { value: stored, healed: false };
95}
96
97/**
98 * Resolve the true "last activity" for a repo, healing the stored column when
99 * git knows better. Never throws: a freshness read must not be able to break
100 * the page it decorates.
101 */
102export async function resolvePushedAt(
103 owner: string,
104 name: string,
105 repoId: string,
106 stored: Date | null
107): Promise<Date | null> {
108 try {
109 const { value, healed } = reconcileFreshness(
110 stored,
111 await newestCommitDate(owner, name)
112 );
113 if (healed && value) {
114 // Fire and forget — the page has its answer either way, and a write
115 // failure must not cost the reader their repository page.
116 db.update(repositories)
117 .set({ pushedAt: value })
118 .where(eq(repositories.id, repoId))
119 .catch((err) => {
120 console.warn(`[freshness] heal failed for ${owner}/${name}:`, err);
121 });
122 }
123 return value;
124 } catch (err) {
125 console.warn(`[freshness] resolve failed for ${owner}/${name}:`, err);
126 return stored;
127 }
128}
Addedsrc/lib/repo-name.ts+46−0View fileUnifiedSplit
1/**
2 * The canonical form of a repository name: lowercase.
3 *
4 * Names used to be stored exactly as the creator typed them, and
5 * case-insensitivity was retrofitted onto the *lookups* instead — `lower()`
6 * in `namespace.ts` and `repo-access.ts`, a readdir scan in
7 * `git/repository.ts` (`resolveCaseInsensitive`), a canonicalising resolver in
8 * front of the MCP tools. Each of those is a separate place that has to
9 * remember to fold case, and the failure when one forgets is silent: an
10 * exact-match lookup against a mixed-case row returns nothing, which is
11 * indistinguishable from "no such repository". That is the same defect shape
12 * `src/lib/selfcheck/rules/case-consistency.ts` was written to catch after an
13 * account created as `Alice@Example.com` became permanently unreachable.
14 *
15 * Storing lowercase at creation removes the divergence at its source rather
16 * than papering over it at each read. The case-insensitive lookups stay —
17 * rows created before this change (`ccantynz/Gluecron.com`, `ccantynz/Vapron`)
18 * still carry their original casing and must keep resolving.
19 *
20 * The validation pattern deliberately still ACCEPTS uppercase. A user who
21 * types "MyRepo" gets `myrepo`, not a rejection; turning a working form into
22 * an error message would be a worse product than the problem being fixed.
23 */
24
25/**
26 * Characters a repository name may contain. Accepts uppercase on input —
27 * {@link normalizeRepoName} is what decides how it is stored. Anything using
28 * this to validate must normalize before writing, or the two disagree.
29 */
30export const REPO_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
31
32/**
33 * Canonical stored spelling of a repository name.
34 *
35 * Lowercasing is unconditional and character-preserving: every character the
36 * validation pattern allows survives it, so a name that validated before
37 * normalization still validates after.
38 *
39 * Non-string input collapses to `""` so callers that forward request bodies
40 * straight in get an empty name their existing "name is required" branch
41 * already rejects, rather than a `TypeError` mid-handler.
42 */
43export function normalizeRepoName(raw: unknown): string {
44 if (typeof raw !== "string") return "";
45 return raw.trim().toLowerCase();
46}
Modifiedsrc/lib/selfcheck/rules/case-consistency.ts+7−2View fileUnifiedSplit
3737
3838/**
3939 * Calls that mean "I have decided case is not significant here".
40 * `normalizeEmail`/`normalizeSlug` are this codebase's own helpers.
40 * `normalizeEmail`/`normalizeSlug`/`normalizeRepoName` are this codebase's own
41 * helpers. `normalizeRepoName` joined the list when repository names started
42 * being stored lowercase at creation — the same divergence this rule catches
43 * for email is available for repo slugs, and the two mixed-case rows that
44 * predate the change (ccantynz/Gluecron.com, ccantynz/Vapron) mean an
45 * exact-match comparison against a normalized name is still wrong today.
4146 */
42const NORMALIZERS = /^(?:toLowerCase|toUpperCase|normalizeEmail|normalizeSlug|toLocaleLowerCase)$/;
47const NORMALIZERS = /^(?:toLowerCase|toUpperCase|normalizeEmail|normalizeSlug|normalizeRepoName|toLocaleLowerCase)$/;
4348
4449/** The nearest enclosing function-like node, or the source file. */
4550function enclosingFunction(node: ts.Node): ts.Node {
Modifiedsrc/lib/synthetic-journeys.ts+158−2View fileUnifiedSplit
1717 * just return 200 ("emptiness on display" was a
1818 * whole bug class here: pages that advertise
1919 * liveness and prove deadness).
20 * journey:repo-write — create a repo, read it back, delete it. The
21 * only journey that WRITES; the three above stay
22 * green through a full disk, a broken bare-repo
23 * init, or a CSRF regression that rejects every
24 * form POST on the platform.
2025 *
2126 * Results share the synthetic_checks pipeline (same table, same SSE topic,
2227 * same transition alerting via the monitor tick) under the `journey:`
2833 * only so authed rendering can be exercised without storing a credential.
2934 */
3035
31import { eq } from "drizzle-orm";
36import { and, eq, like } from "drizzle-orm";
3237import { db } from "../db";
33import { sessions, users } from "../db/schema";
38import { repositories, sessions, users } from "../db/schema";
3439import { generateSessionToken } from "./auth";
3540import { normalizeEmail } from "./email-normalize";
3641import { config } from "./config";
215220 }
216221}
217222
223/**
224 * Create a repository, confirm it exists, delete it.
225 *
226 * Every journey above this one reads. That is a real gap: the three read
227 * probes stay green through a full-disk volume, a broken `git init --bare`,
228 * a CSRF regression that rejects every form POST, and a repo-create quota
229 * gate stuck closed — because none of them ever writes. "The site is up"
230 * and "anyone can make anything" are different claims, and only one of them
231 * was being checked every five minutes.
232 *
233 * Stops short of push/PR/merge on purpose. Those need a git subprocess and
234 * minutes of wall clock, which does not belong on a 5-minute tick; the
235 * daily first-run journey (scripts/first-run-journey.mjs) owns that half.
236 * The split is deliberate — this is the cheapest write that still proves
237 * the form, the session, the CSRF check, the quota gate, the DB insert and
238 * the on-disk repo init all work together.
239 *
240 * The success condition is the Location header, not the status. This
241 * handler answers 302 to BOTH outcomes: `/{owner}/{name}` when the repo was
242 * made and `/new?error=...` when it refused. Checking only for a 3xx would
243 * score every possible refusal — quota exhausted, name taken, validation
244 * rejected — as a healthy write path.
245 */
246const PROBE_REPO_PREFIX = "spine-probe-";
247
248/**
249 * Did POST /new actually create the repo?
250 *
251 * Pulled out as a pure function because it is the part that is easy to get
252 * wrong and impossible to unit-test in place — everything around it needs a
253 * live database. The handler answers 302 to both outcomes, so the naive
254 * `res.ok`/`status < 400` reading scores a refused creation as a healthy
255 * write path, and the journey would then stay green through exactly the
256 * failures it exists to catch: quota gate stuck closed, name collision,
257 * validation rejection.
258 */
259export function classifyRepoCreate(
260 status: number,
261 location: string | null,
262 owner: string,
263 repoName: string
264): { ok: boolean; reason?: string } {
265 if (status >= 400) return { ok: false, reason: `POST /new returned ${status}` };
266 const loc = location ?? "";
267 if (loc.includes(`/${owner}/${repoName}`)) return { ok: true };
268 const why = /error=([^&]*)/.exec(loc)?.[1];
269 return {
270 ok: false,
271 reason: `repo creation refused — redirected to ${loc || "(no location)"}${
272 why ? ` (${decodeURIComponent(why.replace(/\+/g, " "))})` : ""
273 }`,
274 };
275}
276
277async function repoWriteJourney(
278 baseUrl: string,
279 fetchImpl: typeof fetch
280): Promise<SyntheticCheckResult> {
281 const name = "journey:repo-write";
282 const t0 = Date.now();
283 const repoName = `${PROBE_REPO_PREFIX}${Date.now().toString(36)}`;
284 let token: string | null = null;
285
286 // Deleting through the route, not the table, so the bare repo leaves the
287 // disk too. A DB-only delete would drop the row and leak the directory,
288 // and this journey runs 288 times a day.
289 const destroy = async (repo: string, cookie: string) => {
290 await fetchWithTimeout(fetchImpl, `${baseUrl}/${PROBE_USERNAME}/${repo}/settings/delete`, {
291 method: "POST",
292 headers: {
293 Cookie: cookie,
294 Origin: baseUrl,
295 "Content-Type": "application/x-www-form-urlencoded",
296 },
297 body: "",
298 }).catch(() => {});
299 };
300
301 try {
302 const userId = await ensureProbeUser();
303 token = generateSessionToken();
304 await db.insert(sessions).values({
305 userId,
306 token,
307 expiresAt: new Date(Date.now() + 10 * 60_000),
308 });
309 const cookie = `session=${token}`;
310
311 // Sweep anything a previous run failed to clean up. Without this, one
312 // broken delete route turns a health check into a source of unbounded
313 // repo growth — the probe becomes the outage.
314 const leftovers = await db
315 .select({ name: repositories.name })
316 .from(repositories)
317 .where(
318 and(
319 eq(repositories.ownerId, userId),
320 like(repositories.name, `${PROBE_REPO_PREFIX}%`)
321 )
322 )
323 .limit(25);
324 for (const old of leftovers) await destroy(old.name, cookie);
325
326 const created = await fetchWithTimeout(fetchImpl, `${baseUrl}/new`, {
327 method: "POST",
328 headers: {
329 Cookie: cookie,
330 Origin: baseUrl,
331 "Content-Type": "application/x-www-form-urlencoded",
332 },
333 body: new URLSearchParams({
334 name: repoName,
335 visibility: "private",
336 // "empty" keeps the tick cheap: no scaffold commit, no workflow
337 // discovery, no CI run queued every five minutes forever.
338 starter: "empty",
339 }).toString(),
340 });
341
342 const verdict = classifyRepoCreate(
343 created.status,
344 created.headers.get("location"),
345 PROBE_USERNAME,
346 repoName
347 );
348 if (!verdict.ok) return red(name, t0, verdict.reason!);
349
350 // The row can exist while the page 500s on it. Read it back the way a
351 // user would, since that is the state they would actually meet.
352 const page = await fetchWithTimeout(
353 fetchImpl,
354 `${baseUrl}/${PROBE_USERNAME}/${repoName}`,
355 { headers: { Cookie: cookie } }
356 );
357 if (page.status !== 200) {
358 await destroy(repoName, cookie);
359 return red(name, t0, `repo created but its page returned ${page.status}`);
360 }
361
362 await destroy(repoName, cookie);
363 return green(name, t0, 200);
364 } catch (err) {
365 return red(name, t0, err instanceof Error ? err.message : String(err));
366 } finally {
367 if (token) {
368 await db.delete(sessions).where(eq(sessions.token, token)).catch(() => {});
369 }
370 }
371}
372
218373/**
219374 * Run all journeys. Same contract as runSyntheticChecks: parallel, each
220375 * self-timed and self-caught, never throws.
229384 loginFormJourney(baseUrl, fetchImpl),
230385 authedSessionJourney(baseUrl, fetchImpl),
231386 exploreDataJourney(baseUrl, fetchImpl),
387 repoWriteJourney(baseUrl, fetchImpl),
232388 ]);
233389}
Addedsrc/lib/uuid-param.ts+32−0View fileUnifiedSplit
1/**
2 * Is this path parameter shaped like one of our ids?
3 *
4 * Every id in this schema is a uuid, and Postgres does not politely return
5 * no rows for `where id = '_probe'` — it raises 22P02, "invalid input
6 * syntax for type uuid". That propagates out of the handler as an unhandled
7 * error, so the route answers 500 while the `if (!row) return 404` line
8 * directly beneath the query never executes.
9 *
10 * The visible effect is a page that 500s on a typo'd, truncated, or stale
11 * URL instead of saying "not found" — and a 500 in the logs for every
12 * crawler and scanner that guesses at a path. Four repo-scoped pages were
13 * doing this (milestone detail, milestone edit, agent session, ruleset
14 * detail), found by the authz-matrix gate the moment it started probing
15 * routes instead of only claiming to.
16 *
17 * Callers use it to skip the query, not to build a second 404 branch:
18 *
19 * const [row] = isUuid(id) ? await db.select()... : [];
20 * if (!row) return notFound();
21 *
22 * That way a malformed id and a nonexistent id take the same path and
23 * produce the same answer, which is the honest one — the caller cannot
24 * tell the difference between "no such milestone" and "that is not even an
25 * id", and does not need to.
26 */
27const UUID_RE =
28 /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
29
30export function isUuid(value: string | undefined | null): boolean {
31 return typeof value === "string" && UUID_RE.test(value);
32}
Addedsrc/lib/workflow-exec-policy.ts+131−0View fileUnifiedSplit
1/**
2 * Who is allowed to execute workflow steps on this instance.
3 *
4 * ── Why this exists ──────────────────────────────────────────────────────
5 *
6 * A workflow `run:` step is `bash -c <string the user wrote>`, spawned by
7 * the application process, inside the application container
8 * (workflow-runner.ts, `executeRun`). There is no sandbox, no namespace, no
9 * cgroup and no seccomp profile: the step is a child of the app, running as
10 * the same uid the app runs as (`USER bun` in the Dockerfile).
11 *
12 * `buildRunnerEnv` carefully hands the step a curated allowlist rather than
13 * the platform's own environment — a real and deliberate control. It is
14 * defeated by `/proc/1/environ`. PID 1 in that container is the app, owned
15 * by the same uid as the step, and compose starts it with `env_file: .env`,
16 * so PID 1's environment holds every secret the platform has. Same uid means
17 * readable. The allowlist governs what the step is *given*, not what it can
18 * *reach*.
19 *
20 * The same applies to storage: the `git-repos` volume is mounted read-write
21 * at /data/repos, so a step can read and rewrite every repository of every
22 * account on the instance, not merely its own.
23 *
24 * Registration is open (`auth.post("/register")` has no invite or approval
25 * gate), and a push enqueues a run (`push-workflow-sync.ts`). Those three
26 * facts compose into one sentence: anybody who can sign up can run arbitrary
27 * code inside the production container.
28 *
29 * ── What this does, and what it explicitly does not ──────────────────────
30 *
31 * This is CONTAINMENT, not a fix. It reduces who can reach the hole; it does
32 * not close it. Anyone on the allowlist still has unsandboxed execution, so
33 * the list means "people already trusted with production", not "people we
34 * have decided to let run CI". The actual fix is per-job isolation — see
35 * docs/AUDIT-CI-RUNNER-ISOLATION.md — and this file should be deleted the
36 * day that lands, not extended.
37 *
38 * ── Fail closed ──────────────────────────────────────────────────────────
39 *
40 * If the policy cannot be determined, execution is REFUSED. That direction
41 * is not the obvious one for a CI system — the tempting default is "if in
42 * doubt, run it, don't break people's builds" — but the cost of the two
43 * mistakes is not symmetric. Wrongly refusing costs a red run and a support
44 * message. Wrongly allowing costs the database credentials. A control that
45 * fails open under exactly the conditions an attacker can induce is not a
46 * control.
47 */
48
49/** Nobody, by explicit configuration. Self-hosters wanting CI off entirely. */
50const NONE = "none";
51
52export interface ExecPolicyDecision {
53 allowed: boolean;
54 /** Shown on the run, so it must say what happened and what to do. */
55 reason: string;
56}
57
58/**
59 * Parse `WORKFLOW_EXEC_ALLOWLIST` into a set of usernames.
60 *
61 * Comma-separated, case-insensitive, whitespace tolerated — it is edited by
62 * hand in a .env file at 3am, which is the input format this has to survive.
63 */
64export function parseAllowlist(raw: string | undefined | null): Set<string> {
65 return new Set(
66 String(raw ?? "")
67 .split(",")
68 .map((s) => s.trim().toLowerCase())
69 .filter(Boolean)
70 );
71}
72
73/**
74 * May `ownerUsername`'s repository execute workflow steps here?
75 *
76 * `ownerUsername` is the OWNER of the repository the run belongs to, not
77 * whoever pushed. A collaborator's push runs under the owner's trust,
78 * because the owner is who granted them write access — and because the
79 * pusher is not a meaningful boundary when write access is what enqueues
80 * the run in the first place.
81 */
82export function checkExecutionAllowed(
83 ownerUsername: string | null | undefined,
84 env: NodeJS.ProcessEnv = process.env
85): ExecPolicyDecision {
86 const raw = env.WORKFLOW_EXEC_ALLOWLIST;
87
88 // Unset is not "no policy configured, carry on". On this instance an
89 // unset allowlist is indistinguishable from a .env that lost the line,
90 // and the failure mode of guessing wrong is the whole reason this file
91 // exists. Self-hosters who genuinely want open execution have to say so.
92 if (raw === undefined || String(raw).trim() === "") {
93 return {
94 allowed: false,
95 reason:
96 "Workflow execution is not configured on this instance. Steps run unsandboxed in the application container, so execution is refused until WORKFLOW_EXEC_ALLOWLIST names the accounts trusted with it (or 'all' to accept the risk deliberately). See docs/AUDIT-CI-RUNNER-ISOLATION.md.",
97 };
98 }
99
100 const value = String(raw).trim().toLowerCase();
101
102 if (value === NONE) {
103 return {
104 allowed: false,
105 reason: "Workflow execution is disabled on this instance (WORKFLOW_EXEC_ALLOWLIST=none).",
106 };
107 }
108
109 // The deliberate opt-out. Spelled as a word rather than inferred from an
110 // empty value, so that turning the control off is something somebody
111 // typed on purpose and can be found in a diff.
112 if (value === "all") return { allowed: true, reason: "allowlist=all" };
113
114 if (!ownerUsername) {
115 return {
116 allowed: false,
117 reason:
118 "Workflow execution refused: the owning account for this run could not be determined, so it cannot be checked against the allowlist.",
119 };
120 }
121
122 const allowed = parseAllowlist(value);
123 if (allowed.has(ownerUsername.trim().toLowerCase())) {
124 return { allowed: true, reason: `allowlisted: ${ownerUsername}` };
125 }
126
127 return {
128 allowed: false,
129 reason: `Workflow execution refused: '${ownerUsername}' is not in WORKFLOW_EXEC_ALLOWLIST. Steps on this instance run unsandboxed in the application container, so execution is limited to accounts already trusted with production.`,
130 };
131}
Modifiedsrc/lib/workflow-runner.ts+66−0View fileUnifiedSplit
2323import { db } from "../db";
2424import {
2525 repositories,
26 users,
2627 workflowJobs,
2728 workflowRuns,
2829 workflows,
2930} from "../db/schema";
31import { checkExecutionAllowed } from "./workflow-exec-policy";
3032import {
3133 loadSecretsContext,
3234 substituteSecrets,
435437 }
436438}
437439
440/**
441 * Refused by policy — distinct from a failed build.
442 *
443 * Conclusion is `blocked` rather than `failure` so the gate and the run
444 * page can tell "we declined to run this" from "this ran and broke". They
445 * are different facts and they need different fixes: one is a config line,
446 * the other is the user's code. Recording a refusal as a build failure
447 * sends someone to debug a test suite that never executed.
448 *
449 * It is emphatically NOT recorded as a success or a skip. A run that was
450 * refused must never satisfy a check.
451 */
452async function markRunBlocked(runId: string, reason: string): Promise<void> {
453 try {
454 await db
455 .update(workflowRuns)
456 .set({
457 status: "failure",
458 conclusion: "blocked",
459 finishedAt: new Date(),
460 })
461 .where(eq(workflowRuns.id, runId));
462 } catch (err) {
463 console.error("[workflow-runner] markRunBlocked:", err);
464 }
465 void reason;
466}
467
438468async function markRunFailed(
439469 runId: string,
440470 conclusion: string
928958 return;
929959 }
930960
961 // --- CONTAINMENT: may this repo's owner execute steps here? ---
962 //
963 // Checked here, at the last point before any step can spawn, rather than
964 // at enqueue. Enqueue has several callers (push sync, PR sync, slash
965 // commands, MCP, re-run from the UI) and a control with five doors is a
966 // control with five chances to add a sixth that forgets. Everything that
967 // ends in a subprocess ends here.
968 //
969 // See workflow-exec-policy.ts: steps are unsandboxed in the app
970 // container, so this limits WHO can reach that, and does not fix it.
971 let ownerUsername: string | null = null;
972 let ownerLookupFailed = false;
973 try {
974 const [owner] = await db
975 .select({ username: users.username })
976 .from(users)
977 .where(eq(users.id, repoRow.ownerId))
978 .limit(1);
979 ownerUsername = owner?.username ?? null;
980 } catch (err) {
981 // A database blip must not become an open door. Left null, which the
982 // policy refuses.
983 console.error("[workflow-runner] owner lookup for exec policy:", err);
984 ownerLookupFailed = true;
985 }
986 const policy = checkExecutionAllowed(ownerUsername);
987 if (!policy.allowed) {
988 console.warn(
989 `[workflow-runner] run ${runId} refused: ${policy.reason}${ownerLookupFailed ? " (owner lookup failed)" : ""}`
990 );
991 // "blocked", not "failure with no detail" — an operator reading the run
992 // needs to see a policy decision, not think their build is broken.
993 await markRunBlocked(runId, policy.reason);
994 return;
995 }
996
931997 // --- Parse workflow JSON ---
932998 // v2: try the extended parser first (it surfaces needs/strategy/if/uses/
933999 // step-level env & if). If the module or the parse fails, fall back to the
Addedsrc/lib/workflow-silent-steps.ts+57−0View fileUnifiedSplit
1/**
2 * "This step succeeded but did no work" detection.
3 *
4 * A required status check can be pointed at a command that is structurally
5 * incapable of failing. Verified example: `vitest` with no `node` binary on
6 * PATH exits 0 having run ZERO tests. The run goes green, the required
7 * check is satisfied, and nothing was ever tested — the same class of
8 * defect as a gate that scores a check it could not run as passed.
9 *
10 * The runner records four things per step: exitCode, stdout, stderr and
11 * durationMs. Exactly ONE combination of those is an unambiguous statement
12 * about work: a step that exited 0 and wrote nothing at all to either
13 * stream produced no observable output. That is the whole heuristic. It is
14 * deliberately narrow:
15 *
16 * - It is a WARNING, never a failure. A step that legitimately prints
17 * nothing (`mkdir -p`, `cp`, `export`) will trip it, and a false
18 * positive that blocked merges would be worse than the disease.
19 * - It does NOT claim to catch every incapable-of-failing command. A test
20 * runner that prints a banner and then runs zero tests writes output,
21 * so this will not see it. Counting tests would mean guessing at each
22 * framework's output format, which is inventing a signal rather than
23 * reading one — so we do not.
24 *
25 * What it says is only what it observed, and that is the point.
26 */
27
28/** The subset of a persisted `workflow_jobs.steps` entry this reads. */
29export interface SilentStepCandidate {
30 name?: string;
31 run?: string;
32 status?: string;
33 exitCode?: number | null;
34 stdout?: string;
35 stderr?: string;
36}
37
38/**
39 * Names of the steps that exited 0 having written nothing to stdout or
40 * stderr. Empty array = nothing to warn about.
41 */
42export function detectSilentSuccessSteps(
43 steps: SilentStepCandidate[]
44): string[] {
45 const out: string[] = [];
46 for (const [i, s] of steps.entries()) {
47 if (s.status !== "success") continue;
48 if (s.exitCode !== 0) continue;
49 // A step with no `run:` is a v1 "skipped" placeholder, not a command
50 // that produced nothing — it never had a chance to produce anything.
51 if (!s.run || !s.run.trim()) continue;
52 if ((s.stdout || "").trim()) continue;
53 if ((s.stderr || "").trim()) continue;
54 out.push(s.name?.trim() || `Step ${i + 1}`);
55 }
56 return out;
57}
Modifiedsrc/routes/agent-pipelines.tsx+15−10View fileUnifiedSplit
2424import type { AuthEnv } from "../middleware/auth";
2525import { requireRepoAccess } from "../middleware/repo-access";
2626import { createAgentSession } from "../lib/agent-multiplayer";
27import { isUuid } from "../lib/uuid-param";
2728
2829const agentPipelinesRoutes = new Hono<AuthEnv>();
2930
834835 const repository = c.get("repository" as never) as { id: string };
835836 const cancelled = c.req.query("cancelled") === "1";
836837
837 const [session] = await db
838 .select()
839 .from(agentSessions)
840 .where(
841 and(
842 eq(agentSessions.id, sessionId),
843 eq(agentSessions.repositoryId, repository.id)
844 )
845 )
846 .limit(1);
838 // See isUuid: an unparseable sessionId raises 22P02 and 500s the page
839 // rather than falling through to the notFound() below.
840 const [session] = isUuid(sessionId)
841 ? await db
842 .select()
843 .from(agentSessions)
844 .where(
845 and(
846 eq(agentSessions.id, sessionId),
847 eq(agentSessions.repositoryId, repository.id)
848 )
849 )
850 .limit(1)
851 : [];
847852
848853 if (!session) {
849854 return c.notFound();
Modifiedsrc/routes/api-v2.ts+9−4View fileUnifiedSplit
88
99import { Hono } from "hono";
1010import { parseIdNumber } from "../lib/route-params";
11import { normalizeRepoName, REPO_NAME_PATTERN } from "../lib/repo-name";
1112import { join } from "path";
1213import { eq, and, desc, asc, sql, like, or, gte, lte, gt } from "drizzle-orm";
1314import { deflateRawSync } from "node:zlib";
550551 isPrivate?: boolean;
551552 }>();
552553
553 if (!body.name || !/^[a-zA-Z0-9._-]+$/.test(body.name)) {
554 if (!body.name || !REPO_NAME_PATTERN.test(body.name)) {
554555 return c.json({ error: "Invalid repository name" }, 400);
555556 }
557 // Uppercase is accepted above and folded here, so an API client posting
558 // "MyRepo" gets `myrepo` rather than a 400. Everything downstream — the
559 // existence check, the bare repo on disk, the row — uses this spelling.
560 const repoName = normalizeRepoName(body.name);
556561
557562 // P4 — plan-quota gate. 402 Payment Required is the canonical HTTP
558563 // signal that the client should branch on (e.g. show an upgrade CTA).
562567 return c.json({ error: gate.reason, upgrade_url: gate.upgradeUrl }, 402);
563568 }
564569
565 if (await repoExists(user.username, body.name)) {
570 if (await repoExists(user.username, repoName)) {
566571 return c.json({ error: "Repository already exists" }, 409);
567572 }
568573
569 const diskPath = await initBareRepo(user.username, body.name);
574 const diskPath = await initBareRepo(user.username, repoName);
570575 const result = await db
571576 .insert(repositories)
572577 .values({
573 name: body.name,
578 name: repoName,
574579 ownerId: user.id,
575580 description: body.description || null,
576581 isPrivate: body.isPrivate || false,
Modifiedsrc/routes/api.ts+7−2View fileUnifiedSplit
1313import { renderMarkdown } from "../lib/markdown";
1414import { openIssueCountsByRepo } from "../lib/issue-counts";
1515import { normalizeEmail } from "../lib/email-normalize";
16import { normalizeRepoName, REPO_NAME_PATTERN } from "../lib/repo-name";
1617
1718// Typed with AuthEnv so handlers can read the viewer that the global
1819// `softAuth` middleware already resolved (needed for private-repo gating).
6768 body.owner = viewer.username;
6869
6970 // Validate repo name
70 if (!/^[a-zA-Z0-9._-]+$/.test(body.name)) {
71 if (!REPO_NAME_PATTERN.test(body.name)) {
7172 return c.json({ error: "Invalid repository name" }, 400);
7273 }
7374 // Normalize to lowercase — repo slugs are canonically lowercase so
7475 // "MyRepo" and "myrepo" can't both exist and cause "Vapron" vs "vapron"
7576 // confusion. Applied before the duplicate checks + on-disk init below.
76 body.name = body.name.toLowerCase();
77 body.name = normalizeRepoName(body.name);
7778
7879 try {
7980 // Find creator (user who is performing the action)
321322 400
322323 );
323324 }
325 // Bootstrap creates a repository like every other path does, so it stores
326 // the same canonical spelling. This one matters more than most: whatever it
327 // writes is the name baked into the first clone URL an operator copies.
328 body.repoName = normalizeRepoName(body.repoName);
324329
325330 try {
326331 // Upsert user
Modifiedsrc/routes/claude-integration.ts+7−3View fileUnifiedSplit
1717import { users, repositories, activityFeed, apiTokens, pullRequests } from "../db/schema";
1818import { initBareRepo, getDefaultBranch } from "../git/repository";
1919import { config } from "../lib/config";
20import { normalizeRepoName, REPO_NAME_PATTERN } from "../lib/repo-name";
2021import type { AuthEnv } from "../middleware/auth";
2122
2223const claudeIntegration = new Hono<AuthEnv>();
107108 return c.json({ ok: false, error: "Invalid JSON body" }, 400);
108109 }
109110
110 // repoName is optional — if omitted, return basic connection info without a repo
111 const repoName = body.repoName?.trim();
111 // repoName is optional — if omitted, return basic connection info without a repo.
112 // Normalized before anything else so the gitRemote echoed back below is the
113 // spelling that was actually stored; handing an agent a URL that differs from
114 // the row is exactly the failure this endpoint exists to prevent.
115 const repoName = body.repoName ? normalizeRepoName(body.repoName) : undefined;
112116 const description = body.description?.trim() || null;
113117
114118 try {
125129 }
126130
127131 // Validate repo name
128 if (!/^[a-zA-Z0-9._-]+$/.test(repoName)) {
132 if (!REPO_NAME_PATTERN.test(repoName)) {
129133 return c.json({ ok: false, error: "Invalid repository name. Use letters, digits, hyphens, dots, or underscores." }, 400);
130134 }
131135
Modifiedsrc/routes/fork.tsx+13−4View fileUnifiedSplit
2626 gitExecTimeoutMs,
2727} from "../git/repository";
2828import { config } from "../lib/config";
29import { normalizeRepoName } from "../lib/repo-name";
2930import { join } from "path";
3031import { Layout } from "../views/layout";
3132import { RepoHeader, RepoNav } from "../views/components";
629630 return c.redirect(`/${ownerName}/${repoName}`);
630631 }
631632
632 // Clone the bare repo
633 // Clone the bare repo. The fork's name comes from the SOURCE ROW, folded to
634 // lowercase — not from the URL param. Forking `ccantynz/Vapron` therefore
635 // produces `you/vapron`, and the new directory on disk carries the same
636 // spelling as the new row instead of inheriting the source's casing.
637 const forkName = normalizeRepoName(sourceRepo.name);
633638 const sourcePath = getRepoPath(ownerName, repoName);
634 const destPath = join(config.gitReposPath, user.username, `${repoName}.git`);
639 const destPath = join(config.gitReposPath, user.username, `${forkName}.git`);
635640
636641 const proc = Bun.spawn(["git", "clone", "--bare", sourcePath, destPath], { timeout: gitExecTimeoutMs(), killSignal: "SIGKILL",
637642 stdout: "pipe",
643648 const [newRepo] = await db
644649 .insert(repositories)
645650 .values({
646 name: repoName,
651 name: forkName,
647652 ownerId: user.id,
648653 description: sourceRepo.description
649654 ? `Fork of ${ownerName}/${repoName}${sourceRepo.description}`
687692 // best effort
688693 }
689694
690 return c.redirect(`/${user.username}/${repoName}`);
695 // The fork that was created, not the source spelling from the URL — a fork
696 // of /ccantynz/Vapron lands at /you/vapron, and redirecting to /you/Vapron
697 // would send the user through the case-insensitive fallback for a repo they
698 // just made.
699 return c.redirect(`/${user.username}/${forkName}`);
691700});
692701
693702export default fork;
Modifiedsrc/routes/merge-queue.tsx+33−165View fileUnifiedSplit
66 * POST /:owner/:repo/queue/:id/dequeue — remove entry (owner OR enqueuer)
77 * POST /:owner/:repo/queue/process-next — owner-only: run the head
88 *
9 * The "process-next" handler is v1 — it just re-runs gates against the base
10 * and, if green, merges by updating the base branch ref. A full background
9 * "process-next" runs the head entry through `performGatedMerge` — the same
10 * shared policy chain the merge button and the v2/MCP merge endpoints use —
11 * so a queued merge is gated exactly like a clicked one. A full background
1112 * worker is future work; this keeps the feature usable without a daemon.
1213 *
1314 * 2026 polish: scoped `.mq-*` class system, gradient hero + section cards
3839 markHeadRunning,
3940 completeEntry,
4041} from "../lib/merge-queue";
41import { mergeWithAutoResolve } from "../lib/merge-resolver";
4242import {
4343 peekHead,
4444} from "../lib/merge-queue";
45import { runAllGateChecks } from "../lib/gate";
46import { resolveRef, getRepoPath,
47 gitExecTimeoutMs,
48} from "../git/repository";
45import { performGatedMerge } from "../lib/pr-merge-gated";
4946import { audit } from "../lib/notify";
50import { readRefSha, verifyAndRecord } from "../lib/merge-verifier";
5147
5248const queue = new Hono<AuthEnv>();
5349queue.use("*", softAuth);
646642 <span class="mq-title-grad">Serialised merges.</span>
647643 </h1>
648644 <p class="mq-sub">
649 Queued PRs re-run gates against the latest base before merging.
650 This prevents green-in-isolation, red-after-merge races.
645 Queued PRs re-run the full merge policy — gates, branch
646 protection, required checks, CODEOWNERS — against the latest
647 base before merging. This prevents green-in-isolation,
648 red-after-merge races.
651649 </p>
652650 </div>
653651 <a href={`/${owner}/${repo}/pulls`} class="mq-hero-cta">
938936 );
939937 }
940938
941 // Re-run gates against latest base.
939 // The queue merges through the SAME shared chain as the merge button and
940 // the v2/MCP endpoints (`performGatedMerge`). It used to run only
941 // `runAllGateChecks` and then move the ref itself, which meant every
942 // protection the other paths enforce was silently absent from the queue:
943 // branch protection (required approvals, required status checks, green
944 // gates), CODEOWNERS approval, the draft guard, and the M3 risk band. It
945 // also passed `true` for the AI-review verdict instead of reading it, so
946 // a PR the AI had blocked merged clean via the queue. A protected branch
947 // was therefore only as protected as the route a merge happened to take.
942948 const [pr] = await db
943949 .select()
944950 .from(pullRequests)
950956 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR vanished")}`
951957 );
952958 }
953 if (pr.state !== "open") {
954 await completeEntry(started.id, "failed", "Pull request is no longer open.");
955 return c.redirect(
956 `/${owner}/${repo}/queue?error=${encodeURIComponent("PR is no longer open")}`
957 );
958 }
959
960 const headSha = await resolveRef(owner, repo, pr.headBranch);
961 if (!headSha) {
962 await completeEntry(started.id, "failed", "Head branch not found.");
963 return c.redirect(
964 `/${owner}/${repo}/queue?error=${encodeURIComponent("Head branch not found")}`
965 );
966 }
967959
968 const gateResult = await runAllGateChecks(
960 const result = await performGatedMerge({
969961 owner,
970 repo,
971 pr.baseBranch,
972 pr.headBranch,
973 headSha,
974 true
975 );
976 const hardFailures = gateResult.checks.filter(
977 (check) => !check.passed && check.name !== "Merge check"
978 );
979 if (hardFailures.length > 0) {
980 const msg = hardFailures
981 .map((f) => `${f.name}: ${f.details}`)
982 .join("; ");
962 repo: repoRow.name,
963 repoId: repoRow.id,
964 defaultBranch: repoRow.defaultBranch || "main",
965 pr,
966 actorUserId: user.id,
967 // Not forwarded from the form on purpose: a critical M3 risk band marks
968 // this entry failed with the reason instead of merging. The queue keeps
969 // moving (the entry leaves `queued`/`running`), and the owner can still
970 // merge deliberately from the PR page.
971 source: "merge_queue",
972 });
973
974 if (!result.merged) {
975 const msg = result.reason || "Merge blocked by policy";
983976 await completeEntry(started.id, "failed", msg);
984977 try {
985978 await db.insert(prComments).values({
986979 pullRequestId: pr.id,
987980 authorId: user.id,
988 body: `**Merge queue:** gates failed on latest base — ${msg}`,
981 body: `**Merge queue:** blocked on latest base — ${msg}`,
989982 isAiReview: false,
990983 });
991984 } catch (err) {
1002995 );
1003996 }
1004997
1005 // Gates passed — merge by updating base ref to head.
1006 // INCIDENT 2026-08-08 guard (same as pr-merge.ts executeGitMerge): a bare
1007 // update-ref only IS a merge when base is an ancestor of head. A stale
1008 // branch would otherwise REPLACE main and discard newer merges.
1009 const repoDir = getRepoPath(owner, repo);
1010 // Post-merge verification inputs (merge-verifier.ts): the previous base
1011 // tip MUST be read before any ref moves.
1012 const previousBaseSha = await readRefSha(repoDir, `refs/heads/${pr.baseBranch}`);
1013 const expectedHeadSha =
1014 (await readRefSha(repoDir, `refs/heads/${pr.headBranch}`)) ?? headSha;
1015 const fireVerify = (mergedSha: string | null | undefined, kind: "ff" | "merge") => {
1016 if (!previousBaseSha || !mergedSha) return;
1017 void verifyAndRecord({
1018 repoDir,
1019 baseBranch: pr.baseBranch,
1020 previousBaseSha,
1021 expectedHeadSha,
1022 mergedSha,
1023 kind,
1024 prId: pr.id,
1025 repoId: repoRow.id,
1026 owner,
1027 repo,
1028 prNumber: pr.number,
1029 actorUserId: user.id,
1030 source: "merge_queue",
1031 });
1032 };
1033 const ancestry = Bun.spawnSync(
1034 [
1035 "git",
1036 "merge-base",
1037 "--is-ancestor",
1038 `refs/heads/${pr.baseBranch}`,
1039 `refs/heads/${pr.headBranch}`,
1040 ],
1041 // `spawnSync` on a request path with no deadline blocks the event loop —
1042 // not just this request, the whole server — for as long as git takes.
1043 // `merge-base --is-ancestor` is normally instant, but it walks history,
1044 // and a corrupt or enormous object store has no upper bound.
1045 //
1046 // On timeout the child is killed and `exitCode` is non-zero, which routes
1047 // to `mergeWithAutoResolve` below — the conservative branch that treats
1048 // the ancestry as unproven. Failing that way is correct: the INCIDENT
1049 // guard above exists to stop a stale branch REPLACING base, so "could not
1050 // prove base is an ancestor" must never be read as "it is".
1051 { cwd: repoDir, timeout: 15_000, killSignal: "SIGKILL" }
1052 );
1053 if (ancestry.exitCode !== 0) {
1054 const mergeResult = await mergeWithAutoResolve(
1055 owner,
1056 repo,
1057 pr.baseBranch,
1058 pr.headBranch,
1059 `Merge pull request #${pr.number}: ${pr.title}`
1060 );
1061 if (!mergeResult.success) {
1062 await completeEntry(
1063 started.id,
1064 "failed",
1065 `non-fast-forward merge failed: ${mergeResult.error || "unknown"}`
1066 );
1067 return c.redirect(
1068 `/${owner}/${repo}/queue?error=${encodeURIComponent(
1069 "Merge failed — branch is behind the base and could not be merged cleanly"
1070 )}`
1071 );
1072 }
1073 // Post-merge verification (advancement #3) — fire-and-forget.
1074 fireVerify(mergeResult.commitSha, "merge");
1075 await completeEntry(started.id, "merged");
1076 void import("../lib/push-workflow-sync").then((m) =>
1077 m.enqueuePushWorkflowsForBranchAdvance({
1078 owner,
1079 repo,
1080 repositoryId: repoRow.id,
1081 branch: pr.baseBranch,
1082 triggeredBy: user.id,
1083 })
1084 );
1085 return c.redirect(`/${owner}/${repo}/queue`);
1086 }
1087 const proc = Bun.spawn(
1088 [
1089 "git",
1090 "update-ref",
1091 `refs/heads/${pr.baseBranch}`,
1092 `refs/heads/${pr.headBranch}`,
1093 ],
1094 { timeout: gitExecTimeoutMs(), killSignal: "SIGKILL", cwd: repoDir, stdout: "pipe", stderr: "pipe" }
1095 );
1096 const exit = await proc.exited;
1097 if (exit !== 0) {
1098 await completeEntry(started.id, "failed", "update-ref failed");
1099 return c.redirect(
1100 `/${owner}/${repo}/queue?error=${encodeURIComponent(
1101 "Merge failed — unable to update base ref"
1102 )}`
1103 );
1104 }
1105 // Post-merge verification (advancement #3) — fire-and-forget.
1106 fireVerify(expectedHeadSha, "ff");
1107
1108 await db
1109 .update(pullRequests)
1110 .set({
1111 state: "merged",
1112 mergedAt: new Date(),
1113 mergedBy: user.id,
1114 updatedAt: new Date(),
1115 })
1116 .where(eq(pullRequests.id, pr.id));
1117
1118998 await completeEntry(started.id, "merged");
1119999
1120 // A merge advances the base branch with no git push, so the receive-pack
1121 // hook never fires `on: push` workflows for it — enqueue them here.
1122 void import("../lib/push-workflow-sync").then((m) =>
1123 m.enqueuePushWorkflowsForBranchAdvance({
1124 owner,
1125 repo,
1126 repositoryId: repoRow.id,
1127 branch: pr.baseBranch,
1128 triggeredBy: user.id,
1129 })
1130 );
1131
11321000 await audit({
11331001 userId: user.id,
11341002 repositoryId: repoRow.id,
Modifiedsrc/routes/milestones.tsx+19−10View fileUnifiedSplit
2222import { softAuth, requireAuth } from "../middleware/auth";
2323import type { AuthEnv } from "../middleware/auth";
2424import { requireRepoAccess } from "../middleware/repo-access";
25import { isUuid } from "../lib/uuid-param";
2526
2627const milestonesRoutes = new Hono<AuthEnv>();
2728
887888 }
888889 const { repo } = resolved;
889890
890 const [ms] = await db
891 .select()
892 .from(milestones)
893 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, repo.id)))
894 .limit(1);
891 // A malformed id takes the same path as a nonexistent one — see
892 // isUuid. Querying with "_probe" raises 22P02 and 500s the page.
893 const [ms] = isUuid(milestoneId)
894 ? await db
895 .select()
896 .from(milestones)
897 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, repo.id)))
898 .limit(1)
899 : [];
895900
896901 if (!ms) {
897902 return c.html(
10981103 const resolved = await resolveRepo(ownerName, repoName);
10991104 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
11001105
1101 const [ms] = await db
1102 .select()
1103 .from(milestones)
1104 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, resolved.repo.id)))
1105 .limit(1);
1106 // See isUuid: an unparseable id raises 22P02 rather than returning no
1107 // rows, so the redirect below never runs and the page 500s instead.
1108 const [ms] = isUuid(milestoneId)
1109 ? await db
1110 .select()
1111 .from(milestones)
1112 .where(and(eq(milestones.id, milestoneId), eq(milestones.repositoryId, resolved.repo.id)))
1113 .limit(1)
1114 : [];
11061115
11071116 if (!ms) {
11081117 return c.redirect(`/${ownerName}/${repoName}/milestones`);
Modifiedsrc/routes/rulesets.tsx+4−1View fileUnifiedSplit
3434 parseParams,
3535 updateRulesetEnforcement,
3636} from "../lib/rulesets";
37import { isUuid } from "../lib/uuid-param";
3738
3839const rulesets = new Hono<AuthEnv>();
3940rulesets.use("*", softAuth);
814815 if (ctx instanceof Response) return ctx;
815816 const { ownerName, repoName, repo, user } = ctx;
816817 const id = c.req.param("id");
817 const rs = await getRuleset(id, repo.id);
818 // See isUuid: querying with an unparseable id raises 22P02 and 500s
819 // the page instead of reaching the notFound() below.
820 const rs = isUuid(id) ? await getRuleset(id, repo.id) : null;
818821 if (!rs) return c.notFound();
819822 const base = `/${ownerName}/${repoName}/settings/rulesets/${id}`;
820823 const message = c.req.query("message");
Modifiedsrc/routes/templates.ts+6−2View fileUnifiedSplit
2121 gitExecTimeoutMs,
2222} from "../git/repository";
2323import { config } from "../lib/config";
24import { normalizeRepoName, REPO_NAME_PATTERN } from "../lib/repo-name";
2425import { join } from "path";
2526
2627const templates = new Hono<AuthEnv>();
3031 const { owner: ownerName, repo: repoName } = c.req.param();
3132 const user = c.get("user")!;
3233 const body = await c.req.parseBody();
33 const newName = String(body.name || "").trim();
34 // Stored lowercase like every other creation path — see src/lib/repo-name.ts.
35 // The redirect at the end of this handler sends the user to this exact
36 // spelling, so normalizing here is also what keeps that link correct.
37 const newName = normalizeRepoName(body.name);
3438 if (!newName) {
3539 return c.redirect(`/${ownerName}/${repoName}?error=Name+required`);
3640 }
37 if (!/^[a-zA-Z0-9._-]+$/.test(newName)) {
41 if (!REPO_NAME_PATTERN.test(newName)) {
3842 return c.redirect(`/${ownerName}/${repoName}?error=Invalid+name`);
3943 }
4044
Modifiedsrc/routes/web.tsx+21−4View fileUnifiedSplit
99import { db } from "../db";
1010import { fireWebhooks } from "./webhooks";
1111import { config } from "../lib/config";
12import { resolvePushedAt } from "../lib/repo-freshness";
13import { normalizeRepoName, REPO_NAME_PATTERN } from "../lib/repo-name";
1214import { hostHas } from "../lib/host-capabilities";
1315import {
1416 users,
20652067 class="new-repo-input"
20662068 />
20672069 <p class="new-repo-hint">
2068 Lowercase, numbers, dots, dashes, and underscores. The URL will be{" "}
2070 Letters, numbers, dots, dashes, and underscores. Names are stored
2071 lowercase, so <code>MyProject</code> becomes{" "}
2072 <code>myproject</code>. The URL will be{" "}
20692073 <code>{user.username}/&lt;name&gt;</code>.
20702074 </p>
20712075 </div>
21982202web.post("/new", requireAuth, async (c) => {
21992203 const user = c.get("user")!;
22002204 const body = await c.req.parseBody();
2201 const name = String(body.name || "").trim();
2205 // Stored lowercase regardless of what was typed. The row used to keep the
2206 // creator's casing, which left every reader depending on a case-insensitive
2207 // lookup to find it again — see src/lib/repo-name.ts.
2208 const name = normalizeRepoName(body.name);
22022209 const description = String(body.description || "").trim();
22032210 const isPrivate = body.visibility === "private";
22042211 const dataRegion = body.data_region === "eu" ? "eu" : "us";
22152222 return c.redirect(`/new?error=${encodeURIComponent(gate.reason)}`);
22162223 }
22172224
2218 if (!/^[a-zA-Z0-9._-]+$/.test(name)) {
2225 if (!REPO_NAME_PATTERN.test(name)) {
22192226 return c.redirect("/new?error=Invalid+repository+name");
22202227 }
22212228
33003307 isTemplate: repoRow.isTemplate,
33013308 forkCount: repoRow.forkCount,
33023309 description: repoRow.description as string | null,
3303 pushedAt: (repoRow.pushedAt as Date | null) ?? null,
3310 // Ask git, not just the column. pushed_at only advances on pushes
3311 // that go THROUGH the app; a ref written directly to the bare repo
3312 // (system sshd, a push run on the box, an admin fix) changes the
3313 // repository and never tells the database. That is how this page
3314 // reported "Updated 6d ago" while thirty branches were merging in.
3315 pushedAt: await resolvePushedAt(
3316 owner,
3317 repo,
3318 repoRow.id as string,
3319 (repoRow.pushedAt as Date | null) ?? null
3320 ),
33043321 createdAt: (repoRow.createdAt as Date | null) ?? null,
33053322 repoId: repoRow.id as string,
33063323 repoOwnerId: repoRow.ownerId as string,
Modifiedsrc/routes/workflows.tsx+45−0View fileUnifiedSplit
3737import { getUnreadCount } from "../lib/unread";
3838import { audit } from "../lib/notify";
3939import { enqueueRun } from "../lib/workflow-runner";
40import {
41 detectSilentSuccessSteps,
42 type SilentStepCandidate,
43} from "../lib/workflow-silent-steps";
4044
4145const actions = new Hono<AuthEnv>();
4246actions.use("*", softAuth);
941945 }
942946 const aiReady = isAiAvailable();
943947
948 // "Succeeded but did no work" — a green run is only meaningful if the
949 // steps actually ran something. A required check pointed at a command
950 // that cannot fail (the canonical case: `vitest` with no `node` on PATH
951 // exits 0 having run zero tests) makes a green run mean nothing. This is
952 // a warning, never a verdict: see lib/workflow-silent-steps.ts for what
953 // the signal does and does not claim.
954 const silentSteps: string[] = [];
955 for (const j of jobs) {
956 if (j.status !== "success" && j.conclusion !== "success") continue;
957 let parsedSteps: SilentStepCandidate[] = [];
958 try {
959 const raw = JSON.parse(j.steps || "[]");
960 if (Array.isArray(raw)) parsedSteps = raw;
961 } catch {
962 parsedSteps = [];
963 }
964 for (const name of detectSilentSuccessSteps(parsedSteps)) {
965 silentSteps.push(`${j.name}${name}`);
966 }
967 }
968
944969 return c.html(
945970 <Layout
946971 title={`Run #${run.runNumber} — ${owner}/${repo}`}
10341059 </div>
10351060 </section>
10361061
1062 {silentSteps.length > 0 && (
1063 <section
1064 class="wf-card"
1065 style="margin-bottom:14px;border-left:3px solid var(--warning, #b7791f)"
1066 >
1067 <div style="font-weight:600;margin-bottom:4px">
1068 Succeeded without producing any output
1069 </div>
1070 <div style="opacity:0.75;font-size:0.9em;line-height:1.5">
1071 {silentSteps.join(", ")} — exited 0 and wrote nothing to stdout
1072 or stderr. This is a warning, not a failure: some commands are
1073 legitimately silent. But a green check is only worth what the
1074 step actually did, and a test runner that cannot start (for
1075 example <code>vitest</code> with no <code>node</code> on PATH)
1076 also exits 0. Worth confirming this step ran what you think it
1077 ran before trusting it as a required check.
1078 </div>
1079 </section>
1080 )}
1081
10371082 {failed && (
10381083 <section class="wf-card" style="margin-bottom:14px">
10391084 <div style="display:flex;gap:12px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap">
10401085
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts