cast/test/bindings.test.ts

457 lines
14 KiB
TypeScript
Raw Normal View History

fix: diff refuses an absent target instead of reporting it as empty (#11, #6) `diff` could not tell "this project does not exist" from "this project is empty" — both came back as [] from fetchLive. That is right for `apply` (a first apply legitimately creates the project and its environment) and quietly wrong for `diff`: computeDiff(desired, []) means "every desired resource is missing", rendered as a confident full-create plan. So a diff aimed at a name that does not exist reported a CLEAN-LOOKING plan that verified nothing. Same shape of lie as the wrong-team token #10 closed — an unverifiable read that answers "absent" and invites a create — reached through the project name instead of the team. It matters more now: with a single Root Team the team assert can never fire, so it is no longer guarding this class of bug at all. There are two roads to it, not one. The project name may be wrong, and so may the environment name: cast names environments after --env, but a project built by hand in the Coolify UI uses whatever someone typed (Coolify's own default is `production`, not `prod`). Both are gated. - fetchLive returns a LiveLookup union, so absence is its own answer rather than a value that happens to equal "empty". diff refuses (exit 2) and names what it looked for, where that name came from, and what exists instead; apply keeps today's tolerant behaviour, which is the whole point of the split. - --project <name> overrides the repo-derived project name, for an instance that names it differently. It overrides ONLY that: secrets stay keyed by the repo, a state-repo convention we own. #6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so existing state files keep working. A short name is unique only *within* an org, so two orgs' same-named repos collapsed onto one entry and whichever App was bound there would clone both — silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds. Verified end-to-end against a fake Coolify, driving the real binary: an absent project refuses (exit 2), --project recovers it (exit 0), an absent environment refuses (exit 2). 12 new tests; 98 pass; check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
import { describe, expect, it } from "vitest";
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
import {
type Bindings,
type ProjectBinding,
githubAppNameFor,
loadBindings,
projectBindingFor,
feat: a project registry — the list of what exists (#25) environments.yaml could say where things deploy to, and how a project you have already named is placed once it is there. It could not say which projects exist. "Every project" was a thing the operator remembered — so fleet operations (#26) had nothing to iterate, and rebuild-from-state (#27) was an assumption, since you cannot restore what you cannot enumerate. A new optional top-level block, keyed by the full <org>/<repo> slug: projects: heavy-duty/incubator: environments: [prod, staging] The key IS the repo — no `repo:` field, because a second place to write the same string is a second place for it to be wrong. No bare-<repo> fallback, unlike github_apps and environments.<env>.projects: those carry one because state files in the wild are keyed that way, and this block has none to support. A bare <repo> is unique only within an org, which is why it is not a key (#12, twice learned). Validated in loadBindings, so every verb refuses a registry that lies: - an environment no `environments:` block defines is an error — the project would be registered into an environment no command can visit - every environments.<env>.projects.<slug> binding must be registered for that env, or the two blocks describe two different fleets: a destination or smoke_target real enough for a direct apply, invisible to every fleet run. Only enforced when `projects:` is present, so pre-registry state files keep loading unchanged. Both defend one failure: a silently skipped project reads exactly like a clean one. Errors render multi-line now — zod's own .message is the issue array as JSON, which flattened the refusals into a line of \n escapes. projectsIn(bindings, env) gives an environment's slugs, sorted; [] with no registry. The --all flag that consumes it is #26's, not here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:03:35 +00:00
projectsIn,
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
smokeTargetFor,
} from "../src/bindings.js";
fix: diff refuses an absent target instead of reporting it as empty (#11, #6) `diff` could not tell "this project does not exist" from "this project is empty" — both came back as [] from fetchLive. That is right for `apply` (a first apply legitimately creates the project and its environment) and quietly wrong for `diff`: computeDiff(desired, []) means "every desired resource is missing", rendered as a confident full-create plan. So a diff aimed at a name that does not exist reported a CLEAN-LOOKING plan that verified nothing. Same shape of lie as the wrong-team token #10 closed — an unverifiable read that answers "absent" and invites a create — reached through the project name instead of the team. It matters more now: with a single Root Team the team assert can never fire, so it is no longer guarding this class of bug at all. There are two roads to it, not one. The project name may be wrong, and so may the environment name: cast names environments after --env, but a project built by hand in the Coolify UI uses whatever someone typed (Coolify's own default is `production`, not `prod`). Both are gated. - fetchLive returns a LiveLookup union, so absence is its own answer rather than a value that happens to equal "empty". diff refuses (exit 2) and names what it looked for, where that name came from, and what exists instead; apply keeps today's tolerant behaviour, which is the whole point of the split. - --project <name> overrides the repo-derived project name, for an instance that names it differently. It overrides ONLY that: secrets stay keyed by the repo, a state-repo convention we own. #6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so existing state files keep working. A short name is unique only *within* an org, so two orgs' same-named repos collapsed onto one entry and whichever App was bound there would clone both — silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds. Verified end-to-end against a fake Coolify, driving the real binary: an absent project refuses (exit 2), --project recovers it (exit 0), an absent environment refuses (exit 2). 12 new tests; 98 pass; check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
function bindings(github_apps: Record<string, string>): Bindings {
return {
environments: {
prod: { server: "prod-box", team: { id: 0, name: "Root Team" } },
},
github_apps,
} as Bindings;
}
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
function withProjects(
projects: Record<string, ProjectBinding>,
smoke_target?: string,
): Bindings {
return {
environments: {
prod: {
server: "shared-box",
team: { id: 0, name: "Root Team" },
projects,
},
staging: { server: "staging-box", team: { id: 0, name: "Root Team" } },
},
github_apps: {},
...(smoke_target ? { smoke_target } : {}),
} as Bindings;
}
fix: diff refuses an absent target instead of reporting it as empty (#11, #6) `diff` could not tell "this project does not exist" from "this project is empty" — both came back as [] from fetchLive. That is right for `apply` (a first apply legitimately creates the project and its environment) and quietly wrong for `diff`: computeDiff(desired, []) means "every desired resource is missing", rendered as a confident full-create plan. So a diff aimed at a name that does not exist reported a CLEAN-LOOKING plan that verified nothing. Same shape of lie as the wrong-team token #10 closed — an unverifiable read that answers "absent" and invites a create — reached through the project name instead of the team. It matters more now: with a single Root Team the team assert can never fire, so it is no longer guarding this class of bug at all. There are two roads to it, not one. The project name may be wrong, and so may the environment name: cast names environments after --env, but a project built by hand in the Coolify UI uses whatever someone typed (Coolify's own default is `production`, not `prod`). Both are gated. - fetchLive returns a LiveLookup union, so absence is its own answer rather than a value that happens to equal "empty". diff refuses (exit 2) and names what it looked for, where that name came from, and what exists instead; apply keeps today's tolerant behaviour, which is the whole point of the split. - --project <name> overrides the repo-derived project name, for an instance that names it differently. It overrides ONLY that: secrets stay keyed by the repo, a state-repo convention we own. #6, same root cause — `repoShort` was doing four unrelated jobs. github_apps is now resolved by full <org>/<repo> slug, falling back to a bare <repo> key so existing state files keep working. A short name is unique only *within* an org, so two orgs' same-named repos collapsed onto one entry and whichever App was bound there would clone both — silently, because a wrong-but-existing App still resolves to a real uuid and the create succeeds. Verified end-to-end against a fake Coolify, driving the real binary: an absent project refuses (exit 2), --project recovers it (exit 0), an absent environment refuses (exit 2). 12 new tests; 98 pass; check clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 14:52:38 +00:00
describe("githubAppNameFor", () => {
it("resolves the full <org>/<repo> slug", () => {
const b = bindings({ "heavy-duty/incubator": "hdb-coolify" });
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
});
// Every state file written before full-slug keying uses the bare repo name.
// Dropping that would break them for no gain, so it stays as a fallback.
it("still resolves a legacy bare <repo> key", () => {
const b = bindings({ incubator: "hdb-coolify" });
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
});
// The whole point of the issue. A short name is unique only *within* an org,
// so two orgs' same-named repos collapse onto one key — and the loser gets
// cloned by the winner's App, silently, because a wrong-but-existing App
// still resolves to a real uuid and the create succeeds.
it("keeps two orgs' same-named repos on separate Apps", () => {
const b = bindings({
"heavy-duty/incubator": "hdb-coolify",
"acme/incubator": "acme-coolify",
});
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
expect(githubAppNameFor(b, "acme/incubator")).toBe("acme-coolify");
});
// Precedence matters in exactly the case that motivated the fix: a state file
// mid-migration carries both a legacy short key and a new full-slug one. The
// slug is the thing that actually identifies a repo, so it must win.
it("prefers the full slug over a colliding bare key", () => {
const b = bindings({
incubator: "legacy-app",
"heavy-duty/incubator": "hdb-coolify",
});
expect(githubAppNameFor(b, "heavy-duty/incubator")).toBe("hdb-coolify");
});
it("refuses an unbound repo, naming both keys it tried", () => {
const b = bindings({ "heavy-duty/other": "other-app" });
const err = githubAppNameFor.bind(
null,
b,
"heavy-duty/incubator",
) as () => string;
expect(err).toThrow(/no GitHub App bound for heavy-duty\/incubator/);
expect(err).toThrow(/github_apps\["heavy-duty\/incubator"\]/);
expect(err).toThrow(/github_apps\["incubator"\]/);
expect(err).toThrow(/heavy-duty\/other/);
});
});
feat: place a resource on a destination — and a state file that can say which (#21) A destination is the Docker network a resource is created on. cast never sent one, so everything landed on the server's default — invisible and harmless while each server hosts one project, and neither the moment a server hosts two. The state file had nowhere to say otherwise, either. A destination is scoped project × environment, and `environments.<env>` is scoped by environment alone: a `destination:` key there would mean "one network shared by every project in this environment", which is the isolation it is meant to provide, inverted. So: - `environments.<env>.projects.<repo>` — per-project state, keyed by repo, full `<org>/<repo>` slug first with a bare-`<repo>` fallback, exactly like `github_apps`. It carries `destination_uuid` and `smoke_target`. - `smoke_target` moves there. It was state-file-scoped: it named ONE project's app (`core`) from a key that could not tell two projects apart — or even prod's app from staging's. The old key is still read (with a warning), so an unmigrated state file keeps smoking, and `cast smoke` now takes an optional `<org>/<repo>`. - `apply` sends `destination_uuid` on create, for applications, databases and services alike — Coolify runs identical destination logic in all three. The API turns out to be worse than the issue assumed, in a way that changes what "diff should compare the destination" can honestly mean. Verified against coollabsio/coolify v4.1.2 (routes/api.php + the three Api controllers), and written up in reference/README.md: - There is NO destinations API. Zero routes. A destination cannot be listed, read or resolved by name — only a raw UUID from the UI identifies one, exactly as with `s3_destination`. Hence `destination_uuid:` and not `destination:`. - The field is WRITE-ONLY. Coolify takes `destination_uuid` on write and returns `destination_id` (an integer PK) on read, with nothing mapping between them. - On a server with >1 destination, a create that OMITS it is a hard 400. So cast could not deploy onto a shared box at all — it did not silently misplace there, it simply failed. On a single-destination server the uuid is ignored entirely and never validated, so a wrong one is invisible until a second one exists. A declared UUID therefore cannot be verified against the resource it was sent for — by cast or by anything else. Diffing it as a field would compare a UUID to an int and report drift that never clears, so it is reported rather than compared, and the limit is stated out loud: every diff that declares a destination says it did not verify it. Silence would make an unverified setting read as a verified one, which is the failure shape #12/#14/#17/#18 are all about. What IS comparable is the live side to itself. `diff` groups live resources by the `destination_id` Coolify does report, and a project whose resources do not all share one network is drift — non-clean, both sides named, and never repaired (apply moves nothing between networks). That catches the thing actually worth catching, including on a box whose destinations were made by hand. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 19:31:02 +00:00
describe("projectBindingFor", () => {
it("resolves the full slug, and a legacy bare <repo> key", () => {
const bySlug = withProjects({
"heavy-duty/incubator": { destination_uuid: "dest-a" },
});
const byShort = withProjects({ incubator: { destination_uuid: "dest-a" } });
expect(
projectBindingFor(bySlug, "prod", "heavy-duty/incubator")
?.destination_uuid,
).toBe("dest-a");
expect(
projectBindingFor(byShort, "prod", "heavy-duty/incubator")
?.destination_uuid,
).toBe("dest-a");
});
// The reason the destination has to be project-scoped at all: one server, two
// projects, two networks. An environment-scoped key could not say this.
it("gives two projects on one server two different destinations", () => {
const b = withProjects({
"heavy-duty/incubator": { destination_uuid: "dest-incubator" },
"acme/client-site": { destination_uuid: "dest-client" },
});
expect(
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
).toBe("dest-incubator");
expect(
projectBindingFor(b, "prod", "acme/client-site")?.destination_uuid,
).toBe("dest-client");
});
it("prefers the full slug over a colliding bare key", () => {
const b = withProjects({
incubator: { destination_uuid: "legacy" },
"heavy-duty/incubator": { destination_uuid: "dest-a" },
});
expect(
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
).toBe("dest-a");
});
// Absence is not an error: an environment whose server hosts one project has
// nothing to declare, and that is the state of every box today.
it("is undefined for an environment with no projects block", () => {
const b = withProjects({ "heavy-duty/incubator": {} });
expect(projectBindingFor(b, "staging", "heavy-duty/incubator")).toBe(
undefined,
);
expect(projectBindingFor(b, "prod", "heavy-duty/other")).toBe(undefined);
});
});
describe("smokeTargetFor", () => {
it("prefers the project-scoped target", () => {
const b = withProjects(
{ "heavy-duty/incubator": { smoke_target: "core" } },
"old-target",
);
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
target: "core",
source: "project",
});
});
// The state file mid-migration still has only the old key — it must keep
// smoking, exactly as the bare-`<repo>` github_apps key keeps resolving.
it("falls back to the deprecated state-file-scoped key, and says so", () => {
const b = withProjects({}, "old-target");
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")).toEqual({
target: "old-target",
source: "deprecated",
});
// ...and with no repo passed at all, which is the old invocation.
expect(smokeTargetFor(b, "prod")).toEqual({
target: "old-target",
source: "deprecated",
});
});
it("is undefined when neither key names a target", () => {
expect(
smokeTargetFor(withProjects({}), "prod", "heavy-duty/incubator"),
).toBe(undefined);
});
// Two projects, each with its own smoke target: the case the old key could
// not express at all, since it named one app for the whole state file.
it("keeps two projects' smoke targets apart", () => {
const b = withProjects({
"heavy-duty/incubator": { smoke_target: "core" },
"acme/client-site": { smoke_target: "web" },
});
expect(smokeTargetFor(b, "prod", "heavy-duty/incubator")?.target).toBe(
"core",
);
expect(smokeTargetFor(b, "prod", "acme/client-site")?.target).toBe("web");
});
});
describe("BindingsSchema (projects)", () => {
it("parses a project-scoped destination and smoke_target", () => {
const b = loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination_uuid: dest-abc
smoke_target: core
github_apps: {}
`,
});
expect(projectBindingFor(b, "prod", "heavy-duty/incubator")).toEqual({
destination_uuid: "dest-abc",
smoke_target: "core",
});
});
it("rejects an unknown key under a project (a typo is not a placement)", () => {
expect(() =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination: dest-abc
github_apps: {}
`,
}),
).toThrow(/invalid bindings/);
});
});
feat: a project registry — the list of what exists (#25) environments.yaml could say where things deploy to, and how a project you have already named is placed once it is there. It could not say which projects exist. "Every project" was a thing the operator remembered — so fleet operations (#26) had nothing to iterate, and rebuild-from-state (#27) was an assumption, since you cannot restore what you cannot enumerate. A new optional top-level block, keyed by the full <org>/<repo> slug: projects: heavy-duty/incubator: environments: [prod, staging] The key IS the repo — no `repo:` field, because a second place to write the same string is a second place for it to be wrong. No bare-<repo> fallback, unlike github_apps and environments.<env>.projects: those carry one because state files in the wild are keyed that way, and this block has none to support. A bare <repo> is unique only within an org, which is why it is not a key (#12, twice learned). Validated in loadBindings, so every verb refuses a registry that lies: - an environment no `environments:` block defines is an error — the project would be registered into an environment no command can visit - every environments.<env>.projects.<slug> binding must be registered for that env, or the two blocks describe two different fleets: a destination or smoke_target real enough for a direct apply, invisible to every fleet run. Only enforced when `projects:` is present, so pre-registry state files keep loading unchanged. Both defend one failure: a silently skipped project reads exactly like a clean one. Errors render multi-line now — zod's own .message is the issue array as JSON, which flattened the refusals into a line of \n escapes. projectsIn(bindings, env) gives an environment's slugs, sorted; [] with no registry. The --all flag that consumes it is #26's, not here. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 20:03:35 +00:00
// The registry: the list of which projects exist at all. Everything it is FOR
// (fleet iteration, rebuild-from-state) depends on it being true, and the way it
// stops being true is silent — see the refusals below.
describe("the project registry", () => {
const twoEnvs = `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
staging:
server: staging-box
team: { id: 0, name: Root Team }
`;
it("registers projects per environment, keyed by the full slug", () => {
const b = loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, staging]
acme/client-site:
environments: [prod]
github_apps: {}
`,
});
expect(b.projects).toEqual({
"heavy-duty/incubator": { environments: ["prod", "staging"] },
"acme/client-site": { environments: ["prod"] },
});
});
describe("projectsIn", () => {
const b = loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, staging]
acme/client-site:
environments: [prod]
github_apps: {}
`,
});
// Sorted, not file-order: a fleet run's output is read by a human and diffed
// by CI, and must not reshuffle because someone appended a project.
it("gives an environment's projects, sorted", () => {
expect(projectsIn(b, "prod")).toEqual([
"acme/client-site",
"heavy-duty/incubator",
]);
});
it("gives only the projects registered for that environment", () => {
expect(projectsIn(b, "staging")).toEqual(["heavy-duty/incubator"]);
});
it("is empty for an environment no project is registered in", () => {
expect(projectsIn(b, "nowhere")).toEqual([]);
});
});
// The refusal the issue is actually about. A typo'd environment name makes the
// project real and its environment imaginary: `cast diff --all` visits nothing
// for it, reports nothing, and exits clean — and a silently skipped project
// reads exactly like a clean one.
it("refuses an environment that does not exist, naming the ones that do", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod, stagng]
github_apps: {}
`,
});
expect(err).toThrow(/environment "stagng", which does not exist/);
expect(err).toThrow(/known envs:\s+prod, staging/);
});
// The other direction, and the one that rots quietly: per-environment state
// (#21) sitting in an environment the registry does not register the project
// for. The two blocks then describe two different fleets.
it("refuses a binding in an environment the registry does not register", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
staging:
server: staging-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination_uuid: dest-abc
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(
/environments\.staging\.projects\["heavy-duty\/incubator"\]/,
);
expect(err).toThrow(/registered for:\s+prod/);
});
it("refuses a binding for a project the registry does not carry at all", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
acme/client-site:
destination_uuid: dest-client
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/environments\.prod\.projects\["acme\/client-site"\]/);
expect(err).toThrow(/registry has:\s+heavy-duty\/incubator/);
});
// A legacy bare-<repo> binding key (projectBindingFor still resolves one) under
// a slug-keyed registry is drift with an obvious fix — say which fix.
it("tells a legacy bare-<repo> binding key which slug to rename to", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
incubator:
smoke_target: core
projects:
heavy-duty/incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/registry has:\s+projects\["heavy-duty\/incubator"\]/);
expect(err).toThrow(/legacy bare-<repo> key/);
});
// No fallback here, unlike github_apps: this block is new, so it has no state
// files in the wild to keep working, and a bare <repo> is unique only within an
// org — which is exactly why it is not a key.
it("refuses a bare <repo> registry key — the org is not optional", () => {
const err = () =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
incubator:
environments: [prod]
github_apps: {}
`,
});
expect(err).toThrow(/projects\["incubator"\] is not a repo/);
expect(err).toThrow(/full <org>\/<repo> slug/);
});
// A project registered into nothing is a line of YAML that reads like a
// registration and is skipped by every fleet run.
it("refuses a project registered into no environment", () => {
expect(() =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: []
github_apps: {}
`,
}),
).toThrow(/invalid bindings/);
});
it("rejects an unknown key inside a registry entry", () => {
expect(() =>
loadBindings("environments.yaml", {
overrideText: `${twoEnvs}
projects:
heavy-duty/incubator:
environments: [prod]
repo: heavy-duty/incubator
github_apps: {}
`,
}),
).toThrow(/invalid bindings/);
});
// Back-compat: the registry is optional, and every state file written before it
// existed has no `projects:` block. Such a file loads unchanged — including its
// per-environment bindings, which are NOT checked against a registry that is
// not there.
describe("with no registry at all", () => {
const b = loadBindings("environments.yaml", {
overrideText: `
environments:
prod:
server: shared-box
team: { id: 0, name: Root Team }
projects:
heavy-duty/incubator:
destination_uuid: dest-abc
smoke_target: core
github_apps: {}
`,
});
it("loads, and keeps its per-environment bindings working", () => {
expect(b.projects).toBe(undefined);
expect(
projectBindingFor(b, "prod", "heavy-duty/incubator")?.destination_uuid,
).toBe("dest-abc");
});
it("has no projects registered in any environment", () => {
expect(projectsIn(b, "prod")).toEqual([]);
});
});
});