From 6875dabdd72a62dcee3ea3e182bd6e1175cbcef3 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:43:25 -0800 Subject: [PATCH 1/8] chore(quest): claim quest/m1/kix/ci-read-only.md Quest-Claim: a736d3d4522d1a65 From 550ecf12a6c6b3c2b04d4eec9d28464490593718 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:51:23 -0800 Subject: [PATCH 2/8] feat(kix): warn when an enrolled repository's pull-request CI can write Co-Authored-By: Claude Opus 5.5 --- cloudflare/github-app-manifest.json | 4 + cloudflare/src/github/ci.ts | 169 +++++++++++++++++++++++++++ cloudflare/src/github/service.ts | 63 +++++++--- cloudflare/src/projects.tsx | 26 +++++ cloudflare/test/projects.test.ts | 173 ++++++++++++++++++++++++++++ 5 files changed, 418 insertions(+), 17 deletions(-) create mode 100644 cloudflare/src/github/ci.ts diff --git a/cloudflare/github-app-manifest.json b/cloudflare/github-app-manifest.json index 1207e8cd..f68699bb 100644 --- a/cloudflare/github-app-manifest.json +++ b/cloudflare/github-app-manifest.json @@ -14,6 +14,10 @@ "pull_requests": "write", "checks": "read", "statuses": "read", + "administration": "read", + "secrets": "read", + "actions": "read", + "environments": "read", "email_addresses": "read" }, "default_events": [ diff --git a/cloudflare/src/github/ci.ts b/cloudflare/src/github/ci.ts new file mode 100644 index 00000000..01273ce8 --- /dev/null +++ b/cloudflare/src/github/ci.ts @@ -0,0 +1,169 @@ +// kix publishes job branches on the enrolled repository itself, where GitHub +// gives a branch's workflows the repository's secrets and default token. So +// pull-request CI must stay read-only: code no maintainer has reviewed meets a +// read-only GITHUB_TOKEN and no write credentials. This check reads the +// repository's Actions settings and reports what breaks that rule. It only +// warns: jobs still run, and a failed read becomes a warning of its own. + +/** The App permissions the check reads with, beyond metadata. */ +export const CI_PERMISSIONS = { + administration: "read", + secrets: "read", + actions: "read", + environments: "read", +} as const; +export type CIPermission = keyof typeof CI_PERMISSIONS; + +/** REST reads below `/repos//`, through the caller's scoped token. */ +export type CIReads = { get(path: string): Promise }; + +/** + * Environments examined per check. Each costs up to two reads, which keeps a + * setup page within Workers Free's 50 subrequests alongside its other reads. + */ +export const ENVIRONMENTS = 12; + +type Secrets = { total_count: number; secrets: { name: string }[] }; +type Environment = { + name: string; + deployment_branch_policy: { protected_branches: boolean; custom_branch_policies: boolean } | null; +}; +type Policy = { name: string; type?: string }; + +const PERMISSION_NAMES: Record = { + administration: "Administration", + secrets: "Secrets", + actions: "Actions", + environments: "Environments", +}; +const WILDCARD = /[*?[\]{}\\]/; +const QUEUE = /^gh-readonly-queue\/([^*?[\]{}\\]+)\/\*$/; + +function names(secrets: Secrets) { + const listed = secrets.secrets.map((secret) => secret.name); + const more = secrets.total_count - listed.length; + return listed.join(", ") + (more > 0 ? ` and ${more} more` : ""); +} +function count(n: number, noun: string) { + return `${n} ${noun}${n === 1 ? "" : "s"}`; +} + +/** + * What lets pull-request CI write, as sentences for the maintainer, or an + * empty list when nothing does. `granted` names the CI permissions the + * installation has accepted; checks needing another are reported unchecked. + */ +export async function ciProblems(reads: CIReads, granted: ReadonlySet): Promise { + const problems: string[] = []; + // A failed or malformed read is a warning, never a failed page. + const read = async (path: string, what: string, valid: (value: T) => boolean): Promise => { + try { + const value = await reads.get(path); + if (value && valid(value)) return value; + } catch {} + problems.push(`kix could not read ${what}; review this project again later.`); + return null; + }; + const secretList = (value: Secrets) => typeof value.total_count === "number" && Array.isArray(value.secrets); + + const missing = (Object.keys(CI_PERMISSIONS) as CIPermission[]).filter((p) => !granted.has(p)); + if (missing.length) + problems.push( + `Accept the App's ${missing.map((p) => `${PERMISSION_NAMES[p]} (read)`).join(", ")} permission${missing.length === 1 ? "" : "s"} in its GitHub installation settings so kix can check ${[ + granted.has("administration") ? null : "the default workflow token", + granted.has("secrets") ? null : "repository and organization secrets", + granted.has("actions") && granted.has("environments") ? null : "environments", + ] + .filter(Boolean) + .join(", ")}.`, + ); + + if (granted.has("administration")) { + const token = await read<{ default_workflow_permissions: string }>( + "/actions/permissions/workflow", + "the default workflow token's permissions", + (value) => typeof value.default_workflow_permissions === "string", + ); + if (token && token.default_workflow_permissions !== "read") + problems.push( + "The default GITHUB_TOKEN can write. Set Settings → Actions → General → Workflow permissions to read repository contents and packages.", + ); + } + + if (granted.has("secrets")) { + const repository = await read("/actions/secrets?per_page=100", "repository secrets", secretList); + if (repository?.total_count) + problems.push( + `Every workflow, including those on kix's branches, receives ${count(repository.total_count, "repository secret")}: ${names(repository)}. Move them into environments limited to protected branches.`, + ); + const organization = await read( + "/actions/organization-secrets?per_page=100", + "organization secrets", + secretList, + ); + if (organization?.total_count) + problems.push( + `Every workflow, including those on kix's branches, receives ${count(organization.total_count, "organization secret")}: ${names(organization)}. Stop sharing them with this repository, or move them into environments limited to protected branches.`, + ); + } + + if (granted.has("actions") && granted.has("environments")) { + const listed = await read<{ total_count: number; environments: Environment[] }>( + "/environments?per_page=100", + "environments", + (value) => typeof value.total_count === "number" && Array.isArray(value.environments), + ); + // Read once, only when a policy names protected branches. + let protectedBranches: Promise | null> | undefined; + const branches = () => + (protectedBranches ??= read<{ name: string }[]>( + "/branches?protected=true&per_page=100", + "protected branches", + Array.isArray, + ).then((list) => list && new Set(list.map((branch) => branch.name)))); + for (const environment of listed?.environments.slice(0, ENVIRONMENTS) ?? []) { + const path = `/environments/${encodeURIComponent(environment.name)}`; + const policy = environment.deployment_branch_policy; + let reason: string | null = null; + if (!policy) reason = "lets any branch deploy"; + else if (policy.protected_branches) { + const known = await branches(); + if (known && known.size === 0) + reason = + "allows only protected branches, but no branch is protected, so GitHub lets any branch deploy"; + } else if (policy.custom_branch_policies) { + const policies = await read<{ branch_policies: Policy[] }>( + `${path}/deployment-branch-policies?per_page=100`, + `environment ${environment.name}'s deployment branches`, + (value) => Array.isArray(value.branch_policies), + ); + if (!policies) continue; + // Tags are written by people with write access, never by kix. A branch + // counts only when GitHub reports it protected, or it is the merge + // queue's own branch for one; a wildcard may match a branch kix writes. + const patterns = policies.branch_policies.filter((p) => (p.type ?? "branch") === "branch"); + const fixed = patterns.map((p) => p.name.match(QUEUE)?.[1] ?? p.name); + const known = fixed.some((name) => !WILDCARD.test(name)) ? await branches() : new Set(); + if (!known) continue; + const open = patterns.filter((_, i) => WILDCARD.test(fixed[i]!) || !known.has(fixed[i]!)); + if (open.length) + reason = `lets ${open.map((p) => p.name).join(", ")} deploy, which kix cannot confirm ${open.length === 1 ? "is a protected branch" : "are protected branches"}`; + } + if (!reason) continue; + const secrets = await read( + `${path}/secrets?per_page=100`, + `environment ${environment.name}'s secrets`, + secretList, + ); + if (secrets?.total_count) + problems.push( + `Environment ${environment.name} has ${count(secrets.total_count, "secret")} and ${reason}. Limit its deployment branches to protected branches.`, + ); + } + if (listed && listed.total_count > ENVIRONMENTS) + problems.push( + `kix checked only the first ${ENVIRONMENTS} of ${listed.total_count} environments. Limit the rest to protected branches too.`, + ); + } + return problems; +} diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index 0cdefb16..85ee3553 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -2,6 +2,7 @@ import { createHash, createPrivateKey, sign } from "node:crypto"; import { HOLD_PREFIX, isHoldLabel, latestHold, heldToken, check, isQuest, parse, ready } from "quest/core"; import { cleanAutoMergeRefusal, immediateMerge } from "../../../src/github/merge"; import { osiApproved } from "../licenses"; +import { CI_PERMISSIONS, type CIPermission, ciProblems } from "./ci"; import { unreviewed } from "./review"; import { positiveId } from "./webhook"; import { @@ -20,7 +21,7 @@ export interface GitHubRepository { name: string; id: number; } -type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses"; +type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses" | CIPermission; type Permissions = Partial>; /** What reading a project's observations needs. */ const observing: Permissions = { @@ -148,6 +149,8 @@ export type ProjectPreview = { licenseError: string | null; quests: { path: string; title: string }[]; state: ContributionState | null; + /** What lets pull-request CI write (`./ci.ts`); empty when nothing does. */ + ci: string[]; }; type Tree = { truncated: boolean; tree: { path: string; type: string; mode: string; sha: string }[] }; @@ -263,10 +266,12 @@ export class GitHubService { } } + /** `optional` permissions join the token when the installation grants them; `action` sees the token's scope. */ async #withInstallation( repo: Pick & { id?: number }, permissions: Permissions, - action: (token: string, installationId: number) => Promise, + action: (token: string, installationId: number, scope: Permissions) => Promise, + optional: Permissions = {}, ) { if (!name(repo.owner) || !name(repo.name)) throw new GitHubError("Invalid repository"); const path = `/repos/${repo.owner}/${repo.name}`; @@ -286,6 +291,10 @@ export class GitHubService { if (!granted || (level === "write" && granted !== "write")) throw new GitHubError(`GitHub ${permission} permission required`); } + for (const [permission, level] of Object.entries(optional)) { + const granted = installation.permissions[permission as Permission]; + if (granted && (level === "read" || granted === "write")) scope[permission as Permission] = level; + } const minted = await this.#api<{ token: string }>( this.#jwt(), `/app/installations/${installation.id}/access_tokens`, @@ -298,7 +307,7 @@ export class GitHubService { if (!minted.token) throw new GitHubError("Installation token unavailable"); try { await this.#gate(installation.id); - return await action(minted.token, installation.id); + return await action(minted.token, installation.id, scope); } finally { // Do not turn a completed mutation into a retryable error if cleanup // fails. GitHub also expires installation tokens after one hour. @@ -313,16 +322,22 @@ export class GitHubService { async #withRepository( repo: GitHubRepository, permissions: Permissions, - action: (token: string, current: Repository, installationId: number) => Promise, + action: (token: string, current: Repository, installationId: number, scope: Permissions) => Promise, allowUnavailable = false, + optional: Permissions = {}, ) { const path = repositoryPath(repo); - return this.#withInstallation(repo, permissions, async (token, installation) => { - const current = await this.#api(token, path); - this.#repository(repo, current, allowUnavailable); - await this.#gate(installation); - return action(token, current, installation); - }); + return this.#withInstallation( + repo, + permissions, + async (token, installation, scope) => { + const current = await this.#api(token, path); + this.#repository(repo, current, allowUnavailable); + await this.#gate(installation); + return action(token, current, installation, scope); + }, + optional, + ); } /** @@ -386,18 +401,20 @@ export class GitHubService { repo: GitHubRepository, userId: string, permissions: Permissions, - action: (token: string, current: Repository, installationId: number) => Promise, + action: (token: string, current: Repository, installationId: number, scope: Permissions) => Promise, allowUnavailable = false, + optional: Permissions = {}, ) { return this.#withRepository( repo, permissions, - async (token, current, installationId) => { + async (token, current, installationId, scope) => { await this.#maintainer(token, repo, userId); await this.#gate(installationId); - return action(token, current, installationId); + return action(token, current, installationId, scope); }, allowUnavailable, + optional, ); } @@ -700,7 +717,7 @@ export class GitHubService { repo: GitHubRepository, current: Repository, installation: number, - ): Promise { + ): Promise> { const snapshot = await this.#snapshot(token, repo, current); return { repository: repo, @@ -1096,9 +1113,21 @@ export class GitHubService { return commits.size === count && (count === 0 || commits.has(revision)); } - previewProject(repo: GitHubRepository, userId: string) { - return this.#authorized(repo, userId, { contents: "read" }, (token, current, installation) => - this.#preview(token, repo, current, installation), + /** The setup page's preview, with the pull-request CI check (`./ci.ts`) on the same token. */ + previewProject(repo: GitHubRepository, userId: string): Promise { + return this.#authorized( + repo, + userId, + { contents: "read" }, + async (token, current, installation, scope) => ({ + ...(await this.#preview(token, repo, current, installation)), + ci: await ciProblems( + { get: (path) => this.#api(token, `${repositoryPath(repo)}${path}`) }, + new Set((Object.keys(CI_PERMISSIONS) as CIPermission[]).filter((p) => scope[p])), + ), + }), + false, + CI_PERMISSIONS, ); } diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx index 6eb2acc5..9c012098 100644 --- a/cloudflare/src/projects.tsx +++ b/cloudflare/src/projects.tsx @@ -230,6 +230,32 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E )} +
+

Pull-request CI

+

+ kix publishes job branches on this repository, where GitHub gives their workflows the repository's + secrets and default token. Keep that CI read-only, so code no maintainer has reviewed meets only a + read-only GITHUB_TOKEN and no write credentials. +

+ {preview.ci.length ? ( +
    + {preview.ci.map((problem) => ( +
  • {problem}
  • + ))} +
+ ) : ( +

+ The default token is read-only, no repository or organization secrets reach workflows, and + environment secrets deploy only from protected branches. +

+ )} +

+ Jobs still run either way. kix does not read workflow files, so also keep write{" "} + permissions: and secrets out of jobs that pull requests and pushes to unprotected + branches trigger, including pull_request_target and workflow_run jobs that + check out a pull request's head. +

+

Ready quests

{props.enrollment && ( diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts index 5aa6b833..4861355e 100644 --- a/cloudflare/test/projects.test.ts +++ b/cloudflare/test/projects.test.ts @@ -4,12 +4,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { app } from "../src/index"; import { readBoard } from "../src/board/model"; import { type ActivityPage, associatedQuest, RETENTION, readActivity } from "../src/github/state"; +import { CI_PERMISSIONS, ENVIRONMENTS } from "../src/github/ci"; import { GitHubService } from "../src/github/service"; import { privateKey } from "./github-key"; import { identity, oauth, origin } from "./login-fixtures"; import { delivery } from "./project-fixtures"; const testEnv = { ...env, GITHUB_APP_PRIVATE_KEY: privateKey }; +/** A repository's Actions settings, as the CI check reads them. */ +type CISettings = { + token?: "read" | "write"; + repository?: string[]; + organization?: string[]; + /** Branches GitHub reports protected; `trunk` by default. */ + protected?: string[]; + /** `policy` is null for any branch, "protected", or branch patterns, with `tag:` marking a tag pattern. */ + environments?: { name: string; policy: null | "protected" | string[]; secrets: number }[]; + /** Paths below the repository that GitHub refuses. */ + fail?: string[]; +}; const repo = { owner: "upstream", name: "project", id: 10 }; const commit = "a".repeat(40); const tree = "b".repeat(40); @@ -65,6 +78,8 @@ function github( licenseStatus?: number; licenseFails?: boolean; onLicenseRead?: () => void; + /** The repository's Actions settings; the installation grants the CI check's permissions only with this. */ + ci?: CISettings; } = {}, ) { const repositoryName = options.repositoryName ?? repo.name; @@ -122,6 +137,7 @@ function github( pull_requests: "read", checks: "read", statuses: "read", + ...(options.ci && CI_PERMISSIONS), }, }, { status: options.missing ? 404 : 200 }, @@ -151,6 +167,56 @@ function github( } if (path.startsWith("/user/")) return Response.json({ id: Number(path.split("/").at(-1)), login: "maintainer" }); + if (options.ci && path.startsWith(`${repositoryBase}/`)) { + const ci = options.ci; + const rest = path.slice(repositoryBase.length); + const failed = ci.fail?.includes(rest) ? Response.json({ message: "Forbidden" }, { status: 403 }) : null; + const secrets = (names: string[]) => ({ + total_count: names.length, + secrets: names.map((name) => ({ name })), + }); + if (rest === "/actions/permissions/workflow") + return failed ?? Response.json({ default_workflow_permissions: ci.token ?? "read" }); + if (rest === "/actions/secrets") return failed ?? Response.json(secrets(ci.repository ?? [])); + if (rest === "/actions/organization-secrets") + return failed ?? Response.json(secrets(ci.organization ?? [])); + if (rest === "/branches") { + expect(url.searchParams.get("protected")).toBe("true"); + return failed ?? Response.json((ci.protected ?? ["trunk"]).map((name) => ({ name, protected: true }))); + } + const environments = ci.environments ?? []; + if (rest === "/environments") + return ( + failed ?? + Response.json({ + total_count: environments.length, + environments: environments.slice(0, 100).map(({ name, policy }) => ({ + name, + deployment_branch_policy: + policy === null + ? null + : { + protected_branches: policy === "protected", + custom_branch_policies: Array.isArray(policy), + }, + })), + }) + ); + const environment = rest.match(/^\/environments\/([^/]+)\/(secrets|deployment-branch-policies)$/); + const found = environment && environments.find((e) => e.name === decodeURIComponent(environment[1]!)); + if (found && environment![2] === "secrets") + return failed ?? Response.json(secrets(Array.from({ length: found.secrets }, (_, i) => `S${i}`))); + if (found && Array.isArray(found.policy)) + return ( + failed ?? + Response.json({ + total_count: found.policy.length, + branch_policies: found.policy.map((name) => + name.startsWith("tag:") ? { name: name.slice(4), type: "tag" } : { name, type: "branch" }, + ), + }) + ); + } if (path.endsWith("/permission")) { return Response.json({ user: { id: options.account ?? 7007 }, @@ -271,6 +337,113 @@ describe("project enrollment", () => { expect(calls.some((call) => call.path === `/repos/upstream/project/git/trees/${tree}?recursive=1`)).toBe(true); expect(calls.at(-1)?.path).toBe("/installation/token"); }); + it("asks for the CI check's permissions an installation has not accepted, without failing the preview", async () => { + const { service, calls } = github(); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "Accept the App's Administration (read), Secrets (read), Actions (read), Environments (read) permissions in its GitHub installation settings so kix can check the default workflow token, repository and organization secrets, environments.", + ]); + expect(calls.some((call) => call.path.includes("/actions/") || call.path.includes("/environments"))).toBe( + false, + ); + }); + it("finds nothing to fix in read-only pull-request CI", async () => { + const { service, calls } = github({ + ci: { + environments: [ + { name: "release", policy: "protected", secrets: 2 }, + { name: "land", policy: ["trunk", "gh-readonly-queue/trunk/*", "tag:v*"], secrets: 1 }, + { name: "preview", policy: null, secrets: 0 }, + ], + }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([]); + expect(calls.filter((call) => call.path.endsWith("/access_tokens")).at(-1)?.body.permissions).toEqual({ + metadata: "read", + contents: "read", + ...CI_PERMISSIONS, + }); + // Protected branches are read once however many policies name them, and + // an environment's secrets only when its policy lets an unprotected branch deploy. + expect(calls.filter((call) => call.path.startsWith("/repos/upstream/project/branches"))).toHaveLength(1); + expect(calls.filter((call) => call.path.includes("secrets?")).map((call) => call.path)).toEqual([ + "/repos/upstream/project/actions/secrets?per_page=100", + "/repos/upstream/project/actions/organization-secrets?per_page=100", + "/repos/upstream/project/environments/preview/secrets?per_page=100", + ]); + }); + it("warns about each way pull-request CI can write", async () => { + const { service } = github({ + ci: { + token: "write", + repository: ["NPM_TOKEN"], + organization: ["DISCORD_WEBHOOK", "CACHIX"], + protected: ["trunk"], + environments: [ + { name: "open", policy: null, secrets: 1 }, + { name: "wild", policy: ["release/*", "tag:v*"], secrets: 2 }, + { name: "loose", policy: ["trunk", "staging"], secrets: 1 }, + { name: "queue", policy: ["gh-readonly-queue/staging/*"], secrets: 1 }, + ], + }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "The default GITHUB_TOKEN can write. Set Settings → Actions → General → Workflow permissions to read repository contents and packages.", + "Every workflow, including those on kix's branches, receives 1 repository secret: NPM_TOKEN. Move them into environments limited to protected branches.", + "Every workflow, including those on kix's branches, receives 2 organization secrets: DISCORD_WEBHOOK, CACHIX. Stop sharing them with this repository, or move them into environments limited to protected branches.", + "Environment open has 1 secret and lets any branch deploy. Limit its deployment branches to protected branches.", + "Environment wild has 2 secrets and lets release/* deploy, which kix cannot confirm is a protected branch. Limit its deployment branches to protected branches.", + "Environment loose has 1 secret and lets staging deploy, which kix cannot confirm is a protected branch. Limit its deployment branches to protected branches.", + "Environment queue has 1 secret and lets gh-readonly-queue/staging/* deploy, which kix cannot confirm is a protected branch. Limit its deployment branches to protected branches.", + ]); + }); + it("warns that a protected-branches policy admits every branch while none is protected", async () => { + const { service } = github({ + ci: { protected: [], environments: [{ name: "deploy", policy: "protected", secrets: 1 }] }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "Environment deploy has 1 secret and allows only protected branches, but no branch is protected, so GitHub lets any branch deploy. Limit its deployment branches to protected branches.", + ]); + }); + it("reports a failed CI read as a warning and checks the rest", async () => { + const { service } = github({ + ci: { repository: ["TOKEN"], fail: ["/actions/permissions/workflow", "/environments"] }, + }); + const preview = await service.previewProject(repo, "enrollment-test"); + expect(preview.quests).toHaveLength(1); + expect(preview.ci).toEqual([ + "kix could not read the default workflow token's permissions; review this project again later.", + "Every workflow, including those on kix's branches, receives 1 repository secret: TOKEN. Move them into environments limited to protected branches.", + "kix could not read environments; review this project again later.", + ]); + }); + it("checks a bounded number of environments within Workers Free's 50 external subrequests", async () => { + const environments = Array.from({ length: ENVIRONMENTS + 3 }, (_, i) => ({ + name: `env-${i}`, + policy: ["feature/*"], + secrets: 1, + })); + const { service, calls } = github({ ci: { environments }, subrequestLimit: 50 }); + await service.resolveRepository(repo.owner, repo.name); + const { ci } = await service.previewProject(repo, "enrollment-test"); + expect(ci).toHaveLength(ENVIRONMENTS + 1); + expect(ci.at(-1)).toBe( + `kix checked only the first ${ENVIRONMENTS} of ${ENVIRONMENTS + 3} environments. Limit the rest to protected branches too.`, + ); + expect(calls.length).toBeLessThanOrEqual(50); + }); + it("shows the CI check on the project's setup page", async () => { + const who = identity("github"); + const { session } = await oauth(who); + const { request } = github({ account: Number(who.id), ci: { token: "write" } }); + vi.spyOn(globalThis, "fetch").mockImplementation(request as typeof fetch); + const html = await ( + await app.request(`${origin}/projects/github/upstream/project`, { headers: { Cookie: session! } }, testEnv) + ).text(); + expect(html).toContain("Pull-request CI"); + expect(html).toContain("The default GITHUB_TOKEN can write."); + // Warnings never block enabling contributions. + expect(html).not.toContain("disabled"); + }); it.each(["AGENTS.md", "CLAUDE.md"])("ignores root and nested %s instructions like the CLI", async (filename) => { const { service, calls } = github({ documents: [ From e2fe7ff63f0afca697f305e2ce7fee048e5fafb2 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:53:46 -0800 Subject: [PATCH 3/8] docs(kix): write down trust by stage and the read-only CI rule Co-Authored-By: Claude Opus 5.5 --- README.md | 5 +- SETUP.md | 11 +++++ cloudflare/GITHUB.md | 86 +++++++++++++++++++++++++++++----- cloudflare/README.md | 8 +++- cloudflare/dev/github.ts | 31 ++++++++++-- quest/m0/kix/operator-setup.md | 11 +++++ quest/m1/kix/README.md | 1 - quest/m1/kix/ci-read-only.md | 58 ----------------------- 8 files changed, 131 insertions(+), 80 deletions(-) delete mode 100644 quest/m1/kix/ci-read-only.md diff --git a/README.md b/README.md index 31c9c53c..ff599305 100644 --- a/README.md +++ b/README.md @@ -1115,8 +1115,9 @@ pull request reads it through `context`, since the runner holds no GitHub token; a new plan reads your idea and pasted context there. kix publishes as its GitHub App, so the runner never pushes. A job that hits an account's limit pauses for you to pick another account; the runner never switches on its own. On SIGINT or SIGTERM, running work is published and its jobs move to another -runner. Runners take only trusted revisions until they can build untrusted -heads' devShells in a VM. A pull request's head is trusted only when every +runner. `quest runner` takes only trusted revisions until it can build +untrusted heads' devShells in a VM: it registers without declaring VM builds, +so kix routes it no other job, and it refuses one if sent. A pull request's head is trusted only when every commit beyond the default branch has a valid signature from a current maintainer, so sign your commits (for example with [SSH signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#ssh-commit-signature-verification)) diff --git a/SETUP.md b/SETUP.md index ff211298..fd7246cc 100644 --- a/SETUP.md +++ b/SETUP.md @@ -81,6 +81,17 @@ job the condition `github.event_name != 'pull_request' || github.event.pull_request.draft == false`. Change workflows only if the user agrees. +Agents push their quest branches to the repository itself, and GitHub gives +workflows on those branches the repository's secrets and default token, so +recommend keeping that CI read-only: code no maintainer has reviewed should +meet only a read-only `GITHUB_TOKEN` and no write credentials. In GitHub, set +Settings → Actions → General → Workflow permissions to read; move repository +secrets, and organization secrets shared with the repository, into +environments whose deployment branches are limited to protected branches; and +keep write `permissions:` out of jobs that pull requests and pushes to other +branches run. kix checks the same settings when a repository enrolls. +Change settings and workflows only if the user agrees. + Run `quest check`, then suggest next steps: restart the agent session so it loads the new skills, then `/quest-plan` (`$quest-plan` in Codex) to plan work or `/quest-import` to bring in open GitHub issues. diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index 2f722846..fda047a7 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -16,6 +16,10 @@ and installation tokens stay inside the Worker; agents receive neither. | Pull requests: write | Observe, open and update job PRs, explicitly merge an exact head | Read or write for that operation | | Checks: read | Observe checks on a commit | Check reads only | | Commit statuses: read | Observe legacy CI status contexts | Check reads only | +| Administration: read | Read whether the default workflow token can write | The setup page's [CI check](#read-only-pull-request-ci) only | +| Secrets: read | List the names of Actions secrets that reach every workflow | The setup page's CI check only | +| Actions: read | List environments and their deployment branch policies | The setup page's CI check only | +| Environments: read | Count each environment's secrets | The setup page's CI check only | The manifest subscribes to push, issues, issue comments, pull requests, reviews, review threads, inline review comments, checks and statuses. Existing App registrations @@ -26,12 +30,32 @@ user permission, which Better Auth needs to sign in users whose email is private It grants no repository authority: the user token is used only for the sign-in profile and email reads and to list the [project picker](#project-enrollment)'s repositories, and never for repository writes or authority checks. No organization, -administration, workflow, or checks-write permissions are requested. These installation grants +administration-write, workflow, or checks-write permissions are requested, and +GitHub never returns a secret's value, only its name. These installation grants are necessarily broader than individual operations: minted tokens always name one repository ID and the particular permissions required. Tokens are never cached and are revoked after use; failed cleanup is logged without secrets and does not retry a successful write. GitHub also expires tokens after an hour. +## Trust by stage + +Each stage of a job trusts as little as it can. Untrusted text, such as an +issue, a pull request, a comment, a prompt answer, or anything an agent writes, +is data at every stage: it never grants authority, approves work, or changes +what kix may write. + +| Stage | What is trusted | Why | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Intake | GitHub's live answers to kix's own reads. A webhook only asks for a read; its signature proves GitHub sent it, not that its contents are current | Deliveries arrive late, out of order, or not at all, and bodies are anyone's text ([webhooks](#webhooks-and-revocation), [project state](#project-state-and-activity)) | +| Job creation | The signed-in maintainer's request, after a live check of their maintain or admin role, bound to the revision or head the maintainer saw | A browser's claims grant nothing, and a head that moved since observations read it is refused ([README](README.md)) | +| Routing | The requester's own runners, and their owner's word for each runner. Untrusted heads go only to Linux runners whose owner declared VM builds; kix does not verify the flag | A runner is its owner's machine and risk, and jobs never reach another user's runner | +| devShell | A trusted revision: the default branch, or a pull request head whose every commit beyond the default branch carries a valid signature from a current maintainer. A head kix pushed to is never trusted | The runner's host Nix evaluates the flake outside the sandbox, and "kix published it" proves only that a runner sent it ([head approval](../quest/m0/kix/sandbox/head-approval.md) plans an explicit per-head approval) | +| Agent run | Nothing the agent does. Its commands run in an OS sandbox with no GitHub credential, and it acts on an answer only once the runner's owner gave or approved it | Issue and pull request text can inject prompts ([README](../README.md)) | +| Publication | Nothing in the submitted files. kix writes them as Git data to the job's branch or the same-repository head it iterates, refuses workflow files and the default branch, and fences out stale attempts | A prompt-injected agent or a compromised runner can send anything ([Publication](#publication)) | +| CI | The enrolled repository's settings keep pull-request CI read-only. A runner's request to mark its pull request ready is its owner's statement, and under read-only CI it starts only harmless checks | GitHub gives same-repository branches' workflows the repository's secrets and default token, unlike fork pull requests ([Read-only pull-request CI](#read-only-pull-request-ci)) | +| Review | A review that counts on the exact head: a writer's approval, the App's `quest review` check, or the author's own `## Review` record on a commit no App made. An agent's recommendation never counts | Picking merge approves the head the maintainer saw, not whatever is pushed after it ([Landing only reviewed heads](#landing-only-reviewed-heads)) | +| Land | The maintainer's explicit pick of that head, under GitHub's branch rules and checks | kix never writes the default branch itself; GitHub's refusals stand ([Merge jobs](#merge-jobs)) | + ## Trusted boundary `src/github/service.ts` provides specific server-only operations for quest @@ -240,8 +264,9 @@ An author's record carries only across merges no App committed. kix defers to GitHub only where GitHub enforces the same rule: a ruleset on the base that requires an approval and either dismisses stale approvals or requires approval of the most recent push, which GitHub reports this App cannot bypass. -Classic branch protection's review settings need the administration permission -to read, so kix keeps its own check there. +Classic branch protection's review settings need the administration +permission, which landing tokens do not carry, so kix keeps its own check +there. When no review counts, a direct merge is refused and a merge job pauses on a decision. Both say which commit needs a review and how to give one. @@ -263,15 +288,45 @@ one, kix turns that pull request's auto-merge off. If GitHub refuses or does not show whether it is on, the job pushes nothing and pauses on a decision. The new head lands only through a new landing. +### Read-only pull-request CI + kix pull requests come from upstream branches, and GitHub gives workflows on -same-repository branches the repository's secrets, unlike fork pull requests. -So enrolled repositories must keep pull-request CI read-only: a read-only -default `GITHUB_TOKEN`, no write secrets at repository level, and write -credentials only in environments limited to protected branches. Agent-written -code then runs in CI before review with nothing it can write through. -`pull_request_target` and `workflow_run` jobs that check out a PR head fall -under the same rule. kix never runs pull request code in the publisher and -grants runners no GitHub credentials. +same-repository branches the repository's secrets and default token, unlike +fork pull requests. Pushing a job branch also runs its `push` workflows. So +enrolled repositories must keep that CI read-only: a read-only default +`GITHUB_TOKEN`, no secrets at repository or organization level, and secrets +only in environments limited to protected branches. Agent-written code then +runs in CI before review with nothing it can write through. Jobs whose +workflow asks for write `permissions:` itself, and `pull_request_target` and +`workflow_run` jobs that check out a PR head, fall under the same rule. kix +never runs pull request code in the publisher and grants runners no GitHub +credentials. This rule, rather than a service fork or a review gate on every +push, is kix's answer to agent-written code reaching CI. + +The project's setup page, where a maintainer enables contributions and later +pauses them, checks the repository's settings with the installation's token +(`src/github/ci.ts`) and lists each problem it finds: + +- the default workflow token can write; +- Actions secrets at repository level, or organization secrets shared with the + repository, by name; +- an environment with secrets whose deployment branches include one GitHub + does not report protected. + +An environment limited to protected branches passes while some branch is +protected, since GitHub lets every branch deploy when none is. One limited to +selected branches passes when each branch pattern names a protected branch, +or the merge queue's `gh-readonly-queue//*` for one; a wildcard could +match a branch kix writes, so it is reported. Tag patterns pass, since kix +never writes tags. Required reviewers on an environment do not count: a +reviewer approves a deployment, not the code a job branch carries. + +The check only warns: enabling still works, and jobs still run. A failed read +becomes a warning of its own. It checks the first 12 environments, so the page +stays within a Worker's subrequest budget, and says when there are more. It +does not read workflow files, so the page reminds the maintainer of the rest +of the rule. An installation that has not accepted the check's permissions +gets a warning naming them instead of the checks they cover. Installation grants are broader than any one publication: `contents: write` and `pull_requests: write` cover every installed repository. Each token names @@ -337,11 +392,16 @@ reconcile current provider state rather than replay issue/PR payloads as facts. those grants become available. GitHub's installation UI, not browser form claims, chooses repository access. Enroll each repository on `/projects`; jobs publish their branches there, with no fork setup. -4. Run `bun run wrangler d1 migrations apply DB --remote`, then the normal +4. Existing App registrations must add the **Administration**, **Secrets**, + **Actions**, and **Environments** repository permissions, each read-only, + which the setup page's [CI check](#read-only-pull-request-ci) reads with. + Each installation then accepts them on GitHub; until it does, the check + names them instead of running. +5. Run `bun run wrangler d1 migrations apply DB --remote`, then the normal Worker deployment from [README](README.md). GitHub App setup, credentials, production deployment and live installation tests are operator work; this change performs none of those external actions. -5. Send a GitHub ping, check successful delivery, then test installation +6. Send a GitHub ping, check successful delivery, then test installation suspension/removal and repository deselection in a development installation. Check that service operations reject revoked access and reduced permissions. Use GitHub's delivery/redelivery UI to retry failed deliveries. Rotate the diff --git a/cloudflare/README.md b/cloudflare/README.md index c05e060c..a570ff66 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -95,7 +95,10 @@ every commit beyond the repository's default branch, independently of the PR's chosen base, has a valid signature from a current maintain or admin collaborator. The check runs at creation and again at assignment. Other heads, including unsigned ones and those kix pushed to, route only to Linux -runners that declare VM builds. A `merge` job runs the merge +runners whose owner declared VM builds when registering them, a statement kix +does not verify ([trust by stage](GITHUB.md#trust-by-stage)). `quest runner` +cannot build in a VM yet: it never declares one and refuses an untrusted job, +so such jobs wait for a runner that does. A `merge` job runs the merge skill's loop on an open, non-draft pull request and lands it; it holds the PR with its own `quest:kix` label, which local sessions respect as they do each other's `quest:active` (`src/jobs/landing.ts`), as described in @@ -110,7 +113,8 @@ on the machine and prints a link to `/runners?name=…&platform=…&key=…`, wh `key` is the token's SHA-256; the signed-in user approves it there (`POST /runners/connect`), and the runner polls `GET /api/runner` with its token until it is accepted. A script can instead register at `POST /api/runners` and -get a token back. Either way kix stores only the hash. With that token a +get a token back, declaring `vm: true` if it builds untrusted heads in a VM. +Either way kix stores only the hash. With that token a runner opens a WebSocket at `/api/runner/connect`. `/runners`, linked from the decision inbox, lists the user's runners with their presence and accounts, and moves an account earlier or later in the preference order diff --git a/cloudflare/dev/github.ts b/cloudflare/dev/github.ts index b988b536..54934a9c 100644 --- a/cloudflare/dev/github.ts +++ b/cloudflare/dev/github.ts @@ -561,7 +561,17 @@ function workflowsOf(repo: Repo, sha: string): Record { return files; } -type Permission = "metadata" | "contents" | "issues" | "pull_requests" | "checks" | "statuses" | "administration"; +type Permission = + | "metadata" + | "contents" + | "issues" + | "pull_requests" + | "checks" + | "statuses" + | "administration" + | "secrets" + | "actions" + | "environments"; /** What an installation token may do: the repositories (`owner/name`) and permissions it was minted for. */ type Grant = { repositories: Set; permissions: Partial> }; @@ -729,9 +739,12 @@ export function fakeGitHub(world: World) { if (match && tail === "/installation") { if (!app) return unauthorized(); if (!repo) return notFound(); - const permissions = Object.fromEntries( - ["metadata", "contents", "issues", "pull_requests", "checks", "statuses"].map((p) => [p, "write"]), - ); + const permissions = { + ...Object.fromEntries( + ["metadata", "contents", "issues", "pull_requests", "checks", "statuses"].map((p) => [p, "write"]), + ), + ...Object.fromEntries(["administration", "secrets", "actions", "environments"].map((p) => [p, "read"])), + }; return json({ id: world.installation, app_id: world.app.id, suspended_at: null, permissions }); } if (!scope) return unauthorized(); @@ -897,6 +910,16 @@ export function fakeGitHub(world: World) { if (!scope.permissions.administration) return forbidden(); return repo.protection ? json(repo.protection) : json({ message: "Branch not protected" }, 404); } + // The setup page's CI check finds read-only pull-request CI: a read-only + // default token, no secrets, and no environments. + if (tail === "/actions/permissions/workflow" && method === "GET") + return scope.permissions.administration + ? json({ default_workflow_permissions: "read", can_approve_pull_request_reviews: false }) + : forbidden(); + if ((tail === "/actions/secrets" || tail === "/actions/organization-secrets") && method === "GET") + return scope.permissions.secrets ? json({ total_count: 0, secrets: [] }) : forbidden(); + if (tail === "/environments" && method === "GET") + return scope.permissions.actions ? json({ total_count: 0, environments: [] }) : forbidden(); const ruleset = tail.match(/^\/rulesets\/(\d+)$/); if (ruleset && method === "GET") { const found = repo.rulesets?.[Number(ruleset[1])]; diff --git a/quest/m0/kix/operator-setup.md b/quest/m0/kix/operator-setup.md index 587f32e5..38595f82 100644 --- a/quest/m0/kix/operator-setup.md +++ b/quest/m0/kix/operator-setup.md @@ -34,6 +34,17 @@ bounded test PR verify the integration before contributors are invited. [moq-dev/quest#190](https://github.com/moq-dev/quest/pull/190) handles either way (adopt it if open, open a new one if closed) but tests only against the fake. Added 2026-10-22 (user). +- Each pilot's setup page shows no Pull-request CI warning before + contributors are invited (Read-only pull-request CI in + `cloudflare/GITHUB.md`; moved from the read-only CI quest on 2026-11-02). + Read with `gh api` that day: both default workflow tokens are read-only, + but the moq-dev organization shares its `DISCORD_WEBHOOK` secret with both + pilots, and moq-dev/moq keeps 18 repository secrets, including the + `MBX_R2_*` pair its `check.yml` passes to `pull_request` checks; moq-dev/moq + tracks moving them in + [read-only secrets](https://github.com/moq-dev/moq/blob/main/quest/m0/ci/read-only-secrets.md). + Its environments name only protected branches, its merge queue's, and tags. + Accept the App's new CI-check permissions on both installations first. - Rehearse on the operator's own runner and an explicitly selected operator account. - Record nonsecret setup/run instructions and deployment identifiers. diff --git a/quest/m1/kix/README.md b/quest/m1/kix/README.md index 0ba6942e..add2dc96 100644 --- a/quest/m1/kix/README.md +++ b/quest/m1/kix/README.md @@ -16,7 +16,6 @@ maintainers, so they need none of this first. ## Required - [Setup PR](/quest/m1/kix/setup-pr.md) - enrolling a repository without Quest makes the App open its `quest init` pull request -- [Read-only CI](/quest/m1/kix/ci-read-only.md) - enrolled repositories keep pull-request CI read-only, checked at enrollment, with every trust boundary written down - [Contributor sandboxing](/quest/m1/kix/sandbox/README.md) - contributors' PR heads build in a VM, on stock Ubuntu and verified on macOS - [Onboarding guide](/quest/m1/kix/guide.md) - a complete maintainer path, from runner setup through a reviewed PR - [Missing review flag](/quest/m1/kix/review-flag.md) - the inbox shows a PR's missing review before the merge click diff --git a/quest/m1/kix/ci-read-only.md b/quest/m1/kix/ci-read-only.md deleted file mode 100644 index 72c63192..00000000 --- a/quest/m1/kix/ci-read-only.md +++ /dev/null @@ -1,58 +0,0 @@ -# [M] Require read-only pull-request CI - -## Goal - -Every kix trust boundary is written down, and enrolled repositories keep -pull-request CI read-only. kix publishes job branches on the repository -itself, where GitHub gives a branch's workflows the repository's secrets and -default token. So code no maintainer has reviewed must meet only a read-only -`GITHUB_TOKEN` and no write credentials. kix checks this when a repository is -enrolled and warns the maintainer about what to fix. `cloudflare/GITHUB.md` -states the trust rule for each stage of a job. - -## Plan - -Decided 2026-10-28 (user, /quest-plan trust audit started from -moq-dev/quest#246's Codex Security P1): - -- **Rule over gates.** Read-only CI replaces a service fork or a per-push - review gate as the answer to agent-written code reaching CI. Fork PRs - already get no secrets; same-repository branches need the repository's own - settings to match. The guide states the rule for agents - ([moq-dev/quest#263](https://github.com/moq-dev/quest/pull/263)). -- **Check and warn, don't refuse.** At enrollment, and on the project's - settings page, kix reports each problem it finds: - - the default workflow token can write; - - Actions secrets at repository level; - - an environment with secrets but no protected-branch deployment policy. - - Jobs still run. Check which App permission reads secret and environment - metadata; adding one is an operator step in `cloudflare/GITHUB.md`. -- **Runner claims are the owner's attestation.** Jobs go only to the - requester's own runners, so a runner's self-declared `vm` flag and its - request to mark a PR ready are its owner's statements. kix doesn't verify - them. Under read-only CI, a runner marking its PR ready starts only harmless - CI. -- Rewrite `cloudflare/GITHUB.md`'s trust section as a stage-by-stage table: - intake, job creation, routing, devShell, agent run, publication, CI, review, - and land. For each stage, name what is trusted and why. Fix the - contradiction between `cloudflare/README.md` ("route only to Linux runners - that declare VM builds") and the root README (every runner refuses untrusted - jobs). -- `SETUP.md` recommends the same settings when a repository adopts Quest, - beside its advice to skip CI on drafts. -- The pilot repositories pass the check before contributors are invited: - moq-dev/quest's `land` environment was limited to `main` on 2026-10-28, - and moq-dev/moq tracks its fix in - [read-only secrets](https://github.com/moq-dev/moq/blob/main/quest/m0/ci/read-only-secrets.md). -- Noted 2026-11-01 (user, /quest-plan, from - [moq-dev/quest#246](https://github.com/moq-dev/quest/pull/246)): - moq-dev/moq's `.github/workflows/check.yml` passes the - `MBX_R2_ACCESS_KEY_ID` and `MBX_R2_SECRET_ACCESS_KEY` secrets to its - `pull_request` checks. They must move to an environment limited to - protected branches before moq enrolls. - -## Related - -- [Operator setup](/quest/m0/kix/operator-setup.md) - pilot repositories should pass this check -- [Head approval](/quest/m0/kix/sandbox/head-approval.md) - the devShell stage's trust rule for heads without signatures From 2f6e0163321e0cd76a7ba80f56139baf84d873cd Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 19:55:50 -0800 Subject: [PATCH 4/8] docs(kix): format the trust table Co-Authored-By: Claude Opus 5.5 --- cloudflare/GITHUB.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index fda047a7..0e338891 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -51,7 +51,7 @@ what kix may write. | Routing | The requester's own runners, and their owner's word for each runner. Untrusted heads go only to Linux runners whose owner declared VM builds; kix does not verify the flag | A runner is its owner's machine and risk, and jobs never reach another user's runner | | devShell | A trusted revision: the default branch, or a pull request head whose every commit beyond the default branch carries a valid signature from a current maintainer. A head kix pushed to is never trusted | The runner's host Nix evaluates the flake outside the sandbox, and "kix published it" proves only that a runner sent it ([head approval](../quest/m0/kix/sandbox/head-approval.md) plans an explicit per-head approval) | | Agent run | Nothing the agent does. Its commands run in an OS sandbox with no GitHub credential, and it acts on an answer only once the runner's owner gave or approved it | Issue and pull request text can inject prompts ([README](../README.md)) | -| Publication | Nothing in the submitted files. kix writes them as Git data to the job's branch or the same-repository head it iterates, refuses workflow files and the default branch, and fences out stale attempts | A prompt-injected agent or a compromised runner can send anything ([Publication](#publication)) | +| Publication | Nothing in the submitted files. kix writes them as Git data to the job's branch or the same-repository head it iterates, refuses workflow files and the default branch, and fences out stale attempts | A prompt-injected agent or a compromised runner can send anything ([Publication](#publication)) | | CI | The enrolled repository's settings keep pull-request CI read-only. A runner's request to mark its pull request ready is its owner's statement, and under read-only CI it starts only harmless checks | GitHub gives same-repository branches' workflows the repository's secrets and default token, unlike fork pull requests ([Read-only pull-request CI](#read-only-pull-request-ci)) | | Review | A review that counts on the exact head: a writer's approval, the App's `quest review` check, or the author's own `## Review` record on a commit no App made. An agent's recommendation never counts | Picking merge approves the head the maintainer saw, not whatever is pushed after it ([Landing only reviewed heads](#landing-only-reviewed-heads)) | | Land | The maintainer's explicit pick of that head, under GitHub's branch rules and checks | kix never writes the default branch itself; GitHub's refusals stand ([Merge jobs](#merge-jobs)) | From 7bac40edb59929ccbd744167590413b1b86fed70 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 20:03:48 -0800 Subject: [PATCH 5/8] fix(kix): look up protected branches beyond GitHub's first page Co-Authored-By: Claude Opus 5.5 --- cloudflare/src/github/ci.ts | 70 ++++++++++++++++++++++++-------- cloudflare/test/projects.test.ts | 57 +++++++++++++++++++++++--- 2 files changed, 106 insertions(+), 21 deletions(-) diff --git a/cloudflare/src/github/ci.ts b/cloudflare/src/github/ci.ts index 01273ce8..43fedf32 100644 --- a/cloudflare/src/github/ci.ts +++ b/cloudflare/src/github/ci.ts @@ -113,41 +113,79 @@ export async function ciProblems(reads: CIReads, granted: ReadonlySet typeof value.total_count === "number" && Array.isArray(value.environments), ); - // Read once, only when a policy names protected branches. - let protectedBranches: Promise | null> | undefined; - const branches = () => - (protectedBranches ??= read<{ name: string }[]>( + // GitHub's first page of protected branches, read once and only when a + // policy names protected branches. A full page may leave some out, so a + // branch missing from it is looked up by name. + let page: Promise<{ names: Set; complete: boolean } | null> | undefined; + const protectedPage = () => + (page ??= read<{ name: string }[]>( "/branches?protected=true&per_page=100", "protected branches", Array.isArray, - ).then((list) => list && new Set(list.map((branch) => branch.name)))); + ).then( + (list) => list && { names: new Set(list.map((branch) => branch.name)), complete: list.length < 100 }, + )); + const lookups = new Map>(); + /** Whether GitHub reports the branch protected, or null when kix could not tell. */ + const isProtected = async (name: string): Promise => { + const known = await protectedPage(); + if (!known) return null; + if (known.names.has(name) || known.complete) return known.names.has(name); + let lookup = lookups.get(name); + if (!lookup) + lookups.set( + name, + (lookup = (async () => { + try { + const branch = await reads.get<{ protected?: unknown }>( + `/branches/${name.split("/").map(encodeURIComponent).join("/")}`, + ); + if (typeof branch?.protected === "boolean") return branch.protected; + } catch (error) { + // A branch that does not exist is not protected. + if ((error as { status?: unknown }).status === 404) return false; + } + problems.push(`kix could not read branch ${name}; review this project again later.`); + return null; + })()), + ); + return lookup; + }; for (const environment of listed?.environments.slice(0, ENVIRONMENTS) ?? []) { const path = `/environments/${encodeURIComponent(environment.name)}`; const policy = environment.deployment_branch_policy; let reason: string | null = null; if (!policy) reason = "lets any branch deploy"; else if (policy.protected_branches) { - const known = await branches(); - if (known && known.size === 0) + const known = await protectedPage(); + if (known && known.names.size === 0) reason = "allows only protected branches, but no branch is protected, so GitHub lets any branch deploy"; } else if (policy.custom_branch_policies) { - const policies = await read<{ branch_policies: Policy[] }>( + const policies = await read<{ total_count: number; branch_policies: Policy[] }>( `${path}/deployment-branch-policies?per_page=100`, `environment ${environment.name}'s deployment branches`, - (value) => Array.isArray(value.branch_policies), + (value) => typeof value.total_count === "number" && Array.isArray(value.branch_policies), ); if (!policies) continue; // Tags are written by people with write access, never by kix. A branch // counts only when GitHub reports it protected, or it is the merge // queue's own branch for one; a wildcard may match a branch kix writes. - const patterns = policies.branch_policies.filter((p) => (p.type ?? "branch") === "branch"); - const fixed = patterns.map((p) => p.name.match(QUEUE)?.[1] ?? p.name); - const known = fixed.some((name) => !WILDCARD.test(name)) ? await branches() : new Set(); - if (!known) continue; - const open = patterns.filter((_, i) => WILDCARD.test(fixed[i]!) || !known.has(fixed[i]!)); - if (open.length) - reason = `lets ${open.map((p) => p.name).join(", ")} deploy, which kix cannot confirm ${open.length === 1 ? "is a protected branch" : "are protected branches"}`; + const open: string[] = []; + let unknown = false; + for (const pattern of policies.branch_policies) { + if ((pattern.type ?? "branch") !== "branch") continue; + const branch = pattern.name.match(QUEUE)?.[1] ?? pattern.name; + const safe = WILDCARD.test(branch) ? false : await isProtected(branch); + // The failed read is already a warning. + if (safe === null) unknown = true; + else if (!safe) open.push(pattern.name); + } + if (unknown) continue; + if (policies.total_count > policies.branch_policies.length) + reason = `lists ${policies.total_count} deployment branch and tag patterns, more than kix reads`; + else if (open.length) + reason = `lets ${open.join(", ")} deploy, which kix cannot confirm ${open.length === 1 ? "is a protected branch" : "are protected branches"}`; } if (!reason) continue; const secrets = await read( diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts index 4861355e..dce61023 100644 --- a/cloudflare/test/projects.test.ts +++ b/cloudflare/test/projects.test.ts @@ -16,8 +16,10 @@ type CISettings = { token?: "read" | "write"; repository?: string[]; organization?: string[]; - /** Branches GitHub reports protected; `trunk` by default. */ + /** Branches GitHub reports protected, listing the first hundred; `trunk` by default. */ protected?: string[]; + /** Other branches that exist. */ + branches?: string[]; /** `policy` is null for any branch, "protected", or branch patterns, with `tag:` marking a tag pattern. */ environments?: { name: string; policy: null | "protected" | string[]; secrets: number }[]; /** Paths below the repository that GitHub refuses. */ @@ -180,9 +182,19 @@ function github( if (rest === "/actions/secrets") return failed ?? Response.json(secrets(ci.repository ?? [])); if (rest === "/actions/organization-secrets") return failed ?? Response.json(secrets(ci.organization ?? [])); + const protectedBranches = ci.protected ?? ["trunk"]; if (rest === "/branches") { expect(url.searchParams.get("protected")).toBe("true"); - return failed ?? Response.json((ci.protected ?? ["trunk"]).map((name) => ({ name, protected: true }))); + return ( + failed ?? Response.json(protectedBranches.slice(0, 100).map((name) => ({ name, protected: true }))) + ); + } + const branch = rest.match(/^\/branches\/(.+)$/)?.[1]; + if (branch !== undefined) { + const name = branch.split("/").map(decodeURIComponent).join("/"); + if (!protectedBranches.includes(name) && !ci.branches?.includes(name)) + return Response.json({ message: "Branch not found" }, { status: 404 }); + return failed ?? Response.json({ name, protected: protectedBranches.includes(name) }); } const environments = ci.environments ?? []; if (rest === "/environments") @@ -211,9 +223,13 @@ function github( failed ?? Response.json({ total_count: found.policy.length, - branch_policies: found.policy.map((name) => - name.startsWith("tag:") ? { name: name.slice(4), type: "tag" } : { name, type: "branch" }, - ), + branch_policies: found.policy + .slice(0, 100) + .map((name) => + name.startsWith("tag:") + ? { name: name.slice(4), type: "tag" } + : { name, type: "branch" }, + ), }) ); } @@ -396,6 +412,37 @@ describe("project enrollment", () => { "Environment queue has 1 secret and lets gh-readonly-queue/staging/* deploy, which kix cannot confirm is a protected branch. Limit its deployment branches to protected branches.", ]); }); + it("looks up a named branch that a full page of protected branches leaves out", async () => { + const many = Array.from({ length: 100 }, (_, i) => `keep/${i}`); + const { service, calls } = github({ + ci: { + protected: [...many, "release/v1"], + branches: ["staging"], + environments: [ + { name: "ship", policy: ["release/v1", "keep/0"], secrets: 1 }, + { name: "stage", policy: ["staging", "missing"], secrets: 1 }, + { name: "again", policy: ["release/v1"], secrets: 1 }, + ], + }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "Environment stage has 1 secret and lets staging, missing deploy, which kix cannot confirm are protected branches. Limit its deployment branches to protected branches.", + ]); + // Each branch the page left out is looked up once. + expect( + calls.filter((call) => /\/branches\/./.test(call.path)).map((call) => call.path.split("/branches/")[1]), + ).toEqual(["release/v1", "staging", "missing"]); + }); + it("does not confirm a deployment policy with more patterns than one page holds", async () => { + const { service } = github({ + ci: { + environments: [{ name: "big", policy: Array.from({ length: 101 }, () => "trunk"), secrets: 1 }], + }, + }); + expect((await service.previewProject(repo, "enrollment-test")).ci).toEqual([ + "Environment big has 1 secret and lists 101 deployment branch and tag patterns, more than kix reads. Limit its deployment branches to protected branches.", + ]); + }); it("warns that a protected-branches policy admits every branch while none is protected", async () => { const { service } = github({ ci: { protected: [], environments: [{ name: "deploy", policy: "protected", secrets: 1 }] }, From 445647fb24104329ad66750c583e6249d7b2ed1c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 2 Nov 2026 20:06:19 -0800 Subject: [PATCH 6/8] fix(kix): bound the CI check by one read budget Co-Authored-By: Claude Opus 5.5 --- cloudflare/GITHUB.md | 5 ++-- cloudflare/src/github/ci.ts | 40 ++++++++++++++++++------- cloudflare/test/projects.test.ts | 50 +++++++++++++++++++++++++------- 3 files changed, 72 insertions(+), 23 deletions(-) diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index 0e338891..c1e7dad3 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -322,8 +322,9 @@ never writes tags. Required reviewers on an environment do not count: a reviewer approves a deployment, not the code a job branch carries. The check only warns: enabling still works, and jobs still run. A failed read -becomes a warning of its own. It checks the first 12 environments, so the page -stays within a Worker's subrequest budget, and says when there are more. It +becomes a warning of its own. It makes at most 30 reads, so the page stays +within a Worker's subrequest budget, and says when it stopped before checking +every environment. It does not read workflow files, so the page reminds the maintainer of the rest of the rule. An installation that has not accepted the check's permissions gets a warning naming them instead of the checks they cover. diff --git a/cloudflare/src/github/ci.ts b/cloudflare/src/github/ci.ts index 43fedf32..efc6cf81 100644 --- a/cloudflare/src/github/ci.ts +++ b/cloudflare/src/github/ci.ts @@ -18,10 +18,13 @@ export type CIPermission = keyof typeof CI_PERMISSIONS; export type CIReads = { get(path: string): Promise }; /** - * Environments examined per check. Each costs up to two reads, which keeps a - * setup page within Workers Free's 50 subrequests alongside its other reads. + * The reads one check may make, which keeps a setup page within Workers + * Free's 50 subrequests alongside its other reads. A check that runs out + * says so rather than reporting the rest as fine. */ -export const ENVIRONMENTS = 12; +export const READS = 30; +/** Thrown once the check has spent its reads. */ +const SPENT = Symbol("spent"); type Secrets = { total_count: number; secrets: { name: string }[] }; type Environment = { @@ -55,12 +58,24 @@ function count(n: number, noun: string) { */ export async function ciProblems(reads: CIReads, granted: ReadonlySet): Promise { const problems: string[] = []; + let left = READS; + let stopped = false; + const get = (path: string) => { + if (left <= 0) { + stopped = true; + throw SPENT; + } + left--; + return reads.get(path); + }; // A failed or malformed read is a warning, never a failed page. const read = async (path: string, what: string, valid: (value: T) => boolean): Promise => { try { - const value = await reads.get(path); + const value = await get(path); if (value && valid(value)) return value; - } catch {} + } catch (error) { + if (error === SPENT) return null; + } problems.push(`kix could not read ${what}; review this project again later.`); return null; }; @@ -137,11 +152,12 @@ export async function ciProblems(reads: CIReads, granted: ReadonlySet { try { - const branch = await reads.get<{ protected?: unknown }>( + const branch = await get<{ protected?: unknown }>( `/branches/${name.split("/").map(encodeURIComponent).join("/")}`, ); if (typeof branch?.protected === "boolean") return branch.protected; } catch (error) { + if (error === SPENT) return null; // A branch that does not exist is not protected. if ((error as { status?: unknown }).status === 404) return false; } @@ -151,7 +167,11 @@ export async function ciProblems(reads: CIReads, granted: ReadonlySet ENVIRONMENTS) + if (listed && (stopped || listed.total_count > listed.environments.length)) problems.push( - `kix checked only the first ${ENVIRONMENTS} of ${listed.total_count} environments. Limit the rest to protected branches too.`, + `kix stopped after ${READS} reads of this repository's settings, before checking every environment. Limit each environment with secrets to protected branches.`, ); } return problems; diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts index dce61023..bef9106f 100644 --- a/cloudflare/test/projects.test.ts +++ b/cloudflare/test/projects.test.ts @@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { app } from "../src/index"; import { readBoard } from "../src/board/model"; import { type ActivityPage, associatedQuest, RETENTION, readActivity } from "../src/github/state"; -import { CI_PERMISSIONS, ENVIRONMENTS } from "../src/github/ci"; +import { CI_PERMISSIONS, READS } from "../src/github/ci"; import { GitHubService } from "../src/github/service"; import { privateKey } from "./github-key"; import { identity, oauth, origin } from "./login-fixtures"; @@ -463,20 +463,48 @@ describe("project enrollment", () => { "kix could not read environments; review this project again later.", ]); }); - it("checks a bounded number of environments within Workers Free's 50 external subrequests", async () => { - const environments = Array.from({ length: ENVIRONMENTS + 3 }, (_, i) => ({ + it.each([ + { + name: "many environments", + ci: { + environments: Array.from({ length: 20 }, (_, i) => ({ + name: `env-${i}`, + policy: ["feature/*"], + secrets: 1, + })), + }, + }, + { + name: "branch lookups past a full page of protected branches", + ci: { + protected: Array.from({ length: 100 }, (_, i) => `keep/${i}`), + environments: [ + { name: "wide", policy: Array.from({ length: 60 }, (_, i) => `other/${i}`), secrets: 1 }, + { name: "open", policy: null, secrets: 1 }, + ], + }, + }, + ])("stops a CI check with $name within Workers Free's 50 external subrequests", async ({ ci }) => { + const { service, calls } = github({ ci, subrequestLimit: 50 }); + await service.resolveRepository(repo.owner, repo.name); + const preview = await service.previewProject(repo, "enrollment-test"); + expect(preview.ci.at(-1)).toBe( + `kix stopped after ${READS} reads of this repository's settings, before checking every environment. Limit each environment with secrets to protected branches.`, + ); + expect(preview.ci.filter((problem) => problem.startsWith("kix could not"))).toEqual([]); + expect(calls.length).toBeLessThanOrEqual(50); + }); + it("does not report a check that spent its last read on its last environment as stopped", async () => { + // Four reads before environments, then one for each one's secrets. + const environments = Array.from({ length: READS - 4 }, (_, i) => ({ name: `env-${i}`, - policy: ["feature/*"], + policy: null, secrets: 1, })); - const { service, calls } = github({ ci: { environments }, subrequestLimit: 50 }); - await service.resolveRepository(repo.owner, repo.name); + const { service } = github({ ci: { environments } }); const { ci } = await service.previewProject(repo, "enrollment-test"); - expect(ci).toHaveLength(ENVIRONMENTS + 1); - expect(ci.at(-1)).toBe( - `kix checked only the first ${ENVIRONMENTS} of ${ENVIRONMENTS + 3} environments. Limit the rest to protected branches too.`, - ); - expect(calls.length).toBeLessThanOrEqual(50); + expect(ci).toHaveLength(READS - 4); + expect(ci.filter((problem) => problem.startsWith("kix stopped"))).toEqual([]); }); it("shows the CI check on the project's setup page", async () => { const who = identity("github"); From 5fa3ef1f0616b580bc5e0c01e5ecf64d8c425d75 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 08:54:35 -0800 Subject: [PATCH 7/8] docs(kix): say what an unverified VM declaration does to an untrusted job Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- cloudflare/README.md | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 993fd9ff..bbe53b16 100644 --- a/README.md +++ b/README.md @@ -1159,7 +1159,7 @@ pauses for you to pick another account; the runner never switches on its own. On SIGINT or SIGTERM, running work is published and its jobs move to another runner. `quest runner` takes only trusted revisions until it can build untrusted heads' devShells in a VM: it registers without declaring VM builds, -so kix routes it no other job, and it refuses one if sent. A pull request's head is trusted only when every +so kix routes it no other job, and it fails one if sent. A pull request's head is trusted only when every commit beyond the default branch has a valid signature from a current maintainer, so sign your commits (for example with [SSH signing](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#ssh-commit-signature-verification)) diff --git a/cloudflare/README.md b/cloudflare/README.md index 04c0bc2e..64b0819b 100644 --- a/cloudflare/README.md +++ b/cloudflare/README.md @@ -104,8 +104,11 @@ creation and again at assignment. Other heads, including unsigned ones and those kix pushed to, route only to Linux runners whose owner declared VM builds when registering them, a statement kix does not verify ([trust by stage](GITHUB.md#trust-by-stage)). `quest runner` cannot build in a -VM yet: it never declares one and refuses an untrusted job, so such jobs wait -for a runner that does. A `merge` job runs the merge +VM yet: `quest runner login` registers it without declaring VM builds, so such +jobs wait for a runner that declares them. kix trusts that declaration, so a +runner declared through `POST /api/runners` with `vm: true` that cannot build +in a VM is still sent the job; `quest runner` fails it rather than run it. A +`merge` job runs the merge skill's loop on an open, non-draft pull request and lands it; it holds the PR with its own `quest:kix` label, which local sessions respect as they do each other's `quest:active` (`src/jobs/landing.ts`), as described in From 48f753373d05566b25fb8bde9afb7d12fb22e3aa Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Tue, 3 Nov 2026 09:30:02 -0800 Subject: [PATCH 8/8] fix(kix): show the CI check beside the setup pull request's form Merging the setup pull request enables contributions, so a repository without Quest files now gets the same pull-request CI warnings before the maintainer opens it. The check runs on the preview's token when the preview finds no Quest files, and NoQuestFiles carries its warnings. Co-Authored-By: Claude Opus 5.5 --- cloudflare/GITHUB.md | 3 +- cloudflare/src/github/service.ts | 31 ++++++++++----- cloudflare/src/index.tsx | 2 +- cloudflare/src/projects.tsx | 67 +++++++++++++++++++------------- cloudflare/test/projects.test.ts | 12 ++++++ cloudflare/test/setup.test.ts | 2 + 6 files changed, 79 insertions(+), 38 deletions(-) diff --git a/cloudflare/GITHUB.md b/cloudflare/GITHUB.md index c87d4066..d566cdee 100644 --- a/cloudflare/GITHUB.md +++ b/cloudflare/GITHUB.md @@ -305,7 +305,8 @@ credentials. This rule, rather than a service fork or a review gate on every push, is kix's answer to agent-written code reaching CI. The project's setup page, where a maintainer enables contributions and later -pauses them, checks the repository's settings with the installation's token +pauses them, or opens the [setup pull request](#setup-pull-request) whose merge +enables them, checks the repository's settings with the installation's token (`src/github/ci.ts`) and lists each problem it finds: - the default workflow token can write; diff --git a/cloudflare/src/github/service.ts b/cloudflare/src/github/service.ts index a546d9ec..ecd79fa7 100644 --- a/cloudflare/src/github/service.ts +++ b/cloudflare/src/github/service.ts @@ -112,7 +112,8 @@ export class NotOpen extends GitHubError { /** The default branch has no Quest files, so the project sets up Quest before enabling. */ export class NoQuestFiles extends GitHubError { - constructor() { + /** `ci` is the setup page's pull-request CI check (`./ci.ts`), when it ran. */ + constructor(readonly ci: string[] = []) { super("Quest files are required. Open the setup pull request first."); } } @@ -1248,19 +1249,31 @@ export class GitHubService { return commits.size === count && (count === 0 || commits.has(revision)); } - /** The setup page's preview, with the pull-request CI check (`./ci.ts`) on the same token. */ + /** + * The setup page's preview, with the pull-request CI check (`./ci.ts`) on + * the same token. A repository without Quest files throws `NoQuestFiles` + * carrying the check, since merging its setup pull request enables it. + */ previewProject(repo: GitHubRepository, userId: string): Promise { return this.#authorized( repo, userId, { contents: "read" }, - async (token, current, installation, scope) => ({ - ...(await this.#preview(token, repo, current, installation)), - ci: await ciProblems( - { get: (path) => this.#api(token, `${repositoryPath(repo)}${path}`) }, - new Set((Object.keys(CI_PERMISSIONS) as CIPermission[]).filter((p) => scope[p])), - ), - }), + async (token, current, installation, scope) => { + const ci = () => + ciProblems( + { get: (path) => this.#api(token, `${repositoryPath(repo)}${path}`) }, + new Set((Object.keys(CI_PERMISSIONS) as CIPermission[]).filter((p) => scope[p])), + ); + let preview; + try { + preview = await this.#preview(token, repo, current, installation); + } catch (error) { + if (error instanceof NoQuestFiles) throw new NoQuestFiles(await ci()); + throw error; + } + return { ...preview, ci: await ci() }; + }, false, CI_PERMISSIONS, ); diff --git a/cloudflare/src/index.tsx b/cloudflare/src/index.tsx index fa2da56a..a734cd5a 100644 --- a/cloudflare/src/index.tsx +++ b/cloudflare/src/index.tsx @@ -183,7 +183,7 @@ app.get("/projects/github/:owner/:repository", async (c) => { return page( c, { title: `${repository.owner}/${repository.name} setup`, description: "Set up Quest." }, - , + , ); } return page( diff --git a/cloudflare/src/projects.tsx b/cloudflare/src/projects.tsx index 18b80f34..3a8d9b69 100644 --- a/cloudflare/src/projects.tsx +++ b/cloudflare/src/projects.tsx @@ -205,8 +205,45 @@ function GoalField() { ); } +/** The pull-request CI check's warnings (`./github/ci.ts`), which never block enabling. */ +function CIPanel(props: { problems: string[] }) { + return ( +
+

Pull-request CI

+

+ kix publishes job branches on this repository, where GitHub gives their workflows the repository's + secrets and default token. Keep that CI read-only, so code no maintainer has reviewed meets only a + read-only GITHUB_TOKEN and no write credentials. +

+ {props.problems.length ? ( +
    + {props.problems.map((problem) => ( +
  • {problem}
  • + ))} +
+ ) : ( +

+ The default token is read-only, no repository or organization secrets reach workflows, and + environment secrets deploy only from protected branches. +

+ )} +

+ Jobs still run either way. kix does not read workflow files, so also keep write{" "} + permissions: and secrets out of jobs that pull requests and pushes to unprotected branches + trigger, including pull_request_target and workflow_run jobs that check out a + pull request's head. +

+
+ ); +} + /** Enrolling a repository with no Quest files: the setup pull request's one question. */ -export function QuestSetupPage(props: { repository: { id: number; owner: string; name: string }; error?: string }) { +export function QuestSetupPage(props: { + repository: { id: number; owner: string; name: string }; + /** The pull-request CI check's warnings, when it ran. */ + ci?: string[]; + error?: string; +}) { const repo = props.repository; return (
@@ -233,6 +270,7 @@ export function QuestSetupPage(props: { repository: { id: number; owner: string;

+ {props.ci && }
); } @@ -366,32 +404,7 @@ export function ProjectSetupPage(props: { preview: ProjectPreview; enrollment: E )} -
-

Pull-request CI

-

- kix publishes job branches on this repository, where GitHub gives their workflows the repository's - secrets and default token. Keep that CI read-only, so code no maintainer has reviewed meets only a - read-only GITHUB_TOKEN and no write credentials. -

- {preview.ci.length ? ( -
    - {preview.ci.map((problem) => ( -
  • {problem}
  • - ))} -
- ) : ( -

- The default token is read-only, no repository or organization secrets reach workflows, and - environment secrets deploy only from protected branches. -

- )} -

- Jobs still run either way. kix does not read workflow files, so also keep write{" "} - permissions: and secrets out of jobs that pull requests and pushes to unprotected - branches trigger, including pull_request_target and workflow_run jobs that - check out a pull request's head. -

-
+

Ready quests

{props.enrollment && ( diff --git a/cloudflare/test/projects.test.ts b/cloudflare/test/projects.test.ts index 54e27c22..c771a3e3 100644 --- a/cloudflare/test/projects.test.ts +++ b/cloudflare/test/projects.test.ts @@ -522,6 +522,18 @@ describe("project enrollment", () => { // Warnings never block enabling contributions. expect(html).not.toContain("disabled"); }); + it("shows the CI check beside the setup pull request's form, since merging it enables contributions", async () => { + const who = identity("github"); + const { session } = await oauth(who); + const { request } = github({ account: Number(who.id), noQuests: true, ci: { repository: ["NPM_TOKEN"] } }); + vi.spyOn(globalThis, "fetch").mockImplementation(request as typeof fetch); + const html = await ( + await app.request(`${origin}/projects/github/upstream/project`, { headers: { Cookie: session! } }, testEnv) + ).text(); + expect(html).toContain("Open the setup pull request"); + expect(html).toContain("Pull-request CI"); + expect(html).toContain("receives 1 repository secret: NPM_TOKEN"); + }); it.each(["AGENTS.md", "CLAUDE.md"])("ignores root and nested %s instructions like the CLI", async (filename) => { const { service, calls } = github({ documents: [ diff --git a/cloudflare/test/setup.test.ts b/cloudflare/test/setup.test.ts index 516633c2..9b933e44 100644 --- a/cloudflare/test/setup.test.ts +++ b/cloudflare/test/setup.test.ts @@ -96,6 +96,8 @@ describe("the setup pull request", () => { const html = await form.text(); expect(html).toContain("Set up Quest"); expect(html).toContain('name="goal"'); + // The fake's CI settings are read-only, so the CI check finds nothing. + expect(html).toContain("The default token is read-only"); const response = await post("/setup", { id: "10", goal: "Ship a fast parser.\n# Not a heading" }); expect(response.status).toBe(303);