@intentius/chant 0.124.43 → 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
@@ -25,8 +25,14 @@
25
25
  * `down` reads the environment's record on chant/lifecycle, checks it names
26
26
  * this pull request's environment, and tears down what `planTeardown` finds
27
27
  * under the record's marker, after dropping any entry whose marker is not
28
- * exactly that one. No gate holds it: closing the pull request is the
29
- * decision, as is the TTL the base set for `sweep`.
28
+ * exactly that one. The lexicons tear down what they own, and the command
29
+ * `ci.prEnvironment.teardown` names tears down the rest (#4330). No gate
30
+ * holds it: closing the pull request is the decision, as is the TTL the base
31
+ * set for `sweep`.
32
+ *
33
+ * The runs themselves are ../../components/pr-env-run.ts, which a product
34
+ * calls with an adapter of its own (#4330). This file supplies the
35
+ * components adapter and the CLI's ports.
30
36
  */
31
37
 
32
38
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -37,39 +43,41 @@ import { isEmptyChangeSet } from "../../change-set";
37
43
  import {
38
44
  PR_ENV_EXIT,
39
45
  PR_ENV_GATE,
40
- PR_ENV_LEDGER_DIR,
41
- PR_ENV_LEDGER_FILE,
42
46
  PR_ENV_RECORD_FILE,
43
47
  PR_ENV_REPORT_DIR,
44
48
  acceptHandedRecord,
45
49
  handedPrEnvReport,
46
50
  PR_ENV_TEMPLATE_VAR,
47
51
  derivePrEnvironment,
48
- expired,
49
- livePrEnvironments,
50
- parsePrEnvRecords,
51
52
  prEnvApproveCommand,
52
- prEnvNoteMarker,
53
- prEnvStatusContext,
54
- prEnvStatusDescription,
55
53
  prEnvStatusState,
56
54
  prEnvironmentName,
55
+ prEnvironmentSite,
57
56
  renderPrEnvNote,
58
- teardownComplete,
59
- teardownCounts,
60
- tearDownPrEnvironment,
61
57
  ttlMs,
62
- type LivePrEnvironment,
63
- type PrEnvRecord,
64
58
  type PrEnvReport,
59
+ type PrEnvReportRow,
60
+ type PrEnvTeardownAdapter,
65
61
  } from "../../components/pr-environment";
66
- import { applyPrSet, approvalOf, decidePrGate, readOnlyLedger } from "../../components/pr-run";
67
- import { gitGateLedgerPort } from "../../op/gate";
62
+ import {
63
+ commandTeardownAdapter,
64
+ forgePrEnvPublisher,
65
+ lifecyclePrEnvPorts,
66
+ prEnvDown as prEnvDownRun,
67
+ prEnvOp,
68
+ prEnvSweep as prEnvSweepRun,
69
+ prEnvUp as prEnvUpRun,
70
+ type PrEnvAdapter,
71
+ type PrEnvPorts,
72
+ type PrEnvTarget,
73
+ } from "../../components/pr-env-run";
74
+ import { applyPrSet, type PrPlanSet } from "../../components/pr-run";
68
75
  import { prOp } from "../../pr-loop";
69
76
  import { FORGE_KINDS, forgeFromEnv, forgePrincipalOf, type ForgeKind, type PrForge } from "../../pr-forge";
70
77
  import { formatError, formatInfo, formatWarning } from "../format";
71
78
  import { withComponentEndpoint } from "../component-endpoint";
72
79
  import type { CommandContext } from "../registry";
80
+ import type { Prepared } from "./pr";
73
81
 
74
82
  const USAGE = "chant components pr-env up --base <ref> --pr <n> [--no-push] | post --pr <n> --head <sha> | down --pr <n> [--reason closed|expired] | sweep";
75
83
 
@@ -107,36 +115,30 @@ function memberOf(ctx: CommandContext): string | undefined {
107
115
  return ctx.args.members?.length === 1 ? ctx.args.members[0] : undefined;
108
116
  }
109
117
 
110
- // ── the ledger ─────────────────────────────────────────────────────────────
118
+ // ── the ports ──────────────────────────────────────────────────────────────
111
119
 
112
- async function readRecords(): Promise<PrEnvRecord[]> {
113
- const { fetchLifecycle, readBlobFromPath } = await import("../../lifecycle/git");
114
- await fetchLifecycle().catch(() => false);
115
- return parsePrEnvRecords(await readBlobFromPath(PR_ENV_LEDGER_DIR, PR_ENV_LEDGER_FILE));
116
- }
120
+ const log = (line: string): void => console.error(formatInfo(line));
121
+ const warn = (message: string): void => console.error(formatWarning({ message }));
117
122
 
118
123
  /**
119
- * Append `record` to chant/lifecycle and push it. With `--no-push` (#4295)
120
- * the record is written to `pr-env-record.json` under `--output` instead,
121
- * for `pr-env post` to append from a job that runs none of the change's
122
- * code. A failure warns: what was applied or torn down stands.
124
+ * The ports `pr-env` runs with (../../components/pr-env-run.ts): the records
125
+ * and the gate ledger on chant/lifecycle here, and the report written under
126
+ * `--output` and posted through `forge`. With `--no-push` (#4295) the record
127
+ * is written to `pr-env-record.json` under `--output` instead, for `pr-env
128
+ * post` to append from a job that runs none of the change's code.
123
129
  */
124
- async function appendRecord(ctx: CommandContext, record: PrEnvRecord): Promise<void> {
125
- if (ctx.args.noPush) {
126
- const dir = resolve(ctx.args.output ?? PR_ENV_REPORT_DIR);
127
- mkdirSync(dir, { recursive: true });
128
- writeFileSync(join(dir, PR_ENV_RECORD_FILE), JSON.stringify(record, null, 2) + "\n");
129
- return;
130
- }
131
- const { appendLedgerLine, fetchLifecycle, pushLifecycleReport } = await import("../../lifecycle/git");
132
- try {
133
- await fetchLifecycle().catch(() => false);
134
- await appendLedgerLine(PR_ENV_LEDGER_DIR, PR_ENV_LEDGER_FILE, JSON.stringify(record), `Pull request environment ${record.env} ${record.kind} (${record.result})`);
135
- const pushed = await pushLifecycleReport();
136
- if (pushed.pushWarning) console.error(formatWarning({ message: pushed.pushWarning }));
137
- } catch (err) {
138
- console.error(formatWarning({ message: `could not record ${record.env}'s ${record.kind} on chant/lifecycle: ${err instanceof Error ? err.message : String(err)}` }));
139
- }
130
+ function cliPorts(ctx: CommandContext, forge: PrForge | undefined): PrEnvPorts {
131
+ const lifecycle = lifecyclePrEnvPorts({ ...(forge ? { forge } : {}), log, warn });
132
+ return {
133
+ ...lifecycle,
134
+ async appendRecord(record) {
135
+ if (!ctx.args.noPush) return lifecycle.appendRecord(record);
136
+ const dir = resolve(ctx.args.output ?? PR_ENV_REPORT_DIR);
137
+ mkdirSync(dir, { recursive: true });
138
+ writeFileSync(join(dir, PR_ENV_RECORD_FILE), JSON.stringify(record, null, 2) + "\n");
139
+ },
140
+ publish: (report) => finish(ctx, forge, report),
141
+ };
140
142
  }
141
143
 
142
144
  // ── the report and the note ────────────────────────────────────────────────
@@ -148,24 +150,7 @@ async function finish(ctx: CommandContext, forge: PrForge | undefined, report: P
148
150
  writeFileSync(join(dir, `pr-env-${report.stage}.json`), JSON.stringify(report, null, 2) + "\n");
149
151
  writeFileSync(join(dir, "pr-env-note.md"), note);
150
152
  if (ctx.args.json) console.log(JSON.stringify(report, null, 2));
151
- if (!forge) return;
152
- if (report.head) {
153
- try {
154
- await forge.setStatus(report.head, {
155
- context: prEnvStatusContext(report.member),
156
- state: prEnvStatusState(report),
157
- description: prEnvStatusDescription(report),
158
- ...(forge.runUrl ? { url: forge.runUrl } : {}),
159
- });
160
- } catch (err) {
161
- console.error(formatWarning({ message: `could not set the ${prEnvStatusContext(report.member)} status: ${err instanceof Error ? err.message : String(err)}` }));
162
- }
163
- }
164
- try {
165
- await forge.upsertNote(report.pr, prEnvNoteMarker(report.env, report.member), note);
166
- } catch (err) {
167
- console.error(formatWarning({ message: `could not post the note on #${report.pr}: ${err instanceof Error ? err.message : String(err)}` }));
168
- }
153
+ if (forge) await forgePrEnvPublisher(forge, warn)(report);
169
154
  }
170
155
 
171
156
  // ── up ─────────────────────────────────────────────────────────────────────
@@ -190,7 +175,6 @@ async function prEnvUp(ctx: CommandContext): Promise<number> {
190
175
  const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
191
176
  const derived = derivePrEnvironment(config, setting.template, pr);
192
177
  if ("refusal" in derived) return fail(derived.refusal);
193
- const ttl = ttlMs(setting.ttl) ?? ttlMs("72h")!;
194
178
 
195
179
  // The build sees pr-<n> as its environment and the template's name beside it.
196
180
  process.env[ENV_VAR] = derived.env;
@@ -200,111 +184,76 @@ async function prEnvUp(ctx: CommandContext): Promise<number> {
200
184
  args: { ...args, env: derived.env, param: [...(args.param ?? []).filter((p) => !p.startsWith(`${derived.param}=`)), `${derived.param}=${derived.env}`] },
201
185
  };
202
186
  console.error(formatInfo(`pr-env up: ${derived.env}, derived from ${derived.template}${derived.endpoint ? ` at ${derived.endpoint}` : ""}, marker ${derived.stack}/${derived.env}`));
203
- return withComponentEndpoint(envCtx, () => up(envCtx, pr, derived, ttl), { config: { ...config, environments: derived.environments }, environment: derived.env });
187
+ return withComponentEndpoint(envCtx, () => up(envCtx, pr, derived, setting.ttl), { config: { ...config, environments: derived.environments }, environment: derived.env });
204
188
  }
205
189
 
206
- async function up(ctx: CommandContext, pr: number, derived: Exclude<ReturnType<typeof derivePrEnvironment>, { refusal: string }>, ttl: number): Promise<number> {
190
+ async function up(ctx: CommandContext, pr: number, derived: Exclude<ReturnType<typeof derivePrEnvironment>, { refusal: string }>, ttl: string): Promise<number> {
207
191
  const { prepare, planAll, placeHooks, runStageHook } = await import("./pr");
208
192
  const p = await prepare(ctx, "plan");
209
193
  if (typeof p === "number") return p;
210
- const forge = p.forge;
211
- const op = prOp(pr, p.member);
212
194
  // ci.hooks (#4288), read at base as the other pull-request jobs read them, around the plan and the apply.
213
- placeHooks(ctx, p, "apply", op);
214
- const base: PrEnvReport = {
215
- stage: "up",
195
+ placeHooks(ctx, p, "apply", prEnvOp(pr, p.member));
196
+ const result = await prEnvUpRun({
216
197
  pr,
217
- env: derived.env,
218
- ...(p.member ? { member: p.member } : {}),
219
- template: derived.template,
220
198
  head: p.head,
221
- op,
222
- gate: PR_ENV_GATE,
223
- digest: "",
224
- status: "nothing",
199
+ template: derived.template,
200
+ ttl,
201
+ stack: derived.stack,
202
+ ...(p.member ? { member: p.member } : {}),
225
203
  ...(derived.endpoint ? { endpoint: derived.endpoint } : {}),
226
- components: [],
227
- };
228
- if (p.plan.order.length === 0) {
229
- await finish(ctx, forge, { ...base, ...(p.routedAway ? { message: p.routedAway } : {}) });
230
- console.error(formatInfo(`pr-env up: nothing this change touches is deployed by a component, so ${derived.env} gets nothing`));
231
- return PR_ENV_EXIT.done;
232
- }
204
+ adapter: componentsPrEnvAdapter(p, { planAll, runStageHook }),
205
+ ports: cliPorts(ctx, p.forge),
206
+ actor: actor(),
207
+ ...(p.forge?.runUrl ? { runUrl: p.forge.runUrl } : {}),
208
+ });
209
+ if (result.report.status === "waiting" || result.report.status === "changed") console.error(formatInfo(`approve : ${prEnvApproveCommand(result.report.op, result.report.digest)}`));
210
+ return result.exit;
211
+ }
233
212
 
234
- const set = await planAll(p);
235
- const rows = (status: (component: string) => string) =>
213
+ /**
214
+ * The components as a pull request environment's adapter: the plan is
215
+ * `pr-plan`'s, with the plan-side hooks around it, and the apply is the
216
+ * fan-out's, with the apply-side hooks around it. Each row carries the
217
+ * component's site in `pr-<n>`, carried over from the template's (#4330,
218
+ * `prEnvironmentSite`).
219
+ */
220
+ function componentsPrEnvAdapter(p: Prepared, run: { planAll: typeof import("./pr").planAll; runStageHook: typeof import("./pr").runStageHook }): PrEnvAdapter {
221
+ let set: PrPlanSet | undefined;
222
+ const rows = (target: PrEnvTarget, status: (component: string) => string): PrEnvReportRow[] =>
236
223
  p.plan.order.map((component) => {
237
- const ms = set.members.filter((m) => m.component === component);
224
+ const ms = (set?.members ?? []).filter((m) => m.component === component);
238
225
  const sum = (k: "create" | "update" | "replace" | "delete") => ms.reduce((n, m) => n + m.counts[k], 0);
239
- return { component, status: status(component), create: sum("create"), update: sum("update"), replace: sum("replace"), delete: sum("delete") };
226
+ const found = prEnvironmentSite(p.sites?.[component], target.template, target.env);
227
+ const site = found ? { ...(found.runtime ? { runtime: found.runtime } : {}), ...(found.url ? { url: found.url } : {}), ...(found.domain ? { domain: found.domain } : {}) } : undefined;
228
+ return { component, status: status(component), create: sum("create"), update: sum("update"), replace: sum("replace"), delete: sum("delete"), ...(site && Object.keys(site).length > 0 ? { site } : {}) };
240
229
  });
241
- const digest = set.doc.digest;
242
- if (set.failed) {
243
- await finish(ctx, forge, { ...base, digest, status: "plan-failed", ...(p.hookFailed ? { message: p.hookFailed } : {}), components: rows((c) => (set.members.some((m) => m.component === c && m.status === "plan-failed") ? "plan failed" : "planned")) });
244
- return PR_ENV_EXIT.failed;
245
- }
246
-
247
- // ws-080, ws-104: a non-empty plan applies only under a person's approval of its digest. An empty one passes.
248
- let approvedBy: string[] | undefined;
249
- if (!isEmptyChangeSet(set.doc)) {
250
- let approval;
251
- try {
252
- approval = approvalOf(await decidePrGate(readOnlyLedger(gitGateLedgerPort()), { op, gate: PR_ENV_GATE, digest, description: `${derived.env}: ${p.plan.order.join(", ")}` }));
253
- } catch (err) {
254
- return fail(`could not read the gate ledger, so ${derived.env} does not apply: ${err instanceof Error ? err.message : String(err)}`);
255
- }
256
- if (approval.status !== "approved") {
257
- const status = approval.status === "changed" ? "changed" : "waiting";
258
- await finish(ctx, forge, { ...base, digest, status, components: rows(() => "planned") });
259
- console.error(formatInfo(`pr-env up: ${derived.env} waits for an approval of ${digest}`));
260
- console.error(formatInfo(`approve : ${prEnvApproveCommand(op, digest)}`));
261
- return PR_ENV_EXIT.waiting;
262
- }
263
- approvedBy = approval.approvedBy ?? [];
264
- }
265
-
266
- const hookStop = runStageHook(p, "before-apply", p.plan.order, set);
267
- if (hookStop) {
268
- await finish(ctx, forge, { ...base, digest, status: "failed", message: hookStop, components: rows(() => "planned"), ...(approvedBy ? { approvedBy } : {}) });
269
- return PR_ENV_EXIT.failed;
270
- }
271
- const applied = await applyPrSet({ components: p.components, plan: p.plan, registry: p.registry, env: derived.env, set, gate: PR_ENV_GATE });
272
- const afterHook = runStageHook(p, "after-apply", p.plan.order, set, "the environment stands as applied");
273
- const status = (component: string) => applied.members.find((m) => m.component === component)?.status ?? "planned";
274
- const ok = applied.status === "applied";
275
- const now = new Date();
276
- const expiresAt = new Date(now.getTime() + ttl).toISOString();
277
- // A copy that failed part way is live too: what applied is torn down on close or at expiry like the rest.
278
- await appendRecord(ctx, {
279
- version: 1,
280
- kind: "up",
281
- pr,
282
- env: derived.env,
283
- ...(p.member ? { member: p.member } : {}),
284
- template: derived.template,
285
- marker: { stack: derived.stack, env: derived.env },
286
- commit: p.head,
287
- components: p.plan.order,
288
- planDigest: digest,
289
- result: ok ? "applied" : "failed",
290
- by: actor(),
291
- timestamp: now.toISOString(),
292
- expiresAt,
293
- ...(approvedBy ? { approvedBy } : {}),
294
- releaseTarget: false,
295
- ...(forge?.runUrl ? { runUrl: forge.runUrl } : {}),
296
- });
297
- await finish(ctx, forge, {
298
- ...base,
299
- digest,
300
- status: ok ? "applied" : "failed",
301
- components: rows(status),
302
- ...(approvedBy ? { approvedBy } : {}),
303
- expiresAt,
304
- ...(ok ? (afterHook ? { message: afterHook } : {}) : { message: applied.status === "waiting" ? "A component read an upstream output that moved when it applied; run the job again to plan it against the new value." : "A component failed to apply; what applied stays in the environment and is torn down with it." }),
305
- });
306
- console.error((ok ? formatInfo : (m: string) => formatWarning({ message: m }))(`pr-env up: ${derived.env} ${ok ? "applied" : applied.status}; it expires ${expiresAt}`));
307
- return ok && !afterHook ? PR_ENV_EXIT.done : PR_ENV_EXIT.failed;
230
+ return {
231
+ async plan(target) {
232
+ if (p.plan.order.length === 0) return { digest: "", empty: true, rows: [], ...(p.routedAway ? { message: p.routedAway } : {}) };
233
+ set = await run.planAll(p);
234
+ const planned = set;
235
+ return {
236
+ digest: planned.doc.digest,
237
+ empty: isEmptyChangeSet(planned.doc),
238
+ ...(planned.failed ? { failed: true } : {}),
239
+ rows: rows(target, (c) => (planned.failed && planned.members.some((m) => m.component === c && m.status === "plan-failed") ? "plan failed" : "planned")),
240
+ ...(p.hookFailed ? { message: p.hookFailed } : {}),
241
+ };
242
+ },
243
+ async apply(target) {
244
+ const planned = set!;
245
+ const hookStop = run.runStageHook(p, "before-apply", p.plan.order, planned);
246
+ if (hookStop) return { status: "stopped", rows: rows(target, () => "planned"), message: hookStop };
247
+ const applied = await applyPrSet({ components: p.components, plan: p.plan, registry: p.registry, env: target.env, set: planned, gate: PR_ENV_GATE });
248
+ const afterHook = run.runStageHook(p, "after-apply", p.plan.order, planned, "the environment stands as applied");
249
+ const status = (component: string) => applied.members.find((m) => m.component === component)?.status ?? "planned";
250
+ return {
251
+ status: applied.status === "applied" ? "applied" : applied.status === "waiting" ? "waiting" : "failed",
252
+ rows: rows(target, status),
253
+ ...(afterHook ? { afterFailure: afterHook } : {}),
254
+ };
255
+ },
256
+ };
308
257
  }
309
258
 
310
259
  // ── post ───────────────────────────────────────────────────────────────────
@@ -352,7 +301,7 @@ async function prEnvPost(ctx: CommandContext): Promise<number> {
352
301
  report.message = `${report.message ? `${report.message} ` : ""}${accepted.refusal}`;
353
302
  code = PR_ENV_EXIT.failed;
354
303
  } else {
355
- await appendRecord({ ...ctx, args: { ...args, noPush: false } }, accepted.record);
304
+ await lifecyclePrEnvPorts({ warn }).appendRecord(accepted.record);
356
305
  if (accepted.record.expiresAt) report.expiresAt = accepted.record.expiresAt;
357
306
  }
358
307
  }
@@ -363,6 +312,19 @@ async function prEnvPost(ctx: CommandContext): Promise<number> {
363
312
 
364
313
  // ── down ───────────────────────────────────────────────────────────────────
365
314
 
315
+ /**
316
+ * What tears the environment down: the lexicons, and the command
317
+ * `ci.prEnvironment.teardown` names in the declaration at this commit
318
+ * (#4330), which the down and sweep jobs check out from the base branch.
319
+ */
320
+ async function teardownSources(): Promise<{ config: ChantConfig; adapters: PrEnvTeardownAdapter[] }> {
321
+ const projectPath = resolve(".");
322
+ const { config } = await loadChantConfig(projectPath).catch(() => ({ config: {} as ChantConfig }));
323
+ const { scopeSource } = await import("../../workspace/write-scope");
324
+ const hook = scopeSource(projectPath, "HEAD").declaration?.ci?.prEnvironment?.teardown;
325
+ return { config, adapters: hook ? [commandTeardownAdapter(hook, { cwd: projectPath })] : [] };
326
+ }
327
+
366
328
  async function prEnvDown(ctx: CommandContext): Promise<number> {
367
329
  const { args } = ctx;
368
330
  if (args.pr === undefined) return fail("pr-env down needs the pull or merge request: --pr <n>");
@@ -370,57 +332,20 @@ async function prEnvDown(ctx: CommandContext): Promise<number> {
370
332
  if (reason !== "closed" && reason !== "expired") return fail(`--reason ${reason} is not closed or expired`);
371
333
  const forge = forgeOf(ctx);
372
334
  if (typeof forge === "number") return forge;
373
- const live = livePrEnvironments(await readRecords()).get(prEnvironmentName(args.pr));
374
- return teardown(ctx, forge, args.pr, live, reason);
375
- }
376
-
377
- async function teardown(ctx: CommandContext, forge: PrForge | undefined, pr: number, live: LivePrEnvironment | undefined, reason: "closed" | "expired"): Promise<number> {
378
- const env = prEnvironmentName(pr);
379
335
  const member = memberOf(ctx);
380
- const report: PrEnvReport = { stage: "down", pr, env, ...(member ? { member } : {}), template: live?.template ?? "", head: "", op: prOp(pr, member), gate: PR_ENV_GATE, digest: "", status: "absent", components: [] };
381
- if (!live) {
382
- console.error(formatInfo(`pr-env down: ${env} has no live record, so there is nothing to tear down`));
383
- if (forge) await finish(ctx, forge, report);
384
- return PR_ENV_EXIT.done;
385
- }
386
- const { config } = await loadChantConfig(resolve(".")).catch(() => ({ config: {} as ChantConfig }));
387
- const torn = await tearDownPrEnvironment({ pr, live, config, plugins: ctx.plugins, warn: (message) => console.error(formatWarning({ message })) });
388
- if ("refusal" in torn) {
389
- await finish(ctx, forge, { ...report, template: live.template, status: "refused", message: torn.refusal });
390
- return fail(torn.refusal);
391
- }
392
- const { report: result, dropped, marker } = torn;
393
- const counts = teardownCounts(result);
394
- const complete = teardownComplete(result);
395
- const lexicons = [...new Set(result.outcomes.map((o) => o.lexicon))].sort();
396
- const head = await headCommit();
397
- await appendRecord(ctx, {
398
- version: 1,
399
- kind: "down",
400
- pr,
401
- env,
402
- ...(live.member ? { member: live.member } : {}),
403
- template: live.template,
404
- marker,
405
- commit: head,
406
- components: lexicons,
407
- planDigest: "",
408
- result: complete ? "destroyed" : "incomplete",
409
- by: actor(),
410
- timestamp: new Date().toISOString(),
336
+ const result = await prEnvDownRun({
337
+ pr: args.pr,
411
338
  reason,
412
- outcomes: counts,
413
- releaseTarget: false,
339
+ ...(member ? { member } : {}),
340
+ ports: cliPorts(ctx, forge),
341
+ ...(await teardownSources()),
342
+ plugins: ctx.plugins,
343
+ commit: await headCommit(),
344
+ actor: actor(),
414
345
  ...(forge?.runUrl ? { runUrl: forge.runUrl } : {}),
415
346
  });
416
- const why: string[] = [];
417
- if (dropped > 0) why.push(`${dropped} candidate${dropped === 1 ? "" : "s"} whose marker was not ${marker.stack}/${env} left alone`);
418
- if (result.unimplemented.length > 0) why.push(`no teardown execution in ${result.unimplemented.join(", ")}`);
419
- if (result.plan.holes.length > 0) why.push(`${result.plan.holes.length} part${result.plan.holes.length === 1 ? "" : "s"} could not be read`);
420
- for (const o of result.outcomes.filter((x) => x.outcome === "failed")) why.push(`${o.lexicon} ${o.name}: ${o.detail ?? "failed"}`);
421
- await finish(ctx, forge, { ...report, template: live.template, status: complete ? "destroyed" : "incomplete", outcomes: counts, ...(why.length > 0 ? { message: why.join("; ") } : {}) });
422
- console.error((complete ? formatInfo : (m: string) => formatWarning({ message: m }))(`pr-env down: ${env} ${complete ? "torn down" : "not wholly torn down; it stays live and the sweep tries again"} (${counts.deleted} deleted, ${counts.failed} failed, ${counts.skipped} skipped)`));
423
- return complete ? PR_ENV_EXIT.done : PR_ENV_EXIT.failed;
347
+ if (result.report.status === "refused") return fail(result.report.message ?? "refused");
348
+ return result.exit;
424
349
  }
425
350
 
426
351
  async function headCommit(): Promise<string> {
@@ -434,29 +359,13 @@ async function headCommit(): Promise<string> {
434
359
  async function prEnvSweep(ctx: CommandContext): Promise<number> {
435
360
  const forge = forgeOf(ctx);
436
361
  if (typeof forge === "number") return forge;
437
- const live = [...livePrEnvironments(await readRecords()).values()];
438
- if (live.length === 0) {
439
- console.error(formatInfo("pr-env sweep: no pull request environment is live"));
440
- return PR_ENV_EXIT.done;
441
- }
442
- const now = new Date();
443
- let code: number = PR_ENV_EXIT.done;
444
- for (const e of live) {
445
- let reason: "closed" | "expired" | undefined = expired(e, now) ? "expired" : undefined;
446
- if (!reason && forge?.pullRequestState) {
447
- try {
448
- const state = await forge.pullRequestState(e.pr);
449
- if (state !== "open") reason = "closed";
450
- } catch (err) {
451
- console.error(formatWarning({ message: `could not read whether #${e.pr} is open (${err instanceof Error ? err.message : String(err)}); its TTL still holds` }));
452
- }
453
- }
454
- if (!reason) {
455
- console.error(formatInfo(`pr-env sweep: ${e.env} stays; it expires ${e.expiresAt}`));
456
- continue;
457
- }
458
- const c = await teardown(ctx, forge, e.pr, e, reason);
459
- if (c !== PR_ENV_EXIT.done) code = PR_ENV_EXIT.failed;
460
- }
461
- return code;
362
+ const result = await prEnvSweepRun({
363
+ ports: cliPorts(ctx, forge),
364
+ ...(await teardownSources()),
365
+ plugins: ctx.plugins,
366
+ commit: await headCommit(),
367
+ actor: actor(),
368
+ ...(forge?.runUrl ? { runUrl: forge.runUrl } : {}),
369
+ });
370
+ return result.exit;
462
371
  }
@@ -129,6 +129,7 @@ import { withComponentEndpoint } from "../component-endpoint";
129
129
  import type { ComponentChangeSignal, FanOutPlan } from "../../components/fan-out";
130
130
  import { failedRunExitCode, type DriverComponent } from "../../components/driver";
131
131
  import type { CapabilityRegistry } from "../../components/capability";
132
+ import type { ComponentEnvironmentDeclaration } from "../../components/component";
132
133
  import { prLoopHooksAt, runPrHooks, type PrHookPlace, type PrLoopHooks } from "../../components/pr-hooks";
133
134
  import { decidePrPolicyOverride, prLoopPolicyAt, prPolicyReport, runPrPolicy, spendPrPolicyOverride, type PrLoopPolicy } from "../../components/pr-policy";
134
135
  import type { OpWaveHookMoment } from "../../op/op-waves";
@@ -157,6 +158,8 @@ export interface Prepared {
157
158
  signal: ComponentChangeSignal;
158
159
  registry: CapabilityRegistry;
159
160
  seededOutputs: Record<string, Record<string, unknown>>;
161
+ /** Component name to the environments it declares (#3153), whose sites `pr-env up` carries over (#4330). */
162
+ sites?: Record<string, ComponentEnvironmentDeclaration[]>;
160
163
  /** `ci.hooks` from the declaration at base (#4288). */
161
164
  hooks: PrLoopHooks;
162
165
  /** Every hook the stage ran so far, for the report's `hooks`. */
@@ -296,6 +299,7 @@ export async function prepare(ctx: CommandContext, stage: "plan" | "apply" | "re
296
299
  components: derived.components,
297
300
  plan: routedAway ? { ...derived.plan, order: [], waves: [], seeds: [], indeterminate: [] } : derived.plan,
298
301
  signal: derived.signal,
302
+ ...(derived.sites ? { sites: derived.sites } : {}),
299
303
  registry,
300
304
  seededOutputs,
301
305
  hooks,
@@ -545,6 +545,8 @@ export interface ResolvedComponentTargets {
545
545
  targets: DriverComponent[];
546
546
  /** For a list selector (#3950): the dependencies the closure added that the list did not name, in discovery order. */
547
547
  added?: string[];
548
+ /** Component name to the environments it declares (#3153), for the components that declare any (#4330). */
549
+ sites?: Record<string, ComponentEnvironmentDeclaration[]>;
548
550
  error?: string;
549
551
  }
550
552
 
@@ -577,7 +579,10 @@ export async function resolveComponentTargets(
577
579
 
578
580
  if (isComponentList(selector)) return resolveComponentList(selector, discovered, all);
579
581
 
580
- if (selector === "all") return { success: true, targets: withoutDisabled(discovered, all) };
582
+ if (selector === "all") {
583
+ const sites = Object.fromEntries(discovered.filter((c) => c.environments && c.environments.length > 0).map((c) => [c.name, c.environments!.map((e) => ({ ...e }))]));
584
+ return { success: true, targets: withoutDisabled(discovered, all), ...(Object.keys(sites).length > 0 ? { sites } : {}) };
585
+ }
581
586
 
582
587
  const found = all.find((c) => c.name === selector);
583
588
  const disabledByName = discovered.find((c) => c.name === selector && c.enabled === false);
@@ -20,6 +20,7 @@ import { buildCapabilityRegistry } from "./capability-plugin-loader";
20
20
  import { componentsForUnits, planFanOut, type ChangedUnits, type ComponentChangeSignal, type FanOutPlan } from "./fan-out";
21
21
  import type { CapabilityRegistry } from "./capability";
22
22
  import type { DriverComponent } from "./driver";
23
+ import type { ComponentEnvironmentDeclaration } from "./component";
23
24
  import type { BuildParamProvenance } from "../provenance";
24
25
  import { loadChantConfig, type ChantConfig } from "../config";
25
26
 
@@ -49,6 +50,8 @@ export interface DerivedFanOut {
49
50
  /** What the stack-level signal meant in component terms, `unclaimed` included. */
50
51
  signal: ComponentChangeSignal;
51
52
  plan: FanOutPlan;
53
+ /** Component name to the environments it declares, for the components that declare any (#4330). */
54
+ sites?: Record<string, ComponentEnvironmentDeclaration[]>;
52
55
  error?: string;
53
56
  }
54
57
 
@@ -85,7 +88,7 @@ export async function deriveFanOut(options: DeriveFanOutOptions): Promise<Derive
85
88
  ...(options.canary?.length ? { canary: options.canary } : {}),
86
89
  });
87
90
 
88
- return { success: true, components, signal, plan };
91
+ return { success: true, components, signal, plan, ...(resolved.sites ? { sites: resolved.sites } : {}) };
89
92
  }
90
93
 
91
94
  /**