diff --git a/package.json b/package.json index c498e89b..0b29d05a 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.4", "turbo": "^2.5.8", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "yaml": "^2.9.0" } } diff --git a/packages/cli/src/init.mjs b/packages/cli/src/init.mjs index 33eb010a..cc6f1a78 100644 --- a/packages/cli/src/init.mjs +++ b/packages/cli/src/init.mjs @@ -125,6 +125,13 @@ function checksList(checks) { return lines.join("\n"); } +// The provision command is interpolated into a YAML block scalar, not a bare +// scalar: a command containing ": " (and the no-provision fallback below does) +// otherwise renders a workflow GitHub cannot parse. +function provisionRun(command) { + return command.split("\n").map((line) => ` ${line}`).join("\n"); +} + function checksRun(checks) { if (checks.length) return checks.map((command) => ` ${command}`).join("\n"); return [ @@ -406,6 +413,7 @@ export async function init(flags, pkgRoot, version) { CODEX_ARCHITECT_REPO_LANE: "true", CODEX_BUILDER_REPO_LANE: "true", PROVISION_CMD: provisionCmd, + PROVISION_RUN: provisionRun(provisionCmd), CHECKS_INLINE: checksInline, CHECKS_RUN: checksRun(checks), CHECKS_LIST: checksList(checks), diff --git a/packages/cli/templates/workflows/facility-address-review.yml b/packages/cli/templates/workflows/facility-address-review.yml index a2d083c0..bb8ea44c 100644 --- a/packages/cli/templates/workflows/facility-address-review.yml +++ b/packages/cli/templates/workflows/facility-address-review.yml @@ -61,7 +61,8 @@ jobs: # Provisioned job site, so the agent can verify before pushing. - name: Provision environment if: steps.workflow-change.outputs.changed != 'true' - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} {{ANTHROPIC_AUTH_SETUP_CONDITIONAL}} diff --git a/packages/cli/templates/workflows/facility-codex.yml b/packages/cli/templates/workflows/facility-codex.yml index 643a170b..61666b2b 100644 --- a/packages/cli/templates/workflows/facility-codex.yml +++ b/packages/cli/templates/workflows/facility-codex.yml @@ -155,7 +155,8 @@ jobs: {{TOOLCHAIN_STEPS}} - name: Provision environment if: steps.resolve.outputs.run == 'true' - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} - name: Install pinned Codex CLI if: steps.resolve.outputs.run == 'true' diff --git a/packages/cli/templates/workflows/facility-crew.yml b/packages/cli/templates/workflows/facility-crew.yml index 55bc954c..8bf52b84 100644 --- a/packages/cli/templates/workflows/facility-crew.yml +++ b/packages/cli/templates/workflows/facility-crew.yml @@ -186,7 +186,8 @@ jobs: # cannot verify will defer; a provisioned one finishes. - name: Provision environment if: steps.requested-agent.outputs.run == 'true' - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} - name: Capture /builder delivery baseline id: builder-start diff --git a/packages/cli/templates/workflows/facility-doctor.yml b/packages/cli/templates/workflows/facility-doctor.yml index 471a6b55..a6737d87 100644 --- a/packages/cli/templates/workflows/facility-doctor.yml +++ b/packages/cli/templates/workflows/facility-doctor.yml @@ -109,7 +109,8 @@ jobs: # The doctor verifies before pushing, so it gets the same provisioned # world as the crew. - name: Provision environment - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} {{ANTHROPIC_AUTH_SETUP}} diff --git a/packages/cli/templates/workflows/facility-review.yml b/packages/cli/templates/workflows/facility-review.yml index d09ca58b..606030d2 100644 --- a/packages/cli/templates/workflows/facility-review.yml +++ b/packages/cli/templates/workflows/facility-review.yml @@ -48,7 +48,8 @@ jobs: {{TOOLCHAIN_STEPS_CONDITIONAL}} - name: Provision environment if: steps.workflow-change.outputs.changed != 'true' - run: {{PROVISION_CMD}} + run: | +{{PROVISION_RUN}} - name: Capture review baseline id: review-start diff --git a/packages/cli/test/init.test.mjs b/packages/cli/test/init.test.mjs index b33e245b..d5f5c4ed 100644 --- a/packages/cli/test/init.test.mjs +++ b/packages/cli/test/init.test.mjs @@ -1,12 +1,13 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { lstatSync, mkdtempSync, readFileSync, writeFileSync, existsSync, rmSync } from "node:fs"; +import { lstatSync, mkdtempSync, readFileSync, readdirSync, writeFileSync, existsSync, rmSync } from "node:fs"; import { createHash } from "node:crypto"; import { pathToFileURL } from "node:url"; import { tmpdir } from "node:os"; import { join, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { parse as parseYaml } from "yaml"; const pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const cli = join(pkgRoot, "bin", "facility.mjs"); @@ -583,3 +584,66 @@ test("local commands reject unknown, valueless, and conflicting flags", () => { "--local cannot be combined with platform target options", ); }); + +test("every rendered workflow parses whatever the provision command contains", async (t) => { + // A bare `run:` scalar ends at its first ": ", so the no-provision fallback + // text — and any command containing ": " — renders a workflow GitHub cannot + // parse, and the trigger that workflow carries never runs. + const scenarios = [ + { label: "no provision command detected", setup: false, flags: [], run: /^echo "facility: / }, + { + label: "provision command containing \": \"", + setup: true, + flags: ['--provision=docker compose up -d && echo "db: ready"'], + run: 'docker compose up -d && echo "db: ready"', + }, + { label: "ordinary provision command", setup: true, flags: ["--provision=npm run setup"], run: "npm run setup" }, + ]; + + for (const scenario of scenarios) { + const dir = mkdtempSync(join(tmpdir(), "facility-provision-")); + t.after(() => rmSync(dir, { recursive: true, force: true })); + execFileSync("git", ["init", "-b", "main"], { cwd: dir }); + writeFileSync( + join(dir, "package.json"), + JSON.stringify( + { + name: "demo-app", + private: true, + // `setup` is what detection proposes as the provision command, so + // omitting it is what exercises the fallback. + scripts: scenario.setup ? { test: "vitest run", setup: "docker compose up -d" } : { test: "vitest run" }, + }, + null, + 2 + ) + "\n" + ); + writeFileSync(join(dir, "package-lock.json"), "{}\n"); + + const result = runCli(["init", "--yes", `--dir=${dir}`, ...scenario.flags], dir); + assert.equal(result.status, 0, `${scenario.label}: ${result.stdout}${result.stderr}`); + + let provisionSteps = 0; + for (const entry of readdirSync(join(dir, ".github/workflows"))) { + const source = readFileSync(join(dir, ".github/workflows", entry), "utf8"); + let document; + assert.doesNotThrow(() => { + document = parseYaml(source); + }, `${scenario.label}: ${entry} must parse as YAML`); + + for (const job of Object.values(document.jobs ?? {})) { + for (const step of job.steps ?? []) { + if (step.name !== "Provision environment") continue; + provisionSteps += 1; + const run = step.run.trimEnd(); + if (scenario.run instanceof RegExp) { + assert.match(run, scenario.run, `${scenario.label}: ${entry} provision command`); + } else { + assert.equal(run, scenario.run, `${scenario.label}: ${entry} provision command`); + } + } + } + } + assert.equal(provisionSteps, 5, `${scenario.label}: every workflow with a provision step must be covered`); + } +}); diff --git a/packages/core/src/render.ts b/packages/core/src/render.ts index 24f2c498..4538b8a9 100644 --- a/packages/core/src/render.ts +++ b/packages/core/src/render.ts @@ -251,6 +251,17 @@ function checksList(checks: string[]): string { return lines.join("\n"); } +// The provision command is interpolated into a YAML block scalar, not a bare +// scalar: a command containing ": " (and the no-provision fallback does) +// otherwise renders a workflow GitHub cannot parse. Mirrors provisionRun in +// packages/cli/src/init.mjs — the byte-for-byte parity test holds them equal. +function provisionRun(command: string): string { + return command + .split("\n") + .map((line) => ` ${line}`) + .join("\n"); +} + function checksRun(checks: string[]): string { if (checks.length) return checks.map((command) => ` ${command}`).join("\n"); return [ @@ -453,6 +464,7 @@ export async function renderFacilityInit( ? "false" : "true", PROVISION_CMD: provision, + PROVISION_RUN: provisionRun(provision), CHECKS_INLINE: checks.length ? checks.join(" ; ") : "the checks configured in STANDARD.md", CHECKS_LIST: checksList(checks), CHECKS_RUN: checksRun(checks), diff --git a/packages/core/test/core.test.ts b/packages/core/test/core.test.ts index 69eb10dd..882b45cc 100644 --- a/packages/core/test/core.test.ts +++ b/packages/core/test/core.test.ts @@ -280,63 +280,80 @@ describe("fingerprints", () => { }); describe("render", () => { - it("matches the real CLI init output byte-for-byte", async () => { - const dir = mkdtempSync(join(tmpdir(), "facility-core-render-")); - writeFileSync( - join(dir, "package.json"), - `${JSON.stringify( - { - scripts: { - setup: "node setup.mjs", - typecheck: "tsc --noEmit", - test: "vitest run", + // The provision command reaches the workflows through a YAML block scalar, + // so a command containing ": " renders differently from an ordinary one and + // the no-provision fallback contains ": " itself. Both renderers have to + // agree on all three shapes, not just the easy one. + const provisionShapes = [ + { name: "an ordinary provision command", provision: "pnpm run setup", setupScript: true }, + { + name: 'a provision command containing ": "', + provision: 'docker compose up -d && echo "db: ready"', + setupScript: true, + }, + { name: "no provision command, so both fall back", provision: undefined, setupScript: false }, + ]; + + for (const shape of provisionShapes) { + it(`matches the real CLI init output byte-for-byte with ${shape.name}`, async () => { + const dir = mkdtempSync(join(tmpdir(), "facility-core-render-")); + writeFileSync( + join(dir, "package.json"), + `${JSON.stringify( + { + scripts: { + // `setup` is what CLI detection proposes as the provision + // command, so omitting it is what exercises the fallback. + ...(shape.setupScript ? { setup: "node setup.mjs" } : {}), + typecheck: "tsc --noEmit", + test: "vitest run", + }, }, - }, - null, - 2, - )}\n`, - ); - writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); - mkdirSync(join(dir, "migrations"), { recursive: true }); - writeFileSync(join(dir, "migrations/001.sql"), "select 1;\n"); - execFileSync("node", [ - join(process.cwd(), "../cli/bin/facility.mjs"), - "init", - "--yes", - "--dir", - dir, - "--branch", - "main", - "--provision", - "pnpm run setup", - "--checks", - "pnpm run typecheck, pnpm run test", - "--modules", - "database", - ]); - const cliFiles = collect(dir); - const rendered = await renderFacilityInit({ - defaultBranch: "main", - provisionCmd: "pnpm run setup", - checkCmds: ["pnpm run typecheck", "pnpm run test"], - modules: ["database"], - packageManager: "pnpm", - workflowNames: [], + null, + 2, + )}\n`, + ); + writeFileSync(join(dir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + mkdirSync(join(dir, "migrations"), { recursive: true }); + writeFileSync(join(dir, "migrations/001.sql"), "select 1;\n"); + execFileSync("node", [ + join(process.cwd(), "../cli/bin/facility.mjs"), + "init", + "--yes", + "--dir", + dir, + "--branch", + "main", + ...(shape.provision ? ["--provision", shape.provision] : []), + "--checks", + "pnpm run typecheck, pnpm run test", + "--modules", + "database", + ]); + const cliFiles = collect(dir); + const rendered = await renderFacilityInit({ + defaultBranch: "main", + provisionCmd: shape.provision, + checkCmds: ["pnpm run typecheck", "pnpm run test"], + modules: ["database"], + packageManager: "pnpm", + workflowNames: [], + }); + const coreFiles = new Map( + rendered.files.map((file) => [ + file.path, + { + mode: file.mode ?? (file.executable ? "100755" : "100644"), + content: file.content, + }, + ]), + ); + expect([...coreFiles.keys()].sort()).toEqual([...cliFiles.keys()].sort()); + for (const [path, cliFile] of cliFiles) { + expect(coreFiles.get(path), path).toEqual(cliFile); + } }); - const coreFiles = new Map( - rendered.files.map((file) => [ - file.path, - { - mode: file.mode ?? (file.executable ? "100755" : "100644"), - content: file.content, - }, - ]), - ); - expect([...coreFiles.keys()].sort()).toEqual([...cliFiles.keys()].sort()); - for (const [path, cliFile] of cliFiles) { - expect(coreFiles.get(path), path).toEqual(cliFile); - } - }); + } it("keeps platform-lane slash commands out of repo workflows", async () => { const rendered = await renderFacilityInit({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 10db54b1..b053b488 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -46,6 +46,9 @@ importers: typescript: specifier: ^5.9.3 version: 5.9.3 + yaml: + specifier: ^2.9.0 + version: 2.9.0 apps/docs: dependencies: