@intentius/chant 0.124.44 → 0.124.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/ci-identity.d.ts +183 -12
  2. package/dist/ci-identity.d.ts.map +1 -1
  3. package/dist/ci-identity.js +362 -53
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +17 -1
  6. package/dist/cli/handlers/pr-env.d.ts +8 -2
  7. package/dist/cli/handlers/pr-env.d.ts.map +1 -1
  8. package/dist/cli/handlers/pr-env.js +126 -225
  9. package/dist/cli/handlers/pr.d.ts +3 -0
  10. package/dist/cli/handlers/pr.d.ts.map +1 -1
  11. package/dist/cli/handlers/pr.js +1 -0
  12. package/dist/components/cli-support.d.ts +2 -0
  13. package/dist/components/cli-support.d.ts.map +1 -1
  14. package/dist/components/cli-support.js +4 -2
  15. package/dist/components/fan-out-support.d.ts +3 -0
  16. package/dist/components/fan-out-support.d.ts.map +1 -1
  17. package/dist/components/fan-out-support.js +1 -1
  18. package/dist/components/pr-env-run.d.ts +270 -0
  19. package/dist/components/pr-env-run.d.ts.map +1 -0
  20. package/dist/components/pr-env-run.js +440 -0
  21. package/dist/components/pr-environment.d.ts +89 -14
  22. package/dist/components/pr-environment.d.ts.map +1 -1
  23. package/dist/components/pr-environment.js +94 -8
  24. package/dist/components/pr-pipeline.d.ts +10 -1
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/op/op-waves.d.ts +17 -0
  27. package/dist/op/op-waves.d.ts.map +1 -1
  28. package/dist/op/op-waves.js +23 -1
  29. package/dist/pr-forge.d.ts +15 -1
  30. package/dist/pr-forge.d.ts.map +1 -1
  31. package/dist/pr-forge.js +28 -16
  32. package/dist/pr-requires.d.ts +5 -1
  33. package/dist/pr-requires.d.ts.map +1 -1
  34. package/dist/wave-runtime.mjs +179 -179
  35. package/dist/workspace/ci-cloud-identity.d.ts +3 -1
  36. package/dist/workspace/ci-cloud-identity.d.ts.map +1 -1
  37. package/dist/workspace/ci-cloud-identity.js +16 -1
  38. package/dist/workspace/ci-run.d.ts +35 -8
  39. package/dist/workspace/ci-run.d.ts.map +1 -1
  40. package/dist/workspace/ci-run.js +95 -21
  41. package/dist/workspace/ci-secrets.d.ts.map +1 -1
  42. package/dist/workspace/ci-secrets.js +4 -3
  43. package/dist/workspace/declaration.d.ts +10 -0
  44. package/dist/workspace/declaration.d.ts.map +1 -1
  45. package/dist/workspace/declaration.js +17 -1
  46. package/dist/workspace/declaration.schema.json +36 -1
  47. package/package.json +1 -1
  48. package/src/ci-identity-targets.test.ts +275 -0
  49. package/src/ci-identity.ts +394 -61
  50. package/src/cli/handlers/build.ts +15 -1
  51. package/src/cli/handlers/pr-env.ts +136 -227
  52. package/src/cli/handlers/pr.ts +4 -0
  53. package/src/components/cli-support.ts +6 -1
  54. package/src/components/fan-out-support.ts +4 -1
  55. package/src/components/pr-env-run.test.ts +290 -0
  56. package/src/components/pr-env-run.ts +634 -0
  57. package/src/components/pr-environment.ts +159 -12
  58. package/src/components/pr-pipeline.ts +10 -1
  59. package/src/op/op-waves.ts +38 -1
  60. package/src/pr-forge-bundle.test.ts +1 -1
  61. package/src/pr-forge.ts +44 -17
  62. package/src/pr-requires.test.ts +43 -6
  63. package/src/pr-requires.ts +5 -1
  64. package/src/workspace/ci-cloud-identity.test.ts +20 -0
  65. package/src/workspace/ci-cloud-identity.ts +14 -1
  66. package/src/workspace/ci-run.test.ts +74 -1
  67. package/src/workspace/ci-run.ts +122 -29
  68. package/src/workspace/ci-secrets.test.ts +1 -1
  69. package/src/workspace/ci-secrets.ts +4 -3
  70. package/src/workspace/declaration.schema.json +36 -1
  71. package/src/workspace/declaration.test.ts +7 -0
  72. package/src/workspace/declaration.ts +24 -2
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Running a pull request's environment over an adapter (#4330): the gate,
3
+ * the records, the ownership check and the sweep come from chant, and the
4
+ * plan, apply and teardown from the adapter, as terragucci supplies them for
5
+ * Terraform roots under suffixed state keys. Test values only.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync } from "node:fs";
9
+ import { describe, expect, test } from "vitest";
10
+ import type { ObservationLexicon, TeardownCandidate } from "../lexicon";
11
+ import { resolvePrEnvCloudIdentity } from "../ci-identity";
12
+ import {
13
+ PR_ENV_TEARDOWN_ENV,
14
+ commandTeardownAdapter,
15
+ prEnvDown,
16
+ prEnvSweep,
17
+ prEnvUp,
18
+ readTeardownEnumeration,
19
+ type PrEnvAdapter,
20
+ type PrEnvApproval,
21
+ type PrEnvPorts,
22
+ } from "./pr-env-run";
23
+ import { livePrEnvironments, prEnvironmentSite, renderPrEnvNote, type PrEnvRecord, type PrEnvReport, type PrEnvTeardownAdapter } from "./pr-environment";
24
+
25
+ const HEAD = "b".repeat(40);
26
+ const NOW = new Date("2026-10-23T10:00:00.000Z");
27
+
28
+ /** In-memory ports: the records, a gate that answers as told, and what was published. */
29
+ function ports(approval: PrEnvApproval = { status: "pending" }, records: PrEnvRecord[] = [], state?: Record<number, "open" | "closed" | "merged">): PrEnvPorts & { records: PrEnvRecord[]; published: PrEnvReport[]; asked: string[] } {
30
+ const published: PrEnvReport[] = [];
31
+ const asked: string[] = [];
32
+ return {
33
+ records,
34
+ published,
35
+ asked,
36
+ async readRecords() {
37
+ return [...records];
38
+ },
39
+ async appendRecord(r) {
40
+ records.push(r);
41
+ },
42
+ async decideGate(input) {
43
+ asked.push(`${input.op} ${input.gate} ${input.digest}`);
44
+ return approval;
45
+ },
46
+ async publish(r) {
47
+ published.push(r);
48
+ },
49
+ ...(state ? { pullRequestState: async (pr: number) => state[pr] ?? "open" } : {}),
50
+ };
51
+ }
52
+
53
+ /** A Terraform-like adapter: two roots, each copied under a state key suffixed -pr-<n>. */
54
+ function roots(opts: { changes?: number; failPlan?: boolean } = {}): PrEnvAdapter & { applied: string[] } {
55
+ const applied: string[] = [];
56
+ const changes = opts.changes ?? 2;
57
+ return {
58
+ applied,
59
+ async plan(target) {
60
+ return {
61
+ digest: "sha256:set",
62
+ empty: changes === 0,
63
+ ...(opts.failPlan ? { failed: true, message: "network: plan failed" } : {}),
64
+ rows: ["network", "app"].map((root) => ({ component: root, status: "planned", create: changes, update: 0, replace: 0, delete: 0, location: `s3://state-test/${root}/terraform-${target.env}.tfstate` })),
65
+ };
66
+ },
67
+ async apply(target, { planned }) {
68
+ applied.push(...planned.rows.map((r) => r.component));
69
+ return {
70
+ status: "applied",
71
+ rows: planned.rows.map((r) => ({ ...r, status: "applied" })),
72
+ locations: Object.fromEntries(planned.rows.map((r) => [r.component, r.location!])),
73
+ };
74
+ },
75
+ };
76
+ }
77
+
78
+ const UP = { pr: 7, head: HEAD, template: "staging", ttl: "72h", stack: "shop", now: () => NOW };
79
+
80
+ describe("up", () => {
81
+ test("a non-empty plan waits for an approval of its digest and applies nothing", async () => {
82
+ const p = ports();
83
+ const adapter = roots();
84
+ const r = await prEnvUp({ ...UP, adapter, ports: p });
85
+ expect(r.exit).toBe(3);
86
+ expect(r.report.status).toBe("waiting");
87
+ expect(p.asked).toEqual(["pr-7 pr-env sha256:set"]);
88
+ expect(adapter.applied).toEqual([]);
89
+ expect(p.records).toEqual([]);
90
+ expect(renderPrEnvNote(r.report)).toContain("chant approve pr-7 pr-env --plan sha256:set");
91
+ });
92
+
93
+ test("an approved plan applies, and the record keeps each root's location for the teardown", async () => {
94
+ const p = ports({ status: "approved", approvedBy: ["reviewer"] });
95
+ const r = await prEnvUp({ ...UP, adapter: roots(), ports: p, actor: "octo" });
96
+ expect(r.exit).toBe(0);
97
+ expect(r.record).toMatchObject({ kind: "up", env: "pr-7", marker: { stack: "shop", env: "pr-7" }, components: ["network", "app"], approvedBy: ["reviewer"], expiresAt: "2026-10-26T10:00:00.000Z", releaseTarget: false });
98
+ expect(livePrEnvironments(p.records).get("pr-7")?.locations).toEqual({ network: "s3://state-test/network/terraform-pr-7.tfstate", app: "s3://state-test/app/terraform-pr-7.tfstate" });
99
+ expect(p.published.map((x) => x.status)).toEqual(["applied"]);
100
+ expect(renderPrEnvNote(r.report)).toContain("s3://state-test/app/terraform-pr-7.tfstate");
101
+ });
102
+
103
+ test("an empty plan applies at once, with no approval asked", async () => {
104
+ const p = ports();
105
+ const r = await prEnvUp({ ...UP, adapter: roots({ changes: 0 }), ports: p });
106
+ expect(r.exit).toBe(0);
107
+ expect(p.asked).toEqual([]);
108
+ expect(p.records).toHaveLength(1);
109
+ });
110
+
111
+ test("a failed plan applies nothing and keeps no record", async () => {
112
+ const p = ports({ status: "approved" });
113
+ const adapter = roots({ failPlan: true });
114
+ const r = await prEnvUp({ ...UP, adapter, ports: p });
115
+ expect(r.exit).toBe(1);
116
+ expect(r.report.status).toBe("plan-failed");
117
+ expect(adapter.applied).toEqual([]);
118
+ expect(p.records).toEqual([]);
119
+ });
120
+
121
+ test("an apply that stopped before anything applied keeps no record", async () => {
122
+ const p = ports({ status: "approved" });
123
+ const adapter: PrEnvAdapter = { ...roots(), apply: async (_t, { planned }) => ({ status: "stopped", rows: planned.rows, message: "the before-apply hook failed" }) };
124
+ const r = await prEnvUp({ ...UP, adapter, ports: p });
125
+ expect(r.exit).toBe(1);
126
+ expect(p.records).toEqual([]);
127
+ });
128
+ });
129
+
130
+ /** A teardown adapter over the roots the record names: each root's copy, with the marker its key's suffix says. */
131
+ function destroyer(opts: { foreign?: boolean } = {}): PrEnvTeardownAdapter & { destroyed: string[] } {
132
+ const destroyed: string[] = [];
133
+ return {
134
+ name: "terragucci",
135
+ lexicons: ["terraform"],
136
+ destroyed,
137
+ async owned({ live, marker }) {
138
+ const candidates: TeardownCandidate[] = Object.entries(live.locations ?? {}).map(([root, location]) => ({ name: root, type: "Terraform::Root", physicalId: location, marker: { stack: marker.stack, env: /-(pr-[0-9]+)\.tfstate$/.exec(location)?.[1] ?? "" } }));
139
+ if (opts.foreign) candidates.push({ name: "shared", type: "Terraform::Root", physicalId: "s3://state-test/shared/terraform.tfstate", marker: { stack: "shop", env: "staging" } });
140
+ return { candidates };
141
+ },
142
+ async teardown({ candidates }) {
143
+ destroyed.push(...candidates.map((c) => c.name));
144
+ return { outcomes: candidates.map((c) => ({ name: c.name, outcome: "deleted" as const })) };
145
+ },
146
+ };
147
+ }
148
+
149
+ /** The terraform lexicon as it stands: it reads resources and has no teardown execution. */
150
+ const terraformLexicon = {
151
+ name: "terraform",
152
+ async teardownOwned() {
153
+ return { candidates: [{ name: "aws_s3_bucket.assets", type: "aws_s3_bucket", marker: { stack: "shop", env: "pr-7" } }] };
154
+ },
155
+ } as unknown as ObservationLexicon;
156
+
157
+ async function applied(): Promise<PrEnvRecord[]> {
158
+ const p = ports({ status: "approved", approvedBy: ["reviewer"] });
159
+ await prEnvUp({ ...UP, adapter: roots(), ports: p });
160
+ return p.records;
161
+ }
162
+
163
+ describe("down through a product's teardown adapter", () => {
164
+ test("tears down every root's copy, and the terraform lexicon it covers does not leave the environment incomplete", async () => {
165
+ const p = ports(undefined, await applied());
166
+ const d = destroyer();
167
+ const r = await prEnvDown({ pr: 7, reason: "closed", ports: p, plugins: [terraformLexicon], adapters: [d], commit: "c".repeat(40), now: () => NOW });
168
+ expect(r.exit).toBe(0);
169
+ expect(r.report.status).toBe("destroyed");
170
+ expect(d.destroyed.sort()).toEqual(["app", "network"]);
171
+ expect(livePrEnvironments(p.records).has("pr-7")).toBe(false);
172
+ });
173
+
174
+ test("a candidate whose marker is another environment's never reaches the adapter's teardown", async () => {
175
+ const p = ports(undefined, await applied());
176
+ const d = destroyer({ foreign: true });
177
+ const r = await prEnvDown({ pr: 7, reason: "closed", ports: p, adapters: [d], commit: "c".repeat(40) });
178
+ expect(d.destroyed).not.toContain("shared");
179
+ expect(r.report.status).toBe("destroyed");
180
+ });
181
+
182
+ test("without the adapter, the terraform lexicon's candidates are skipped and the environment stays live", async () => {
183
+ const p = ports(undefined, await applied());
184
+ const r = await prEnvDown({ pr: 7, reason: "closed", ports: p, plugins: [terraformLexicon], commit: "c".repeat(40) });
185
+ expect(r.exit).toBe(1);
186
+ expect(r.report.status).toBe("incomplete");
187
+ expect(livePrEnvironments(p.records).get("pr-7")?.teardownIncomplete).toBe(true);
188
+ });
189
+
190
+ test("another pull request's down reads nothing and deletes nothing", async () => {
191
+ const records = await applied();
192
+ const p = ports(undefined, records);
193
+ const d = destroyer();
194
+ const live = livePrEnvironments(records).get("pr-7")!;
195
+ const r = await prEnvDown({ pr: 8, reason: "closed", ports: p, adapters: [d], live, commit: "c".repeat(40) });
196
+ expect(r.report.status).toBe("refused");
197
+ expect(d.destroyed).toEqual([]);
198
+ });
199
+ });
200
+
201
+ describe("sweep", () => {
202
+ test("tears down an expired environment and one whose pull request closed, and keeps an open one", async () => {
203
+ const records = await applied();
204
+ const other = (pr: number, expiresAt: string): PrEnvRecord => ({ ...records[0]!, pr, env: `pr-${pr}`, marker: { stack: "shop", env: `pr-${pr}` }, expiresAt });
205
+ records.push(other(8, "2026-10-30T10:00:00.000Z"), other(9, "2026-10-30T10:00:00.000Z"));
206
+ const p = ports(undefined, records, { 8: "merged", 9: "open" });
207
+ const d = destroyer();
208
+ const r = await prEnvSweep({ ports: p, adapters: [d], commit: "c".repeat(40), now: () => new Date("2026-10-27T10:00:00.000Z") });
209
+ expect(r.results.map((x) => [x.report.env, x.record?.reason])).toEqual([["pr-7", "expired"], ["pr-8", "closed"]]);
210
+ expect([...livePrEnvironments(p.records).keys()]).toEqual(["pr-9"]);
211
+ });
212
+ });
213
+
214
+ describe("a teardown command (ci.prEnvironment.teardown)", () => {
215
+ test("runs the owned and teardown stages without the forge's tokens, and reads their answers as data", async () => {
216
+ const live = livePrEnvironments(await applied()).get("pr-7")!;
217
+ const calls: { argv: string[]; env: Record<string, string> }[] = [];
218
+ const adapter = commandTeardownAdapter(
219
+ { command: ["./teardown.sh", "{stage}", "{env}"], lexicons: ["terraform"] },
220
+ {
221
+ cwd: "/tmp",
222
+ env: { PATH: "/bin", GITHUB_TOKEN: "test-token", CHANT_FORGE_TOKEN: "test-token" },
223
+ exec: (argv, _cwd, env) => {
224
+ calls.push({ argv, env });
225
+ const stage = env[PR_ENV_TEARDOWN_ENV.stage];
226
+ const located = JSON.parse(readFileSync(env[PR_ENV_TEARDOWN_ENV.live]!, "utf-8")) as { locations: Record<string, string> };
227
+ const answer =
228
+ stage === "owned"
229
+ ? { candidates: Object.keys(located.locations).map((root) => ({ name: root, type: "Terraform::Root", marker: { stack: env[PR_ENV_TEARDOWN_ENV.stack], env: env[PR_ENV_TEARDOWN_ENV.env] } })) }
230
+ : { outcomes: (JSON.parse(readFileSync(env[PR_ENV_TEARDOWN_ENV.candidates]!, "utf-8")) as TeardownCandidate[]).map((c) => ({ name: c.name, outcome: "deleted" })) };
231
+ writeFileSync(env[PR_ENV_TEARDOWN_ENV.result]!, JSON.stringify(answer));
232
+ return 0;
233
+ },
234
+ },
235
+ );
236
+ const p = ports(undefined, [{ ...livePrRecord(live) }]);
237
+ const r = await prEnvDown({ pr: 7, reason: "closed", ports: p, plugins: [terraformLexicon], adapters: [adapter], live, commit: "c".repeat(40) });
238
+ expect(r.report.status).toBe("destroyed");
239
+ expect(calls.map((c) => c.argv)).toEqual([["./teardown.sh", "owned", "pr-7"], ["./teardown.sh", "teardown", "pr-7"]]);
240
+ for (const c of calls) {
241
+ expect(c.env.GITHUB_TOKEN).toBeUndefined();
242
+ expect(c.env.CHANT_FORGE_TOKEN).toBeUndefined();
243
+ expect(c.env.PATH).toBe("/bin");
244
+ expect(c.env[PR_ENV_TEARDOWN_ENV.commit]).toBe(HEAD);
245
+ }
246
+ });
247
+
248
+ test("a failed owned stage is a hole, so the environment stays live", async () => {
249
+ const live = livePrEnvironments(await applied()).get("pr-7")!;
250
+ const adapter = commandTeardownAdapter({ command: ["false"] }, { cwd: "/tmp", env: {}, exec: () => 2 });
251
+ const r = await prEnvDown({ pr: 7, reason: "closed", ports: ports(), adapters: [adapter], live, commit: "c".repeat(40) });
252
+ expect(r.report.status).toBe("incomplete");
253
+ });
254
+
255
+ test("a candidate with no marker read back is refused", () => {
256
+ expect(() => readTeardownEnumeration({ candidates: [{ name: "app", type: "Terraform::Root" }] })).toThrow(/marker/);
257
+ });
258
+ });
259
+
260
+ describe("a component's site in pr-<n>", () => {
261
+ const declared = [
262
+ { name: "staging", runtime: "fly", url: "https://shop-{env}.test.invalid", domain: "staging.test.invalid", lifecycle: "git@test.invalid:shop-staging.git" },
263
+ ];
264
+
265
+ test("the template's runtime and lifecycle carry over, a URL naming {env} is filled in, and a literal domain is left out", () => {
266
+ expect(prEnvironmentSite(declared, "staging", "pr-7")).toEqual({ name: "pr-7", runtime: "fly", url: "https://shop-pr-7.test.invalid", lifecycle: "git@test.invalid:shop-staging.git" });
267
+ });
268
+
269
+ test("an entry the component declares for the environment itself wins, and no template entry means no site", () => {
270
+ expect(prEnvironmentSite([...declared, { name: "pr-7", url: "https://seven.test.invalid" }], "staging", "pr-7")).toEqual({ name: "pr-7", url: "https://seven.test.invalid" });
271
+ expect(prEnvironmentSite(declared, "prod", "pr-7")).toBeUndefined();
272
+ });
273
+ });
274
+
275
+ function livePrRecord(live: ReturnType<typeof livePrEnvironments> extends Map<string, infer L> ? L : never): PrEnvRecord {
276
+ return { version: 1, kind: "up", pr: live.pr, env: live.env, template: live.template, marker: live.marker, commit: live.commit, components: live.components, ...(live.locations ? { locations: live.locations } : {}), planDigest: "sha256:set", result: "applied", by: "octo", timestamp: live.applied, expiresAt: live.expiresAt, releaseTarget: false };
277
+ }
278
+
279
+ describe("the cloud identity of the environment's jobs (#4330)", () => {
280
+ const aws = (rules: Array<{ environments?: string[]; plan: string; apply: string }>) => ({ aws: { endpoint: "http://localhost:4566", rules } });
281
+
282
+ test("is the apply identity of the first rule that names the pull request environments", () => {
283
+ const spec = resolvePrEnvCloudIdentity(aws([{ environments: ["prod"], plan: "prod-plan", apply: "prod-apply" }, { environments: ["pr-*"], plan: "pr-plan", apply: "pr-apply" }]));
284
+ expect(spec?.aws).toMatchObject({ role: "pr-apply" });
285
+ });
286
+
287
+ test("a rule with no environments, which covers every one, is never handed to the change's code", () => {
288
+ expect(resolvePrEnvCloudIdentity(aws([{ plan: "any-plan", apply: "any-apply" }]))).toBeUndefined();
289
+ });
290
+ });