fix(draft): name is_static in UNCAPTURED.md when the live read cannot see it (#70)

Coolify 4.1.2 never serializes is_static on any read path — it lives on
the ApplicationSetting relation, which no read endpoint loads (cast#68).
diff already degrades the field honestly (staticNotCompared, warn-and-
skip, #69), but the draft path did not: applicationSpec emits
`static: true` only for a present truthy raw.is_static, so on 4.1.2 the
key is simply absent, the drafted manifest of a live static site
silently omits the flag, and UNCAPTURED.md said nothing. That breaks
the draft's own contract (#27) — a reviewer approving the draft has no
cue the field even exists to lose, and the #63 failure mode (static
site rebuilt and run as a plain app) re-enters through the draft door.

Now, when raw.is_static is absent/null (the same predicate the diff
path's staticNotCompared uses) and the app is plausibly static — a
nixpacks/static build pack with a publish_directory — the draft flags
is_static in UNCAPTURED.md as unreadable on this Coolify, telling the
reviewer to check the box in the UI and add `static: true` by hand if
set. A real boolean (a future Coolify) behaves exactly as before:
expressed in the manifest, never flagged.

Part of #70; the remaining items there are blocked on Coolify v4.2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
claude-hdb 2026-07-16 18:20:59 +00:00
parent 6e8ec34398
commit 50b0d2d2e5
2 changed files with 140 additions and 0 deletions

View file

@ -434,6 +434,27 @@ function applicationSpec(
);
}
// is_static lives on Coolify 4.1.2's ApplicationSetting relation, which no
// read endpoint serializes (cast#68) — so on that Coolify the key is simply
// ABSENT here, and the `static: true` emission below can never fire, however
// the box is actually configured. The same predicate `diff` uses for its
// staticNotCompared escape hatch (cli.ts: `is_static == null`), applied to
// the draft's contract: a drafted static site would silently come back as a
// plain app (the #63 crash), so when the app even LOOKS static — a
// nixpacks/static pack serving a publish_directory — UNCAPTURED.md must name
// the flag as unreadable, not let its absence pass for `false`. A real
// boolean (a future Coolify) is handled below and says nothing here.
if (
r.raw.is_static == null &&
(pack === "nixpacks" || pack === "static") &&
r.raw.publish_directory
) {
flag(
"is_static",
`this Coolify cannot say whether the app serves as a static site — is_static lives on the ApplicationSetting relation, which 4.1.2's read API never returns (cast#68) — and this app is plausibly static (${pack} pack with publish_directory ${String(r.raw.publish_directory)}). The draft carries no \`static: true\`; if the box has "Is it a static site?" checked, a rebuild from this draft would build and RUN it as a plain app (the #63 crash). Check the box in the Coolify UI (Build settings) and, if set, add \`static: true\` under \`build:\` yourself.`,
);
}
return {
source: {
repo: repo ?? String(r.raw.git_repository ?? ""),

View file

@ -333,6 +333,125 @@ describe("planDraft — the emitted shape", () => {
expect(build).not.toHaveProperty("static");
});
// #70: Coolify 4.1.2 never returns is_static on any read (it lives on the
// ApplicationSetting relation, cast#68), so on that Coolify the key is ABSENT
// from the raw payload and the draft cannot know whether the box is a static
// site. UNCAPTURED.md is the draft's honesty contract (#27): when the app
// even looks static, the unreadable flag must be NAMED there — otherwise a
// reviewer has no cue the field exists to lose, and the #63 crash (static
// site rebuilt as a plain app) re-enters through the draft door.
it("names is_static in UNCAPTURED.md when the live read cannot see it and the app looks static", () => {
const p = project({
resources: [
{
kind: "application",
name: "Landing",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
publish_directory: "/apps/landing-site/dist",
fqdn: "https://landing.example.com",
// no is_static at all — Coolify 4.1.2's read path
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
const item = plan.uncaptured.find((u) => u.setting === "is_static");
expect(item).toBeDefined();
expect(item?.resource).toBe("Landing");
// The entry tells the reviewer where the truth lives: the Coolify UI.
expect(item?.detail).toContain("Coolify UI");
expect(item?.detail).toContain("static: true");
// And it reaches the page a reviewer actually reads.
const uncap = plan.files.find((f) => f.path.endsWith("UNCAPTURED.md"));
expect(uncap?.content).toContain("is_static");
// The manifest itself stays silent — absent is not `true`, and a guessed
// `static: true` would be exactly the fabrication UNCAPTURED.md exists to
// prevent.
const manifest = plan.files.find((f) => f.path.endsWith("manifest.yaml"));
const dir = mkdtempSync(join(tmpdir(), "cast-draft-"));
const path = join(dir, "manifest.yaml");
writeFileSync(path, manifest?.content ?? "");
const build =
loadManifest(path).environments.prod.applications.Landing.build;
expect(build).not.toHaveProperty("static");
});
// The flag is only worth a reviewer's attention when the app is PLAUSIBLY
// static — nixpacks/static pack serving a publish_directory (the same
// heuristic as #70). An app with nothing to serve statically gets no entry;
// flagging every application would bury the page in noise.
it("does not flag is_static for an app that does not look static", () => {
const p = project({
resources: [
{
kind: "application",
name: "Api",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://api.example.com",
// no publish_directory, no is_static
},
env: {},
},
{
kind: "application",
name: "Built",
uuid: "a2",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "dockerfile",
base_directory: "/",
publish_directory: "/dist",
fqdn: "https://built.example.com",
// dockerfile pack — Coolify's static toggle is a buildpack concept
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
expect(plan.uncaptured.some((u) => u.setting === "is_static")).toBe(false);
});
// A future Coolify that DOES serialize is_static gets the old behavior
// untouched: a real boolean is expressed in the manifest, not flagged.
// (`static: true` emission for is_static: true is covered above; here the
// read said `false`, which is an answer, not an absence.)
it("does not flag is_static when the live read answered false", () => {
const p = project({
resources: [
{
kind: "application",
name: "Plain",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
publish_directory: "/dist",
fqdn: "https://plain.example.com",
is_static: false,
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
expect(plan.uncaptured.some((u) => u.setting === "is_static")).toBe(false);
});
it("emits an env template every cast reader can parse", () => {
const plan = planDraft([project()], ctx);
const tpl = plan.files.find((f) => f.path.endsWith(".env.template"));