CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

feat(api): list every workflow run in a repo, not one workflow at a time #5597

MergedXSccantynz wants to mergefeat/api-list-workflow-runsmainopened 1d ago
3 changed files+259−6
Modifieddocs/CI-ISOLATION-NEXT-INCREMENT.md+38−6View fileUnifiedSplit
100100## Done when
101101
102102`bunx tsc --noEmit` and `bash scripts/ci-tests.sh` complete inside the runner
103container while the app sits under an app-sized `mem_limit`, and inside a live
104step:
103container while the app sits under an app-sized `mem_limit`, and:
105104
106105```
107tr '\0' '\n' < /proc/1/environ # no DATABASE_URL
108ls /data/repos # No such file or directory
106tr '\0' '\n' < /proc/1/environ | cut -d= -f1 # NAMES only - never print values
107docker inspect gluecron-runner-1 --format '{{range .Mounts}}{{.Name}} {{end}}'
109108```
110109
111Those two commands failing to find anything is the deliverable. Everything else
112is how we got there.
110Print variable NAMES, not the environment. The whole point is that there is
111nothing sensitive in there, and the way to be wrong about that is to paste it
112into a transcript before checking.
113
114### Verified in production, 2026-08-31 (`859e3cb`)
115
116The runner container's PID 1 holds **eleven** variables:
117
118```
119BUN_INSTALL_BIN BUN_RUNTIME_TRANSPILER_CACHE_PATH GIT_REPOS_PATH HOME
120HOSTNAME NODE_ENV PATH PORT PWD RUNNER_PORT RUNNER_TOKEN
121```
122
123No `DATABASE_URL`, no `ANTHROPIC_API_KEY`, no `GLUECRON_PAT`, no
124`WORKFLOW_SECRETS_KEY`. The app's PID 1 holds thirty. Mounts:
125
126```
127runner: gluecron_ci-work -> /ci-work
128app: gluecron_git-repos -> /data/repos, gluecron_ci-work -> /ci-work
129```
130
131Bounds applied: `mem=5 GiB`, `cpus=2`, `pids=512`. Both containers healthy.
132
133**One correction to what this document originally predicted.** It said
134`ls /data/repos` would report *No such file or directory*. It does not - the
135directory exists in the image and is simply **empty**, because the `git-repos`
136volume is not mounted over it. The property holds (no repository is
137reachable), but the stated test would have been read as a failure by whoever
138ran it. The mount list, not the directory listing, is the check.
139
140`GIT_REPOS_PATH=/data/repos` is likewise present in the runner's environment,
141inherited from the Dockerfile. It is inert - no git code runs in that process
142- but it misleads a reader, and a later change that started honouring it would
143find an empty directory rather than an error. Worth removing when the image is
144next touched.
Addedsrc/__tests__/actions-runs-list.test.ts+91−0View fileUnifiedSplit
1/**
2 * The repo-wide run list GitHub has had since 2018 and this API did not.
3 *
4 * Runs could only be listed PER WORKFLOW, by filename. That sounds like a
5 * small gap and is not: everything that watches CI from outside — an agent
6 * polling a branch, a status badge, a deploy gate — asks "what happened on
7 * this branch", not "what happened in ci.yml on this branch". This session's
8 * own monitor was pointed at the missing endpoint twice and reported nothing
9 * both times, which is the worse half of the bug: a 404 that a polling loop
10 * reads as silence, and silence reads as "still running".
11 *
12 * The parsing is what these cover. It is where the compatibility promises
13 * live, and it is the half a client can break without a database.
14 */
15
16import { describe, expect, test } from "bun:test";
17import { parseRunListQuery } from "../routes/api-v2";
18
19describe("branch accepts what both kinds of client actually send", () => {
20 test("a short name becomes a full ref", () => {
21 expect(parseRunListQuery({ branch: "main" }).ref).toBe("refs/heads/main");
22 });
23
24 test("an already-qualified ref is left alone", () => {
25 // Clients written against git send this form. Prefixing it again would
26 // produce refs/heads/refs/heads/main and silently match nothing — an
27 // empty list, which reads as "no runs on that branch" rather than as the
28 // bug it is.
29 expect(parseRunListQuery({ branch: "refs/heads/main" }).ref).toBe("refs/heads/main");
30 });
31
32 test("a tag ref survives", () => {
33 expect(parseRunListQuery({ branch: "refs/tags/v1.2.0" }).ref).toBe("refs/tags/v1.2.0");
34 });
35
36 test("absent or blank means no branch filter, not a filter on empty", () => {
37 expect(parseRunListQuery({}).ref).toBeNull();
38 expect(parseRunListQuery({ branch: " " }).ref).toBeNull();
39 });
40});
41
42describe("paging cannot be turned into a denial of service", () => {
43 test("per_page is clamped to 100", () => {
44 // Unclamped, one request against a repo with a long CI history is a
45 // trivially available way to make the server serialise to a timeout.
46 expect(parseRunListQuery({ per_page: "100000" }).perPage).toBe(100);
47 });
48
49 test("zero and negative per_page fall back to something usable", () => {
50 expect(parseRunListQuery({ per_page: "0" }).perPage).toBe(30);
51 expect(parseRunListQuery({ per_page: "-5" }).perPage).toBe(1);
52 });
53
54 test("garbage per_page is the default, not an empty page", () => {
55 // An empty page is the dangerous failure: the client reads it as "no
56 // runs" and acts on that.
57 expect(parseRunListQuery({ per_page: "abc" }).perPage).toBe(30);
58 });
59
60 test("page is never below 1, so the offset is never negative", () => {
61 expect(parseRunListQuery({ page: "0" }).page).toBe(1);
62 expect(parseRunListQuery({ page: "-3" }).page).toBe(1);
63 expect(parseRunListQuery({ page: "abc" }).page).toBe(1);
64 expect(parseRunListQuery({ page: "4" }).page).toBe(4);
65 });
66});
67
68describe("the other filters", () => {
69 test("status is passed through for the handler to match against both columns", () => {
70 // GitHub's quirk, kept on purpose: `status` accepts lifecycle values AND
71 // conclusions, because that is what existing clients already send.
72 // Rejecting half the vocabulary would be compatible in shape and useless
73 // in practice.
74 expect(parseRunListQuery({ status: "in_progress" }).status).toBe("in_progress");
75 expect(parseRunListQuery({ status: "success" }).status).toBe("success");
76 });
77
78 test("head_sha and event are trimmed, and blank means unset", () => {
79 const f = parseRunListQuery({ head_sha: " abc123 ", event: " push " });
80 expect(f.headSha).toBe("abc123");
81 expect(f.event).toBe("push");
82 expect(parseRunListQuery({ head_sha: "", event: "" }).headSha).toBeNull();
83 });
84
85 test("no query at all filters on nothing", () => {
86 const f = parseRunListQuery({});
87 expect([f.ref, f.headSha, f.event, f.status]).toEqual([null, null, null, null]);
88 expect(f.perPage).toBe(30);
89 expect(f.page).toBe(1);
90 });
91});
Modifiedsrc/routes/api-v2.ts+130−0View fileUnifiedSplit
27052705//
27062706// POST /repos/:owner/:repo/actions/workflows/:filename/dispatches
27072707// GET /repos/:owner/:repo/actions/workflows/:filename/runs
2708// GET /repos/:owner/:repo/actions/runs
27082709// GET /repos/:owner/:repo/actions/runs/:run_id
27092710// GET /repos/:owner/:repo/actions/runs/:run_id/logs (.zip)
27102711// POST /repos/:owner/:repo/actions/runs/:run_id/cancel
30203021 }
30213022);
30223023
3024// ─── 2b. GET /actions/runs — every run in the repo ─────────────────────────
3025//
3026// The repo-wide list GitHub has had since 2018, and which this API did not:
3027// runs could only be listed PER WORKFLOW, by filename. That sounds like a
3028// small gap and is not. Everything that watches CI from outside — an agent
3029// polling a branch, a status badge, a deploy gate, the monitor this very
3030// session pointed at a 404 twice before noticing — wants "what happened on
3031// this branch", not "what happened in ci.yml on this branch". Without it a
3032// client has to list workflows first, then fan out one request per workflow
3033// and merge the results by hand, and it still cannot order them correctly
3034// across workflows.
3035//
3036// `status` follows GitHub's quirk deliberately: it accepts BOTH lifecycle
3037// values (queued, in_progress) and conclusions (success, failure, cancelled),
3038// because that is what every existing client already sends. Rejecting the
3039// half of the vocabulary GitHub accepts would make this endpoint compatible
3040// in shape and useless in practice.
3041
3042/**
3043 * Parse the run-list query string. Pure, so the semantics can be tested
3044 * without a database.
3045 *
3046 * The paging clamps matter more than they look: `per_page=100000` on a repo
3047 * with a long CI history is a trivially available way to make the server
3048 * serialise its way to a timeout, and a client that sends `page=0` or
3049 * `per_page=abc` should get a sane page rather than an empty one it will
3050 * misread as "no runs".
3051 */
3052export function parseRunListQuery(q: {
3053 per_page?: string | undefined;
3054 page?: string | undefined;
3055 branch?: string | undefined;
3056 head_sha?: string | undefined;
3057 event?: string | undefined;
3058 status?: string | undefined;
3059}): {
3060 perPage: number;
3061 page: number;
3062 ref: string | null;
3063 headSha: string | null;
3064 event: string | null;
3065 status: string | null;
3066} {
3067 const perPage = Math.min(100, Math.max(1, parseInt(q.per_page || "30", 10) || 30));
3068 const page = Math.max(1, parseInt(q.page || "1", 10) || 1);
3069 const branch = (q.branch || "").trim();
3070 return {
3071 perPage,
3072 page,
3073 // Accept a short branch name or a fully-qualified ref, because clients
3074 // written against `git` send one and clients written against the API
3075 // send the other, and refusing either is a support ticket.
3076 ref: branch ? (branch.startsWith("refs/") ? branch : `refs/heads/${branch}`) : null,
3077 headSha: (q.head_sha || "").trim() || null,
3078 event: (q.event || "").trim() || null,
3079 status: (q.status || "").trim() || null,
3080 };
3081}
3082
3083apiv2.get("/repos/:owner/:repo/actions/runs", async (c) => {
3084 const { owner, repo } = c.req.param();
3085 const resolved = await resolveRepo(owner, repo);
3086 if (!resolved) return c.json({ error: "Not Found" }, 404);
3087
3088 const repoRow = resolved.repo as any;
3089
3090 // Same privacy guard as the per-workflow list: soft auth only, so without
3091 // this a private repo's entire CI history is world-readable. 404 rather
3092 // than 403 — a 403 confirms the repo exists.
3093 const user = c.get("user");
3094 const runsAccess = await resolveRepoAccess({
3095 repoId: repoRow.id,
3096 userId: user?.id ?? null,
3097 isPublic: !repoRow.isPrivate,
3098 });
3099 if (!satisfiesAccess(runsAccess, "read")) {
3100 return c.json({ error: "Not Found" }, 404);
3101 }
3102
3103 const f = parseRunListQuery({
3104 per_page: c.req.query("per_page"),
3105 page: c.req.query("page"),
3106 branch: c.req.query("branch"),
3107 head_sha: c.req.query("head_sha"),
3108 event: c.req.query("event"),
3109 status: c.req.query("status"),
3110 });
3111 const offset = (f.page - 1) * f.perPage;
3112
3113 const conditions = [eq(workflowRuns.repositoryId, repoRow.id)];
3114 if (f.ref) conditions.push(eq(workflowRuns.ref, f.ref));
3115 if (f.headSha) conditions.push(eq(workflowRuns.commitSha, f.headSha));
3116 if (f.event) conditions.push(eq(workflowRuns.event, f.event));
3117 if (f.status) {
3118 // One parameter, two columns — see the note above.
3119 conditions.push(
3120 or(eq(workflowRuns.status, f.status), eq(workflowRuns.conclusion, f.status))!
3121 );
3122 }
3123
3124 const where = conditions.length === 1 ? conditions[0] : and(...conditions);
3125
3126 const [{ n }] = await db
3127 .select({ n: sql<number>`count(*)::int` })
3128 .from(workflowRuns)
3129 .where(where);
3130
3131 // Left join: a run whose workflow row was deleted still happened, and
3132 // dropping it here would make the count disagree with the list — the shape
3133 // of bug that gets diagnosed as "the API is lying" months later.
3134 const rows = await db
3135 .select({ run: workflowRuns, workflowName: workflows.name })
3136 .from(workflowRuns)
3137 .leftJoin(workflows, eq(workflows.id, workflowRuns.workflowId))
3138 .where(where)
3139 .orderBy(desc(workflowRuns.queuedAt))
3140 .limit(f.perPage)
3141 .offset(offset);
3142
3143 return c.json({
3144 total_count: Number(n) || 0,
3145 workflow_runs: rows.map((r) =>
3146 serializeRun(r.run, r.workflowName ?? "", owner, repo)
3147 ),
3148 });
3149});
3150
30233151// ─── 3. GET /actions/runs/:run_id ───────────────────────────────────────────
30243152
30253153apiv2.get("/repos/:owner/:repo/actions/runs/:run_id", async (c) => {
34333561 "Dispatch a workflow run (204 No Content)",
34343562 "GET /api/v2/repos/:owner/:repo/actions/workflows/:filename/runs":
34353563 "List runs of a workflow (paginated: per_page, page; filters: branch, head_sha)",
3564 "GET /api/v2/repos/:owner/:repo/actions/runs":
3565 "List every run in the repo (paginated: per_page, page; filters: branch, head_sha, event, status — status matches a lifecycle value or a conclusion)",
34363566 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id":
34373567 "Get a single workflow run",
34383568 "GET /api/v2/repos/:owner/:repo/actions/runs/:run_id/logs":
34393569
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts