@intentius/chant 0.124.41 → 0.124.42

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 (122) 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 +112 -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/op-waves-run.d.ts +3 -79
  48. package/dist/op/op-waves-run.d.ts.map +1 -1
  49. package/dist/op/op-waves-run.js +4 -133
  50. package/dist/op/stage-hooks.d.ts +132 -0
  51. package/dist/op/stage-hooks.d.ts.map +1 -0
  52. package/dist/op/stage-hooks.js +142 -0
  53. package/dist/pr-forge.d.ts +28 -0
  54. package/dist/pr-forge.d.ts.map +1 -1
  55. package/dist/pr-forge.js +251 -46
  56. package/dist/pr-loop.d.ts +18 -1
  57. package/dist/pr-loop.d.ts.map +1 -1
  58. package/dist/pr-loop.js +7 -0
  59. package/dist/pr-requires.d.ts +38 -0
  60. package/dist/pr-requires.d.ts.map +1 -0
  61. package/dist/pr-requires.js +48 -0
  62. package/dist/wave-runtime.mjs +201 -201
  63. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  64. package/dist/workspace/checks/ci-workflow.js +19 -4
  65. package/dist/workspace/ci-init.d.ts.map +1 -1
  66. package/dist/workspace/ci-init.js +5 -4
  67. package/dist/workspace/ci-run.d.ts.map +1 -1
  68. package/dist/workspace/ci-run.js +2 -1
  69. package/dist/workspace/ci-workflow-at.d.ts +14 -0
  70. package/dist/workspace/ci-workflow-at.d.ts.map +1 -0
  71. package/dist/workspace/ci-workflow-at.js +17 -0
  72. package/dist/workspace/ci-workflow.d.ts +11 -3
  73. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  74. package/dist/workspace/ci-workflow.js +10 -2
  75. package/dist/workspace/declaration.d.ts +6 -3
  76. package/dist/workspace/declaration.d.ts.map +1 -1
  77. package/dist/workspace/declaration.schema.json +2 -2
  78. package/dist/workspace/pr-report.schema.json +23 -3
  79. package/dist/workspace/reason-codes.d.ts +1 -1
  80. package/dist/workspace/reason-codes.d.ts.map +1 -1
  81. package/dist/workspace/reason-codes.js +1 -1
  82. package/package.json +1 -1
  83. package/src/cli/handlers/build.ts +3 -0
  84. package/src/cli/handlers/fan-out.ts +27 -3
  85. package/src/cli/handlers/pr-before-merge.test.ts +276 -0
  86. package/src/cli/handlers/pr.ts +109 -2
  87. package/src/cli/handlers/run.ts +10 -0
  88. package/src/cli/main.ts +8 -2
  89. package/src/cli/parse-args.ts +11 -0
  90. package/src/cli/registry.ts +19 -0
  91. package/src/components/capability-targets.test.ts +162 -0
  92. package/src/components/capability.ts +71 -0
  93. package/src/components/cli-support.ts +14 -1
  94. package/src/components/driver.ts +54 -3
  95. package/src/components/fan-out-run.ts +19 -1
  96. package/src/components/gate-plan.ts +3 -0
  97. package/src/components/pr-pipeline.ts +64 -16
  98. package/src/components/pr-run.ts +9 -0
  99. package/src/components/wave-plan.ts +38 -3
  100. package/src/op/activities/pr-refs.ts +28 -0
  101. package/src/op/activities/predict-behaviour.ts +3 -23
  102. package/src/op/activities/review-agent.ts +1 -1
  103. package/src/op/op-waves-run.ts +20 -228
  104. package/src/op/stage-hooks.ts +251 -0
  105. package/src/pr-forge-bundle.test.ts +2 -2
  106. package/src/pr-forge.ts +235 -37
  107. package/src/pr-loop-bundle.test.ts +2 -2
  108. package/src/pr-loop.ts +25 -1
  109. package/src/pr-requires.test.ts +184 -0
  110. package/src/pr-requires.ts +79 -0
  111. package/src/review-agent-bundle.test.ts +35 -0
  112. package/src/stage-hooks-bundle.test.ts +38 -0
  113. package/src/workspace/checks/ci-workflow.test.ts +9 -0
  114. package/src/workspace/checks/ci-workflow.ts +18 -4
  115. package/src/workspace/ci-init.ts +5 -4
  116. package/src/workspace/ci-run.ts +2 -1
  117. package/src/workspace/ci-workflow-at.ts +23 -0
  118. package/src/workspace/ci-workflow.ts +13 -2
  119. package/src/workspace/declaration.schema.json +2 -2
  120. package/src/workspace/declaration.ts +6 -3
  121. package/src/workspace/pr-report.schema.json +23 -3
  122. package/src/workspace/reason-codes.ts +1 -1
@@ -0,0 +1,184 @@
1
+ /**
2
+ * The requires checks of an apply before merge (#4301, ws-106): the four
3
+ * PrForge methods on GitHub, Forgejo and GitLab, driven through a recording
4
+ * fake forge, and checkPrRequires over them.
5
+ */
6
+
7
+ import { describe, expect, test } from "vitest";
8
+ import { forgejoForge, githubForge, gitlabForge, type ForgeFetch, type PrForge } from "./pr-forge";
9
+ import { checkPrRequires, PR_APPLY_REQUIRES, unmetRequires } from "./pr-requires";
10
+
11
+ /** A fake forge: `routes` maps `METHOD path-without-query` to a body, a status, or a function of the query. */
12
+ function fakeFetch(routes: Record<string, unknown | ((q: URLSearchParams) => unknown)>): ForgeFetch {
13
+ return async (url, init) => {
14
+ const u = new URL(url);
15
+ let found = routes[`${init.method} ${u.pathname}`];
16
+ if (typeof found === "function") found = (found as (q: URLSearchParams) => unknown)(u.searchParams);
17
+ if (found === undefined) return { ok: false, status: 404, text: async () => '{"message":"Not Found"}' };
18
+ if (typeof found === "number") return { ok: false, status: found, text: async () => "" };
19
+ return { ok: true, status: 200, text: async () => JSON.stringify(found) };
20
+ };
21
+ }
22
+
23
+ const HEAD = "a".repeat(40);
24
+ const TIP = "b".repeat(40);
25
+ const OTHER = "c".repeat(40);
26
+
27
+ describe("github", () => {
28
+ const repo = "/repos/acme/infra";
29
+ const pull = (over: Record<string, unknown> = {}) => ({ number: 7, state: "open", user: { login: "alice" }, head: { ref: "f", sha: HEAD }, base: { ref: "main" }, mergeable: true, mergeable_state: "clean", ...over });
30
+ const forge = (routes: Record<string, unknown>) =>
31
+ githubForge({ apiBase: "https://api.github.com", repo: "acme/infra", token: "t", fetch: fakeFetch({ [`GET ${repo}/pulls/7`]: pull(), [`GET ${repo}/pulls/7/commits`]: [{ author: { login: "alice" }, committer: { login: "alice" } }], ...routes }) });
32
+
33
+ test("approved: a reviewer other than the authors approved this head", async () => {
34
+ const f = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }] });
35
+ expect(await f.prApproved!(7, HEAD)).toEqual({ passed: true, reason: "approved at aaaaaaaa by bob" });
36
+ });
37
+
38
+ test("approved: an approval of an older head, the author's own, or a request for changes does not pass", async () => {
39
+ const older = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: OTHER }, { user: { login: "alice" }, state: "APPROVED", commit_id: HEAD }] });
40
+ expect((await older.prApproved!(7, HEAD)).passed).toBe(false);
41
+ const changes = forge({ [`GET ${repo}/pulls/7/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "carol" }, state: "CHANGES_REQUESTED", commit_id: HEAD }] });
42
+ expect(await changes.prApproved!(7, HEAD)).toEqual({ passed: false, reason: "carol asked for changes" });
43
+ });
44
+
45
+ test("every check fails when the forge's head is another commit", async () => {
46
+ const f = forge({ [`GET ${repo}/pulls/7`]: pull({ head: { ref: "f", sha: OTHER } }) });
47
+ const r = await f.prMergeable!(7, HEAD);
48
+ expect(r.passed).toBe(false);
49
+ expect(r.reason).toContain("head is cccccccc, not aaaaaaaa");
50
+ });
51
+
52
+ test("mergeable: conflicts and branch protection refuse, an unknown answer is pending", async () => {
53
+ expect((await forge({}).prMergeable!(7, HEAD)).passed).toBe(true);
54
+ expect((await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable: false, mergeable_state: "dirty" }) }).prMergeable!(7, HEAD)).reason).toContain("conflicts with main");
55
+ expect((await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable_state: "blocked" }) }).prMergeable!(7, HEAD)).reason).toContain("branch protection");
56
+ expect(await forge({ [`GET ${repo}/pulls/7`]: pull({ mergeable: null }) }).prMergeable!(7, HEAD)).toMatchObject({ passed: false, pending: true });
57
+ });
58
+
59
+ test("undiverged: reads behind_by from the compare of the base branch with the head", async () => {
60
+ expect((await forge({ [`GET ${repo}/compare/main...${HEAD}`]: { behind_by: 0 } }).prUndiverged!(7, HEAD)).passed).toBe(true);
61
+ expect((await forge({ [`GET ${repo}/compare/main...${HEAD}`]: { behind_by: 2 } }).prUndiverged!(7, HEAD)).reason).toBe("aaaaaaaa is 2 commits behind main");
62
+ });
63
+
64
+ test("checks: statuses and check runs, the apply's own left out", async () => {
65
+ const statuses = (state: string) => ({ statuses: [{ context: "ci/test", state }, { context: "chant/apply", state: "failure" }] });
66
+ const runs = (conclusion: string | null, status = "completed") => ({ check_runs: [{ name: "lint", status, conclusion }] });
67
+ const f = (state: string, run: unknown) => forge({ [`GET ${repo}/commits/${HEAD}/status`]: statuses(state), [`GET ${repo}/commits/${HEAD}/check-runs`]: run });
68
+ expect(await f("success", runs("success")).prChecksPassed!(7, HEAD, ["chant/apply"])).toEqual({ passed: true, reason: "2 checks passed on aaaaaaaa" });
69
+ expect((await f("success", runs("success")).prChecksPassed!(7, HEAD)).reason).toBe("chant/apply failed on aaaaaaaa");
70
+ expect(await f("pending", runs("success")).prChecksPassed!(7, HEAD, ["chant/apply"])).toMatchObject({ passed: false, pending: true });
71
+ expect((await f("success", runs(null, "in_progress")).prChecksPassed!(7, HEAD, ["chant/apply"])).pending).toBe(true);
72
+ expect((await f("success", runs("failure")).prChecksPassed!(7, HEAD, ["chant/apply"])).reason).toBe("lint failed on aaaaaaaa");
73
+ });
74
+
75
+ test("checks: nothing reported is pending", async () => {
76
+ const f = forge({ [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [] }, [`GET ${repo}/commits/${HEAD}/check-runs`]: { check_runs: [] } });
77
+ expect(await f.prChecksPassed!(7, HEAD)).toMatchObject({ passed: false, pending: true });
78
+ });
79
+ });
80
+
81
+ describe("forgejo", () => {
82
+ const repo = "/api/v1/repos/acme/infra";
83
+ const pull = (over: Record<string, unknown> = {}) => ({ number: 4, user: { login: "alice" }, head: { ref: "f", sha: HEAD }, base: { ref: "main" }, mergeable: true, merge_base: TIP, ...over });
84
+ const forge = (routes: Record<string, unknown>) =>
85
+ forgejoForge({ apiBase: "https://code.example/api/v1", repo: "acme/infra", host: "code.example", token: "t", fetch: fakeFetch({ [`GET ${repo}/pulls/4`]: pull(), [`GET ${repo}/pulls/4/commits`]: [], [`GET ${repo}/branches/main`]: { commit: { id: TIP } }, ...routes }) });
86
+
87
+ test("approved: Forgejo's REQUEST_CHANGES state refuses", async () => {
88
+ const f = forge({ [`GET ${repo}/pulls/4/reviews`]: [{ user: { login: "bob" }, state: "APPROVED", commit_id: HEAD }, { user: { login: "dave" }, state: "REQUEST_CHANGES" }] });
89
+ expect((await f.prApproved!(4, HEAD)).reason).toBe("dave asked for changes");
90
+ });
91
+
92
+ test("undiverged: the pull request's merge base is the base branch's tip", async () => {
93
+ expect((await forge({}).prUndiverged!(4, HEAD)).passed).toBe(true);
94
+ expect((await forge({ [`GET ${repo}/pulls/4`]: pull({ merge_base: OTHER }) }).prUndiverged!(4, HEAD)).reason).toBe("aaaaaaaa does not contain main at bbbbbbbb");
95
+ });
96
+
97
+ test("mergeable: no branch protection reading, and checks from the combined status alone", async () => {
98
+ expect((await forge({ [`GET ${repo}/pulls/4`]: pull({ mergeable_state: "blocked" }) }).prMergeable!(4, HEAD)).passed).toBe(true);
99
+ const f = forge({ [`GET ${repo}/commits/${HEAD}/status`]: { statuses: [{ context: "ci/test", status: "success" }] } });
100
+ expect(await f.prChecksPassed!(4, HEAD)).toEqual({ passed: true, reason: "1 check passed on aaaaaaaa" });
101
+ });
102
+ });
103
+
104
+ describe("gitlab", () => {
105
+ const project = "/api/v4/projects/9";
106
+ const mr = (over: Record<string, unknown> = {}) => ({ iid: 3, sha: HEAD, author: { username: "alice" }, target_branch: "main", detailed_merge_status: "mergeable", diverged_commits_count: 0, ...over });
107
+ const note = (who: string, body: string, at: string) => ({ system: true, author: { username: who }, body, created_at: at });
108
+ const forge = (routes: Record<string, unknown>) =>
109
+ gitlabForge({
110
+ apiBase: "https://gitlab.example/api/v4",
111
+ project: "9",
112
+ token: "t",
113
+ host: "gitlab.example",
114
+ fetch: fakeFetch({
115
+ [`GET ${project}/merge_requests/3`]: mr(),
116
+ [`GET ${project}/merge_requests/3/commits`]: [],
117
+ "GET /api/v4/user": { id: 1, username: "chant-bot" },
118
+ [`GET ${project}/merge_requests/3/versions`]: [{ created_at: "2026-10-01T10:00:00Z" }],
119
+ ...routes,
120
+ }),
121
+ });
122
+
123
+ test("approved: an approval after the latest push, not the token user's or the author's", async () => {
124
+ const f = forge({
125
+ [`GET ${project}/merge_requests/3/notes`]: [
126
+ note("erin", "approved this merge request", "2026-10-01T09:00:00Z"),
127
+ note("chant-bot", "approved this merge request", "2026-10-01T11:00:00Z"),
128
+ note("alice", "approved this merge request", "2026-10-01T11:00:00Z"),
129
+ ],
130
+ });
131
+ expect((await f.prApproved!(3, HEAD)).passed).toBe(false);
132
+ const g = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z")] });
133
+ expect(await g.prApproved!(3, HEAD)).toEqual({ passed: true, reason: "approved after aaaaaaaa by bob" });
134
+ const h = forge({ [`GET ${project}/merge_requests/3/notes`]: [note("bob", "approved this merge request", "2026-10-01T11:00:00Z"), note("carol", "requested changes", "2026-10-01T12:00:00Z")] });
135
+ expect((await h.prApproved!(3, HEAD)).reason).toBe("carol requested changes");
136
+ });
137
+
138
+ test("mergeable and undiverged read the merge request", async () => {
139
+ expect((await forge({}).prMergeable!(3, HEAD)).passed).toBe(true);
140
+ expect(await forge({ [`GET ${project}/merge_requests/3`]: mr({ detailed_merge_status: "checking" }) }).prMergeable!(3, HEAD)).toMatchObject({ passed: false, pending: true });
141
+ expect((await forge({ [`GET ${project}/merge_requests/3`]: mr({ has_conflicts: true, detailed_merge_status: "conflict" }) }).prMergeable!(3, HEAD)).reason).toContain("conflicts");
142
+ expect((await forge({}).prUndiverged!(3, HEAD)).passed).toBe(true);
143
+ expect((await forge({ [`GET ${project}/merge_requests/3`]: mr({ diverged_commits_count: 1 }) }).prUndiverged!(3, HEAD)).reason).toBe("aaaaaaaa is 1 commit behind main");
144
+ });
145
+
146
+ test("checks: each name's newest status, a job allowed to fail holding nothing back", async () => {
147
+ const f = forge({
148
+ [`GET ${project}/repository/commits/${HEAD}/statuses`]: [
149
+ { id: 1, name: "test", status: "failed" },
150
+ { id: 2, name: "test", status: "success" },
151
+ { id: 3, name: "flaky", status: "failed", allow_failure: true },
152
+ { id: 4, name: "apply", status: "running" },
153
+ ],
154
+ });
155
+ expect(await f.prChecksPassed!(3, HEAD, ["apply"])).toEqual({ passed: true, reason: "2 checks passed on aaaaaaaa" });
156
+ expect((await f.prChecksPassed!(3, HEAD)).pending).toBe(true);
157
+ });
158
+ });
159
+
160
+ describe("checkPrRequires", () => {
161
+ const ok = { passed: true, reason: "ok" };
162
+ test("asks each check once in order, and a pending one again until it settles", async () => {
163
+ let reads = 0;
164
+ const forge = {
165
+ kind: "github",
166
+ prApproved: async () => ok,
167
+ prMergeable: async () => (++reads < 3 ? { passed: false, pending: true, reason: "working it out" } : ok),
168
+ prUndiverged: async () => ok,
169
+ prChecksPassed: async (_pr: number, _head: string, ignore?: readonly string[]) => ({ passed: true, reason: `ignored ${ignore?.join(",")}` }),
170
+ } as unknown as PrForge;
171
+ const out = await checkPrRequires(forge, 7, HEAD, { ignore: ["chant/apply"], wait: async () => {} });
172
+ expect(out.map((o) => o.name)).toEqual([...PR_APPLY_REQUIRES]);
173
+ expect(reads).toBe(3);
174
+ expect(out[3]!.reason).toBe("ignored chant/apply");
175
+ expect(unmetRequires(out)).toBeNull();
176
+ });
177
+
178
+ test("a client without a method, or a call that throws, is unmet", async () => {
179
+ const forge = { kind: "gitlab", prApproved: async () => { throw new Error("401"); } } as unknown as PrForge;
180
+ const out = await checkPrRequires(forge, 3, HEAD, { reads: 1 });
181
+ expect(out.every((o) => !o.passed)).toBe(true);
182
+ expect(unmetRequires(out)).toBe("approved: could not ask gitlab: 401; mergeable: this gitlab client cannot say; undiverged: this gitlab client cannot say; checks: this gitlab client cannot say");
183
+ });
184
+ });
@@ -0,0 +1,79 @@
1
+ /**
2
+ * The requires checks of an apply before merge (#4301, ws-106): the pull
3
+ * request is approved, mergeable, undiverged and its checks passed, each
4
+ * asked of the forge through its {@link PrForge} method. All four must pass.
5
+ *
6
+ * The checks are terragucci's `apply.requires` (comment-apply.ts:781-860 at
7
+ * INTENTIUS/terragucci e6cb431), with no setting to drop one. A check the
8
+ * forge has not settled, such as GitHub working out whether a pull request
9
+ * can merge, is asked again a few times before it counts as unmet.
10
+ *
11
+ * Like ./pr-forge.ts this imports nothing beyond it, so a product bundles it
12
+ * without the TypeScript toolchain.
13
+ */
14
+ import type { PrForge, PrRequireResult } from "./pr-forge";
15
+
16
+ /** The four checks, in the order they run and the report lists them. */
17
+ export const PR_APPLY_REQUIRES = ["approved", "mergeable", "undiverged", "checks"] as const;
18
+
19
+ export type PrApplyRequire = (typeof PR_APPLY_REQUIRES)[number];
20
+
21
+ /** One check as the pull-request report's `beforeMerge.requires` holds it. */
22
+ export interface PrRequireOutcome extends PrRequireResult {
23
+ name: PrApplyRequire;
24
+ }
25
+
26
+ export interface PrRequiresOptions {
27
+ /** Status names the `checks` require leaves out: the apply's own. */
28
+ ignore?: readonly string[];
29
+ /** How many times a pending check is asked in all. Default 5. */
30
+ reads?: number;
31
+ /** The wait between reads. Default 3 seconds. */
32
+ wait?: (ms: number) => Promise<void>;
33
+ }
34
+
35
+ const READS = 5;
36
+ const WAIT_MS = 3000;
37
+
38
+ /**
39
+ * Ask `forge` each of the four checks for pull request `pr` at `head`. A
40
+ * forge client without a method, or a call that throws, gives an unmet
41
+ * check with the reason, so nothing applies on a check nobody made.
42
+ */
43
+ export async function checkPrRequires(forge: PrForge, pr: number, head: string, opts: PrRequiresOptions = {}): Promise<PrRequireOutcome[]> {
44
+ const wait = opts.wait ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
45
+ const reads = Math.max(1, opts.reads ?? READS);
46
+ const ask: Record<PrApplyRequire, (() => Promise<PrRequireResult>) | undefined> = {
47
+ approved: forge.prApproved ? () => forge.prApproved!(pr, head) : undefined,
48
+ mergeable: forge.prMergeable ? () => forge.prMergeable!(pr, head) : undefined,
49
+ undiverged: forge.prUndiverged ? () => forge.prUndiverged!(pr, head) : undefined,
50
+ checks: forge.prChecksPassed ? () => forge.prChecksPassed!(pr, head, opts.ignore ?? []) : undefined,
51
+ };
52
+ const out: PrRequireOutcome[] = [];
53
+ for (const name of PR_APPLY_REQUIRES) {
54
+ const call = ask[name];
55
+ if (!call) {
56
+ out.push({ name, passed: false, reason: `this ${forge.kind} client cannot say` });
57
+ continue;
58
+ }
59
+ let result: PrRequireResult;
60
+ for (let read = 1; ; read++) {
61
+ try {
62
+ result = await call();
63
+ } catch (err) {
64
+ result = { passed: false, reason: `could not ask ${forge.kind}: ${err instanceof Error ? err.message : String(err)}` };
65
+ }
66
+ if (result.passed || !result.pending || read >= reads) break;
67
+ await wait(WAIT_MS);
68
+ }
69
+ out.push({ name, ...result });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** The unmet checks as one line, or null when all four passed. */
75
+ export function unmetRequires(outcomes: readonly PrRequireOutcome[]): string | null {
76
+ const unmet = outcomes.filter((o) => !o.passed);
77
+ if (unmet.length === 0) return null;
78
+ return unmet.map((o) => `${o.name}: ${o.reason}`).join("; ");
79
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The review agent bundles alone (#4299): a product such as terragucci
3
+ * imports `@intentius/chant/op/activities/review-agent` into one bundled
4
+ * file, so it must not reach predict-behaviour's estate code. This bundles
5
+ * the module and reads esbuild's list of what went in.
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import { build } from "esbuild";
10
+ import { describe, expect, test } from "vitest";
11
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
12
+
13
+ describe("the review-agent bundle", () => {
14
+ test("holds the review agent, the note it posts through and nothing else", async () => {
15
+ const result = await build({
16
+ entryPoints: [join(REPO, "packages", "core", "src", "op", "activities", "review-agent.ts")],
17
+ bundle: true,
18
+ platform: "node",
19
+ format: "esm",
20
+ write: false,
21
+ metafile: true,
22
+ outdir: "out",
23
+ logLevel: "silent",
24
+ });
25
+ expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
26
+ "packages/core/src/op/activities/forge-pull-request.ts",
27
+ "packages/core/src/op/activities/gh-host.ts",
28
+ "packages/core/src/op/activities/pr-refs.ts",
29
+ "packages/core/src/op/activities/reconcile.ts",
30
+ "packages/core/src/op/activities/review-agent.ts",
31
+ "packages/core/src/pr-forge.ts",
32
+ ]);
33
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(96 * 1024);
34
+ });
35
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * The stage-hook runner bundles alone (#4288): a product that runs
3
+ * `ci.hooks` around its own stages imports
4
+ * `@intentius/chant/op/stage-hooks` into one bundled file, without the wave
5
+ * loop, the gate or the ledger. This bundles the module and reads esbuild's
6
+ * list of what went in.
7
+ */
8
+
9
+ import { join } from "node:path";
10
+ import { build } from "esbuild";
11
+ import { describe, expect, test } from "vitest";
12
+ import { REPO } from "./workspace/__fixtures__/contract-repo";
13
+
14
+ describe("the stage-hooks bundle", () => {
15
+ test("holds the hook runner and the Op waves spec, and no wave loop, gate or ledger", async () => {
16
+ const result = await build({
17
+ entryPoints: [join(REPO, "packages", "core", "src", "op", "stage-hooks.ts")],
18
+ bundle: true,
19
+ platform: "node",
20
+ format: "esm",
21
+ write: false,
22
+ metafile: true,
23
+ outdir: "out",
24
+ logLevel: "silent",
25
+ });
26
+ expect(Object.keys(result.metafile.inputs).map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual([
27
+ "packages/core/src/lifecycle/gate-seal.ts",
28
+ "packages/core/src/notify-verify.ts",
29
+ "packages/core/src/notify.ts",
30
+ "packages/core/src/op/gate-point.ts",
31
+ "packages/core/src/op/gate-quorum.ts",
32
+ "packages/core/src/op/op-waves.ts",
33
+ "packages/core/src/op/stage-hooks.ts",
34
+ "packages/core/src/wave-layout.ts",
35
+ ]);
36
+ expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(48 * 1024);
37
+ });
38
+ });
@@ -120,6 +120,15 @@ describe("ci.workflow (WSP151)", () => {
120
120
  expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": noRecord })))).toEqual([["ci-workflow-pipeline-missing", null]]);
121
121
  });
122
122
 
123
+ test("apply-before-merge needs a job that applies before merge, and the other workflows refuse one (#4301)", async () => {
124
+ const early = `${PIPELINE} apply:\n steps:\n - run: chant ci run apply --in-place --base "$BASE_SHA" --pr "$PR_NUMBER" --apply-before-merge --env prod --gate pr-apply --forge github\n`;
125
+ const decl = declaration({ ci: { workflow: "apply-before-merge", green: green() } });
126
+ expect(await found(repo(complete({ "chant.workspace.json": decl })))).toEqual([["ci-workflow-pipeline-differs", null]]);
127
+ expect(await found(repo(complete({ "chant.workspace.json": decl, ".github/workflows/chant-pr.yml": early })))).toEqual([]);
128
+ expect(await found(repo(complete({ ".github/workflows/chant-pr.yml": early })))).toEqual([["ci-workflow-pipeline-differs", null]]);
129
+ expect(workflowOf({ ci: { green: null, reportUrl: null, workflow: "apply-before-merge" } } as never, null)).toBe("apply-before-merge");
130
+ });
131
+
123
132
  test("a member's own workflow wins, and a top-level workflow skips members that don't build with chant", async () => {
124
133
  const root = repo({
125
134
  ...complete(),
@@ -14,7 +14,7 @@
14
14
 
15
15
  import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
16
16
  import type { CiWorkflow, Member } from "../declaration";
17
- import { appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
17
+ import { appliesBeforeMerge, appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
18
18
  import { matchesCheckRun } from "../ci-green";
19
19
  import { staticPromotionOrder } from "../promotion-order";
20
20
  import { joinPath } from "../tree";
@@ -27,6 +27,8 @@ const memberDir = (dir: string) => (dir === "." ? "" : dir);
27
27
  const PLAN = /\bci\s+run\s+plan\b|\bcomponents\s+pr-plan\b/;
28
28
  const RECORD = /\bci\s+run\s+record\b|\bcomponents\s+pr-record\b/;
29
29
  const APPLY = /\bci\s+run\s+apply\b|\bcomponents\s+pr-apply\b/;
30
+ /** An apply before merge (#4301): `pr-apply --before-merge`, or `ci run apply --apply-before-merge`. */
31
+ const BEFORE_MERGE = /--(?:apply-)?before-merge\b/;
30
32
  const TICK = /\bci\s+tick\b/;
31
33
  const PUBLISH = /\brelease\s+publish\b/;
32
34
 
@@ -113,16 +115,28 @@ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
113
115
  if (missing.length > 0) {
114
116
  finding("ci-workflow-pipeline-missing", `no pipeline file runs ${missing.join(", ")}; generate them with chant build --components --generate <forge> --pr-loop and chant ci workflow`);
115
117
  }
118
+ // #4301: an apply before merge is its own job, allowed only under apply-before-merge.
119
+ const beforeMerge = under.some((u) => appliesBeforeMerge(u.workflow)) || appliesBeforeMerge(d.ci?.workflow ?? null);
120
+ const earlyLines = commandLines(ctx, APPLY).filter((c) => BEFORE_MERGE.test(c.text));
121
+ if (!beforeMerge) {
122
+ for (const l of earlyLines) {
123
+ finding("ci-workflow-pipeline-differs", `${l.path} applies before merge, and no member's workflow is apply-before-merge; regenerate without --apply-before-merge, or set ci.workflow to apply-before-merge`, { treeFile: l.path, line: l.line });
124
+ }
125
+ } else if (earlyLines.length === 0 && has(PLAN)) {
126
+ const l = commandLines(ctx, PLAN)[0]!;
127
+ finding("ci-workflow-pipeline-differs", `${l.path} has no job that applies before merge, and the workflow is apply-before-merge; regenerate with --apply-before-merge`, { treeFile: l.path, line: l.line });
128
+ }
129
+ const mergeApply = (l: { text: string }) => !BEFORE_MERGE.test(l.text);
116
130
  if (!applyOnMerge) {
117
- for (const l of commandLines(ctx, APPLY)) {
131
+ for (const l of commandLines(ctx, APPLY).filter(mergeApply)) {
118
132
  finding("ci-workflow-pipeline-differs", `${l.path} applies on merge, and the workflow is trunk-curated, where a merge records the plan and deploys nothing; regenerate without --apply-on-merge, or set ci.workflow to trunk-curated+apply-on-merge`, { treeFile: l.path, line: l.line });
119
133
  }
120
- } else if (!has(APPLY) && has(RECORD)) {
134
+ } else if (!commandLines(ctx, APPLY).some(mergeApply) && has(RECORD)) {
121
135
  const l = commandLines(ctx, RECORD)[0]!;
122
136
  finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
123
137
  } else {
124
138
  // #4168 step 9: an apply measured from a push's base applies each merge; the workflow applies green commits.
125
- for (const l of commandLines(ctx, APPLY).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
139
+ for (const l of commandLines(ctx, APPLY).filter(mergeApply).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
126
140
  finding("ci-workflow-pipeline-differs", `${l.path} applies each merge, and under trunk-curated+apply-on-merge the apply applies the newest green commit; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
127
141
  }
128
142
  }
@@ -37,7 +37,7 @@ import { formatError, formatSuccess } from "../cli/format";
37
37
  import { parseYAMLDocument } from "../yaml";
38
38
  import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow";
39
39
  import type { WorkspaceDiagnostic } from "./checks";
40
- import { CI_WORKFLOWS, STUB_MARKER, appliesOnMerge } from "./ci-workflow";
40
+ import { CI_WORKFLOWS, STUB_MARKER, appliesBeforeMerge, appliesOnMerge } from "./ci-workflow";
41
41
 
42
42
  export { STUB_MARKER };
43
43
  import { CI_GREEN_WORKFLOW_FILE, jobCheckRuns, readWorkflowSources, workflowsHolding } from "./ci-green-workflow";
@@ -48,7 +48,7 @@ import { SECURITY_GROUPS, type SecurityGroup } from "./security";
48
48
  import { gitTop, workingTree, type WorkspaceTree } from "./tree";
49
49
  import { RELEASE_WORKFLOW_FORGES, type ReleaseWorkflowForge } from "./release-workflow";
50
50
 
51
- const USAGE = "chant ci init --workflow trunk-curated|trunk-curated+apply-on-merge [--forge github|gitlab|forgejo] [--security <group>]... [--yes]";
51
+ const USAGE = "chant ci init --workflow trunk-curated|trunk-curated+apply-on-merge|apply-before-merge [--forge github|gitlab|forgejo] [--security <group>]... [--yes]";
52
52
 
53
53
 
54
54
  /** The environments a config with none is given. */
@@ -280,11 +280,12 @@ export function proposeCiInit(root: string, options: CiInitOptions): CiInitPropo
280
280
  if (reported("ci-workflow-pipeline-missing") || reported("ci-workflow-pipeline-differs")) {
281
281
  for (const m of releasing) {
282
282
  const env = firstEnv.get(m.name);
283
+ const workflow = m.ciWorkflow ?? proposed.ci!.workflow!;
283
284
  commands.push({
284
285
  cwd: memberDir(m.dir) || ".",
285
286
  // The root member builds alone: at a workspace root, chant build refuses without --root-only (WSP000).
286
- args: ["build", "--components", "--generate", forge, "--pr-loop", ...(m.dir === "." ? ["--root-only"] : []), ...(env ? ["--env", env] : []), ...(appliesOnMerge(m.ciWorkflow ?? proposed.ci!.workflow!) ? ["--apply-on-merge"] : [])],
287
- why: `the pull-request pipeline for ${m.name}: plan on a pull request, ${appliesOnMerge(m.ciWorkflow ?? proposed.ci!.workflow!) ? "apply" : "record"} on merge`,
287
+ args: ["build", "--components", "--generate", forge, "--pr-loop", ...(m.dir === "." ? ["--root-only"] : []), ...(env ? ["--env", env] : []), ...(appliesOnMerge(workflow) ? ["--apply-on-merge"] : []), ...(appliesBeforeMerge(workflow) ? ["--apply-before-merge"] : [])],
288
+ why: `the pull-request pipeline for ${m.name}: plan on a pull request, ${appliesBeforeMerge(workflow) ? "apply on request before merge, record" : appliesOnMerge(workflow) ? "apply" : "record"} on merge`,
288
289
  });
289
290
  }
290
291
  if (forge === "github") {
@@ -1040,13 +1040,14 @@ export function ciLoopOptions(args: CommandContext["args"]): PrLoopPipelineOptio
1040
1040
  ...(args.prBuildCommand ? { buildCommand: args.prBuildCommand } : {}),
1041
1041
  ...(args.waveGate ? { waveGate: args.waveGate } : {}),
1042
1042
  ...(args.requireReview === true ? { requireReview: true } : {}),
1043
+ ...(args.applyBeforeMerge ? { applyBeforeMerge: true } : {}),
1043
1044
  };
1044
1045
  }
1045
1046
 
1046
1047
  const USAGE =
1047
1048
  "chant ci run plan|record|apply --env <env> --base <sha> [--pr <n>] [--commit <sha>] [--forge github|gitlab|forgejo] " +
1048
1049
  "[--gate <name>] [--wave-gate <name>] [--wave <k>] [--branch <name>] [--events-json] [--report-url <url>] [--run-index-limit <n>] [--image <image>] [--before-script <line>]... " +
1049
- "[--pr-build] [--pr-build-command <line>] [--require-review] [--no-status] [--status-url <url template>] " +
1050
+ "[--pr-build] [--pr-build-command <line>] [--require-review] [--apply-before-merge] [--no-status] [--status-url <url template>] " +
1050
1051
  "[--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [-o <dir>] [--in-place | --local] [--no-push] [--verbose] | chant ci run --at <sha> | chant ci run --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>]";
1051
1052
 
1052
1053
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * `ci.workflow` read at base (#4301, ws-106), as `ci.locks` is
3
+ * (./ci-locks.ts): the target branch's tip, so a pull request that sets
4
+ * `apply-before-merge` in its own copy of the declaration gets nothing from it.
5
+ */
6
+ import type { CiWorkflow } from "./declaration";
7
+ import { workflowOf } from "./ci-workflow";
8
+ import { memberAtCwd } from "./security";
9
+ import { scopeSource } from "./write-scope";
10
+
11
+ /**
12
+ * The workflow the member holding `cwd` follows, or `member` when given:
13
+ * its own `ci.workflow`, else the top level's, read at `at` when given, else
14
+ * at base, else from the working tree. Null outside a workspace and when
15
+ * neither sets one.
16
+ */
17
+ export function ciWorkflowAt(cwd: string, at?: string, member?: string): CiWorkflow | null {
18
+ const source = scopeSource(cwd, at);
19
+ const d = source.declaration;
20
+ if (!d) return null;
21
+ const name = member ?? memberAtCwd(source, cwd);
22
+ return workflowOf(d, d.members.find((m) => m.name === name) ?? null);
23
+ }
@@ -7,6 +7,8 @@
7
7
  * from a green commit and promoted through the member's environments, gated
8
8
  * in the last (4.2). `trunk-curated+apply-on-merge` is the same, and each
9
9
  * newest green commit is also applied (section 5, step 9).
10
+ * `apply-before-merge` (#4301, ws-106) is `trunk-curated`, and a person may
11
+ * also apply an open pull request's plan from its head before it merges.
10
12
  *
11
13
  * A workflow requires no security group: those are opt-in on their own
12
14
  * (./security.ts). `chant workspace check` reports what a member under a
@@ -19,13 +21,22 @@ import type { CiWorkflow, Declaration, Member } from "./declaration";
19
21
  /** The marker a component stub `chant ci init` writes carries until someone fills it in. WSP151 reports a member whose only components carry it. */
20
22
  export const STUB_MARKER = "TODO(chant ci init)";
21
23
 
22
- export const CI_WORKFLOWS = ["trunk-curated", "trunk-curated+apply-on-merge"] as const satisfies readonly CiWorkflow[];
24
+ export const CI_WORKFLOWS = ["trunk-curated", "trunk-curated+apply-on-merge", "apply-before-merge"] as const satisfies readonly CiWorkflow[];
23
25
 
24
26
  /** Whether `workflow` turns the apply on merge back on. */
25
27
  export function appliesOnMerge(workflow: CiWorkflow): boolean {
26
28
  return workflow === "trunk-curated+apply-on-merge";
27
29
  }
28
30
 
31
+ /**
32
+ * Whether `workflow` lets an open pull request's plan apply from its head
33
+ * before it merges (#4301, ws-106). Only `apply-before-merge` does, so with
34
+ * no workflow or either trunk-curated one nothing applies before merge.
35
+ */
36
+ export function appliesBeforeMerge(workflow: CiWorkflow | null): boolean {
37
+ return workflow === "apply-before-merge";
38
+ }
39
+
29
40
  /** The workflow `member` follows: its own `ci.workflow`, else the top level's, else null. */
30
41
  export function workflowOf(declaration: Pick<Declaration, "ci">, member: Pick<Member, "ciWorkflow"> | null): CiWorkflow | null {
31
42
  return member?.ciWorkflow ?? declaration.ci?.workflow ?? null;
@@ -56,7 +67,7 @@ export const CI_WORKFLOW_REQUIREMENTS = [
56
67
  { code: "ci-workflow-no-environments", requirement: "the member's `chant.config.ts` declares `environments`, the promotion order", fix: "`environments` in `chant.config.ts`", perMember: true },
57
68
  { code: "ci-workflow-ungated-env", requirement: "each component has a gate, so a promote to the last environment waits on an approval", fix: "a `gate()` step in the component", perMember: true },
58
69
  { code: "ci-workflow-pipeline-missing", requirement: "a pipeline file runs the plan job, the record job (`ci run record`) and `chant ci tick`", fix: "`chant build --components --generate <forge> --pr-loop` and `chant ci workflow`", perMember: false },
59
- { code: "ci-workflow-pipeline-differs", requirement: "the pipeline matches the workflow: no apply job under `trunk-curated`, and under `trunk-curated+apply-on-merge` an apply job that applies the newest green commit, not each merge", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge", perMember: false },
70
+ { code: "ci-workflow-pipeline-differs", requirement: "the pipeline matches the workflow: no apply job under `trunk-curated`, and under `trunk-curated+apply-on-merge` an apply job that applies the newest green commit, not each merge; a job that applies before merge only, and always, under `apply-before-merge`", fix: "regenerate the pull-request pipeline, with `--apply-on-merge` only under apply on merge and `--apply-before-merge` only under apply before merge", perMember: false },
60
71
  { code: "ci-workflow-publish-missing", requirement: "a pipeline file runs `chant release publish`", fix: "`chant release workflow`", perMember: false },
61
72
  ] as const satisfies readonly CiWorkflowRequirement[];
62
73
 
@@ -1718,8 +1718,8 @@
1718
1718
  "additionalProperties": false
1719
1719
  },
1720
1720
  "ciWorkflow": {
1721
- "enum": ["trunk-curated", "trunk-curated+apply-on-merge"],
1722
- "description": "A prescribed delivery workflow (#4168). trunk-curated: a merge records the plan and deploys nothing; releases are cut from green commits and promoted through each member's environments, gated in the last. trunk-curated+apply-on-merge: the same, with the apply on merge turned back on. chant workspace check reports what a member under one still lacks (WSP151), and chant ci init writes it. At the top level for every member, or on a member, which wins. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer."
1721
+ "enum": ["trunk-curated", "trunk-curated+apply-on-merge", "apply-before-merge"],
1722
+ "description": "A prescribed delivery workflow (#4168). trunk-curated: a merge records the plan and deploys nothing; releases are cut from green commits and promoted through each member's environments, gated in the last. trunk-curated+apply-on-merge: the same, with the apply on merge turned back on. apply-before-merge (#4301, ws-106): the same as trunk-curated, and a person may also apply an open pull request's plan from its head under its gate, once it is approved, mergeable, undiverged and its checks passed (chant components pr-apply --before-merge); added by chant 0.124.42, so a declaration that uses it sets minReader to 0.124.42 or newer. chant workspace check reports what a member under one still lacks (WSP151), and chant ci init writes it. At the top level for every member, or on a member, which wins. Added in schema 1 by chant 0.124.34, so a declaration that uses it sets minReader to 0.124.34 or newer."
1723
1723
  },
1724
1724
  "ciHooks": {
1725
1725
  "type": "object",
@@ -258,10 +258,13 @@ export type CiSource = "checks" | "runs";
258
258
  * A prescribed delivery workflow (#4168 section 7). `trunk-curated`: a merge
259
259
  * records the plan and deploys nothing, and releases are cut from green
260
260
  * commits and promoted through the environments. `trunk-curated+apply-on-merge`
261
- * adds the apply on merge as an opt-in. `chant workspace check` reports what a
262
- * member under one still lacks (./checks/ci-workflow.ts, WSP151).
261
+ * adds the apply on merge as an opt-in. `apply-before-merge` (#4301, ws-106)
262
+ * is `trunk-curated` with an open pull request's plan applied from its head
263
+ * on request, once it is approved, mergeable, undiverged and green. `chant
264
+ * workspace check` reports what a member under one still lacks
265
+ * (./checks/ci-workflow.ts, WSP151).
263
266
  */
264
- export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge";
267
+ export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge" | "apply-before-merge";
265
268
 
266
269
  /**
267
270
  * When a pull request takes its target locks (#4293, ../lifecycle/target-lock.ts).
@@ -8,7 +8,7 @@
8
8
  "properties": {
9
9
  "$schema": { "const": "https://intentius.io/chant/schemas/workspace/pr-report/v1/pr-report.schema.json" },
10
10
  "contract": { "const": 1 },
11
- "stage": { "enum": ["plan", "apply", "record"], "description": "plan: on the pull request. record (#4168, chant 0.124.33 and newer): on merge, the default; it plans the merged commit and writes the merge record, applying nothing. apply: on merge with --apply-on-merge." },
11
+ "stage": { "enum": ["plan", "apply", "record"], "description": "plan: on the pull request. record (#4168, chant 0.124.33 and newer): on merge, the default; it plans the merged commit and writes the merge record, applying nothing. apply: on merge with --apply-on-merge, or (#4301, chant 0.124.42 and newer) at an open pull request's head with --before-merge, when beforeMerge is set." },
12
12
  "pr": {
13
13
  "type": ["integer", "null"],
14
14
  "minimum": 1,
@@ -37,8 +37,8 @@
37
37
  "description": "planned: every member planned (plan stage). recorded (#4168, chant 0.124.33 and newer): every member planned on the merged commit and the merge record was written (record stage); approval says whether the plan is the approved one, and nothing applied. plan-failed: a member failed to plan, so nothing can apply. nothing: no component deploys what the change touches. applied: every member applied. failed: a member failed to apply and its dependents did not run. wave-applied (#4003, chant 0.124.32 and newer): with --wave-gate and --wave, the wave applied and a later wave is still to run. refused: nothing applied, see refusal. waiting (#3999): the approved plans applied except members held back after an upstream applied, which wait on an approval of the plan the last of rounds made."
38
38
  },
39
39
  "refusal": {
40
- "enum": ["not-approved", "plan-changed", "not-a-reviewer", "author-approved", "no-pull-request", "wave-not-approved", "wave-plan-changed", "earlier-wave", "locked", "policy-denied", "policy-error"],
41
- "description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review. author-approved (#4000): under --require-review, an approval names the pull request's author or an author or committer of one of its commits. With --wave-gate (#4003, chant 0.124.32 and newer): wave-not-approved, a wave's new plan is not covered by the approved one and no approval of its gate stands for it; wave-plan-changed, an approval of its gate stands for another digest; earlier-wave, an earlier wave has work left or no record says it applied. The waves that applied before a wave refused stay applied; waves says which. locked (#4293, chant 0.124.41 and newer): under ci.locks, another open pull request holds a lock on a target the apply reaches, and nothing applied. With ci.policy (#4294, chant 0.124.41 and newer): policy-denied, the policy at base denied the plan and no override counts; policy-error, the policy could not be run or gave no readable answer."
40
+ "enum": ["not-approved", "plan-changed", "not-a-reviewer", "author-approved", "no-pull-request", "wave-not-approved", "wave-plan-changed", "earlier-wave", "locked", "policy-denied", "policy-error", "requires-unmet", "before-merge-off"],
41
+ "description": "Why an apply refused. plan-changed: an approval stands for another digest, so the plan changed after review. author-approved (#4000): under --require-review, an approval names the pull request's author or an author or committer of one of its commits. With --wave-gate (#4003, chant 0.124.32 and newer): wave-not-approved, a wave's new plan is not covered by the approved one and no approval of its gate stands for it; wave-plan-changed, an approval of its gate stands for another digest; earlier-wave, an earlier wave has work left or no record says it applied. The waves that applied before a wave refused stay applied; waves says which. locked (#4293, chant 0.124.41 and newer): under ci.locks, another open pull request holds a lock on a target the apply reaches, and nothing applied. With ci.policy (#4294, chant 0.124.41 and newer): policy-denied, the policy at base denied the plan and no override counts; policy-error, the policy could not be run or gave no readable answer. With pr-apply --before-merge (#4301, chant 0.124.42 and newer): requires-unmet, the pull request is not approved, mergeable, undiverged or green, and beforeMerge says which; before-merge-off, the declaration at base does not set ci.workflow to apply-before-merge for the member. Nothing applied."
42
42
  },
43
43
  "message": { "type": "string", "description": "The refusal or failure as printed." },
44
44
  "resumed": {
@@ -207,6 +207,26 @@
207
207
  }
208
208
  }
209
209
  },
210
+ "beforeMerge": {
211
+ "type": "object",
212
+ "required": ["requires"],
213
+ "description": "Added within contract version 1 by #4301, written by chant 0.124.42 and newer. pr-apply --before-merge: the requires checks the forge answered before anything planned (ws-106). Absent on every other stage.",
214
+ "properties": {
215
+ "requires": {
216
+ "type": "array",
217
+ "items": {
218
+ "type": "object",
219
+ "required": ["name", "passed", "reason"],
220
+ "properties": {
221
+ "name": { "enum": ["approved", "mergeable", "undiverged", "checks"] },
222
+ "passed": { "type": "boolean" },
223
+ "pending": { "type": "boolean", "description": "The forge had not settled it when asked: a check still ran, or it had not worked out whether the pull request merges." },
224
+ "reason": { "type": "string" }
225
+ }
226
+ }
227
+ }
228
+ }
229
+ },
210
230
  "policy": {
211
231
  "type": "object",
212
232
  "description": "Added within contract version 1 by #4294, written by chant 0.124.41 and newer. What ci.policy in the workspace declaration at base said about the change set. Absent when the base sets no policy, or the stage reached no plan to judge.",
@@ -255,7 +255,7 @@ export const REASONS = {
255
255
  "ci-workflow-no-environments": "Under ci.workflow, a member's chant.config.ts declares no environments, so a release has nowhere to be promoted to.",
256
256
  "ci-workflow-ungated-env": "Under ci.workflow, a component has no gate, so a promote to the last environment waits on no approval.",
257
257
  "ci-workflow-pipeline-missing": "Under ci.workflow, no pipeline file runs the plan job, the merge job or chant ci tick.",
258
- "ci-workflow-pipeline-differs": "Under ci.workflow, the merge job does not match the workflow: it applies under trunk-curated, or records under trunk-curated+apply-on-merge.",
258
+ "ci-workflow-pipeline-differs": "Under ci.workflow, the pipeline does not match the workflow: it applies on merge under trunk-curated, records under trunk-curated+apply-on-merge, or applies before merge, or fails to, against apply-before-merge.",
259
259
  "ci-workflow-publish-missing": "Under ci.workflow, no pipeline file runs chant release publish.",
260
260
  // A box that holds a credential (check, #2726). Each is also a WSP finding.
261
261
  "box-credential-declared": "A file in a box member's directory carries a literal secret: a credential's shape, or a literal where a credential goes. A variable or secret-manager reference is not one.",