@intentius/chant 0.124.32 → 0.124.33

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 (281) hide show
  1. package/dist/ci-jobs.d.ts +4 -0
  2. package/dist/ci-jobs.d.ts.map +1 -1
  3. package/dist/ci-jobs.js +9 -2
  4. package/dist/cli/handlers/build.d.ts.map +1 -1
  5. package/dist/cli/handlers/build.js +18 -3
  6. package/dist/cli/handlers/lifecycle-health.d.ts +16 -0
  7. package/dist/cli/handlers/lifecycle-health.d.ts.map +1 -0
  8. package/dist/cli/handlers/lifecycle-health.js +150 -0
  9. package/dist/cli/handlers/lifecycle.d.ts +26 -0
  10. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  11. package/dist/cli/handlers/lifecycle.js +1 -1
  12. package/dist/cli/handlers/pr.d.ts +2 -0
  13. package/dist/cli/handlers/pr.d.ts.map +1 -1
  14. package/dist/cli/handlers/pr.js +124 -2
  15. package/dist/cli/handlers/promote.d.ts.map +1 -1
  16. package/dist/cli/handlers/promote.js +16 -1
  17. package/dist/cli/handlers/release.d.ts +16 -1
  18. package/dist/cli/handlers/release.d.ts.map +1 -1
  19. package/dist/cli/handlers/release.js +83 -11
  20. package/dist/cli/main.d.ts.map +1 -1
  21. package/dist/cli/main.js +81 -21
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +23 -1
  24. package/dist/cli/registry.d.ts +18 -4
  25. package/dist/cli/registry.d.ts.map +1 -1
  26. package/dist/components/deploy-inputs.d.ts +91 -0
  27. package/dist/components/deploy-inputs.d.ts.map +1 -0
  28. package/dist/components/deploy-inputs.js +184 -0
  29. package/dist/components/driver.d.ts.map +1 -1
  30. package/dist/components/driver.js +9 -0
  31. package/dist/components/gate-plan.d.ts +8 -0
  32. package/dist/components/gate-plan.d.ts.map +1 -1
  33. package/dist/components/gate-plan.js +6 -0
  34. package/dist/components/pr-pipeline.d.ts +33 -10
  35. package/dist/components/pr-pipeline.d.ts.map +1 -1
  36. package/dist/components/pr-pipeline.js +42 -11
  37. package/dist/components/pr-run.d.ts +1 -1
  38. package/dist/components/pr-run.d.ts.map +1 -1
  39. package/dist/components/release-compile.d.ts +37 -2
  40. package/dist/components/release-compile.d.ts.map +1 -1
  41. package/dist/components/release-compile.js +64 -8
  42. package/dist/components/release-promote.d.ts +17 -3
  43. package/dist/components/release-promote.d.ts.map +1 -1
  44. package/dist/components/release-promote.js +79 -18
  45. package/dist/components/verbs/bom-writer.d.ts +1 -1
  46. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  47. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  48. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  49. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  50. package/dist/components/wave-plan.d.ts +10 -0
  51. package/dist/components/wave-plan.d.ts.map +1 -1
  52. package/dist/components/wave-plan.js +32 -4
  53. package/dist/graph-detail.d.ts.map +1 -1
  54. package/dist/graph-detail.js +32 -7
  55. package/dist/health.d.ts +110 -0
  56. package/dist/health.d.ts.map +1 -0
  57. package/dist/health.js +105 -0
  58. package/dist/health.schema.json +191 -0
  59. package/dist/lexicon.d.ts +27 -0
  60. package/dist/lexicon.d.ts.map +1 -1
  61. package/dist/lifecycle/merge-record.d.ts +104 -0
  62. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  63. package/dist/lifecycle/merge-record.js +102 -0
  64. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  65. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  66. package/dist/lifecycle/publish-attachments.js +27 -13
  67. package/dist/lifecycle/publish-npm.d.ts +3 -2
  68. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  69. package/dist/lifecycle/publish-npm.js +4 -3
  70. package/dist/lifecycle/release-at.d.ts +40 -0
  71. package/dist/lifecycle/release-at.d.ts.map +1 -0
  72. package/dist/lifecycle/release-at.js +83 -0
  73. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  74. package/dist/lifecycle/release-cut.js +23 -12
  75. package/dist/lifecycle/release-publish.d.ts +28 -12
  76. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  77. package/dist/lifecycle/release-publish.js +29 -17
  78. package/dist/lifecycle/release-store.d.ts +25 -2
  79. package/dist/lifecycle/release-store.d.ts.map +1 -1
  80. package/dist/lifecycle/release-store.js +40 -13
  81. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  82. package/dist/lifecycle/run-ledger.js +1 -0
  83. package/dist/lint/rules/op/index.d.ts +2 -1
  84. package/dist/lint/rules/op/index.d.ts.map +1 -1
  85. package/dist/lint/rules/op/index.js +4 -2
  86. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  87. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  88. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  89. package/dist/op/activities/activity-contracts.d.ts +5 -0
  90. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  91. package/dist/op/activities/activity-contracts.js +3 -0
  92. package/dist/op/activities/shell.d.ts +38 -0
  93. package/dist/op/activities/shell.d.ts.map +1 -1
  94. package/dist/op/activities/shell.js +89 -3
  95. package/dist/op/builders.d.ts +7 -0
  96. package/dist/op/builders.d.ts.map +1 -1
  97. package/dist/op/builders.js +7 -0
  98. package/dist/op/runtime.d.ts +2 -0
  99. package/dist/op/runtime.d.ts.map +1 -1
  100. package/dist/pr-loop.d.ts +14 -3
  101. package/dist/pr-loop.d.ts.map +1 -1
  102. package/dist/pr-loop.js +15 -5
  103. package/dist/pr-report-url.d.ts +2 -2
  104. package/dist/pr-report-url.d.ts.map +1 -1
  105. package/dist/runner-start.d.ts +179 -0
  106. package/dist/runner-start.d.ts.map +1 -0
  107. package/dist/runner-start.js +277 -0
  108. package/dist/wave-runtime.mjs +235 -228
  109. package/dist/workspace/check.schema.json +70 -2
  110. package/dist/workspace/checks/security.d.ts +35 -0
  111. package/dist/workspace/checks/security.d.ts.map +1 -0
  112. package/dist/workspace/checks/security.js +305 -0
  113. package/dist/workspace/checks.d.ts +2 -0
  114. package/dist/workspace/checks.d.ts.map +1 -1
  115. package/dist/workspace/checks.js +5 -0
  116. package/dist/workspace/ci-builds.d.ts +163 -0
  117. package/dist/workspace/ci-builds.d.ts.map +1 -0
  118. package/dist/workspace/ci-builds.js +315 -0
  119. package/dist/workspace/ci-green-cli.d.ts +2 -1
  120. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  121. package/dist/workspace/ci-green-cli.js +25 -5
  122. package/dist/workspace/ci-green-forge.d.ts +12 -0
  123. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  124. package/dist/workspace/ci-green-forge.js +12 -0
  125. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  126. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  127. package/dist/workspace/ci-green-workflow.js +128 -1
  128. package/dist/workspace/ci-green.d.ts +89 -4
  129. package/dist/workspace/ci-green.d.ts.map +1 -1
  130. package/dist/workspace/ci-green.js +310 -23
  131. package/dist/workspace/ci-last-green.schema.json +49 -0
  132. package/dist/workspace/ci-list.schema.json +35 -1
  133. package/dist/workspace/ci-run-index.d.ts +1 -1
  134. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  135. package/dist/workspace/ci-run-index.schema.json +1 -1
  136. package/dist/workspace/ci-run.d.ts +12 -6
  137. package/dist/workspace/ci-run.d.ts.map +1 -1
  138. package/dist/workspace/ci-run.js +72 -16
  139. package/dist/workspace/ci-runner-start.d.ts +77 -0
  140. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  141. package/dist/workspace/ci-runner-start.js +185 -0
  142. package/dist/workspace/ci-serve.d.ts +76 -7
  143. package/dist/workspace/ci-serve.d.ts.map +1 -1
  144. package/dist/workspace/ci-serve.js +153 -19
  145. package/dist/workspace/declaration.d.ts +17 -0
  146. package/dist/workspace/declaration.d.ts.map +1 -1
  147. package/dist/workspace/declaration.js +3 -0
  148. package/dist/workspace/declaration.schema.json +23 -0
  149. package/dist/workspace/ls.d.ts +2 -0
  150. package/dist/workspace/ls.d.ts.map +1 -1
  151. package/dist/workspace/ls.js +1 -1
  152. package/dist/workspace/ls.schema.json +2 -1
  153. package/dist/workspace/member-pipeline.d.ts +1 -0
  154. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  155. package/dist/workspace/member-pipeline.js +2 -0
  156. package/dist/workspace/merge-record.schema.json +49 -0
  157. package/dist/workspace/pr-report.schema.json +3 -3
  158. package/dist/workspace/reason-codes.d.ts +17 -0
  159. package/dist/workspace/reason-codes.d.ts.map +1 -1
  160. package/dist/workspace/reason-codes.js +18 -0
  161. package/dist/workspace/release-candidates.d.ts +129 -0
  162. package/dist/workspace/release-candidates.d.ts.map +1 -0
  163. package/dist/workspace/release-candidates.js +233 -0
  164. package/dist/workspace/release-candidates.schema.json +353 -0
  165. package/dist/workspace/security.d.ts +40 -0
  166. package/dist/workspace/security.d.ts.map +1 -0
  167. package/dist/workspace/security.js +99 -0
  168. package/package.json +8 -1
  169. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  170. package/src/ci-jobs.ts +11 -2
  171. package/src/cli/handlers/build.test.ts +36 -0
  172. package/src/cli/handlers/build.ts +18 -3
  173. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  174. package/src/cli/handlers/lifecycle-health.ts +157 -0
  175. package/src/cli/handlers/lifecycle.ts +1 -1
  176. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  177. package/src/cli/handlers/pr-record.test.ts +201 -0
  178. package/src/cli/handlers/pr.test.ts +22 -0
  179. package/src/cli/handlers/pr.ts +127 -4
  180. package/src/cli/handlers/promote.test.ts +17 -0
  181. package/src/cli/handlers/promote.ts +16 -1
  182. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  183. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  184. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  185. package/src/cli/handlers/release.e2e.test.ts +19 -28
  186. package/src/cli/handlers/release.ts +81 -11
  187. package/src/cli/main.ts +81 -21
  188. package/src/cli/parse-args.ts +17 -1
  189. package/src/cli/registry.ts +18 -4
  190. package/src/components/deploy-inputs.test.ts +108 -0
  191. package/src/components/deploy-inputs.ts +225 -0
  192. package/src/components/driver.ts +11 -0
  193. package/src/components/gate-plan.ts +9 -0
  194. package/src/components/pr-pipeline.ts +56 -15
  195. package/src/components/pr-run.ts +1 -1
  196. package/src/components/release-compile.test.ts +34 -0
  197. package/src/components/release-compile.ts +89 -8
  198. package/src/components/release-promote.test.ts +55 -2
  199. package/src/components/release-promote.ts +93 -23
  200. package/src/components/verbs/bom-writer.ts +1 -1
  201. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  202. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  203. package/src/components/wave-plan.ts +32 -4
  204. package/src/graph-detail.test.ts +57 -0
  205. package/src/graph-detail.ts +33 -7
  206. package/src/health.schema.json +191 -0
  207. package/src/health.test.ts +62 -0
  208. package/src/health.ts +178 -0
  209. package/src/lexicon.ts +41 -1
  210. package/src/lifecycle/merge-record.test.ts +111 -0
  211. package/src/lifecycle/merge-record.ts +154 -0
  212. package/src/lifecycle/publish-attachments.ts +25 -13
  213. package/src/lifecycle/publish-npm.ts +4 -3
  214. package/src/lifecycle/publish-targets.test.ts +34 -4
  215. package/src/lifecycle/release-at.test.ts +48 -0
  216. package/src/lifecycle/release-at.ts +100 -0
  217. package/src/lifecycle/release-cut.test.ts +41 -0
  218. package/src/lifecycle/release-cut.ts +22 -11
  219. package/src/lifecycle/release-publish.test.ts +43 -13
  220. package/src/lifecycle/release-publish.ts +38 -18
  221. package/src/lifecycle/release-store.ts +57 -13
  222. package/src/lifecycle/run-ledger.test.ts +13 -0
  223. package/src/lifecycle/run-ledger.ts +1 -0
  224. package/src/lint/rules/op/index.ts +4 -2
  225. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  226. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  227. package/src/op/activities/activity-contracts.ts +3 -0
  228. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  229. package/src/op/activities/shell.test.ts +44 -1
  230. package/src/op/activities/shell.ts +110 -3
  231. package/src/op/builders.ts +7 -0
  232. package/src/op/runtime.ts +2 -0
  233. package/src/op/step-console.test.ts +4 -2
  234. package/src/pr-loop.test.ts +29 -0
  235. package/src/pr-loop.ts +26 -7
  236. package/src/pr-report-url.ts +2 -2
  237. package/src/runner-start-bundle.test.ts +29 -0
  238. package/src/runner-start.test.ts +179 -0
  239. package/src/runner-start.ts +329 -0
  240. package/src/workspace/check-contract.test.ts +3 -2
  241. package/src/workspace/check.schema.json +70 -2
  242. package/src/workspace/checks/security.test.ts +138 -0
  243. package/src/workspace/checks/security.ts +305 -0
  244. package/src/workspace/checks.ts +5 -0
  245. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  246. package/src/workspace/ci-builds.test.ts +174 -0
  247. package/src/workspace/ci-builds.ts +385 -0
  248. package/src/workspace/ci-green-builds.test.ts +180 -0
  249. package/src/workspace/ci-green-cli.test.ts +54 -0
  250. package/src/workspace/ci-green-cli.ts +26 -5
  251. package/src/workspace/ci-green-forge.ts +24 -0
  252. package/src/workspace/ci-green-workflow.test.ts +84 -1
  253. package/src/workspace/ci-green-workflow.ts +139 -2
  254. package/src/workspace/ci-green.test.ts +5 -2
  255. package/src/workspace/ci-green.ts +359 -27
  256. package/src/workspace/ci-last-green.schema.json +49 -0
  257. package/src/workspace/ci-list.schema.json +35 -1
  258. package/src/workspace/ci-run-index.schema.json +1 -1
  259. package/src/workspace/ci-run-index.ts +1 -1
  260. package/src/workspace/ci-run.test.ts +24 -7
  261. package/src/workspace/ci-run.ts +78 -16
  262. package/src/workspace/ci-runner-start.test.ts +151 -0
  263. package/src/workspace/ci-runner-start.ts +193 -0
  264. package/src/workspace/ci-serve-builds.test.ts +86 -0
  265. package/src/workspace/ci-serve.test.ts +59 -0
  266. package/src/workspace/ci-serve.ts +177 -22
  267. package/src/workspace/declaration.schema.json +23 -0
  268. package/src/workspace/declaration.test.ts +17 -0
  269. package/src/workspace/declaration.ts +22 -1
  270. package/src/workspace/ls.schema.json +2 -1
  271. package/src/workspace/ls.ts +3 -2
  272. package/src/workspace/member-pipeline.ts +2 -1
  273. package/src/workspace/merge-record.schema.json +49 -0
  274. package/src/workspace/pr-report.schema.json +3 -3
  275. package/src/workspace/reason-codes.test.ts +2 -0
  276. package/src/workspace/reason-codes.ts +18 -0
  277. package/src/workspace/release-candidates.schema.json +353 -0
  278. package/src/workspace/release-candidates.test.ts +106 -0
  279. package/src/workspace/release-candidates.ts +315 -0
  280. package/src/workspace/security.test.ts +70 -0
  281. package/src/workspace/security.ts +118 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * #4169 end to end: `chant ci tick` and `chant ci run --at`, as a process,
3
+ * against a bare git remote and a local server that answers GitHub's check
4
+ * run, re-run and workflow dispatch calls the way Actions would for a
5
+ * `suites` phase declared `build: "newest"`.
6
+ *
7
+ * Seven commits go to main at once: a green base, five commits the suites
8
+ * never ran on (GitHub dropped their waiting runs), and the newest, whose
9
+ * suites run fails. The fourth of the five broke the suites. The tick runs
10
+ * each failed job again once, then bisects: it halves the five, steps back
11
+ * from the red, and names the culprit, leaving the two commits below the
12
+ * green middle one unbuilt.
13
+ */
14
+
15
+ import { spawn } from "node:child_process";
16
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
17
+ import type { AddressInfo } from "node:net";
18
+ import { join } from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
+ import { cleanScratch, declaration, git, REPO, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
22
+
23
+ afterAll(cleanScratch);
24
+
25
+ const GREEN = {
26
+ branch: "main",
27
+ phases: { check: { runs: ["check"], build: "every" }, suites: { runs: ["suites"], build: "newest" } },
28
+ require: ["check", "suites"],
29
+ };
30
+ const SUITES_WORKFLOW = "name: release-suites\non:\n push:\n branches: [main]\n workflow_dispatch: {}\njobs:\n suites:\n runs-on: ubuntu-latest\n steps:\n - run: true\n";
31
+
32
+ interface Run {
33
+ id: number;
34
+ name: string;
35
+ status: string;
36
+ conclusion: string | null;
37
+ }
38
+
39
+ /** The fake GitHub: runs per commit, and what dispatching the suites at a commit concludes. */
40
+ class FakeGithub {
41
+ runs = new Map<string, Run[]>();
42
+ dispatched: string[] = [];
43
+ reruns: number[] = [];
44
+ private id = 1;
45
+ server!: Server;
46
+ constructor(
47
+ readonly origin: string,
48
+ readonly suitesPass: (sha: string) => boolean,
49
+ ) {}
50
+
51
+ add(sha: string, name: string, conclusion: string): void {
52
+ this.runs.set(sha, [...(this.runs.get(sha) ?? []), { id: this.id++, name, status: "completed", conclusion }]);
53
+ }
54
+
55
+ async listen(): Promise<string> {
56
+ this.server = createServer((req, res) => void this.handle(req, res));
57
+ await new Promise<void>((done) => this.server.listen(0, "127.0.0.1", done));
58
+ return `http://127.0.0.1:${(this.server.address() as AddressInfo).port}`;
59
+ }
60
+
61
+ private async handle(req: IncomingMessage, res: ServerResponse): Promise<void> {
62
+ const body = await new Promise<string>((done) => {
63
+ let text = "";
64
+ req.on("data", (c) => (text += c));
65
+ req.on("end", () => done(text));
66
+ });
67
+ const url = new URL(req.url ?? "/", "http://x");
68
+ const send = (status: number, json?: unknown) => {
69
+ res.writeHead(status, { "Content-Type": "application/json" });
70
+ res.end(json === undefined ? "" : JSON.stringify(json));
71
+ };
72
+ let m = /^\/repos\/acme\/app\/commits\/([0-9a-f]+)\/check-runs$/.exec(url.pathname);
73
+ if (req.method === "GET" && m) {
74
+ const runs = this.runs.get(m[1]) ?? [];
75
+ return send(200, { total_count: runs.length, check_runs: runs.map((r) => ({ ...r, completed_at: null, html_url: null })) });
76
+ }
77
+ m = /^\/repos\/acme\/app\/actions\/jobs\/(\d+)\/rerun$/.exec(url.pathname);
78
+ if (req.method === "POST" && m) {
79
+ const id = Number(m[1]);
80
+ this.reruns.push(id);
81
+ // The job runs again and fails the same way.
82
+ for (const [sha, runs] of this.runs) {
83
+ const r = runs.find((x) => x.id === id);
84
+ if (r) this.add(sha, r.name, r.conclusion ?? "failure");
85
+ }
86
+ return send(201, {});
87
+ }
88
+ m = /^\/repos\/acme\/app\/actions\/workflows\/([^/]+)\/dispatches$/.exec(url.pathname);
89
+ if (req.method === "POST" && m) {
90
+ const ref = (JSON.parse(body) as { ref: string }).ref;
91
+ const sha = git(this.origin, "rev-parse", `refs/tags/${ref}^{commit}`);
92
+ this.dispatched.push(sha);
93
+ this.add(sha, "suites", this.suitesPass(sha) ? "success" : "failure");
94
+ return send(204);
95
+ }
96
+ send(404, { message: "not found" });
97
+ }
98
+ }
99
+
100
+ function chant(cwd: string, api: string, ...args: string[]): Promise<{ code: number; out: string; err: string }> {
101
+ return new Promise((done) => {
102
+ const child = spawn(process.execPath, ["--import", pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href, join(REPO, "packages/core/src/cli/main.ts"), ...args], {
103
+ cwd,
104
+ env: { ...process.env, GITHUB_API_URL: api, GITHUB_REPOSITORY: "acme/app", GITHUB_TOKEN: "t", GIT_TERMINAL_PROMPT: "0", NO_COLOR: "1" },
105
+ stdio: ["ignore", "pipe", "pipe"],
106
+ });
107
+ let out = "";
108
+ let err = "";
109
+ child.stdout.on("data", (c) => (out += c));
110
+ child.stderr.on("data", (c) => (err += c));
111
+ child.on("close", (code) => done({ code: code ?? 1, out, err }));
112
+ });
113
+ }
114
+
115
+ describe("a newest phase, bisected end to end (#4169)", () => {
116
+ let root: string;
117
+ let origin: string;
118
+ let forge: FakeGithub;
119
+ let api: string;
120
+ const shas: Record<string, string> = {};
121
+
122
+ beforeAll(async () => {
123
+ origin = scratchDir("chant-builds-e2e-origin-");
124
+ git(origin, "init", "-q", "--bare");
125
+ root = scratchDir("chant-builds-e2e-work-");
126
+ git(root, "init", "-q");
127
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
128
+ git(root, "config", "user.name", "t");
129
+ git(root, "config", "user.email", "t@example.com");
130
+ git(root, "remote", "add", "origin", origin);
131
+ writeFiles(root, {
132
+ "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: GREEN } }),
133
+ ".github/workflows/release-suites.yml": SUITES_WORKFLOW,
134
+ });
135
+ for (const name of ["base", "u1", "u2", "u3", "u4", "u5", "top"]) {
136
+ writeFiles(root, { [`app/${name}.txt`]: `${name}\n` });
137
+ git(root, "add", "-A");
138
+ git(root, "commit", "-q", "-m", name);
139
+ shas[name] = git(root, "rev-parse", "HEAD");
140
+ }
141
+ git(root, "push", "-q", "origin", "main");
142
+ const order = ["base", "u1", "u2", "u3", "u4", "u5", "top"].map((n) => shas[n]);
143
+ // u4 broke the suites: they pass below it.
144
+ forge = new FakeGithub(origin, (sha) => order.indexOf(sha) < order.indexOf(shas.u4));
145
+ for (const sha of order) forge.add(sha, "check", "success");
146
+ forge.add(shas.base, "suites", "success");
147
+ forge.add(shas.top, "suites", "failure");
148
+ api = await forge.listen();
149
+ });
150
+
151
+ afterAll(() => forge?.server.close());
152
+
153
+ const tags = () => git(origin, "for-each-ref", "--format=%(refname:lstrip=2)", "refs/tags/").split("\n").filter(Boolean).sort();
154
+ const record = (name: string) => JSON.parse(git(origin, "show", `refs/heads/chant/lifecycle:_ci/${shas[name]}.json`));
155
+ const tick = async () => {
156
+ const r = await chant(root, api, "ci", "tick");
157
+ expect(r.code, r.err).toBe(0);
158
+ return r.out;
159
+ };
160
+
161
+ test("retries once, bisects to the culprit, and leaves the commits below the green one unbuilt", async () => {
162
+ // The failed suites run again once, and fail again.
163
+ expect(await tick()).toContain(`ran suites at ${shas.top.slice(0, 12)} again`);
164
+ expect(forge.reruns).toHaveLength(1);
165
+ expect(record("top")).toMatchObject({ state: "pending", phases: { suites: { reason: "retrying" } } });
166
+
167
+ // Red now: five unbuilt below it, so the middle one, u3.
168
+ await tick();
169
+ expect(forge.dispatched).toEqual([shas.u3]);
170
+ // u3 passes and goes green; then the step back from the red, u5, then u4,
171
+ // each red run of them run again once before the next step.
172
+ await tick();
173
+ expect(tags()).toContain(`ci/green/${shas.u3}`);
174
+ expect(forge.dispatched).toEqual([shas.u3, shas.u5]);
175
+ expect(await tick()).toContain(`ran suites at ${shas.u5.slice(0, 12)} again`);
176
+ await tick();
177
+ expect(forge.dispatched).toEqual([shas.u3, shas.u5, shas.u4]);
178
+ expect(await tick()).toContain(`ran suites at ${shas.u4.slice(0, 12)} again`);
179
+ await tick();
180
+ expect(forge.dispatched).toHaveLength(3);
181
+ expect(forge.reruns).toHaveLength(3);
182
+
183
+ for (const name of ["u4", "u5", "top"]) expect(record(name), name).toMatchObject({ state: "red", phases: { suites: { culprit: shas.u4 } } });
184
+ for (const name of ["u1", "u2"]) expect(record(name), name).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "superseded" } } });
185
+ expect(record("u3")).toMatchObject({ state: "green" });
186
+ expect(tags().filter((t) => t.startsWith("ci/build/"))).toEqual([]);
187
+ expect(tags()).toEqual([`ci/green/${shas.base}`, `ci/green/${shas.u3}`].sort());
188
+
189
+ const last = await chant(root, api, "ci", "last-green", "--json");
190
+ const doc = JSON.parse(last.out);
191
+ expect(doc.commit.sha).toBe(shas.u3);
192
+ expect(doc.newer.map((n: { sha: string; record: { state: string; culprit?: string } }) => [n.sha, n.record.state, n.record.culprit])).toEqual([
193
+ [shas.top, "red", shas.u4],
194
+ [shas.u5, "red", shas.u4],
195
+ [shas.u4, "red", shas.u4],
196
+ ]);
197
+ }, 180_000);
198
+
199
+ test("ci run --at asks for a chosen commit and starts the suites there", async () => {
200
+ const r = await chant(root, api, "ci", "run", "--at", shas.u1);
201
+ expect(r.code, r.err).toBe(0);
202
+ expect(r.out).toContain(`Asked for check, suites at ${shas.u1.slice(0, 12)}: ci/build/${shas.u1}. Started release-suites.yml.`);
203
+ expect(tags()).toContain(`ci/build/${shas.u1}`);
204
+ await tick();
205
+ expect(tags()).toContain(`ci/green/${shas.u1}`);
206
+ expect(tags()).not.toContain(`ci/build/${shas.u1}`);
207
+ }, 60_000);
208
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * #4169 — which commits get built: supersession, retries, requests and
3
+ * bisection over judged commits, and the records each commit gets.
4
+ */
5
+
6
+ import { describe, expect, test } from "vitest";
7
+ import { agedOut, BUILD_REQUEST_TTL_MS, planBuilds, recordOf, RETRY_TTL_MS, sameRecord, type BuildRequest, type CiRecord } from "./ci-builds";
8
+ import { evaluateCommit, type TickCommit } from "./ci-green";
9
+ import type { CheckRun } from "./ci-green-forge";
10
+ import type { CiGreen } from "./declaration";
11
+
12
+ const NOW = new Date("2026-10-20T12:00:00Z");
13
+ const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString().replace(/\.\d{3}Z$/, "Z");
14
+
15
+ const GREEN: CiGreen = {
16
+ branch: "main",
17
+ window: "24h",
18
+ phases: [
19
+ { name: "check", runs: ["check"], skipped: "fail", build: "every", pointer: "/ci/green/phases/check" },
20
+ { name: "suites", runs: ["suites"], skipped: "fail", build: "newest", pointer: "/ci/green/phases/suites" },
21
+ ],
22
+ require: ["check", "suites"],
23
+ signedSince: null,
24
+ source: "checks",
25
+ pointer: "/ci/green",
26
+ };
27
+
28
+ let nextId = 1;
29
+ const run = (name: string, conclusion: string | null, status = "completed"): CheckRun => ({ id: nextId++, name, status, conclusion: status === "completed" ? conclusion : null, completedAt: null, url: null });
30
+ const sha = (n: number) => String(n).padStart(40, "0");
31
+
32
+ /** Commits newest first, each with its runs, judged as the tick judges them. */
33
+ function judged(runsPerCommit: CheckRun[][], before: TickCommit["before"][] = []): { commits: TickCommit[]; runs: Map<string, CheckRun[]> } {
34
+ const runs = new Map<string, CheckRun[]>();
35
+ const commits = runsPerCommit.map((r, i) => {
36
+ const s = sha(runsPerCommit.length - i);
37
+ runs.set(s, r);
38
+ return { sha: s, committed: NOW.toISOString(), before: before[i] ?? "none", verdict: evaluateCommit(GREEN, r), made: null } as TickCommit;
39
+ });
40
+ return { commits, runs };
41
+ }
42
+
43
+ const plan = (commits: TickCommit[], runs: Map<string, CheckRun[]>, extra: Partial<Parameters<typeof planBuilds>[0]> = {}) =>
44
+ planBuilds({ green: GREEN, commits, runs, requests: new Map(), records: new Map(), now: NOW, retryVia: null, ...extra });
45
+
46
+ const suites = (c: TickCommit) => c.verdict!.phases.find((p) => p.phase === "suites")!;
47
+
48
+ describe("supersession (#4169)", () => {
49
+ test("a newest phase with no run below a commit that has one is unbuilt, covered by it; the newest commit stays pending", () => {
50
+ const { commits, runs } = judged([[run("check", "success")], [run("check", "success"), run("suites", "success")], [run("check", "success")]]);
51
+ plan(commits, runs);
52
+ expect(commits.map((c) => c.verdict!.verdict)).toEqual(["pending", "pass", "unbuilt"]);
53
+ expect(suites(commits[2])).toMatchObject({ verdict: "unbuilt", reason: "superseded", coveredBy: commits[1].sha });
54
+ expect(suites(commits[0])).toMatchObject({ verdict: "pending" });
55
+ });
56
+
57
+ test("an every phase is never superseded, and a cancelled run is unbuilt under either", () => {
58
+ const { commits, runs } = judged([[run("check", "success"), run("suites", "success")], [run("suites", "success")], [run("check", "cancelled"), run("suites", "success")]]);
59
+ plan(commits, runs);
60
+ expect(commits.map((c) => c.verdict!.verdict)).toEqual(["pass", "pending", "unbuilt"]);
61
+ expect(commits[2].verdict!.phases[0]).toMatchObject({ phase: "check", verdict: "unbuilt", reason: "cancelled" });
62
+ });
63
+ });
64
+
65
+ describe("bisect (#4169)", () => {
66
+ const green = () => [run("check", "success"), run("suites", "success")];
67
+ const red = () => [run("check", "success"), run("suites", "failure")];
68
+ const none = () => [run("check", "success")];
69
+
70
+ test("a red newest commit asks for the one just below it when four or fewer are unbuilt", () => {
71
+ const { commits, runs } = judged([red(), none(), none(), green()]);
72
+ const p = plan(commits, runs);
73
+ expect(p.requests).toEqual([expect.objectContaining({ commit: commits[1].sha, phases: ["suites"], reason: "bisect", tag: `ci/build/${commits[1].sha}` })]);
74
+ expect(suites(commits[1])).toMatchObject({ verdict: "pending", reason: "requested" });
75
+ });
76
+
77
+ test("halves the range above four, and asks for nothing while a request in it waits", () => {
78
+ const { commits, runs } = judged([red(), none(), none(), none(), none(), none(), none(), green()]);
79
+ const p = plan(commits, runs);
80
+ expect(p.requests.map((r) => r.commit)).toEqual([commits[4].sha]);
81
+ const waiting: BuildRequest = { ...p.requests[0], at: ago(60_000) };
82
+ const again = judged([red(), none(), none(), none(), none(), none(), none(), green()]);
83
+ expect(plan(again.commits, again.runs, { requests: new Map([[waiting.commit, waiting]]) }).requests).toEqual([]);
84
+ });
85
+
86
+ test("names the culprit on it and every commit after it once the red sits on a green", () => {
87
+ const { commits, runs } = judged([red(), none(), red(), green()]);
88
+ // The commit below the newest red is unbuilt between two reds: the culprit is at or below the older red.
89
+ plan(commits, runs);
90
+ expect(commits.map((c) => suites(c).culprit)).toEqual([commits[2].sha, commits[2].sha, commits[2].sha, undefined]);
91
+ });
92
+
93
+ test("names no culprit without a green below, and an every phase gets one too", () => {
94
+ const noGreen = judged([red(), red()]);
95
+ plan(noGreen.commits, noGreen.runs);
96
+ expect(noGreen.commits.map((c) => suites(c).culprit)).toEqual([undefined, undefined]);
97
+ const every = judged([[run("check", "failure")], [run("check", "failure")], [run("check", "success")]]);
98
+ plan(every.commits, every.runs);
99
+ expect(every.commits.map((c) => c.verdict!.phases[0].culprit)).toEqual([every.commits[1].sha, every.commits[1].sha, undefined]);
100
+ });
101
+ });
102
+
103
+ describe("requests (#4169)", () => {
104
+ const req = (commit: string, at: string): BuildRequest => ({ schema: 1, tag: `ci/build/${commit}`, commit, branch: "main", phases: ["suites"], reason: "requested", at });
105
+
106
+ test("a request holds its phase pending; once decided, or too old, or out of the window, it is done", () => {
107
+ const { commits, runs } = judged([[run("check", "success"), run("suites", "success")], [run("check", "success")]]);
108
+ const waiting = req(commits[1].sha, ago(60_000));
109
+ const p = plan(commits, runs, { requests: new Map([[waiting.commit, waiting]]) });
110
+ expect(suites(commits[1])).toMatchObject({ verdict: "pending", reason: "requested" });
111
+ expect(p.resolved).toEqual([]);
112
+
113
+ const old = judged([[run("check", "success"), run("suites", "success")], [run("check", "success")]]);
114
+ const stale = req(old.commits[1].sha, ago(BUILD_REQUEST_TTL_MS + 1));
115
+ expect(plan(old.commits, old.runs, { requests: new Map([[stale.commit, stale]]) }).resolved).toEqual([stale.tag]);
116
+ expect(suites(old.commits[1])).toMatchObject({ verdict: "unbuilt", reason: "request-unserved" });
117
+
118
+ const done = judged([[run("check", "success"), run("suites", "success")]]);
119
+ const served = req(done.commits[0].sha, ago(60_000));
120
+ const gone = req(sha(99), ago(60_000));
121
+ expect(plan(done.commits, done.runs, { requests: new Map([[served.commit, served], [gone.commit, gone]]) }).resolved.sort()).toEqual([served.tag, gone.tag].sort());
122
+ });
123
+ });
124
+
125
+ describe("retry (#4169)", () => {
126
+ test("a failed run with one attempt is run again and holds the commit pending; a second attempt decides", () => {
127
+ const failed = run("check", "failure");
128
+ const { commits, runs } = judged([[failed, run("suites", "success")]]);
129
+ const p = plan(commits, runs, { retryVia: "forge" });
130
+ expect(p.retries).toEqual([{ sha: commits[0].sha, run: failed }]);
131
+ expect(commits[0].verdict!.verdict).toBe("pending");
132
+ expect(commits[0].verdict!.phases[0]).toMatchObject({ verdict: "pending", reason: "retrying" });
133
+
134
+ const twice = judged([[failed, run("check", "failure"), run("suites", "success")]]);
135
+ expect(plan(twice.commits, twice.runs, { retryVia: "forge" }).retries).toEqual([]);
136
+ expect(twice.commits[0].verdict!.verdict).toBe("fail");
137
+ });
138
+
139
+ test("a retry asked for waits an hour for its attempt, then the failure counts; chant runs ask by request; a green commit is never retried", () => {
140
+ const failed = run("check", "failure");
141
+ const asked = (at: string): CiRecord => ({ schema: 1, commit: sha(1), branch: "main", state: "pending", at, phases: {}, retried: { check: { run: failed.id, at } } });
142
+ const waiting = judged([[failed, run("suites", "success")]]);
143
+ expect(plan(waiting.commits, waiting.runs, { retryVia: "forge", records: new Map([[sha(1), asked(ago(60_000))]]) }).retries).toEqual([]);
144
+ expect(waiting.commits[0].verdict!.verdict).toBe("pending");
145
+ const late = judged([[failed, run("suites", "success")]]);
146
+ plan(late.commits, late.runs, { retryVia: "forge", records: new Map([[sha(1), asked(ago(RETRY_TTL_MS + 1))]]) });
147
+ expect(late.commits[0].verdict!.verdict).toBe("fail");
148
+
149
+ const viaRequest = judged([[failed, run("suites", "success")]]);
150
+ expect(plan(viaRequest.commits, viaRequest.runs, { retryVia: "request" }).requests).toEqual([expect.objectContaining({ commit: sha(1), phases: ["check"], reason: "retry" })]);
151
+
152
+ const tagged = judged([[failed, run("suites", "success")]], ["green"]);
153
+ expect(plan(tagged.commits, tagged.runs, { retryVia: "forge" }).retries).toEqual([]);
154
+ });
155
+ });
156
+
157
+ describe("records (#4169)", () => {
158
+ test("say green, red, unbuilt or pending with each phase's verdict, reason and covering commit, and skip the time when compared", () => {
159
+ const { commits, runs } = judged([[run("check", "success"), run("suites", "success")], [run("check", "success")]]);
160
+ plan(commits, runs);
161
+ commits[0].made = { tag: `ci/green/${commits[0].sha}`, message: "" };
162
+ const top = recordOf({ green: GREEN, commit: commits[0], retries: [], requests: new Map(), now: NOW });
163
+ expect(top).toMatchObject({ state: "green", commit: commits[0].sha, phases: { check: { verdict: "pass", build: "every" }, suites: { verdict: "pass", build: "newest" } } });
164
+ const below = recordOf({ green: GREEN, commit: commits[1], retries: [], requests: new Map(), now: NOW });
165
+ expect(below).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "superseded", coveredBy: commits[0].sha } } });
166
+ expect(sameRecord({ ...below, at: "2020-01-01T00:00:00Z" }, below)).toBe(true);
167
+ expect(sameRecord({ ...below, state: "pending" }, below)).toBe(false);
168
+ });
169
+
170
+ test("a pending record that left the window is rewritten unbuilt, aged out", () => {
171
+ const pending: CiRecord = { schema: 1, commit: sha(1), branch: "main", state: "pending", at: ago(1), phases: { suites: { verdict: "pending", build: "newest", runs: [] } } };
172
+ expect(agedOut(pending, NOW)).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "aged-out" } } });
173
+ });
174
+ });