@intentius/chant 0.124.32 → 0.124.34

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 (320) 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 +277 -74
  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 +99 -22
  22. package/dist/cli/parse-args.d.ts.map +1 -1
  23. package/dist/cli/parse-args.js +30 -1
  24. package/dist/cli/registry.d.ts +21 -5
  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/promote-rounds.d.ts +102 -0
  40. package/dist/components/promote-rounds.d.ts.map +1 -0
  41. package/dist/components/promote-rounds.js +174 -0
  42. package/dist/components/release-compile.d.ts +37 -2
  43. package/dist/components/release-compile.d.ts.map +1 -1
  44. package/dist/components/release-compile.js +64 -8
  45. package/dist/components/release-gate.d.ts +30 -0
  46. package/dist/components/release-gate.d.ts.map +1 -1
  47. package/dist/components/release-gate.js +92 -42
  48. package/dist/components/release-promote.d.ts +17 -3
  49. package/dist/components/release-promote.d.ts.map +1 -1
  50. package/dist/components/release-promote.js +79 -18
  51. package/dist/components/verbs/bom-writer.d.ts +1 -1
  52. package/dist/components/verbs/bom-writer.d.ts.map +1 -1
  53. package/dist/components/verbs/lockfile-sbom-generator.d.ts +20 -0
  54. package/dist/components/verbs/lockfile-sbom-generator.d.ts.map +1 -1
  55. package/dist/components/verbs/lockfile-sbom-generator.js +41 -4
  56. package/dist/components/wave-plan.d.ts +10 -0
  57. package/dist/components/wave-plan.d.ts.map +1 -1
  58. package/dist/components/wave-plan.js +32 -4
  59. package/dist/graph-declared.d.ts +4 -0
  60. package/dist/graph-declared.d.ts.map +1 -1
  61. package/dist/graph-declared.js +11 -1
  62. package/dist/graph-detail.d.ts.map +1 -1
  63. package/dist/graph-detail.js +32 -7
  64. package/dist/health.d.ts +110 -0
  65. package/dist/health.d.ts.map +1 -0
  66. package/dist/health.js +105 -0
  67. package/dist/health.schema.json +191 -0
  68. package/dist/lexicon.d.ts +27 -0
  69. package/dist/lexicon.d.ts.map +1 -1
  70. package/dist/lifecycle/merge-record.d.ts +104 -0
  71. package/dist/lifecycle/merge-record.d.ts.map +1 -0
  72. package/dist/lifecycle/merge-record.js +102 -0
  73. package/dist/lifecycle/publish-attachments.d.ts +8 -5
  74. package/dist/lifecycle/publish-attachments.d.ts.map +1 -1
  75. package/dist/lifecycle/publish-attachments.js +27 -13
  76. package/dist/lifecycle/publish-npm.d.ts +3 -2
  77. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  78. package/dist/lifecycle/publish-npm.js +4 -3
  79. package/dist/lifecycle/release-at.d.ts +40 -0
  80. package/dist/lifecycle/release-at.d.ts.map +1 -0
  81. package/dist/lifecycle/release-at.js +83 -0
  82. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  83. package/dist/lifecycle/release-cut.js +23 -12
  84. package/dist/lifecycle/release-publish.d.ts +28 -12
  85. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  86. package/dist/lifecycle/release-publish.js +29 -17
  87. package/dist/lifecycle/release-store.d.ts +25 -2
  88. package/dist/lifecycle/release-store.d.ts.map +1 -1
  89. package/dist/lifecycle/release-store.js +40 -13
  90. package/dist/lifecycle/run-ledger.d.ts.map +1 -1
  91. package/dist/lifecycle/run-ledger.js +1 -0
  92. package/dist/lint/rules/op/index.d.ts +2 -1
  93. package/dist/lint/rules/op/index.d.ts.map +1 -1
  94. package/dist/lint/rules/op/index.js +4 -2
  95. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts +10 -0
  96. package/dist/lint/rules/op/ops018-shell-image-digest.d.ts.map +1 -0
  97. package/dist/lint/rules/op/ops018-shell-image-digest.js +38 -0
  98. package/dist/op/activities/activity-contracts.d.ts +5 -0
  99. package/dist/op/activities/activity-contracts.d.ts.map +1 -1
  100. package/dist/op/activities/activity-contracts.js +3 -0
  101. package/dist/op/activities/shell.d.ts +38 -0
  102. package/dist/op/activities/shell.d.ts.map +1 -1
  103. package/dist/op/activities/shell.js +89 -3
  104. package/dist/op/builders.d.ts +7 -0
  105. package/dist/op/builders.d.ts.map +1 -1
  106. package/dist/op/builders.js +7 -0
  107. package/dist/op/runtime.d.ts +2 -0
  108. package/dist/op/runtime.d.ts.map +1 -1
  109. package/dist/pr-loop.d.ts +14 -3
  110. package/dist/pr-loop.d.ts.map +1 -1
  111. package/dist/pr-loop.js +15 -5
  112. package/dist/pr-report-url.d.ts +2 -2
  113. package/dist/pr-report-url.d.ts.map +1 -1
  114. package/dist/runner-start.d.ts +179 -0
  115. package/dist/runner-start.d.ts.map +1 -0
  116. package/dist/runner-start.js +277 -0
  117. package/dist/wave-runtime.mjs +236 -229
  118. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  119. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  120. package/dist/workspace/check.schema.json +88 -2
  121. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  122. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  123. package/dist/workspace/checks/ci-workflow.js +123 -0
  124. package/dist/workspace/checks/security.d.ts +50 -0
  125. package/dist/workspace/checks/security.d.ts.map +1 -0
  126. package/dist/workspace/checks/security.js +305 -0
  127. package/dist/workspace/checks.d.ts +2 -0
  128. package/dist/workspace/checks.d.ts.map +1 -1
  129. package/dist/workspace/checks.js +8 -0
  130. package/dist/workspace/ci-builds.d.ts +163 -0
  131. package/dist/workspace/ci-builds.d.ts.map +1 -0
  132. package/dist/workspace/ci-builds.js +315 -0
  133. package/dist/workspace/ci-green-cli.d.ts +2 -1
  134. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  135. package/dist/workspace/ci-green-cli.js +25 -5
  136. package/dist/workspace/ci-green-forge.d.ts +12 -0
  137. package/dist/workspace/ci-green-forge.d.ts.map +1 -1
  138. package/dist/workspace/ci-green-forge.js +12 -0
  139. package/dist/workspace/ci-green-workflow.d.ts +55 -1
  140. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  141. package/dist/workspace/ci-green-workflow.js +128 -1
  142. package/dist/workspace/ci-green.d.ts +89 -4
  143. package/dist/workspace/ci-green.d.ts.map +1 -1
  144. package/dist/workspace/ci-green.js +310 -23
  145. package/dist/workspace/ci-init.d.ts +100 -0
  146. package/dist/workspace/ci-init.d.ts.map +1 -0
  147. package/dist/workspace/ci-init.js +434 -0
  148. package/dist/workspace/ci-last-green.schema.json +49 -0
  149. package/dist/workspace/ci-list.schema.json +35 -1
  150. package/dist/workspace/ci-run-index.d.ts +1 -1
  151. package/dist/workspace/ci-run-index.d.ts.map +1 -1
  152. package/dist/workspace/ci-run-index.schema.json +1 -1
  153. package/dist/workspace/ci-run.d.ts +12 -6
  154. package/dist/workspace/ci-run.d.ts.map +1 -1
  155. package/dist/workspace/ci-run.js +72 -16
  156. package/dist/workspace/ci-runner-start.d.ts +77 -0
  157. package/dist/workspace/ci-runner-start.d.ts.map +1 -0
  158. package/dist/workspace/ci-runner-start.js +185 -0
  159. package/dist/workspace/ci-serve.d.ts +76 -7
  160. package/dist/workspace/ci-serve.d.ts.map +1 -1
  161. package/dist/workspace/ci-serve.js +153 -19
  162. package/dist/workspace/ci-workflow.d.ts +91 -0
  163. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  164. package/dist/workspace/ci-workflow.js +33 -0
  165. package/dist/workspace/declaration.d.ts +35 -0
  166. package/dist/workspace/declaration.d.ts.map +1 -1
  167. package/dist/workspace/declaration.js +12 -2
  168. package/dist/workspace/declaration.schema.json +51 -0
  169. package/dist/workspace/init.js +2 -2
  170. package/dist/workspace/ls.d.ts +2 -0
  171. package/dist/workspace/ls.d.ts.map +1 -1
  172. package/dist/workspace/ls.js +1 -1
  173. package/dist/workspace/ls.schema.json +2 -1
  174. package/dist/workspace/member-pipeline.d.ts +1 -0
  175. package/dist/workspace/member-pipeline.d.ts.map +1 -1
  176. package/dist/workspace/member-pipeline.js +2 -0
  177. package/dist/workspace/merge-record.schema.json +49 -0
  178. package/dist/workspace/pr-report.schema.json +3 -3
  179. package/dist/workspace/reason-codes.d.ts +26 -0
  180. package/dist/workspace/reason-codes.d.ts.map +1 -1
  181. package/dist/workspace/reason-codes.js +28 -0
  182. package/dist/workspace/release-candidates.d.ts +129 -0
  183. package/dist/workspace/release-candidates.d.ts.map +1 -0
  184. package/dist/workspace/release-candidates.js +233 -0
  185. package/dist/workspace/release-candidates.schema.json +353 -0
  186. package/dist/workspace/release-workflow.d.ts +31 -0
  187. package/dist/workspace/release-workflow.d.ts.map +1 -0
  188. package/dist/workspace/release-workflow.js +150 -0
  189. package/dist/workspace/security.d.ts +40 -0
  190. package/dist/workspace/security.d.ts.map +1 -0
  191. package/dist/workspace/security.js +99 -0
  192. package/package.json +8 -1
  193. package/src/__fixtures__/pr-loop/pr-plan.note.golden.md +1 -1
  194. package/src/ci-jobs.ts +11 -2
  195. package/src/cli/handlers/build.test.ts +36 -0
  196. package/src/cli/handlers/build.ts +18 -3
  197. package/src/cli/handlers/lifecycle-health.test.ts +123 -0
  198. package/src/cli/handlers/lifecycle-health.ts +157 -0
  199. package/src/cli/handlers/lifecycle.ts +1 -1
  200. package/src/cli/handlers/pr-deploy-inputs.test.ts +209 -0
  201. package/src/cli/handlers/pr-record.test.ts +201 -0
  202. package/src/cli/handlers/pr.test.ts +22 -0
  203. package/src/cli/handlers/pr.ts +127 -4
  204. package/src/cli/handlers/promote.test.ts +17 -0
  205. package/src/cli/handlers/promote.ts +296 -84
  206. package/src/cli/handlers/release-cut-at.e2e.test.ts +115 -0
  207. package/src/cli/handlers/release-publish-targets.e2e.test.ts +2 -2
  208. package/src/cli/handlers/release-verify.e2e.test.ts +12 -10
  209. package/src/cli/handlers/release.e2e.test.ts +19 -28
  210. package/src/cli/handlers/release.ts +81 -11
  211. package/src/cli/main.ts +99 -22
  212. package/src/cli/parse-args.ts +22 -1
  213. package/src/cli/registry.ts +21 -5
  214. package/src/components/deploy-inputs.test.ts +108 -0
  215. package/src/components/deploy-inputs.ts +225 -0
  216. package/src/components/driver.ts +11 -0
  217. package/src/components/gate-plan.ts +9 -0
  218. package/src/components/pr-pipeline.ts +56 -15
  219. package/src/components/pr-run.ts +1 -1
  220. package/src/components/promote-rounds.test.ts +220 -0
  221. package/src/components/promote-rounds.ts +232 -0
  222. package/src/components/release-compile.test.ts +34 -0
  223. package/src/components/release-compile.ts +89 -8
  224. package/src/components/release-gate.test.ts +47 -0
  225. package/src/components/release-gate.ts +111 -46
  226. package/src/components/release-promote.test.ts +55 -2
  227. package/src/components/release-promote.ts +93 -23
  228. package/src/components/verbs/bom-writer.ts +1 -1
  229. package/src/components/verbs/lockfile-sbom-generator.test.ts +36 -0
  230. package/src/components/verbs/lockfile-sbom-generator.ts +43 -5
  231. package/src/components/wave-plan.ts +32 -4
  232. package/src/graph-declared-exports.test.ts +40 -0
  233. package/src/graph-declared.ts +9 -1
  234. package/src/graph-detail.test.ts +57 -0
  235. package/src/graph-detail.ts +33 -7
  236. package/src/health.schema.json +191 -0
  237. package/src/health.test.ts +62 -0
  238. package/src/health.ts +178 -0
  239. package/src/lexicon.ts +41 -1
  240. package/src/lifecycle/merge-record.test.ts +111 -0
  241. package/src/lifecycle/merge-record.ts +154 -0
  242. package/src/lifecycle/publish-attachments.ts +25 -13
  243. package/src/lifecycle/publish-npm.ts +4 -3
  244. package/src/lifecycle/publish-targets.test.ts +34 -4
  245. package/src/lifecycle/release-at.test.ts +48 -0
  246. package/src/lifecycle/release-at.ts +100 -0
  247. package/src/lifecycle/release-cut.test.ts +41 -0
  248. package/src/lifecycle/release-cut.ts +22 -11
  249. package/src/lifecycle/release-publish.test.ts +43 -13
  250. package/src/lifecycle/release-publish.ts +38 -18
  251. package/src/lifecycle/release-store.ts +57 -13
  252. package/src/lifecycle/run-ledger.test.ts +13 -0
  253. package/src/lifecycle/run-ledger.ts +1 -0
  254. package/src/lint/rules/op/index.ts +4 -2
  255. package/src/lint/rules/op/ops018-shell-image-digest.test.ts +60 -0
  256. package/src/lint/rules/op/ops018-shell-image-digest.ts +48 -0
  257. package/src/op/activities/activity-contracts.ts +3 -0
  258. package/src/op/activities/shell-container.e2e.test.ts +67 -0
  259. package/src/op/activities/shell.test.ts +44 -1
  260. package/src/op/activities/shell.ts +110 -3
  261. package/src/op/builders.ts +7 -0
  262. package/src/op/runtime.ts +2 -0
  263. package/src/op/step-console.test.ts +4 -2
  264. package/src/pr-loop.test.ts +29 -0
  265. package/src/pr-loop.ts +26 -7
  266. package/src/pr-report-url.ts +2 -2
  267. package/src/runner-start-bundle.test.ts +29 -0
  268. package/src/runner-start.test.ts +179 -0
  269. package/src/runner-start.ts +329 -0
  270. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  271. package/src/workspace/check-contract.test.ts +4 -2
  272. package/src/workspace/check.schema.json +88 -2
  273. package/src/workspace/checks/ci-workflow.test.ts +150 -0
  274. package/src/workspace/checks/ci-workflow.ts +129 -0
  275. package/src/workspace/checks/security.test.ts +138 -0
  276. package/src/workspace/checks/security.ts +305 -0
  277. package/src/workspace/checks.ts +8 -0
  278. package/src/workspace/ci-builds.e2e.test.ts +208 -0
  279. package/src/workspace/ci-builds.test.ts +174 -0
  280. package/src/workspace/ci-builds.ts +385 -0
  281. package/src/workspace/ci-green-builds.test.ts +180 -0
  282. package/src/workspace/ci-green-cli.test.ts +54 -0
  283. package/src/workspace/ci-green-cli.ts +26 -5
  284. package/src/workspace/ci-green-forge.ts +24 -0
  285. package/src/workspace/ci-green-workflow.test.ts +84 -1
  286. package/src/workspace/ci-green-workflow.ts +139 -2
  287. package/src/workspace/ci-green.test.ts +5 -2
  288. package/src/workspace/ci-green.ts +359 -27
  289. package/src/workspace/ci-init.test.ts +159 -0
  290. package/src/workspace/ci-init.ts +445 -0
  291. package/src/workspace/ci-last-green.schema.json +49 -0
  292. package/src/workspace/ci-list.schema.json +35 -1
  293. package/src/workspace/ci-report-url.test.ts +6 -1
  294. package/src/workspace/ci-run-index.schema.json +1 -1
  295. package/src/workspace/ci-run-index.ts +1 -1
  296. package/src/workspace/ci-run.test.ts +24 -7
  297. package/src/workspace/ci-run.ts +78 -16
  298. package/src/workspace/ci-runner-start.test.ts +151 -0
  299. package/src/workspace/ci-runner-start.ts +193 -0
  300. package/src/workspace/ci-serve-builds.test.ts +86 -0
  301. package/src/workspace/ci-serve.test.ts +59 -0
  302. package/src/workspace/ci-serve.ts +177 -22
  303. package/src/workspace/ci-workflow.ts +71 -0
  304. package/src/workspace/declaration.schema.json +51 -0
  305. package/src/workspace/declaration.test.ts +17 -0
  306. package/src/workspace/declaration.ts +53 -3
  307. package/src/workspace/init.ts +2 -2
  308. package/src/workspace/ls.schema.json +2 -1
  309. package/src/workspace/ls.ts +3 -2
  310. package/src/workspace/member-pipeline.ts +2 -1
  311. package/src/workspace/merge-record.schema.json +49 -0
  312. package/src/workspace/pr-report.schema.json +3 -3
  313. package/src/workspace/reason-codes.test.ts +4 -0
  314. package/src/workspace/reason-codes.ts +28 -0
  315. package/src/workspace/release-candidates.schema.json +353 -0
  316. package/src/workspace/release-candidates.test.ts +106 -0
  317. package/src/workspace/release-candidates.ts +315 -0
  318. package/src/workspace/release-workflow.ts +167 -0
  319. package/src/workspace/security.test.ts +70 -0
  320. package/src/workspace/security.ts +118 -0
@@ -0,0 +1,180 @@
1
+ /**
2
+ * #4169 — the tick against a real remote with a fake forge, for a phase that
3
+ * builds only the newest commit: the records it writes on chant/lifecycle,
4
+ * the build requests it pushes and starts, the retries it asks for, and what
5
+ * `ci list`, `ci last-green` and `ci run --at` read and do.
6
+ */
7
+
8
+ import { execFileSync } from "node:child_process";
9
+ import { afterAll, describe, expect, test } from "vitest";
10
+ import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
11
+ import { ciList, ciTick, lastGreen, requestBuild } from "./ci-green";
12
+ import type { CheckRun, CiForge } from "./ci-green-forge";
13
+ import { readBuildRequests, readCiRecord } from "./ci-builds";
14
+
15
+ afterAll(cleanScratch);
16
+
17
+ const NOW = new Date("2026-10-20T12:00:00Z");
18
+ const hoursAgo = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString();
19
+
20
+ const GREEN = {
21
+ branch: "main",
22
+ phases: { check: { runs: ["check"], build: "every" }, suites: { runs: ["suites"], build: "newest" } },
23
+ require: ["check", "suites"],
24
+ };
25
+
26
+ 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";
27
+
28
+ function gitAt(cwd: string, date: string, ...args: string[]): string {
29
+ return execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], {
30
+ cwd,
31
+ encoding: "utf-8",
32
+ stdio: ["ignore", "pipe", "pipe"],
33
+ env: { ...process.env, GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date },
34
+ }).trim();
35
+ }
36
+
37
+ function commitAt(root: string, hours: number, file: string): string {
38
+ writeFiles(root, { [file]: `${file} ${hours}\n` });
39
+ gitAt(root, hoursAgo(hours), "add", "-A");
40
+ gitAt(root, hoursAgo(hours), "commit", "-q", "-m", file);
41
+ return git(root, "rev-parse", "HEAD");
42
+ }
43
+
44
+ function workspace(): { root: string; origin: string } {
45
+ const origin = scratchDir("chant-builds-origin-");
46
+ git(origin, "init", "-q", "--bare");
47
+ const root = scratchDir("chant-builds-work-");
48
+ git(root, "init", "-q");
49
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
50
+ writeFiles(root, {
51
+ "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: GREEN } }),
52
+ "app/a.txt": "a\n",
53
+ ".github/workflows/release-suites.yml": SUITES_WORKFLOW,
54
+ });
55
+ git(root, "remote", "add", "origin", origin);
56
+ return { root, origin };
57
+ }
58
+
59
+ let id = 1;
60
+ const run = (name: string, conclusion: string | null, status = "completed"): CheckRun => ({ id: id++, name, status, conclusion: status === "completed" ? conclusion : null, completedAt: null, url: null });
61
+
62
+ /** A forge whose runs the test sets per commit, recording the re-runs and the workflows started. */
63
+ function fakeForge(runs: Map<string, CheckRun[]>): CiForge & { reruns: number[]; dispatched: string[] } {
64
+ const reruns: number[] = [];
65
+ const dispatched: string[] = [];
66
+ return {
67
+ kind: "github",
68
+ reruns,
69
+ dispatched,
70
+ checkRuns: async (sha) => runs.get(sha) ?? [],
71
+ rerun: async (r) => void reruns.push(r.id),
72
+ dispatch: async (w, ref) => void dispatched.push(`${w}@${ref}`),
73
+ };
74
+ }
75
+
76
+ const remoteRecord = (origin: string, sha: string) => JSON.parse(git(origin, "show", `refs/heads/chant/lifecycle:_ci/${sha}.json`));
77
+ const remoteTags = (origin: string) => git(origin, "for-each-ref", "--format=%(refname:lstrip=2)", "refs/tags/").split("\n").filter(Boolean).sort();
78
+
79
+ describe("the tick for a newest phase (#4169)", () => {
80
+ test("records each commit, and asks for a bisect step at a ci/build tag and starts the workflow there once", async () => {
81
+ const { root, origin } = workspace();
82
+ const base = commitAt(root, 6, "app/base.txt");
83
+ const a = commitAt(root, 5, "app/a.txt");
84
+ const b = commitAt(root, 4, "app/b.txt");
85
+ const c = commitAt(root, 3, "app/c.txt");
86
+ git(root, "push", "-q", "origin", "main");
87
+ const runs = new Map<string, CheckRun[]>([
88
+ [base, [run("check", "success"), run("suites", "success")]],
89
+ [a, [run("check", "success")]],
90
+ [b, [run("check", "success")]],
91
+ [c, [run("check", "success"), run("suites", "failure"), run("suites", "failure")]],
92
+ ]);
93
+ const forge = fakeForge(runs);
94
+
95
+ const first = await ciTick({ cwd: root, forge, now: NOW });
96
+ expect(first.made).toEqual([`ci/green/${base}`]);
97
+ // The newest red asks for the commit just below it.
98
+ expect(first.requested.map((r) => [r.commit, r.reason])).toEqual([[b, "bisect"]]);
99
+ expect(remoteTags(origin)).toEqual([`ci/build/${b}`, `ci/green/${base}`].sort());
100
+ expect(forge.dispatched).toEqual([`release-suites.yml@ci/build/${b}`]);
101
+ expect(remoteRecord(origin, a)).toMatchObject({ state: "unbuilt", phases: { suites: { verdict: "unbuilt", reason: "superseded", coveredBy: c } } });
102
+ expect(remoteRecord(origin, b)).toMatchObject({ state: "pending", request: { tag: `ci/build/${b}`, reason: "bisect", dispatched: true }, phases: { suites: { reason: "requested" } } });
103
+ expect(remoteRecord(origin, c)).toMatchObject({ state: "red", failure: { phase: "suites" } });
104
+
105
+ // A second tick asks for nothing more and starts nothing again while b's build waits.
106
+ const second = await ciTick({ cwd: root, forge, now: NOW });
107
+ expect(second.requested).toEqual([]);
108
+ expect(forge.dispatched).toHaveLength(1);
109
+
110
+ // ci list and ci last-green read the records. (The walk to the culprit is ci-builds.e2e.test.ts.)
111
+ const list = await ciList({ cwd: root, now: NOW });
112
+ if ("error" in list) throw new Error(list.error.message);
113
+ expect(list.commits.find((x) => x.sha === a)!.record).toEqual({ state: "unbuilt", reason: "superseded", coveredBy: c });
114
+ const last = lastGreen({ cwd: root });
115
+ if ("error" in last) throw new Error(last.error.message);
116
+ expect(last.commit!.sha).toBe(base);
117
+ expect(last.newer.map((n) => [n.sha, n.record?.state])).toEqual([
118
+ [c, "red"],
119
+ [b, "pending"],
120
+ [a, "unbuilt"],
121
+ ]);
122
+ });
123
+
124
+ test("runs a failed job again once before the commit counts red", async () => {
125
+ const { root, origin } = workspace();
126
+ const a = commitAt(root, 2, "app/a.txt");
127
+ git(root, "push", "-q", "origin", "main");
128
+ const failed = run("check", "failure");
129
+ const runs = new Map([[a, [failed, run("suites", "success")]]]);
130
+ const forge = fakeForge(runs);
131
+ const tick = await ciTick({ cwd: root, forge, now: NOW });
132
+ expect(tick.retried).toEqual([{ sha: a, run: "check" }]);
133
+ expect(forge.reruns).toEqual([failed.id]);
134
+ expect(remoteRecord(origin, a)).toMatchObject({ state: "pending", retried: { check: { run: failed.id } } });
135
+ // Asked once: the next tick waits for the attempt and does not ask again.
136
+ await ciTick({ cwd: root, forge, now: NOW });
137
+ expect(forge.reruns).toEqual([failed.id]);
138
+ runs.set(a, [failed, run("check", "success"), run("suites", "success")]);
139
+ expect((await ciTick({ cwd: root, forge, now: NOW })).made).toEqual([`ci/green/${a}`]);
140
+ });
141
+
142
+ test("a dry run writes no record and pushes no request", async () => {
143
+ const { root, origin } = workspace();
144
+ const base = commitAt(root, 4, "app/base.txt");
145
+ const a = commitAt(root, 3, "app/a.txt");
146
+ const b = commitAt(root, 2, "app/b.txt");
147
+ git(root, "push", "-q", "origin", "main");
148
+ const runs = new Map([
149
+ [base, [run("check", "success"), run("suites", "success")]],
150
+ [a, [run("check", "success")]],
151
+ [b, [run("check", "success"), run("suites", "failure"), run("suites", "failure")]],
152
+ ]);
153
+ const tick = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW, dryRun: true });
154
+ expect(tick.requested.map((r) => r.commit)).toEqual([a]);
155
+ expect(tick.recorded).toEqual([]);
156
+ expect(remoteTags(origin)).toEqual([]);
157
+ expect(() => git(origin, "rev-parse", "--verify", "refs/heads/chant/lifecycle")).toThrow();
158
+ });
159
+ });
160
+
161
+ describe("chant ci run --at (#4169)", () => {
162
+ test("pushes a request for every required phase and starts the workflows holding them", async () => {
163
+ const { root, origin } = workspace();
164
+ const a = commitAt(root, 2, "app/a.txt");
165
+ commitAt(root, 1, "app/b.txt");
166
+ git(root, "push", "-q", "origin", "main");
167
+ const forge = fakeForge(new Map());
168
+ const r = await requestBuild({ cwd: root, at: a, forge, now: NOW });
169
+ expect(r.request).toMatchObject({ commit: a, phases: ["check", "suites"], reason: "requested", tag: `ci/build/${a}` });
170
+ expect(r.existed).toBe(false);
171
+ expect(r.dispatched).toEqual(["release-suites.yml"]);
172
+ expect(remoteTags(origin)).toEqual([`ci/build/${a}`]);
173
+ expect(readBuildRequests(root).get(a)).toMatchObject({ reason: "requested" });
174
+ expect((await requestBuild({ cwd: root, at: a, now: NOW })).existed).toBe(true);
175
+ await expect(requestBuild({ cwd: root, at: "nope", now: NOW })).rejects.toThrow(/names no commit/);
176
+ // The tick takes the run already going at the commit as started, and records the request.
177
+ await ciTick({ cwd: root, forge: fakeForge(new Map([[a, [run("check", "success"), run("suites", null, "in_progress")]]])), now: NOW });
178
+ expect(readCiRecord(root, a)).toMatchObject({ state: "pending", request: { reason: "requested", dispatched: true } });
179
+ });
180
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `chant ci tick` under the who-ran-it security group (#4168): no key, no tick.
3
+ */
4
+
5
+ import { execFileSync } from "node:child_process";
6
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { afterAll, afterEach, describe, expect, test, vi } from "vitest";
10
+ import { runCiTick } from "./ci-green-cli";
11
+ import { RUNNER_KEY_ENV } from "./ci-green";
12
+
13
+ const scratch: string[] = [];
14
+ afterAll(() => {
15
+ for (const d of scratch) rmSync(d, { recursive: true, force: true });
16
+ });
17
+ afterEach(() => {
18
+ vi.restoreAllMocks();
19
+ vi.unstubAllEnvs();
20
+ });
21
+
22
+ function repo(extra: Record<string, unknown>): string {
23
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-ci-tick-")));
24
+ scratch.push(root);
25
+ execFileSync("git", ["init", "-q"], { cwd: root });
26
+ writeFileSync(
27
+ join(root, "chant.workspace.json"),
28
+ JSON.stringify({ name: "acme", schema: 1, members: [], ci: { green: { branch: "main", phases: { test: ["test"] }, require: ["test"], source: "runs" } }, ...extra }),
29
+ );
30
+ return root;
31
+ }
32
+
33
+ const ctx = (args: Record<string, unknown> = {}) => ({ args: { command: "ci", path: "tick", ...args }, plugins: [] }) as never;
34
+
35
+ describe("ci tick and who-ran-it", () => {
36
+ test("under who-ran-it a tick with no runner key is refused before it reads anything", async () => {
37
+ vi.stubEnv(RUNNER_KEY_ENV, "");
38
+ vi.spyOn(process, "cwd").mockReturnValue(repo({ security: ["who-ran-it"] }));
39
+ const errors: string[] = [];
40
+ vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => void errors.push(m.map(String).join(" ")));
41
+ expect(await runCiTick(ctx())).toBe(1);
42
+ expect(errors.join("\n")).toContain("the workspace opts into who-ran-it, so the tick signs every tag it makes and needs a runner key");
43
+ });
44
+
45
+ test("without the group the same tick is not refused for its key", async () => {
46
+ vi.stubEnv(RUNNER_KEY_ENV, "");
47
+ vi.spyOn(process, "cwd").mockReturnValue(repo({}));
48
+ const errors: string[] = [];
49
+ vi.spyOn(console, "error").mockImplementation((...m: unknown[]) => void errors.push(m.map(String).join(" ")));
50
+ vi.spyOn(console, "log").mockImplementation(() => {});
51
+ await runCiTick(ctx());
52
+ expect(errors.join("\n")).not.toContain("who-ran-it");
53
+ });
54
+ });
@@ -8,7 +8,8 @@
8
8
  * tag the commits that turned green, revoke the ones that turned red
9
9
  * ci workflow [--workflow <name>]... [--output <file>] [--chant <command>] [--install <command>]
10
10
  * [--token-secret <NAME> | --app-id-var <VAR> --app-key-secret <NAME>] [--runner-key-secret <NAME>]
11
- * write the root CI file that runs the tick
11
+ * write the root CI file that runs the tick, and the triggers and
12
+ * concurrency of the workflows holding a phase that sets build (#4169)
12
13
  *
13
14
  * The tick signs its tags with a runner key (#4031) from `--key <file>` or
14
15
  * the PEM in `CHANT_RUNNER_KEY`, which the generated workflow sets from the
@@ -30,7 +31,7 @@ import type { CommandContext } from "../cli/registry";
30
31
  import { findWorkspaceRoot } from "../project-root";
31
32
  import { ciList, ciTick, CiGreenError, formatList, formatTick, lastGreen, RUNNER_KEY_ENV } from "./ci-green";
32
33
  import { ciForgeFromEnv } from "./ci-green-forge";
33
- import { ACTIONS_NAME, CI_GREEN_WORKFLOW_FILE, ciGreenHeader, type PushToken, readWorkflowSources, renderCiGreenWorkflow, workflowsHolding, writeWorkflowFile } from "./ci-green-workflow";
34
+ import { ACTIONS_NAME, buildTriggerEdits, CI_GREEN_WORKFLOW_FILE, ciGreenHeader, type PushToken, readWorkflowSources, renderCiGreenWorkflow, workflowsHolding, writeWorkflowFile } from "./ci-green-workflow";
34
35
  import { readDeclaration, readerVersion, WorkspaceReadError, type CiSource } from "./declaration";
35
36
  import { recordGeneratedFiles, resolveMemberContext, shellArg } from "./member-pipeline";
36
37
  import { gitTop, workingTree } from "./tree";
@@ -105,6 +106,13 @@ export async function runCiTick(ctx: CommandContext): Promise<number> {
105
106
  // The forge is made only when the source is check runs, so a tick from chant runs needs no forge token.
106
107
  const forge = () => ciForgeFromEnv(ctx.args.forge ?? "github");
107
108
  const runnerKey = ctx.args.key !== undefined ? readFileSync(resolve(ctx.args.key), "utf-8") : process.env[RUNNER_KEY_ENV] || undefined;
109
+ // #4168: under who-ran-it every tag the tick makes is signed, so a tick without a key makes none.
110
+ if (runnerKey === undefined && ctx.args.dryRun !== true) {
111
+ const { securityGroupsAt } = await import("./security");
112
+ if (securityGroupsAt(process.cwd()).has("who-ran-it")) {
113
+ throw new Error(`the workspace opts into who-ran-it, so the tick signs every tag it makes and needs a runner key; pass --key <runner key.pem> or set ${RUNNER_KEY_ENV}`);
114
+ }
115
+ }
108
116
  const result = await ciTick({ cwd: process.cwd(), forge, dryRun: ctx.args.dryRun === true, ...(source ? { source } : {}), ...(runnerKey !== undefined ? { runnerKey } : {}) });
109
117
  console.log(formatTick(result, ctx.args.dryRun === true));
110
118
  return 0;
@@ -143,7 +151,10 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
143
151
  const target = ctx.args.output ? resolve(realpathSync(cwd), ctx.args.output) : join(repoRoot, CI_GREEN_WORKFLOW_FILE);
144
152
  const fromRepo = relative(repoRoot, target).split(sep).join("/");
145
153
 
146
- const scan = workflowsHolding(green, readWorkflowSources(repoRoot, fromRepo));
154
+ const sources = readWorkflowSources(repoRoot, fromRepo);
155
+ const scan = workflowsHolding(green, sources);
156
+ // #4169: the triggers and concurrency of the workflows holding a phase that sets build. Refused before anything is written.
157
+ const edits = buildTriggerEdits(green, sources);
147
158
  const extra = ctx.args.ciWorkflows ?? [];
148
159
  const workflows = [...new Set([...scan.workflows, ...extra])].sort();
149
160
  for (const f of scan.unreadable) console.error(`warning: ${f} is not valid YAML, so its jobs were not read`);
@@ -161,8 +172,9 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
161
172
  if (push && "appIdVar" in push) parts.push("--app-id-var", push.appIdVar, "--app-key-secret", push.appKeySecret);
162
173
  const runnerKeySecret = ctx.args.ciRunnerKeySecret;
163
174
  if (runnerKeySecret) parts.push("--runner-key-secret", runnerKeySecret);
164
- if (green.signedSince && !runnerKeySecret) {
165
- console.error(`warning: ci.green.signedSince is set, so the tick needs a runner key; pass --runner-key-secret <NAME> naming the repository secret that holds it, or every tick will fail`);
175
+ const { securityGroupsAt } = await import("./security");
176
+ if ((green.signedSince || securityGroupsAt(root).has("who-ran-it")) && !runnerKeySecret) {
177
+ console.error(`warning: ci.green.signedSince is set or the workspace opts into who-ran-it, so the tick needs a runner key; pass --runner-key-secret <NAME> naming the repository secret that holds it, or every tick will fail`);
166
178
  }
167
179
  const command = parts.map(shellArg).join(" ");
168
180
  const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
@@ -172,6 +184,15 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
172
184
  const written = writeWorkflowFile(target, ciGreenHeader(command, member ? "member" : "root") + yaml);
173
185
  if (member) recordGeneratedFiles(member.memberRoot, [{ path: fromRepo, command }]);
174
186
  console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}, and every 15 minutes.`);
187
+ for (const e of edits) {
188
+ if (!e.changed) continue;
189
+ const path = writeWorkflowFile(join(repoRoot, e.path), e.text);
190
+ console.log(
191
+ e.build === "newest"
192
+ ? `Wrote ${path}'s triggers and concurrency: ${e.phases.join(", ")} build${e.phases.length === 1 ? "s" : ""} the newest commit on ${green.branch} when a run ends, and a ci/build/<sha> tag by hand.`
193
+ : `Wrote ${path}'s concurrency: ${e.phases.join(", ")} build${e.phases.length === 1 ? "s" : ""} every commit on ${green.branch}, none cancelled.`,
194
+ );
195
+ }
175
196
  return 0;
176
197
  } catch (err) {
177
198
  console.error(formatError({ message: `chant ci workflow: ${describe(err)}`, hint: USAGE }));
@@ -55,6 +55,18 @@ export interface CiForge {
55
55
  kind: ForgeKind;
56
56
  /** Every check run on `sha`, every attempt. */
57
57
  checkRuns(sha: string): Promise<CheckRun[]>;
58
+ /**
59
+ * Run a failed check run's job again (#4169), so a flake costs one more
60
+ * run before the commit counts red. Absent on a forge chant can't ask.
61
+ */
62
+ rerun?(run: CheckRun): Promise<void>;
63
+ /**
64
+ * Start a workflow at a ref (#4169), for a build the tick asks for: a
65
+ * bisect step, or `chant ci run --at`. `workflow` is the file's name in
66
+ * `.github/workflows/`, and `ref` a branch or tag. Absent on a forge chant
67
+ * can't ask.
68
+ */
69
+ dispatch?(workflow: string, ref: string): Promise<void>;
58
70
  }
59
71
 
60
72
  export interface GithubCiForgeOptions {
@@ -103,7 +115,19 @@ export function githubCiForge(opts: GithubCiForgeOptions): CiForge {
103
115
  }
104
116
  return out;
105
117
  },
118
+ // An Actions job's check run has the job's id, so the job is re-run by it. Both need `actions: write`.
119
+ async rerun(run) {
120
+ await post(`${root}/repos/${opts.repo}/actions/jobs/${run.id}/rerun`, {});
121
+ },
122
+ async dispatch(workflow, ref) {
123
+ await post(`${root}/repos/${opts.repo}/actions/workflows/${encodeURIComponent(workflow)}/dispatches`, { ref });
124
+ },
106
125
  };
126
+
127
+ async function post(url: string, body: unknown): Promise<void> {
128
+ const res = await fetchImpl(url, { method: "POST", headers: { ...headers, "Content-Type": "application/json" }, body: JSON.stringify(body) });
129
+ if (!res.ok) throw new ForgeApiError("POST", url, res.status, await res.text());
130
+ }
107
131
  }
108
132
 
109
133
  /** Thrown for a forge `chant ci tick` has no check-run client for yet. */
@@ -11,7 +11,7 @@ import { GENERATED_MARKER } from "../discovery/files";
11
11
  import type { CommandContext } from "../cli/registry";
12
12
  import { parseYAMLDocument } from "../yaml";
13
13
  import { runCiWorkflow } from "./ci-green-cli";
14
- import { CI_GREEN_WORKFLOW_FILE, jobCheckRuns, renderCiGreenWorkflow, workflowsHolding } from "./ci-green-workflow";
14
+ import { BUILD_BLOCK_MARKER, buildTriggerEdits, CI_GREEN_WORKFLOW_FILE, dispatchableWorkflows, jobCheckRuns, renderCiGreenWorkflow, workflowsHolding, writeTopLevel } from "./ci-green-workflow";
15
15
  import { parseDeclaration } from "./declaration";
16
16
  import { cleanScratch, declaration, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
17
17
 
@@ -302,3 +302,86 @@ describe("chant ci workflow (#3573)", () => {
302
302
  }
303
303
  });
304
304
  });
305
+
306
+ // ── A phase's triggers (#4169) ───────────────────────────────────────────────
307
+
308
+ const BUILD_GREEN = { branch: "main", phases: { lint: { runs: ["lint"], build: "every" }, suites: { runs: ["suites"], build: "newest" } }, require: ["lint", "suites"] };
309
+ const LINT_WF = `name: ci
310
+ on:
311
+ push:
312
+ branches: [main]
313
+ pull_request:
314
+
315
+ # Comments above a block stay.
316
+ concurrency:
317
+ group: ci-\${{ github.ref }}
318
+ cancel-in-progress: true
319
+
320
+ jobs:
321
+ lint:
322
+ runs-on: ubuntu-latest
323
+ `;
324
+ const SUITES_WF = `name: suites
325
+ on:
326
+ schedule:
327
+ - cron: "0 */3 * * *"
328
+ workflow_dispatch: {}
329
+ permissions:
330
+ contents: read
331
+ jobs:
332
+ suites:
333
+ runs-on: ubuntu-latest
334
+ `;
335
+
336
+ describe("the triggers of a phase that sets build (#4169)", () => {
337
+ const green = () => parseDeclaration(JSON.stringify({ name: "w", schema: 1, members: [], ci: { green: BUILD_GREEN } }), "chant.workspace.json").ci!.green!;
338
+
339
+ test("newest: pushes to the branch and by hand, no schedule, one group per ref that never cancels; every: the commit's own group on the branch", () => {
340
+ const edits = buildTriggerEdits(green(), [
341
+ { path: ".github/workflows/ci.yml", text: LINT_WF },
342
+ { path: ".github/workflows/suites.yml", text: SUITES_WF },
343
+ ]);
344
+ expect(edits.map((e) => [e.path, e.build, e.phases, e.changed])).toEqual([
345
+ [".github/workflows/ci.yml", "every", ["lint"], true],
346
+ [".github/workflows/suites.yml", "newest", ["suites"], true],
347
+ ]);
348
+ const ci = parseYAMLDocument(edits[0].text) as Yaml;
349
+ // The triggers of an every phase stay, pull requests included.
350
+ expect(ci.on).toEqual({ push: { branches: ["main"] }, pull_request: null });
351
+ expect(ci.concurrency).toEqual({ group: "chant-build-lint-${{ github.ref == 'refs/heads/main' && github.sha || github.ref }}", "cancel-in-progress": "${{ github.ref != 'refs/heads/main' }}" });
352
+ expect(edits[0].text).toContain(`# Comments above a block stay.\n${BUILD_BLOCK_MARKER}\nconcurrency:`);
353
+ const suites = parseYAMLDocument(edits[1].text) as Yaml;
354
+ expect(suites.on).toEqual({ push: { branches: ["main"] }, workflow_dispatch: {} });
355
+ expect(suites.concurrency).toEqual({ group: "chant-build-suites-${{ github.ref }}", "cancel-in-progress": false });
356
+ // A file without the block gets it before permissions:, and writing again changes nothing.
357
+ expect(edits[1].text.indexOf("concurrency:")).toBeLessThan(edits[1].text.indexOf("permissions:"));
358
+ const again = buildTriggerEdits(green(), edits.map((e) => ({ path: e.path, text: e.text })));
359
+ expect(again.map((e) => e.changed)).toEqual([false, false]);
360
+ });
361
+
362
+ test("a phase without build leaves its workflow alone, and one workflow holding both builds is refused", () => {
363
+ const plain = parseDeclaration(JSON.stringify({ name: "w", schema: 1, members: [], ci: { green: { branch: "main", phases: { lint: ["lint"] }, require: ["lint"] } } }), "chant.workspace.json").ci!.green!;
364
+ expect(buildTriggerEdits(plain, [{ path: ".github/workflows/ci.yml", text: LINT_WF }])).toEqual([]);
365
+ const both = LINT_WF.replace("jobs:\n", "jobs:\n suites:\n runs-on: ubuntu-latest\n");
366
+ expect(() => buildTriggerEdits(green(), [{ path: ".github/workflows/ci.yml", text: both }])).toThrow(/holds runs of lint \(build: every\) and suites \(build: newest\)/);
367
+ });
368
+
369
+ test("writeTopLevel replaces a block up to the next top-level line, or adds one before jobs", () => {
370
+ expect(writeTopLevel("a: 1\nconcurrency:\n group: x\n\njobs: {}\n", "concurrency", { group: "y" })).toBe(`a: 1\n${BUILD_BLOCK_MARKER}\nconcurrency:\n group: y\n\njobs: {}\n`);
371
+ expect(writeTopLevel("a: 1\njobs: {}\n", "concurrency", { group: "y" })).toBe(`a: 1\n${BUILD_BLOCK_MARKER}\nconcurrency:\n group: y\n\njobs: {}\n`);
372
+ });
373
+
374
+ test("the tick may re-run jobs and start workflows, and ticks on a build tag's runs, only when a phase sets build", () => {
375
+ const tick = parseYAMLDocument(renderCiGreenWorkflow({ green: green(), workflows: ["ci"], chantVersion: "0.124.33", workspaceRoot: "." })) as Yaml;
376
+ expect(tick.permissions).toEqual({ contents: "write", checks: "read", actions: "write" });
377
+ expect(tick.on.workflow_run.branches).toEqual(["main", "ci/build/**"]);
378
+ const plain = parseDeclaration(JSON.stringify({ name: "w", schema: 1, members: [], ci: { green: { branch: "main", phases: { lint: ["lint"] }, require: ["lint"] } } }), "chant.workspace.json").ci!.green!;
379
+ const before = parseYAMLDocument(renderCiGreenWorkflow({ green: plain, workflows: ["ci"], chantVersion: "0.124.33", workspaceRoot: "." })) as Yaml;
380
+ expect(before.permissions).toEqual({ contents: "write", checks: "read" });
381
+ expect(before.on.workflow_run.branches).toEqual(["main"]);
382
+ });
383
+
384
+ test("the workflows a build request can start are the ones holding its phases with workflow_dispatch", () => {
385
+ expect(dispatchableWorkflows(green(), ["lint", "suites"], [{ path: ".github/workflows/ci.yml", text: LINT_WF }, { path: ".github/workflows/suites.yml", text: SUITES_WF }])).toEqual({ dispatchable: ["suites.yml"], without: ["ci.yml"] });
386
+ });
387
+ });
@@ -48,8 +48,8 @@
48
48
  import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
49
49
  import { dirname, join, relative, resolve, sep } from "node:path";
50
50
  import { GENERATED_MARKER } from "../discovery/files";
51
- import { parseYAMLDocument } from "../yaml";
52
- import type { CiGreen } from "./declaration";
51
+ import { emitYAMLEntry, parseYAMLDocument } from "../yaml";
52
+ import type { CiBuild, CiGreen } from "./declaration";
53
53
  import { matchesCheckRun } from "./ci-green";
54
54
  import { githubSecretOrJobToken } from "../ci-jobs";
55
55
 
@@ -129,6 +129,31 @@ export function workflowsHolding(green: CiGreen, sources: readonly WorkflowSourc
129
129
  };
130
130
  }
131
131
 
132
+ /**
133
+ * The workflow files that hold a check run of one of `phases` and can be
134
+ * started by hand (`workflow_dispatch`), by file name, for a build the tick
135
+ * asks for (#4169). Files that can't be parsed are left out.
136
+ */
137
+ export function dispatchableWorkflows(green: CiGreen, phases: readonly string[], sources: readonly WorkflowSource[]): { dispatchable: string[]; without: string[] } {
138
+ const patterns = phases.flatMap((name) => green.phases.find((p) => p.name === name)?.runs ?? []);
139
+ const dispatchable: string[] = [];
140
+ const without: string[] = [];
141
+ for (const s of sources) {
142
+ let doc: unknown;
143
+ try {
144
+ doc = parseYAMLDocument(s.text);
145
+ } catch {
146
+ continue;
147
+ }
148
+ const runs = jobCheckRuns(doc);
149
+ if (!patterns.some((p) => runs.some((r) => meet(p, r)))) continue;
150
+ const on = isObject(doc) ? doc.on : undefined;
151
+ const file = s.path.slice(s.path.lastIndexOf("/") + 1);
152
+ (isObject(on) && "workflow_dispatch" in on ? dispatchable : without).push(file);
153
+ }
154
+ return { dispatchable: dispatchable.sort(), without: without.sort() };
155
+ }
156
+
132
157
  /** The repository's GitHub workflow files, other than `skip`. */
133
158
  export function readWorkflowSources(repoRoot: string, skip: string): WorkflowSource[] {
134
159
  const dir = join(repoRoot, ".github", "workflows");
@@ -213,6 +238,8 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
213
238
  " - completed",
214
239
  " branches:",
215
240
  ` - ${q(green.branch)}`,
241
+ // #4169: a build the tick asked for runs at a ci/build/<sha> tag, and its completion ticks too.
242
+ ...(newestPhases(green) ? [` - ${q("ci/build/**")}`] : []),
216
243
  " schedule:",
217
244
  " - cron: \"*/15 * * * *\"",
218
245
  " workflow_dispatch: {}",
@@ -225,6 +252,8 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
225
252
  "permissions:",
226
253
  " contents: write",
227
254
  " checks: read",
255
+ // #4169: re-running a failed job and starting a workflow at a ci/build/<sha> tag.
256
+ ...(declaresBuild(green) ? [" actions: write"] : []),
228
257
  "",
229
258
  "jobs:",
230
259
  " tick:",
@@ -254,6 +283,114 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
254
283
  return lines.join("\n") + "\n";
255
284
  }
256
285
 
286
+ /** Whether any phase sets `build` (#4169): then the tick may re-run jobs and start workflows. */
287
+ export const declaresBuild = (green: CiGreen) => green.phases.some((p) => p.build !== undefined);
288
+ /** Whether a required phase builds only the newest commit (#4169). */
289
+ export const newestPhases = (green: CiGreen) => green.phases.some((p) => p.build === "newest" && green.require.includes(p.name));
290
+
291
+ // ── A phase's triggers (#4169) ───────────────────────────────────────────────
292
+
293
+ /** The comment line above each top-level block `ci workflow` writes into a phase's workflow. */
294
+ export const BUILD_BLOCK_MARKER = "# Written by chant ci workflow from ci.green's build (#4169); edit ci.green and run it again.";
295
+
296
+ /** What `ci workflow` writes into one workflow holding a phase that sets `build`. */
297
+ export interface BuildTriggerEdit {
298
+ /** Relative to the repository root. */
299
+ path: string;
300
+ build: CiBuild;
301
+ phases: string[];
302
+ /** The file with its triggers and concurrency written. */
303
+ text: string;
304
+ changed: boolean;
305
+ }
306
+
307
+ /**
308
+ * The workflows holding the check runs of phases that set `build`, each with
309
+ * its triggers and concurrency written for that build (#4169):
310
+ *
311
+ * - `newest`: on pushes to the branch, and by hand (`workflow_dispatch`),
312
+ * which is how the tick starts a bisect step or a requested build at a
313
+ * `ci/build/<sha>` tag. A `schedule` goes: a runner freeing up, not a
314
+ * clock, picks the commit. Other triggers stay. One concurrency group per
315
+ * ref that never cancels a run in progress, so GitHub keeps the newest
316
+ * push waiting and drops the ones between, and each tag's build runs in a
317
+ * group of its own.
318
+ * - `every`: the triggers stay, and the group is the commit itself on the
319
+ * branch, so no push cancels or replaces another's run; elsewhere, such as
320
+ * a pull request, a new push cancels the run it supersedes.
321
+ *
322
+ * Only the top-level `on:` (for `newest`) and `concurrency:` blocks change,
323
+ * each under {@link BUILD_BLOCK_MARKER}; the rest of the file is kept as it
324
+ * is. Throws when one workflow holds a phase of each build, which no one
325
+ * concurrency group can serve.
326
+ */
327
+ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[]): BuildTriggerEdit[] {
328
+ const out: BuildTriggerEdit[] = [];
329
+ for (const s of sources) {
330
+ let doc: unknown;
331
+ try {
332
+ doc = parseYAMLDocument(s.text);
333
+ } catch {
334
+ continue;
335
+ }
336
+ const runs = jobCheckRuns(doc);
337
+ const held = green.phases.filter((p) => p.build !== undefined && p.runs.some((pattern) => runs.some((r) => meet(pattern, r))));
338
+ if (held.length === 0) continue;
339
+ const builds = [...new Set(held.map((p) => p.build!))];
340
+ if (builds.length > 1) {
341
+ throw new Error(`${s.path} holds runs of ${held.map((p) => `${p.name} (build: ${p.build})`).join(" and ")}, and one workflow can't run on every commit and only the newest; move the jobs into a workflow per build`);
342
+ }
343
+ const build = builds[0];
344
+ const phases = held.map((p) => p.name);
345
+ const tag = phases.map((p) => p.replace(/[^A-Za-z0-9_-]/g, "-")).join("-");
346
+ let text = s.text;
347
+ if (build === "newest") {
348
+ const on = isObject(doc) && isObject(doc.on) ? { ...doc.on } : {};
349
+ delete on.schedule;
350
+ const push = isObject(on.push) ? { ...on.push } : {};
351
+ const branches = Array.isArray(push.branches) ? push.branches.filter((b): b is string => typeof b === "string") : [];
352
+ push.branches = branches.includes(green.branch) ? branches : [...branches, green.branch];
353
+ const { push: _push, workflow_dispatch: dispatch, ...rest } = on;
354
+ text = writeTopLevel(text, "on", { push, workflow_dispatch: dispatch ?? {}, ...rest });
355
+ text = writeTopLevel(text, "concurrency", { group: `chant-build-${tag}-\${{ github.ref }}`, "cancel-in-progress": false });
356
+ } else {
357
+ const main = `refs/heads/${green.branch}`;
358
+ // Written as lines: the emitter would quote the expression, and GitHub reads it the same either way.
359
+ text = writeTopLevel(text, "concurrency", [
360
+ "concurrency:",
361
+ ` group: chant-build-${tag}-\${{ github.ref == '${main}' && github.sha || github.ref }}`,
362
+ ` cancel-in-progress: \${{ github.ref != '${main}' }}`,
363
+ ]);
364
+ }
365
+ out.push({ path: s.path, build, phases, text, changed: text !== s.text });
366
+ }
367
+ return out;
368
+ }
369
+
370
+ /**
371
+ * Replace the top-level `key:` block of a YAML file with `value` (or with
372
+ * `value`'s lines, the key's own line first), under
373
+ * {@link BUILD_BLOCK_MARKER}, or add it before `permissions:` or `jobs:`
374
+ * when the file has none. The block runs from its key's line to the next
375
+ * line at column 0 that is not blank; comments above the key stay.
376
+ */
377
+ export function writeTopLevel(text: string, key: string, value: Record<string, unknown> | string[]): string {
378
+ const lines = text.split("\n");
379
+ const block = [BUILD_BLOCK_MARKER, ...(Array.isArray(value) ? value : emitYAMLEntry(key, value).split("\n"))];
380
+ const at = lines.findIndex((l) => l === `${key}:` || l.startsWith(`${key}: `) || l.startsWith(`${key}:#`));
381
+ if (at < 0) {
382
+ const before = lines.findIndex((l) => /^(permissions|jobs):/.test(l));
383
+ const where = before < 0 ? lines.length : before;
384
+ return [...lines.slice(0, where), ...block, "", ...lines.slice(where)].join("\n");
385
+ }
386
+ let end = at + 1;
387
+ while (end < lines.length && (lines[end] === "" || /^\s/.test(lines[end]))) end++;
388
+ // A blank line ending the block belongs to what follows it.
389
+ while (end > at + 1 && lines[end - 1] === "") end--;
390
+ const start = at > 0 && lines[at - 1] === BUILD_BLOCK_MARKER ? at - 1 : at;
391
+ return [...lines.slice(0, start), ...block, ...lines.slice(end)].join("\n");
392
+ }
393
+
257
394
  /** The header line naming the command that regenerates the file, and where to run it. */
258
395
  export function ciGreenHeader(command: string, where: "member" | "root"): string {
259
396
  return `# ${GENERATED_MARKER}. Regenerate with: ${command} (in the ${where === "member" ? "member's directory" : "workspace root"})\n`;
@@ -117,10 +117,13 @@ describe("judging a phase from check runs (#3573)", () => {
117
117
  const runs = [run(1, "macos (test)", "skipped")];
118
118
  expect(evaluatePhase(phase(["macos (test)"]), runs).verdict).toBe("fail");
119
119
  expect(evaluatePhase(phase(["macos (test)"], "pass"), runs).verdict).toBe("pass");
120
- // Neither counts any other conclusion as a pass.
121
- for (const c of ["neutral", "cancelled", "timed_out", "action_required", "stale"]) {
120
+ // Neither counts any other conclusion as a pass, and a stopped run is no failure either (#4169).
121
+ for (const c of ["neutral", "timed_out", "action_required"]) {
122
122
  expect(evaluatePhase(phase(["x"], "pass"), [run(1, "x", c)]).verdict, c).toBe("fail");
123
123
  }
124
+ for (const c of ["cancelled", "stale"]) {
125
+ expect(evaluatePhase(phase(["x"], "pass"), [run(1, "x", c)]), c).toMatchObject({ verdict: "unbuilt", reason: "cancelled" });
126
+ }
124
127
  });
125
128
 
126
129
  test("a run in progress, or a pattern nothing matches yet, leaves the phase undecided; a failure decides it", () => {