Merge pull request #64 from claude-hdb/fix/static-build-fields

fix(apply): express static-site build settings so a monorepo app is served, not run (#63)
This commit is contained in:
Daniel Marin 2026-07-15 00:49:30 +01:00 committed by GitHub
commit 9084713a5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 405 additions and 3 deletions

View file

@ -221,6 +221,60 @@ softened by an implementation detail):
- **Reserved names:** cast never writes `SOURCE_COMMIT` or a `COOLIFY_*` var,
under any manifest, in any environment. See below.
## Build settings (`install_command`, `build_command`, `start_command`, `static`)
A manifest application's `build` block carries, beyond `pack` and the checkout
paths, four optional settings Coolify accepts on both the create
(`POST /applications/private-github-app`) and update (`PATCH /applications/{uuid}`)
routes:
```yaml
landing:
source: { repo: acme/widget, branch: main }
build:
pack: nixpacks
base_directory: /
install_command: npm ci
build_command: npm run build -w apps/landing-site
publish_directory: /apps/landing-site/dist
static: true
domains: ["https://widget.example.com"]
```
- `install_command` / `build_command` / `start_command` are free-form strings,
passed through verbatim — cast does not parse the shell in them. They exist so
a **workspace monorepo** can scope the build to one app (`npm run build -w
apps/landing-site`) instead of letting nixpacks auto-detect from the repo-root
`package.json`, whose scripts may build and run a *different* workspace.
- `static: true` maps to Coolify's `is_static`: Coolify then **serves
`publish_directory` and runs no start command**. Without it a static site in a
monorepo gets built and then *run* as its root `package.json` — for the
incubator's `landing` that meant Coolify ran `npm run start -w apps/core`, the
API server, which crash-looped on a missing `DATABASE_URL` (#63).
**All four are rejected on a `dockercompose` app** — a compose app builds and
runs from its compose file, so Coolify never consults them — and `static: true`
is rejected without a `publish_directory` (nothing to serve). Both are parse-time
refusals, like the checkout-path rules above.
**Managing `is_static` is opt-in.** cast emits it only when the manifest declares
`static:` — like the three commands, not on every app. Emitting `is_static:false`
by default would make the first apply after this ships PATCH `is_static=false`
onto any static/SPA app configured in the UI whose manifest has not yet been
migrated — silently disabling static serving and re-creating the very crash, now
caused by cast; a `pack: static` app that Coolify couples to `is_static=true`
would drift-and-revert forever. So: declare `static: true` to serve, `static:
false` to actively guard against a UI flip to `true`, or omit it to leave the
field alone (Coolify keeps `pack` and `is_static` independent, which is why this
is an explicit field, not inferred from `pack`). The three commands are likewise
conditional (an unset command means "let the build pack decide"), diffed only
when declared. `projectLiveFields` reads `is_static` back on every app so it is
there to compare when a manifest does declare it. `draft` emits all four when the
live box carries them (and `static` only alongside a `publish_directory`, so the
draft always loads) — they used to sit in its `NO_HOME` list of settings a
rebuild silently dropped, and `is_static` was not even there, which is exactly
how a rebuilt static site came back wrong.
## Reserved env var names (`SOURCE_COMMIT`, `COOLIFY_*`)
Coolify injects a set of values into an application's runtime environment

View file

@ -326,6 +326,17 @@ export function projectLiveFields(
? { docker_compose_location: raw.docker_compose_location }
: {}),
...(composeDomains ? { docker_compose_domains: composeDomains } : {}),
// is_static is read on every application so it is there to compare WHEN a
// manifest declares `static:`. computeDiff compares only fields the DESIRED
// side declares, so an app whose manifest omits `static` never diffs on it
// (which is what keeps this from PATCHing is_static off an un-migrated
// static app), and the three commands are the same — live carrying them
// here is safe and never reads as spurious drift. Coolify's Application
// model casts is_static to boolean; tolerate a 1/0 defensively.
is_static: raw.is_static === true || raw.is_static === 1,
...(raw.install_command ? { install_command: raw.install_command } : {}),
...(raw.build_command ? { build_command: raw.build_command } : {}),
...(raw.start_command ? { start_command: raw.start_command } : {}),
};
}
if (kind === "database") {
@ -2445,6 +2456,10 @@ export function applicationApiFields(
const { port, healthcheck, domains, docker_compose_domains, ...rest } =
fields;
return {
// is_static/install_command/build_command/start_command ride through `rest`
// unchanged: they are valid API params verbatim, accepted on both the create
// (POST /applications/private-github-app) and update (PATCH
// /applications/{uuid}) routes — verified against the vendored OpenAPI.
...rest,
// ports_exposes wants a string; healthcheck -> health_check_path;
// domains wants a comma-separated string, not an array.

View file

@ -435,9 +435,6 @@ function applicationSpec(
const NO_HOME: Array<[string, string]> = [
["custom_labels", "custom Traefik/Docker labels (Basic Auth lives here)"],
["ports_mappings", "host port mappings"],
["install_command", "a custom install command"],
["build_command", "a custom build command"],
["start_command", "a custom start command"],
["pre_deployment_command", "a pre-deployment command"],
["post_deployment_command", "a post-deployment command"],
["dockerfile", "an inline Dockerfile"],
@ -483,6 +480,33 @@ function applicationSpec(
...(!compose && r.raw.publish_directory
? { publish_directory: String(r.raw.publish_directory) }
: {}),
// The three build/run commands and the static flag now HAVE manifest
// fields (see manifest.ts), so a rebuild carries them instead of silently
// dropping them. `is_static` was previously not even in NO_HOME, so a
// rebuild lost it without a word — that is exactly the #63 crash: a static
// site whose is_static was true on the box came back as false, got built
// and RUN from the repo-root package.json, and crash-looped. draft now
// carries it. (Compose apps get none of these — see the else above.)
...(!compose && r.raw.install_command
? { install_command: String(r.raw.install_command) }
: {}),
...(!compose && r.raw.build_command
? { build_command: String(r.raw.build_command) }
: {}),
...(!compose && r.raw.start_command
? { start_command: String(r.raw.start_command) }
: {}),
// Gated on publish_directory as well: the schema refuses `static: true`
// with nothing to serve, and a draft must only ever emit a manifest that
// loads. Coolify's static apps carry a publish_directory (its default is
// `/`), so this drops `static` only for a box in a state the manifest could
// not express anyway — a loud, correct omission rather than an unloadable
// file.
...(!compose &&
(r.raw.is_static === true || r.raw.is_static === 1) &&
r.raw.publish_directory
? { static: true }
: {}),
},
...(compose
? {}

View file

@ -43,6 +43,19 @@ const AppSpecSchema = z
base_directory: repoDirectoryPath("base_directory"),
publish_directory: repoDirectoryPath("publish_directory").optional(),
compose_file: composeFilePath.optional(),
// The three build/run commands and the static flag Coolify accepts on
// both the create (POST /applications/private-github-app) and the
// update (PATCH /applications/{uuid}) routes. They are free-form
// strings passed through verbatim — cast does not parse or validate the
// shell in them, only whether they belong on this pack (superRefine
// below). `static` maps to Coolify's `is_static`: it makes Coolify
// SERVE `publish_directory` and run NO start command, which is exactly
// the fix for a static site in a workspace monorepo that otherwise gets
// built and RUN from the repo-root package.json (#63).
install_command: z.string().optional(),
build_command: z.string().optional(),
start_command: z.string().optional(),
static: z.boolean().optional(),
})
.strict(),
port: z.number().int().optional(),
@ -75,6 +88,26 @@ const AppSpecSchema = z
code: "custom",
message: "publish_directory not allowed on a dockercompose app",
});
// A compose app builds and runs from its compose file — Coolify never
// consults these on it. Reject them at parse time rather than post them
// and have them silently ignored (the same reasoning as publish_directory
// and port above).
for (const k of [
"install_command",
"build_command",
"start_command",
] as const)
if (app.build[k] !== undefined)
ctx.addIssue({
code: "custom",
message: `build.${k} not allowed on a dockercompose app (it builds from its compose file)`,
});
if (app.build.static !== undefined)
ctx.addIssue({
code: "custom",
message:
"build.static not allowed on a dockercompose app (a compose file decides what is served)",
});
} else {
if (!app.domains)
ctx.addIssue({
@ -87,6 +120,16 @@ const AppSpecSchema = z
message:
"service_domains/compose_file only allowed with pack dockercompose",
});
// `static: true` tells Coolify to serve publish_directory and run no
// start command — so a static app with nothing to serve is almost
// certainly a mistake, and one that would deploy green while serving an
// empty site. Catch it in the file, once, not on a live box.
if (app.build.static === true && !app.build.publish_directory)
ctx.addIssue({
code: "custom",
message:
"build.static: true serves publish_directory and runs no start command — but no publish_directory is set, so there is nothing to serve",
});
}
});

View file

@ -414,6 +414,31 @@ export function desiredFromManifest(
...(app.port !== undefined ? { port: app.port } : {}),
...(app.healthcheck ? { healthcheck: app.healthcheck } : {}),
domains: app.domains,
// Emitted only when the manifest DECLARES `static:` — like the
// three commands, not unconditionally. Emitting `is_static:false`
// on every non-compose app would make the first apply after this
// ships PATCH `is_static=false` onto any static/SPA app configured
// in the UI whose manifest has not yet been migrated — silently
// disabling static serving and re-creating the #63 crash, now
// caused by cast. And a `pack: static` app that Coolify couples to
// is_static=true would drift-and-revert forever. So managing
// is_static is opt-in: declare `static: true` to serve, `static:
// false` to actively guard against a UI flip to true, or omit it to
// leave the field alone. (Coolify keeps pack and is_static
// independent, which is why this stays an explicit field, not a
// heuristic off `pack`.)
...(app.build.static !== undefined
? { is_static: app.build.static }
: {}),
...(app.build.install_command !== undefined
? { install_command: app.build.install_command }
: {}),
...(app.build.build_command !== undefined
? { build_command: app.build.build_command }
: {}),
...(app.build.start_command !== undefined
? { start_command: app.build.start_command }
: {}),
}),
},
env: resolveEnvFile(name, app.env_template),

View file

@ -171,6 +171,81 @@ describe("planDraft — the emitted shape", () => {
expect(env.generated_secrets).toEqual(["DATABASE_URL"]);
});
// #63: is_static was previously not even in NO_HOME, so a rebuild silently
// lost it — the exact crash. draft now carries it, plus the install/build/
// start commands that used to be flagged as NO_HOME.
it("carries static + install/build/start commands, and does NOT flag them as uncaptured", () => {
const p = project({
resources: [
{
kind: "application",
name: "Landing",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "static",
base_directory: "/",
publish_directory: "/apps/landing-site/dist",
fqdn: "https://landing.example.com",
is_static: true,
install_command: "npm ci",
build_command: "npm run build -w apps/landing-site",
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
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.static).toBe(true);
expect(build.install_command).toBe("npm ci");
expect(build.build_command).toBe("npm run build -w apps/landing-site");
// These now have a manifest home, so they must NOT be reported as settings
// cast could see but not express.
for (const setting of ["is_static", "install_command", "build_command"]) {
expect(plan.uncaptured.some((u) => u.setting === setting)).toBe(false);
}
});
// A draft must only ever emit a manifest that LOADS. is_static true with no
// publish_directory would be `static: true` with nothing to serve, which the
// schema refuses — so draft omits `static` for that (rare, malformed) box
// rather than writing a file that throws on load.
it("does not emit static:true when the box has is_static but no publish_directory", () => {
const p = project({
resources: [
{
kind: "application",
name: "Odd",
uuid: "a1",
raw: {
git_repository: "https://github.com/heavy-duty/incubator",
git_branch: "main",
build_pack: "nixpacks",
base_directory: "/",
fqdn: "https://odd.example.com",
is_static: true,
},
env: {},
},
],
});
const plan = planDraft([p], ctx);
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 ?? "");
// The whole point: it loads (does not throw), and simply carries no `static`.
const build = loadManifest(path).environments.prod.applications.Odd.build;
expect(build).not.toHaveProperty("static");
});
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"));

View file

@ -212,6 +212,77 @@ environments:
}),
).toThrow(/service_domains/);
});
// #63: the working (hand-built) config for a static site in a workspace
// monorepo — is_static plus workspace-scoped install/build commands that build
// only the target app and serve its dist, instead of the repo root's start
// script booting a different workspace.
it("accepts install/build/start commands and static on a non-compose app, round-tripping them", () => {
const m = loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
landing:
source: { repo: acme/widget, branch: main }
build:
pack: static
base_directory: /
publish_directory: /apps/landing-site/dist
install_command: npm ci
build_command: npm run build -w apps/landing-site
start_command: node server.js
static: true
domains: ["https://landing.example.com"]
`,
});
const b = m.environments.prod.applications.landing.build;
expect(b.install_command).toBe("npm ci");
expect(b.build_command).toBe("npm run build -w apps/landing-site");
expect(b.start_command).toBe("node server.js");
expect(b.static).toBe(true);
expect(b.publish_directory).toBe("/apps/landing-site/dist");
});
it("rejects static: true with nothing to serve (no publish_directory)", () => {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
landing:
source: { repo: acme/widget, branch: main }
build: { pack: static, base_directory: /, static: true }
domains: ["https://landing.example.com"]
`,
}),
).toThrow(/nothing to serve/);
});
it("rejects install/build/start commands and static on a dockercompose app", () => {
for (const field of [
"install_command: npm ci",
"build_command: npm run build",
"start_command: node server.js",
"static: true",
]) {
expect(() =>
loadManifest(`${FIX}manifest.yaml`, {
overrideText: `
project: widget
environments:
prod:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: dockercompose, base_directory: /, compose_file: /docker-compose.yaml, ${field} }
service_domains:
api: ["https://api.example.com"]
`,
}),
).toThrow(/not allowed on a dockercompose app/);
}
});
});
describe("loadBindings", () => {

View file

@ -158,6 +158,64 @@ environments:
value: "secret-v",
secret: true,
});
// None of the four build settings are emitted for an app that declares none:
// managing is_static is opt-in (declaring it would otherwise PATCH static
// serving OFF on an un-migrated app), and the commands default to "let the
// build pack decide".
expect(desired[0].fields).not.toHaveProperty("is_static");
expect(desired[0].fields).not.toHaveProperty("install_command");
expect(desired[0].fields).not.toHaveProperty("build_command");
expect(desired[0].fields).not.toHaveProperty("start_command");
});
it("emits is_static:false when static:false is explicitly declared (a guard against a UI flip)", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
core:
source: { repo: acme/widget, branch: main }
build: { pack: nixpacks, base_directory: /, static: false }
domains: ["https://c.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields.is_static).toBe(false);
});
// #63: the static-site build settings a workspace monorepo needs.
it("emits is_static:true and the three commands for a non-compose app that declares them", () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));
mkdirSync(join(dir, ".infra"), { recursive: true });
writeFileSync(
join(dir, ".infra", "manifest.yaml"),
`project: widget
environments:
staging:
applications:
landing:
source: { repo: acme/widget, branch: main }
build:
pack: static
base_directory: /
publish_directory: /apps/landing-site/dist
install_command: npm ci
build_command: npm run build -w apps/landing-site
start_command: node server.js
static: true
domains: ["https://landing.example.com"]
`,
);
const { desired } = desiredFromManifest(dir, "staging", {});
expect(desired[0].fields).toMatchObject({
is_static: true,
install_command: "npm ci",
build_command: "npm run build -w apps/landing-site",
start_command: "node server.js",
publish_directory: "/apps/landing-site/dist",
});
});
// The reverse of what this file used to assert. `backup` was deliberately
// routed AROUND `fields` into a side channel, because live Coolify was
@ -345,6 +403,12 @@ environments:
expect(desired[0].fields).not.toHaveProperty("port");
expect(desired[0].fields).not.toHaveProperty("healthcheck");
expect(desired[0].fields).not.toHaveProperty("domains");
// A compose app builds from its compose file — none of the static/command
// fields belong on it, not even is_static (which every NON-compose app gets).
expect(desired[0].fields).not.toHaveProperty("is_static");
expect(desired[0].fields).not.toHaveProperty("install_command");
expect(desired[0].fields).not.toHaveProperty("build_command");
expect(desired[0].fields).not.toHaveProperty("start_command");
});
it('warns that apply cannot enable "Include Source Commit in Build" on a dockercompose app (unsettable via the Coolify 4.1.2 API)', () => {
const dir = mkdtempSync(join(tmpdir(), "infra-co-"));

View file

@ -119,6 +119,37 @@ describe("projectLiveFields", () => {
});
expect(out).not.toHaveProperty("docker_compose_domains");
});
// #63: the static-site fields must read back off a live application so they
// diff against the manifest — otherwise a UI flip of is_static is invisible.
it("reads is_static and the build/run commands off a live application", () => {
const out = projectLiveFields("application", {
git_repository: "org/repo",
git_branch: "main",
build_pack: "static",
base_directory: "/",
is_static: true,
install_command: "npm ci",
build_command: "npm run build -w apps/landing-site",
start_command: "node server.js",
});
expect(out.is_static).toBe(true);
expect(out.install_command).toBe("npm ci");
expect(out.build_command).toBe("npm run build -w apps/landing-site");
expect(out.start_command).toBe("node server.js");
});
it("always reports is_static as a boolean, tolerating Coolify's 1/0", () => {
expect(projectLiveFields("application", { is_static: 1 }).is_static).toBe(
true,
);
expect(projectLiveFields("application", { is_static: 0 }).is_static).toBe(
false,
);
// Absent on the wire reads as false (its real default), never undefined —
// so it compares against the desired side, which always emits it.
expect(projectLiveFields("application", {}).is_static).toBe(false);
});
});
describe("compose app idempotency (review finding #2)", () => {