cast/test/github-app-cli.test.ts
dan-claude-bot b2801938a4 fix: resolve the GitHub App only when the manifest declares applications (#103)
Found live in the 2026-07-19 release drill: a manifest declaring only
databases (applications: {}) rendered its plan of two creates and then
died in preflight on "no GitHub App bound" — over a binding nothing in
the run would ever have used. A GitHub App exists to clone application
source, and cast reads it in exactly one call, the application create
(POST /applications/private-github-app); databases and services never
touch it. Resolving it unconditionally gated infra-only projects — the
databases a fleet's other projects share — behind the GitHub-App
browser-registration ceremony for no reason.

apply now resolves the App (binding lookup and uuid resolution both)
only when the desired state contains at least one application. The
executor's githubAppUuid field is typed string | null, and its single
consumer guards the null with cast's own internal error — unreachable
by construction, since a plan can only create resources the desired
state holds, but a null slipping onto the wire would otherwise surface
as a Coolify 422 about somebody else's field.

Keyed off desired rather than the plan's changes, deliberately: a
manifest that declares an application keeps the missing-binding refusal
even on a clean plan, byte-identical to before — that binding is state
the next create will need, and the operator should hear about it now,
not mid-bootstrap.

Fixes #103

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 12:26:35 +00:00

213 lines
7.4 KiB
TypeScript

import { execFileSync, spawn } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
// When does `apply` need a GitHub App at all? (#103, found live in the
// 2026-07-19 release drill.)
//
// A GitHub App exists to clone application source, and cast reads it in exactly
// one call — the application create. But apply used to resolve it
// unconditionally, after the plan had already rendered: a manifest declaring
// only databases printed its creates and then died in preflight on "no GitHub
// App bound", over a binding nothing in the run would ever have used. That
// gates infra-only projects (databases shared by other projects) behind the
// GitHub-App browser-registration ceremony for no reason.
//
// Both directions are pinned here, end to end against a stub Coolify:
// a databases-only manifest applies with NO github_apps binding and the stub
// never sees a /github-apps request — and a manifest that DOES declare an
// application still refuses on the missing binding, with the same message.
let recipient: string;
let keyFile: string;
beforeAll(() => {
const dir = mkdtempSync(join(tmpdir(), "cast-age-"));
keyFile = join(dir, "age.key");
execFileSync("age-keygen", ["-o", keyFile], { stdio: "pipe" });
recipient = execFileSync("age-keygen", ["-y", keyFile], {
encoding: "utf8",
}).trim();
});
type Stub = { url: string; hits: string[]; close: () => Promise<void> };
const stubs: Stub[] = [];
// A box holding the project and its (empty) environment, so the plan is pure
// creates — the drill's shape. `hits` records "METHOD path" so a test can
// assert which routes a run touched, and which it never did.
async function stubCoolify(): Promise<Stub> {
const hits: string[] = [];
const server = createServer((req, res) => {
const path = new URL(req.url ?? "", "http://x").pathname.replace(
"/api/v1",
"",
);
hits.push(`${req.method} ${path}`);
const json = (body: unknown) => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
if (path === "/teams/current") return json({ id: 0, name: "Root Team" });
if (path === "/servers") return json([{ uuid: "s1", name: "drill-box" }]);
if (path === "/projects" && req.method === "GET")
return json([{ uuid: "p1", name: "drill-widget" }]);
if (path === "/projects/p1/staging") return json({});
if (path === "/projects/p1/environments")
return json([{ name: "staging" }]);
// The domain preflight's instance-wide read (only an application plan asks).
if (path === "/applications" && req.method === "GET") return json([]);
if (path === "/databases/redis" && req.method === "POST")
return json({ uuid: "db-9" });
if (path === "/deploy") return json({});
res.writeHead(404);
res.end("{}");
});
await new Promise<void>((r) => {
server.listen(0, "127.0.0.1", r);
});
const stub: Stub = {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
hits,
close: () =>
new Promise<void>((r) => {
server.close(() => r());
}),
};
stubs.push(stub);
return stub;
}
afterEach(async () => {
await Promise.all(stubs.splice(0).map((s) => s.close()));
});
// The drill's manifest: applications declared, and empty — this project IS its
// databases.
const DATABASES_ONLY = `project: drill-widget
environments:
staging:
applications: {}
databases:
cache:
type: redis
`;
const WITH_APPLICATION = `project: drill-widget
environments:
staging:
applications:
web:
source: { repo: heavy-duty/drill-widget, branch: main }
build: { pack: nixpacks, base_directory: / }
domains: ["http://web.example.com"]
databases:
cache:
type: redis
`;
// The state file the issue is about: no github_apps entry for this repo at all.
function fixture(url: string, manifest: string) {
const checkout = mkdtempSync(join(tmpdir(), "cast-co-"));
mkdirSync(join(checkout, ".infra", "env"), { recursive: true });
writeFileSync(join(checkout, ".infra", "manifest.yaml"), manifest);
const state = mkdtempSync(join(tmpdir(), "cast-state-"));
mkdirSync(join(state, "secrets"));
writeFileSync(
join(state, ".coolify.env"),
`COOLIFY_BASE_URL="${url}"\nCOOLIFY_ACCESS_TOKEN="t"\n`,
);
// No template refs any secret, but the store still has to exist and open.
execFileSync("age", ["-r", recipient, "-o", "drill-widget.staging.env.age"], {
input: "\n",
cwd: join(state, "secrets"),
stdio: ["pipe", "pipe", "pipe"],
});
writeFileSync(
join(state, "environments.yaml"),
[
"environments:",
" staging:",
" server: drill-box",
" team: { id: 0, name: Root Team }",
"github_apps: {}",
"",
].join("\n"),
);
return { checkout, state };
}
function run(f: {
checkout: string;
state: string;
}): Promise<{ code: number; output: string }> {
return new Promise((resolve) => {
const child = spawn(
"node",
[
"dist/cli.js",
"apply",
"heavy-duty/drill-widget",
"--env",
"staging",
"--path",
f.checkout,
"--state",
f.state,
],
{
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, CAST_AGE_KEY_FILE_STAGING: keyFile },
},
);
let output = "";
child.stdout.on("data", (d) => {
output += String(d);
});
child.stderr.on("data", (d) => {
output += String(d);
});
child.on("close", (code) => resolve({ code: code ?? 0, output }));
});
}
describe("cast apply — GitHub App resolved only for applications (#103)", () => {
it("applies a databases-only manifest with no github_apps binding, never asking for one", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, DATABASES_ONLY);
const r = await run(f);
expect(r.code).toBe(0);
// The apply went all the way through: the database was created and
// redeployed, not merely planned.
expect(r.output).toContain("applied + redeployed: cache");
expect(stub.hits).toContain("POST /databases/redis");
// The load-bearing absence: nothing in this run may ask Coolify for a
// GitHub App — the resolution, not just the create, must be skipped.
expect(stub.hits.some((h) => h.includes("/github-apps"))).toBe(false);
expect(r.output).not.toContain("no GitHub App bound");
});
it("still refuses a manifest WITH an application when the binding is missing", async () => {
const stub = await stubCoolify();
const f = fixture(stub.url, WITH_APPLICATION);
const r = await run(f);
expect(r.code).toBe(1);
// The message githubAppNameFor has always thrown, unchanged.
expect(r.output).toContain(
"no GitHub App bound for heavy-duty/drill-widget",
);
expect(r.output).toContain('github_apps["heavy-duty/drill-widget"]');
expect(r.output).toContain("bound repos: (none)");
// Refused in preflight: nothing was created — not even the database the
// manifest also declares.
expect(stub.hits.some((h) => h.startsWith("POST /databases"))).toBe(false);
expect(
stub.hits.some((h) => h.includes("/applications/private-github-app")),
).toBe(false);
});
});