Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

OIDC environment and pull request subjects; a repository runs at most 20 hosted jobs and keeps 500 runs unfinished (#82)

* OIDC tokens use GitHub's environment and pull request subjects, and carry repository, owner and actor ids

A job deploying to an environment got the same sub as any job on its branch,
so a cloud trust policy couldn't require the environment's protection rules,
and GitHub-style environment subjects never matched. Without numeric ids, a
repository recreated under a reused name minted identical tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

* One repository runs at most 20 hosted jobs at once and keeps at most 500 runs unfinished

A workflow could dispatch itself in a loop, and one repository's runs could
take every hosted runner container on the instance.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo

---------

Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent 2838763commit 797793ee8b2ba8232507eb3ee6ae4c757f0c6193Browse files
10 files changed+144−18
Files (10)
modifiedapps/diffinery/docs/actions/environments.md+0−2View file
@@ -770,7 +770,6 @@
770770 - **Reading or updating a single branch pattern.** You can list, add and delete patterns, but not `GET` or `PUT` one by id.
771771 - **Sealed-box secrets.** Send `value`, not `encrypted_value`.
772772 - **Organization-level environments and environment secrets.** Environments belong to one repository.
773-- **OIDC environment claims.** OIDC tokens for jobs with `environment:` have no `environment` claim, and their `sub` stays `repo:<owner>/<repo>:ref:<ref>`. See [OpenID Connect (OIDC)](/docs/actions/oidc).
774773 - **`on: deployment` and `on: deployment_status` workflow triggers.** Accepted but never started.
775774
776775 ## Differences from GitHub
@@ -787,7 +786,6 @@
787786 | `environment.url` | Can use `steps` outputs | `steps` references are ignored |
788787 | Deployment `auto_merge` | Merges the default branch into the ref | Ignored |
789788 | Branch patterns | `fnmatch` syntax with character classes | `*`, `**` and `?` only, unlike workflow filters |
790-| OIDC | `environment` claim and an environment-based `sub` | No `environment` claim |
791789
792790 ## Troubleshooting
793791
modifiedapps/diffinery/docs/actions/oidc.md+12−10View file
@@ -55,7 +55,7 @@
5555 "response_types_supported": ["id_token"],
5656 "subject_types_supported": ["public"],
5757 "id_token_signing_alg_values_supported": ["ES256"],
58- "claims_supported": ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"]
58+ "claims_supported": ["sub", "aud", "iss", "repository", "repository_id", "repository_owner", "repository_owner_id", "environment", "ref", "ref_type", "sha", "workflow", "event_name", "actor", "actor_id", "run_id"]
5959 }
6060 ```
6161
@@ -94,23 +94,30 @@
9494
9595 ## Claims
9696
97-Every token has exactly these claims. All values are strings except the timestamps.
97+Every token has these claims. All values are strings except the timestamps.
9898
9999 | Claim | Example | Meaning |
100100 |---|---|---|
101101 | `iss` | `https://diffinery.com` | Issuer. |
102102 | `aud` | `sts.amazonaws.com` | The requested audience, or `https://diffinery.com` if none was given. A single string. |
103-| `sub` | `repo:acme/website:ref:refs/heads/main` | Always `repo:<owner>/<repo>:ref:<ref>`. |
103+| `sub` | `repo:acme/website:ref:refs/heads/main` | As on GitHub: `repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for a `pull_request` run, and `repo:<owner>/<repo>:ref:<ref>` otherwise. |
104104 | `iat`, `nbf` | `1760000000` | Issue time and not-before time (the same). |
105105 | `exp` | `1760000600` | Expiry, 10 minutes after issue. |
106106 | `repository` | `acme/website` | Repository that runs the workflow. |
107+| `repository_id` | `2918374650` | The repository's numeric id. It stays the same when the repository is renamed or transferred, and a new repository with an old name gets a new one. |
107108 | `repository_owner` | `acme` | Its owner (user or organization). |
109+| `repository_owner_id` | `3482910375` | The owner's numeric id, which a new account with a reused name doesn't get. |
110+| `environment` | `production` | The environment the job deployed to. Only on jobs with `environment:`, once its protection rules passed. |
111+| `ref_type` | `branch` | `branch` or `tag`. |
112+| `head_ref`, `base_ref` | `feature`, `main` | The pull request's branches, on pull request runs only. |
113+| `run_number`, `run_attempt` | `128`, `1` | The run's number and attempt. |
108114 | `ref` | `refs/heads/main` | The run's ref. See the table below. |
109115 | `sha` | `9fceb02d0ae598e95dc970b74767f19372d61af8` | The commit the run checked out. |
110116 | `workflow` | `Deploy` | The workflow's `name`, or its file path when it has none. |
111117 | `event_name` | `push` | The event that started the run. |
112118 | `run_id` | `128` | The run number, as a string. It's the same value as `github.run_id`, and stays the same when the run is re-run. |
113119 | `actor` | `diffy` | Who started the run. |
120+| `actor_id` | `1938475620` | Their numeric id. |
114121 | `job_workflow_ref` | `acme/website/.github/workflows/deploy.yml@refs/heads/main` | The run's top-level workflow file and ref. |
115122
116123 The token header is `{"alg": "ES256", "typ": "JWT", "kid": "..."}`.
@@ -131,15 +138,10 @@
131138
132139 ## Jobs that use an environment
133140
134-Tokens don't change when a job has `environment:`. There's no `environment` claim, and `sub` stays `repo:<owner>/<repo>:ref:<ref>`; GitHub's `repo:<owner>/<repo>:environment:<name>` form is never issued. A trust policy written for GitHub that matches `sub` against `...:environment:production` never matches a Diffinery token.
141+A job with `environment:` gets GitHub's environment form of `sub`, `repo:<owner>/<repo>:environment:<name>`, and an `environment` claim. The token is only issued after the environment's deployment branch policy has passed, so a trust policy on `repo:acme/website:environment:production` only trusts jobs that were allowed to deploy to `production` (see [Deployments and environments](/docs/actions/environments)).
135142
136-To get the same protection, combine two checks:
143+To keep trusting a reused name out, also check `repository_id` and `repository_owner_id` where your provider lets you: a repository deleted and recreated, or an owner name taken by a new account, gets new ids.
137144
138-1. In your cloud provider, trust only the refs that may deploy, for example `repo:acme/website:ref:refs/heads/main`.
139-2. In Diffinery, give the deploy job `environment: production` and set that environment's deployment branch policy to the same refs, so no other branch can run the job (see [Deployments and environments](/docs/actions/environments)).
140-
141-Unlike environment secrets, an OIDC token is issued to any job that has `id-token: write`, whatever its environment and whoever started the run. The `sub`, `ref` and `event_name` claims are what tell your provider where the request came from.
142-
143145 ## AWS
144146
145147 AWS accepts Diffinery tokens through an IAM OIDC identity provider and a role whose trust policy names it.
modifiedapps/diffinery/docs/reference/limits.md+2−0View file
@@ -179,6 +179,8 @@
179179 | Starting a job | 3 retries | Starting a job's container is retried 3 times with exponential backoff from 10 seconds before the job fails with "Could not start a runner". |
180180 | Hosted runner size | Cloudflare Containers `standard-2` | Each `runs-on: ubuntu-latest` job runs in its own container of this instance type. |
181181 | Hosted job containers at once | 100 | `max_instances` of the runner container class. |
182+| Hosted jobs at once per repository | 20 | Further jobs stay queued and start, oldest first, as the repository's own jobs finish, so one repository can't take every container. Self-hosted jobs don't count. |
183+| Unfinished runs per repository | 500 | Past it, a `workflow_dispatch` or `repository_dispatch` is refused with `429 this repository has 500 unfinished workflow runs, the most allowed; wait for some to finish`, and other events start no run. |
182184 | Jobs during a deploy | Not interrupted | A deploy doesn't replace the container of a running hosted job: it finishes on the version it started with. If the platform replaces a job's container anyway, the job keeps going for up to 13 minutes, then is cancelled. |
183185 | Service container health check | 15 minutes | How long a job waits for a `services:` container's health check to pass. |
184186 | Log line length | 16,384 characters | Longer lines are cut and end with `…`. |
modifiedapps/diffinery/docs/security/overview.md+1−1View file
@@ -242,7 +242,7 @@
242242
243243 ## OIDC id tokens
244244
245-Jobs that declare `permissions: id-token: write` can request a short-lived OIDC id token to authenticate to cloud providers without stored credentials. Tokens are signed with ES256 by a key configured on the instance, expire after 10 minutes, use the instance URL as issuer (and as the default audience, unless the job asks for another one), and have the subject `repo:<owner>/<repo>:ref:<ref>`. The public keys are at `https://diffinery.com/.well-known/jwks`. A job without the permission gets `403` with `this job lacks permissions: id-token: write`. See [OIDC](/docs/actions/oidc).
245+Jobs that declare `permissions: id-token: write` can request a short-lived OIDC id token to authenticate to cloud providers without stored credentials. Tokens are signed with ES256 by a key configured on the instance, expire after 10 minutes, use the instance URL as issuer (and as the default audience, unless the job asks for another one), and have GitHub's subject forms (`repo:<owner>/<repo>:environment:<name>` for a job with `environment:`, `repo:<owner>/<repo>:pull_request` for pull request runs, `repo:<owner>/<repo>:ref:<ref>` otherwise), with numeric `repository_id` and `repository_owner_id` claims that a reused name doesn't inherit. The public keys are at `https://diffinery.com/.well-known/jwks`. A job without the permission gets `403` with `this job lacks permissions: id-token: write`. See [OIDC](/docs/actions/oidc).
246246
247247 ## Webhook signatures
248248
modifiedapps/diffinery/src/http/api.ts+1−1View file
@@ -1038,7 +1038,7 @@
10381038 // OIDC discovery for workflow id tokens.
10391039 export const wellKnown = new Hono<{ Bindings: Env; Variables: AppVars }>();
10401040 wellKnown.get("/.well-known/openid-configuration", (c) =>
1041- c.json({ issuer: c.env.PUBLIC_URL, jwks_uri: `${c.env.PUBLIC_URL}/.well-known/jwks`, response_types_supported: ["id_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], claims_supported: ["sub", "aud", "iss", "repository", "ref", "sha", "workflow", "actor", "run_id"] }),
1041+ c.json({ issuer: c.env.PUBLIC_URL, jwks_uri: `${c.env.PUBLIC_URL}/.well-known/jwks`, response_types_supported: ["id_token"], subject_types_supported: ["public"], id_token_signing_alg_values_supported: ["ES256"], claims_supported: ["sub", "aud", "iss", "repository", "repository_id", "repository_owner", "repository_owner_id", "environment", "ref", "ref_type", "sha", "workflow", "event_name", "actor", "actor_id", "run_id"] }),
10421042 );
10431043 wellKnown.get("/.well-known/jwks", (c) => c.json({ keys: c.env.OIDC_PRIVATE_KEY ? [publicJwk(JSON.parse(c.env.OIDC_PRIVATE_KEY))] : [] }));
10441044
modifiedapps/diffinery/src/http/runner.ts+11−1View file
@@ -141,12 +141,22 @@
141141 const value = await signEs256(JSON.parse(c.env.OIDC_PRIVATE_KEY), {
142142 iss: c.env.PUBLIC_URL,
143143 aud: c.req.query("audience") ?? c.env.PUBLIC_URL,
144- sub: `repo:${g.repository}:ref:${g.ref}`,
144+ // GitHub's forms: a job in an environment, a pull request, or a branch or tag.
145+ sub: g.environment ? `repo:${g.repository}:environment:${g.environment}` : g.event_name === "pull_request" ? `repo:${g.repository}:pull_request` : `repo:${g.repository}:ref:${g.ref}`,
145146 iat: nowS,
146147 nbf: nowS,
147148 exp: nowS + 600,
148149 repository: g.repository,
150+ repository_id: g.repository_id,
149151 repository_owner: g.repository_owner,
152+ repository_owner_id: g.repository_owner_id,
153+ actor_id: g.actor_id,
154+ environment: g.environment,
155+ ref_type: g.ref_type,
156+ head_ref: g.head_ref || undefined,
157+ base_ref: g.base_ref || undefined,
158+ run_number: g.run_number,
159+ run_attempt: g.run_attempt,
150160 ref: g.ref,
151161 sha: g.sha,
152162 workflow: g.workflow,
modifiedapps/diffinery/src/repo/actions.ts+32−0View file
@@ -44,6 +44,10 @@
4444 /** Every push reads and parses a commit's workflow files: at most this many, each at most this large. */
4545 export const MAX_WORKFLOW_FILES = 256;
4646 export const MAX_WORKFLOW_BYTES = 512 * 1024;
47+/** Hosted jobs one repository runs at once (GitHub's free plan allows 20 per account); more wait their turn. */
48+export const MAX_HOSTED_JOBS = 20;
49+/** Runs one repository may have unfinished at once; past it, new ones aren't started. */
50+export const MAX_ACTIVE_RUNS = 500;
4751 const SUPPORTED_LABEL = /^(ubuntu-.*|linux|diffinery(-.*)?|self-hosted|x64|amd64)$/i;
4852
4953 export type RunRow = {
@@ -98,6 +102,7 @@
98102 concurrency_group: string | null;
99103 concurrency_cancel: number;
100104 log_chars: number;
105+ fleet_held: number;
101106 };
102107
103108 export type RunInfo = {
@@ -496,6 +501,13 @@
496501 // runs, so workflows cannot trigger themselves in a loop; workflow_dispatch and
497502 // repository_dispatch are the exceptions.
498503 if (isJobCaller(o.caller) && o.event !== "workflow_dispatch" && o.event !== "repository_dispatch") return null;
504+ // A dispatch loop (or a flood of pushes) stops growing here.
505+ const active = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM runs WHERE status != 'completed'").one().n;
506+ if (active >= MAX_ACTIVE_RUNS) {
507+ if (o.event === "workflow_dispatch" || o.event === "repository_dispatch") throw new HttpError(429, `this repository has ${active} unfinished workflow runs, the most allowed; wait for some to finish`);
508+ console.warn(`not starting ${wf.path}: ${active} unfinished runs`);
509+ return null;
510+ }
499511 const id = newId();
500512 const t = now();
501513 const run: RunRow = {
@@ -914,6 +926,14 @@
914926 this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
915927 return;
916928 }
929+ // One repository can't take the whole fleet: past its share, the job waits for one of its own to finish.
930+ const busy = this.sql.exec<{ n: number }>("SELECT count(*) AS n FROM jobs WHERE labels IS NULL AND container IS NOT NULL AND status IN ('waiting', 'in_progress') AND id != ?", jobId).one().n;
931+ if (busy >= MAX_HOSTED_JOBS) {
932+ this.sql.exec("UPDATE jobs SET status = 'queued', fleet_held = 1 WHERE id = ?", jobId);
933+ this.repo.upsertCheck({ sha: run.head_sha ?? run.sha, name: this.checkName(run, job), status: "queued", runId: run.id, jobId, detailsUrl: `${this.env.PUBLIC_URL}/${m.owner}/${m.name}/actions/runs/${run.id}/job/${jobId}` });
934+ return;
935+ }
936+ this.sql.exec("UPDATE jobs SET fleet_held = 0 WHERE id = ?", jobId);
917937 const token = `djt_${m.repoId}_${jobId}_${randomToken(24)}`;
918938 const container = `job-${jobId}`;
919939 this.sql.exec("UPDATE jobs SET status = 'waiting', token_hash = ?, container = ? WHERE id = ?", await sha256(token), container, jobId);
@@ -1171,6 +1191,13 @@
11711191 const ctx = this.githubContext(run, localKey(job.job_key, exp.parentOf.get(job.job_key) ?? null), token) as Record<string, Json>;
11721192 const github: Record<string, string> = {};
11731193 for (const [k, v] of Object.entries(ctx)) if (typeof v === "string" || typeof v === "number") github[k] = String(v);
1194+ // Ids don't change when a name is reused, so a cloud trust policy can pin them; the environment is the one
1195+ // whose protection rules this job passed (runnerSpec records its deployment).
1196+ github.repository_owner_id = String(numericId(this.repo.meta.ownerId));
1197+ const actor = JSON.parse(run.actor) as Actor;
1198+ if (actor.id) github.actor_id = String(numericId(actor.id));
1199+ const env = this.sql.exec<{ environment: string }>("SELECT environment FROM deployments WHERE job_id = ?", jobId).toArray()[0];
1200+ if (env) github.environment = env.environment;
11741201 return { allowed, github };
11751202 }
11761203
@@ -1374,6 +1401,11 @@
13741401 const queued = siblings.find((s) => s.status === "queued");
13751402 if (queued) await this.launch(queued.id);
13761403 }
1404+ // A hosted job hands its slot to the oldest job waiting for one.
1405+ if (!job.labels && job.container) {
1406+ const held = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE fleet_held = 1 AND status = 'queued' ORDER BY rowid LIMIT 1").toArray()[0];
1407+ if (held) await this.launch(held.id);
1408+ }
13771409 // A job that held its concurrency group hands it to the oldest pending job (of any run).
13781410 if (job.concurrency_group && (job.status === "waiting" || job.status === "in_progress")) {
13791411 const next = this.sql.exec<{ id: string }>("SELECT id FROM jobs WHERE concurrency_group = ? AND status = 'pending' ORDER BY rowid LIMIT 1", job.concurrency_group).toArray()[0];
modifiedapps/diffinery/src/repo/schema.ts+2−0View file
@@ -177,6 +177,8 @@
177177 ["jobs", "concurrency_cancel", "INTEGER NOT NULL DEFAULT 0"],
178178 // Characters of log text stored so far (repo/actions.ts MAX_JOB_LOG).
179179 ["jobs", "log_chars", "INTEGER NOT NULL DEFAULT 0"],
180+ // 1 while a queued job waits for one of the repository's hosted runner slots (repo/actions.ts MAX_HOSTED_JOBS).
181+ ["jobs", "fleet_held", "INTEGER NOT NULL DEFAULT 0"],
180182 // The GitHub import that wrote this row (null for everything else); a retry may only overwrite its own rows.
181183 ["issues", "imported", "TEXT"],
182184 ["pulls", "imported", "TEXT"],
modifiedapps/diffinery/test/e2e/security.test.mjs+30−2View file
@@ -21,7 +21,7 @@
2121 // Agents land changes on protected branches only through auto-merge (or the
2222 // merge queue) where a person allowed it and a required check gates it, never by bypass.
2323 import { spawn, spawnSync, execFileSync } from "node:child_process";
24-import { createHash, randomBytes } from "node:crypto";
24+import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
2525 import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2626 import { gzipSync } from "node:zlib";
2727 import { tmpdir } from "node:os";
@@ -140,7 +140,7 @@
140140 );
141141 dev = spawn(
142142 "pnpm",
143- ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--show-interactive-dev-session=false"],
143+ ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `OIDC_PRIVATE_KEY:${JSON.stringify(generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }))}`, "--show-interactive-dev-session=false"],
144144 { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true },
145145 );
146146 dev.stdout.on("data", (d) => (devLog += d));
@@ -1810,3 +1810,31 @@
18101810 assert.equal(taken.status, 403);
18111811 assert.match(await taken.text(), /named after the guild\/vault repository/);
18121812 });
1813+
1814+test("security: OIDC tokens name the environment a job deployed to, and carry ids that survive a reused name", async () => {
1815+ const R = `/repos/${OWNER}/oidc-claims`;
1816+ await repoWithReadme("oidc-claims");
1817+ const wf = "name: ci\non: push\npermissions:\n id-token: write\n contents: read\njobs:\n plain:\n runs-on: [self-hosted, linux]\n steps:\n - run: echo hi\n deploy:\n runs-on: [self-hosted, linux]\n environment: production\n steps:\n - run: echo hi\n";
1818+ await ok("PUT", `${R}/contents/.github/workflows/ci.yml`, { message: "ci", content: b64(wf) });
1819+ const tokens = {};
1820+ await until("both jobs claimed", async () => {
1821+ const r = await api("POST", `${R}/actions/runners/claim`, { name: "oidc-runner", labels: ["linux"] });
1822+ if (r.status === 200) {
1823+ const spec = await (await fetch(`${base}/runner/jobs/${r.json.job_id}`, { headers: { authorization: `Bearer ${r.json.token}` } })).json();
1824+ const res = await fetch(`${base}/runner/jobs/${r.json.job_id}/oidc?audience=sts.example.com`, { headers: { authorization: `Bearer ${r.json.token}` } });
1825+ assert.equal(res.status, 200, await res.clone().text());
1826+ tokens[spec.jobKey] = JSON.parse(Buffer.from((await res.json()).value.split(".")[1], "base64url").toString());
1827+ await fetch(`${base}/runner/jobs/${r.json.job_id}/complete`, { method: "POST", headers: { authorization: `Bearer ${r.json.token}`, "content-type": "application/json" }, body: JSON.stringify({ conclusion: "success", outputs: {} }) });
1828+ }
1829+ return Object.keys(tokens).length === 2;
1830+ });
1831+ const repo = await ok("GET", R);
1832+ assert.equal(tokens.plain.sub, `repo:${OWNER}/oidc-claims:ref:refs/heads/main`);
1833+ assert.equal(tokens.deploy.sub, `repo:${OWNER}/oidc-claims:environment:production`);
1834+ assert.equal(tokens.deploy.environment, "production");
1835+ assert.equal(tokens.plain.environment, undefined);
1836+ assert.equal(tokens.plain.aud, "sts.example.com");
1837+ assert.equal(tokens.plain.repository_id, String(repo.id));
1838+ assert.equal(tokens.plain.repository_owner_id, String(repo.owner.id));
1839+ assert.match(tokens.plain.actor_id, /^\d+$/);
1840+});
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+53−1View file
@@ -1,7 +1,7 @@
11 import { describe, expect, it } from "vitest";
22 import { BodyTooLarge, STEP_OPS, batchParts, blockKey, chunkKey, compose, finalKey, listStaged, parseBlockList, parseRange, planCompose, putStream, runComposeStep, stagingPrefix, type BlobBucket, type ComposeLimits, type ComposeStep } from "../../src/lib/blob-store.js";
33 import { DatabaseSync } from "node:sqlite";
4-import { Actions } from "../../src/repo/actions.js";
4+import { Actions, MAX_ACTIVE_RUNS, MAX_HOSTED_JOBS } from "../../src/repo/actions.js";
55 import { DEFAULT_ARTIFACT_QUOTA, Runtime, artifactFileName, artifactObjectKeys } from "../../src/repo/runtime.js";
66 import { REPO_SCHEMA, migrateRepoSchema } from "../../src/repo/schema.js";
77 import type { RepoDO } from "../../src/repo/RepoDO.js";
@@ -643,3 +643,55 @@
643643 expect(f.sql.exec("SELECT key FROM caches ORDER BY key").toArray()).toEqual([{ key: "refs/heads/main\nnew" }, { key: "refs/heads/main\nrecent" }]);
644644 });
645645 });
646+
647+describe("hosted runner slots", () => {
648+ it("a repository runs at most MAX_HOSTED_JOBS hosted jobs at once; the rest wait their turn", async () => {
649+ const sql = sqlStorage();
650+ sql.exec(REPO_SCHEMA);
651+ migrateRepoSchema(sql as unknown as SqlStorage);
652+ const started: string[] = [];
653+ const repo = {
654+ sql,
655+ meta: { repoId: "r", owner: "acme", name: "app", defaultBranch: "main" },
656+ upsertCheck: () => {},
657+ broadcast: () => {},
658+ deploys: { jobCompleted: () => {} },
659+ runtime: { sweepJob: async () => {} },
660+ } as unknown as RepoDO;
661+ const env = {
662+ PUBLIC_URL: "https://d.test",
663+ BUCKET: memoryBucket().bucket,
664+ RUNNER: { idFromName: (n: string) => n, get: (n: string) => ({ startJob: async () => void started.push(n), stopJob: async () => {} }) },
665+ RUN_WORKFLOW: { get: async () => ({ sendEvent: async () => {} }) },
666+ } as unknown as Env;
667+ const actions = new Actions(repo, env);
668+ const t = new Date().toISOString();
669+ sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES ('run1', 1, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'in_progress', ?, ?, '{}')", t, t);
670+ for (let i = 0; i < MAX_HOSTED_JOBS + 2; i++) sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status) VALUES (?, 'run1', ?, ?, 'queued')", `j${i}`, `k${i}`, `k${i}`);
671+ const launch = (id: string) => (actions as unknown as { launch(id: string): Promise<void> }).launch(id);
672+ for (let i = 0; i < MAX_HOSTED_JOBS + 2; i++) await launch(`j${i}`);
673+ expect(started).toHaveLength(MAX_HOSTED_JOBS);
674+ const held = () => sql.exec("SELECT id FROM jobs WHERE fleet_held = 1 AND status = 'queued' ORDER BY rowid").toArray().map((r) => r.id);
675+ expect(held()).toEqual([`j${MAX_HOSTED_JOBS}`, `j${MAX_HOSTED_JOBS + 1}`]);
676+ // A finished job hands its slot to the oldest one waiting.
677+ await (actions as unknown as { completeJob(id: string, c: unknown): Promise<void> }).completeJob("j0", { conclusion: "success", outputs: {} });
678+ expect(started).toHaveLength(MAX_HOSTED_JOBS + 1);
679+ expect(started.at(-1)).toBe(`job-j${MAX_HOSTED_JOBS}`);
680+ expect(held()).toEqual([`j${MAX_HOSTED_JOBS + 1}`]);
681+ });
682+});
683+
684+describe("unfinished runs", () => {
685+ it("past MAX_ACTIVE_RUNS a dispatch is refused and other events start nothing", async () => {
686+ const sql = sqlStorage();
687+ sql.exec(REPO_SCHEMA);
688+ migrateRepoSchema(sql as unknown as SqlStorage);
689+ const actions = new Actions({ sql, meta: { repoId: "r", owner: "acme", name: "app", defaultBranch: "main" } } as unknown as RepoDO, { PUBLIC_URL: "https://d.test" } as unknown as Env);
690+ const t = new Date().toISOString();
691+ for (let i = 0; i < MAX_ACTIVE_RUNS; i++) sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES (?, ?, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'queued', ?, ?, '{}')", `run${i}`, i + 1, t, t);
692+ const wf = { path: ".github/workflows/w.yml", on: {}, jobs: {} } as never;
693+ const caller = { id: "u1", login: "ada", kind: "user" as const };
694+ await expect(actions.createRun(wf, { event: "workflow_dispatch", ref: "refs/heads/main", sha: "abc", payload: {}, caller, title: "w" })).rejects.toThrow(/500 unfinished workflow runs/);
695+ expect(await actions.createRun(wf, { event: "push", ref: "refs/heads/main", sha: "abc", payload: {}, caller, title: "w" })).toBeNull();
696+ });
697+});