@intentius/chant 0.105.1 → 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.
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/pr.d.ts +16 -2
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +7 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/components/changed-files.d.ts +8 -2
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/fan-out-record.d.ts +24 -0
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/pr-pipeline.d.ts +60 -4
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-run.d.ts +92 -3
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/gated-waves.d.ts +4 -1
- package/dist/gated-waves.d.ts.map +1 -1
- package/dist/lexicon.d.ts +13 -2
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +2 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +10 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +2 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -2
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +19 -2
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/pr-loop.d.ts +29 -4
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -1
- package/dist/workspace/member-pipeline.d.ts +14 -1
- package/dist/workspace/member-pipeline.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__fixtures__/change-set/warden.change-set.golden.json +2 -2
- package/src/__fixtures__/change-set/warden.plan.json +47 -40
- package/src/__fixtures__/plan-summary/normalization-vectors.json +275 -2
- package/src/change-set.test.ts +7 -8
- package/src/cli/commands/lint.ts +34 -1
- package/src/cli/handlers/build-lexicon-output.test.ts +94 -0
- package/src/cli/handlers/build.ts +65 -0
- package/src/cli/handlers/fan-out.test.ts +12 -1
- package/src/cli/handlers/fan-out.ts +7 -3
- package/src/cli/handlers/operator.ts +1 -2
- package/src/cli/handlers/pr.ts +220 -35
- package/src/cli/handlers/promote.test.ts +15 -1
- package/src/cli/handlers/promote.ts +5 -3
- package/src/cli/handlers/run.test.ts +164 -1
- package/src/cli/handlers/run.ts +22 -11
- package/src/cli/main.ts +13 -1
- package/src/cli/mcp/never-over-mcp.test.ts +74 -0
- package/src/cli/registry.ts +7 -0
- package/src/components/changed-files.test.ts +58 -0
- package/src/components/changed-files.ts +19 -4
- package/src/components/fan-out-record.test.ts +47 -2
- package/src/components/fan-out-record.ts +53 -0
- package/src/components/pr-pipeline.ts +89 -6
- package/src/components/pr-run.test.ts +300 -3
- package/src/components/pr-run.ts +196 -5
- package/src/gated-waves.test.ts +11 -0
- package/src/gated-waves.ts +7 -2
- package/src/lexicon.ts +13 -1
- package/src/lifecycle/gate-ledger.ts +4 -3
- package/src/op/gate-name.ts +17 -0
- package/src/op/gate-summary.test.ts +12 -0
- package/src/op/gate-summary.ts +3 -1
- package/src/op/gate.test.ts +15 -1
- package/src/op/gate.ts +21 -4
- package/src/op/index.ts +2 -2
- package/src/op/local-executor.test.ts +35 -0
- package/src/op/local-executor.ts +2 -2
- package/src/op/local-output.test.ts +20 -0
- package/src/op/local-output.ts +4 -4
- package/src/op/runtime.ts +20 -2
- package/src/op/runtimes/local.test.ts +15 -0
- package/src/op/runtimes/local.ts +3 -1
- package/src/pr-loop.test.ts +41 -1
- package/src/pr-loop.ts +38 -8
- package/src/workspace/lineage-upgrade-cli.ts +4 -3
- package/src/workspace/lineage-upgrade.test.ts +40 -0
- package/src/workspace/member-pipeline.test.ts +28 -0
- package/src/workspace/member-pipeline.ts +20 -3
- package/src/workspace/pr-report.schema.json +14 -1
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();
|
package/src/op/runtimes/local.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/pr-loop.test.ts
CHANGED
|
@@ -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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
/**
|
|
49
|
-
export
|
|
50
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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"],
|