@intentius/chant 0.124.34 → 0.124.36

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 (171) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +23 -8
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +19 -1
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +17 -0
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote.d.ts +4 -1
  28. package/dist/components/promote.d.ts.map +1 -1
  29. package/dist/components/promote.js +6 -3
  30. package/dist/components/release-promote.d.ts +9 -0
  31. package/dist/components/release-promote.d.ts.map +1 -1
  32. package/dist/components/release-promote.js +15 -0
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/deep-observation.d.ts +11 -0
  36. package/dist/deep-observation.d.ts.map +1 -1
  37. package/dist/graph-declared.d.ts +5 -1
  38. package/dist/graph-declared.d.ts.map +1 -1
  39. package/dist/graph-declared.js +7 -2
  40. package/dist/graph-detail.d.ts.map +1 -1
  41. package/dist/graph-detail.js +30 -1
  42. package/dist/graph-ir.d.ts.map +1 -1
  43. package/dist/graph-ir.js +13 -2
  44. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts +5 -0
  45. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  46. package/dist/lifecycle/__fixtures__/publish-fakes.js +20 -2
  47. package/dist/lifecycle/compose-refs.d.ts +52 -0
  48. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  49. package/dist/lifecycle/compose-refs.js +115 -0
  50. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  51. package/dist/lifecycle/deep-observe.js +3 -2
  52. package/dist/lifecycle/green-apply.d.ts +39 -0
  53. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  54. package/dist/lifecycle/green-apply.js +51 -0
  55. package/dist/lifecycle/publish-declaration.d.ts +6 -0
  56. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  57. package/dist/lifecycle/publish-declaration.js +21 -0
  58. package/dist/lifecycle/publish-npm.d.ts +11 -4
  59. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  60. package/dist/lifecycle/publish-npm.js +104 -14
  61. package/dist/lifecycle/release-compose.d.ts +91 -0
  62. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  63. package/dist/lifecycle/release-compose.js +235 -0
  64. package/dist/lifecycle/release-cut.d.ts +8 -0
  65. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  66. package/dist/lifecycle/release-cut.js +2 -0
  67. package/dist/lifecycle/release-publish.d.ts +6 -0
  68. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  69. package/dist/lifecycle/release-publish.js +2 -1
  70. package/dist/lifecycle/release-store.d.ts +15 -0
  71. package/dist/lifecycle/release-store.d.ts.map +1 -1
  72. package/dist/lifecycle/release-store.js +5 -1
  73. package/dist/wave-runtime.mjs +225 -223
  74. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  75. package/dist/workspace/checks/ci-workflow.js +8 -1
  76. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  77. package/dist/workspace/ci-green-cli.js +5 -3
  78. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  79. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  80. package/dist/workspace/ci-green-workflow.js +27 -5
  81. package/dist/workspace/ci-green.d.ts +31 -0
  82. package/dist/workspace/ci-green.d.ts.map +1 -1
  83. package/dist/workspace/ci-green.js +78 -4
  84. package/dist/workspace/ci-run.d.ts +6 -0
  85. package/dist/workspace/ci-run.d.ts.map +1 -1
  86. package/dist/workspace/ci-run.js +2 -1
  87. package/dist/workspace/ci-serve.d.ts +94 -13
  88. package/dist/workspace/ci-serve.d.ts.map +1 -1
  89. package/dist/workspace/ci-serve.js +248 -30
  90. package/dist/workspace/ci-workflow.d.ts +4 -4
  91. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  92. package/dist/workspace/ci-workflow.js +2 -2
  93. package/dist/workspace/delivery-metrics.d.ts +3 -1
  94. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  95. package/dist/workspace/delivery-metrics.js +12 -3
  96. package/dist/workspace/member-commands.d.ts.map +1 -1
  97. package/dist/workspace/member-commands.js +4 -0
  98. package/dist/workspace/nested-graph.d.ts.map +1 -1
  99. package/dist/workspace/nested-graph.js +1 -0
  100. package/dist/workspace/release-candidates.d.ts +28 -0
  101. package/dist/workspace/release-candidates.d.ts.map +1 -1
  102. package/dist/workspace/release-candidates.js +38 -2
  103. package/dist/workspace/release-candidates.schema.json +51 -0
  104. package/dist/workspace/releases.schema.json +30 -0
  105. package/package.json +1 -1
  106. package/src/cli/handlers/graph.ts +2 -2
  107. package/src/cli/handlers/pr-green.test.ts +230 -0
  108. package/src/cli/handlers/pr.ts +148 -66
  109. package/src/cli/handlers/promote.test.ts +34 -0
  110. package/src/cli/handlers/promote.ts +22 -8
  111. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  112. package/src/cli/handlers/release-publish-targets.e2e.test.ts +4 -1
  113. package/src/cli/handlers/release.ts +112 -10
  114. package/src/cli/handlers/run.ts +12 -1
  115. package/src/cli/main.ts +19 -1
  116. package/src/cli/parse-args.ts +13 -0
  117. package/src/cli/registry.ts +14 -0
  118. package/src/codegen/release-wiring.test.ts +56 -9
  119. package/src/components/auto-release.test.ts +22 -0
  120. package/src/components/auto-release.ts +9 -2
  121. package/src/components/green-cover.test.ts +170 -0
  122. package/src/components/green-cover.ts +186 -0
  123. package/src/components/pr-pipeline.test.ts +14 -4
  124. package/src/components/pr-pipeline.ts +56 -5
  125. package/src/components/promote.test.ts +7 -0
  126. package/src/components/promote.ts +9 -4
  127. package/src/components/release-promote.test.ts +24 -1
  128. package/src/components/release-promote.ts +15 -0
  129. package/src/deep-observation.ts +8 -0
  130. package/src/graph-declared.test.ts +18 -0
  131. package/src/graph-declared.ts +8 -1
  132. package/src/graph-detail.test.ts +68 -1
  133. package/src/graph-detail.ts +29 -1
  134. package/src/graph-ir.ts +12 -4
  135. package/src/lifecycle/__fixtures__/publish-fakes.ts +20 -2
  136. package/src/lifecycle/compose-refs.ts +125 -0
  137. package/src/lifecycle/deep-observe.test.ts +20 -0
  138. package/src/lifecycle/deep-observe.ts +4 -2
  139. package/src/lifecycle/green-apply.ts +71 -0
  140. package/src/lifecycle/publish-declaration.ts +21 -0
  141. package/src/lifecycle/publish-npm.ts +100 -14
  142. package/src/lifecycle/publish-targets.test.ts +108 -0
  143. package/src/lifecycle/release-compose.test.ts +125 -0
  144. package/src/lifecycle/release-compose.ts +312 -0
  145. package/src/lifecycle/release-cut.ts +6 -0
  146. package/src/lifecycle/release-publish.ts +8 -1
  147. package/src/lifecycle/release-store.ts +16 -1
  148. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  149. package/src/workspace/checks/ci-workflow.ts +7 -1
  150. package/src/workspace/ci-green-cli.ts +5 -3
  151. package/src/workspace/ci-green-workflow.test.ts +52 -0
  152. package/src/workspace/ci-green-workflow.ts +34 -5
  153. package/src/workspace/ci-green.test.ts +33 -0
  154. package/src/workspace/ci-green.ts +85 -4
  155. package/src/workspace/ci-run.test.ts +18 -13
  156. package/src/workspace/ci-run.ts +8 -1
  157. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  158. package/src/workspace/ci-serve-land.test.ts +176 -0
  159. package/src/workspace/ci-serve.test.ts +77 -43
  160. package/src/workspace/ci-serve.ts +268 -34
  161. package/src/workspace/ci-workflow.ts +4 -4
  162. package/src/workspace/delivery-metrics.ts +12 -3
  163. package/src/workspace/graph-contract.test.ts +3 -2
  164. package/src/workspace/member-commands.test.ts +11 -4
  165. package/src/workspace/member-commands.ts +3 -0
  166. package/src/workspace/nested-graph.test.ts +4 -0
  167. package/src/workspace/nested-graph.ts +1 -0
  168. package/src/workspace/release-candidates.schema.json +51 -0
  169. package/src/workspace/release-candidates.test.ts +30 -3
  170. package/src/workspace/release-candidates.ts +54 -2
  171. package/src/workspace/releases.schema.json +30 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * #4168 4.4 — `ci serve --land` fast-forwards the target to a pushed
3
+ * `chant/land/<name>` branch once its pre-merge phases pass on it, and
4
+ * #4016 gap 6 — `ci serve --forge git` reads a plain git server's refs.
5
+ *
6
+ * Each test works in its own clone with a bare origin. The build and the
7
+ * judge are fakes; `ci-serve-land.e2e.test.ts` runs the real ones.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { cleanScratch, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
13
+ import { ciServeLands, formatCiServeEvent, gitCiServeForge, newCiServeRuntime, readCiServeState, type CiServeEvent, type CiServeOptions } from "./ci-serve";
14
+ import type { CommitVerdict } from "./ci-green";
15
+
16
+ afterAll(cleanScratch);
17
+
18
+ function commit(root: string, file: string): string {
19
+ writeFiles(root, { [file]: `${file}\n` });
20
+ git(root, "add", "-A");
21
+ git(root, "commit", "-q", "-m", file);
22
+ return git(root, "rev-parse", "HEAD");
23
+ }
24
+
25
+ /** A clone on main with a bare origin. */
26
+ function clone(): { root: string; origin: string; main: string } {
27
+ const origin = scratchDir("chant-serve-land-origin-");
28
+ git(origin, "init", "-q", "--bare");
29
+ const root = scratchDir("chant-serve-land-");
30
+ git(root, "init", "-q");
31
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
32
+ git(root, "config", "user.name", "t");
33
+ git(root, "config", "user.email", "t@example.com");
34
+ git(root, "remote", "add", "origin", origin);
35
+ const main = commit(root, "a.txt");
36
+ git(root, "push", "-q", "origin", "main");
37
+ return { root, origin, main };
38
+ }
39
+
40
+ /** A commit on top of `from`, pushed as `chant/land/<name>`. */
41
+ function pushLand(root: string, name: string, from: string, file: string): string {
42
+ git(root, "checkout", "-q", "--detach", from);
43
+ const sha = commit(root, file);
44
+ git(root, "push", "-q", "--force", "origin", `${sha}:refs/heads/chant/land/${name}`);
45
+ git(root, "checkout", "-q", "main");
46
+ return sha;
47
+ }
48
+
49
+ const verdict = (phases: Record<string, "pass" | "fail" | "pending">): CommitVerdict => {
50
+ const list = Object.entries(phases).map(([phase, v]) => ({ phase, verdict: v, runs: [], unmatched: [] }));
51
+ return { verdict: list.some((p) => p.verdict === "fail") ? "fail" : list.some((p) => p.verdict === "pending") ? "pending" : "pass", phases: list, failure: null };
52
+ };
53
+
54
+ function options(root: string, log: CiServeEvent[], extra: Partial<CiServeOptions> = {}): CiServeOptions & { built: { commit: string; phases: string[]; reason: string }[] } {
55
+ const built: { commit: string; phases: string[]; reason: string }[] = [];
56
+ return {
57
+ cwd: root,
58
+ forge: "git",
59
+ repo: "origin",
60
+ env: "prod",
61
+ client: gitCiServeForge(root),
62
+ stateDir: join(root, ".git", "serve"),
63
+ buildCommand: "true",
64
+ build: async ({ request }) => (built.push({ ...request }), 0),
65
+ land: { phases: ["check"] },
66
+ judgeLand: async () => verdict({ check: "pass" }),
67
+ log: (e) => log.push(e),
68
+ built,
69
+ ...extra,
70
+ };
71
+ }
72
+
73
+ const remoteHead = (origin: string, ref: string): string | undefined => {
74
+ try {
75
+ return git(origin, "rev-parse", "--verify", "--quiet", ref);
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ };
80
+
81
+ describe("the git forge kind (#4016 gap 6)", () => {
82
+ test("reads the branch head through ls-remote, lists no pull requests, and says when the branch is missing", async () => {
83
+ const { root, main } = clone();
84
+ const forge = gitCiServeForge(root);
85
+ expect(await forge.branchHead("main")).toBe(main);
86
+ expect(await forge.branchHead("nope")).toBeUndefined();
87
+ expect(await forge.openPullRequests("main")).toEqual([]);
88
+ expect(forge.pullRequestFor).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe("ci serve --land (#4168 4.4)", () => {
93
+ test("a land whose pre-merge phases pass moves the target to it and deletes the land branch, once", async () => {
94
+ const { root, origin, main } = clone();
95
+ const head = pushLand(root, "fix", main, "b.txt");
96
+ const log: CiServeEvent[] = [];
97
+ const opts = options(root, log);
98
+ const rt = newCiServeRuntime();
99
+ const pass = await ciServeLands(opts, rt);
100
+ await Promise.all(pass.started);
101
+
102
+ expect(opts.built).toEqual([{ commit: head, phases: ["check"], reason: "land" }]);
103
+ expect(remoteHead(origin, "refs/heads/main")).toBe(head);
104
+ expect(remoteHead(origin, "refs/heads/chant/land/fix")).toBeUndefined();
105
+ const state = readCiServeState(join(root, ".git", "serve", "chant-apply-prod.json"), { forge: "git", repo: "origin", env: "prod", branch: "main" });
106
+ expect(state.lands?.[`land:fix:${head}`]).toMatchObject({ branch: "chant/land/fix", commit: head, status: "landed" });
107
+ expect(log.map(formatCiServeEvent)).toContain(`chant/land/fix at ${head.slice(0, 12)} landed, and the land branch was deleted`);
108
+
109
+ // Nothing is left to serve.
110
+ expect((await ciServeLands(opts, rt)).started).toEqual([]);
111
+ });
112
+
113
+ test("a red pre-merge phase refuses the land and leaves the target and the land branch", async () => {
114
+ const { root, origin, main } = clone();
115
+ const head = pushLand(root, "red", main, "b.txt");
116
+ const log: CiServeEvent[] = [];
117
+ const opts = options(root, log, { judgeLand: async () => verdict({ check: "fail" }) });
118
+ await Promise.all((await ciServeLands(opts, newCiServeRuntime())).started);
119
+
120
+ expect(remoteHead(origin, "refs/heads/main")).toBe(main);
121
+ expect(remoteHead(origin, "refs/heads/chant/land/red")).toBe(head);
122
+ expect(log.map(formatCiServeEvent)).toContain(`chant/land/red at ${head.slice(0, 12)} was refused and left on the remote: the pre-merge phase check failed`);
123
+ });
124
+
125
+ test("a phase with no finished run, or a build that fails, refuses the land", async () => {
126
+ const { root, origin, main } = clone();
127
+ const a = pushLand(root, "norun", main, "b.txt");
128
+ const b = pushLand(root, "broken", main, "c.txt");
129
+ const log: CiServeEvent[] = [];
130
+ const opts = options(root, log, {
131
+ maxJobs: 2,
132
+ judgeLand: async () => verdict({ check: "pending" }),
133
+ build: async ({ request }) => (request.commit === b ? 3 : 0),
134
+ });
135
+ await Promise.all((await ciServeLands(opts, newCiServeRuntime())).started);
136
+
137
+ expect(remoteHead(origin, "refs/heads/main")).toBe(main);
138
+ const lines = log.map(formatCiServeEvent);
139
+ expect(lines).toContain(`chant/land/norun at ${a.slice(0, 12)} was refused and left on the remote: the build recorded no finished run for check`);
140
+ expect(lines).toContain(`chant/land/broken at ${b.slice(0, 12)} was refused and left on the remote: the build of check exited 3`);
141
+ });
142
+
143
+ test("a land that does not descend from the target is refused without a build, and a new push to it is tried again", async () => {
144
+ const { root, origin, main } = clone();
145
+ const stale = pushLand(root, "old", main, "b.txt");
146
+ const moved = commit(root, "c.txt");
147
+ git(root, "push", "-q", "origin", "main");
148
+ const log: CiServeEvent[] = [];
149
+ const opts = options(root, log);
150
+ const rt = newCiServeRuntime();
151
+ await Promise.all((await ciServeLands(opts, rt)).started);
152
+
153
+ expect(opts.built).toEqual([]);
154
+ expect(remoteHead(origin, "refs/heads/main")).toBe(moved);
155
+ expect(log.map(formatCiServeEvent)).toContain(
156
+ `chant/land/old at ${stale.slice(0, 12)} was refused and left on the remote: ${stale.slice(0, 12)} does not descend from main at ${moved.slice(0, 12)}; rebase chant/land/old onto it and push again`,
157
+ );
158
+ // The same head is not tried twice.
159
+ expect((await ciServeLands(opts, rt)).started).toEqual([]);
160
+
161
+ // Rebased and pushed again, it lands.
162
+ const rebased = pushLand(root, "old", moved, "b.txt");
163
+ await Promise.all((await ciServeLands(opts, rt)).started);
164
+ expect(remoteHead(origin, "refs/heads/main")).toBe(rebased);
165
+ });
166
+
167
+ test("lands wait for capacity", async () => {
168
+ const { root, main } = clone();
169
+ const head = pushLand(root, "fix", main, "b.txt");
170
+ const log: CiServeEvent[] = [];
171
+ const rt = newCiServeRuntime();
172
+ rt.inflight.set("busy", new Promise(() => {}));
173
+ expect((await ciServeLands(options(root, log), rt)).started).toEqual([]);
174
+ expect(log.map(formatCiServeEvent)).toContain(`chant/land/fix at ${head.slice(0, 12)} waits: --max-jobs jobs run`);
175
+ });
176
+ });
@@ -143,8 +143,8 @@ function serve(app: string, client: CiServeForge, extra: Partial<CiServeOptions>
143
143
  client,
144
144
  stateDir: state,
145
145
  holder: "serve-test",
146
- // Most tests here cover the apply on merge, which #4168 made opt-in; the record tests pass false.
147
- applyOnMerge: true,
146
+ // #4168: a merge gets the record job; the apply-on-merge tests pass true and tag a commit green.
147
+ applyOnMerge: false,
148
148
  // A job image of its own, so no setup line downloads OpenTofu.
149
149
  job: { image: "chant-ci-serve-test:local", chant: fakeChant(), hostEnv: env, passEnv: Object.keys(hostEnv ?? {}) },
150
150
  log: (e) => events.push(e),
@@ -155,6 +155,23 @@ function serve(app: string, client: CiServeForge, extra: Partial<CiServeOptions>
155
155
 
156
156
  const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
157
157
 
158
+ /** A workspace that declares `ci.green`, so a `ci/green/<sha>` tag counts (#4168 step 9), with that commit pushed as main. */
159
+ function greenWorkspace(): ReturnType<typeof workspace> {
160
+ const w = workspace();
161
+ writeFiles(w.root, {
162
+ "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: { branch: "main", phases: { test: ["test"] }, require: ["test"], source: "runs" } } }),
163
+ });
164
+ const main = commit(w.root, "app/green.txt", "declare ci.green");
165
+ git(w.root, "push", "-q", "origin", "main");
166
+ return { ...w, main };
167
+ }
168
+
169
+ /** Tag `sha` green on the forge, as the tick does. */
170
+ function tagGreen(root: string, sha: string): void {
171
+ git(root, "tag", "-f", `ci/green/${sha}`, sha);
172
+ git(root, "push", "-q", "-f", "origin", `refs/tags/ci/green/${sha}`);
173
+ }
174
+
158
175
  /** A commit on no branch, with `parent` when given, as a ledger commit stands in. */
159
176
  const ledgerCommit = (root: string, message: string, parent?: string): string => git(root, "commit-tree", EMPTY_TREE, ...(parent ? ["-p", parent] : []), "-m", message);
160
177
 
@@ -183,7 +200,7 @@ describe("ci serve records each merge unless apply on merge is opted in (#4168)"
183
200
  test("a merge to the target branch gets the record job, measured from the head before it, and no resume record", async () => {
184
201
  const { root, app, main } = workspace();
185
202
  const forge = fakeForge(main);
186
- const { opts, events, state } = serve(app, forge, { applyOnMerge: false });
203
+ const { opts, events, state } = serve(app, forge);
187
204
  await ciServePass(opts);
188
205
  const merged = commit(root, "app/merged.txt");
189
206
  git(root, "push", "-q", "origin", "main");
@@ -209,7 +226,6 @@ describe("ci serve records each merge unless apply on merge is opted in (#4168)"
209
226
  const ticks: string[] = [];
210
227
  let fail = false;
211
228
  const { opts, events } = serve(app, forge, {
212
- applyOnMerge: false,
213
229
  tick: async () => {
214
230
  ticks.push("tick");
215
231
  if (fail) throw new Error("forge said no");
@@ -267,28 +283,49 @@ describe("chant ci serve (#4095)", () => {
267
283
  expect(forge.polls).toBe(2);
268
284
  });
269
285
 
270
- test("a merge to the target branch applies the new commit, measured from the head before it", async () => {
271
- const { root, app, main } = workspace();
286
+ test("under apply on merge a merge gets the record job, and the newest green commit the apply, once (#4168 step 9)", async () => {
287
+ const { root, app, main } = greenWorkspace();
272
288
  const forge = fakeForge(main);
273
- const { opts, events } = serve(app, forge);
289
+ const { opts, events } = serve(app, forge, { applyOnMerge: true });
274
290
  expect((await ciServePass(opts)).jobs).toEqual([]);
275
291
  expect(ofType(events, "baseline")).toHaveLength(1);
276
292
 
277
293
  const merged = commit(root, "app/merged.txt");
278
294
  git(root, "push", "-q", "origin", "main");
279
295
  forge.head = merged;
296
+ // Not green yet: the merge is recorded, and nothing applies.
297
+ const recorded = await ciServePass(opts);
298
+ expect(recorded.jobs).toEqual([expect.objectContaining({ job: "record", commit: merged, base: main, status: "ok" })]);
299
+
300
+ tagGreen(root, merged);
280
301
  const pass = await ciServePass(opts);
281
- expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: main, status: "ok" })]);
302
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, status: "ok" })]);
303
+ expect(pass.jobs[0]!.pr).toBeUndefined();
282
304
  const argv = argsOf(pass.jobs[0]!.report!, "pr-apply");
283
- expect(argv.slice(0, 4)).toEqual(["components", "pr-apply", "--base", main]);
305
+ expect(argv.slice(0, 3)).toEqual(["components", "pr-apply", "--green"]);
306
+ expect(argv).not.toContain("--base");
284
307
  expect(argv.slice(-2)).toEqual(["--resume", ".chant/pr-resume/pr-apply.json"]);
285
308
  // The run was recorded on chant/lifecycle and pushed.
286
309
  expect(git(root, "ls-remote", "origin", "chant/lifecycle")).toContain(git(root, "rev-parse", "chant/lifecycle"));
287
-
310
+ // The green commit is applied once, and the branch head the serve tracks stays the merge's.
288
311
  expect((await ciServePass(opts)).jobs).toEqual([]);
312
+ const saved = readCiServeState(join(opts.stateDir!, `${prApplyGroup("prod", "app")}.json`), { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" });
313
+ expect(saved.target).toBe(merged);
289
314
  });
290
315
 
291
- test("an apply gets the number of the pull request whose merge commit it is (#4112)", async () => {
316
+ test("without ci.green apply on merge has nothing to apply, and the merge is recorded", async () => {
317
+ const { root, app, main } = workspace();
318
+ const forge = fakeForge(main);
319
+ const { opts } = serve(app, forge, { applyOnMerge: true });
320
+ await ciServePass(opts);
321
+ const merged = commit(root, "app/merged.txt");
322
+ git(root, "push", "-q", "origin", "main");
323
+ forge.head = merged;
324
+ tagGreen(root, merged);
325
+ expect((await ciServePass(opts)).jobs).toEqual([expect.objectContaining({ job: "record", commit: merged, status: "ok" })]);
326
+ });
327
+
328
+ test("a record gets the number of the pull request whose merge commit it is (#4112)", async () => {
292
329
  const { root, app, main } = workspace();
293
330
  const forge = fakeForge(main);
294
331
  const seen: Array<number | undefined> = [];
@@ -301,7 +338,7 @@ describe("chant ci serve (#4095)", () => {
301
338
 
302
339
  const pass = await ciServePass(opts);
303
340
  expect(forge.asked).toEqual([merged]);
304
- expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, pr: 17, status: "ok" })]);
341
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", commit: merged, pr: 17, status: "ok" })]);
305
342
  expect(seen).toEqual([17]);
306
343
  });
307
344
 
@@ -315,25 +352,25 @@ describe("chant ci serve (#4095)", () => {
315
352
  return { root, forge, seen, opts };
316
353
  }
317
354
 
318
- test("an apply with no pull request found runs without --pr (#4112)", async () => {
355
+ test("a record with no pull request found runs without --pr (#4112)", async () => {
319
356
  const { root, forge, seen, opts } = await mergeWithoutPr();
320
357
  const direct = commit(root, "app/direct.txt");
321
358
  git(root, "push", "-q", "origin", "main");
322
359
  forge.head = direct;
323
360
  const pass = await ciServePass(opts);
324
- expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: direct, status: "ok" })]);
361
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", commit: direct, status: "ok" })]);
325
362
  expect(pass.jobs[0]!.pr).toBeUndefined();
326
363
  expect(seen).toEqual([undefined]);
327
364
  });
328
365
 
329
- test("an apply whose pull request lookup fails runs without --pr (#4112)", async () => {
366
+ test("a record whose pull request lookup fails runs without --pr (#4112)", async () => {
330
367
  const { root, forge, seen, opts } = await mergeWithoutPr();
331
368
  const broken = commit(root, "app/broken.txt");
332
369
  git(root, "push", "-q", "origin", "main");
333
370
  forge.head = broken;
334
371
  forge.merges.set(broken, "throw");
335
372
  const pass = await ciServePass(opts);
336
- expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: broken, status: "ok" })]);
373
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", commit: broken, status: "ok" })]);
337
374
  expect(pass.jobs[0]!.pr).toBeUndefined();
338
375
  expect(seen).toEqual([undefined]);
339
376
  });
@@ -353,13 +390,12 @@ describe("chant ci serve (#4095)", () => {
353
390
  });
354
391
 
355
392
  test("the group's lease held elsewhere holds the apply until it is released", async () => {
356
- const { root, app, main } = workspace();
393
+ const { root, app, main } = greenWorkspace();
357
394
  const forge = fakeForge(main);
358
- const { opts, events } = serve(app, forge);
359
- await ciServePass(opts);
360
- const merged = commit(root, "app/merged.txt");
361
- git(root, "push", "-q", "origin", "main");
362
- forge.head = merged;
395
+ // The branch head is the green commit from the first poll, so only the apply is due.
396
+ const merged = main;
397
+ tagGreen(root, merged);
398
+ const { opts, events } = serve(app, forge, { applyOnMerge: true });
363
399
 
364
400
  const group = prApplyGroup("prod", "app");
365
401
  const other = await acquireLease(group, "other-host", { cwd: app, mode: "claim", requirePush: true });
@@ -371,7 +407,7 @@ describe("chant ci serve (#4095)", () => {
371
407
 
372
408
  expect(await releaseLease(group, "other-host", other.lease!.token, { cwd: app })).toBe(true);
373
409
  const ran = await ciServePass(opts);
374
- expect(ran.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: main, status: "ok" })]);
410
+ expect(ran.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, status: "ok" })]);
375
411
  // The serve released its own lease after the apply.
376
412
  expect((await acquireLease(group, "after", { cwd: app, mode: "claim" })).acquired).toBe(true);
377
413
  });
@@ -387,13 +423,11 @@ describe("chant ci serve (#4095)", () => {
387
423
  let record: string;
388
424
 
389
425
  beforeAll(async () => {
390
- w = workspace();
426
+ w = greenWorkspace();
391
427
  forge = fakeForge(w.main);
392
- failing = serve(w.app, forge, { hostEnv: { FAKE_EXIT: "3", FAKE_ATTEMPT: "1" } });
393
- await ciServePass(failing.opts);
394
- merged = commit(w.root, "app/merged.txt");
395
- git(w.root, "push", "-q", "origin", "main");
396
- forge.head = merged;
428
+ merged = w.main;
429
+ tagGreen(w.root, merged);
430
+ failing = serve(w.app, forge, { applyOnMerge: true, hostEnv: { FAKE_EXIT: "3", FAKE_ATTEMPT: "1" } });
397
431
  failed = await ciServePass(failing.opts);
398
432
  record = join(failing.state, "resume", `${prApplyRecordKey("prod", merged, "app")}.json`);
399
433
  });
@@ -408,9 +442,9 @@ describe("chant ci serve (#4095)", () => {
408
442
  });
409
443
 
410
444
  test("--rerun-failed runs it again with its resume record", async () => {
411
- const retry = serve(w.app, forge, { stateDir: failing.state, rerunFailed: true, hostEnv: { FAKE_EXIT: "0", FAKE_ATTEMPT: "2" } });
445
+ const retry = serve(w.app, forge, { applyOnMerge: true, stateDir: failing.state, rerunFailed: true, hostEnv: { FAKE_EXIT: "0", FAKE_ATTEMPT: "2" } });
412
446
  const again = await ciServePass(retry.opts);
413
- expect(again.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, base: w.main, status: "ok", attempts: 2 })]);
447
+ expect(again.jobs).toEqual([expect.objectContaining({ job: "apply", commit: merged, status: "ok", attempts: 2 })]);
414
448
  expect(ofType(retry.events, "started")[0]).toMatchObject({ attempt: 2, resume: record });
415
449
  // The job read the first attempt's record, and left its own.
416
450
  expect(JSON.parse(readFileSync(join(again.jobs[0]!.report!, "resume-in.json"), "utf-8"))).toEqual({ attempt: "1" });
@@ -450,7 +484,7 @@ describe("chant ci serve (#4095)", () => {
450
484
  });
451
485
 
452
486
  describe("ci serve follows the forge's chant/lifecycle (#4121)", () => {
453
- /** A serve past its baseline pass, with one merge waiting to apply, and each run's result in `results`. */
487
+ /** A serve past its baseline pass, with one merge waiting to be recorded, and each run's result in `results`. */
454
488
  async function mergeWaiting() {
455
489
  const w = workspace();
456
490
  const seed = seedLedger(w.root);
@@ -464,14 +498,14 @@ describe("ci serve follows the forge's chant/lifecycle (#4121)", () => {
464
498
  return { ...w, seed, opts, events, results };
465
499
  }
466
500
 
467
- test("a ledger the forge deleted is deleted here, and the next apply runs clean", async () => {
501
+ test("a ledger the forge deleted is deleted here, and the next job runs clean", async () => {
468
502
  const { root, origin, seed, opts, events, results } = await mergeWaiting();
469
503
  git(origin, "update-ref", "-d", "refs/heads/chant/lifecycle");
470
504
 
471
505
  const pass = await ciServePass(opts);
472
506
  expect(ofType(events, "ledger")).toEqual([{ type: "ledger", action: "deleted", local: seed }]);
473
507
  expect(formatCiServeEvent(ofType(events, "ledger")[0]!)).toContain("was deleted");
474
- expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
508
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", status: "ok" })]);
475
509
  expect(results[0]).toMatchObject({ pushed: true });
476
510
  const tip = git(origin, "rev-parse", "refs/heads/chant/lifecycle");
477
511
  expect(git(root, "rev-parse", "chant/lifecycle")).toBe(tip);
@@ -486,28 +520,28 @@ describe("ci serve follows the forge's chant/lifecycle (#4121)", () => {
486
520
 
487
521
  const pass = await ciServePass(opts);
488
522
  expect(ofType(events, "ledger")).toEqual([{ type: "ledger", action: "reset", local: seed, forge: rewritten }]);
489
- expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
523
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", status: "ok" })]);
490
524
  expect(results[0]).toMatchObject({ pushed: true });
491
525
  const tip = git(origin, "rev-parse", "refs/heads/chant/lifecycle");
492
526
  expect(isAncestor(origin, rewritten, tip)).toBe(true);
493
527
  expect(isAncestor(root, seed, tip)).toBe(false);
494
528
  });
495
529
 
496
- test("a ledger ahead of the forge's keeps its facts, and the apply pushes them", async () => {
530
+ test("a ledger ahead of the forge's keeps its facts, and the job pushes them", async () => {
497
531
  const { root, origin, seed, opts, events, results } = await mergeWaiting();
498
532
  const ahead = ledgerCommit(root, "unpushed fact", seed);
499
533
  git(root, "update-ref", "refs/heads/chant/lifecycle", ahead);
500
534
 
501
535
  const pass = await ciServePass(opts);
502
536
  expect(ofType(events, "ledger")).toEqual([]);
503
- expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", status: "ok" })]);
537
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", status: "ok" })]);
504
538
  expect(results[0]).toMatchObject({ pushed: true });
505
539
  expect(isAncestor(origin, ahead, git(origin, "rev-parse", "refs/heads/chant/lifecycle"))).toBe(true);
506
540
  });
507
541
  });
508
542
 
509
- describe("ci serve applies fast-forwards only, and honours [skip ci] (#4122)", () => {
510
- test("a force push back to an older commit is recorded as seen and not applied, and the next fast-forward applies", async () => {
543
+ describe("ci serve runs fast-forwards only, and honours [skip ci] (#4122)", () => {
544
+ test("a force push back to an older commit is recorded as seen and not run, and the next fast-forward is recorded", async () => {
511
545
  const { root, app, main } = workspace();
512
546
  const later = commit(root, "app/later.txt");
513
547
  git(root, "push", "-q", "origin", "main");
@@ -521,7 +555,7 @@ describe("ci serve applies fast-forwards only, and honours [skip ci] (#4122)", (
521
555
  forge.head = main;
522
556
  const back = await ciServePass(opts);
523
557
  expect(back.jobs).toEqual([]);
524
- expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "apply", commit: main, reason: "not-fast-forward", branch: "main", from: later }]);
558
+ expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "record", commit: main, reason: "not-fast-forward", branch: "main", from: later }]);
525
559
  expect(formatCiServeEvent(ofType(events, "not-run")[0]!)).toContain("not a fast-forward");
526
560
  const file = join(state, `${prApplyGroup("prod", "app")}.json`);
527
561
  expect(readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" }).target).toBe(main);
@@ -530,10 +564,10 @@ describe("ci serve applies fast-forwards only, and honours [skip ci] (#4122)", (
530
564
  git(root, "push", "-q", "origin", "main");
531
565
  forge.head = next;
532
566
  const pass = await ciServePass(opts);
533
- expect(pass.jobs).toEqual([expect.objectContaining({ job: "apply", commit: next, base: main, status: "ok" })]);
567
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", commit: next, base: main, status: "ok" })]);
534
568
  });
535
569
 
536
- test("a target head whose message says [skip ci] is recorded as seen and not applied", async () => {
570
+ test("a target head whose message says [skip ci] is recorded as seen and not run", async () => {
537
571
  const { root, app, main } = workspace();
538
572
  const forge = fakeForge(main);
539
573
  const { opts, events, state } = serve(app, forge);
@@ -543,7 +577,7 @@ describe("ci serve applies fast-forwards only, and honours [skip ci] (#4122)", (
543
577
  forge.head = skip;
544
578
 
545
579
  expect((await ciServePass(opts)).jobs).toEqual([]);
546
- expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "apply", commit: skip, reason: "skip-ci" }]);
580
+ expect(ofType(events, "not-run")).toEqual([{ type: "not-run", job: "record", commit: skip, reason: "skip-ci" }]);
547
581
  expect(formatCiServeEvent(ofType(events, "not-run")[0]!)).toContain("says [skip ci]");
548
582
  const file = join(state, `${prApplyGroup("prod", "app")}.json`);
549
583
  expect(readCiServeState(file, { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" }).target).toBe(skip);