Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,14 @@ update them from local `.env`:
npm run setup:render -- --workspace <tea-...>
```

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 <tea-...>` 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.
Expand Down
10 changes: 9 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 20 additions & 2 deletions docs/runbooks/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ Run:
```bash
node --version
npm ci
npm run setup:preflight -- --local
npm run check
```

Expand All @@ -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.
Expand Down Expand Up @@ -203,8 +205,24 @@ Run:
npm run setup:render -- --workspace <tea-...>
```

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 <tea-...>
```

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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 9 additions & 1 deletion scripts/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 11 additions & 3 deletions scripts/lib/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>>;
/** The agents whose harnesses to check; defaults to every agent the factory runs. */
agents?: readonly Agent[];
renderApiUrl?: string;
Expand Down Expand Up @@ -103,7 +106,7 @@ async function checkGitHub(opts: DoctorOptions, out: Reporter): Promise<void> {
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);
}

Expand Down Expand Up @@ -150,7 +153,7 @@ async function checkWebhook(app: GitHubApp, info: Awaited<ReturnType<GitHubApp["
}
}

async function checkRepo(app: GitHubApp, info: { slug: string; html_url: string }, fullName: string, out: Reporter) {
async function checkRepo(app: GitHubApp, info: { slug: string; html_url: string }, fullName: string, out: Reporter, baseBranch?: string) {
out.heading(fullName);
try {
const { owner, name } = splitRepo(fullName);
Expand All @@ -167,9 +170,14 @@ async function checkRepo(app: GitHubApp, info: { slug: string; html_url: string
const events = missingEvents(installation.events);
if (events.length) out.warn(`installation isn't receiving: ${events.join(", ")}`, "Accept the App's updated events");

const token = await app.installationToken(installation.id, name, { issues: "read", metadata: "read" });
const token = await app.installationToken(installation.id, name, {
issues: "read",
metadata: "read",
...(baseBranch ? { contents: "read" } : {}),
});
out.addSecrets(token);
out.pass("installation token minted");
await checkRepositoryPrerequisites(app.repo(owner, name, token), fullName, baseBranch, out);

const existing = new Set((await app.repo(owner, name, token).listLabels()).map((l) => l.name.toLowerCase()));
const missing = LABELS.filter((label) => !existing.has(label.name.toLowerCase()));
Expand Down
8 changes: 8 additions & 0 deletions scripts/lib/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,14 @@ class RepoClient {
return this.http.paginate<RepoLabel>(`${this.base}/labels`);
}

metadata(): Promise<unknown> {
return this.http.json("GET", this.base);
}

branch(name: string): Promise<unknown> {
return this.http.json("GET", `${this.base}/branches/${encodeURIComponent(name)}`);
}

async createLabel(label: { name: string; color: string; description: string }): Promise<void> {
await this.http.json("POST", `${this.base}/labels`, { body: label });
}
Expand Down
96 changes: 96 additions & 0 deletions scripts/lib/preflight.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, RepoConfig>>;
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<number> {
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<number> {
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();
}
44 changes: 44 additions & 0 deletions scripts/lib/repo-prerequisites.ts
Original file line number Diff line number Diff line change
@@ -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<GitHubApp["repo"]>,
fullName: string,
baseBranch: string | undefined,
out: Reporter,
): Promise<void> {
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",
);
}
}
14 changes: 14 additions & 0 deletions scripts/preflight.ts
Original file line number Diff line number Diff line change
@@ -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(),
);
8 changes: 6 additions & 2 deletions scripts/setup-render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,22 @@
* 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();

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`);
Expand Down
Loading
Loading