@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
@@ -13,7 +13,7 @@
13
13
  * Like ./pr-forge.ts this imports nothing beyond it, so a product bundles it
14
14
  * without the TypeScript toolchain.
15
15
  */
16
- import type { PrApproverFilter, PrForge, PrRequireResult } from "./pr-forge";
16
+ import type { PrApproverFilter, PrExcludedApprover, PrForge, PrRequireResult } from "./pr-forge";
17
17
 
18
18
  /** The four checks, in the order they run and the report lists them. */
19
19
  export const PR_APPLY_REQUIRES = ["approved", "mergeable", "undiverged", "checks"] as const;
@@ -23,6 +23,10 @@ export type PrApplyRequire = (typeof PR_APPLY_REQUIRES)[number];
23
23
  /** One check as the pull-request report's `beforeMerge.requires` holds it. */
24
24
  export interface PrRequireOutcome extends PrRequireResult {
25
25
  name: PrApplyRequire;
26
+ /** `approved` only (#4287): the logins that counted, as {@link PrApprovalResult} gives them. */
27
+ approvers?: string[];
28
+ /** `approved` only (#4287): the logins left out, each with why. */
29
+ excluded?: PrExcludedApprover[];
26
30
  }
27
31
 
28
32
  export interface PrRequiresOptions {
@@ -50,4 +50,24 @@ describe("declaredCloudIdentity (#4296)", () => {
50
50
  const root = workspace({ identity: { cloud: { aws: { rules: [{ plan: "r", apply: "r" }] } } } });
51
51
  await expect(declaredCloudIdentity(root, "production")).rejects.toThrow(/plans and applies as r.*\[declaration-invalid\]/);
52
52
  });
53
+
54
+ test("rules with targets and a name read from the declaration, and a target glob list may not be empty (#4332)", async () => {
55
+ const root = workspace({
56
+ identity: {
57
+ cloud: {
58
+ aws: {
59
+ rules: [
60
+ { members: ["net"], targets: ["envs/prod/**"], name: "prod", plan: "arn:aws:iam::1:role/prod-plan", apply: "arn:aws:iam::1:role/prod-apply" },
61
+ { plan: "arn:aws:iam::1:role/plan", apply: "arn:aws:iam::1:role/apply" },
62
+ ],
63
+ },
64
+ },
65
+ },
66
+ });
67
+ const net = (await declaredCloudIdentity(join(root, "net"), "production", "net"))!;
68
+ expect(net.plan.aws).toMatchObject({ role: "arn:aws:iam::1:role/plan", targets: [{ targets: ["envs/prod/**"], role: "arn:aws:iam::1:role/prod-plan" }] });
69
+ expect((await declaredCloudIdentity(join(root, "app"), "production", "app"))!.plan.aws!.targets).toBeUndefined();
70
+ const empty = workspace({ identity: { cloud: { aws: { rules: [{ targets: [], plan: "p", apply: "a" }] } } } });
71
+ await expect(declaredCloudIdentity(empty, "production")).rejects.toThrow(/\[declaration-invalid\]/);
72
+ });
53
73
  });
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { findWorkspaceRoot } from "../project-root";
10
- import { resolveCloudIdentity, type PipelineCloudIdentity } from "../ci-identity";
10
+ import { resolveCloudIdentity, resolvePrEnvCloudIdentity, type CloudTradeSpec, type PipelineCloudIdentity } from "../ci-identity";
11
11
 
12
12
  /** The plan and apply identities of the pipeline for `env` and `member`, or undefined. */
13
13
  export async function declaredCloudIdentity(cwd: string, env: string, member?: string): Promise<PipelineCloudIdentity | undefined> {
@@ -21,3 +21,16 @@ export async function declaredCloudIdentity(cwd: string, env: string, member?: s
21
21
  throw Object.assign(new Error(`${err.describe()} [${err.code}]`), { code: err.code });
22
22
  }
23
23
  }
24
+
25
+ /** The trade a pull request environment's jobs take for `member` (#4330, `resolvePrEnvCloudIdentity`), or undefined. */
26
+ export async function declaredPrEnvCloudIdentity(cwd: string, member?: string): Promise<CloudTradeSpec | undefined> {
27
+ const found = findWorkspaceRoot(cwd);
28
+ if (!found) return undefined;
29
+ const [{ readDeclaration, WorkspaceReadError }, { workingTree }] = await Promise.all([import("./declaration"), import("./tree")]);
30
+ try {
31
+ return resolvePrEnvCloudIdentity(readDeclaration(workingTree(found.dir)).ci?.identity?.cloud, member);
32
+ } catch (err) {
33
+ if (!(err instanceof WorkspaceReadError)) throw err;
34
+ throw Object.assign(new Error(`${err.describe()} [${err.code}]`), { code: err.code });
35
+ }
36
+ }
@@ -26,7 +26,7 @@ import { sshFromEd25519 } from "./trust/dsse";
26
26
  import { verifyOpRunStatement } from "./trust/op-run-statement";
27
27
  import { readRunLedger } from "../lifecycle/run-ledger";
28
28
  import { resolveRunId } from "../lifecycle/release-ledger";
29
- import { prLoopJobs, PR_LOOP_SETUP } from "../components/pr-pipeline";
29
+ import { prEnvJobs, prLoopJobs, PR_LOOP_SETUP } from "../components/pr-pipeline";
30
30
  import { parseArgs } from "../cli/parse-args";
31
31
  import { ciSecretsFor, readCiSecrets } from "./ci-secrets";
32
32
  import { runEventFileName, runEventsDir } from "../run-event-store";
@@ -1039,3 +1039,76 @@ describe("the plan's steps hold no forge token (#4295)", () => {
1039
1039
  expect(plan!.command.endsWith(" --no-status")).toBe(true);
1040
1040
  });
1041
1041
  });
1042
+
1043
+ /**
1044
+ * #4330: a pull request environment's jobs run through `ci run` too. The
1045
+ * stand-in chant keeps each action's arguments, environment, directory and
1046
+ * commit under `.chant/pr-env`, and `up` writes a report and a record there.
1047
+ */
1048
+ function fakeEnvChant(): string {
1049
+ const file = join(scratchDir("chant-ci-run-env-bin-"), "chant");
1050
+ writeFileSync(
1051
+ file,
1052
+ [
1053
+ "#!/bin/sh",
1054
+ 'action="$3"',
1055
+ "mkdir -p .chant/pr-env",
1056
+ 'printf "%s\\n" "$@" > ".chant/pr-env/$action.args"',
1057
+ 'env | sort > ".chant/pr-env/$action.env"',
1058
+ 'git rev-parse HEAD > ".chant/pr-env/$action.commit"',
1059
+ 'ls .chant/pr-env > ".chant/pr-env/$action.found"',
1060
+ 'if [ "$action" = up ]; then echo "{\\"stage\\": \\"up\\"}" > .chant/pr-env/pr-env-up.json; echo "{}" > .chant/pr-env/pr-env-record.json; fi',
1061
+ 'exit "${FAKE_EXIT:-0}"',
1062
+ "",
1063
+ ].join("\n"),
1064
+ );
1065
+ chmodSync(file, 0o755);
1066
+ return file;
1067
+ }
1068
+
1069
+ describe("a pull request environment's jobs (#4330)", () => {
1070
+ test("env-up runs with no forge token, then env-post runs at the base commit with it and the report env-up handed on", async () => {
1071
+ const { app, base, sha } = workspace();
1072
+ const out = join(scratchDir("chant-ci-run-env-out-"), "pr-env");
1073
+ const r = await ciRun({ image: JOB_IMAGE, cwd: app, job: "env-up", env: "pr-7", forge: "github", base, pr: 7, chant: fakeEnvChant(), hostEnv: HOST, output: out, push: false });
1074
+ expect(r.op).toBe("pr-env-up");
1075
+ expect(r.status).toBe("ok");
1076
+ const upEnv = envFile(out, "up");
1077
+ for (const name of FORGE_TOKEN_VARS) expect(upEnv[name]).toBeUndefined();
1078
+ expect(upEnv.CHANT_ENV).toBe("pr-7");
1079
+ expect(upEnv.PR_NUMBER).toBe("7");
1080
+ expect(readFileSync(join(out, "up.commit"), "utf-8").trim()).toBe(sha);
1081
+ expect(readFileSync(join(out, "up.args"), "utf-8")).not.toContain("--forge");
1082
+
1083
+ expect(r.post?.op).toBe("pr-env-post");
1084
+ const postEnv = envFile(out, "post");
1085
+ // The token reached env-post, and its copied report is redacted.
1086
+ expect(postEnv.GITHUB_TOKEN).toBe("***");
1087
+ expect(postEnv.HEAD_SHA).toBe(sha);
1088
+ expect(postEnv.UP_RESULT).toBe("success");
1089
+ expect(readFileSync(join(out, "post.commit"), "utf-8").trim()).toBe(base);
1090
+ expect(readFileSync(join(out, "post.found"), "utf-8")).toContain("pr-env-record.json");
1091
+ });
1092
+
1093
+ test("env-down runs with the token and writes its report under .chant/pr-env, and a local env-up posts nothing", async () => {
1094
+ const { app, base } = workspace();
1095
+ const out = join(scratchDir("chant-ci-run-env-out-"), "pr-env");
1096
+ const down = await ciRun({ image: JOB_IMAGE, cwd: app, job: "env-down", env: "pr-7", forge: "github", base, pr: 7, chant: fakeEnvChant(), hostEnv: HOST, output: out, push: false });
1097
+ expect(down.op).toBe("pr-env-down");
1098
+ expect(envFile(out, "down").GITHUB_TOKEN).toBe("***");
1099
+ expect(readFileSync(join(out, "down.args"), "utf-8")).toContain("closed");
1100
+
1101
+ const local = await ciRun({ image: JOB_IMAGE, cwd: app, job: "env-up", env: "pr-7", forge: "github", base, pr: 7, chant: fakeEnvChant(), hostEnv: HOST, output: join(scratchDir("chant-ci-run-env-out-"), "pr-env"), local: true });
1102
+ expect(local.post).toBeUndefined();
1103
+ });
1104
+
1105
+ test("env-post reads the head and the up job's result from flags", () => {
1106
+ expect(() => ciRunEnvJobEnv({})).toThrow(/pass --head <sha>/);
1107
+ expect(ciRunEnvJobEnv({ head: "h".repeat(40), upResult: "failure" })).toMatchObject({ HEAD_SHA: "h".repeat(40), UP_RESULT: "failure", PR_NUMBER: "7" });
1108
+ });
1109
+ });
1110
+
1111
+ function ciRunEnvJobEnv(values: { head?: string; upResult?: string }): Record<string, string> {
1112
+ const post = prEnvJobs("github", {}, "app").find((j) => j.jobName === "env-post")!;
1113
+ return ciJobEnv(post, { forge: "github", env: "pr-7", values: { pr: 7, base: "b".repeat(40), commit: "b".repeat(40), ...values }, hostEnv: HOST });
1114
+ }
@@ -84,7 +84,7 @@ import { execFileSync } from "node:child_process";
84
84
  import { copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
85
85
  import { tmpdir } from "node:os";
86
86
  import { dirname, join, relative, resolve, sep } from "node:path";
87
- import { PR_APPLY_RECORD, PR_LOOP_REPORT_DIR, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
87
+ import { PR_APPLY_RECORD, PR_ENV_POST_JOB, PR_ENV_REPORT_DIR, PR_LOOP_REPORT_DIR, prEnvJobs, prLoopJobs, prLoopSetup, PR_LOOP_IMAGE, type PrEnvJob, type PrLoopJob, type PrLoopPipelineOptions } from "../components/pr-pipeline";
88
88
  import { detectForge, ForgeEnvironmentError, forgeFromEnv, FORGE_KINDS, type CommitStatus, type ForgeKind, type PrForge } from "../pr-forge";
89
89
  import { holdsForgeToken, withoutForgeTokens } from "../ci-handover";
90
90
  import { postPlanReport } from "../pr-post";
@@ -114,20 +114,42 @@ import { hostRunnerKey } from "./ci-runner-key";
114
114
  import { requireRunnerStart } from "./ci-runner-start";
115
115
  import { securityGroupsAt } from "./security";
116
116
 
117
- /** The jobs a pull-request pipeline has: plan, and on merge record (#4168) or, opted in, apply. */
118
- export type CiJobName = PrLoopJob["jobName"];
119
- export const CI_JOB_NAMES: readonly CiJobName[] = ["plan", "apply", "record"];
117
+ /**
118
+ * The jobs a pull-request pipeline has: plan, and on merge record (#4168) or,
119
+ * opted in, apply; and with `--pr-env` (#4298, #4330) the jobs of a pull
120
+ * request's environment.
121
+ */
122
+ export type CiJobName = PrLoopJob["jobName"] | PrEnvJob["jobName"];
123
+ export const CI_JOB_NAMES: readonly CiJobName[] = ["plan", "apply", "record", "env-up", "env-post", "env-down", "env-sweep"];
124
+
125
+ /** The jobs of a pull request's environment (#4330). */
126
+ export const CI_ENV_JOB_NAMES: readonly PrEnvJob["jobName"][] = ["env-up", "env-post", "env-down", "env-sweep"];
120
127
 
121
- /** The Op each job is recorded as, which `ci.green` phases match. */
122
- export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "pr-apply", record: "pr-record" };
128
+ /** Whether `job` is one of a pull request environment's jobs. */
129
+ export const isCiEnvJob = (job: CiJobName): job is PrEnvJob["jobName"] => (CI_ENV_JOB_NAMES as readonly string[]).includes(job);
130
+
131
+ /** The Op each job is recorded as, which `ci.green` phases match. An environment job's is its report's stem too. */
132
+ export const CI_JOB_OPS: Record<CiJobName, string> = { plan: "pr-plan", apply: "pr-apply", record: "pr-record", "env-up": "pr-env-up", "env-post": "pr-env-post", "env-down": "pr-env-down", "env-sweep": "pr-env-sweep" };
123
133
 
124
134
  /** What each job's Op says it does. */
125
135
  const CI_JOB_OVERVIEWS: Record<CiJobName, string> = {
126
136
  plan: "Plan the members this pull request reaches",
127
137
  apply: "Apply the plan a reviewer approved",
128
138
  record: "Record the plan on the merged commit; nothing applies",
139
+ "env-up": "Plan this pull request's environment and apply it once its plan is approved",
140
+ "env-post": "Keep the environment's record and post its note",
141
+ "env-down": "Tear down what this pull request's environment owns",
142
+ "env-sweep": "Tear down the environments whose TTL passed or whose pull request closed",
129
143
  };
130
144
 
145
+ /** One job as `ci run` runs it: a pull-request job, or an environment job (#4330). */
146
+ export type CiJob = Pick<PrLoopJob, "command" | "env" | "setup" | "build" | "wave"> & { jobName: CiJobName };
147
+
148
+ /** Where a job writes its report, inside its checkout. */
149
+ export function ciJobReportDir(job: CiJobName): string {
150
+ return isCiEnvJob(job) ? PR_ENV_REPORT_DIR : PR_LOOP_REPORT_DIR;
151
+ }
152
+
131
153
  /** Where a job the chant runner ran says it ran (#4094). */
132
154
  export const CHANT_RUNNER_ORIGIN: RunOrigin = { forge: "chant" };
133
155
 
@@ -167,6 +189,10 @@ export interface CiRunValues {
167
189
  pr?: number;
168
190
  /** The full commit the job runs at. */
169
191
  commit: string;
192
+ /** `--head`: the pull request's head, `HEAD_SHA`, which `env-post` reads (#4330). */
193
+ head?: string;
194
+ /** `--result`: the env-up job's result, `UP_RESULT`, which `env-post` reads on GitHub and Forgejo (#4330). */
195
+ upResult?: string;
170
196
  }
171
197
 
172
198
  export class CiRunError extends Error {
@@ -188,7 +214,7 @@ export class CiRunError extends Error {
188
214
  * runner sets for the job. `CHANT_RUNNER_KEY` is never in it.
189
215
  */
190
216
  export function ciJobEnv(
191
- job: PrLoopJob,
217
+ job: CiJob,
192
218
  input: {
193
219
  forge: ForgeKind;
194
220
  env: string;
@@ -211,12 +237,18 @@ export function ciJobEnv(
211
237
  const flags: Record<string, { value: string | undefined; flag: string }> = {
212
238
  BASE_SHA: { value: input.values.base, flag: "--base <sha>" },
213
239
  PR_NUMBER: { value: input.values.pr !== undefined ? String(input.values.pr) : undefined, flag: "--pr <number>" },
240
+ HEAD_SHA: { value: input.values.head, flag: "--head <sha>" },
241
+ UP_RESULT: { value: input.values.upResult, flag: "--result success|failure" },
214
242
  };
243
+ // #4330: an environment job's CHANT_ENV is pr-<n>, which a forge fills from the event.
244
+ const prEnv = isCiEnvJob(job.jobName) && input.values.pr !== undefined ? `pr-${input.values.pr}` : undefined;
215
245
  for (const [name, value] of Object.entries(job.env)) {
216
246
  const given = flags[name];
217
247
  if (given) {
218
248
  if (given.value === undefined) throw new CiRunError(`the ${job.jobName} job reads ${name}, which a forge fills from the event; pass ${given.flag}`);
219
249
  out[name] = given.value;
250
+ } else if (name === "CHANT_ENV" && prEnv) {
251
+ out[name] = prEnv;
220
252
  } else if (!value.includes("$")) {
221
253
  out[name] = value;
222
254
  } else if (input.secrets?.[name] !== undefined) {
@@ -231,7 +263,7 @@ export function ciJobEnv(
231
263
 
232
264
  /** What {@link ciJobOp} builds the Op from. */
233
265
  export interface CiJobOpInput {
234
- job: PrLoopJob;
266
+ job: CiJob;
235
267
  env: string;
236
268
  /** The directory the steps run in, inside the fresh checkout. */
237
269
  cwd: string;
@@ -251,7 +283,7 @@ export function withChant(line: string, chant?: string): string {
251
283
  }
252
284
 
253
285
  /** The job's own setup lines and its command, as one shell script, with `chant` at its start replaced by the command the caller names. */
254
- export function ciJobCommand(job: PrLoopJob, chant?: string): string {
286
+ export function ciJobCommand(job: CiJob, chant?: string): string {
255
287
  return [...(job.setup ?? []), withChant(job.command, chant)].join("\n");
256
288
  }
257
289
 
@@ -283,7 +315,7 @@ export function ciJobOp(input: CiJobOpInput): OpConfig {
283
315
  }
284
316
 
285
317
  /** The pull-request report statuses that mean the job did not do its work, which a failed job's `failure.code` takes. */
286
- const FAILED_REPORT_STATUSES = new Set(["plan-failed", "failed", "refused"]);
318
+ const FAILED_REPORT_STATUSES = new Set(["plan-failed", "failed", "refused", "incomplete"]);
287
319
 
288
320
  /** The exit code a failed shell step's error names (`command exited <n>`), when it names one. */
289
321
  function exitCodeOf(error: string | undefined): number | undefined {
@@ -441,10 +473,14 @@ export interface CiRunRequest {
441
473
  * `forge` then only shapes the job's variables.
442
474
  */
443
475
  noForge?: boolean;
444
- /** `--base`, `--pr` and `--commit` (default `HEAD`). */
476
+ /** `--base`, `--pr` and `--commit` (default `HEAD`; for `env-post`, `--base`). */
445
477
  base?: string;
446
478
  pr?: number;
447
479
  commit?: string;
480
+ /** `--head`: for `env-post` (#4330), the pull request's head its environment was applied from. */
481
+ head?: string;
482
+ /** `--result`: for `env-post` (#4330), the env-up job's result. */
483
+ upResult?: string;
448
484
  /**
449
485
  * The generator's options: `--gate`, `--branch`, `--events-json`,
450
486
  * `--report-url`, `--pr-build` and `--pr-build-command`, and `--wave-gate`
@@ -553,6 +589,11 @@ export interface CiRunResult {
553
589
  checkout: string;
554
590
  /** The job ran with `local` (#4161): its record stays on this machine. */
555
591
  local?: true;
592
+ /**
593
+ * For `env-up` (#4330): the `env-post` job the runner ran after it at
594
+ * `--base`, with its own token, which kept the record and posted the note.
595
+ */
596
+ post?: CiRunResult;
556
597
  }
557
598
 
558
599
  const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
@@ -619,7 +660,7 @@ function freshCheckout(top: string, commit: string): { dir: string; remove(): vo
619
660
  */
620
661
  async function recordInIndex(input: {
621
662
  url: string;
622
- job: CiJobName;
663
+ job: PrLoopJob["jobName"];
623
664
  commit: string;
624
665
  member?: string;
625
666
  pr?: number;
@@ -679,11 +720,14 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
679
720
  const cwd = realpathSync(resolve(req.cwd));
680
721
  const top = gitTop(cwd);
681
722
  if (!top) throw new CiRunError("a job runs in a fresh checkout of a commit, and this directory is not in a git repository");
682
- const commit = resolveJobCommit(top, req.commit ?? "HEAD");
723
+ // #4330: env-post runs at the target branch's commit, none of the change's code.
724
+ const commit = resolveJobCommit(top, req.commit ?? (req.job === PR_ENV_POST_JOB && req.base ? req.base : "HEAD"));
683
725
  if (req.inPlace && commit !== resolveJobCommit(top, "HEAD")) throw new CiRunError(`--in-place runs the checkout's HEAD, and --commit names ${commit.slice(0, 12)}`);
684
726
  if (req.local && req.inPlace) throw new CiRunError("--in-place runs a forge job in the forge's checkout, and --local runs one on this machine; pass one of them");
685
727
  const place = ciJobPlace(cwd, top);
686
- const jobs = prLoopJobs(req.forge, req.env, req.local ? localLoop(req.loop) : (req.loop ?? {}), place.member);
728
+ const loop = req.local ? localLoop(req.loop) : (req.loop ?? {});
729
+ // #4330: the jobs of a pull request's environment, as the generators wrap them with --pr-env.
730
+ const jobs: CiJob[] = isCiEnvJob(req.job) ? prEnvJobs(req.forge, loop, place.member) : prLoopJobs(req.forge, req.env, loop, place.member);
687
731
  const found = jobs.find((j) => j.jobName === req.job);
688
732
  if (!found) throw new CiRunError(`the pull-request pipeline has no ${req.job} job; it has ${jobs.map((j) => j.jobName).join(" and ")}`);
689
733
  if (req.wave !== undefined && (req.job !== "apply" || !req.loop?.waveGate)) throw new CiRunError("--wave names the one wave an apply runs, and takes --wave-gate: ci run apply --wave-gate <gate> --wave <k>");
@@ -699,15 +743,15 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
699
743
  const own = ciJobEnv(job, {
700
744
  forge: req.forge,
701
745
  env: req.env,
702
- values: { base: req.base, pr: req.pr, commit },
746
+ values: { base: req.base, pr: req.pr, commit, ...(req.head ? { head: req.head } : {}), ...(req.upResult ? { upResult: req.upResult } : {}) },
703
747
  hostEnv,
704
748
  passEnv: req.passEnv,
705
749
  secrets: ciSecretsFor(req.secrets, req.job),
706
750
  });
707
751
  // #4156: in place, the forge job's environment is the job's, less the runner key.
708
- // #4295: the plan runs the pull request's code, so no step of it gets a forge token, whatever gave it one.
752
+ // #4295: the plan and env-up run the pull request's code, so no step of them gets a forge token, whatever gave it one.
709
753
  const merged = req.inPlace ? { ...definedEnv(hostEnv), ...own } : own;
710
- const jobEnv = req.job === "plan" ? withoutForgeTokens(merged) : merged;
754
+ const jobEnv = req.job === "plan" || req.job === "env-up" ? withoutForgeTokens(merged) : merged;
711
755
  delete jobEnv[RUNNER_KEY_ENV];
712
756
  const poster = req.job === "plan" ? planPoster(req, hostEnv) : undefined;
713
757
  // #4096: the secrets and the job's secret-looking variables, redacted from everything the run writes and returns.
@@ -716,7 +760,9 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
716
760
  try {
717
761
  const ran = await runCiJob(req, { cwd, top, commit, place, job, jobEnv, ...(redact ? { redact } : {}) });
718
762
  const posted = poster ? await postFromReport(req, poster, ran, { commit, ...(place.member ? { member: place.member } : {}) }) : [];
719
- const withPosts = posted.length > 0 ? { ...ran, warnings: [...ran.warnings, ...posted] } : ran;
763
+ const envPost = req.job === "env-up" ? await envPostAfterUp(req, { cwd, top, head: commit, place, jobs, hostEnv, ran }) : undefined;
764
+ const withPost = envPost ? ("result" in envPost ? { ...ran, post: envPost.result } : { ...ran, warnings: [...ran.warnings, envPost.warning] }) : ran;
765
+ const withPosts = posted.length > 0 ? { ...withPost, warnings: [...withPost.warnings, ...posted] } : withPost;
720
766
  const result = started ? { ...withPosts, notes: [started, ...withPosts.notes] } : withPosts;
721
767
  return redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result;
722
768
  } catch (err) {
@@ -725,6 +771,40 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
725
771
  }
726
772
  }
727
773
 
774
+ /**
775
+ * The env-post job after an env-up (#4330), as a generated pipeline runs it
776
+ * in a job of its own: at `--base`, the target branch's commit, with the
777
+ * forge token env-up's steps never got, taking the report and record env-up
778
+ * handed on. None on `--local`, in place (the forge's env-post job posts),
779
+ * for a plain git server, without `--base` and `--pr`, or without a token.
780
+ */
781
+ async function envPostAfterUp(
782
+ req: CiRunRequest,
783
+ at: { cwd: string; top: string; head: string; place: { member?: string; dir: string }; jobs: CiJob[]; hostEnv: Record<string, string | undefined>; ran: CiRunResult },
784
+ ): Promise<{ result: CiRunResult } | { warning: string } | undefined> {
785
+ if (req.local || req.inPlace || req.noForge || req.pr === undefined) return undefined;
786
+ if (!req.base) return { warning: "env-post did not run: it runs at the target branch's commit, so pass --base <sha>" };
787
+ if (!holdsForgeToken(at.hostEnv)) return { warning: "env-post did not run: there is no forge token, so the environment's record is in the report and nothing kept it" };
788
+ const found = at.jobs.find((j) => j.jobName === PR_ENV_POST_JOB);
789
+ if (!found) return undefined;
790
+ const base = resolveJobCommit(at.top, req.base);
791
+ const job = { ...found, command: req.noForge ? found.command.replace(` --forge ${req.forge}`, "") : found.command };
792
+ const postReq: CiRunRequest = { ...req, job: PR_ENV_POST_JOB, commit: base, head: at.head, upResult: at.ran.status === "ok" ? "success" : "failure" };
793
+ delete postReq.status;
794
+ delete postReq.resumeRecord;
795
+ delete postReq.wave;
796
+ const jobEnv = ciJobEnv(job, { forge: req.forge, env: req.env, values: { base: req.base, pr: req.pr, commit: base, head: at.head, upResult: postReq.upResult! }, hostEnv: at.hostEnv, passEnv: req.passEnv, secrets: ciSecretsFor(req.secrets, PR_ENV_POST_JOB) });
797
+ delete jobEnv[RUNNER_KEY_ENV];
798
+ // Its own redactor: env-post holds the token env-up's steps never got.
799
+ const redact = ciRedactor(jobEnv, req.secrets);
800
+ try {
801
+ const result = await runCiJob(postReq, { cwd: at.cwd, top: at.top, commit: base, place: at.place, job, jobEnv, ...(redact ? { redact } : {}), ...(at.ran.report ? { seed: at.ran.report } : {}) });
802
+ return { result: redact ? { ...result, warnings: result.warnings.map(redact), notes: result.notes.map(redact), ...(result.pushWarning ? { pushWarning: redact(result.pushWarning) } : {}) } : result };
803
+ } catch (err) {
804
+ return { warning: `env-post did not run: ${message(err)}` };
805
+ }
806
+ }
807
+
728
808
  /**
729
809
  * Who posts the plan's note and `chant/plan` status (#4295): the runner,
730
810
  * from the report, once the plan's steps settled, with the forge token the
@@ -854,7 +934,7 @@ function jobCheckout(top: string, commit: string, inPlace?: boolean): { dir: str
854
934
  /** {@link ciRun} once the job and its environment are known. */
855
935
  async function runCiJob(
856
936
  req: CiRunRequest,
857
- at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job: PrLoopJob; jobEnv: Record<string, string>; redact?: Redact },
937
+ at: { cwd: string; top: string; commit: string; place: { member?: string; dir: string }; job: CiJob; jobEnv: Record<string, string>; redact?: Redact; seed?: string },
858
938
  ): Promise<CiRunResult> {
859
939
  const { cwd, top, commit, place, job, jobEnv, redact } = at;
860
940
  const local = req.local === true;
@@ -884,6 +964,8 @@ async function runCiJob(
884
964
  ...(req.chant ? { chant: req.chant } : {}),
885
965
  });
886
966
  const warnings: string[] = [...early];
967
+ // #4330: env-post takes the report env-up wrote, as a forge job takes it from the up job's outputs.
968
+ if (at.seed && existsSync(at.seed)) cpSync(at.seed, join(jobDir, ciJobReportDir(req.job)), { recursive: true });
887
969
  const resume = req.job === "apply" && req.resumeRecord ? { host: resolve(req.resumeRecord), job: join(jobDir, PR_APPLY_RECORD) } : undefined;
888
970
  if (resume && existsSync(resume.host)) {
889
971
  mkdirSync(dirname(resume.job), { recursive: true });
@@ -913,7 +995,7 @@ async function runCiJob(
913
995
  commit,
914
996
  ledger: { cwd: jobDir },
915
997
  runner: { kind: "pipeline", runtime: local ? "local" : "ci", runOrigin: origin },
916
- describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, PR_LOOP_REPORT_DIR) }),
998
+ describeFailure: (records) => ciJobFailure({ job: req.job, op, records, reportDir: join(jobDir, ciJobReportDir(req.job)) }),
917
999
  ...(req.runEvents ? { runEvents: { write: req.runEvents } } : {}),
918
1000
  ...(req.stepOutput ? { stepOutput: req.stepOutput } : {}),
919
1001
  ...(signer ? { signRecord: (record: OpRunRecord) => signOpRunRecord(record, signer!) } : {}),
@@ -946,10 +1028,10 @@ async function runCiJob(
946
1028
  warnings.push(`the resume record could not be kept at ${resume.host}: ${message(err)}`);
947
1029
  }
948
1030
  }
949
- const written = join(jobDir, PR_LOOP_REPORT_DIR);
1031
+ const written = join(jobDir, ciJobReportDir(req.job));
950
1032
  let report: string | null = null;
951
1033
  if (existsSync(written)) {
952
- report = resolve(cwd, req.output ?? PR_LOOP_REPORT_DIR);
1034
+ report = resolve(cwd, req.output ?? ciJobReportDir(req.job));
953
1035
  // In place the report is already where the forge keeps it, and stays as the command wrote it.
954
1036
  if (!(existsSync(report) && realpathSync(report) === realpathSync(written))) {
955
1037
  cpSync(written, report, { recursive: true });
@@ -958,7 +1040,8 @@ async function runCiJob(
958
1040
  }
959
1041
  const failed = result.records.find((r) => r.status === "fail");
960
1042
  const exitCode = failed?.phase === req.job ? exitCodeOf(failed.error) : undefined;
961
- if (reportUrl) {
1043
+ // The run index lists the pull-request jobs; an environment job's run is in its record only (#4330).
1044
+ if (reportUrl && !isCiEnvJob(req.job)) {
962
1045
  await recordInIndex({ url: reportUrl, job: req.job, commit, member: place.member, pr: req.pr, record: result.record, runOrigin: origin, signer: result.record.statement && signer ? signer.principal : null, exitCode: ciJobExitCode({ status: result.status, ...(exitCode !== undefined ? { exitCode } : {}) }), reportDir: written, limit: req.runIndexLimit, ...(redact ? { redact } : {}), warn: (w) => warnings.push(w) });
963
1046
  }
964
1047
  // In place the forge job's outcome is the command's, so a record that could not be written is a warning there.
@@ -1045,7 +1128,7 @@ export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptio
1045
1128
  }
1046
1129
 
1047
1130
  const USAGE =
1048
- "chant ci run plan|record|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
1131
+ "chant ci run plan|record|apply|env-up|env-post|env-down|env-sweep --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--head <sha>] [--result <r>] [--forge github|gitlab|forgejo] " +
1049
1132
  "[--gate <name>] [--wave-gate <name>] [--wave <k>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
1050
1133
  "[--pr-build] [--pr-build-command <line>] [--require-review] [--apply-before-merge] [--no-status] [--status-url <url template>] " +
1051
1134
  "[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] [--in-place | --local] [--no-push] [--verbose] | chant ci run --at <sha> | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
@@ -1101,9 +1184,11 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
1101
1184
  try {
1102
1185
  if (args.setupKey) return (await import("./ci-serve")).runnerKeySetupCommand(ctx);
1103
1186
  if (args.at !== undefined && args.extraPositional === undefined) return await requestBuildCommand(args.at);
1104
- const job = args.extraPositional;
1105
- if (job !== "plan" && job !== "apply" && job !== "record") throw new CiRunError(`ci run takes the job to run, plan, record or apply${job ? `, not ${JSON.stringify(job)}` : ""}`);
1106
- if (!args.env) throw new CiRunError("--env names the environment the pipeline is for, as chant build --components --generate <lexicon> --pr-loop took it: --env <env>");
1187
+ const job = args.extraPositional as CiJobName | undefined;
1188
+ if (!job || !CI_JOB_NAMES.includes(job)) throw new CiRunError(`ci run takes the job to run, ${CI_JOB_NAMES.join(", ")}${job ? `, not ${JSON.stringify(job)}` : ""}`);
1189
+ // #4330: an environment job's environment is pr-<n>; the sweep's run is recorded under --env, else local.
1190
+ const env = args.env ?? (isCiEnvJob(job) ? (args.pr !== undefined && job !== "env-sweep" ? `pr-${args.pr}` : "local") : undefined);
1191
+ if (!env) throw new CiRunError("--env names the environment the pipeline is for, as chant build --components --generate <lexicon> --pr-loop took it: --env <env>");
1107
1192
  const forge = args.forge ?? detectForge(process.env);
1108
1193
  if (!forge) throw new CiRunError("--forge names the forge the job posts to: --forge github|gitlab|forgejo");
1109
1194
  if (!(FORGE_KINDS as readonly string[]).includes(forge)) throw new CiRunError(`--forge takes github, gitlab or forgejo, not ${JSON.stringify(forge)}`);
@@ -1124,11 +1209,13 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
1124
1209
  cwd: process.cwd(),
1125
1210
  ...(status ? { status } : {}),
1126
1211
  job,
1127
- env: args.env,
1212
+ env,
1128
1213
  forge: forge as ForgeKind,
1129
1214
  ...(args.base ? { base: args.base } : {}),
1130
1215
  ...(args.pr !== undefined ? { pr: args.pr } : {}),
1131
1216
  ...(args.commit ? { commit: args.commit } : {}),
1217
+ ...(args.head ? { head: args.head } : {}),
1218
+ ...(args.planResult ? { upResult: args.planResult } : {}),
1132
1219
  loop: ciLoopOptions(reportUrl ? { ...args, reportUrl } : args),
1133
1220
  ...(args.ciInPlace ? { inPlace: true } : {}),
1134
1221
  ...(args.local ? { local: true } : {}),
@@ -1149,8 +1236,14 @@ export async function runCiRun(ctx: CommandContext): Promise<number> {
1149
1236
  for (const w of result.warnings) console.error(formatWarning({ message: w }));
1150
1237
  if (args.verbose) for (const n of result.notes) console.error(`ci run: ${n}`);
1151
1238
  console.error(formatCiRun(result));
1239
+ if (result.post) {
1240
+ for (const w of result.post.warnings) console.error(formatWarning({ message: w }));
1241
+ console.error(formatCiRun(result.post));
1242
+ }
1152
1243
  // #4156: a failed command's own exit code, such as 3 for a moved plan, is the job's, as it was before the job ran through ci run.
1153
- return ciJobExitCode(result);
1244
+ // #4330: an env-up whose env-post failed fails too, since its record was not kept.
1245
+ const code = ciJobExitCode(result);
1246
+ return code === 0 && result.post ? ciJobExitCode(result.post) : code;
1154
1247
  } catch (err) {
1155
1248
  console.error(formatError({ message: `chant ci run: ${message(err)}`, hint: USAGE }));
1156
1249
  return 1;
@@ -69,7 +69,7 @@ describe("the secrets file (#4096)", () => {
69
69
  expect(ciSecretsFor(undefined, "plan")).toEqual({});
70
70
  expect(ciSecretsFor({ ...s, jobs: { plan: [] } }, "plan")).toEqual({});
71
71
  expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { plan: ["C"] } }), "s.json")).toThrow(/jobs\.plan names C, which the file does not set/);
72
- expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan and apply/);
72
+ expect(() => parseCiSecrets(JSON.stringify({ secrets: { A: "aaaa" }, jobs: { deploy: [] } }), "s.json")).toThrow(/the jobs are plan, apply, env-up/);
73
73
  expect(() => parseCiSecrets('{ "A": 1 }', "s.json")).toThrow(/must be a string/);
74
74
  // A parse error does not quote the text, which may hold a secret.
75
75
  expect(() => parseCiSecrets('{ "A": "hunter2', "s.json")).toThrow(/^s\.json starts with \{, and is not valid JSON$/);
@@ -37,7 +37,8 @@ export const SECRET_NAME = /TOKEN|SECRET|KEY|PASSWORD/i;
37
37
 
38
38
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
39
39
 
40
- const JOBS: readonly CiJobName[] = ["plan", "apply"];
40
+ /** The jobs a secrets file can give a list of its own: the pull-request jobs, and a pull request environment's (#4330). */
41
+ const JOBS: readonly CiJobName[] = ["plan", "apply", "env-up", "env-post", "env-down", "env-sweep"];
41
42
 
42
43
  /** The secrets file, read. */
43
44
  export interface CiSecrets {
@@ -176,9 +177,9 @@ export function parseCiSecrets(text: string, file: string): Omit<CiSecrets, "fil
176
177
  if (structured) {
177
178
  for (const key of Object.keys(doc)) if (key !== "secrets" && key !== "jobs") throw new CiSecretsError(`${file} has ${JSON.stringify(key)} beside secrets and jobs`);
178
179
  const given = doc.jobs ?? {};
179
- if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map plan and apply to lists of secret names`);
180
+ if (given === null || typeof given !== "object" || Array.isArray(given)) throw new CiSecretsError(`${file}: jobs must map ${JOBS.join(", ")} to lists of secret names`);
180
181
  for (const [job, names] of Object.entries(given as Record<string, unknown>)) {
181
- if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are plan and apply`);
182
+ if (!(JOBS as readonly string[]).includes(job)) throw new CiSecretsError(`${file}: jobs names ${JSON.stringify(job)}, and the jobs are ${JOBS.join(", ")}`);
182
183
  if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) throw new CiSecretsError(`${file}: jobs.${job} must be a list of secret names`);
183
184
  for (const n of names as string[]) if (!Object.prototype.hasOwnProperty.call(values, n)) throw new CiSecretsError(`${file}: jobs.${job} names ${n}, which the file does not set`);
184
185
  jobs[job as CiJobName] = [...(names as string[])];
@@ -289,6 +289,33 @@
289
289
  "type": "string",
290
290
  "pattern": "^[1-9][0-9]*(m|h|d)$",
291
291
  "description": "How long a pull request's environment lives after its last apply, such as 72h, 90m or 7d. chant components pr-env sweep tears down one whose TTL passed. 72h when absent."
292
+ },
293
+ "teardown": {
294
+ "type": "object",
295
+ "description": "A command that tears down what no lexicon does, such as a Terraform root's copy of the environment (#4330). pr-env down and pr-env sweep run it after the ownership check, twice: CHANT_PR_ENV_STAGE=owned writes the candidates with their read-back markers to CHANT_PR_ENV_RESULT, and CHANT_PR_ENV_STAGE=teardown deletes the candidates in CHANT_PR_ENV_CANDIDATES and writes their outcomes. It runs without the forge token variables. Added in schema 1 by chant 0.124.45, so a declaration that uses it sets minReader to 0.124.45 or newer.",
296
+ "required": ["command"],
297
+ "additionalProperties": false,
298
+ "patternProperties": {
299
+ "^x-": true
300
+ },
301
+ "properties": {
302
+ "name": {
303
+ "type": "string",
304
+ "pattern": "^[a-z0-9][a-z0-9-]*$",
305
+ "description": "The name the teardown's outcomes are attributed to, as a lexicon's are. teardown when absent."
306
+ },
307
+ "command": {
308
+ "type": "array",
309
+ "minItems": 1,
310
+ "items": { "type": "string", "minLength": 1 },
311
+ "description": "Argv, run in the member's directory at the commit the job runs at. {env}, {pr} and {stage} are filled in."
312
+ },
313
+ "lexicons": {
314
+ "type": "array",
315
+ "items": { "type": "string", "minLength": 1 },
316
+ "description": "The lexicons the command tears down in place of, such as terraform. Their own teardown is left out, so one with no teardown execution does not leave the environment incomplete."
317
+ }
318
+ }
292
319
  }
293
320
  }
294
321
  }
@@ -363,7 +390,7 @@
363
390
  "ciCloudIdentityRules": {
364
391
  "type": "array",
365
392
  "minItems": 1,
366
- "description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member. A rule with no environments matches every environment, and one with no members every member and a pipeline outside a member.",
393
+ "description": "Rules, first match wins: a rule matches a pipeline when its environments match the pipeline's environment and its members match the pipeline's member, and a target inside the member when its targets match the target too. A rule with no environments matches every environment, one with no members every member and a pipeline outside a member, and one with no targets every target.",
367
394
  "items": {
368
395
  "type": "object",
369
396
  "additionalProperties": false,
@@ -378,6 +405,14 @@
378
405
  "description": "Environment-name globs, such as prod or staging-*."
379
406
  },
380
407
  "members": { "$ref": "#/$defs/ciMemberGlobs" },
408
+ "targets": {
409
+ "type": "array",
410
+ "minItems": 1,
411
+ "uniqueItems": true,
412
+ "items": { "type": "string", "minLength": 1 },
413
+ "description": "Target globs inside the member (#4332), such as Terraform roots or Op wave run targets: * within one / segment, ** across segments, ? one character. The plan job trades for every plan identity the member's targets take, and an apply splits into one job per apply identity. Added in schema 1 by chant 0.124.45, so a declaration that uses it sets minReader to 0.124.45 or newer."
414
+ },
415
+ "name": { "type": "string", "pattern": "^[a-z0-9_-]+$", "description": "The name of the apply job group the rule's targets form (#4332). Default: from its first target glob. Added by chant 0.124.45." },
381
416
  "plan": { "type": "string", "minLength": 1, "description": "The identity the plan and record jobs take: a role ARN, a service account email or a client id." },
382
417
  "apply": { "type": "string", "minLength": 1, "description": "The identity the apply jobs take." }
383
418
  }
@@ -564,4 +564,11 @@ describe("ci.prEnvironment (#4298)", () => {
564
564
  expect(failure(base(members, { ci: { prEnvironment: {} } })).code).toBe("declaration-invalid");
565
565
  expect(failure(base(members, { ci: { prEnvironment: { template: "staging", ttl: "1w" } } })).code).toBe("declaration-invalid");
566
566
  });
567
+
568
+ test("a teardown command is read with its argv and the lexicons it covers (#4330)", () => {
569
+ const teardown = { name: "terragucci", command: ["terragucci", "pr-env", "teardown", "{stage}"], lexicons: ["terraform"] };
570
+ expect(parse(base(members, { ci: { prEnvironment: { template: "staging", teardown } } })).ci?.prEnvironment).toEqual({ template: "staging", ttl: "72h", teardown });
571
+ expect(failure(base(members, { ci: { prEnvironment: { template: "staging", teardown: { command: [] } } } })).code).toBe("declaration-invalid");
572
+ expect(failure(base(members, { ci: { prEnvironment: { template: "staging", teardown: { command: ["x"], runs: "y" } } } })).code).toBe("declaration-invalid");
573
+ });
567
574
  });