@intentius/chant 0.124.33 → 0.124.35

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 (218) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +284 -81
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +37 -2
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +24 -0
  16. package/dist/cli/registry.d.ts +17 -1
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote-rounds.d.ts +102 -0
  28. package/dist/components/promote-rounds.d.ts.map +1 -0
  29. package/dist/components/promote-rounds.js +174 -0
  30. package/dist/components/promote.d.ts +4 -1
  31. package/dist/components/promote.d.ts.map +1 -1
  32. package/dist/components/promote.js +6 -3
  33. package/dist/components/release-gate.d.ts +30 -0
  34. package/dist/components/release-gate.d.ts.map +1 -1
  35. package/dist/components/release-gate.js +92 -42
  36. package/dist/components/release-promote.d.ts +9 -0
  37. package/dist/components/release-promote.d.ts.map +1 -1
  38. package/dist/components/release-promote.js +15 -0
  39. package/dist/config.d.ts +2 -0
  40. package/dist/config.d.ts.map +1 -1
  41. package/dist/deep-observation.d.ts +11 -0
  42. package/dist/deep-observation.d.ts.map +1 -1
  43. package/dist/graph-declared.d.ts +9 -1
  44. package/dist/graph-declared.d.ts.map +1 -1
  45. package/dist/graph-declared.js +18 -3
  46. package/dist/graph-detail.d.ts.map +1 -1
  47. package/dist/graph-detail.js +30 -1
  48. package/dist/graph-ir.d.ts.map +1 -1
  49. package/dist/graph-ir.js +13 -2
  50. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  51. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  52. package/dist/lifecycle/compose-refs.d.ts +52 -0
  53. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  54. package/dist/lifecycle/compose-refs.js +115 -0
  55. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  56. package/dist/lifecycle/deep-observe.js +3 -2
  57. package/dist/lifecycle/green-apply.d.ts +39 -0
  58. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  59. package/dist/lifecycle/green-apply.js +51 -0
  60. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  61. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  62. package/dist/lifecycle/publish-declaration.js +15 -0
  63. package/dist/lifecycle/publish-npm.d.ts +7 -4
  64. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  65. package/dist/lifecycle/publish-npm.js +60 -13
  66. package/dist/lifecycle/release-compose.d.ts +91 -0
  67. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  68. package/dist/lifecycle/release-compose.js +235 -0
  69. package/dist/lifecycle/release-cut.d.ts +8 -0
  70. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  71. package/dist/lifecycle/release-cut.js +2 -0
  72. package/dist/lifecycle/release-publish.d.ts +2 -0
  73. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  74. package/dist/lifecycle/release-publish.js +2 -1
  75. package/dist/lifecycle/release-store.d.ts +15 -0
  76. package/dist/lifecycle/release-store.d.ts.map +1 -1
  77. package/dist/lifecycle/release-store.js +5 -1
  78. package/dist/wave-runtime.mjs +226 -224
  79. package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -1
  80. package/dist/workspace/__fixtures__/contract-repo.js +13 -2
  81. package/dist/workspace/check.schema.json +20 -2
  82. package/dist/workspace/checks/ci-workflow.d.ts +17 -0
  83. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -0
  84. package/dist/workspace/checks/ci-workflow.js +130 -0
  85. package/dist/workspace/checks/security.d.ts +16 -1
  86. package/dist/workspace/checks/security.d.ts.map +1 -1
  87. package/dist/workspace/checks/security.js +4 -4
  88. package/dist/workspace/checks.d.ts +1 -1
  89. package/dist/workspace/checks.d.ts.map +1 -1
  90. package/dist/workspace/checks.js +4 -1
  91. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  92. package/dist/workspace/ci-green-cli.js +5 -3
  93. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  94. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  95. package/dist/workspace/ci-green-workflow.js +27 -5
  96. package/dist/workspace/ci-green.d.ts +31 -0
  97. package/dist/workspace/ci-green.d.ts.map +1 -1
  98. package/dist/workspace/ci-green.js +78 -4
  99. package/dist/workspace/ci-init.d.ts +100 -0
  100. package/dist/workspace/ci-init.d.ts.map +1 -0
  101. package/dist/workspace/ci-init.js +434 -0
  102. package/dist/workspace/ci-run.d.ts +6 -0
  103. package/dist/workspace/ci-run.d.ts.map +1 -1
  104. package/dist/workspace/ci-run.js +2 -1
  105. package/dist/workspace/ci-serve.d.ts +94 -13
  106. package/dist/workspace/ci-serve.d.ts.map +1 -1
  107. package/dist/workspace/ci-serve.js +248 -30
  108. package/dist/workspace/ci-workflow.d.ts +91 -0
  109. package/dist/workspace/ci-workflow.d.ts.map +1 -0
  110. package/dist/workspace/ci-workflow.js +33 -0
  111. package/dist/workspace/declaration.d.ts +18 -0
  112. package/dist/workspace/declaration.d.ts.map +1 -1
  113. package/dist/workspace/declaration.js +9 -2
  114. package/dist/workspace/declaration.schema.json +28 -0
  115. package/dist/workspace/delivery-metrics.d.ts +3 -1
  116. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  117. package/dist/workspace/delivery-metrics.js +12 -3
  118. package/dist/workspace/init.js +2 -2
  119. package/dist/workspace/member-commands.d.ts.map +1 -1
  120. package/dist/workspace/member-commands.js +4 -0
  121. package/dist/workspace/nested-graph.d.ts.map +1 -1
  122. package/dist/workspace/nested-graph.js +1 -0
  123. package/dist/workspace/reason-codes.d.ts +9 -0
  124. package/dist/workspace/reason-codes.d.ts.map +1 -1
  125. package/dist/workspace/reason-codes.js +10 -0
  126. package/dist/workspace/release-candidates.d.ts +28 -0
  127. package/dist/workspace/release-candidates.d.ts.map +1 -1
  128. package/dist/workspace/release-candidates.js +38 -2
  129. package/dist/workspace/release-candidates.schema.json +51 -0
  130. package/dist/workspace/release-workflow.d.ts +31 -0
  131. package/dist/workspace/release-workflow.d.ts.map +1 -0
  132. package/dist/workspace/release-workflow.js +150 -0
  133. package/dist/workspace/releases.schema.json +30 -0
  134. package/package.json +1 -1
  135. package/src/cli/handlers/graph.ts +2 -2
  136. package/src/cli/handlers/pr-green.test.ts +230 -0
  137. package/src/cli/handlers/pr.ts +148 -66
  138. package/src/cli/handlers/promote.test.ts +34 -0
  139. package/src/cli/handlers/promote.ts +302 -91
  140. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  141. package/src/cli/handlers/release.ts +112 -10
  142. package/src/cli/handlers/run.ts +12 -1
  143. package/src/cli/main.ts +37 -2
  144. package/src/cli/parse-args.ts +18 -0
  145. package/src/cli/registry.ts +17 -1
  146. package/src/codegen/release-wiring.test.ts +56 -9
  147. package/src/components/auto-release.test.ts +22 -0
  148. package/src/components/auto-release.ts +9 -2
  149. package/src/components/green-cover.test.ts +170 -0
  150. package/src/components/green-cover.ts +186 -0
  151. package/src/components/pr-pipeline.test.ts +14 -4
  152. package/src/components/pr-pipeline.ts +56 -5
  153. package/src/components/promote-rounds.test.ts +220 -0
  154. package/src/components/promote-rounds.ts +232 -0
  155. package/src/components/promote.test.ts +7 -0
  156. package/src/components/promote.ts +9 -4
  157. package/src/components/release-gate.test.ts +47 -0
  158. package/src/components/release-gate.ts +111 -46
  159. package/src/components/release-promote.test.ts +24 -1
  160. package/src/components/release-promote.ts +15 -0
  161. package/src/deep-observation.ts +8 -0
  162. package/src/graph-declared-exports.test.ts +40 -0
  163. package/src/graph-declared.test.ts +18 -0
  164. package/src/graph-declared.ts +17 -2
  165. package/src/graph-detail.test.ts +68 -1
  166. package/src/graph-detail.ts +29 -1
  167. package/src/graph-ir.ts +12 -4
  168. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  169. package/src/lifecycle/compose-refs.ts +125 -0
  170. package/src/lifecycle/deep-observe.test.ts +20 -0
  171. package/src/lifecycle/deep-observe.ts +4 -2
  172. package/src/lifecycle/green-apply.ts +71 -0
  173. package/src/lifecycle/publish-declaration.ts +15 -0
  174. package/src/lifecycle/publish-npm.ts +55 -13
  175. package/src/lifecycle/publish-targets.test.ts +45 -0
  176. package/src/lifecycle/release-compose.test.ts +125 -0
  177. package/src/lifecycle/release-compose.ts +312 -0
  178. package/src/lifecycle/release-cut.ts +6 -0
  179. package/src/lifecycle/release-publish.ts +4 -1
  180. package/src/lifecycle/release-store.ts +16 -1
  181. package/src/workspace/__fixtures__/contract-repo.ts +11 -2
  182. package/src/workspace/check-contract.test.ts +3 -2
  183. package/src/workspace/check.schema.json +20 -2
  184. package/src/workspace/checks/ci-workflow.test.ts +160 -0
  185. package/src/workspace/checks/ci-workflow.ts +135 -0
  186. package/src/workspace/checks/security.ts +4 -4
  187. package/src/workspace/checks.ts +4 -1
  188. package/src/workspace/ci-green-cli.ts +5 -3
  189. package/src/workspace/ci-green-workflow.test.ts +52 -0
  190. package/src/workspace/ci-green-workflow.ts +34 -5
  191. package/src/workspace/ci-green.test.ts +33 -0
  192. package/src/workspace/ci-green.ts +85 -4
  193. package/src/workspace/ci-init.test.ts +159 -0
  194. package/src/workspace/ci-init.ts +445 -0
  195. package/src/workspace/ci-report-url.test.ts +6 -1
  196. package/src/workspace/ci-run.test.ts +18 -13
  197. package/src/workspace/ci-run.ts +8 -1
  198. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  199. package/src/workspace/ci-serve-land.test.ts +176 -0
  200. package/src/workspace/ci-serve.test.ts +77 -43
  201. package/src/workspace/ci-serve.ts +268 -34
  202. package/src/workspace/ci-workflow.ts +71 -0
  203. package/src/workspace/declaration.schema.json +28 -0
  204. package/src/workspace/declaration.ts +32 -3
  205. package/src/workspace/delivery-metrics.ts +12 -3
  206. package/src/workspace/graph-contract.test.ts +3 -2
  207. package/src/workspace/init.ts +2 -2
  208. package/src/workspace/member-commands.test.ts +11 -4
  209. package/src/workspace/member-commands.ts +3 -0
  210. package/src/workspace/nested-graph.test.ts +4 -0
  211. package/src/workspace/nested-graph.ts +1 -0
  212. package/src/workspace/reason-codes.test.ts +2 -0
  213. package/src/workspace/reason-codes.ts +10 -0
  214. package/src/workspace/release-candidates.schema.json +51 -0
  215. package/src/workspace/release-candidates.test.ts +30 -3
  216. package/src/workspace/release-candidates.ts +54 -2
  217. package/src/workspace/release-workflow.ts +167 -0
  218. package/src/workspace/releases.schema.json +30 -0
@@ -145,7 +145,7 @@ const envFile = (dir: string, stage: string): Record<string, string> =>
145
145
 
146
146
  describe("the job as an Op (#4094)", () => {
147
147
  test("the job's variables come from flags, literals and the runner's tokens, and nothing else of the runner's", () => {
148
- const [plan, apply] = prLoopJobs("forgejo", "prod", { events: true });
148
+ const [plan, apply, record] = prLoopJobs("forgejo", "prod", { events: true });
149
149
  const env = ciJobEnv(plan!, { forge: "forgejo", env: "prod", values: { base: "b".repeat(40), pr: 7, commit: "c".repeat(40) }, hostEnv: HOST, passEnv: ["AWS_ACCESS_KEY_ID"] });
150
150
  expect(env).toMatchObject({
151
151
  BASE_SHA: "b".repeat(40),
@@ -162,10 +162,12 @@ describe("the job as an Op (#4094)", () => {
162
162
  expect(Object.values(env).some((v) => v.includes("${{"))).toBe(false);
163
163
 
164
164
  expect(() => ciJobEnv(plan!, { forge: "forgejo", env: "prod", values: { base: "b".repeat(40), commit: "c".repeat(40) }, hostEnv: {} })).toThrow(CiRunError);
165
- expect(() => ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { commit: "c".repeat(40) }, hostEnv: {} })).toThrow(/pass --base <sha>/);
165
+ expect(() => ciJobEnv(record!, { forge: "forgejo", env: "prod", values: { commit: "c".repeat(40) }, hostEnv: {} })).toThrow(/pass --base <sha>/);
166
+ // #4168 step 9: the apply measures from the last applied green commit, so it needs no --base.
167
+ expect(ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { commit: "c".repeat(40) }, hostEnv: {} })).not.toHaveProperty("BASE_SHA");
166
168
 
167
- const [, gitlabApply] = prLoopJobs("gitlab", "prod");
168
- const gl = ciJobEnv(gitlabApply!, { forge: "gitlab", env: "prod", values: { base: "b".repeat(40), commit: "c".repeat(40) }, hostEnv: {} });
169
+ const [, , gitlabRecord] = prLoopJobs("gitlab", "prod");
170
+ const gl = ciJobEnv(gitlabRecord!, { forge: "gitlab", env: "prod", values: { base: "b".repeat(40), commit: "c".repeat(40) }, hostEnv: {} });
169
171
  expect(gl).toEqual({ BASE_SHA: "b".repeat(40), CHANT_ENV: "prod", CI_COMMIT_SHA: "c".repeat(40) });
170
172
  });
171
173
 
@@ -180,11 +182,13 @@ describe("the job as an Op (#4094)", () => {
180
182
  expect(last.args).toEqual({ cmd: `npx chant${plan!.command.slice("chant".length)}`, cwd: "/w/app", env: { BASE_SHA: "b" }, inheritEnv: false });
181
183
  expect(plan!.command).toContain("--member app");
182
184
 
183
- // No setup or build lines: the job's phase alone. GitLab's apply keeps its base fallback in the command's shell.
184
- const [, gitlabApply] = prLoopJobs("gitlab", "prod");
185
- const bare = ciJobOp({ job: gitlabApply!, env: "prod", cwd: "/w", jobEnv: {}, setup: [], build: [] });
186
- expect(bare.phases.map((p) => p.name)).toEqual(["apply"]);
187
- expect(ciJobCommand(gitlabApply!)).toBe(`${gitlabApply!.setup![0]}\n${gitlabApply!.command}`);
185
+ // No setup or build lines: the job's phase alone. GitLab's record keeps its base fallback in the command's shell.
186
+ const [, gitlabApply, gitlabRecord] = prLoopJobs("gitlab", "prod");
187
+ const bare = ciJobOp({ job: gitlabRecord!, env: "prod", cwd: "/w", jobEnv: {}, setup: [], build: [] });
188
+ expect(bare.phases.map((p) => p.name)).toEqual(["record"]);
189
+ expect(ciJobCommand(gitlabRecord!)).toBe(`${gitlabRecord!.setup![0]}\n${gitlabRecord!.command}`);
190
+ // #4168 step 9: the apply's green checkout is the generated job's, never ci run's: it runs at the commit it was given.
191
+ expect(ciJobCommand(gitlabApply!)).toBe(gitlabApply!.command);
188
192
 
189
193
  // #4088's build and lint lines follow --before-script, with chant replaced in each.
190
194
  const [built] = prLoopJobs("forgejo", "prod", { build: true });
@@ -298,7 +302,7 @@ describe("chant ci run, then ci tick from runs (#4094)", () => {
298
302
 
299
303
  expect(apply.status).toBe("ok");
300
304
  const applyArgs = readFileSync(join(reports, "apply", "pr-apply.args"), "utf-8").trimEnd().split("\n");
301
- expect(applyArgs).toEqual(["components", "pr-apply", "--base", base, "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--resume", ".chant/pr-resume/pr-apply.json"]);
305
+ expect(applyArgs).toEqual(["components", "pr-apply", "--green", "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--resume", ".chant/pr-resume/pr-apply.json"]);
302
306
  expect(formatCiRun(apply)).toContain("signed by chant-runner");
303
307
  });
304
308
 
@@ -671,7 +675,8 @@ describe("the job's secrets from the host (#4096)", () => {
671
675
  const applyEnv = ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, secrets: ciSecretsFor(secrets, "apply") });
672
676
  expect(applyEnv).toMatchObject({ DB_PASSWORD: "hunter22-db", DEPLOY_KEY: "deploy-key-value" });
673
677
  // A secret can replace a forge token, and never the variables the runner sets.
674
- const over = ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, secrets: { GITHUB_TOKEN: "from-file", BASE_SHA: "x", CHANT_RUNNER_KEY: "k" } });
678
+ const [, , record] = prLoopJobs("forgejo", "prod");
679
+ const over = ciJobEnv(record!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, secrets: { GITHUB_TOKEN: "from-file", BASE_SHA: "x", CHANT_RUNNER_KEY: "k" } });
675
680
  expect(over).toMatchObject({ GITHUB_TOKEN: "from-file", BASE_SHA: base });
676
681
  expect(over).not.toHaveProperty("CHANT_RUNNER_KEY");
677
682
  expect(() => ciJobEnv(apply!, { forge: "forgejo", env: "prod", values: { base, commit: base }, hostEnv: HOST, passEnv: ["CHANT_RUNNER_KEY"] })).toThrow(/never passed to a job/);
@@ -791,10 +796,10 @@ describe("a generated forge job runs ci run --in-place (#4156)", () => {
791
796
  expect(r.report).toBe(join(realpathSync(app), ".chant/pr"));
792
797
  expect(r.record.phases.map((p) => p.name)).toEqual(["apply"]);
793
798
  const argv = readFileSync(join(app, ".chant/pr/pr-apply.args"), "utf-8").trimEnd().split("\n");
794
- expect(argv).toEqual(["components", "pr-apply", "--base", base, "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--resume", ".chant/pr-resume/pr-apply.json"]);
799
+ expect(argv).toEqual(["components", "pr-apply", "--green", "--env", "prod", "--gate", "pr-apply", "--output", ".chant/pr", "--forge", "forgejo", "--member", "app", "--resume", ".chant/pr-resume/pr-apply.json"]);
795
800
  // The forge job's whole environment reaches the step, but never the runner key.
796
801
  const env = envFile(join(app, ".chant/pr"), "pr-apply");
797
- expect(env).toMatchObject({ NOT_FOR_JOBS: "leak", GITHUB_RUN_ID: "4242", BASE_SHA: base, GITHUB_SHA: sha });
802
+ expect(env).toMatchObject({ NOT_FOR_JOBS: "leak", GITHUB_RUN_ID: "4242", GITHUB_SHA: sha });
798
803
  expect(env).not.toHaveProperty("CHANT_RUNNER_KEY");
799
804
 
800
805
  // The record: the forge's run, job and attempt as runOrigin, signed, and it verifies against trust.json.
@@ -423,6 +423,12 @@ export interface CiRunRequest {
423
423
  /** `--env`: the environment the pipeline is for. */
424
424
  env: string;
425
425
  forge: ForgeKind;
426
+ /**
427
+ * Run the job's command without `--forge` (#4016 gap 6): `ci serve --forge
428
+ * git` reads a plain git server, which has no API for the command to ask.
429
+ * `forge` then only shapes the job's variables.
430
+ */
431
+ noForge?: boolean;
426
432
  /** `--base`, `--pr` and `--commit` (default `HEAD`). */
427
433
  base?: string;
428
434
  pr?: number;
@@ -668,7 +674,8 @@ export async function ciRun(req: CiRunRequest): Promise<CiRunResult> {
668
674
  const isolated = !req.requireRunnerStart && !req.local && req.job === "apply" && securityGroupsAt(cwd).has("isolated-runners");
669
675
  const started = req.requireRunnerStart || isolated ? runnerStartFor(req, { top, commit, hostEnv }) : undefined;
670
676
  // A generated wave job's command, as prLoopWaveJobs writes it. #4161: a local job's command posts no status or note.
671
- const command = `${found.command}${req.wave !== undefined ? ` --wave ${req.wave}` : ""}${req.local ? " --no-status" : ""}`;
677
+ const forgeCommand = req.noForge ? found.command.replace(` --forge ${req.forge}`, "") : found.command;
678
+ const command = `${forgeCommand}${req.wave !== undefined ? ` --wave ${req.wave}` : ""}${req.local ? " --no-status" : ""}`;
672
679
  const job = { ...found, command, ...(req.wave !== undefined ? { wave: req.wave } : {}) };
673
680
  const own = ciJobEnv(job, {
674
681
  forge: req.forge,
@@ -0,0 +1,138 @@
1
+ /**
2
+ * #4168 4.4 and #4016 gap 6 end to end: `chant ci serve --forge git --land`,
3
+ * as a process, against a bare git remote with no forge API, no runner key
4
+ * and no signers.
5
+ *
6
+ * The build command records an Op run named `check` on the land branch's
7
+ * head, which passes unless the head holds `red.txt`. The serve judges
8
+ * `ci.green.preMerge` from that record: a green land moves main and the land
9
+ * branch goes; a red one leaves both. Each serve gets a fresh state
10
+ * directory, so its first poll only notes main's head and runs no job.
11
+ */
12
+
13
+ import { spawn } from "node:child_process";
14
+ import { writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { pathToFileURL } from "node:url";
17
+ import { afterAll, beforeAll, describe, expect, test } from "vitest";
18
+ import { cleanScratch, declaration, git, REPO, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
19
+
20
+ afterAll(cleanScratch);
21
+
22
+ const LOADER = pathToFileURL(join(REPO, "node_modules/tsx/dist/loader.mjs")).href;
23
+ const GREEN = { branch: "main", phases: { check: ["check"], suites: ["suites"] }, require: ["check", "suites"], preMerge: ["check"], source: "runs" };
24
+
25
+ /** Records an unsigned `check` run on the checkout's HEAD: it fails when `red.txt` is there. */
26
+ const RECORD_CHECK = `
27
+ import { existsSync } from "node:fs";
28
+ import { runOpLocally, OpRunFailure } from ${JSON.stringify(join(REPO, "packages/core/src/op/local-executor.ts"))};
29
+ const red = existsSync("red.txt");
30
+ const op = { name: "check", overview: "check", labels: { Env: "ci" }, phases: [{ name: "Run", steps: [{ kind: "activity", fn: "step", args: {} }] }] };
31
+ const fns = new Map([["step", async () => { if (red) throw new Error("red"); return {}; }]]);
32
+ try {
33
+ await runOpLocally(op, fns, {}, undefined, { cwd: process.cwd(), ledger: { cwd: process.cwd() } });
34
+ } catch (err) {
35
+ if (!(err instanceof OpRunFailure)) throw err;
36
+ }
37
+ `;
38
+
39
+ function chant(cwd: string, ...args: string[]): Promise<{ code: number; out: string; err: string }> {
40
+ return new Promise((done) => {
41
+ const env: Record<string, string | undefined> = { ...process.env, GIT_TERMINAL_PROMPT: "0", NO_COLOR: "1" };
42
+ // No forge and no key: nothing here may lean on either.
43
+ for (const name of ["GITHUB_ACTIONS", "GITHUB_TOKEN", "GH_TOKEN", "CHANT_FORGE_TOKEN", "CHANT_RUNNER_KEY", "GITHUB_REPOSITORY"]) delete env[name];
44
+ const child = spawn(process.execPath, ["--import", LOADER, join(REPO, "packages/core/src/cli/main.ts"), ...args], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
45
+ let out = "";
46
+ let err = "";
47
+ child.stdout.on("data", (c) => (out += c));
48
+ child.stderr.on("data", (c) => (err += c));
49
+ child.on("close", (code) => done({ code: code ?? 1, out, err }));
50
+ });
51
+ }
52
+
53
+ function commit(root: string, file: string): string {
54
+ writeFiles(root, { [file]: `${file}\n` });
55
+ git(root, "add", "-A");
56
+ git(root, "commit", "-q", "-m", file);
57
+ return git(root, "rev-parse", "HEAD");
58
+ }
59
+
60
+ function pushLand(root: string, name: string, files: string[]): string {
61
+ git(root, "checkout", "-q", "-b", `land-${name}`, "main");
62
+ let sha = "";
63
+ for (const f of files) sha = commit(root, f);
64
+ git(root, "push", "-q", "origin", `${sha}:refs/heads/chant/land/${name}`);
65
+ git(root, "checkout", "-q", "main");
66
+ return sha;
67
+ }
68
+
69
+ const remote = (origin: string, ref: string): string | null => {
70
+ try {
71
+ return git(origin, "rev-parse", "--verify", "--quiet", ref);
72
+ } catch {
73
+ return null;
74
+ }
75
+ };
76
+
77
+ describe("ci serve --forge git --land, end to end (#4168 4.4, #4016 gap 6)", () => {
78
+ let root: string;
79
+ let origin: string;
80
+ let main: string;
81
+ let build: string;
82
+
83
+ beforeAll(() => {
84
+ origin = scratchDir("chant-land-e2e-origin-");
85
+ git(origin, "init", "-q", "--bare");
86
+ root = scratchDir("chant-land-e2e-work-");
87
+ git(root, "init", "-q");
88
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
89
+ git(root, "config", "user.name", "t");
90
+ git(root, "config", "user.email", "t@example.com");
91
+ git(root, "remote", "add", "origin", origin);
92
+ writeFiles(root, {
93
+ "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: GREEN } }),
94
+ "app/a.txt": "a\n",
95
+ });
96
+ main = commit(root, "init.txt");
97
+ git(root, "push", "-q", "origin", "main");
98
+ const script = join(scratchDir("chant-land-e2e-bin-"), "record-check.mts");
99
+ writeFileSync(script, RECORD_CHECK);
100
+ build = `${JSON.stringify(process.execPath)} --import ${JSON.stringify(LOADER)} ${JSON.stringify(script)}`;
101
+ });
102
+
103
+ const serve = (state: string) => chant(root, "ci", "serve", "--forge", "git", "--env", "prod", "--once", "--land", "--build-command", build, "--state", join(root, ".git", state));
104
+
105
+ test("a land whose pre-merge phase passes moves main to it, and the land branch is deleted", async () => {
106
+ const head = pushLand(root, "fix", ["fix-1.txt", "fix-2.txt"]);
107
+ const r = await serve("serve-green");
108
+ expect(r.err).toContain("no commit status is set");
109
+ expect(r.err).toContain(`chant/land/fix at ${head.slice(0, 12)} landed, and the land branch was deleted`);
110
+ expect(r.code).toBe(0);
111
+ expect(remote(origin, "refs/heads/main")).toBe(head);
112
+ expect(remote(origin, "refs/heads/chant/land/fix")).toBeNull();
113
+ main = head;
114
+ });
115
+
116
+ test("a land whose pre-merge phase fails leaves main and the land branch", async () => {
117
+ git(root, "fetch", "-q", "origin", "main");
118
+ git(root, "reset", "-q", "--hard", "origin/main");
119
+ const head = pushLand(root, "red", ["red.txt"]);
120
+ const r = await serve("serve-red");
121
+ expect(r.err).toContain(`chant/land/red at ${head.slice(0, 12)} was refused and left on the remote: the pre-merge phase check failed`);
122
+ expect(remote(origin, "refs/heads/main")).toBe(main);
123
+ expect(remote(origin, "refs/heads/chant/land/red")).toBe(head);
124
+ });
125
+
126
+ test("--land without a build command, or without ci.green.preMerge, is refused before the first poll", async () => {
127
+ const noBuild = await chant(root, "ci", "serve", "--forge", "git", "--env", "prod", "--once", "--land");
128
+ expect(noBuild.code).toBe(1);
129
+ expect(noBuild.err).toContain("--land runs each land branch's pre-merge phases with --build-command");
130
+
131
+ const bare = scratchDir("chant-land-e2e-nopre-");
132
+ git(bare, "init", "-q");
133
+ writeFiles(bare, { "chant.workspace.json": declaration([{ name: "app", dir: "app", kind: "other", because: "the app" }], { ci: { green: { ...GREEN, preMerge: undefined } } }) });
134
+ const noPre = await chant(bare, "ci", "serve", "--forge", "git", "--env", "prod", "--once", "--land", "--build-command", "true");
135
+ expect(noPre.code).toBe(1);
136
+ expect(noPre.err).toContain("the declaration names none");
137
+ });
138
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * #4168 4.4 — `ci serve --land` fast-forwards the target to a pushed
3
+ * `chant/land/<name>` branch once its pre-merge phases pass on it, and
4
+ * #4016 gap 6 — `ci serve --forge git` reads a plain git server's refs.
5
+ *
6
+ * Each test works in its own clone with a bare origin. The build and the
7
+ * judge are fakes; `ci-serve-land.e2e.test.ts` runs the real ones.
8
+ */
9
+
10
+ import { join } from "node:path";
11
+ import { afterAll, describe, expect, test } from "vitest";
12
+ import { cleanScratch, git, scratchDir, writeFiles } from "./__fixtures__/contract-repo";
13
+ import { ciServeLands, formatCiServeEvent, gitCiServeForge, newCiServeRuntime, readCiServeState, type CiServeEvent, type CiServeOptions } from "./ci-serve";
14
+ import type { CommitVerdict } from "./ci-green";
15
+
16
+ afterAll(cleanScratch);
17
+
18
+ function commit(root: string, file: string): string {
19
+ writeFiles(root, { [file]: `${file}\n` });
20
+ git(root, "add", "-A");
21
+ git(root, "commit", "-q", "-m", file);
22
+ return git(root, "rev-parse", "HEAD");
23
+ }
24
+
25
+ /** A clone on main with a bare origin. */
26
+ function clone(): { root: string; origin: string; main: string } {
27
+ const origin = scratchDir("chant-serve-land-origin-");
28
+ git(origin, "init", "-q", "--bare");
29
+ const root = scratchDir("chant-serve-land-");
30
+ git(root, "init", "-q");
31
+ git(root, "symbolic-ref", "HEAD", "refs/heads/main");
32
+ git(root, "config", "user.name", "t");
33
+ git(root, "config", "user.email", "t@example.com");
34
+ git(root, "remote", "add", "origin", origin);
35
+ const main = commit(root, "a.txt");
36
+ git(root, "push", "-q", "origin", "main");
37
+ return { root, origin, main };
38
+ }
39
+
40
+ /** A commit on top of `from`, pushed as `chant/land/<name>`. */
41
+ function pushLand(root: string, name: string, from: string, file: string): string {
42
+ git(root, "checkout", "-q", "--detach", from);
43
+ const sha = commit(root, file);
44
+ git(root, "push", "-q", "--force", "origin", `${sha}:refs/heads/chant/land/${name}`);
45
+ git(root, "checkout", "-q", "main");
46
+ return sha;
47
+ }
48
+
49
+ const verdict = (phases: Record<string, "pass" | "fail" | "pending">): CommitVerdict => {
50
+ const list = Object.entries(phases).map(([phase, v]) => ({ phase, verdict: v, runs: [], unmatched: [] }));
51
+ return { verdict: list.some((p) => p.verdict === "fail") ? "fail" : list.some((p) => p.verdict === "pending") ? "pending" : "pass", phases: list, failure: null };
52
+ };
53
+
54
+ function options(root: string, log: CiServeEvent[], extra: Partial<CiServeOptions> = {}): CiServeOptions & { built: { commit: string; phases: string[]; reason: string }[] } {
55
+ const built: { commit: string; phases: string[]; reason: string }[] = [];
56
+ return {
57
+ cwd: root,
58
+ forge: "git",
59
+ repo: "origin",
60
+ env: "prod",
61
+ client: gitCiServeForge(root),
62
+ stateDir: join(root, ".git", "serve"),
63
+ buildCommand: "true",
64
+ build: async ({ request }) => (built.push({ ...request }), 0),
65
+ land: { phases: ["check"] },
66
+ judgeLand: async () => verdict({ check: "pass" }),
67
+ log: (e) => log.push(e),
68
+ built,
69
+ ...extra,
70
+ };
71
+ }
72
+
73
+ const remoteHead = (origin: string, ref: string): string | undefined => {
74
+ try {
75
+ return git(origin, "rev-parse", "--verify", "--quiet", ref);
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ };
80
+
81
+ describe("the git forge kind (#4016 gap 6)", () => {
82
+ test("reads the branch head through ls-remote, lists no pull requests, and says when the branch is missing", async () => {
83
+ const { root, main } = clone();
84
+ const forge = gitCiServeForge(root);
85
+ expect(await forge.branchHead("main")).toBe(main);
86
+ expect(await forge.branchHead("nope")).toBeUndefined();
87
+ expect(await forge.openPullRequests("main")).toEqual([]);
88
+ expect(forge.pullRequestFor).toBeUndefined();
89
+ });
90
+ });
91
+
92
+ describe("ci serve --land (#4168 4.4)", () => {
93
+ test("a land whose pre-merge phases pass moves the target to it and deletes the land branch, once", async () => {
94
+ const { root, origin, main } = clone();
95
+ const head = pushLand(root, "fix", main, "b.txt");
96
+ const log: CiServeEvent[] = [];
97
+ const opts = options(root, log);
98
+ const rt = newCiServeRuntime();
99
+ const pass = await ciServeLands(opts, rt);
100
+ await Promise.all(pass.started);
101
+
102
+ expect(opts.built).toEqual([{ commit: head, phases: ["check"], reason: "land" }]);
103
+ expect(remoteHead(origin, "refs/heads/main")).toBe(head);
104
+ expect(remoteHead(origin, "refs/heads/chant/land/fix")).toBeUndefined();
105
+ const state = readCiServeState(join(root, ".git", "serve", "chant-apply-prod.json"), { forge: "git", repo: "origin", env: "prod", branch: "main" });
106
+ expect(state.lands?.[`land:fix:${head}`]).toMatchObject({ branch: "chant/land/fix", commit: head, status: "landed" });
107
+ expect(log.map(formatCiServeEvent)).toContain(`chant/land/fix at ${head.slice(0, 12)} landed, and the land branch was deleted`);
108
+
109
+ // Nothing is left to serve.
110
+ expect((await ciServeLands(opts, rt)).started).toEqual([]);
111
+ });
112
+
113
+ test("a red pre-merge phase refuses the land and leaves the target and the land branch", async () => {
114
+ const { root, origin, main } = clone();
115
+ const head = pushLand(root, "red", main, "b.txt");
116
+ const log: CiServeEvent[] = [];
117
+ const opts = options(root, log, { judgeLand: async () => verdict({ check: "fail" }) });
118
+ await Promise.all((await ciServeLands(opts, newCiServeRuntime())).started);
119
+
120
+ expect(remoteHead(origin, "refs/heads/main")).toBe(main);
121
+ expect(remoteHead(origin, "refs/heads/chant/land/red")).toBe(head);
122
+ expect(log.map(formatCiServeEvent)).toContain(`chant/land/red at ${head.slice(0, 12)} was refused and left on the remote: the pre-merge phase check failed`);
123
+ });
124
+
125
+ test("a phase with no finished run, or a build that fails, refuses the land", async () => {
126
+ const { root, origin, main } = clone();
127
+ const a = pushLand(root, "norun", main, "b.txt");
128
+ const b = pushLand(root, "broken", main, "c.txt");
129
+ const log: CiServeEvent[] = [];
130
+ const opts = options(root, log, {
131
+ maxJobs: 2,
132
+ judgeLand: async () => verdict({ check: "pending" }),
133
+ build: async ({ request }) => (request.commit === b ? 3 : 0),
134
+ });
135
+ await Promise.all((await ciServeLands(opts, newCiServeRuntime())).started);
136
+
137
+ expect(remoteHead(origin, "refs/heads/main")).toBe(main);
138
+ const lines = log.map(formatCiServeEvent);
139
+ expect(lines).toContain(`chant/land/norun at ${a.slice(0, 12)} was refused and left on the remote: the build recorded no finished run for check`);
140
+ expect(lines).toContain(`chant/land/broken at ${b.slice(0, 12)} was refused and left on the remote: the build of check exited 3`);
141
+ });
142
+
143
+ test("a land that does not descend from the target is refused without a build, and a new push to it is tried again", async () => {
144
+ const { root, origin, main } = clone();
145
+ const stale = pushLand(root, "old", main, "b.txt");
146
+ const moved = commit(root, "c.txt");
147
+ git(root, "push", "-q", "origin", "main");
148
+ const log: CiServeEvent[] = [];
149
+ const opts = options(root, log);
150
+ const rt = newCiServeRuntime();
151
+ await Promise.all((await ciServeLands(opts, rt)).started);
152
+
153
+ expect(opts.built).toEqual([]);
154
+ expect(remoteHead(origin, "refs/heads/main")).toBe(moved);
155
+ expect(log.map(formatCiServeEvent)).toContain(
156
+ `chant/land/old at ${stale.slice(0, 12)} was refused and left on the remote: ${stale.slice(0, 12)} does not descend from main at ${moved.slice(0, 12)}; rebase chant/land/old onto it and push again`,
157
+ );
158
+ // The same head is not tried twice.
159
+ expect((await ciServeLands(opts, rt)).started).toEqual([]);
160
+
161
+ // Rebased and pushed again, it lands.
162
+ const rebased = pushLand(root, "old", moved, "b.txt");
163
+ await Promise.all((await ciServeLands(opts, rt)).started);
164
+ expect(remoteHead(origin, "refs/heads/main")).toBe(rebased);
165
+ });
166
+
167
+ test("lands wait for capacity", async () => {
168
+ const { root, main } = clone();
169
+ const head = pushLand(root, "fix", main, "b.txt");
170
+ const log: CiServeEvent[] = [];
171
+ const rt = newCiServeRuntime();
172
+ rt.inflight.set("busy", new Promise(() => {}));
173
+ expect((await ciServeLands(options(root, log), rt)).started).toEqual([]);
174
+ expect(log.map(formatCiServeEvent)).toContain(`chant/land/fix at ${head.slice(0, 12)} waits: --max-jobs jobs run`);
175
+ });
176
+ });