@intentius/chant 0.124.41 → 0.124.43

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 (127) hide show
  1. package/dist/cli/handlers/build.d.ts.map +1 -1
  2. package/dist/cli/handlers/build.js +3 -0
  3. package/dist/cli/handlers/fan-out.d.ts.map +1 -1
  4. package/dist/cli/handlers/fan-out.js +27 -3
  5. package/dist/cli/handlers/pr.d.ts +3 -1
  6. package/dist/cli/handlers/pr.d.ts.map +1 -1
  7. package/dist/cli/handlers/pr.js +113 -2
  8. package/dist/cli/handlers/run.d.ts.map +1 -1
  9. package/dist/cli/handlers/run.js +11 -0
  10. package/dist/cli/main.d.ts.map +1 -1
  11. package/dist/cli/main.js +8 -2
  12. package/dist/cli/parse-args.d.ts.map +1 -1
  13. package/dist/cli/parse-args.js +15 -0
  14. package/dist/cli/registry.d.ts +19 -0
  15. package/dist/cli/registry.d.ts.map +1 -1
  16. package/dist/components/capability.d.ts +47 -0
  17. package/dist/components/capability.d.ts.map +1 -1
  18. package/dist/components/capability.js +44 -0
  19. package/dist/components/cli-support.d.ts +6 -0
  20. package/dist/components/cli-support.d.ts.map +1 -1
  21. package/dist/components/cli-support.js +8 -1
  22. package/dist/components/driver.d.ts +10 -0
  23. package/dist/components/driver.d.ts.map +1 -1
  24. package/dist/components/driver.js +52 -2
  25. package/dist/components/fan-out-run.d.ts +9 -0
  26. package/dist/components/fan-out-run.d.ts.map +1 -1
  27. package/dist/components/fan-out-run.js +17 -1
  28. package/dist/components/gate-plan.d.ts +2 -0
  29. package/dist/components/gate-plan.d.ts.map +1 -1
  30. package/dist/components/gate-plan.js +1 -0
  31. package/dist/components/pr-pipeline.d.ts +20 -0
  32. package/dist/components/pr-pipeline.d.ts.map +1 -1
  33. package/dist/components/pr-pipeline.js +54 -16
  34. package/dist/components/pr-run.d.ts +6 -0
  35. package/dist/components/pr-run.d.ts.map +1 -1
  36. package/dist/components/pr-run.js +3 -0
  37. package/dist/components/wave-plan.d.ts +14 -1
  38. package/dist/components/wave-plan.d.ts.map +1 -1
  39. package/dist/components/wave-plan.js +34 -2
  40. package/dist/op/activities/pr-refs.d.ts +10 -0
  41. package/dist/op/activities/pr-refs.d.ts.map +1 -0
  42. package/dist/op/activities/pr-refs.js +26 -0
  43. package/dist/op/activities/predict-behaviour.d.ts +2 -4
  44. package/dist/op/activities/predict-behaviour.d.ts.map +1 -1
  45. package/dist/op/activities/predict-behaviour.js +2 -21
  46. package/dist/op/activities/review-agent.js +1 -1
  47. package/dist/op/index.d.ts +2 -2
  48. package/dist/op/index.d.ts.map +1 -1
  49. package/dist/op/index.js +1 -1
  50. package/dist/op/op-waves-run.d.ts +3 -79
  51. package/dist/op/op-waves-run.d.ts.map +1 -1
  52. package/dist/op/op-waves-run.js +4 -133
  53. package/dist/op/stage-hooks.d.ts +152 -0
  54. package/dist/op/stage-hooks.d.ts.map +1 -0
  55. package/dist/op/stage-hooks.js +199 -0
  56. package/dist/pr-forge.d.ts +56 -0
  57. package/dist/pr-forge.d.ts.map +1 -1
  58. package/dist/pr-forge.js +320 -45
  59. package/dist/pr-loop.d.ts +18 -1
  60. package/dist/pr-loop.d.ts.map +1 -1
  61. package/dist/pr-loop.js +7 -0
  62. package/dist/pr-requires.d.ts +47 -0
  63. package/dist/pr-requires.d.ts.map +1 -0
  64. package/dist/pr-requires.js +53 -0
  65. package/dist/wave-runtime.mjs +213 -213
  66. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  67. package/dist/workspace/checks/ci-workflow.js +19 -4
  68. package/dist/workspace/ci-init.d.ts.map +1 -1
  69. package/dist/workspace/ci-init.js +5 -4
  70. package/dist/workspace/ci-run.d.ts.map +1 -1
  71. package/dist/workspace/ci-run.js +2 -1
  72. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  73. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  74. package/dist/workspace/ci-workflow-at.js +17 -0
  75. package/dist/workspace/ci-workflow.d.ts +11 -3
  76. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  77. package/dist/workspace/ci-workflow.js +10 -2
  78. package/dist/workspace/declaration.d.ts +6 -3
  79. package/dist/workspace/declaration.d.ts.map +1 -1
  80. package/dist/workspace/declaration.schema.json +2 -2
  81. package/dist/workspace/pr-report.schema.json +23 -3
  82. package/dist/workspace/reason-codes.d.ts +1 -1
  83. package/dist/workspace/reason-codes.d.ts.map +1 -1
  84. package/dist/workspace/reason-codes.js +1 -1
  85. package/package.json +1 -1
  86. package/src/cli/handlers/build.ts +3 -0
  87. package/src/cli/handlers/fan-out.ts +27 -3
  88. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  89. package/src/cli/handlers/pr.ts +110 -2
  90. package/src/cli/handlers/run.ts +10 -0
  91. package/src/cli/main.ts +8 -2
  92. package/src/cli/parse-args.ts +11 -0
  93. package/src/cli/registry.ts +19 -0
  94. package/src/components/capability-targets.test.ts +162 -0
  95. package/src/components/capability.ts +71 -0
  96. package/src/components/cli-support.ts +14 -1
  97. package/src/components/driver.ts +54 -3
  98. package/src/components/fan-out-run.ts +19 -1
  99. package/src/components/gate-plan.ts +3 -0
  100. package/src/components/pr-pipeline.ts +64 -16
  101. package/src/components/pr-run.ts +9 -0
  102. package/src/components/wave-plan.ts +38 -3
  103. package/src/op/activities/pr-refs.ts +28 -0
  104. package/src/op/activities/predict-behaviour.ts +3 -23
  105. package/src/op/activities/review-agent.ts +1 -1
  106. package/src/op/index.ts +4 -0
  107. package/src/op/op-waves-run.ts +24 -228
  108. package/src/op/stage-hooks-async.test.ts +79 -0
  109. package/src/op/stage-hooks.ts +346 -0
  110. package/src/pr-forge-bundle.test.ts +2 -2
  111. package/src/pr-forge.ts +334 -36
  112. package/src/pr-loop-bundle.test.ts +2 -2
  113. package/src/pr-loop.ts +25 -1
  114. package/src/pr-requires.test.ts +248 -0
  115. package/src/pr-requires.ts +93 -0
  116. package/src/review-agent-bundle.test.ts +35 -0
  117. package/src/stage-hooks-bundle.test.ts +38 -0
  118. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  119. package/src/workspace/checks/ci-workflow.ts +18 -4
  120. package/src/workspace/ci-init.ts +5 -4
  121. package/src/workspace/ci-run.ts +2 -1
  122. package/src/workspace/ci-workflow-at.ts +23 -0
  123. package/src/workspace/ci-workflow.ts +13 -2
  124. package/src/workspace/declaration.schema.json +2 -2
  125. package/src/workspace/declaration.ts +6 -3
  126. package/src/workspace/pr-report.schema.json +23 -3
  127. package/src/workspace/reason-codes.ts +1 -1
@@ -0,0 +1,276 @@
1
+ /**
2
+ * `chant components pr-apply --before-merge` (#4301, ws-106): the workflow
3
+ * read at base and the four requires checks, asked of a fake GitHub before
4
+ * any of the head's code loads, then the pull request's own gate, and the
5
+ * apply locks under ci.locks. Git, the change signal, the derivation and the
6
+ * gate ledger are fakes, as in pr.test.ts.
7
+ */
8
+
9
+ import { mkdtemp, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { readFileSync } from "node:fs";
12
+ import { join } from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
14
+ import type { DriverComponent } from "../../components/driver";
15
+ import type { DeployContext } from "../../components/capability";
16
+ import type { ParsedArgs } from "../registry";
17
+
18
+ const HEAD = "a".repeat(40);
19
+ const BASE = "b".repeat(40);
20
+
21
+ /** The security groups pr-apply reads from the declaration (#4168); none unless a test sets them. */
22
+ let groups: string[] = [];
23
+ vi.mock("../../workspace/security", async (original) => ({
24
+ ...(await original<typeof import("../../workspace/security")>()),
25
+ securityGroupsAt: () => new Set(groups),
26
+ }));
27
+
28
+ /** The ci.hooks pr-plan and pr-apply read at base (#4288); none unless a test sets them. */
29
+ let hooksAtBase: Record<string, unknown> = {};
30
+ vi.mock("../../components/pr-hooks", async (original) => ({
31
+ ...(await original<typeof import("../../components/pr-hooks")>()),
32
+ prLoopHooksAt: (_cwd: string, base: string) => ({ hooks: hooksAtBase, base }),
33
+ }));
34
+ /** `ci.locks` pr-apply reads at base (#4293), and the locks other pull requests hold; none unless a test sets them. */
35
+ let locksMode: "apply" | "plan" | null = null;
36
+ let heldLocks: (gate: string, targets: readonly string[], pr: number | null) => Promise<unknown[]> = async () => [];
37
+ vi.mock("../../workspace/ci-locks", () => ({ ciLocksAt: () => locksMode }));
38
+ let taken: unknown[] = [];
39
+ vi.mock("../../lifecycle/target-lock", async (original) => ({
40
+ ...(await original<typeof import("../../lifecycle/target-lock")>()),
41
+ heldTargetLocks: (gate: string, targets: readonly string[], pr: number | null) => heldLocks(gate, targets, pr),
42
+ takeTargetLocks: async (gate: string, targets: readonly string[], holder: unknown) => {
43
+ taken.push({ gate, targets, holder });
44
+ return { ok: true, taken: [...targets].sort(), released: [], expired: [], pushed: false };
45
+ },
46
+ }));
47
+ /** The ci.workflow pr-apply --before-merge reads at base (#4301). */
48
+ let workflowAtBase: string | null = "apply-before-merge";
49
+ vi.mock("../../workspace/ci-workflow-at", () => ({ ciWorkflowAt: () => workflowAtBase }));
50
+ /** Whether the head's code was loaded: the config read and the derivation. */
51
+ let loaded: string[] = [];
52
+
53
+ /** What ci.policy at base says (#4294): no policy unless a test sets a verdict. The engine itself is gate-policy.test.ts's. */
54
+ let policyVerdict: import("../../gate-policy").GatePolicyVerdict | undefined;
55
+ vi.mock("../../components/pr-policy", async (original) => ({
56
+ ...(await original<typeof import("../../components/pr-policy")>()),
57
+ prLoopPolicyAt: (_cwd: string, base: string) => ({ base }),
58
+ runPrPolicy: () => policyVerdict,
59
+ }));
60
+ vi.mock("../../override-gate", async (original) => ({
61
+ ...(await original<typeof import("../../override-gate")>()),
62
+ overridersAt: (_repo: string, base: string) => ({ base, path: ".chant/overriders", active: true, principals: ["github:carol"] }),
63
+ }));
64
+
65
+ vi.mock("../../components/changed-files", () => ({
66
+ resolveCommit: async () => HEAD,
67
+ resolveMergeBase: async () => BASE,
68
+ }));
69
+
70
+ vi.mock("../../config", async () => {
71
+ const actual = await vi.importActual<typeof import("../../config")>("../../config");
72
+ return { ...actual, loadChantConfig: async () => { loaded.push("config"); return { config: {} }; } };
73
+ });
74
+
75
+ vi.mock("./fan-out", () => ({
76
+ resolveChangedUnits: async () => ({ units: { changed: ["net"], indeterminate: [] } }),
77
+ }));
78
+
79
+ // #4022: pr-apply stores the change set its gate binds on chant/lifecycle and
80
+ // pushes it. These tests run in the chant checkout, so the store is replaced:
81
+ // it must never write or push the real repository's lifecycle branch.
82
+ vi.mock("../../lifecycle/gate-change-set", () => ({
83
+ storeGateChangeSet: async () => ({ written: false }),
84
+ readGraphIds: async () => ({ ids: [] }),
85
+ setGraphIdReader: () => {},
86
+ gateGraphReader: () => async () => ({ ids: [] }),
87
+ }));
88
+ vi.mock("../../lifecycle/git", async () => {
89
+ const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
90
+ return { ...actual, pushLifecycleReport: async () => { throw new Error("pr.test must not push chant/lifecycle"); } };
91
+ });
92
+
93
+ vi.mock("./run", () => ({
94
+ GATED_EXIT_CODE: 3,
95
+ autoReleaseRun: (id: string) => ({ runId: id, runOrigin: { forge: "local" } }),
96
+ recordAutoReleasesForRun: async () => [],
97
+ }));
98
+
99
+ let gateLedger: ReturnType<typeof import("../../op/gate").memoryGateLedgerPort>;
100
+ vi.mock("../../op/gate", async () => {
101
+ const actual = await vi.importActual<typeof import("../../op/gate")>("../../op/gate");
102
+ return { ...actual, gitGateLedgerPort: () => gateLedger, gateIsSealed: async () => false };
103
+ });
104
+
105
+ const failing = new Set<string>();
106
+ /** The error a root's apply throws, in place of applying (#3997). */
107
+ const refusal = new Map<string, Error>();
108
+ /** Called as a component's apply starts, before it can fail. */
109
+ let onRun: (root: string) => void = () => {};
110
+ vi.mock("../../components/fan-out-support", async () => {
111
+ const { CapabilityRegistry } = await import("../../components/capability");
112
+ const { planFanOut } = await import("../../components/fan-out");
113
+ const { computePlanDigest } = await import("../../lifecycle/plan-digest");
114
+ const registry = new CapabilityRegistry();
115
+ registry.register({
116
+ kind: "fake-root",
117
+ async plan(_ctx: DeployContext, input: { root: string }) {
118
+ const planDigest = computePlanDigest("fake-root", { root: input.root });
119
+ return {
120
+ member: input.root,
121
+ planDigest,
122
+ artifact: { planDigest },
123
+ changeSet: { member: { member: input.root, lexicon: "terraform", planner: "tofu", status: "planned", planDigest, holes: [] }, entries: [] },
124
+ };
125
+ },
126
+ async outputs() {
127
+ return {};
128
+ },
129
+ async run(_ctx: DeployContext, input: { root: string }) {
130
+ onRun(input.root);
131
+ if (refusal.has(input.root)) throw refusal.get(input.root);
132
+ if (failing.has(input.root)) throw new Error(`${input.root} failed`);
133
+ return { outputs: {} };
134
+ },
135
+ } as never);
136
+ return {
137
+ fanOutRegistry: async () => registry,
138
+ deriveFanOut: async () => (loaded.push("derive"), {
139
+ success: true,
140
+ components: ESTATE,
141
+ plan: planFanOut({ components: ESTATE, changed: ["net"] }),
142
+ signal: { changed: ["net"], indeterminate: [], unclaimed: [] },
143
+ }),
144
+ };
145
+ });
146
+
147
+ const root = (name: string, dependsOn: string[] = []): DriverComponent => ({
148
+ name,
149
+ dependsOn,
150
+ deploy: [{ phase: "Apply", steps: [{ kind: "fake-root", root: name }] }],
151
+ });
152
+ const ESTATE: DriverComponent[] = [root("net"), root("app", ["net"])];
153
+
154
+ const { memoryGateLedgerPort } = await import("../../op/gate");
155
+ const { runComponentsPrApply, runComponentsPrPlan } = await import("./pr");
156
+
157
+ let dir: string;
158
+ let out: string[];
159
+ let errors: string[];
160
+
161
+ const ctx = (a: Partial<ParsedArgs>) =>
162
+ ({ args: { command: "components", path: "pr-apply", format: "", fix: false, watch: false, verbose: false, help: false, live: false, base: "main", env: "prod", output: dir, pr: 12, forge: "github", beforeMerge: true, ...a } as ParsedArgs, plugins: [] }) as never;
163
+
164
+ beforeEach(async () => {
165
+ dir = await mkdtemp(join(tmpdir(), "pr-before-merge-"));
166
+ taken = [];
167
+ workflowAtBase = "apply-before-merge";
168
+ loaded = [];
169
+ out = [];
170
+ errors = [];
171
+ failing.clear();
172
+ refusal.clear();
173
+ onRun = () => {};
174
+ groups = [];
175
+ hooksAtBase = {};
176
+ locksMode = null;
177
+ heldLocks = async () => [];
178
+ policyVerdict = undefined;
179
+ gateLedger = memoryGateLedgerPort();
180
+ vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array) => { out.push(String(chunk)); return true; });
181
+ vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => { errors.push(m.map(String).join(" ")); });
182
+ vi.spyOn(console, "log").mockImplementation(() => {});
183
+ });
184
+
185
+ afterEach(async () => {
186
+ vi.restoreAllMocks();
187
+ vi.unstubAllGlobals();
188
+ vi.unstubAllEnvs();
189
+ await rm(dir, { recursive: true, force: true });
190
+ });
191
+
192
+ /** The digest pr-plan printed, read back from its report. */
193
+ async function plannedDigest(): Promise<string> {
194
+ const { readFile } = await import("node:fs/promises");
195
+ return JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8")).digest as string;
196
+ }
197
+
198
+ /** Pull request 12 on a fake GitHub, its head HEAD unless `over` says otherwise. */
199
+ function serveGithub(over: { head?: string; reviews?: unknown[]; mergeable?: boolean | null; behind?: number; statuses?: unknown[] } = {}): void {
200
+ const ok = (value: unknown) => ({ ok: true, status: 200, text: async () => JSON.stringify(value) });
201
+ vi.stubEnv("GITHUB_REPOSITORY", "acme/infra");
202
+ vi.stubEnv("GITHUB_TOKEN", "t0k");
203
+ vi.stubEnv("GITHUB_API_URL", "https://api.github.test");
204
+ vi.stubEnv("GITHUB_SERVER_URL", "https://github.com");
205
+ vi.stubGlobal("fetch", async (url: string, init: { method: string }) => {
206
+ const { pathname } = new URL(url);
207
+ if (init.method !== "GET") return ok({ id: 1 });
208
+ const pull = "/repos/acme/infra/pulls/12";
209
+ if (pathname === pull) return ok({ number: 12, state: "open", user: { login: "carol" }, head: { ref: "f", sha: over.head ?? HEAD }, base: { ref: "main" }, mergeable: over.mergeable === undefined ? true : over.mergeable, mergeable_state: "clean" });
210
+ if (pathname === `${pull}/reviews`) return ok(over.reviews ?? [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }]);
211
+ if (pathname === `${pull}/commits`) return ok([{ author: { login: "carol" }, committer: { login: "carol" } }]);
212
+ if (pathname === `/repos/acme/infra/compare/main...${HEAD}`) return ok({ behind_by: over.behind ?? 0 });
213
+ if (pathname === `/repos/acme/infra/commits/${HEAD}/status`) return ok({ statuses: over.statuses ?? [{ context: "chant/plan", state: "success" }, { context: "chant/apply", state: "failure" }] });
214
+ if (pathname === `/repos/acme/infra/commits/${HEAD}/check-runs`) return ok({ check_runs: [] });
215
+ return ok([]);
216
+ });
217
+ }
218
+
219
+ const applyReport = async () => {
220
+ const { readFile } = await import("node:fs/promises");
221
+ return JSON.parse(await readFile(join(dir, "pr-apply.json"), "utf8")) as Record<string, unknown> & { beforeMerge?: { requires: Array<{ name: string; passed: boolean; reason: string }> } };
222
+ };
223
+
224
+ describe("pr-apply --before-merge (#4301)", () => {
225
+ test("without apply-before-merge at base it refuses before-merge-off, and none of the head's code loads", async () => {
226
+ workflowAtBase = "trunk-curated";
227
+ serveGithub();
228
+ expect(await runComponentsPrApply(ctx({}))).toBe(1);
229
+ const report = await applyReport();
230
+ expect(report).toMatchObject({ stage: "apply", pr: 12, status: "refused", refusal: "before-merge-off", beforeMerge: { requires: [] } });
231
+ expect(report.message).toContain("sets ci.workflow to trunk-curated");
232
+ expect(loaded).toEqual([]);
233
+ });
234
+
235
+ test("an unmet require refuses requires-unmet with each check in the report, and none of the head's code loads", async () => {
236
+ serveGithub({ behind: 2, reviews: [{ user: { login: "carol" }, state: "APPROVED", commit_id: HEAD }] });
237
+ expect(await runComponentsPrApply(ctx({}))).toBe(3);
238
+ const report = await applyReport();
239
+ expect(report).toMatchObject({ status: "refused", refusal: "requires-unmet" });
240
+ expect(report.beforeMerge!.requires.map((r) => [r.name, r.passed])).toEqual([["approved", false], ["mergeable", true], ["undiverged", false], ["checks", true]]);
241
+ expect(report.message).toContain("undiverged: aaaaaaaa is 2 commits behind main");
242
+ expect(loaded).toEqual([]);
243
+ });
244
+
245
+ test("it needs --pr and --forge, and takes no --green", async () => {
246
+ expect(await runComponentsPrApply(ctx({ pr: undefined }))).toBe(1);
247
+ expect(await runComponentsPrApply(ctx({ forge: undefined }))).toBe(1);
248
+ expect(await runComponentsPrApply(ctx({ green: true }))).toBe(1);
249
+ expect(loaded).toEqual([]);
250
+ });
251
+
252
+ test("all four passed: the pull request's own gate decides, and an unapproved plan applies nothing", async () => {
253
+ serveGithub();
254
+ expect(await runComponentsPrApply(ctx({}))).toBe(3);
255
+ const report = await applyReport();
256
+ expect(report).toMatchObject({ status: "refused", refusal: "not-approved", op: "pr-12" });
257
+ expect(report.beforeMerge!.requires.every((r) => r.passed)).toBe(true);
258
+ });
259
+
260
+ test("approved: it applies at the head, and under ci.locks holds apply locks on what it reaches", async () => {
261
+ serveGithub();
262
+ await runComponentsPrPlan(ctx({ beforeMerge: false, forge: undefined, noStatus: true }));
263
+ const { readFile } = await import("node:fs/promises");
264
+ const digest = JSON.parse(await readFile(join(dir, "pr-plan.json"), "utf8")).digest as string;
265
+ gateLedger = memoryGateLedgerPort({
266
+ resolutions: [{ version: 1, kind: "resolution", op: "pr-12", gate: "pr-apply", resolvedBy: "github:bob", timestamp: new Date().toISOString(), planDigest: digest }],
267
+ });
268
+ locksMode = "apply";
269
+ const ran: string[] = [];
270
+ onRun = (r) => void ran.push(r);
271
+ expect(await runComponentsPrApply(ctx({}))).toBe(0);
272
+ expect(ran).toEqual(["net", "app"]);
273
+ expect(taken).toEqual([{ gate: "pr-apply", targets: ["net", "app"], holder: expect.objectContaining({ pr: 12, stage: "apply", head: HEAD }) }]);
274
+ expect(await applyReport()).toMatchObject({ status: "applied", beforeMerge: { requires: expect.any(Array) } });
275
+ });
276
+ });
@@ -106,6 +106,7 @@ import {
106
106
  prStatusState,
107
107
  renderPrNote,
108
108
  type PrApproval,
109
+ type PrBeforeMerge,
109
110
  type PrMember,
110
111
  type PrPolicyReport,
111
112
  type PrReport,
@@ -173,6 +174,8 @@ export interface Prepared {
173
174
  policy: PrLoopPolicy;
174
175
  /** What the policy said about the plan, for the report's `policy`. */
175
176
  policyReport?: PrPolicyReport;
177
+ /** `pr-apply --before-merge` (#4301): the requires checks, for the report's `beforeMerge`. */
178
+ beforeMerge?: PrBeforeMerge;
176
179
  }
177
180
 
178
181
  const fail = (message: string, hint?: string): number => {
@@ -495,6 +498,7 @@ async function finish(ctx: CommandContext, p: Prepared, report: PrReport, upload
495
498
  if (p.hookFailed) report = { ...report, message: p.hookFailed };
496
499
  if (p.routedAway && report.status === "nothing" && !report.message) report = { ...report, message: p.routedAway };
497
500
  if (p.policyReport) report = { ...report, policy: p.policyReport };
501
+ if (p.beforeMerge) report = { ...report, beforeMerge: p.beforeMerge };
498
502
  const note = noteFor(report, p.forge);
499
503
  writeOutputs(ctx, report, note, upload);
500
504
  if (!ctx.args.noStatus) await publish(p.forge, report, note);
@@ -730,10 +734,19 @@ async function overdueOps(forge: PrForge | undefined, member: string | undefined
730
734
  /** chant components pr-apply --base <ref> [--pr <n>] [--env <env>] [--gate <name>] [--forge <kind>] [--require-review] [--output <dir>] [--json] [--events-json] [--report-url <url>] */
731
735
  export function runComponentsPrApply(ctx: CommandContext): Promise<number> {
732
736
  if (refusesEventsWithJson(ctx.args, "the stage's report")) return Promise.resolve(1);
733
- return withStageEvents("pr-apply", ctx, (events) => withComponentEndpoint(ctx, () => prApply(ctx, events)));
737
+ return withStageEvents("pr-apply", ctx, async (events) => {
738
+ // #4301: an apply before merge asks the forge its requires checks before any of the head's code loads, the config included.
739
+ let beforeMerge: PrBeforeMerge | undefined;
740
+ if (ctx.args.beforeMerge) {
741
+ const checked = await beforeMergeChecks(ctx);
742
+ if (typeof checked === "number") return checked;
743
+ beforeMerge = checked;
744
+ }
745
+ return withComponentEndpoint(ctx, () => prApply(ctx, events, beforeMerge));
746
+ });
734
747
  }
735
748
 
736
- async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number> {
749
+ async function prApply(ctx: CommandContext, stage: StageEvents, beforeMerge?: PrBeforeMerge): Promise<number> {
737
750
  // #4168: the review-rules security group turns --require-review on, read from the declaration at base.
738
751
  const { securityGroupsAt } = await import("../../workspace/security");
739
752
  const reviewRules = !ctx.args.requireReview && securityGroupsAt(process.cwd(), ctx.args.base).has("review-rules");
@@ -756,6 +769,7 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
756
769
  }
757
770
  const p = await prepare(ctx, "apply");
758
771
  if (typeof p === "number") return p;
772
+ if (beforeMerge) p.beforeMerge = beforeMerge;
759
773
 
760
774
  let pr: number | null = args.pr ?? null;
761
775
  if (pr === null && !green && p.forge && p.plan.order.length > 0) {
@@ -876,6 +890,12 @@ async function prApply(ctx: CommandContext, stage: StageEvents): Promise<number>
876
890
  }
877
891
  const unspent = await spendPolicy(op, judged, p.head);
878
892
  if (unspent) return stop(prReport({ ...base, approval, status: "refused", refusal: "policy-denied", message: unspent }), 1);
893
+ // #4293, #4301: under ci.locks an apply before merge holds its targets until the pull request merges or closes.
894
+ if (locks && beforeMerge && pr !== null) {
895
+ const taken = await takeApplyLocks(p, pr);
896
+ if (typeof taken === "number") return taken;
897
+ if (taken) return stop(prReport({ ...base, approval, status: "refused", refusal: "locked", message: taken }), GATED_EXIT_CODE);
898
+ }
879
899
 
880
900
  // The record is written before the first component runs and again as each
881
901
  // one settles, so a run killed partway still knows what applied.
@@ -1014,6 +1034,94 @@ async function heldLocksOf(p: Prepared, pr: number | null): Promise<string | nul
1014
1034
  return `${describeHeldTargetLocks(held, name)}, so nothing was applied. Apply again once ${holders.length === 1 ? "it merges or closes" : "they merge or close"}, or after \`chant unlock --pr ${holders[0]}${p.gate === PR_APPLY_GATE ? "" : ` --gate ${p.gate}`}\`${isOpen ? "" : ". Without --forge every lock counts as held"}.`;
1015
1035
  }
1016
1036
 
1037
+ /**
1038
+ * `pr-apply --before-merge` (#4301, ws-106), before any of the head's code
1039
+ * loads: the declaration at base must set `ci.workflow: "apply-before-merge"`
1040
+ * for the member, and the forge must say the pull request is approved,
1041
+ * mergeable, undiverged and green at the commit checked out. Returns the
1042
+ * checks for the report, or the exit code once it wrote a refusal.
1043
+ */
1044
+ async function beforeMergeChecks(ctx: CommandContext): Promise<PrBeforeMerge | number> {
1045
+ const { args } = ctx;
1046
+ if (args.green) return fail("--before-merge applies an open pull request's head and --green a green commit; pass one of them");
1047
+ if (args.waveGate) return fail("--before-merge applies the pull request's plan in one run, so it takes no --wave-gate");
1048
+ if (args.pr === undefined) return fail("--before-merge applies an open pull request, so it needs --pr <n>");
1049
+ if (!args.forge || !(FORGE_KINDS as readonly string[]).includes(args.forge)) return fail("--before-merge asks the forge whether the pull request is ready, so it needs --forge github|gitlab|forgejo");
1050
+ if (!args.base) return fail("pr-apply needs the commit the change is measured from: --base <ref>", "Before merge, the target branch's commit.");
1051
+ const cwd = resolve(".");
1052
+ let forge: PrForge;
1053
+ let head: string;
1054
+ let base: string;
1055
+ try {
1056
+ forge = forgeFromEnv(args.forge as ForgeKind);
1057
+ head = await resolveCommit(cwd, args.head ?? "HEAD");
1058
+ base = await resolveMergeBase(cwd, args.base, head);
1059
+ } catch (err) {
1060
+ return fail(err instanceof Error ? err.message : String(err));
1061
+ }
1062
+ const member = args.members?.[0];
1063
+ const gate = args.gate ?? PR_APPLY_GATE;
1064
+ const { ciWorkflowAt } = await import("../../workspace/ci-workflow-at");
1065
+ const { appliesBeforeMerge } = await import("../../workspace/ci-workflow");
1066
+ const { checkPrRequires, unmetRequires } = await import("../../pr-requires");
1067
+ const workflow = ciWorkflowAt(cwd, args.base, member);
1068
+ const early = earlyStop(ctx, forge, { pr: args.pr, base, head, gate, ...(member ? { member } : {}) });
1069
+ if (!appliesBeforeMerge(workflow)) {
1070
+ return early(
1071
+ { requires: [] },
1072
+ "before-merge-off",
1073
+ `The declaration at base sets ci.workflow to ${workflow ?? "nothing"}${member ? ` for ${member}` : ""}, and only apply-before-merge applies a pull request before it merges (ws-106). Nothing was applied.`,
1074
+ 1,
1075
+ );
1076
+ }
1077
+ // The apply's own statuses never hold it back: chant ci run's, and the report's.
1078
+ const ignore = [`chant/${gate}`, `chant/${PR_APPLY_GATE}`, prStatusContext("apply", member)];
1079
+ // No `only`: ws-106 gives the declaration no way to leave a check out, so all four are asked (#4336).
1080
+ const requires = await checkPrRequires(forge, args.pr, head, { ignore });
1081
+ for (const r of requires) console.error(formatInfo(`${r.name}: ${r.passed ? "passed" : r.pending ? "pending" : "unmet"}, ${r.reason}`));
1082
+ const unmet = unmetRequires(requires);
1083
+ if (unmet) {
1084
+ return early({ requires }, "requires-unmet", `${forge.kind === "gitlab" ? `Merge request !${args.pr}` : `Pull request ${args.pr}`} is not ready to apply before merge: ${unmet}. Nothing was applied.`, GATED_EXIT_CODE);
1085
+ }
1086
+ return { requires };
1087
+ }
1088
+
1089
+ /** The refusal report of an apply before merge stopped before it planned (#4301): nothing selected, nothing planned. */
1090
+ function earlyStop(ctx: CommandContext, forge: PrForge, at: { pr: number; base: string; head: string; gate: string; member?: string }) {
1091
+ return async (beforeMerge: PrBeforeMerge, refusal: "requires-unmet" | "before-merge-off", message: string, code: number): Promise<number> => {
1092
+ const plan = { order: [], waves: [], skipped: [], seeds: [], indeterminate: [] } as unknown as FanOutPlan;
1093
+ const signal = { changed: [], indeterminate: [], unclaimed: [] } as unknown as ComponentChangeSignal;
1094
+ const p = { env: ctx.args.env ?? "local", base: at.base, head: at.head, gate: at.gate, forge, hookOutcomes: [], beforeMerge, ...(at.member ? { member: at.member } : {}) } as unknown as Prepared;
1095
+ const set: PrPlanSet = { doc: composeChangeSet([]), members: [], plans: {}, outputs: {}, failed: false };
1096
+ const report = prReport({ stage: "apply", pr: at.pr, env: p.env, ...(at.member ? { member: at.member } : {}), base: at.base, head: at.head, op: prOp(at.pr, at.member), gate: at.gate, plan, signal, set, approval: { status: "pending" }, status: "refused", refusal, message });
1097
+ return stopper(ctx, p, undefined, undefined)(report, code);
1098
+ };
1099
+ }
1100
+
1101
+ /**
1102
+ * Take `apply` locks on the targets an apply before merge reaches (#4293,
1103
+ * #4301), held for pull request `pr` until it merges or closes. Returns the
1104
+ * refusal when another open pull request holds one, null once taken, or the
1105
+ * exit code when the locks could not be written.
1106
+ */
1107
+ async function takeApplyLocks(p: Prepared, pr: number): Promise<string | null | number> {
1108
+ const { takeTargetLocks, describeHeldTargetLocks } = await import("../../lifecycle/target-lock");
1109
+ const isOpen = p.forge?.pullRequestOpen?.bind(p.forge);
1110
+ const by = process.env.GITHUB_ACTOR ?? process.env.GITLAB_USER_LOGIN ?? process.env.USER ?? "unknown";
1111
+ let result;
1112
+ try {
1113
+ result = await takeTargetLocks(p.gate, p.plan.order, { pr, by, at: new Date().toISOString(), stage: "apply", head: p.head }, { ...(isOpen ? { isOpen } : {}) });
1114
+ } catch (err) {
1115
+ return fail(`could not take the target locks of ${p.gate}, so nothing was applied: ${err instanceof Error ? err.message : String(err)}`);
1116
+ }
1117
+ if (result.ok) {
1118
+ if (result.taken.length > 0) console.error(formatInfo(`pull request ${pr} holds ${result.taken.join(", ")} on ${p.gate} until it merges or closes`));
1119
+ return null;
1120
+ }
1121
+ const name = (n: number) => (p.forge?.kind === "gitlab" ? `merge request !${n}` : `pull request ${n}`);
1122
+ return `${describeHeldTargetLocks(result.held, name)}, so nothing was applied.`;
1123
+ }
1124
+
1017
1125
  /** #4274: a component that published nothing is recorded by the plan digest its approval bound. */
1018
1126
  function prAppliedDigests(p: Prepared, members: PrMember[]) {
1019
1127
  return appliedDigestOf({ components: p.components, env: p.env, projectRoot: resolve("."), members });
@@ -34,6 +34,7 @@ import { appliedDigestOf, extractRunDigest, maybeRecordAutoRelease, type Applied
34
34
  import { maybePersistBuildManifest } from "../../components/manifest-persistence";
35
35
  import { failedRunExitCode, type DriverComponentResult } from "../../components/driver";
36
36
  import { isComponentList, type ComponentRunPlan, type RunComponentsResult } from "../../components/cli-support";
37
+ import { parseComponentTargets } from "../../components/capability";
37
38
  import { isApplyRecord, resolveCiRun, type ReleaseRecord, type RunOrigin } from "../../lifecycle/release-ledger";
38
39
  import { gateAdmission, gateApprover } from "../../components/promote";
39
40
  import { runOpGenerate } from "./run-generate";
@@ -1202,10 +1203,19 @@ async function runComponentSelection(ctx: CommandContext): Promise<number> {
1202
1203
  if (plan.added.length > 0) console.error(formatInfo(`added as dependencies: ${plan.added.join(", ")}`));
1203
1204
  }
1204
1205
  : undefined;
1206
+ // #4300: `--target <component>:<address>`, the addresses a component's steps narrow to.
1207
+ let targets: Record<string, string[]> | undefined;
1208
+ try {
1209
+ targets = ctx.args.targets && ctx.args.targets.length > 0 ? parseComponentTargets(ctx.args.targets) : undefined;
1210
+ } catch (err) {
1211
+ console.error(formatError({ message: err instanceof Error ? err.message : String(err) }));
1212
+ return 1;
1213
+ }
1205
1214
  let result: RunComponentsResult;
1206
1215
  try {
1207
1216
  result = await runtime.runComponents(projectPath, selector, {
1208
1217
  env: ctx.args.env,
1218
+ ...(targets ? { targets } : {}),
1209
1219
  componentOutputs: seededOutputs,
1210
1220
  onProgress,
1211
1221
  ...(events ? { onRunEvent: events.emit } : {}),
package/src/cli/main.ts CHANGED
@@ -347,7 +347,7 @@ CI:
347
347
  key. --trust commits its runner entry on a branch of its
348
348
  own and pushes it for review; the target branch is never
349
349
  written
350
- ci init --workflow trunk-curated|trunk-curated+apply-on-merge
350
+ ci init --workflow trunk-curated|trunk-curated+apply-on-merge|apply-before-merge
351
351
  [--forge github|gitlab|forgejo] [--security <group>]... [--yes]
352
352
  Set the workspace up for a prescribed delivery workflow:
353
353
  print what it would write as a diff, then write it on
@@ -1011,7 +1011,8 @@ Component release ledger + status:
1011
1011
  when the wave is reached, with --canary <name>
1012
1012
  for wave 1 and --wave <n> for one wave per CI
1013
1013
  job; --resume <file> finishes an attempt that
1014
- stopped)
1014
+ stopped; --target <component>:<address> plans
1015
+ and applies only that address)
1015
1016
  components pr-plan Plan the components a pull request reaches and
1016
1017
  the ones that depend on them, and write the
1017
1018
  report and note (--base <ref> --pr <n>
@@ -1055,6 +1056,11 @@ Component release ledger + status:
1055
1056
  from the one last applied to --env, under the
1056
1057
  approved plans of the merges since, else at gate
1057
1058
  op green-<sha>;
1059
+ --before-merge --pr <n>: apply the open pull
1060
+ request's head under its gate, once the
1061
+ declaration at base sets ci.workflow to
1062
+ apply-before-merge and the forge says it is
1063
+ approved, mergeable, undiverged and green;
1058
1064
  --events-json, both commands: the run-event
1059
1065
  stream on stdout; --report-url <s3 or file URL>,
1060
1066
  both commands: copy the report, the change set
@@ -378,6 +378,11 @@ export function parseArgs(args: string[]): ParsedArgs {
378
378
  const share = Number(raw);
379
379
  if (!Number.isInteger(share) || share < 1) throw new Error(`--share needs a share number from 1: got "${raw ?? ""}"`);
380
380
  result.share = share;
381
+ } else if (arg === "--target") {
382
+ // `chant components fan-out|run --components --target <component>:<address>` (#4300), repeatable.
383
+ const raw = args[++i];
384
+ if (!raw || raw.startsWith("-") || !raw.includes(":")) throw new Error("--target needs a component and an address: --target <component>:<address>");
385
+ (result.targets ??= []).push(raw);
381
386
  } else if (arg === "--canary") {
382
387
  const raw = args[++i];
383
388
  if (!raw || raw.startsWith("-")) throw new Error("--canary needs a component name: --canary <name>[,<name>...]");
@@ -436,6 +441,12 @@ export function parseArgs(args: string[]): ParsedArgs {
436
441
  } else if (arg === "--green") {
437
442
  // `chant components pr-apply --green` (#4168 step 9): apply the green commit at HEAD.
438
443
  result.green = true;
444
+ } else if (arg === "--before-merge") {
445
+ // `chant components pr-apply --before-merge` (#4301, ws-106): apply an open pull request's plan from its head.
446
+ result.beforeMerge = true;
447
+ } else if (arg === "--apply-before-merge") {
448
+ // `chant build --pr-loop --apply-before-merge` and `chant ci run apply --apply-before-merge` (#4301): the job that applies before merge.
449
+ result.applyBeforeMerge = true;
439
450
  } else if (arg === "--apply-on-merge") {
440
451
  // `chant build --pr-loop --apply-on-merge` and `chant ci serve --apply-on-merge` (#4168): opt into applying on merge.
441
452
  result.applyOnMerge = true;
@@ -294,6 +294,12 @@ export interface ParsedArgs {
294
294
  share?: number;
295
295
  /** `chant run wave --group <name>` (#3914) — apply one job group's runs of a decided wave. */
296
296
  group?: string;
297
+ /**
298
+ * `chant components fan-out --target <component>:<address>` and `chant run
299
+ * --components --target ...` (#4300), repeatable: the addresses a
300
+ * component's steps narrow to. Raw values; `parseComponentTargets` groups them.
301
+ */
302
+ targets?: string[];
297
303
  /** `chant components fan-out --canary <component>` (#3049), repeatable — components that form wave 1. */
298
304
  canary?: string[];
299
305
  /**
@@ -353,6 +359,19 @@ export interface ParsedArgs {
353
359
  green?: boolean;
354
360
  /** `chant build --pr-loop --pr-env` (#4298) — add the env-up, env-down and env-sweep jobs of a pull request's environment. */
355
361
  prEnv?: boolean;
362
+ /**
363
+ * `chant components pr-apply --before-merge` (#4301, ws-106): apply an open
364
+ * pull request's plan from its head, once the declaration at base sets
365
+ * `ci.workflow: "apply-before-merge"` and the pull request is approved,
366
+ * mergeable, undiverged and green.
367
+ */
368
+ beforeMerge?: boolean;
369
+ /**
370
+ * `chant build --pr-loop --apply-before-merge` and `chant ci run apply
371
+ * --apply-before-merge` (#4301): the pipeline's apply job applies an open
372
+ * pull request before it merges, and a merge records.
373
+ */
374
+ applyBeforeMerge?: boolean;
356
375
  /** `chant build --pr-loop --pr-build` (#4088) — build and lint before pr-plan and pr-apply. */
357
376
  prBuild?: boolean;
358
377
  /** `chant ci serve --build-command <line>` (#4169) — the line that builds a commit a `ci/build/<sha>` tag asks for, in a worktree of it. */