@intentius/chant 0.105.0 → 0.106.1

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 (95) hide show
  1. package/dist/cli/commands/lint.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.d.ts.map +1 -1
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/operator.d.ts.map +1 -1
  5. package/dist/cli/handlers/pr.d.ts +16 -2
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/promote.d.ts.map +1 -1
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/main.d.ts.map +1 -1
  10. package/dist/cli/registry.d.ts +7 -0
  11. package/dist/cli/registry.d.ts.map +1 -1
  12. package/dist/components/changed-files.d.ts +8 -2
  13. package/dist/components/changed-files.d.ts.map +1 -1
  14. package/dist/components/fan-out-record.d.ts +24 -0
  15. package/dist/components/fan-out-record.d.ts.map +1 -1
  16. package/dist/components/pr-pipeline.d.ts +60 -4
  17. package/dist/components/pr-pipeline.d.ts.map +1 -1
  18. package/dist/components/pr-run.d.ts +92 -3
  19. package/dist/components/pr-run.d.ts.map +1 -1
  20. package/dist/gated-waves.d.ts +4 -1
  21. package/dist/gated-waves.d.ts.map +1 -1
  22. package/dist/lexicon.d.ts +13 -2
  23. package/dist/lexicon.d.ts.map +1 -1
  24. package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
  25. package/dist/op/activities/factory.d.ts.map +1 -1
  26. package/dist/op/gate-name.d.ts +7 -0
  27. package/dist/op/gate-name.d.ts.map +1 -1
  28. package/dist/op/gate-summary.d.ts +2 -0
  29. package/dist/op/gate-summary.d.ts.map +1 -1
  30. package/dist/op/gate.d.ts +10 -2
  31. package/dist/op/gate.d.ts.map +1 -1
  32. package/dist/op/index.d.ts +2 -2
  33. package/dist/op/index.d.ts.map +1 -1
  34. package/dist/op/local-executor.d.ts.map +1 -1
  35. package/dist/op/local-output.d.ts +2 -2
  36. package/dist/op/local-output.d.ts.map +1 -1
  37. package/dist/op/runtime.d.ts +19 -2
  38. package/dist/op/runtime.d.ts.map +1 -1
  39. package/dist/op/runtimes/local.d.ts.map +1 -1
  40. package/dist/pr-loop.d.ts +29 -4
  41. package/dist/pr-loop.d.ts.map +1 -1
  42. package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
  43. package/dist/workspace/member-pipeline.d.ts +14 -1
  44. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  45. package/package.json +1 -1
  46. package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
  47. package/src/__fixtures__/change-set/warden.plan.json +47 -40
  48. package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
  49. package/src/change-set.test.ts +7 -8
  50. package/src/cli/commands/lint.ts +34 -1
  51. package/src/cli/handlers/build-lexicon-output.test.ts +94 -0
  52. package/src/cli/handlers/build.ts +65 -0
  53. package/src/cli/handlers/fan-out.test.ts +12 -1
  54. package/src/cli/handlers/fan-out.ts +7 -3
  55. package/src/cli/handlers/operator.ts +1 -2
  56. package/src/cli/handlers/pr.ts +220 -35
  57. package/src/cli/handlers/promote.test.ts +15 -1
  58. package/src/cli/handlers/promote.ts +5 -3
  59. package/src/cli/handlers/run.test.ts +164 -1
  60. package/src/cli/handlers/run.ts +22 -11
  61. package/src/cli/main.ts +13 -1
  62. package/src/cli/mcp/never-over-mcp.test.ts +74 -0
  63. package/src/cli/registry.ts +7 -0
  64. package/src/components/changed-files.test.ts +58 -0
  65. package/src/components/changed-files.ts +19 -4
  66. package/src/components/fan-out-record.test.ts +47 -2
  67. package/src/components/fan-out-record.ts +53 -0
  68. package/src/components/pr-pipeline.ts +89 -6
  69. package/src/components/pr-run.test.ts +300 -3
  70. package/src/components/pr-run.ts +196 -5
  71. package/src/gated-waves.test.ts +11 -0
  72. package/src/gated-waves.ts +7 -2
  73. package/src/lexicon.ts +13 -1
  74. package/src/lifecycle/gate-ledger.ts +4 -3
  75. package/src/op/activities/factory.ts +9 -1
  76. package/src/op/gate-name.ts +17 -0
  77. package/src/op/gate-summary.test.ts +12 -0
  78. package/src/op/gate-summary.ts +3 -1
  79. package/src/op/gate.test.ts +15 -1
  80. package/src/op/gate.ts +21 -4
  81. package/src/op/index.ts +2 -2
  82. package/src/op/local-executor.test.ts +35 -0
  83. package/src/op/local-executor.ts +2 -2
  84. package/src/op/local-output.test.ts +20 -0
  85. package/src/op/local-output.ts +4 -4
  86. package/src/op/runtime.ts +20 -2
  87. package/src/op/runtimes/local.test.ts +15 -0
  88. package/src/op/runtimes/local.ts +3 -1
  89. package/src/pr-loop.test.ts +41 -1
  90. package/src/pr-loop.ts +38 -8
  91. package/src/workspace/lineage-upgrade-cli.ts +4 -3
  92. package/src/workspace/lineage-upgrade.test.ts +40 -0
  93. package/src/workspace/member-pipeline.test.ts +28 -0
  94. package/src/workspace/member-pipeline.ts +20 -3
  95. package/src/workspace/pr-report.schema.json +14 -1
@@ -26,7 +26,7 @@ function formatDuration(ms: number): string {
26
26
  * Render a run result as human-readable progress. Defaults to stderr so stdout
27
27
  * stays clean for piping (the `--json` renderer owns stdout).
28
28
  */
29
- export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
29
+ export function renderHuman(result: OpRunResult, write: Writer = stderr, sealed = false): void {
30
30
  let currentPhase: string | undefined;
31
31
  for (const record of result.records) {
32
32
  if (record.phase !== currentPhase) {
@@ -100,7 +100,7 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
100
100
  // #2300: the plan the approval will be bound to. Printed before the
101
101
  // command, because it is what the command approves.
102
102
  if (gate.planDigest) write(` plan : ${gate.planDigest}`);
103
- write(` approve : ${approveCommand(gate.op, gate.gate, undefined, gate.planDigest)}`);
103
+ write(` approve : ${approveCommand(gate.op, gate.gate, undefined, gate.planDigest, sealed)}`);
104
104
  if (gate.url) write(` approve at: ${gate.url}`);
105
105
  write(` expires : ${gate.expiresAt}`);
106
106
  // #2310: this run's own append reached only the local chant/lifecycle
@@ -127,8 +127,8 @@ export function renderHuman(result: OpRunResult, write: Writer = stderr): void {
127
127
  * stopped on; the `approve` command line rides alongside so a CI job reporting
128
128
  * the gate doesn't have to reassemble it from the op and gate names.
129
129
  */
130
- export function renderJson(result: OpRunResult, write: Writer = stdout): void {
131
- const approve = result.gate ? { approve: approveCommand(result.gate.op, result.gate.gate, undefined, result.gate.planDigest) } : {};
130
+ export function renderJson(result: OpRunResult, write: Writer = stdout, sealed = false): void {
131
+ const approve = result.gate ? { approve: approveCommand(result.gate.op, result.gate.gate, undefined, result.gate.planDigest, sealed) } : {};
132
132
  // #2310: whether this run's own append reached the remote — not part of
133
133
  // the persisted ledger record (a replay has nothing new to report), but a
134
134
  // live run's JSON consumer needs it exactly where the human render shows it.
package/src/op/runtime.ts CHANGED
@@ -187,6 +187,23 @@ export interface OpRunStartOptions {
187
187
  * runtime takes it; a hosted run's lease is taken where the run executes.
188
188
  */
189
189
  work?: { item?: string; holder?: string };
190
+ /**
191
+ * The id to name this run by, in place of one the runtime mints (#3539).
192
+ * The local runtime uses it as the run record's id, the gate ledger's
193
+ * `runId` and `currentOpRun().runId`. `chant acp` passes fountain's
194
+ * conversation id here, so the record a hosted run writes in the sandbox
195
+ * and the run `chant run status --on fountain` reports carry the same id.
196
+ */
197
+ runId?: string;
198
+ }
199
+
200
+ /** What a caller hands {@link OpRuntimeProvider.resolveGate} besides the resolution. */
201
+ export interface OpGateResolveOptions {
202
+ /**
203
+ * `--env` on `chant run approve` (#3539): the environment the woken run
204
+ * runs in. Omitted, a runtime keeps the environment the gated run had.
205
+ */
206
+ env?: string;
190
207
  }
191
208
 
192
209
  /**
@@ -220,9 +237,10 @@ export interface OpRuntimeProvider {
220
237
  /**
221
238
  * Wake a run whose gate has just been resolved. The ledger write is the
222
239
  * fact and `chant approve` owns it; this is the runtime's chance to act on
223
- * it. Omit when a run re-reads the ledger on its own.
240
+ * it. Omit when a run re-reads the ledger on its own. `opts.env` is the
241
+ * `--env` the approver named, when they named one.
224
242
  */
225
- resolveGate?(op: string, gate: string, resolution: GateResolutionRecord): Promise<void>;
243
+ resolveGate?(op: string, gate: string, resolution: GateResolutionRecord, opts?: OpGateResolveOptions): Promise<void>;
226
244
 
227
245
  /**
228
246
  * Host `chant run --components`. Optional: a runtime without it refuses the
@@ -95,6 +95,21 @@ describe("the local op runtime", () => {
95
95
  expect(status.result?.record?.id).toBe(handle.runId);
96
96
  });
97
97
 
98
+ test("a run named by its caller carries that id on the handle, the run context and the record (#3539)", async () => {
99
+ const { currentOpRun } = await import("../run-context");
100
+ let seen: ReturnType<typeof currentOpRun>;
101
+ loadActivitiesMock.mockResolvedValue(new Map([["ok", vi.fn(async () => { seen = currentOpRun(); })]]));
102
+ const runtime = createLocalOpRuntime();
103
+
104
+ const handle = await runtime.start(op("hello", [{ kind: "activity", fn: "ok", args: {} }]), { runId: "conv-42" });
105
+ const status = await handle.result();
106
+
107
+ expect(handle.runId).toBe("conv-42");
108
+ expect(status.runId).toBe("conv-42");
109
+ expect(seen?.runId).toBe("conv-42");
110
+ expect(status.result?.record?.id).toBe("conv-42");
111
+ });
112
+
98
113
  test("the project's configured lexicons decide which appliers load", async () => {
99
114
  loadActivitiesMock.mockResolvedValue(new Map([["ok", vi.fn(async () => ({}))]]));
100
115
  const runtime = createLocalOpRuntime();
@@ -139,7 +139,9 @@ export function createLocalOpRuntime(opts: { projectPath?: string } = {}): OpRun
139
139
 
140
140
  const [activities, profiles] = await Promise.all([loadActivities(lexicons), loadProfiles()]);
141
141
 
142
- const runId = `local-${Date.now()}`;
142
+ // A caller that names the run (#3539: `chant acp` on fountain passes the
143
+ // conversation id) gets its id on the record; otherwise one is minted.
144
+ const runId = startOpts.runId ?? `local-${Date.now()}`;
143
145
  const startedAt = new Date().toISOString();
144
146
  noteEnv(op);
145
147
 
@@ -16,7 +16,9 @@ import {
16
16
  PR_REPORT_SCHEMA_ID,
17
17
  prApproveCommand,
18
18
  prNoteMarker,
19
+ PR_GATE_OP,
19
20
  prOp,
21
+ prStatusContext,
20
22
  prStatusDescription,
21
23
  prStatusState,
22
24
  renderPrNote,
@@ -101,6 +103,12 @@ const appliedReport: PrReport = {
101
103
  ],
102
104
  };
103
105
 
106
+ const resumedReport: PrReport = {
107
+ ...appliedReport,
108
+ members: [{ ...planReport.members[0], status: "applied" }, { ...planReport.members[1], status: "applied" }],
109
+ resumed: { applied: ["a"] },
110
+ };
111
+
104
112
  describe("the pr-report schema", () => {
105
113
  test("is a valid schema and names this module's id", () => {
106
114
  expect(validSchema(schema)).toBe(true);
@@ -108,7 +116,7 @@ describe("the pr-report schema", () => {
108
116
  });
109
117
 
110
118
  test("every stage's report validates, and its change set validates against the change-set schema", () => {
111
- for (const report of [planReport, refusedReport, appliedReport]) {
119
+ for (const report of [planReport, refusedReport, appliedReport, resumedReport]) {
112
120
  expectValid(report);
113
121
  expectValidChangeSet(report.changeSet);
114
122
  expect(report.changeSet.digest).toBe(report.digest);
@@ -144,6 +152,12 @@ describe("the note", () => {
144
152
  golden("pr-apply-applied.note.golden.md", note);
145
153
  });
146
154
 
155
+ test("a resumed apply names what the earlier attempt applied (#3464)", () => {
156
+ const note = renderPrNote(resumedReport);
157
+ expect(note).toContain("Resumed under the same approval: `a` applied in an earlier attempt and did not run again.");
158
+ expect(renderPrNote(appliedReport)).not.toContain("Resumed");
159
+ });
160
+
147
161
  test("a small limit leaves the summary out rather than cutting the table", () => {
148
162
  const note = renderPrNote(planReport, { limit: 800 });
149
163
  expect(note).toContain("| `app` |");
@@ -169,3 +183,29 @@ describe("the status", () => {
169
183
  expect(prApproveCommand(planReport, "gitlab:ana")).toBe(`chant approve pr-12 pr-apply --plan ${doc.digest} --approver gitlab:ana --sign`);
170
184
  });
171
185
  });
186
+
187
+ describe("a workspace member's loop (#3465)", () => {
188
+ const memberReport: PrReport = { ...planReport, member: "network", op: prOp(12, "network") };
189
+
190
+ test("the op, the note marker and the status contexts carry the member's name", () => {
191
+ expect(prOp(12)).toBe("pr-12");
192
+ expect(prOp(12, "network")).toBe("pr-12-network");
193
+ expect(prNoteMarker("prod", "network")).toBe("<!-- chant-pr:prod:network -->");
194
+ expect(prNoteMarker("prod", "network")).not.toBe(prNoteMarker("prod"));
195
+ expect(prStatusContext("plan")).toBe("chant/plan");
196
+ expect(prStatusContext("apply", "network")).toBe("chant/apply/network");
197
+ });
198
+
199
+ test("the gate op pattern takes both forms and nothing else", () => {
200
+ for (const op of ["pr-12", "pr-12-network", "pr-3-a1-b2"]) expect(PR_GATE_OP.test(op)).toBe(true);
201
+ for (const op of ["pr-", "pr-x", "pr-12-", "pr-12-Net", "fan-out"]) expect(PR_GATE_OP.test(op)).toBe(false);
202
+ });
203
+
204
+ test("the report with a member validates, and its note names the member and approves its own op", () => {
205
+ expectValid(memberReport);
206
+ const note = renderPrNote(memberReport);
207
+ expect(note.startsWith("<!-- chant-pr:" + memberReport.env + ":network -->")).toBe(true);
208
+ expect(note).toContain("in member `network`");
209
+ expect(note).toContain("chant approve pr-12-network pr-apply");
210
+ });
211
+ });
package/src/pr-loop.ts CHANGED
@@ -26,6 +26,14 @@
26
26
  * On merge the apply plans the same members again, before anything applies,
27
27
  * and decides the gate against the new digest. The same plan applies; a moved
28
28
  * plan stops with both digests named and nothing applied.
29
+ *
30
+ * ## Inside a workspace member
31
+ *
32
+ * Each workspace member that generates the pipeline (#3465) runs its own
33
+ * plan and apply on the same pull request, so each keeps its own gate, note
34
+ * and statuses: op `pr-<number>-<member>`, the note marker and the status
35
+ * contexts carry the member's name. The gate name stays the same, so one
36
+ * `identity.gates` entry covers every member.
29
37
  */
30
38
 
31
39
  import type { ChangeSetAction, ChangeSetDocument } from "./change-set";
@@ -40,19 +48,30 @@ export const PR_REPORT_CONTRACT = 1;
40
48
  /** The gate a pull request's apply waits on, unless the pipeline names another. */
41
49
  export const PR_APPLY_GATE = "pr-apply";
42
50
 
43
- /** The op a pull request's gate is recorded under: `pr-<number>`. */
44
- export function prOp(pr: number): string {
45
- return `pr-${pr}`;
51
+ /**
52
+ * The op a pull request's gate is recorded under: `pr-<number>`, or
53
+ * `pr-<number>-<member>` for a workspace member's pipeline (#3465).
54
+ */
55
+ export function prOp(pr: number, member?: string): string {
56
+ return member ? `pr-${pr}-${member}` : `pr-${pr}`;
46
57
  }
47
58
 
48
- /** The hidden line that marks the pull request's note, one per environment. */
49
- export function prNoteMarker(env: string): string {
50
- return `<!-- chant-pr:${env} -->`;
59
+ /** Whether `op` is a pull request's gate op, with or without a member. Member names follow the declaration's name pattern. */
60
+ export const PR_GATE_OP = /^pr-\d+(?:-[a-z0-9][a-z0-9-]*)?$/;
61
+
62
+ /** The hidden line that marks the pull request's note, one per environment and, in a workspace, per member. */
63
+ export function prNoteMarker(env: string, member?: string): string {
64
+ return member ? `<!-- chant-pr:${env}:${member} -->` : `<!-- chant-pr:${env} -->`;
51
65
  }
52
66
 
53
67
  /** The status contexts the two stages set on the commit. */
54
68
  export const PR_STATUS_CONTEXTS = { plan: "chant/plan", apply: "chant/apply" } as const;
55
69
 
70
+ /** A stage's status context: `chant/plan`, or `chant/plan/<member>` for a workspace member's pipeline. */
71
+ export function prStatusContext(stage: "plan" | "apply", member?: string): string {
72
+ return member ? `${PR_STATUS_CONTEXTS[stage]}/${member}` : PR_STATUS_CONTEXTS[stage];
73
+ }
74
+
56
75
  /** Changes a member's plan proposes, by the actions a reviewer reads. */
57
76
  export interface PrMemberCounts {
58
77
  create: number;
@@ -143,6 +162,8 @@ export interface PrReport {
143
162
  /** The pull or merge request number, when known. */
144
163
  pr: number | null;
145
164
  env: string;
165
+ /** The workspace member whose pipeline ran the stage (#3465). Absent outside a workspace member. */
166
+ member?: string;
146
167
  /** The commit the change is measured from: the merge base of the given base and `head`. */
147
168
  base: string;
148
169
  head: string;
@@ -155,6 +176,12 @@ export interface PrReport {
155
176
  refusal?: PrRefusal;
156
177
  /** The refusal or failure, as printed. */
157
178
  message?: string;
179
+ /**
180
+ * Apply stage, `--resume` (#3464): an earlier attempt at the approved set
181
+ * failed partway, and these components applied in it. This run applied
182
+ * the rest under the same approval and did not run them again.
183
+ */
184
+ resumed?: { applied: string[] };
158
185
  approval: PrApproval;
159
186
  /** Component names: which changed, which run because they depend on one, and what the change touched that no component deploys. */
160
187
  selection: {
@@ -255,12 +282,15 @@ function approvalLines(report: PrReport, approver: string): string[] {
255
282
  export function renderPrNote(report: PrReport, options: { limit?: number; approver?: string } = {}): string {
256
283
  const limit = options.limit ?? GITHUB_COMMENT_LIMIT;
257
284
  const out: string[] = [
258
- prNoteMarker(report.env),
259
- `### chant ${report.stage === "plan" ? "plan" : "apply"} for ${code(report.env)}`,
285
+ prNoteMarker(report.env, report.member),
286
+ `### chant ${report.stage === "plan" ? "plan" : "apply"} for ${code(report.env)}${report.member ? ` in member ${code(report.member)}` : ""}`,
260
287
  "",
261
288
  `${headline(report)} Head ${code(short(report.head))}, measured from ${code(short(report.base))}.`,
262
289
  ];
263
290
  if (report.message) out.push("", `> ${report.message.split("\n").join("\n> ")}`);
291
+ if (report.resumed && report.resumed.applied.length > 0) {
292
+ out.push("", `Resumed under the same approval: ${report.resumed.applied.map(code).join(", ")} applied in an earlier attempt and did not run again.`);
293
+ }
264
294
  if (report.members.length > 0) {
265
295
  out.push("", "| Member | Component | Status | Create | Update | Replace | Delete |", "|---|---|---|---:|---:|---:|---:|");
266
296
  for (const m of report.members) {
@@ -15,7 +15,7 @@ import { writeFileSync } from "node:fs";
15
15
  import { resolve } from "node:path";
16
16
  import { formatError, formatInfo, formatSuccess, formatWarning } from "../cli/format";
17
17
  import type { CommandContext } from "../cli/registry";
18
- import { approveCommand, describeGateMismatch, evaluateGate, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
18
+ import { approveCommand, describeGateMismatch, evaluateGate, gateIsSealed, gitGateLedgerPort, type GateLedgerPort } from "../op/gate";
19
19
  import { WORKSPACE_UPGRADE_GATE_OP } from "../op/gate-name";
20
20
  import { LockError } from "./lineage-lock";
21
21
  import { applyStagedUpgrade, describeStaged, stageUpgrade, type ChantRunner, type StagedUpgrade } from "./lineage-upgrade";
@@ -104,12 +104,13 @@ export async function upgradeCommand(opts: UpgradeCommandOptions): Promise<Upgra
104
104
  });
105
105
  if (!check.satisfied) {
106
106
  if (!opts.json) {
107
- if (check.mismatch) console.error(formatWarning({ message: describeGateMismatch(WORKSPACE_UPGRADE_GATE_OP, gate, check.mismatch) }));
107
+ const sealed = await gateIsSealed(gate);
108
+ if (check.mismatch) console.error(formatWarning({ message: describeGateMismatch(WORKSPACE_UPGRADE_GATE_OP, gate, check.mismatch, sealed) }));
108
109
  if (check.pushWarning) console.error(formatWarning({ message: check.pushWarning }));
109
110
  if (check.quorum) console.error(formatInfo(`approvals so far: ${check.quorum.approvers.length} of ${check.quorum.need}`));
110
111
  console.error(
111
112
  formatInfo(
112
- `Gated: the patch ${staged.digest} waits for approval. Review it (--output <file> writes it), then run \`${approveCommand(WORKSPACE_UPGRADE_GATE_OP, gate)}\` and repeat this command to apply it.`,
113
+ `Gated: the patch ${staged.digest} waits for approval. Review it (--output <file> writes it), then run \`${approveCommand(WORKSPACE_UPGRADE_GATE_OP, gate, undefined, undefined, sealed)}\` and repeat this command to apply it.`,
113
114
  ),
114
115
  );
115
116
  }
@@ -21,6 +21,14 @@ import { LOCK_FILE, fileHash, readLock, writeLock } from "./lineage-lock";
21
21
  import { stageUpgrade, isGovernancePath, UPGRADE_DIR, type ChantRunner } from "./lineage-upgrade";
22
22
  import { upgradeCommand } from "./lineage-upgrade-cli";
23
23
 
24
+ // #3521: whether the workspace seals a gate is read from identity.gates in the
25
+ // working directory; a test says which gates are sealed instead.
26
+ const sealedGates = new Set<string>();
27
+ vi.mock("../op/gate", async () => {
28
+ const actual = await vi.importActual<typeof import("../op/gate")>("../op/gate");
29
+ return { ...actual, gateIsSealed: async (gate: string) => sealedGates.has(gate) };
30
+ });
31
+
24
32
  const ENV = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@t", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@t" };
25
33
 
26
34
  let root: string;
@@ -290,6 +298,38 @@ describe("chant workspace upgrade", () => {
290
298
  expect(again.outcome).toBe("up-to-date");
291
299
  });
292
300
 
301
+ describe("the gated hint's approve command (#3521)", () => {
302
+ /** Runs the upgrade into its gate, and again against an approval of another patch; returns what both printed. */
303
+ async function gatedOutput(): Promise<string> {
304
+ release("v2.0.0", { "README.md": "starter v2\n" });
305
+ const port = ledger();
306
+ const lines: string[] = [];
307
+ vi.spyOn(console, "error").mockImplementation((s: unknown) => { lines.push(String(s)); });
308
+ await upgradeCommand({ root: proj, to: "v2.0.0", runChant: passing, ledger: port, now: "2026-09-23T10:00:00.000Z" });
309
+ port.approve(`sha256:${"0".repeat(64)}`, "2026-09-23T10:01:00.000Z");
310
+ await upgradeCommand({ root: proj, to: "v2.0.0", runChant: passing, ledger: port, now: "2026-09-23T10:02:00.000Z" });
311
+ return lines.join("\n");
312
+ }
313
+
314
+ test("unsealed gate: the gated line and the mismatch line carry no --sign", async () => {
315
+ const out = await gatedOutput();
316
+ expect(out).toContain("then run `chant approve workspace-upgrade .`");
317
+ expect(out).toContain("needs a fresh one: chant approve workspace-upgrade .");
318
+ expect(out).not.toContain("--sign");
319
+ });
320
+
321
+ test("sealed gate: the gated line and the mismatch line end in --sign", async () => {
322
+ sealedGates.add(".");
323
+ try {
324
+ const out = await gatedOutput();
325
+ expect(out).toContain("then run `chant approve workspace-upgrade . --sign`");
326
+ expect(out).toContain("needs a fresh one: chant approve workspace-upgrade . --sign");
327
+ } finally {
328
+ sealedGates.clear();
329
+ }
330
+ });
331
+ });
332
+
293
333
  test("a lock that a .gitignore covers is applied beside the patch, and the digest covers it", async () => {
294
334
  put(proj, ".gitignore", ".chant/\n");
295
335
  git(proj, ["rm", "-q", "--cached", LOCK_FILE]);
@@ -79,6 +79,34 @@ describe("the component pipeline's file and command", () => {
79
79
  expect(plan.command).toBe("chant build --components --generate github --env staging --promote-to prod");
80
80
  });
81
81
 
82
+ test("a pull-request pipeline gets its own file and a command that regenerates it (#3465)", () => {
83
+ expect(memberPipelineFile("github", "api", "staging", "pr")).toBe(".github/workflows/chant-pr-api-staging.yml");
84
+ expect(memberPipelineFile("gitlab", "api", "staging", "pr")).toBe(".gitlab/ci/chant-pr-api-staging.gitlab-ci.yml");
85
+ const dir = join(repo, "services/api");
86
+ const plan = planMemberComponentPipeline({
87
+ projectDir: dir,
88
+ found: findWorkspaceRoot(dir)!,
89
+ lexicon: "github",
90
+ env: "staging",
91
+ prLoop: { gate: "infra-apply", branch: "trunk" },
92
+ })!;
93
+ expect(plan.file).toBe(".github/workflows/chant-pr-api-staging.yml");
94
+ expect(plan.member).toEqual({ name: "api", dir: "services/api", file: ".github/workflows/chant-pr-api-staging.yml" });
95
+ expect(plan.command).toBe("chant build --components --generate github --env staging --pr-loop --gate infra-apply --branch trunk");
96
+ });
97
+
98
+ test("the recorded command keeps --wave-gate, --canary and --base (#3049)", () => {
99
+ const dir = join(repo, "services/api");
100
+ const plan = planMemberComponentPipeline({
101
+ projectDir: dir,
102
+ found: findWorkspaceRoot(dir)!,
103
+ lexicon: "github",
104
+ env: "staging",
105
+ waveGate: { gate: "release", canary: ["a", "b"], base: "origin/main" },
106
+ })!;
107
+ expect(plan.command).toBe("chant build --components --generate github --env staging --wave-gate release --canary a,b --base origin/main");
108
+ });
109
+
82
110
  test("--output keeps the path, relative to the member in the command", () => {
83
111
  const dir = join(repo, "services/api");
84
112
  const plan = planMemberComponentPipeline({
@@ -134,11 +134,14 @@ export function memberPipelineDir(provider: string): string | undefined {
134
134
  * The default path of a member's component pipeline for one environment,
135
135
  * relative to the repository root: `chant-<member>-<env>.yml` in the forge's
136
136
  * workflow directory, or `.gitlab/ci/chant-<member>-<env>.gitlab-ci.yml`.
137
+ * A pull-request pipeline (`kind` `"pr"`, #3465) is `chant-pr-<member>-<env>`,
138
+ * so it sits beside the member's deploy pipeline for the same environment.
137
139
  */
138
- export function memberPipelineFile(provider: string, member: string, env: string): string | undefined {
140
+ export function memberPipelineFile(provider: string, member: string, env: string, kind: "components" | "pr" = "components"): string | undefined {
139
141
  const dir = PIPELINE_DIRS[provider];
140
142
  if (dir === undefined) return undefined;
141
- return `${dir}/chant-${member}-${env}${provider === "gitlab" ? ".gitlab-ci.yml" : ".yml"}`;
143
+ const stem = kind === "pr" ? `chant-pr-${member}-${env}` : `chant-${member}-${env}`;
144
+ return `${dir}/${stem}${provider === "gitlab" ? ".gitlab-ci.yml" : ".yml"}`;
142
145
  }
143
146
 
144
147
  /** A repository-relative path for an absolute one. */
@@ -205,6 +208,10 @@ export interface ComponentPlanInput {
205
208
  output?: string;
206
209
  params?: string[];
207
210
  paramsFile?: string;
211
+ /** `--pr-loop`, with its `--gate` and `--branch` (#3465). */
212
+ prLoop?: { gate?: string; branch?: string };
213
+ /** `--wave-gate` with its `--canary` and `--base` (#3049). */
214
+ waveGate?: { gate: string; canary?: string[]; base?: string };
208
215
  }
209
216
 
210
217
  /** The generators' own default environment when `--env` is not given. */
@@ -224,7 +231,7 @@ export function planMemberComponentPipeline(input: ComponentPlanInput): MemberCo
224
231
  if (input.output) {
225
232
  target = resolve(input.output);
226
233
  } else {
227
- const file = memberPipelineFile(input.lexicon, ctx.member.name, env);
234
+ const file = memberPipelineFile(input.lexicon, ctx.member.name, env, input.prLoop ? "pr" : "components");
228
235
  if (file === undefined) {
229
236
  throw new Error(`there is no default pipeline path for ${input.lexicon} in a workspace member; pass --output <file>`);
230
237
  }
@@ -234,6 +241,16 @@ export function planMemberComponentPipeline(input: ComponentPlanInput): MemberCo
234
241
 
235
242
  const parts = ["chant", "build", "--components", "--generate", input.lexicon, "--env", env];
236
243
  if (input.promoteTo) parts.push("--promote-to", input.promoteTo);
244
+ if (input.waveGate) {
245
+ parts.push("--wave-gate", input.waveGate.gate);
246
+ if (input.waveGate.canary?.length) parts.push("--canary", input.waveGate.canary.join(","));
247
+ if (input.waveGate.base) parts.push("--base", input.waveGate.base);
248
+ }
249
+ if (input.prLoop) {
250
+ parts.push("--pr-loop");
251
+ if (input.prLoop.gate) parts.push("--gate", input.prLoop.gate);
252
+ if (input.prLoop.branch) parts.push("--branch", input.prLoop.branch);
253
+ }
237
254
  for (const p of input.params ?? []) parts.push("--param", p);
238
255
  if (input.paramsFile) parts.push("--params-file", relPath(ctx.memberRoot, resolve(input.paramsFile)));
239
256
  if (input.output) parts.push("--output", relPath(ctx.memberRoot, target));
@@ -15,11 +15,16 @@
15
15
  "description": "The pull or merge request number. null when an apply found no pull request that merged its commit."
16
16
  },
17
17
  "env": { "type": "string" },
18
+ "member": {
19
+ "type": "string",
20
+ "pattern": "^[a-z0-9][a-z0-9-]{0,39}$",
21
+ "description": "The workspace member whose pipeline ran the stage. Absent outside a workspace member."
22
+ },
18
23
  "base": { "type": "string", "pattern": "^[0-9a-f]{40,64}$", "description": "The commit the change is measured from: the merge base of the given base and head." },
19
24
  "head": { "type": "string", "pattern": "^[0-9a-f]{40,64}$" },
20
25
  "op": {
21
26
  "type": "string",
22
- "description": "The gate's op, pr-<number>. Its ledger is _gates/<op>.jsonl on chant/lifecycle. pr-unknown when no pull request was found."
27
+ "description": "The gate's op, pr-<number>, or pr-<number>-<member> for a workspace member's pipeline. Its ledger is _gates/<op>.jsonl on chant/lifecycle. pr-unknown when no pull request was found."
23
28
  },
24
29
  "gate": { "type": "string", "description": "The gate the apply waits on, pr-apply unless the pipeline names another." },
25
30
  "digest": {
@@ -36,6 +41,14 @@
36
41
  "description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review."
37
42
  },
38
43
  "message": { "type": "string", "description": "The refusal or failure as printed." },
44
+ "resumed": {
45
+ "type": "object",
46
+ "required": ["applied"],
47
+ "description": "Apply stage with --resume (#3464): an earlier attempt at the approved set failed partway. This run applied the rest under the same approval.",
48
+ "properties": {
49
+ "applied": { "type": "array", "items": { "type": "string" }, "description": "Components the earlier attempt applied, which this run did not run again." }
50
+ }
51
+ },
39
52
  "approval": {
40
53
  "type": "object",
41
54
  "required": ["status"],