@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,86 @@
1
+ /**
2
+ * #4169 — `ci serve --build-command` serves each `ci/build/<sha>` tag once,
3
+ * within `maxJobs`, and records how each build ended.
4
+ */
5
+
6
+ import { existsSync, readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { afterAll, describe, expect, test } from "vitest";
9
+ import { cleanScratch, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
10
+ import { buildRequestAnnotation, requestedBuild } from "./ci-builds";
11
+ import { ciServeBuilds, ciServePass, formatCiServeEvent, newCiServeRuntime, type CiServeEvent, type CiServeOptions } from "./ci-serve";
12
+ import type { CiGreen } from "./declaration";
13
+
14
+ afterAll(cleanScratch);
15
+
16
+ const GREEN = { branch: "main", phases: [], require: ["suites"] } as unknown as CiGreen;
17
+
18
+ function clone(): { root: string; shas: string[] } {
19
+ const origin = scratchDir("chant-serve-builds-origin-");
20
+ git(origin, "init", "-q", "--bare");
21
+ const root = scratchDir("chant-serve-builds-");
22
+ git(root, "init", "-q");
23
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
24
+ git(root, "config", "user.name", "t");
25
+ git(root, "config", "user.email", "t@example.com");
26
+ git(root, "remote", "add", "origin", origin);
27
+ const shas: string[] = [];
28
+ for (const n of ["a", "b"]) {
29
+ writeFiles(root, { [`${n}.txt`]: `${n}\n` });
30
+ git(root, "add", "-A");
31
+ git(root, "commit", "-q", "-m", n);
32
+ shas.push(git(root, "rev-parse", "HEAD"));
33
+ const r = requestedBuild(GREEN, shas.at(-1)!, ["suites"], new Date(`2026-10-20T1${shas.length}:00:00Z`));
34
+ git(root, "tag", "-a", "-m", buildRequestAnnotation(r), r.tag, r.commit);
35
+ }
36
+ git(root, "push", "-q", "origin", "main", "--tags");
37
+ // The serve's clone learns the tags from the forge.
38
+ for (const s of shas) git(root, "tag", "-d", `ci/build/${s}`);
39
+ return { root, shas };
40
+ }
41
+
42
+ const options = (root: string, built: string[], log: CiServeEvent[], extra: Partial<CiServeOptions> = {}): CiServeOptions => ({
43
+ cwd: root,
44
+ forge: "github",
45
+ repo: "acme/app",
46
+ env: "prod",
47
+ client: { openPullRequests: async () => [], branchHead: async () => undefined },
48
+ stateDir: join(root, ".serve"),
49
+ buildCommand: "true",
50
+ build: async ({ request }) => (built.push(request.commit), request.commit === built[0] ? 0 : 2),
51
+ log: (e) => log.push(e),
52
+ ...extra,
53
+ });
54
+
55
+ describe("ci serve --build-command (#4169)", () => {
56
+ test("serves each request once, oldest first, within maxJobs, and records how it ended", async () => {
57
+ const { root, shas } = clone();
58
+ const built: string[] = [];
59
+ const log: CiServeEvent[] = [];
60
+ const rt = newCiServeRuntime();
61
+ const first = ciServeBuilds(options(root, built, log), rt);
62
+ expect(first.events.map((e) => e.type === "build" && e.status)).toEqual(["started", "held"]);
63
+ await Promise.all(first.started);
64
+ const second = ciServeBuilds(options(root, built, log), rt);
65
+ await Promise.all(second.started);
66
+ expect(built).toEqual(shas);
67
+ const state = JSON.parse(readFileSync(join(root, ".serve", "chant-apply-prod.json"), "utf-8"));
68
+ expect(Object.fromEntries(Object.entries(state.builds).map(([k, v]) => [k, (v as { status: string }).status]))).toEqual({ [`build:${shas[0]}`]: "ok", [`build:${shas[1]}`]: "fail" });
69
+ // Served once: another pass starts nothing.
70
+ expect(ciServeBuilds(options(root, built, log), rt).started).toEqual([]);
71
+ expect(log.map((e) => formatCiServeEvent(e))).toContain(`the build of suites at ${shas[1].slice(0, 12)} failed with exit code 2`);
72
+ });
73
+
74
+ test("without --build-command a pass leaves the tags alone; the default build runs the command in a worktree of the commit", async () => {
75
+ const { root, shas } = clone();
76
+ const log: CiServeEvent[] = [];
77
+ await ciServePass({ ...options(root, [], log), buildCommand: undefined });
78
+ expect(log.filter((e) => e.type === "build")).toEqual([]);
79
+ const out = join(root, ".serve-out");
80
+ await ciServePass({ ...options(root, [], log), build: undefined, buildCommand: `git rev-parse HEAD >> ${out}; echo "$CHANT_BUILD_PHASES $CHANT_BUILD_REASON" >> ${out}`, maxJobs: 2 });
81
+ expect(existsSync(out)).toBe(true);
82
+ const lines = readFileSync(out, "utf-8").trim().split("\n");
83
+ expect(lines.filter((l) => /^[0-9a-f]{40}$/.test(l)).sort()).toEqual([...shas].sort());
84
+ expect(lines.filter((l) => l === "suites requested")).toHaveLength(2);
85
+ });
86
+ });
@@ -143,6 +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
148
  // A job image of its own, so no setup line downloads OpenTofu.
147
149
  job: { image: "chant-ci-serve-test:local", chant: fakeChant(), hostEnv: env, passEnv: Object.keys(hostEnv ?? {}) },
148
150
  log: (e) => events.push(e),
@@ -177,6 +179,63 @@ const isAncestor = (cwd: string, a: string, b: string): boolean => {
177
179
  const argsOf = (report: string, stage: string): string[] => readFileSync(join(report, `${stage}.args`), "utf-8").trimEnd().split("\n");
178
180
  const ofType = <T extends CiServeEvent["type"]>(events: CiServeEvent[], type: T) => events.filter((e): e is Extract<CiServeEvent, { type: T }> => e.type === type);
179
181
 
182
+ describe("ci serve records each merge unless apply on merge is opted in (#4168)", () => {
183
+ test("a merge to the target branch gets the record job, measured from the head before it, and no resume record", async () => {
184
+ const { root, app, main } = workspace();
185
+ const forge = fakeForge(main);
186
+ const { opts, events, state } = serve(app, forge, { applyOnMerge: false });
187
+ await ciServePass(opts);
188
+ const merged = commit(root, "app/merged.txt");
189
+ git(root, "push", "-q", "origin", "main");
190
+ forge.head = merged;
191
+ forge.merges.set(merged, 21);
192
+
193
+ const pass = await ciServePass(opts);
194
+ expect(pass.jobs).toEqual([expect.objectContaining({ job: "record", commit: merged, base: main, pr: 21, status: "ok" })]);
195
+ const argv = argsOf(pass.jobs[0]!.report!, "pr-record");
196
+ expect(argv.slice(0, 4)).toEqual(["components", "pr-record", "--base", main]);
197
+ expect(argv).not.toContain("--resume");
198
+ expect(ofType(events, "started")[0]).not.toHaveProperty("resume");
199
+ expect(existsSync(join(state, "resume"))).toBe(false);
200
+ const saved = readCiServeState(join(state, `${prApplyGroup("prod", "app")}.json`), { forge: "forgejo", repo: "demo/estate", env: "prod", branch: "main" });
201
+ expect(saved.target).toBe(merged);
202
+ expect(saved.jobs[ciServeKey({ job: "record", commit: merged })]).toMatchObject({ status: "ok" });
203
+ expect((await ciServePass(opts)).jobs).toEqual([]);
204
+ });
205
+
206
+ test("each settled job runs the tick, and a tick that fails is an error event, never a failed job", async () => {
207
+ const { root, app, main } = workspace();
208
+ const forge = fakeForge(main);
209
+ const ticks: string[] = [];
210
+ let fail = false;
211
+ const { opts, events } = serve(app, forge, {
212
+ applyOnMerge: false,
213
+ tick: async () => {
214
+ ticks.push("tick");
215
+ if (fail) throw new Error("forge said no");
216
+ return ["ci/green/abc"];
217
+ },
218
+ });
219
+ await ciServePass(opts);
220
+ expect(ticks).toEqual([]);
221
+ const merged = commit(root, "app/merged.txt");
222
+ git(root, "push", "-q", "origin", "main");
223
+ forge.head = merged;
224
+ await ciServePass(opts);
225
+ expect(ticks).toEqual(["tick"]);
226
+ expect(ofType(events, "tick")).toEqual([{ type: "tick", made: ["ci/green/abc"] }]);
227
+ expect(formatCiServeEvent({ type: "tick", made: ["ci/green/abc"] })).toBe("ci tick made ci/green/abc");
228
+
229
+ fail = true;
230
+ const next = commit(root, "app/next.txt");
231
+ git(root, "push", "-q", "origin", "main");
232
+ forge.head = next;
233
+ const pass = await ciServePass(opts);
234
+ expect(pass.jobs[0]).toMatchObject({ job: "record", status: "ok" });
235
+ expect(ofType(events, "error").map((e) => e.message)).toContain("ci tick after a job settled: forge said no");
236
+ });
237
+ });
238
+
180
239
  describe("chant ci serve (#4095)", () => {
181
240
  test("one poll plans a new pull request head once, and skips forks and authors not given", async () => {
182
241
  const { root, app, main } = workspace();
@@ -3,11 +3,15 @@
3
3
  * host. It polls the forge for the open pull requests into the target branch
4
4
  * and for the branch's head, and runs the pull-request jobs with
5
5
  * {@link ciRun} (./ci-run.ts): `plan` for each new pull request head, and
6
- * `apply` for each new commit on the target branch, measured from the head
7
- * the previous poll saw (the push's `before`).
6
+ * `record` for each new commit on the target branch (#4168), measured from
7
+ * the head the previous poll saw (the push's `before`). The record applies
8
+ * nothing; with `applyOnMerge` (`--apply-on-merge`) the commit gets `apply`
9
+ * in its place, as before #4168. After each job settles it runs `ci tick`
10
+ * from the runs the jobs recorded, when the declaration has `ci.green`.
11
+ * What the list below says of the apply holds for it under `applyOnMerge`.
8
12
  *
9
13
  * - Dedupe. A state file in the state directory keeps every job it started,
10
- * keyed by job and commit (`plan:<pr>:<sha>`, `apply:<sha>`), with how it
14
+ * keyed by job and commit (`plan:<pr>:<sha>`, `record:<sha>`, `apply:<sha>`), with how it
11
15
  * ended, and the target head the last apply was measured to. A job already
12
16
  * in it is not started again, unless it failed and `rerunFailed` asks for
13
17
  * it: the forge's "re-run failed jobs".
@@ -37,6 +41,14 @@
37
41
  * - `[skip ci]` (#4122). A target head or pull request head whose commit
38
42
  * message says `[skip ci]` or `[ci skip]` is recorded as seen and not run,
39
43
  * as Actions does.
44
+ * - Build requests (#4169). With `buildCommand`, each `ci/build/<sha>`
45
+ * tag the tick or `ci run --at` pushed is served once: the command runs
46
+ * in a fresh detached worktree of the commit, with `CHANT_BUILD_COMMIT`,
47
+ * `CHANT_BUILD_PHASES` (comma-separated) and `CHANT_BUILD_REASON` set,
48
+ * and is expected to run the phases' Ops (`chant run <op>`), whose
49
+ * records the tick reads. A build counts against `maxJobs`, and the state
50
+ * file keeps it under `build:<sha>`. Without `buildCommand` the tags are
51
+ * left alone.
40
52
  * - The forge's ledger (#4121). Before each job, when no other job of this
41
53
  * serve runs, the clone's `chant/lifecycle` follows the forge's: it is
42
54
  * deleted when the forge has none, and reset to the forge's when neither
@@ -54,9 +66,11 @@
54
66
  * turn a checked payload into the same triggers and schedule them.
55
67
  */
56
68
 
57
- import { execFileSync, spawnSync } from "node:child_process";
58
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
69
+ import { execFileSync, spawn, spawnSync } from "node:child_process";
70
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
71
+ import { tmpdir } from "node:os";
59
72
  import { join, resolve } from "node:path";
73
+ import { CI_BUILD_TAG_PREFIX, readBuildRequests, type BuildRequest } from "./ci-builds";
60
74
  import { prApplyGroup, prApplyRecordKey } from "../components/pr-pipeline";
61
75
  import { forgeFromEnv, type OpenPullRequest } from "../pr-forge";
62
76
  import type { CommandContext } from "../cli/registry";
@@ -65,7 +79,7 @@ import { acquireLease, currentHolderId, releaseLease, DEFAULT_LEASE_TTL_MS, type
65
79
  import { parseDuration } from "../op/duration";
66
80
  import { reportUrlOf } from "./ci-report-url";
67
81
  import { CI_JOB_OPS, ciJobPlace, ciLoopOptions, ciSecretsOf, ciRun, ciStatusFromEnv, CiRunError, type CiJobName, type CiRunRequest, type CiRunResult } from "./ci-run";
68
- import { RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
82
+ import { CiGreenError, ciTick, RUNNER_KEY_ENV, signedRunsRequired } from "./ci-green";
69
83
  import { gitTop } from "./tree";
70
84
  import { ciServeRunnerKey, formatRunnerKeySetup, setupRunnerKey } from "./ci-runner-key";
71
85
 
@@ -80,10 +94,14 @@ export interface CiServeForge {
80
94
  pullRequestFor?(sha: string): Promise<number | null>;
81
95
  }
82
96
 
83
- /** A job the forge's state calls for: the plan of a pull request's head, or the apply of a target-branch commit. */
97
+ /**
98
+ * A job the forge's state calls for: the plan of a pull request's head, or
99
+ * for a target-branch commit its record (#4168) or, with `applyOnMerge`,
100
+ * its apply.
101
+ */
84
102
  export type CiServeTrigger =
85
103
  | { job: "plan"; commit: string; base: string; pr: number; rerun?: boolean }
86
- | { job: "apply"; commit: string; base: string; rerun?: boolean };
104
+ | { job: "apply" | "record"; commit: string; base: string; rerun?: boolean };
87
105
 
88
106
  /** One job in the state file. */
89
107
  export interface CiServeJobState {
@@ -116,6 +134,8 @@ export interface CiServeState {
116
134
  jobs: Record<string, CiServeJobState>;
117
135
  /** Plans seen and not run, by state key, with why (#4122). */
118
136
  skipped?: Record<string, "skip-ci">;
137
+ /** The build requests served (#4169), by `build:<sha>`, with how each ended. */
138
+ builds?: Record<string, { tag: string; status: "running" | "ok" | "fail"; startedAt: string; settledAt?: string }>;
119
139
  }
120
140
 
121
141
  /** What a pass did, in order, for the log. */
@@ -128,6 +148,9 @@ export type CiServeEvent =
128
148
  | { type: "settled"; job: CiJobName; commit: string; pr?: number; status: "ok" | "fail"; failure?: string; report?: string }
129
149
  | { type: "not-run"; job: CiJobName; commit: string; pr?: number; reason: "not-fast-forward" | "skip-ci"; branch?: string; from?: string }
130
150
  | { type: "ledger"; action: "deleted" | "reset"; local: string; forge?: string }
151
+ | { type: "build"; commit: string; phases: string[]; status: "started" | "ok" | "fail" | "held"; exitCode?: number }
152
+ /** `ci tick` after the pass (#4168): the tags it made from the runs. */
153
+ | { type: "tick"; made: string[] }
131
154
  | { type: "error"; message: string };
132
155
 
133
156
  /** The `ciRun` request fields `ci serve` passes through to every job: the generator's options and the runner's. */
@@ -149,6 +172,18 @@ export interface CiServeOptions {
149
172
  authors?: readonly string[];
150
173
  /** Run again the failed apply at the target's head, and the failed plans of open heads. */
151
174
  rerunFailed?: boolean;
175
+ /**
176
+ * Apply each new target-branch commit (#4168), as `ci serve` did before
177
+ * #4168. Default false: each new commit gets the record job, which applies
178
+ * nothing.
179
+ */
180
+ applyOnMerge?: boolean;
181
+ /**
182
+ * Run after each pass that settled a job (#4168): `ci tick` from the runs
183
+ * the jobs recorded. Returns the tags it made, or undefined when the
184
+ * declaration has no `ci.green`. The command passes one; a test may not.
185
+ */
186
+ tick?: () => Promise<string[] | undefined>;
152
187
  /** Passed to each job. `job.hostEnv` gets `GITHUB_REPOSITORY` set to `repo`. */
153
188
  job?: CiServeJobOptions;
154
189
  /** This process's lease identity. Default `currentHolderId()`. */
@@ -158,18 +193,26 @@ export interface CiServeOptions {
158
193
  now?: () => Date;
159
194
  /** Runs a job. Default {@link ciRun}. */
160
195
  run?: (req: CiRunRequest) => Promise<CiRunResult>;
196
+ /**
197
+ * The shell line that builds a requested commit (#4169), run in a fresh
198
+ * worktree of it. Without it `ci/build/<sha>` tags are not served.
199
+ */
200
+ buildCommand?: string;
201
+ /** Runs a build. Default: `sh -c <buildCommand>` in a detached worktree of the commit. */
202
+ build?: (input: { top: string; request: BuildRequest; command: string }) => Promise<number>;
161
203
  }
162
204
 
163
205
  /** The jobs this process runs now, by state key, shared by every pass of one loop. */
164
206
  export interface CiServeRuntime {
165
- inflight: Map<string, Promise<CiServeJobState>>;
207
+ /** Jobs and build requests (#4169) alike, by state key: they share `maxJobs`. */
208
+ inflight: Map<string, Promise<unknown>>;
166
209
  }
167
210
 
168
211
  export const newCiServeRuntime = (): CiServeRuntime => ({ inflight: new Map() });
169
212
 
170
213
  /** The state key of a job. */
171
214
  export function ciServeKey(t: { job: CiJobName; commit: string; pr?: number }): string {
172
- return t.job === "plan" ? `plan:${t.pr}:${t.commit}` : `apply:${t.commit}`;
215
+ return t.job === "plan" ? `plan:${t.pr}:${t.commit}` : `${t.job}:${t.commit}`;
173
216
  }
174
217
 
175
218
  const message = (err: unknown): string => (err instanceof Error ? err.message : String(err));
@@ -324,6 +367,8 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
324
367
  const triggers: CiServeTrigger[] = [];
325
368
  const events: CiServeEvent[] = [];
326
369
  const [head, prs] = await Promise.all([opts.client.branchHead(place.branch), opts.client.openPullRequests(place.branch)]);
370
+ // #4168: a new target commit is recorded, and applied only with applyOnMerge.
371
+ const onMerge: "apply" | "record" = opts.applyOnMerge ? "apply" : "record";
327
372
 
328
373
  if (head === undefined) {
329
374
  events.push({ type: "no-branch", branch: place.branch });
@@ -336,7 +381,7 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
336
381
  } else if (head !== state.target) {
337
382
  const from = state.target;
338
383
  const move = targetMove(place.top, place.branch, from, head);
339
- const entry = state.jobs[ciServeKey({ job: "apply", commit: head })];
384
+ const entry = state.jobs[ciServeKey({ job: onMerge, commit: head })];
340
385
  if (move === "unknown") {
341
386
  events.push({ type: "error", message: `${place.branch} is at ${head.slice(0, 12)} on the forge, and that commit could not be fetched; the next poll tries again` });
342
387
  } else if (move === "other" || (!entry && skipsCi(place.top, head))) {
@@ -344,13 +389,13 @@ export async function ciServeObserve(opts: CiServeOptions, rt: CiServeRuntime =
344
389
  update((s) => {
345
390
  s.target = head;
346
391
  });
347
- events.push({ type: "not-run", job: "apply", commit: head, reason: move === "other" ? "not-fast-forward" : "skip-ci", ...(move === "other" ? { branch: place.branch, from } : {}) });
348
- } else if (!entry) triggers.push({ job: "apply", commit: head, base: from });
349
- else if (opts.rerunFailed && failedEntry(entry, ciServeKey(entry), rt)) triggers.push({ job: "apply", commit: head, base: entry.base, rerun: true });
392
+ events.push({ type: "not-run", job: onMerge, commit: head, reason: move === "other" ? "not-fast-forward" : "skip-ci", ...(move === "other" ? { branch: place.branch, from } : {}) });
393
+ } else if (!entry) triggers.push({ job: onMerge, commit: head, base: from });
394
+ else if (opts.rerunFailed && failedEntry(entry, ciServeKey(entry), rt)) triggers.push({ job: onMerge, commit: head, base: entry.base, rerun: true });
350
395
  } else {
351
- const key = ciServeKey({ job: "apply", commit: head });
396
+ const key = ciServeKey({ job: onMerge, commit: head });
352
397
  const entry = state.jobs[key];
353
- if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: "apply", commit: head, base: entry.base, rerun: true });
398
+ if (opts.rerunFailed && failedEntry(entry, key, rt)) triggers.push({ job: onMerge, commit: head, base: entry.base, rerun: true });
354
399
  }
355
400
 
356
401
  const authors = opts.authors?.length ? new Set(opts.authors.map((a) => a.toLowerCase())) : undefined;
@@ -453,7 +498,7 @@ export async function ciServeSchedule(opts: CiServeOptions, triggers: readonly C
453
498
  const entry: CiServeJobState = { job: t.job, commit: t.commit, base: t.base, ...pr, status: "running", runId, attempts: attempt, startedAt: now().toISOString() };
454
499
  update((s) => {
455
500
  s.jobs[key] = entry;
456
- if (t.job === "apply" && !t.rerun) s.target = t.commit;
501
+ if (t.job !== "plan" && !t.rerun) s.target = t.commit;
457
502
  });
458
503
  emit({ type: "started", job: t.job, commit: t.commit, ...pr, base: t.base, runId, attempt, ...(resume && existsSync(resume) ? { resume } : {}) });
459
504
 
@@ -468,7 +513,7 @@ export async function ciServeSchedule(opts: CiServeOptions, triggers: readonly C
468
513
  emit({ type: "error", message: `${key} settled, and the state file could not be written: ${message(err)}` });
469
514
  }
470
515
  emit({ type: "settled", job: t.job, commit: t.commit, ...pr, status: settled.status === "ok" ? "ok" : "fail", ...(settled.failure ? { failure: settled.failure } : {}), ...(settled.report ? { report: settled.report } : {}) });
471
- return settled;
516
+ return serveTick(opts).then(() => settled);
472
517
  });
473
518
  rt.inflight.set(key, job);
474
519
  started.push(job);
@@ -496,7 +541,7 @@ async function runJob(
496
541
  tryGit(place.top, ["fetch", "--quiet", "--no-tags", "origin", t.job === "plan" ? `refs/pull/${t.pr}/head` : place.branch]);
497
542
  const hostEnv = { ...(opts.job?.hostEnv ?? process.env), GITHUB_REPOSITORY: opts.repo };
498
543
  // An apply is for a merge: pass the pull request whose merge commit it is, so the commit status link and the run index use `<pr>/<commit>`. None found, or a forge that errs, is today's behaviour.
499
- if (t.job === "apply" && opts.client.pullRequestFor) merged = (await opts.client.pullRequestFor(t.commit).catch(() => null)) ?? undefined;
544
+ if (t.job !== "plan" && opts.client.pullRequestFor) merged = (await opts.client.pullRequestFor(t.commit).catch(() => null)) ?? undefined;
500
545
  const run = opts.run ?? ciRun;
501
546
  const r = await run({
502
547
  ...opts.job,
@@ -522,13 +567,101 @@ async function runJob(
522
567
  }
523
568
  }
524
569
 
525
- /** One pass: observe, schedule, and with `wait` wait for the jobs it started. */
570
+ /** One pass: observe, schedule, serve build requests, and with `wait` wait for the jobs it started. */
526
571
  export async function ciServePass(opts: CiServeOptions, rt: CiServeRuntime = newCiServeRuntime(), wait = true): Promise<{ events: CiServeEvent[]; jobs: CiServeJobState[] }> {
527
572
  const observed = await ciServeObserve(opts, rt);
528
573
  for (const e of observed.events) opts.log?.(e);
529
574
  const scheduled = await ciServeSchedule(opts, observed.triggers, rt);
575
+ const builds = opts.buildCommand ? ciServeBuilds(opts, rt) : { started: [], events: [] };
530
576
  const jobs = wait ? await Promise.all(scheduled.started) : [];
531
- return { events: [...observed.events, ...scheduled.events], jobs };
577
+ if (wait) await Promise.all(builds.started);
578
+ return { events: [...observed.events, ...scheduled.events, ...builds.events], jobs };
579
+ }
580
+
581
+ /**
582
+ * Serve the build requests (#4169): fetch the `ci/build/*` tags, and start
583
+ * `buildCommand` for each one not in the state file yet, oldest first, while
584
+ * fewer than `maxJobs` jobs run. A build that fails is recorded and not run
585
+ * again; the tick decides what its phases say.
586
+ */
587
+ export function ciServeBuilds(opts: CiServeOptions, rt: CiServeRuntime): { started: Promise<void>[]; events: CiServeEvent[] } {
588
+ const place = servePlace(opts);
589
+ const fresh = { forge: opts.forge, repo: opts.repo, env: opts.env, branch: place.branch, ...(place.member ? { member: place.member } : {}) };
590
+ const update = (fn: (s: CiServeState) => void): void => {
591
+ const st = readCiServeState(place.stateFile, fresh);
592
+ fn(st);
593
+ writeState(place.stateFile, st);
594
+ };
595
+ const events: CiServeEvent[] = [];
596
+ const started: Promise<void>[] = [];
597
+ tryGit(place.top, ["fetch", "--quiet", "--prune", "origin", `+refs/tags/${CI_BUILD_TAG_PREFIX}*:refs/tags/${CI_BUILD_TAG_PREFIX}*`]);
598
+ const served = readCiServeState(place.stateFile, fresh).builds ?? {};
599
+ const max = opts.maxJobs ?? 1;
600
+ const now = opts.now ?? (() => new Date());
601
+ const requests = [...readBuildRequests(place.top).values()].sort((a, b) => a.at.localeCompare(b.at));
602
+ for (const request of requests) {
603
+ const key = `build:${request.commit}`;
604
+ if (served[key]?.tag === request.tag || rt.inflight.has(key)) continue;
605
+ const log = (e: CiServeEvent) => {
606
+ events.push(e);
607
+ opts.log?.(e);
608
+ };
609
+ if (rt.inflight.size >= max) {
610
+ log({ type: "build", commit: request.commit, phases: request.phases, status: "held" });
611
+ continue;
612
+ }
613
+ update((st) => {
614
+ st.builds = { ...st.builds, [key]: { tag: request.tag, status: "running", startedAt: now().toISOString() } };
615
+ });
616
+ log({ type: "build", commit: request.commit, phases: request.phases, status: "started" });
617
+ const build = opts.build ?? runBuild;
618
+ const job = build({ top: place.top, request, command: opts.buildCommand! })
619
+ .catch(() => 1)
620
+ .then((code) => {
621
+ update((st) => {
622
+ st.builds = { ...st.builds, [key]: { tag: request.tag, status: code === 0 ? "ok" : "fail", startedAt: st.builds?.[key]?.startedAt ?? now().toISOString(), settledAt: now().toISOString() } };
623
+ });
624
+ opts.log?.({ type: "build", commit: request.commit, phases: request.phases, status: code === 0 ? "ok" : "fail", exitCode: code });
625
+ });
626
+ const tracked = job.finally(() => rt.inflight.delete(key));
627
+ rt.inflight.set(key, tracked);
628
+ started.push(tracked);
629
+ }
630
+ return { started, events };
631
+ }
632
+
633
+ /** `sh -c <command>` in a fresh detached worktree of the request's commit, removed after. Resolves to the exit code. */
634
+ async function runBuild(input: { top: string; request: BuildRequest; command: string }): Promise<number> {
635
+ const dir = mkdtempSync(join(tmpdir(), "chant-ci-build-"));
636
+ try {
637
+ execFileSync("git", ["worktree", "add", "--quiet", "--detach", dir, input.request.commit], { cwd: input.top, stdio: ["ignore", "pipe", "pipe"] });
638
+ return await new Promise<number>((done) => {
639
+ const child = spawn("sh", ["-c", input.command], {
640
+ cwd: dir,
641
+ stdio: ["ignore", "inherit", "inherit"],
642
+ env: { ...process.env, CHANT_BUILD_COMMIT: input.request.commit, CHANT_BUILD_PHASES: input.request.phases.join(","), CHANT_BUILD_REASON: input.request.reason },
643
+ });
644
+ child.on("error", () => done(127));
645
+ child.on("close", (code) => done(code ?? 1));
646
+ });
647
+ } finally {
648
+ tryGit(input.top, ["worktree", "remove", "--force", dir]);
649
+ rmSync(dir, { recursive: true, force: true });
650
+ }
651
+ }
652
+
653
+ /** `ci tick` after a job settled (#4168), logged; a tick that fails is an error event and never fails the job. */
654
+ async function serveTick(opts: CiServeOptions): Promise<CiServeEvent | undefined> {
655
+ if (!opts.tick) return undefined;
656
+ let event: CiServeEvent | undefined;
657
+ try {
658
+ const made = await opts.tick();
659
+ event = made === undefined ? undefined : { type: "tick", made };
660
+ } catch (err) {
661
+ event = { type: "error", message: `ci tick after a job settled: ${message(err)}` };
662
+ }
663
+ if (event) opts.log?.(event);
664
+ return event;
532
665
  }
533
666
 
534
667
  /**
@@ -579,6 +712,13 @@ export function formatCiServeEvent(e: CiServeEvent): string {
579
712
  return e.action === "deleted"
580
713
  ? `the forge has no ${LEDGER}, so this clone's, at ${e.local.slice(0, 12)}, was deleted`
581
714
  : `the forge's ${LEDGER} at ${e.forge!.slice(0, 12)} does not contain this clone's at ${e.local.slice(0, 12)}, so this clone's was reset to the forge's and not pushed`;
715
+ case "build": {
716
+ const what = `the build of ${e.phases.join(", ")} at ${e.commit.slice(0, 12)}`;
717
+ if (e.status === "held") return `${what} waits: --max-jobs jobs run`;
718
+ return e.status === "started" ? `${what} started` : e.status === "ok" ? `${what} passed` : `${what} failed${e.exitCode !== undefined ? ` with exit code ${e.exitCode}` : ""}`;
719
+ }
720
+ case "tick":
721
+ return e.made.length > 0 ? `ci tick made ${e.made.join(", ")}` : "ci tick: no tag due";
582
722
  case "error":
583
723
  return `error: ${e.message}`;
584
724
  }
@@ -615,7 +755,7 @@ export function runnerKeySetupCommand(ctx: CommandContext): number {
615
755
  const USAGE =
616
756
  "chant ci serve --setup-key [--trust [--principal <name>]] [--state <dir>] [--branch <name>] | " +
617
757
  "chant ci serve --forge forgejo|github --repo <owner/repo> --env <env> [--interval <s>] [--max-jobs <n>] [--state <dir>] " +
618
- "[--author <login>]... [--once] [--rerun-failed] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
758
+ "[--author <login>]... [--once] [--rerun-failed] [--build-command <line>] [--gate <name>] [--wave-gate <name>] [--branch <name>] [--events-json] [--report-url <url>] [--no-status] [--status-url <url template>] " +
619
759
  "[--image <image>] [--before-script <line>]... [--pr-build] [--pr-build-command <line>] [--chant <command>] [--pass-env <NAME>]... [--secrets <file>] [--key <runner key.pem>] [--unsigned]";
620
760
 
621
761
  const ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
@@ -638,6 +778,8 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
638
778
  if (!args.ciRepo) throw new CiRunError("--repo names the repository whose pull requests ci serve polls: --repo <owner/repo>");
639
779
  if (!args.env) throw new CiRunError("--env names the environment the pipeline is for: --env <env>");
640
780
  for (const name of args.passEnv ?? []) if (!ENV_NAME.test(name)) throw new CiRunError(`--pass-env takes a variable's name, not ${JSON.stringify(name)}`);
781
+ // #4168: a gate per wave gates the apply on merge, which a merge runs only when opted in.
782
+ if (args.waveGate && !args.applyOnMerge) throw new CiRunError("--wave-gate gates the apply on merge, so it needs --apply-on-merge (#4168); without it a merge gets the record job, which applies nothing");
641
783
  const intervalMs = ciServeInterval(args.interval);
642
784
  const cwd = process.cwd();
643
785
  const top = gitTop(cwd);
@@ -670,6 +812,8 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
670
812
  const { status, warning } = ciStatusFromEnv({ forge, hostEnv, noStatus: args.noStatus, statusUrl: args.statusUrl });
671
813
  if (warning) console.error(formatWarning({ message: warning }));
672
814
  if (!client.openPullRequests || !client.branchHead) throw new CiRunError(`the ${forge} client cannot list pull requests`);
815
+ // #4168: a host that applied each merge before the upgrade hears that it no longer does.
816
+ if (!args.applyOnMerge) console.error("ci serve: each new commit on the target branch gets the record job, which applies nothing (#4168); --apply-on-merge applies it");
673
817
  const opts: CiServeOptions = {
674
818
  cwd,
675
819
  forge,
@@ -680,6 +824,17 @@ export async function runCiServe(ctx: CommandContext): Promise<number> {
680
824
  ...(args.maxJobs ? { maxJobs: args.maxJobs } : {}),
681
825
  ...(args.ciAuthors?.length ? { authors: args.ciAuthors } : {}),
682
826
  ...(args.rerunFailed ? { rerunFailed: true } : {}),
827
+ ...(args.ciBuildCommand ? { buildCommand: args.ciBuildCommand } : {}),
828
+ ...(args.applyOnMerge ? { applyOnMerge: true } : {}),
829
+ // #4168: the tick from the runs the jobs recorded, signed with the runs' key. No ci.green, no tick.
830
+ tick: async () => {
831
+ try {
832
+ return (await ciTick({ cwd, source: "runs", ...(runnerKey !== undefined ? { runnerKey } : {}) })).made;
833
+ } catch (err) {
834
+ if (err instanceof CiGreenError && err.code === "ci-green-undeclared") return undefined;
835
+ throw err;
836
+ }
837
+ },
683
838
  job: {
684
839
  loop: ciLoopOptions(reportUrl ? { ...args, reportUrl } : args),
685
840
  ...(args.runIndexLimit ? { runIndexLimit: args.runIndexLimit } : {}),
@@ -236,6 +236,14 @@
236
236
  "description": "A deploy counts as failed when, within this many days after it, the commit it deployed is revoked or the environment is rolled back from it. The default is 7."
237
237
  }
238
238
  }
239
+ },
240
+ "security": {
241
+ "type": "array",
242
+ "uniqueItems": true,
243
+ "description": "The security groups the workspace opts into (#4168), for every member; a member's own security adds to them. With none, a security feature the workspace has not turned on never refuses, warns or hides anything. verified-releases: release publish and promote --release take only a verified release. supply-chain: a publish without OIDC provenance warns, and a target with no SBOM source is refused. who-ran-it: ci tick needs a runner key. review-rules: pr-apply requires a forge review. isolated-runners: an apply job requires a runner-start statement. workspace check reports what a declared group lacks (WSP161 to WSP165). Added in schema 1 by chant 0.124.33, so a declaration that uses it sets minReader to 0.124.33 or newer.",
244
+ "items": {
245
+ "enum": ["verified-releases", "supply-chain", "who-ran-it", "review-rules", "isolated-runners"]
246
+ }
239
247
  }
240
248
  },
241
249
  "patternProperties": {
@@ -487,6 +495,14 @@
487
495
  "type": "boolean",
488
496
  "description": "Whether the member goes with an export (#2524 D10, #2552). chant workspace export writes only members that set travel to true into the export member, and refuses to name one that does not. Added in schema 1 by chant 0.101.0, so a declaration that uses it sets minReader to 0.101.0 or newer."
489
497
  },
498
+ "security": {
499
+ "type": "array",
500
+ "uniqueItems": true,
501
+ "description": "The security groups this member opts into, added to the declaration's top-level security (#4168). A release, publish or pull-request job of this member runs under both lists. Added in schema 1 by chant 0.124.33, so a declaration that uses it sets minReader to 0.124.33 or newer.",
502
+ "items": {
503
+ "enum": ["verified-releases", "supply-chain", "who-ran-it", "review-rules", "isolated-runners"]
504
+ }
505
+ },
490
506
  "because": {
491
507
  "type": "string",
492
508
  "minLength": 1,
@@ -1562,6 +1578,13 @@
1562
1578
  "fail"
1563
1579
  ],
1564
1580
  "description": "What a check run that finished as skipped counts as. fail (the default): it never passes. pass: it passes, for a job a path filter runs only on some commits."
1581
+ },
1582
+ "build": {
1583
+ "enum": [
1584
+ "every",
1585
+ "newest"
1586
+ ],
1587
+ "description": "Added by #4169, read by chant 0.124.33 and newer. Which first-parent commits of the branch the phase runs on. every (the default): each one. newest: only the newest commit when a runner frees. Under either, a run that was cancelled leaves the commit unbuilt rather than red. Under newest, a commit a newer run superseded is unbuilt too, and a red commit starts a bisect over the unbuilt commits back to the last one the phase passed on. chant ci workflow writes the triggers and concurrency of the workflows holding a phase that sets build, and leaves them alone for a phase that does not."
1565
1588
  }
1566
1589
  },
1567
1590
  "patternProperties": {
@@ -459,6 +459,23 @@ describe("ci.reportUrl (#4160)", () => {
459
459
  });
460
460
  });
461
461
 
462
+ describe("security (#4168)", () => {
463
+ test("the groups are read in file order, empty when absent, and securityGroupsOf reads them", async () => {
464
+ const { securityGroupsOf } = await import("./security");
465
+ expect(parse(base([])).security).toEqual([]);
466
+ const decl = parse(base([], { security: ["supply-chain", "verified-releases"] }));
467
+ expect(decl.security).toEqual(["supply-chain", "verified-releases"]);
468
+ expect([...securityGroupsOf(decl)]).toEqual(["supply-chain", "verified-releases"]);
469
+ expect(securityGroupsOf(null).size).toBe(0);
470
+ });
471
+
472
+ test("an unknown group, a repeated group and a non-array are refused", () => {
473
+ for (const security of [["signed"], ["supply-chain", "supply-chain"], "supply-chain"]) {
474
+ expect(failure(base([], { security })).code).toBe("declaration-invalid");
475
+ }
476
+ });
477
+ });
478
+
462
479
  describe("metrics (#4039)", () => {
463
480
  test("failureWindowDays is read as written, without x- keys, and the block is null when absent", () => {
464
481
  expect(parse(base([])).metrics).toBeNull();