diff --git a/docs/configuration.md b/docs/configuration.md index 5e41d51..078755f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -310,7 +310,14 @@ update them from local `.env`: npm run setup:render -- --workspace ``` -The command writes nothing until every value is set. It updates only values +The command runs the [pre-deployment checks](runbooks/setup.md#phase-6-create-the-render-env-groups) +before writing any group: required values, pinned Node version, workspace +access, repository Issues availability, and configured base branches must +pass. Run `npm run setup:preflight -- --workspace ` to check without +updating groups. Model and Sandbox execution remain unverified until the +approved smoke test. + +It updates only values that changed, leaves other variables in each group alone, and never prints a secret. A second group with the same name makes it stop, because a Blueprint links groups by name. diff --git a/docs/quickstart.md b/docs/quickstart.md index adb3b49..f2c64df 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -12,12 +12,20 @@ Prepare: - a Render workspace with Sandboxes access in Oregon; - a Render API key; - a dedicated Anthropic API key with a provider-enforced spend limit; -- admin access to each target GitHub repository; and +- admin access to each target GitHub repository; +- Issues enabled on each target repository (Settings > General > Features); and - the Node.js version in [`.node-version`](../.node-version). The Blueprint creates paid resources: a Starter web service and a Workflows service. Sandbox and model usage are billed separately. +Setup checks the pinned Node version locally, then checks workspace and +repository access before writing Render environment groups. Disabled Issues +or an inaccessible base branch blocks that preflight. See the +[pre-deployment check](runbooks/setup.md#phase-6-create-the-render-env-groups) +for the command and its limits; model and Sandbox execution are verified by +the separately approved smoke test. + ## Start the setup Open your fork in a coding agent and use this prompt: diff --git a/docs/runbooks/setup.md b/docs/runbooks/setup.md index dfcd9fa..8e77e07 100644 --- a/docs/runbooks/setup.md +++ b/docs/runbooks/setup.md @@ -56,6 +56,7 @@ Run: ```bash node --version npm ci +npm run setup:preflight -- --local npm run check ``` @@ -81,6 +82,7 @@ Requirements: - use the exact GitHub `owner/name`; - name an existing base branch; +- confirm Issues is enabled under the target repository's Settings > General > Features (ask an admin to enable it if necessary); - use an install command that is deterministic and leaves tracked files unchanged; and - list every command required before publishing. @@ -203,8 +205,24 @@ Run: npm run setup:render -- --workspace ``` -The command checks that every value is present before it writes anything, -confirms that the API key can access the workspace, then creates or updates: +Before any environment-group writes, the command runs read-only preflight: +it checks the pinned Node version, required values, workspace access, App +installation, repository Issues availability, and each configured base branch. +Resolve every failure before deploying paid resources. To run these checks +without changing any groups, use: + +```bash +npm run setup:preflight -- --workspace +``` + +Preflight mints repository-scoped read-only installation tokens, but changes +no repository settings and creates no Sandbox or model request. It explicitly +leaves Sandbox execution, configured-model access, and provider spend caps +unverified; confirm account access before deployment and verify execution in +the separately approved Triage smoke test. A successful preflight is not the +definition of completed setup. + +After preflight passes, the command creates or updates: - `software-factory-shared`, for both services: `RENDER_API_KEY`; - `software-factory-gateway`, for the gateway: `GITHUB_WEBHOOK_SECRET`; and diff --git a/package.json b/package.json index 77cbe19..b2dc03c 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "dev:gateway": "node --env-file=.env node_modules/.bin/tsx watch app/entrypoints/gateway.ts", "dev:workflows": "render workflows dev -- node --env-file=.env node_modules/.bin/tsx app/entrypoints/workflows.ts", "setup:app": "node --env-file-if-exists=.env --import tsx scripts/setup-app.ts", + "setup:preflight": "node --env-file-if-exists=.env --import tsx scripts/preflight.ts", "setup:render": "node --env-file-if-exists=.env --import tsx scripts/setup-render.ts", "setup:github": "node --env-file-if-exists=.env --import tsx scripts/setup-github.ts", "doctor": "node --env-file-if-exists=.env --import tsx scripts/doctor.ts", diff --git a/scripts/doctor.ts b/scripts/doctor.ts index 5605f83..6be6b0f 100644 --- a/scripts/doctor.ts +++ b/scripts/doctor.ts @@ -26,7 +26,15 @@ if (!services) { const out = new Reporter(); try { - process.exitCode = await runDoctor({ env: process.env, services, repos: Object.keys(config.repos) }, out); + process.exitCode = await runDoctor( + { + env: process.env, + services, + repos: Object.keys(config.repos), + baseBranches: Object.fromEntries(Object.entries(config.repos).map(([name, repo]) => [name, repo.baseBranch])), + }, + out, + ); } catch (error) { out.fail(`doctor crashed: ${errorMessage(error)}`); process.exitCode = 1; diff --git a/scripts/lib/doctor.ts b/scripts/lib/doctor.ts index 3c52ccb..bf8f60a 100644 --- a/scripts/lib/doctor.ts +++ b/scripts/lib/doctor.ts @@ -5,12 +5,15 @@ import { TASKS } from "../../app/config/index.js"; import { appCredentialsFrom, type Env, GitHubApp, type ScriptDeps, splitRepo } from "./github.js"; import { PLACEHOLDER_WEBHOOK_URL } from "./manifest.js"; import { errorMessage, type Reporter } from "./output.js"; +import { checkRepositoryPrerequisites } from "./repo-prerequisites.js"; import { envGroupFor, LABELS, missingEvents, missingPermissions, SECRET_ENV, SERVICE_ENV, type Service, WEBHOOK_PATH } from "./requirements.js"; import { appSettingsUrl } from "./setup-github.js"; export interface DoctorOptions extends ScriptDeps { services: readonly Service[]; repos: readonly string[]; + /** The CLI supplies configured branches; callers without branch checks still verify repository capabilities. */ + baseBranches?: Readonly>; /** The agents whose harnesses to check; defaults to every agent the factory runs. */ agents?: readonly Agent[]; renderApiUrl?: string; @@ -103,7 +106,7 @@ async function checkGitHub(opts: DoctorOptions, out: Reporter): Promise { else out.pass("App subscribes to issues, issue_comment, pull_request, push"); await checkWebhook(app, info, env, out); - for (const fullName of opts.repos) await checkRepo(app, info, fullName, out); + for (const fullName of opts.repos) await checkRepo(app, info, fullName, out, opts.baseBranches?.[fullName]); out.addSecrets(...app.mintedTokens); } @@ -150,7 +153,7 @@ async function checkWebhook(app: GitHubApp, info: Awaited l.name.toLowerCase())); const missing = LABELS.filter((label) => !existing.has(label.name.toLowerCase())); diff --git a/scripts/lib/github.ts b/scripts/lib/github.ts index ea95aba..8d0c358 100644 --- a/scripts/lib/github.ts +++ b/scripts/lib/github.ts @@ -150,6 +150,14 @@ class RepoClient { return this.http.paginate(`${this.base}/labels`); } + metadata(): Promise { + return this.http.json("GET", this.base); + } + + branch(name: string): Promise { + return this.http.json("GET", `${this.base}/branches/${encodeURIComponent(name)}`); + } + async createLabel(label: { name: string; color: string; description: string }): Promise { await this.http.json("POST", `${this.base}/labels`, { body: label }); } diff --git a/scripts/lib/preflight.ts b/scripts/lib/preflight.ts new file mode 100644 index 0000000..590b2aa --- /dev/null +++ b/scripts/lib/preflight.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; +import type { RepoConfig } from "../../app/config/index.js"; +import { GitHubApp, type ScriptDeps, splitRepo } from "./github.js"; +import type { Reporter } from "./output.js"; +import { checkRepositoryPrerequisites } from "./repo-prerequisites.js"; +import { ENV_GROUPS, HARNESS_KEY_ENV, SECRET_ENV } from "./requirements.js"; +import { setupRender } from "./setup-render.js"; + +export interface PreflightOptions extends ScriptDeps { + repos: Readonly>; + nodeVersion: string; + requiredNodeMajor: number; + local?: boolean; + renderApiUrl?: string; +} + +const ownerSchema = z.object({ id: z.string().min(1), name: z.string().min(1) }); + +/** Check every target before provisioning any environment group. */ +export async function setupRenderWithPreflight(opts: PreflightOptions, out: Reporter): Promise { + const code = await runPreflight({ ...opts, local: false }, out); + return code === 0 ? setupRender(opts, out) : code; +} + +/** No configuration writes, model calls, or Sandbox creation; installation tokens are read-only and repository-scoped. */ +export async function runPreflight(opts: PreflightOptions, out: Reporter): Promise { + out.addSecrets(...[...SECRET_ENV, ...HARNESS_KEY_ENV].map((name) => opts.env[name])); + out.heading("Setup prerequisites"); + const major = /^v?(\d+)\./.exec(opts.nodeVersion)?.[1]; + if (Number(major) !== opts.requiredNodeMajor) { + out.fail(`Node ${opts.requiredNodeMajor} is required by .node-version`, `Activate the pinned Node version; currently ${opts.nodeVersion}`); + } else out.pass(`Node ${opts.requiredNodeMajor} matches .node-version`); + if (opts.local) return out.summary(); + + for (const key of new Set(ENV_GROUPS.flatMap((group) => group.keys))) { + if (!opts.env[key]?.trim()) out.fail(`${key} is not set`, "Complete App installation and local secret entry before deployment preflight"); + } + const workspace = opts.env.RENDER_WORKSPACE_ID?.trim(); + if (workspace && !/^tea-[a-z0-9]+$/.test(workspace)) + out.fail("RENDER_WORKSPACE_ID is not a workspace ID", "Use the tea-… ID from Workspace Settings"); + if (!Object.keys(opts.repos).length) out.fail("No target repositories configured", "Configure repos in factory.config.ts"); + for (const [fullName, repo] of Object.entries(opts.repos)) { + if (!repo.baseBranch.trim()) out.fail(`${fullName}: baseBranch is empty`, "Set an existing baseBranch in factory.config.ts"); + } + if (out.count("fail")) return out.summary(); + + try { + const response = await (opts.fetch ?? fetch)( + `${opts.renderApiUrl ?? "https://api.render.com"}/v1/owners/${encodeURIComponent(workspace ?? "")}`, + { + headers: { Authorization: `Bearer ${opts.env.RENDER_API_KEY?.trim()}`, Accept: "application/json" }, + signal: AbortSignal.timeout(15_000), + }, + ); + if (response.status === 401 || response.status === 403) + out.fail("RENDER_API_KEY was rejected", "Use a key with access to the selected workspace"); + else if (!response.ok) + out.fail(`Render workspace access is unverified (HTTP ${response.status})`, "Check RENDER_WORKSPACE_ID and account access"); + else { + const owner = ownerSchema.safeParse(await response.json().catch(() => null)); + if (!owner.success || owner.data.id !== workspace) out.fail("Render workspace metadata is invalid; access is unverified", "Retry preflight"); + else out.pass(`Render key can access workspace ${owner.data.name} (${workspace})`); + } + } catch { + out.fail("Render workspace access could not be verified", "Check connectivity and retry preflight"); + } + + let app: GitHubApp | undefined; + try { + app = new GitHubApp(opts); + for (const [fullName, repoConfig] of Object.entries(opts.repos)) { + try { + const { owner, name } = splitRepo(fullName); + const installation = await app.repoInstallation(owner, name); + if (!installation) { + out.fail(`${fullName}: GitHub App is not installed`, "Install the App on this repository before deployment"); + continue; + } + const token = await app.installationToken(installation.id, name, { contents: "read", metadata: "read" }); + out.addSecrets(token); + await checkRepositoryPrerequisites(app.repo(owner, name, token), fullName, repoConfig.baseBranch, out); + } catch { + out.addSecrets(...app.mintedTokens); + out.fail(`${fullName}: GitHub access could not be verified`, "Check App credentials, installation, and Contents read access"); + } + } + } catch { + out.fail("GitHub App credentials could not be verified", "Complete setup:app and check GITHUB_APP_ID and GITHUB_APP_PRIVATE_KEY"); + } + out.warn( + "Model and Sandbox execution remain unverified", + "Confirm Sandboxes access in Oregon and configured-model access; the approved Triage smoke test verifies execution", + ); + out.line("Key presence does not verify model access or provider spend caps. Preflight creates no Sandbox and makes no model request."); + return out.summary(); +} diff --git a/scripts/lib/repo-prerequisites.ts b/scripts/lib/repo-prerequisites.ts new file mode 100644 index 0000000..5e07656 --- /dev/null +++ b/scripts/lib/repo-prerequisites.ts @@ -0,0 +1,44 @@ +import { z } from "zod"; +import type { GitHubApp } from "./github.js"; +import type { Reporter } from "./output.js"; + +const metadataSchema = z.object({ full_name: z.string().min(1), has_issues: z.boolean(), archived: z.boolean() }); +const branchSchema = z.object({ name: z.string().min(1), commit: z.object({ sha: z.string().regex(/^[a-f0-9]{40}$/i) }) }); + +/** Read-only repository capabilities, shared by preflight and the deployed-value doctor. */ +export async function checkRepositoryPrerequisites( + repo: ReturnType, + fullName: string, + baseBranch: string | undefined, + out: Reporter, +): Promise { + try { + const metadata = metadataSchema.safeParse(await repo.metadata()); + if (!metadata.success || metadata.data.full_name.toLowerCase() !== fullName.toLowerCase()) { + out.fail(`${fullName}: repository metadata is invalid; Issues availability is unverified`, "Check repository access and retry preflight"); + return; + } + if (metadata.data.has_issues) out.pass(`${fullName}: Issues is enabled`); + else out.fail(`${fullName}: Issues is disabled`, "A repository admin must enable Settings > General > Features > Issues before deployment"); + if (metadata.data.archived) out.fail(`${fullName}: repository is archived`, "Choose an active repository or ask its owner to unarchive it"); + } catch { + out.fail(`${fullName}: repository capabilities could not be verified`, "Check the App installation's repository access and retry"); + return; + } + if (baseBranch === undefined) return; + if (!baseBranch.trim()) { + out.fail(`${fullName}: baseBranch is empty`, "Set an existing baseBranch in factory.config.ts"); + return; + } + try { + const branch = branchSchema.safeParse(await repo.branch(baseBranch)); + if (!branch.success || branch.data.name !== baseBranch) { + out.fail(`${fullName}: base branch ${baseBranch} could not be verified`, "Check baseBranch in factory.config.ts and repository access"); + } else out.pass(`${fullName}: base branch ${baseBranch} is accessible`); + } catch { + out.fail( + `${fullName}: base branch ${baseBranch} is missing or inaccessible`, + "Check baseBranch in factory.config.ts and the App's Contents access", + ); + } +} diff --git a/scripts/preflight.ts b/scripts/preflight.ts new file mode 100644 index 0000000..cbe33f6 --- /dev/null +++ b/scripts/preflight.ts @@ -0,0 +1,14 @@ +/** Read-only local/runtime or pre-deployment prerequisite checks. */ +import { readFile } from "node:fs/promises"; +import { parseArgs } from "node:util"; +import { config } from "../app/config/index.js"; +import { Reporter } from "./lib/output.js"; +import { runPreflight } from "./lib/preflight.js"; + +const { values } = parseArgs({ options: { local: { type: "boolean" }, workspace: { type: "string" } } }); +const requiredNodeMajor = Number.parseInt(await readFile(new URL("../.node-version", import.meta.url), "utf8"), 10); +const env = values.workspace ? { ...process.env, RENDER_WORKSPACE_ID: values.workspace.trim() } : process.env; +process.exitCode = await runPreflight( + { env, repos: config.repos, nodeVersion: process.versions.node, requiredNodeMajor, local: values.local }, + new Reporter(), +); diff --git a/scripts/setup-render.ts b/scripts/setup-render.ts index bb05199..6e11e7d 100644 --- a/scripts/setup-render.ts +++ b/scripts/setup-render.ts @@ -8,10 +8,13 @@ * Reads RENDER_API_KEY, RENDER_WORKSPACE_ID, the GitHub App values, and ANTHROPIC_API_KEY from the environment * or .env. --workspace overrides RENDER_WORKSPACE_ID and, after a successful run, saves it to .env. */ + +import { readFile } from "node:fs/promises"; import { parseArgs } from "node:util"; +import { config } from "../app/config/index.js"; import { writeEnvFile } from "./lib/env-file.js"; import { errorMessage, Reporter } from "./lib/output.js"; -import { setupRender } from "./lib/setup-render.js"; +import { setupRenderWithPreflight } from "./lib/preflight.js"; const { values } = parseArgs({ options: { workspace: { type: "string" } } }); const workspace = values.workspace?.trim(); @@ -19,7 +22,8 @@ const workspace = values.workspace?.trim(); const out = new Reporter(); try { const env = workspace ? { ...process.env, RENDER_WORKSPACE_ID: workspace } : process.env; - process.exitCode = await setupRender({ env }, out); + const requiredNodeMajor = Number.parseInt(await readFile(new URL("../.node-version", import.meta.url), "utf8"), 10); + process.exitCode = await setupRenderWithPreflight({ env, repos: config.repos, nodeVersion: process.versions.node, requiredNodeMajor }, out); if (workspace && process.exitCode === 0) { await writeEnvFile(".env", { RENDER_WORKSPACE_ID: workspace }); out.line(`Saved RENDER_WORKSPACE_ID=${workspace} to .env`); diff --git a/tests/scripts-doctor.test.ts b/tests/scripts-doctor.test.ts index 1766e30..83c2ec2 100644 --- a/tests/scripts-doctor.test.ts +++ b/tests/scripts-doctor.test.ts @@ -50,6 +50,8 @@ interface World { installed?: boolean; labels?: string[]; labelsResponse?: { status: number; body: unknown }; + repositoryResponse?: { status?: number; body: unknown }; + branchResponse?: { status?: number; body: unknown }; tasks?: string[]; tasksStatus?: number; } @@ -63,6 +65,12 @@ function fakeFetch(world: World = {}) { calls.push({ method, url, auth: headers.Authorization ?? "" }); const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); switch (`${method} ${url.host}${url.pathname}`) { + case "GET api.github.com/repos/acme/web": + if (world.repositoryResponse) return json(world.repositoryResponse.body, world.repositoryResponse.status); + return json({ full_name: "acme/web", has_issues: true, archived: false }); + case "GET api.github.com/repos/acme/web/branches/release%2Fcandidate": + if (world.branchResponse) return json(world.branchResponse.body, world.branchResponse.status); + return json({ name: "release/candidate", commit: { sha: "a".repeat(40) } }); case "GET api.github.com/app": return json(world.app ?? APP); case "GET api.github.com/app/hook/config": @@ -121,6 +129,27 @@ function expectNoSecrets(output: string) { } describe("doctor", () => { + it("blocks readiness when Issues is disabled even with an installed App and existing labels", async () => { + const { code, find } = await doctor({ world: { repositoryResponse: { body: { full_name: "acme/web", has_issues: false, archived: false } } } }); + expect(code).toBe(1); + expect(find("fail", "acme/web: Issues is disabled")).toBeDefined(); + }); + + it("checks the configured base branch and blocks readiness when it is inaccessible", async () => { + const { code, find } = await doctor({ + baseBranches: { "acme/web": "release/candidate" }, + world: { branchResponse: { status: 404, body: { message: "Not Found" } } }, + }); + expect(code).toBe(1); + expect(find("fail", "base branch release/candidate is missing or inaccessible")).toBeDefined(); + }); + + it("accepts an accessible non-main base branch", async () => { + const { code, find } = await doctor({ baseBranches: { "acme/web": "release/candidate" } }); + expect(code).toBe(0); + expect(find("pass", "base branch release/candidate is accessible")).toBeDefined(); + }); + it("passes a healthy deployment and exits 0", async () => { const { code, results, output } = await doctor(); expect(results.filter((r) => r.level === "fail" || r.level === "warn")).toEqual([]); diff --git a/tests/scripts-preflight.test.ts b/tests/scripts-preflight.test.ts new file mode 100644 index 0000000..3a65443 --- /dev/null +++ b/tests/scripts-preflight.test.ts @@ -0,0 +1,272 @@ +import { generateKeyPairSync } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { Reporter } from "../scripts/lib/output.js"; +import { type PreflightOptions, runPreflight, setupRenderWithPreflight } from "../scripts/lib/preflight.js"; + +const { privateKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + privateKeyEncoding: { type: "pkcs8", format: "pem" }, + publicKeyEncoding: { type: "spki", format: "pem" }, +}); +const WORKSPACE = "tea-abc123"; +const REPO = "acme/widget"; +const BRANCH = "release/candidate"; +const TOKEN = "ghs_readonly_preflight_token"; +const SECRETS = { + RENDER_API_KEY: "rnd_preflight_render_key", + GITHUB_WEBHOOK_SECRET: "preflight_webhook_secret", + GITHUB_APP_PRIVATE_KEY: Buffer.from(privateKey).toString("base64"), + ANTHROPIC_API_KEY: "sk-ant-preflight-model-key", +}; +const ENV = { ...SECRETS, GITHUB_APP_ID: "1", RENDER_WORKSPACE_ID: WORKSPACE }; + +interface Call { + method: string; + url: URL; + auth: string; + body: unknown; +} + +interface WorldOptions { + metadata?: unknown; + metadataStatus?: number; + branch?: unknown; + branchStatus?: number; + installationStatus?: number; + workspace?: unknown; + workspaceStatus?: number; + workspaceInvalidJson?: boolean; + metadataThrows?: boolean; +} + +function world(opts: WorldOptions = {}) { + const calls: Call[] = []; + const fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + const call: Call = { + method: init?.method ?? "GET", + url, + auth: new Headers(init?.headers).get("Authorization") ?? "", + body: init?.body ? JSON.parse(String(init.body)) : undefined, + }; + calls.push(call); + const json = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); + if (url.host === "api.render.com" && url.pathname === `/v1/owners/${WORKSPACE}`) { + if (opts.workspaceInvalidJson) return new Response("not json", { status: 200 }); + return json("workspace" in opts ? opts.workspace : { id: WORKSPACE, name: "Acme" }, opts.workspaceStatus); + } + if (url.host === "api.render.com" && url.pathname === "/v1/env-groups") { + return call.method === "GET" ? json([]) : json({ id: "evg-created", ...(call.body as object) }, 201); + } + if (url.host === "api.github.com") { + if (url.pathname === `/repos/${REPO}/installation`) return json({ id: 7 }, opts.installationStatus); + if (url.pathname === "/app/installations/7/access_tokens") return json({ token: TOKEN }, 201); + if (url.pathname === `/repos/${REPO}`) { + if (opts.metadataThrows) throw new Error(`Network error containing ${TOKEN} and ${SECRETS.RENDER_API_KEY}`); + return json("metadata" in opts ? opts.metadata : { full_name: REPO, has_issues: true, archived: false }, opts.metadataStatus); + } + if (url.pathname === `/repos/${REPO}/branches/${encodeURIComponent(BRANCH)}`) { + return json("branch" in opts ? opts.branch : { name: BRANCH, commit: { sha: "a".repeat(40) } }, opts.branchStatus); + } + } + return json({ message: "Unexpected endpoint" }, 404); + }) as typeof globalThis.fetch; + return { fetch, calls }; +} + +async function run(api: ReturnType, overrides: Partial = {}, combined = true) { + const lines: string[] = []; + const out = new Reporter((line) => lines.push(line)); + const opts: PreflightOptions = { + env: ENV, + repos: { [REPO]: { baseBranch: BRANCH, verify: [] } }, + nodeVersion: "24.18.0", + requiredNodeMajor: 24, + fetch: api.fetch, + sleep: async () => {}, + ...overrides, + }; + const code = await (combined ? setupRenderWithPreflight(opts, out) : runPreflight(opts, out)); + return { code, output: lines.join("\n"), results: out.results }; +} + +const envGroupRequests = (api: ReturnType) => api.calls.filter((call) => call.url.pathname.startsWith("/v1/env-groups")); + +function expectNoSecrets(output: string): void { + for (const secret of [...Object.values(SECRETS), privateKey, TOKEN]) expect(output).not.toContain(secret); +} + +describe("deployment preflight", () => { + it("verifies Issues, a non-main branch with a slash, and workspace access without changing infrastructure", async () => { + const api = world(); + const { code, output } = await run(api, {}, false); + expect(code).toBe(0); + expect(output).toContain(`${REPO}: Issues is enabled`); + expect(output).toContain(`${REPO}: base branch ${BRANCH} is accessible`); + expect(output).toContain(`Acme (${WORKSPACE})`); + expect(output).toContain("Model and Sandbox execution remain unverified"); + expect(output).toContain("Key presence does not verify model access or provider spend caps"); + expect(envGroupRequests(api)).toEqual([]); + expect(api.calls.filter((call) => call.method !== "GET")).toEqual([ + expect.objectContaining({ + method: "POST", + body: { repositories: ["widget"], permissions: { contents: "read", metadata: "read" } }, + }), + ]); + const repositoryReads = api.calls.filter((call) => call.url.pathname === `/repos/${REPO}` || call.url.pathname.includes("/branches/")); + expect(repositoryReads).toHaveLength(2); + expect(repositoryReads.every((call) => call.auth === `Bearer ${TOKEN}`)).toBe(true); + expectNoSecrets(output); + }); + + it("allows environment-group creation only after repository and workspace checks pass", async () => { + const api = world(); + const { code, output } = await run(api); + expect(code).toBe(0); + expect(envGroupRequests(api).filter((call) => call.method === "POST")).toHaveLength(3); + const checkedBranch = api.calls.findIndex((call) => call.url.pathname.includes("/branches/")); + const firstGroupRequest = api.calls.findIndex((call) => call.url.pathname === "/v1/env-groups"); + expect(checkedBranch).toBeGreaterThan(-1); + expect(firstGroupRequest).toBeGreaterThan(checkedBranch); + expectNoSecrets(output); + }); + + it("checks local prerequisites without credentials or API requests", async () => { + const api = world(); + const { code, output } = await run(api, { local: true, env: {}, repos: {} }, false); + expect(code).toBe(0); + expect(output).toContain("Node 24 matches .node-version"); + expect(output).not.toContain("is not set"); + expect(api.calls).toEqual([]); + }); + + it.each([ + { name: "Issues disabled", metadata: { full_name: REPO, has_issues: false, archived: false }, message: "Issues is disabled" }, + { name: "archived repository", metadata: { full_name: REPO, has_issues: true, archived: true }, message: "repository is archived" }, + { name: "null metadata", metadata: null, message: "repository metadata is invalid" }, + { name: "missing capability fields", metadata: { full_name: REPO }, message: "repository metadata is invalid" }, + { + name: "another repository's metadata", + metadata: { full_name: "other/repo", has_issues: true, archived: false }, + message: "repository metadata is invalid", + }, + ])("blocks environment-group setup for $name", async ({ metadata, message }) => { + const api = world({ metadata }); + const { code, output } = await run(api); + expect(code).toBe(1); + expect(output).toContain(message); + expect(envGroupRequests(api)).toEqual([]); + expectNoSecrets(output); + }); + + it.each([403, 404])("blocks environment-group setup when repository metadata is inaccessible (HTTP %i)", async (metadataStatus) => { + const api = world({ metadataStatus }); + const { code, output } = await run(api); + expect(code).toBe(1); + expect(output).toContain("repository capabilities could not be verified"); + expect(envGroupRequests(api)).toEqual([]); + }); + + it.each([403, 404])("blocks environment-group setup for a missing or inaccessible base branch (HTTP %i)", async (branchStatus) => { + const api = world({ branchStatus }); + const { code, output } = await run(api); + expect(code).toBe(1); + expect(output).toContain(`base branch ${BRANCH} is missing or inaccessible`); + expect(envGroupRequests(api)).toEqual([]); + }); + + it.each([ + { name: "null branch", branch: null }, + { name: "wrong branch", branch: { name: "main", commit: { sha: "a".repeat(40) } } }, + { name: "invalid commit", branch: { name: BRANCH, commit: { sha: "not-a-sha" } } }, + ])("blocks environment-group setup when branch metadata is invalid: $name", async ({ branch }) => { + const api = world({ branch }); + const { code, output } = await run(api); + expect(code).toBe(1); + expect(output).toContain(`base branch ${BRANCH} could not be verified`); + expect(envGroupRequests(api)).toEqual([]); + }); + + it.each([401, 403])("blocks environment-group setup when the Render key is rejected (HTTP %i)", async (workspaceStatus) => { + const api = world({ workspaceStatus }); + const { code, output } = await run(api); + expect(code).toBe(1); + expect(output).toContain("RENDER_API_KEY was rejected"); + expect(envGroupRequests(api)).toEqual([]); + }); + + it.each([ + { name: "invalid JSON", workspaceInvalidJson: true }, + { name: "null metadata", workspace: null }, + { name: "another workspace", workspace: { id: "tea-other", name: "Other" } }, + ])("blocks environment-group setup when workspace access cannot be verified: $name", async (opts) => { + const api = world(opts); + const { code, output } = await run(api); + expect(code).toBe(1); + expect(output).toContain("Render workspace metadata is invalid"); + expect(envGroupRequests(api)).toEqual([]); + }); + + it("blocks setup and explains when the App is not installed on the target repository", async () => { + const api = world({ installationStatus: 404 }); + const { code, output } = await run(api); + expect(code).toBe(1); + expect(output).toContain(`${REPO}: GitHub App is not installed`); + expect(api.calls.some((call) => call.method === "POST")).toBe(false); + expect(envGroupRequests(api)).toEqual([]); + }); + + it("checks every configured repository before writing any environment groups", async () => { + const api = world(); + const { code, output } = await run(api, { + repos: { [REPO]: { baseBranch: BRANCH, verify: [] }, "other/uninstalled": { baseBranch: "main", verify: [] } }, + }); + expect(code).toBe(1); + expect(output).toContain(`${REPO}: base branch ${BRANCH} is accessible`); + expect(output).toContain("other/uninstalled: GitHub App is not installed"); + expect(envGroupRequests(api)).toEqual([]); + }); + + it.each(Object.keys(ENV))("blocks setup before any API request when %s is missing", async (key) => { + const api = world(); + const { code, output } = await run(api, { env: { ...ENV, [key]: " " } }); + expect(code).toBe(1); + expect(output).toContain(`${key} is not set`); + expect(api.calls).toEqual([]); + expectNoSecrets(output); + }); + + it("blocks setup for an unsupported Node version before any API request", async () => { + const api = world(); + const { code, output } = await run(api, { nodeVersion: "22.19.0" }); + expect(code).toBe(1); + expect(output).toContain("Node 24 is required"); + expect(api.calls).toEqual([]); + }); + + it.each(["", " "])("blocks setup for an empty configured base branch (%j) before any API request", async (baseBranch) => { + const api = world(); + const { code, output } = await run(api, { repos: { [REPO]: { baseBranch, verify: [] } } }); + expect(code).toBe(1); + expect(output).toContain(`${REPO}: baseBranch is empty`); + expect(api.calls).toEqual([]); + }); + + it("rejects an empty target list and malformed workspace identifier before any API request", async () => { + const api = world(); + const { code, output } = await run(api, { repos: {}, env: { ...ENV, RENDER_WORKSPACE_ID: "srv-not-a-workspace" } }); + expect(code).toBe(1); + expect(output).toContain("No target repositories configured"); + expect(output).toContain("RENDER_WORKSPACE_ID is not a workspace ID"); + expect(api.calls).toEqual([]); + }); + + it("does not expose credentials or a minted installation token when GitHub fails", async () => { + const api = world({ metadataThrows: true }); + const { code, output } = await run(api); + expect(code).toBe(1); + expect(output).toContain("repository capabilities could not be verified"); + expect(envGroupRequests(api)).toEqual([]); + expectNoSecrets(output); + }); +});