From e76f3bbf5bad0f1b7f14fdd7d0831938eea8e3f7 Mon Sep 17 00:00:00 2001 From: Jacob Prall Date: Mon, 26 Oct 2026 16:01:02 -0700 Subject: [PATCH] Store setup secrets in Render environment groups Add npm run setup:render, which creates the env groups render.yaml links from local .env, so each secret is entered once and the Blueprint form asks for nothing. The gateway links no group with GitHub App credentials or a model key. Fixes #60 Co-authored-by: Cursor --- .env.example | 3 + AGENTS.md | 7 +- CONTRIBUTING.md | 5 + README.md | 4 +- docs/configuration.md | 52 ++++++++- docs/quickstart.md | 15 ++- docs/runbooks/extend.md | 4 + docs/runbooks/setup.md | 121 +++++++++++++------- package.json | 1 + render.yaml | 22 ++-- scripts/lib/doctor.ts | 26 ++++- scripts/lib/requirements.ts | 29 ++++- scripts/lib/setup-render.ts | 140 +++++++++++++++++++++++ scripts/setup-app.ts | 27 +++-- scripts/setup-render.ts | 30 +++++ tests/scripts-setup-render.test.ts | 177 +++++++++++++++++++++++++++++ 16 files changed, 579 insertions(+), 84 deletions(-) create mode 100644 scripts/lib/setup-render.ts create mode 100644 scripts/setup-render.ts create mode 100644 tests/scripts-setup-render.test.ts diff --git a/.env.example b/.env.example index 2cec708..af9c982 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# `npm run setup:render` copies these values into the Render env groups render.yaml links; see +# docs/configuration.md#environment-groups. + # Both services: Render API key (the gateway starts tasks; workflows manage sandboxes and cancel runs). RENDER_API_KEY= diff --git a/AGENTS.md b/AGENTS.md index 027ab72..25cd84f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,8 +86,11 @@ strict output schemas. - Configuration key: update `FactoryConfig` in `factory.config.ts` and `docs/configuration.md`. -- Environment variable: update `render.yaml`, `.env.example`, `SERVICE_ENV` - in `scripts/lib/requirements.ts`, and `docs/configuration.md`. +- Environment variable: update `.env.example`, `SERVICE_ENV` and, for a + secret, `ENV_GROUPS` in `scripts/lib/requirements.ts`, and + `docs/configuration.md`. Secrets belong in an env group, not a + `sync: false` entry in `render.yaml`. Never link a group with GitHub App + credentials or a model key to the gateway. - App permission, event, or label: update `scripts/lib/requirements.ts`, setup behavior, and doctor tests. - `app/runner/` or `sandbox/`: refresh every repository snapshot after diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48f071f..81cd57a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -72,6 +72,11 @@ render blueprints validate render.yaml npm audit --omit=dev --audit-level=high ``` +`render blueprints validate` resolves the Blueprint's +[env groups](docs/configuration.md#environment-groups) in the CLI's current +workspace. Outside a workspace where `npm run setup:render` has run, expect +only `depends on non-existent group` errors. + Run one test file with: ```bash diff --git a/README.md b/README.md index 34001cc..8bf87f0 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,8 @@ The recommended setup is agent-guided: asks. The runbook configures the fork before deployment, creates a GitHub App, -deploys the Render Blueprint, connects the webhook, verifies both services, -and runs one Triage smoke test. +stores secrets in Render environment groups, deploys the Render Blueprint, +connects the webhook, verifies both services, and runs one Triage smoke test. **What software-factory solves for you:** 1. Authenticating GitHub events and routing them to durable Render Workflows tasks. diff --git a/docs/configuration.md b/docs/configuration.md index f212f7f..af615bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -234,14 +234,15 @@ Workflows services have no Shell. Put the deployed Workflows values and exact npm run doctor -- --service workflows ``` -The [setup runbook](runbooks/setup.md#phase-9-run-doctor) lists the required +The [setup runbook](runbooks/setup.md#phase-10-run-doctor) lists the required values and common failures. ## Environment variables Local variables are listed in [`.env.example`](../.env.example). The Blueprint supplies `RENDER_WORKFLOW_SLUG` to the gateway from the Workflows -service. +service. Every other deployed value comes from an +[environment group](#environment-groups). ### Gateway @@ -264,7 +265,50 @@ service. | `GITHUB_API_URL` | Optional GitHub API base URL | `GITHUB_API_URL` is not in the Blueprint. GitHub Enterprise Server users must -set it in local `.env` for setup commands and on `factory-workflows` in the -Render Dashboard. +set it in local `.env` for setup commands and add it to the +`software-factory-workflows` env group in the Render Dashboard. Never commit `.env` or provider keys. + +## Environment groups + +Secrets never pass through `render.yaml` or the Blueprint form. Each service +links Render +[environment groups](https://render.com/docs/configure-environment-variables#environment-groups) +instead: + +| Group | Linked to | Variables | +|---|---|---| +| `software-factory-shared` | both services | `RENDER_API_KEY` | +| `software-factory-gateway` | `factory-gateway` | `GITHUB_WEBHOOK_SECRET` | +| `software-factory-workflows` | `factory-workflows` | `RENDER_WORKSPACE_ID`, `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `ANTHROPIC_API_KEY` | + +The gateway links no group that holds GitHub App credentials or a model key. +`ENV_GROUPS` in +[`scripts/lib/requirements.ts`](../scripts/lib/requirements.ts) defines this +layout for setup and `doctor`. + +A Blueprint can't define a group that holds secrets it doesn't commit, so the +groups must exist in the workspace before the Blueprint is applied. Create or +update them from local `.env`: + +```bash +npm run setup:render -- --workspace +``` + +The command writes nothing until every value is set. 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. + +To rotate a secret, change it in `.env`, rerun `npm run setup:render`, and +redeploy the services that link the group. + +### Create the groups in the Dashboard + +To keep the Render API key out of local `.env`, create the groups by hand +before applying the Blueprint. In the workspace, open **Environment Groups** +and create each group in the table with the exact name and variables shown. +Paste values from `.env` or from their source without showing them in chat. +Workflows `doctor` still reads these values locally, so export them in the +shell where it runs. diff --git a/docs/quickstart.md b/docs/quickstart.md index cdd5f04..a0c2da4 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -28,20 +28,25 @@ Configure with base branch . Stop at every human checkpoint. Do not print or expose secrets. ``` -The agent will configure the fork, create a GitHub App, deploy the Blueprint, -connect the webhook, verify the deployment, and guide one Triage smoke test. +The agent will configure the fork, create a GitHub App, create the Render +environment groups, deploy the Blueprint, connect the webhook, verify the +deployment, and guide one Triage smoke test. You must take over for: - approval to commit or push; - GitHub App creation and installation; -- paid Render resource creation; - secret entry; +- paid Render resource creation; - webhook activation; and - creation or labeling of the smoke-test issue. -Enter secrets directly in `.env`, GitHub, or the Render Dashboard. Never put -them in chat, issues, commits, or command output. +You enter each secret once, in local `.env`: the Render API key and the +Anthropic API key. The GitHub App setup writes its own values there. +`npm run setup:render` copies them into +[environment groups](configuration.md#environment-groups) that both services +link, so the Blueprint form asks for nothing. Never put secrets in chat, +issues, commits, or command output. ## Definition of done diff --git a/docs/runbooks/extend.md b/docs/runbooks/extend.md index 9a71f8e..082b84c 100644 --- a/docs/runbooks/extend.md +++ b/docs/runbooks/extend.md @@ -297,6 +297,10 @@ Before handing off, confirm: - Prompt, agent, schema, or Workflows changes require a Workflows deploy. - Gateway route changes require a gateway deploy. - App permission, event, or label changes require `npm run setup:github`. +- A new secret, such as another provider key, goes in `ENV_GROUPS` in + `scripts/lib/requirements.ts`. Add it to `.env`, rerun + `npm run setup:render`, and redeploy the services that link its group. See + [Environment groups](../configuration.md#environment-groups). - Changes under `app/runner/` or `sandbox/` also require a snapshot refresh for every configured repository. diff --git a/docs/runbooks/setup.md b/docs/runbooks/setup.md index 11c4e93..5656ee5 100644 --- a/docs/runbooks/setup.md +++ b/docs/runbooks/setup.md @@ -20,12 +20,13 @@ Collect: - Render workspace with Sandboxes access in Oregon; and - confirmation that the default Anthropic models are acceptable. -The user must enter these secrets directly in local files, GitHub, or Render: +Secrets reach Render through local `.env` and +[environment groups](../configuration.md#environment-groups), never through +chat or the Blueprint form: -- Render API key; -- Anthropic API key; -- GitHub App private key; and -- webhook secret. +- `setup:app` writes the GitHub App private key and webhook secret to `.env`; +- the user adds the Render API key and Anthropic API key to `.env`; and +- `setup:render` copies them into the env groups that `render.yaml` links. Never ask the user to paste a secret into chat. Never print `.env`. @@ -167,7 +168,57 @@ for an owner to approve the installation. Continue only after the user confirms access to every configured repository. -## Phase 6: Deploy the Blueprint +## Phase 6: Create the Render env groups + +`render.yaml` reads every secret from three +[environment groups](../configuration.md#environment-groups) that must exist +in the workspace before the Blueprint is applied. `setup:render` creates them +from local `.env`. + +Ask which workspace to deploy to. It needs Sandboxes access in Oregon. The +workspace ID starts with `tea-` and is not a secret. If the Render CLI is +signed in, list the IDs with: + +```bash +render workspaces -o json +``` + +Otherwise, ask the user to copy the ID from **Workspace Settings**. + +### Human checkpoint: secret entry + +Ask the user to add these lines to `.env` in their editor: + +```text +RENDER_API_KEY= +ANTHROPIC_API_KEY= +``` + +The Anthropic key enters the Sandbox during agent runs. Do not read or print +`.env` to check the values; `setup:render` reports which are missing. + +Run: + +```bash +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: + +- `software-factory-shared`, for both services: `RENDER_API_KEY`; +- `software-factory-gateway`, for the gateway: `GITHUB_WEBHOOK_SECRET`; and +- `software-factory-workflows`, for Workflows: `RENDER_WORKSPACE_ID`, + `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, and `ANTHROPIC_API_KEY`. + +It saves the workspace ID to `.env`, never prints a secret, and is safe to +rerun. Continue only when it reports no failed check. + +If the user would rather not keep the Render API key in `.env`, they can +create the same groups in the Dashboard instead. See +[Environment groups](../configuration.md#environment-groups). + +## Phase 7: Deploy the Blueprint [`render.yaml`](../../render.yaml) creates two paid resources in the `production` environment of a `software-factory` project: @@ -175,30 +226,25 @@ Continue only after the user confirms access to every configured repository. - `factory-gateway`, a Starter web service in Oregon; and - `factory-workflows`, a Workflows service in Oregon. -### Human checkpoint: resources and secrets - -Explain the resources and expected charges. Ask the user to create the -Blueprint from the configured fork and branch. +If the Render CLI is signed in to the same workspace, confirm that the +Blueprint resolves its env groups: -Enter these values for `factory-gateway`: - -- `GITHUB_WEBHOOK_SECRET`; -- `RENDER_API_KEY`. +```bash +render blueprints validate render.yaml +``` -Enter these values for `factory-workflows`: +`depends on non-existent group` means Phase 6 ran in a different workspace or +did not finish. -- `RENDER_API_KEY`; -- `RENDER_WORKSPACE_ID`; -- `GITHUB_APP_ID`; -- `GITHUB_APP_PRIVATE_KEY`; -- `ANTHROPIC_API_KEY`. +### Human checkpoint: resources -The workspace ID starts with `tea-`. Copy GitHub values from local `.env` -without showing them in chat or command output. +Explain the resources and expected charges. Ask the user to create the +Blueprint from the configured fork and branch, in the workspace from Phase 6, +and apply it. The form asks for no values. Continue when both services have deployed and the gateway is live. -## Phase 7: Configure GitHub +## Phase 8: Configure GitHub Ask for the public gateway URL. It is not a secret. Run: @@ -218,7 +264,7 @@ The command: It is safe to rerun. Continue only when it reports no failed repository or webhook operation. -## Phase 8: Activate the webhook +## Phase 9: Activate the webhook `setup:github` cannot activate an App webhook through GitHub's API. @@ -227,7 +273,7 @@ webhook operation. Ask the user to open the App's **Webhook** settings, confirm that the URL ends in `/webhooks/github`, enable **Active**, and save without changing the secret. -## Phase 9: Run `doctor` +## Phase 10: Run `doctor` `doctor` checks the environment in which it runs. It is read-only and does not print secret values. @@ -247,16 +293,11 @@ This checks the gateway's deployed environment. Workflows services have no Shell. Run `doctor` locally with the values used by `factory-workflows`. -Local `.env` already contains the GitHub App values. Ask the user to add: - -- `RENDER_API_KEY`; -- `RENDER_WORKSPACE_ID`; -- `ANTHROPIC_API_KEY`; and -- `RENDER_WORKFLOW_SLUG`. - -Use the deployed workflow slug, including any suffix Render added. Copy the -exact value from `factory-gateway`'s deployed `RENDER_WORKFLOW_SLUG` into -local `.env`. +Local `.env` already holds the values `setup:render` copied into the +`software-factory-shared` and `software-factory-workflows` groups. It also +needs `RENDER_WORKFLOW_SLUG`, which is not a secret. Use the deployed workflow +slug, including any suffix Render added: copy the exact value from +`factory-gateway`'s deployed `RENDER_WORKFLOW_SLUG` into local `.env`. Run: @@ -268,7 +309,7 @@ The warning `no webhook deliveries yet, so the hook may be inactive` is expected before the smoke test. Do not continue while either command has a failed check. -## Phase 10: Smoke-test Triage +## Phase 11: Smoke-test Triage ### Human checkpoint: issue and label @@ -315,7 +356,10 @@ render logs -r --text run.finished ### A service does not deploy -- For a missing `sync: false` value, set it and redeploy. +- For `depends on non-existent group`, run `npm run setup:render` in the + workspace that holds the Blueprint, then retry the Blueprint sync. +- For a missing or wrong value, fix it in `.env`, rerun + `npm run setup:render`, and redeploy the affected service. - For a build failure, inspect the failing service's build log before changing configuration. - For a gateway health failure, confirm that it binds to `0.0.0.0:$PORT` and @@ -367,7 +411,8 @@ Repeated errors for one agent usually require a prompt or schema change. ### An agent cannot start -Check the selected provider key on `factory-workflows`, then run: +Check the selected provider key in the `software-factory-workflows` env +group, then run: ```bash npm run doctor -- --service workflows diff --git a/package.json b/package.json index 4755ba1..77cbe19 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: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", "lint": "biome lint --error-on-warnings app scripts tests factory.config.ts", diff --git a/render.yaml b/render.yaml index cae47ad..eab7ff8 100644 --- a/render.yaml +++ b/render.yaml @@ -3,6 +3,10 @@ # - factory-workflows: runs every task (agents, snapshot refresh, cancel). It has no `repo` field, so it # deploys from the same repository as this Blueprint, including forks. # Run and agent telemetry is logged as JSON lines; find it in each service's logs or a log stream. +# +# Secrets live in three environment groups that `npm run setup:render` creates from .env before this +# Blueprint is applied. Blueprint groups can't hold `sync: false` values, so the groups are not defined here. +# The gateway links no group that holds GitHub App credentials or a model key. projects: - name: software-factory @@ -18,10 +22,8 @@ projects: startCommand: npm run start:gateway healthCheckPath: /ready envVars: - - key: GITHUB_WEBHOOK_SECRET - sync: false - - key: RENDER_API_KEY - sync: false + - fromGroup: software-factory-shared + - fromGroup: software-factory-gateway - key: RENDER_WORKFLOW_SLUG fromService: type: workflow @@ -35,13 +37,5 @@ projects: buildCommand: npm ci startCommand: npm run start:workflows envVars: - - key: RENDER_API_KEY - sync: false - - key: RENDER_WORKSPACE_ID - sync: false - - key: GITHUB_APP_ID - sync: false - - key: GITHUB_APP_PRIVATE_KEY - sync: false - - key: ANTHROPIC_API_KEY - sync: false + - fromGroup: software-factory-shared + - fromGroup: software-factory-workflows diff --git a/scripts/lib/doctor.ts b/scripts/lib/doctor.ts index c449387..9862312 100644 --- a/scripts/lib/doctor.ts +++ b/scripts/lib/doctor.ts @@ -4,7 +4,16 @@ import { PROVIDER_KEY_ENV } from "../../app/runner/keys.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 { LABELS, missingEvents, missingPermissions, SECRET_ENV, SERVICE_ENV, type Service, WEBHOOK_PATH } from "./requirements.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 { @@ -31,7 +40,7 @@ function checkEnv({ env, services }: DoctorOptions, out: Reporter): void { out.heading(`Environment: factory-${service}`); for (const name of SERVICE_ENV[service]) { if (env[name]?.trim()) out.pass(`${name} is set`); - else out.fail(`${name} is not set`, `Set it on factory-${service} (render.yaml declares it)`); + else out.fail(`${name} is not set`, setFix(service, name)); } const workspace = env.RENDER_WORKSPACE_ID?.trim(); if (service === "workflows" && workspace && !workspace.startsWith("tea-")) { @@ -40,6 +49,12 @@ function checkEnv({ env, services }: DoctorOptions, out: Reporter): void { } } +function setFix(service: Service, name: string): string { + const group = envGroupFor(service, name); + if (!group) return `Set it on factory-${service} (render.yaml declares it)`; + return `Set it in the ${group.name} env group: add it to .env and run \`npm run setup:render\``; +} + /** The env var each configured provider's key is read from, in config order. */ function modelKeys(models: Readonly>): string[] { const names = Object.values(models) @@ -68,9 +83,12 @@ function checkModels({ env, models }: DoctorOptions, out: Reporter): void { continue; } if (env[keyName]?.trim()) out.pass(`${label}: ${keyName} is set`); - else out.fail(`${label}: ${keyName} is not set`, "Set it on factory-workflows; use a spend-capped key"); + else out.fail(`${label}: ${keyName} is not set`, `${setFix("workflows", keyName)}; use a spend-capped key`); if (!SERVICE_ENV.workflows.includes(keyName)) { - out.warn(`render.yaml doesn't declare ${keyName}`, "Add it to factory-workflows' envVars with sync: false"); + out.warn( + `setup:render doesn't manage ${keyName}`, + "Add it to the software-factory-workflows group in ENV_GROUPS and SERVICE_ENV (scripts/lib/requirements.ts)", + ); } } } diff --git a/scripts/lib/requirements.ts b/scripts/lib/requirements.ts index 401f8f2..6a6f9b7 100644 --- a/scripts/lib/requirements.ts +++ b/scripts/lib/requirements.ts @@ -60,12 +60,39 @@ export const LABELS: readonly LabelSpec[] = [ export type Service = "gateway" | "workflows"; -/** Each service's env vars, exactly as render.yaml declares them. */ +/** Each service's env vars, exactly as render.yaml declares them (through its env groups or directly). */ export const SERVICE_ENV: Readonly> = { gateway: ["GITHUB_WEBHOOK_SECRET", "RENDER_API_KEY", "RENDER_WORKFLOW_SLUG"], workflows: ["RENDER_API_KEY", "RENDER_WORKSPACE_ID", "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", "ANTHROPIC_API_KEY"], }; +export interface EnvGroupSpec { + name: string; + /** The services whose render.yaml entry links the group with `fromGroup`. */ + services: readonly Service[]; + keys: readonly string[]; +} + +/** + * The environment groups setup:render creates from .env and render.yaml links. Each service gets every key in + * SERVICE_ENV from these except RENDER_WORKFLOW_SLUG, which the Blueprint wires from the Workflows service. + * The gateway links no group that holds GitHub App credentials or a model key. + */ +export const ENV_GROUPS: readonly EnvGroupSpec[] = [ + { name: "software-factory-shared", services: ["gateway", "workflows"], keys: ["RENDER_API_KEY"] }, + { name: "software-factory-gateway", services: ["gateway"], keys: ["GITHUB_WEBHOOK_SECRET"] }, + { + name: "software-factory-workflows", + services: ["workflows"], + keys: ["RENDER_WORKSPACE_ID", "GITHUB_APP_ID", "GITHUB_APP_PRIVATE_KEY", "ANTHROPIC_API_KEY"], + }, +]; + +/** The group a service reads `key` from, if any. */ +export function envGroupFor(service: Service, key: string): EnvGroupSpec | undefined { + return ENV_GROUPS.find((group) => group.services.includes(service) && group.keys.includes(key)); +} + /** Values doctor must never print. */ export const SECRET_ENV: readonly string[] = ["GITHUB_WEBHOOK_SECRET", "RENDER_API_KEY", "GITHUB_APP_PRIVATE_KEY"]; diff --git a/scripts/lib/setup-render.ts b/scripts/lib/setup-render.ts new file mode 100644 index 0000000..5eff329 --- /dev/null +++ b/scripts/lib/setup-render.ts @@ -0,0 +1,140 @@ +/** + * setup:render: create or update the environment groups render.yaml links, from local values. Idempotent. + * It writes only the keys in ENV_GROUPS and leaves other variables in a group alone. + */ +import type { Env, ScriptDeps } from "./github.js"; +import { errorMessage, type Reporter } from "./output.js"; +import { ENV_GROUPS, type EnvGroupSpec } from "./requirements.js"; + +/** Values safe to show; every other group value is redacted from output. */ +const NOT_SECRET = new Set(["RENDER_WORKSPACE_ID", "GITHUB_APP_ID"]); + +export interface SetupRenderOptions extends ScriptDeps { + renderApiUrl?: string; +} + +interface EnvGroup { + id: string; + name: string; + ownerId: string; + envVars?: { key: string; value: string }[]; +} + +class RenderApi { + private readonly base: string; + + constructor( + private readonly apiKey: string, + private readonly deps: SetupRenderOptions, + ) { + this.base = `${(deps.renderApiUrl ?? "https://api.render.com").replace(/\/+$/, "")}/v1`; + } + + async request(method: string, path: string, body?: unknown): Promise<{ status: number; data: T }> { + const response = await (this.deps.fetch ?? fetch)(`${this.base}${path}`, { + method, + headers: { + Authorization: `Bearer ${this.apiKey}`, + Accept: "application/json", + ...(body === undefined ? {} : { "Content-Type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(15_000), + }); + const data = (await response.json().catch(() => undefined)) as T; + return { status: response.status, data }; + } + + async expect(method: string, path: string, body?: unknown): Promise { + const { status, data } = await this.request(method, path, body); + if (status < 200 || status >= 300) { + const message = (data as { message?: string } | undefined)?.message; + throw new Error(`${method} ${path.split("?")[0]} returned ${status}${message ? `: ${message}` : ""}`); + } + return data; + } +} + +export async function setupRender(opts: SetupRenderOptions, out: Reporter): Promise { + const { env } = opts; + for (const group of ENV_GROUPS) out.addSecrets(...group.keys.filter((key) => !NOT_SECRET.has(key)).map((key) => env[key])); + + out.heading("Values from .env"); + let missing = false; + for (const group of ENV_GROUPS) { + for (const key of group.keys) { + if (env[key]?.trim()) continue; + missing = true; + out.fail(`${key} is not set`, `Add it to .env; it goes in the ${group.name} env group`); + } + } + const workspace = env.RENDER_WORKSPACE_ID?.trim(); + if (workspace && !workspace.startsWith("tea-")) { + missing = true; + out.fail("RENDER_WORKSPACE_ID is not a workspace ID", "It looks like tea-…; find it in Workspace Settings"); + } + // Writing nothing until every value is present keeps a half-filled group from reaching a deploy. + if (missing || !workspace) return out.summary(); + out.pass(`all ${ENV_GROUPS.reduce((n, group) => n + group.keys.length, 0)} values are set`); + + const api = new RenderApi(env.RENDER_API_KEY?.trim() ?? "", opts); + out.heading(`Render workspace ${workspace}`); + const owner = await api.request<{ name?: string }>("GET", `/owners/${encodeURIComponent(workspace)}`); + if (owner.status === 401 || owner.status === 403) { + out.fail(`RENDER_API_KEY was rejected by the Render API (${owner.status})`, "Create a key in Account Settings > API Keys"); + return out.summary(); + } + if (owner.status === 404) { + out.fail(`workspace ${workspace} was not found`, "Check RENDER_WORKSPACE_ID and that the API key's user belongs to the workspace"); + return out.summary(); + } + if (owner.status < 200 || owner.status >= 300) { + out.fail(`the Render API returned ${owner.status} for the workspace`); + return out.summary(); + } + out.pass(`RENDER_API_KEY can access ${owner.data?.name ?? workspace}`); + + for (const group of ENV_GROUPS) { + try { + await syncGroup(api, group, workspace, env, out); + } catch (error) { + out.fail(`could not set up ${group.name}: ${errorMessage(error)}`); + } + } + return out.summary(); +} + +async function syncGroup(api: RenderApi, spec: EnvGroupSpec, workspace: string, env: Env, out: Reporter): Promise { + out.heading(`Env group ${spec.name} (${spec.services.map((s) => `factory-${s}`).join(", ")})`); + const query = new URLSearchParams({ name: spec.name, ownerId: workspace, limit: "100" }); + const listed = await api.expect<{ envGroup: EnvGroup }[]>("GET", `/env-groups?${query}`); + // Blueprints link groups by name, so a second group with the same name makes the link ambiguous. + const matches = (listed ?? []).map((item) => item.envGroup).filter((group) => group.name === spec.name && group.ownerId === workspace); + if (matches.length > 1) { + out.fail(`${matches.length} env groups are named ${spec.name}`, "Delete or rename the extra groups in the Render Dashboard"); + return; + } + + const values = Object.fromEntries(spec.keys.map((key) => [key, env[key]?.trim() ?? ""])); + const existing = matches[0]; + if (!existing) { + await api.expect("POST", "/env-groups", { + name: spec.name, + ownerId: workspace, + envVars: spec.keys.map((key) => ({ key, value: values[key] })), + }); + out.pass(`created with ${spec.keys.join(", ")}`); + return; + } + + const current = await api.expect("GET", `/env-groups/${encodeURIComponent(existing.id)}`); + const stored = new Map((current.envVars ?? []).map((v) => [v.key, v.value])); + for (const key of spec.keys) { + if (stored.get(key) === values[key]) { + out.pass(`${key} unchanged`); + continue; + } + await api.expect("PUT", `/env-groups/${encodeURIComponent(existing.id)}/env-vars/${encodeURIComponent(key)}`, { value: values[key] }); + out.pass(`${key} ${stored.has(key) ? "updated" : "added"}`); + } +} diff --git a/scripts/setup-app.ts b/scripts/setup-app.ts index cd694f1..5e7dedd 100644 --- a/scripts/setup-app.ts +++ b/scripts/setup-app.ts @@ -4,7 +4,7 @@ * npm run setup:app [-- --org ] [--name ] [--write-env] * * The App's webhook starts inactive at a placeholder URL; `npm run setup:github` points it at the gateway once the - * Blueprint is deployed. Prints the values the Blueprint form asks for. + * Blueprint is deployed. `npm run setup:render` copies the App's values into Render env groups before that. */ import { randomBytes } from "node:crypto"; import { parseArgs } from "node:util"; @@ -67,21 +67,20 @@ console.log(`\n${bold("1. Install the App")} on every repository in factory.conf console.log(` ${app.html_url}/installations/new`); for (const repo of repos) console.log(` - ${repo}`); -console.log(`\n${bold("2. Deploy the Blueprint")} and paste these into its form:`); -console.log(dim(" factory-workflows")); -console.log(` GITHUB_APP_ID=${env.GITHUB_APP_ID}`); -console.log(` GITHUB_APP_PRIVATE_KEY=${env.GITHUB_APP_PRIVATE_KEY}`); -console.log(dim(" factory-gateway")); -console.log(` GITHUB_WEBHOOK_SECRET=${env.GITHUB_WEBHOOK_SECRET}`); -console.log(dim(" The private key is base64-encoded so it fits on one line. Keep these values secret.")); - -console.log(`\n${bold("3. Connect the webhook")} once the gateway is live:`); -console.log(" npm run setup:github -- --gateway-url https://.onrender.com --webhook-secret "); - if (values["write-env"]) { await writeEnvFile(".env", env); console.log(`\n${green("✓")} Wrote GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, and GITHUB_WEBHOOK_SECRET to .env`); - console.log(dim(" setup:github and doctor read .env, so --webhook-secret can be omitted.")); } else { - console.log(yellow("\nThe private key isn't saved anywhere else. Rerun with --write-env to also write .env.")); + console.log(`\n${bold("Add these to .env")}; setup:render, setup:github, and doctor read them from there:`); + console.log(` GITHUB_APP_ID=${env.GITHUB_APP_ID}`); + console.log(` GITHUB_APP_PRIVATE_KEY=${env.GITHUB_APP_PRIVATE_KEY}`); + console.log(` GITHUB_WEBHOOK_SECRET=${env.GITHUB_WEBHOOK_SECRET}`); + console.log(dim(" The private key is base64-encoded so it fits on one line. Keep these values secret.")); + console.log(yellow(" The private key isn't saved anywhere else. Rerun with --write-env to write .env instead.")); } + +console.log(`\n${bold("2. Create the Render env groups")} after adding RENDER_API_KEY, RENDER_WORKSPACE_ID, and ANTHROPIC_API_KEY to .env:`); +console.log(" npm run setup:render"); + +console.log(`\n${bold("3. Deploy the Blueprint")}, then connect the webhook once the gateway is live:`); +console.log(" npm run setup:github -- --gateway-url https://.onrender.com"); diff --git a/scripts/setup-render.ts b/scripts/setup-render.ts new file mode 100644 index 0000000..bb05199 --- /dev/null +++ b/scripts/setup-render.ts @@ -0,0 +1,30 @@ +/** + * Step 2 of setup, after the GitHub App exists and before the Blueprint is applied: create the Render environment + * groups that render.yaml links, so the Blueprint deploys without asking for any value. Idempotent; never prints + * a secret. + * + * npm run setup:render [-- --workspace tea-…] + * + * 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 { parseArgs } from "node:util"; +import { writeEnvFile } from "./lib/env-file.js"; +import { errorMessage, Reporter } from "./lib/output.js"; +import { setupRender } from "./lib/setup-render.js"; + +const { values } = parseArgs({ options: { workspace: { type: "string" } } }); +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); + if (workspace && process.exitCode === 0) { + await writeEnvFile(".env", { RENDER_WORKSPACE_ID: workspace }); + out.line(`Saved RENDER_WORKSPACE_ID=${workspace} to .env`); + } +} catch (error) { + out.fail(errorMessage(error)); + process.exitCode = 1; +} diff --git a/tests/scripts-setup-render.test.ts b/tests/scripts-setup-render.test.ts new file mode 100644 index 0000000..91aa68a --- /dev/null +++ b/tests/scripts-setup-render.test.ts @@ -0,0 +1,177 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { Reporter } from "../scripts/lib/output.js"; +import { ENV_GROUPS, SERVICE_ENV } from "../scripts/lib/requirements.js"; +import { setupRender } from "../scripts/lib/setup-render.js"; + +const WORKSPACE = "tea-abc123"; +const SECRETS = { + RENDER_API_KEY: "rnd_renderSECRETkey", + GITHUB_WEBHOOK_SECRET: "whsec-super-secret-value", + GITHUB_APP_PRIVATE_KEY: "cHJpdmF0ZS1rZXktU0VDUkVU", + ANTHROPIC_API_KEY: "sk-ant-SECRET-model-key", +}; +const ENV = { ...SECRETS, RENDER_WORKSPACE_ID: WORKSPACE, GITHUB_APP_ID: "1" }; + +interface StoredGroup { + id: string; + name: string; + ownerId: string; + envVars: { key: string; value: string }[]; +} + +interface Call { + method: string; + path: string; + auth: string; + body: unknown; +} + +/** A Render API with an in-memory set of env groups. */ +function renderWorld(opts: { groups?: StoredGroup[]; ownerStatus?: number } = {}) { + const groups = opts.groups ?? []; + const calls: Call[] = []; + const fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(String(input)); + const headers = (init?.headers ?? {}) as Record; + const call: Call = { + method: init?.method ?? "GET", + path: url.pathname, + auth: headers.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" } }); + const groupPath = /^\/v1\/env-groups\/([^/]+)(?:\/env-vars\/([^/]+))?$/.exec(url.pathname); + if (call.method === "GET" && url.pathname === `/v1/owners/${WORKSPACE}`) { + return opts.ownerStatus ? json({ message: "nope" }, opts.ownerStatus) : json({ id: WORKSPACE, name: "Acme", type: "team" }); + } + if (call.method === "GET" && url.pathname === "/v1/env-groups") { + const name = url.searchParams.get("name") ?? ""; + return json(groups.filter((g) => g.name.includes(name)).map(({ envVars: _, ...envGroup }) => ({ cursor: envGroup.id, envGroup }))); + } + if (call.method === "POST" && url.pathname === "/v1/env-groups") { + const body = call.body as Omit; + const group = { ...body, id: `evg-${groups.length + 1}` }; + groups.push(group); + return json(group, 201); + } + const group = groupPath && groups.find((g) => g.id === groupPath[1]); + if (group && call.method === "GET" && !groupPath[2]) return json(group); + if (group && call.method === "PUT" && groupPath[2]) { + const key = decodeURIComponent(groupPath[2]); + const value = (call.body as { value: string }).value; + group.envVars = [...group.envVars.filter((v) => v.key !== key), { key, value }]; + return json(group); + } + return json({ message: "Not Found" }, 404); + }) as typeof globalThis.fetch; + return { fetch, calls, groups }; +} + +async function run(world: ReturnType, env: Record = ENV) { + const lines: string[] = []; + const code = await setupRender({ env, fetch: world.fetch }, new Reporter((line) => lines.push(line))); + return { code, output: lines.join("\n") }; +} + +const writes = (calls: Call[]) => calls.filter((c) => c.method !== "GET"); + +describe("setup:render", () => { + it("creates each group with exactly its keys, and keeps App and model credentials off the gateway", async () => { + const world = renderWorld(); + const { code, output } = await run(world); + expect(code).toBe(0); + + const byName = Object.fromEntries(world.groups.map((g) => [g.name, Object.fromEntries(g.envVars.map((v) => [v.key, v.value]))])); + expect(byName).toEqual({ + "software-factory-shared": { RENDER_API_KEY: SECRETS.RENDER_API_KEY }, + "software-factory-gateway": { GITHUB_WEBHOOK_SECRET: SECRETS.GITHUB_WEBHOOK_SECRET }, + "software-factory-workflows": { + RENDER_WORKSPACE_ID: WORKSPACE, + GITHUB_APP_ID: "1", + GITHUB_APP_PRIVATE_KEY: SECRETS.GITHUB_APP_PRIVATE_KEY, + ANTHROPIC_API_KEY: SECRETS.ANTHROPIC_API_KEY, + }, + }); + expect(world.groups.every((g) => g.ownerId === WORKSPACE)).toBe(true); + expect(world.calls.every((c) => c.auth === `Bearer ${SECRETS.RENDER_API_KEY}`)).toBe(true); + for (const secret of Object.values(SECRETS)) expect(output).not.toContain(secret); + }); + + it("writes nothing when a value is missing", async () => { + const world = renderWorld(); + const { code, output } = await run(world, { ...ENV, ANTHROPIC_API_KEY: "" }); + expect(code).toBe(1); + expect(output).toContain("ANTHROPIC_API_KEY is not set"); + expect(world.calls).toEqual([]); + }); + + it("updates only changed keys and leaves keys it doesn't manage alone", async () => { + const world = renderWorld({ + groups: [ + { id: "evg-a", name: "software-factory-shared", ownerId: WORKSPACE, envVars: [{ key: "RENDER_API_KEY", value: SECRETS.RENDER_API_KEY }] }, + { id: "evg-b", name: "software-factory-gateway", ownerId: WORKSPACE, envVars: [{ key: "GITHUB_WEBHOOK_SECRET", value: "old" }] }, + { + id: "evg-c", + name: "software-factory-workflows", + ownerId: WORKSPACE, + envVars: [{ key: "GITHUB_API_URL", value: "https://ghe.example.com/api/v3" }], + }, + ], + }); + const { code } = await run(world); + expect(code).toBe(0); + expect(writes(world.calls).map((c) => `${c.method} ${c.path}`)).toEqual([ + "PUT /v1/env-groups/evg-b/env-vars/GITHUB_WEBHOOK_SECRET", + "PUT /v1/env-groups/evg-c/env-vars/RENDER_WORKSPACE_ID", + "PUT /v1/env-groups/evg-c/env-vars/GITHUB_APP_ID", + "PUT /v1/env-groups/evg-c/env-vars/GITHUB_APP_PRIVATE_KEY", + "PUT /v1/env-groups/evg-c/env-vars/ANTHROPIC_API_KEY", + ]); + expect(world.groups.find((g) => g.id === "evg-c")?.envVars).toContainEqual({ key: "GITHUB_API_URL", value: "https://ghe.example.com/api/v3" }); + + const again = renderWorld({ groups: world.groups }); + await run(again); + expect(writes(again.calls)).toEqual([]); + }); + + it("refuses to pick between two groups with the same name", async () => { + const duplicate = { name: "software-factory-gateway", ownerId: WORKSPACE, envVars: [] }; + const world = renderWorld({ groups: [{ ...duplicate, id: "evg-1" }, { ...duplicate, id: "evg-2" }] }); + const { code, output } = await run(world); + expect(code).toBe(1); + expect(output).toContain("2 env groups are named software-factory-gateway"); + expect(world.groups.filter((g) => g.name === "software-factory-gateway")).toHaveLength(2); + expect(writes(world.calls).filter((c) => /evg-[12]/.test(c.path))).toEqual([]); + }); + + it("stops before any group work when the API key is rejected", async () => { + const world = renderWorld({ ownerStatus: 401 }); + const { code, output } = await run(world); + expect(code).toBe(1); + expect(output).toContain("RENDER_API_KEY was rejected"); + expect(world.calls).toHaveLength(1); + }); +}); + +describe("render.yaml env groups", () => { + const blueprint = readFileSync(new URL("../render.yaml", import.meta.url), "utf8"); + + /** The `fromGroup` names in one service's block of render.yaml. */ + function linkedGroups(service: string): string[] { + const start = blueprint.search(new RegExp(`- type: \\w+\\n\\s+name: ${service}\\n`)); + const next = blueprint.indexOf("- type:", start + 1); + const block = blueprint.slice(start, next < 0 ? undefined : next); + return [...block.matchAll(/fromGroup:\s*(\S+)/g)].map((m) => m[1] ?? ""); + } + + it("links each service to the groups setup:render creates for it, covering SERVICE_ENV", () => { + for (const service of ["gateway", "workflows"] as const) { + const expected = ENV_GROUPS.filter((g) => g.services.includes(service)); + expect(linkedGroups(`factory-${service}`).sort()).toEqual(expected.map((g) => g.name).sort()); + const keys = [...expected.flatMap((g) => g.keys), "RENDER_WORKFLOW_SLUG"]; + expect(keys.filter((k) => SERVICE_ENV[service].includes(k)).sort()).toEqual([...SERVICE_ENV[service]].sort()); + } + }); +});