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:
parent
6e8ec34398
commit
50b0d2d2e5
2 changed files with 140 additions and 0 deletions
21
src/draft.ts
21
src/draft.ts
|
|
@ -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 ?? ""),
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Reference in a new issue