@intentius/chant 0.124.34 → 0.124.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +23 -8
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +19 -1
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +17 -0
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote.d.ts +4 -1
  28. package/dist/components/promote.d.ts.map +1 -1
  29. package/dist/components/promote.js +6 -3
  30. package/dist/components/release-promote.d.ts +9 -0
  31. package/dist/components/release-promote.d.ts.map +1 -1
  32. package/dist/components/release-promote.js +15 -0
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/deep-observation.d.ts +11 -0
  36. package/dist/deep-observation.d.ts.map +1 -1
  37. package/dist/graph-declared.d.ts +5 -1
  38. package/dist/graph-declared.d.ts.map +1 -1
  39. package/dist/graph-declared.js +7 -2
  40. package/dist/graph-detail.d.ts.map +1 -1
  41. package/dist/graph-detail.js +30 -1
  42. package/dist/graph-ir.d.ts.map +1 -1
  43. package/dist/graph-ir.js +13 -2
  44. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts +5 -0
  45. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  46. package/dist/lifecycle/__fixtures__/publish-fakes.js +20 -2
  47. package/dist/lifecycle/compose-refs.d.ts +52 -0
  48. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  49. package/dist/lifecycle/compose-refs.js +115 -0
  50. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  51. package/dist/lifecycle/deep-observe.js +3 -2
  52. package/dist/lifecycle/green-apply.d.ts +39 -0
  53. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  54. package/dist/lifecycle/green-apply.js +51 -0
  55. package/dist/lifecycle/publish-declaration.d.ts +6 -0
  56. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  57. package/dist/lifecycle/publish-declaration.js +21 -0
  58. package/dist/lifecycle/publish-npm.d.ts +11 -4
  59. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  60. package/dist/lifecycle/publish-npm.js +104 -14
  61. package/dist/lifecycle/release-compose.d.ts +91 -0
  62. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  63. package/dist/lifecycle/release-compose.js +235 -0
  64. package/dist/lifecycle/release-cut.d.ts +8 -0
  65. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  66. package/dist/lifecycle/release-cut.js +2 -0
  67. package/dist/lifecycle/release-publish.d.ts +6 -0
  68. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  69. package/dist/lifecycle/release-publish.js +2 -1
  70. package/dist/lifecycle/release-store.d.ts +15 -0
  71. package/dist/lifecycle/release-store.d.ts.map +1 -1
  72. package/dist/lifecycle/release-store.js +5 -1
  73. package/dist/wave-runtime.mjs +225 -223
  74. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  75. package/dist/workspace/checks/ci-workflow.js +8 -1
  76. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  77. package/dist/workspace/ci-green-cli.js +5 -3
  78. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  79. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  80. package/dist/workspace/ci-green-workflow.js +27 -5
  81. package/dist/workspace/ci-green.d.ts +31 -0
  82. package/dist/workspace/ci-green.d.ts.map +1 -1
  83. package/dist/workspace/ci-green.js +78 -4
  84. package/dist/workspace/ci-run.d.ts +6 -0
  85. package/dist/workspace/ci-run.d.ts.map +1 -1
  86. package/dist/workspace/ci-run.js +2 -1
  87. package/dist/workspace/ci-serve.d.ts +94 -13
  88. package/dist/workspace/ci-serve.d.ts.map +1 -1
  89. package/dist/workspace/ci-serve.js +248 -30
  90. package/dist/workspace/ci-workflow.d.ts +4 -4
  91. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  92. package/dist/workspace/ci-workflow.js +2 -2
  93. package/dist/workspace/delivery-metrics.d.ts +3 -1
  94. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  95. package/dist/workspace/delivery-metrics.js +12 -3
  96. package/dist/workspace/member-commands.d.ts.map +1 -1
  97. package/dist/workspace/member-commands.js +4 -0
  98. package/dist/workspace/nested-graph.d.ts.map +1 -1
  99. package/dist/workspace/nested-graph.js +1 -0
  100. package/dist/workspace/release-candidates.d.ts +28 -0
  101. package/dist/workspace/release-candidates.d.ts.map +1 -1
  102. package/dist/workspace/release-candidates.js +38 -2
  103. package/dist/workspace/release-candidates.schema.json +51 -0
  104. package/dist/workspace/releases.schema.json +30 -0
  105. package/package.json +1 -1
  106. package/src/cli/handlers/graph.ts +2 -2
  107. package/src/cli/handlers/pr-green.test.ts +230 -0
  108. package/src/cli/handlers/pr.ts +148 -66
  109. package/src/cli/handlers/promote.test.ts +34 -0
  110. package/src/cli/handlers/promote.ts +22 -8
  111. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  112. package/src/cli/handlers/release-publish-targets.e2e.test.ts +4 -1
  113. package/src/cli/handlers/release.ts +112 -10
  114. package/src/cli/handlers/run.ts +12 -1
  115. package/src/cli/main.ts +19 -1
  116. package/src/cli/parse-args.ts +13 -0
  117. package/src/cli/registry.ts +14 -0
  118. package/src/codegen/release-wiring.test.ts +56 -9
  119. package/src/components/auto-release.test.ts +22 -0
  120. package/src/components/auto-release.ts +9 -2
  121. package/src/components/green-cover.test.ts +170 -0
  122. package/src/components/green-cover.ts +186 -0
  123. package/src/components/pr-pipeline.test.ts +14 -4
  124. package/src/components/pr-pipeline.ts +56 -5
  125. package/src/components/promote.test.ts +7 -0
  126. package/src/components/promote.ts +9 -4
  127. package/src/components/release-promote.test.ts +24 -1
  128. package/src/components/release-promote.ts +15 -0
  129. package/src/deep-observation.ts +8 -0
  130. package/src/graph-declared.test.ts +18 -0
  131. package/src/graph-declared.ts +8 -1
  132. package/src/graph-detail.test.ts +68 -1
  133. package/src/graph-detail.ts +29 -1
  134. package/src/graph-ir.ts +12 -4
  135. package/src/lifecycle/__fixtures__/publish-fakes.ts +20 -2
  136. package/src/lifecycle/compose-refs.ts +125 -0
  137. package/src/lifecycle/deep-observe.test.ts +20 -0
  138. package/src/lifecycle/deep-observe.ts +4 -2
  139. package/src/lifecycle/green-apply.ts +71 -0
  140. package/src/lifecycle/publish-declaration.ts +21 -0
  141. package/src/lifecycle/publish-npm.ts +100 -14
  142. package/src/lifecycle/publish-targets.test.ts +108 -0
  143. package/src/lifecycle/release-compose.test.ts +125 -0
  144. package/src/lifecycle/release-compose.ts +312 -0
  145. package/src/lifecycle/release-cut.ts +6 -0
  146. package/src/lifecycle/release-publish.ts +8 -1
  147. package/src/lifecycle/release-store.ts +16 -1
  148. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  149. package/src/workspace/checks/ci-workflow.ts +7 -1
  150. package/src/workspace/ci-green-cli.ts +5 -3
  151. package/src/workspace/ci-green-workflow.test.ts +52 -0
  152. package/src/workspace/ci-green-workflow.ts +34 -5
  153. package/src/workspace/ci-green.test.ts +33 -0
  154. package/src/workspace/ci-green.ts +85 -4
  155. package/src/workspace/ci-run.test.ts +18 -13
  156. package/src/workspace/ci-run.ts +8 -1
  157. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  158. package/src/workspace/ci-serve-land.test.ts +176 -0
  159. package/src/workspace/ci-serve.test.ts +77 -43
  160. package/src/workspace/ci-serve.ts +268 -34
  161. package/src/workspace/ci-workflow.ts +4 -4
  162. package/src/workspace/delivery-metrics.ts +12 -3
  163. package/src/workspace/graph-contract.test.ts +3 -2
  164. package/src/workspace/member-commands.test.ts +11 -4
  165. package/src/workspace/member-commands.ts +3 -0
  166. package/src/workspace/nested-graph.test.ts +4 -0
  167. package/src/workspace/nested-graph.ts +1 -0
  168. package/src/workspace/release-candidates.schema.json +51 -0
  169. package/src/workspace/release-candidates.test.ts +30 -3
  170. package/src/workspace/release-candidates.ts +54 -2
  171. package/src/workspace/releases.schema.json +30 -0
@@ -381,6 +381,58 @@ describe("the triggers of a phase that sets build (#4169)", () => {
381
381
  expect(before.on.workflow_run.branches).toEqual(["main"]);
382
382
  });
383
383
 
384
+ test("under a ci.workflow, both builds run on chant/compose/** pushes, a compose branch's every runs are keyed by commit, and the tick ticks on them (#4220)", () => {
385
+ const sources = [
386
+ { path: ".github/workflows/ci.yml", text: LINT_WF },
387
+ { path: ".github/workflows/suites.yml", text: SUITES_WF },
388
+ ];
389
+ const edits = buildTriggerEdits(green(), sources, { compose: true });
390
+ const ci = parseYAMLDocument(edits[0].text) as Yaml;
391
+ expect(ci.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, pull_request: null });
392
+ expect(ci.concurrency).toEqual({
393
+ group: "chant-build-lint-${{ (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) && github.sha || github.ref }}",
394
+ "cancel-in-progress": "${{ !(github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/chant/compose/')) }}",
395
+ });
396
+ const suites = parseYAMLDocument(edits[1].text) as Yaml;
397
+ expect(suites.on).toEqual({ push: { branches: ["main", "chant/compose/**"] }, workflow_dispatch: {} });
398
+ // Writing again changes nothing.
399
+ expect(buildTriggerEdits(green(), edits.map((e) => ({ path: e.path, text: e.text })), { compose: true }).map((e) => e.changed)).toEqual([false, false]);
400
+ // A push with no branch list already runs on every branch, so its triggers stay.
401
+ const anyBranch = LINT_WF.replace(" push:\n branches: [main]\n", " push:\n");
402
+ expect((parseYAMLDocument(buildTriggerEdits(green(), [{ path: ".github/workflows/ci.yml", text: anyBranch }], { compose: true })[0].text) as Yaml).on).toEqual({ push: null, pull_request: null });
403
+ // Without compose the bytes are what #4169 wrote.
404
+ expect(buildTriggerEdits(green(), sources).map((e) => e.text)).toEqual(buildTriggerEdits(green(), sources, { compose: false }).map((e) => e.text));
405
+ const tick = parseYAMLDocument(renderCiGreenWorkflow({ green: green(), workflows: ["ci"], chantVersion: "0.124.33", workspaceRoot: ".", compose: true })) as Yaml;
406
+ expect(tick.on.workflow_run.branches).toEqual(["main", "ci/build/**", "chant/compose/**"]);
407
+ });
408
+
409
+ test("chant ci workflow writes the compose triggers only when the declaration names a ci.workflow (#4220)", async () => {
410
+ const run = async (ci: Record<string, unknown>) => {
411
+ const root = scratchDir();
412
+ git(root, "init", "-q");
413
+ writeFiles(root, {
414
+ "chant.workspace.json": declaration([{ name: "root", dir: ".", kind: "other", because: "x" }], { ci }),
415
+ ".github/workflows/ci.yml": LINT_WF,
416
+ });
417
+ const cwd = vi.spyOn(process, "cwd").mockReturnValue(root);
418
+ const log = vi.spyOn(console, "log").mockImplementation(() => {});
419
+ try {
420
+ expect(await runCiWorkflow({ args: {} } as unknown as CommandContext)).toBe(0);
421
+ return { tick: readFileSync(join(root, CI_GREEN_WORKFLOW_FILE), "utf-8"), ci: readFileSync(join(root, ".github/workflows/ci.yml"), "utf-8") };
422
+ } finally {
423
+ cwd.mockRestore();
424
+ log.mockRestore();
425
+ }
426
+ };
427
+ const lint = { branch: "main", phases: { lint: { runs: ["lint"], build: "every" } }, require: ["lint"] };
428
+ const without = await run({ green: lint });
429
+ expect(without.tick).not.toContain("chant/compose");
430
+ expect(without.ci).not.toContain("chant/compose");
431
+ const curated = await run({ green: lint, workflow: "trunk-curated" });
432
+ expect(curated.tick).toContain("chant/compose/**");
433
+ expect(curated.ci).toContain("chant/compose/**");
434
+ });
435
+
384
436
  test("the workflows a build request can start are the ones holding its phases with workflow_dispatch", () => {
385
437
  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
438
  });
@@ -52,6 +52,7 @@ import { emitYAMLEntry, parseYAMLDocument } from "../yaml";
52
52
  import type { CiBuild, CiGreen } from "./declaration";
53
53
  import { matchesCheckRun } from "./ci-green";
54
54
  import { githubSecretOrJobToken } from "../ci-jobs";
55
+ import { COMPOSE_BRANCH_PREFIX } from "../lifecycle/compose-refs";
55
56
 
56
57
  /** Where the workflow goes, relative to the repository root. */
57
58
  export const CI_GREEN_WORKFLOW_FILE = ".github/workflows/chant-ci-green.yml";
@@ -194,8 +195,17 @@ export interface WorkflowInput {
194
195
  * The key's principal needs the `runner` role in `.chant/trust.json`.
195
196
  */
196
197
  runnerKeySecret?: string;
198
+ /**
199
+ * The workspace declares a `ci.workflow` (#4220), so composed releases
200
+ * live on `chant/compose/<name>` branches (#4168 4.3): the tick also runs
201
+ * when a phase workflow completes on one of them.
202
+ */
203
+ compose?: boolean;
197
204
  }
198
205
 
206
+ /** The branch filter for composed releases' branches (#4220). */
207
+ export const COMPOSE_BRANCHES = `${COMPOSE_BRANCH_PREFIX}**`;
208
+
199
209
  /**
200
210
  * A token the checkout persists for the tick's `git push`: a repository
201
211
  * secret, or a GitHub App's installation token minted from its id (an
@@ -240,6 +250,8 @@ export function renderCiGreenWorkflow(input: WorkflowInput): string {
240
250
  ` - ${q(green.branch)}`,
241
251
  // #4169: a build the tick asked for runs at a ci/build/<sha> tag, and its completion ticks too.
242
252
  ...(newestPhases(green) ? [` - ${q("ci/build/**")}`] : []),
253
+ // #4220: a composed commit's phases run on its chant/compose/<name> branch, and their completion ticks too.
254
+ ...(input.compose ? [` - ${q(COMPOSE_BRANCHES)}`] : []),
243
255
  " schedule:",
244
256
  " - cron: \"*/15 * * * *\"",
245
257
  " workflow_dispatch: {}",
@@ -319,12 +331,19 @@ export interface BuildTriggerEdit {
319
331
  * branch, so no push cancels or replaces another's run; elsewhere, such as
320
332
  * a pull request, a new push cancels the run it supersedes.
321
333
  *
322
- * Only the top-level `on:` (for `newest`) and `concurrency:` blocks change,
334
+ * With `compose` (#4220, a workspace declaring `ci.workflow`), both builds
335
+ * also run on pushes to `chant/compose/**`: `newest` adds the filter to the
336
+ * branches it writes, `every` adds it to an existing `push.branches` list,
337
+ * and `every`'s group keys a compose branch's runs by commit, as the trunk
338
+ * branch's.
339
+ *
340
+ * Only the top-level `on:` (for `newest`, or `every` with `compose`) and `concurrency:` blocks change,
323
341
  * each under {@link BUILD_BLOCK_MARKER}; the rest of the file is kept as it
324
342
  * is. Throws when one workflow holds a phase of each build, which no one
325
343
  * concurrency group can serve.
326
344
  */
327
- export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[]): BuildTriggerEdit[] {
345
+ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSource[], options: { compose?: boolean } = {}): BuildTriggerEdit[] {
346
+ const compose = options.compose === true;
328
347
  const out: BuildTriggerEdit[] = [];
329
348
  for (const s of sources) {
330
349
  let doc: unknown;
@@ -349,17 +368,27 @@ export function buildTriggerEdits(green: CiGreen, sources: readonly WorkflowSour
349
368
  delete on.schedule;
350
369
  const push = isObject(on.push) ? { ...on.push } : {};
351
370
  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];
371
+ const withBranch = branches.includes(green.branch) ? branches : [...branches, green.branch];
372
+ push.branches = compose && !withBranch.includes(COMPOSE_BRANCHES) ? [...withBranch, COMPOSE_BRANCHES] : withBranch;
353
373
  const { push: _push, workflow_dispatch: dispatch, ...rest } = on;
354
374
  text = writeTopLevel(text, "on", { push, workflow_dispatch: dispatch ?? {}, ...rest });
355
375
  text = writeTopLevel(text, "concurrency", { group: `chant-build-${tag}-\${{ github.ref }}`, "cancel-in-progress": false });
356
376
  } else {
357
377
  const main = `refs/heads/${green.branch}`;
378
+ // A push filter that lists branches gains the compose branches; one with no list already runs on every branch.
379
+ const on = isObject(doc) && isObject(doc.on) ? doc.on : undefined;
380
+ const pushOn = on && isObject(on.push) ? on.push : undefined;
381
+ if (compose && on && pushOn && Array.isArray(pushOn.branches) && !pushOn.branches.includes(COMPOSE_BRANCHES)) {
382
+ text = writeTopLevel(text, "on", { ...on, push: { ...pushOn, branches: [...pushOn.branches, COMPOSE_BRANCHES] } });
383
+ }
384
+ // #4220: a compose branch's commits each get a verdict, so its runs are keyed by commit, as the trunk branch's are.
385
+ const keyed = compose ? `(github.ref == '${main}' || startsWith(github.ref, 'refs/heads/${COMPOSE_BRANCH_PREFIX}'))` : `github.ref == '${main}'`;
386
+ const unkeyed = compose ? `!${keyed}` : `github.ref != '${main}'`;
358
387
  // Written as lines: the emitter would quote the expression, and GitHub reads it the same either way.
359
388
  text = writeTopLevel(text, "concurrency", [
360
389
  "concurrency:",
361
- ` group: chant-build-${tag}-\${{ github.ref == '${main}' && github.sha || github.ref }}`,
362
- ` cancel-in-progress: \${{ github.ref != '${main}' }}`,
390
+ ` group: chant-build-${tag}-\${{ ${keyed} && github.sha || github.ref }}`,
391
+ ` cancel-in-progress: \${{ ${unkeyed} }}`,
363
392
  ]);
364
393
  }
365
394
  out.push({ path: s.path, build, phases, text, changed: text !== s.text });
@@ -531,3 +531,36 @@ describe("chant ci list (#4021)", () => {
531
531
  }
532
532
  });
533
533
  });
534
+
535
+ describe("chant ci tick walks compose branches (#4168 4.3)", () => {
536
+ test("judges composed commits, names their branch in the tag, and warns when a source they carry is revoked", async () => {
537
+ const { root, origin } = workspace();
538
+ const a = commitAt(root, 5, "app/a.txt");
539
+ const b = commitAt(root, 4, "app/b.txt");
540
+ push(root);
541
+ // A composed commit on a, picking b.
542
+ git(root, "checkout", "-q", "-b", "chant/compose/hf", a);
543
+ writeFiles(root, { "app/b.txt": "app/b.txt 4\n" });
544
+ gitAt(root, hoursAgo(2), "add", "-A");
545
+ gitAt(root, hoursAgo(2), "commit", "-q", "-m", `app/b.txt\n\nChant-Composed-From: ${b}`);
546
+ const c = git(root, "rev-parse", "HEAD");
547
+ git(root, "push", "-q", "origin", "chant/compose/hf");
548
+ git(root, "checkout", "-q", "main");
549
+
550
+ const runs = new Map([
551
+ [a, PASSING],
552
+ [b, PASSING],
553
+ [c, PASSING],
554
+ ]);
555
+ const first = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
556
+ expect(first.composed.map((x) => [x.branch, x.commits.map((k) => k.sha)])).toEqual([["chant/compose/hf", [c]]]);
557
+ expect(first.made.sort()).toEqual([`ci/green/${a}`, `ci/green/${b}`, `ci/green/${c}`].sort());
558
+ const note = git(origin, "tag", "-l", "--format=%(contents)", `ci/green/${c}`);
559
+ expect(JSON.parse(note.slice(note.indexOf("{")))).toMatchObject({ tag: "green", commit: c, branch: "chant/compose/hf" });
560
+
561
+ runs.set(b, [...PASSING, run(9, "lint", "failure")]);
562
+ const revoke = await ciTick({ cwd: root, forge: fakeForge(runs), now: NOW });
563
+ expect(revoke.made).toEqual([`ci/revoked/${b}`]);
564
+ expect(revoke.warnings.join("\n")).toContain(`chant/compose/hf carries it as ${c.slice(0, 12)}`);
565
+ });
566
+ });
@@ -71,6 +71,7 @@ import { gitRevisionSource } from "./record-source";
71
71
  import { emptyPolicy, readTrustPolicy } from "./trust/policy";
72
72
  import { loadRunnerKey } from "./trust/dsse";
73
73
  import { signCiTagRecord, verifyCiTagStatement, CI_TAG_SIGNER_ROLE, type CiTagFailure, type CiTagRecord, type CiTagSigner } from "./trust/ci-tag-statement";
74
+ import { COMPOSE_BRANCH_PREFIX, composedIncluding, isComposeBranch, remoteComposeBranches } from "../lifecycle/compose-refs";
74
75
 
75
76
  /** The environment variable a tick (#4031) and `chant run` (#4044) read a runner key from, as PEM, when `--key` is not given. */
76
77
  export const RUNNER_KEY_ENV = "CHANT_RUNNER_KEY";
@@ -478,7 +479,9 @@ export function readCiTags(top: string, green: CiGreen | null = null, base: stri
478
479
  continue;
479
480
  }
480
481
  const p = policyAtBase();
481
- const v = verifyCiTagStatement(t.record, { sha: t.sha, kind: t.kind, branch: green!.branch }, p.runners, p.roles);
482
+ // A tag the tick made walking a compose branch (#4168 4.3) is checked against that branch.
483
+ const walked = isComposeBranch(t.record?.branch) ? (t.record!.branch as string) : green!.branch;
484
+ const v = verifyCiTagStatement(t.record, { sha: t.sha, kind: t.kind, branch: walked }, p.runners, p.roles);
482
485
  if (v.status === "signed") counted(v.signer);
483
486
  else ignore(v.status, `${v.reason}; ci.green.signedSince is ${green!.signedSince}, so a tag on a commit that is not a strict first-parent ancestor of it needs one`);
484
487
  }
@@ -501,7 +504,7 @@ export function signedRunsRequired(cwd: string): string | null {
501
504
  }
502
505
 
503
506
  /** The declaration's `ci.green`, read where `cwd` is, and the git top. */
504
- function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { name: string; root: string; file: string } } {
507
+ export function readGreen(cwd: string): { green: CiGreen; top: string; workspace: { name: string; root: string; file: string } } {
505
508
  const located = locateWorkspace(cwd);
506
509
  const declaration = readDeclaration(located.tree);
507
510
  const green = declaration.ci?.green ?? null;
@@ -535,12 +538,21 @@ export interface TickRequest {
535
538
  runnerKey?: string;
536
539
  }
537
540
 
541
+ /** One compose branch a tick walked (#4168 4.3): its composed commits within the window. */
542
+ export interface ComposedBranchTick {
543
+ branch: string;
544
+ ref: string;
545
+ commits: TickCommit[];
546
+ }
547
+
538
548
  export interface TickResult {
539
549
  branch: string;
540
550
  window: string;
541
551
  /** The ref the commits were read from. */
542
552
  ref: string;
543
553
  commits: TickCommit[];
554
+ /** The compose branches walked (#4168 4.3), each with its composed commits judged. Their tags are in `made`. */
555
+ composed: ComposedBranchTick[];
544
556
  /** The tags made and pushed, or that would be under `dryRun`. */
545
557
  made: string[];
546
558
  /** The builds asked for (#4169), as `ci/build/<sha>` tags made and pushed, or that would be under `dryRun`. */
@@ -596,7 +608,16 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
596
608
  const now = req.now ?? new Date();
597
609
  const tracking = `refs/remotes/${remote}/${green.branch}`;
598
610
  try {
599
- git(top, ["fetch", "--quiet", "--no-tags", "--prune", remote, `+refs/heads/${green.branch}:${tracking}`, "+refs/tags/ci/*:refs/tags/ci/*"]);
611
+ git(top, [
612
+ "fetch",
613
+ "--quiet",
614
+ "--no-tags",
615
+ "--prune",
616
+ remote,
617
+ `+refs/heads/${green.branch}:${tracking}`,
618
+ `+refs/heads/${COMPOSE_BRANCH_PREFIX}*:refs/remotes/${remote}/${COMPOSE_BRANCH_PREFIX}*`,
619
+ "+refs/tags/ci/*:refs/tags/ci/*",
620
+ ]);
600
621
  } catch (err) {
601
622
  throw new Error(`could not fetch ${green.branch} and the ci tags from ${remote}: ${stderrOf(err)}`);
602
623
  }
@@ -648,6 +669,28 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
648
669
  signer,
649
670
  });
650
671
  if (links) for (const c of planned) c.work = links.get(c.sha);
672
+ // Compose branches (#4168 4.3): each composed commit within the window is judged as trunk's are, and its tags name its branch.
673
+ const composed: ComposedBranchTick[] = [];
674
+ for (const b of remoteComposeBranches(top, remote)) {
675
+ const own = git(top, ["log", "--first-parent", `--max-age=${cutoff}`, "--format=%H %ct", b.ref, `^${tracking}`])
676
+ .split("\n")
677
+ .filter(Boolean)
678
+ .map((line) => line.split(" "))
679
+ .filter(([, ct]) => Number(ct) >= cutoff)
680
+ .map(([sha, ct]) => ({ sha, committed: iso(new Date(Number(ct) * 1000)) }));
681
+ if (own.length === 0) continue;
682
+ const judged = await planTick({
683
+ green: { ...green, branch: b.branch },
684
+ commits: own,
685
+ greenTags: new Set(tags.green.keys()),
686
+ revokedTags: new Set(tags.revoked.keys()),
687
+ ignored: tags.ignored,
688
+ checkRuns: async (sha) => (runs ? runs.checkRuns(sha) : await forge!.checkRuns(sha)),
689
+ now,
690
+ signer,
691
+ });
692
+ composed.push({ branch: b.branch, ref: b.ref, commits: judged });
693
+ }
651
694
  if (runs) {
652
695
  for (const c of planned) {
653
696
  const ignored = runs.ignored.get(c.sha);
@@ -658,7 +701,14 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
658
701
  const requests = readBuildRequests(top);
659
702
  const retryVia = green.source === "runs" ? "request" : forge?.rerun ? "forge" : null;
660
703
  const builds = planBuilds({ green, commits: planned, runs: runsBy, requests, records, now, retryVia });
661
- const due = planned.filter((c) => c.made !== null);
704
+ const due = [...planned, ...composed.flatMap((b) => b.commits)].filter((c) => c.made !== null);
705
+ // Decision 4: revoking a trunk commit says which composed commits carry it; their own CI decides their releases.
706
+ for (const c of planned) {
707
+ if (!c.made?.tag.startsWith(CI_REVOKED_TAG_PREFIX)) continue;
708
+ for (const k of composedIncluding(top, c.sha, remote)) {
709
+ warnings.push(`${c.sha.slice(0, 12)} is revoked, and ${k.branch} carries it as ${k.commit.slice(0, 12)}; that composed commit's own CI decides whether its releases stand`);
710
+ }
711
+ }
662
712
  if (!req.dryRun && due.length > 0) {
663
713
  const ident = taggerConfig(top);
664
714
  for (const c of due) git(top, [...ident, "tag", "-a", "-m", c.made!.message, c.made!.tag, c.sha]);
@@ -683,6 +733,7 @@ export async function ciTick(req: TickRequest): Promise<TickResult> {
683
733
  ref: tracking,
684
734
  commits: planned,
685
735
  made: due.map((c) => c.made!.tag),
736
+ composed,
686
737
  requested: builds.requests,
687
738
  retried,
688
739
  recorded,
@@ -964,6 +1015,23 @@ export async function readOpRunSource(cwd: string, top: string, base: string, si
964
1015
  return { checkRuns: (sha) => byCommit.get(sha) ?? [], ignored };
965
1016
  }
966
1017
 
1018
+ /**
1019
+ * Judge `ci.green.preMerge` on `commit` from the Op run records on the ledger
1020
+ * (#4168 4.4), as `ci serve --land` does before it moves the target. The
1021
+ * trust policy is read at `base`, the target's head. An unsigned run counts
1022
+ * as the tick counts one: always, unless `ci.green.signedSince` asks for a
1023
+ * signature. Throws {@link CiGreenError} when the declaration has no
1024
+ * `ci.green` or names no pre-merge phase.
1025
+ */
1026
+ export async function judgePreMerge(req: { cwd: string; commit: string; base: string }): Promise<CommitVerdict> {
1027
+ const { green, top } = readGreen(req.cwd);
1028
+ const pre = green.preMerge ?? [];
1029
+ if (pre.length === 0) throw new CiGreenError("ci-green-undeclared", "ci.green.preMerge names no phase, so nothing says which checks must pass before the branch moves");
1030
+ const runs = await readOpRunSource(req.cwd, top, req.base, green.signedSince);
1031
+ const latest = latestAttempts(runs.checkRuns(req.commit));
1032
+ return commitVerdictOf(pre.map((name) => evaluatePhase(green.phases.find((p) => p.name === name)!, latest)));
1033
+ }
1034
+
967
1035
  /**
968
1036
  * What `chant run <op> --key` signs a run's record with (#4044): the runner
969
1037
  * key `.chant/trust.json` at the checkout's `HEAD` lists for a principal
@@ -1063,6 +1131,19 @@ export function formatTick(r: TickResult, dryRun = false): string {
1063
1131
  for (const r of c.ignoredRuns ?? []) lines.push(` ignored run ${r.run} of ${r.op} in ${r.env} (${r.status}): ${r.reason}`);
1064
1132
  if (c.blocked) lines.push(` did not make ${c.blocked}: an ignored tag holds the name. Delete it to let the tick tag the commit: git push origin :refs/tags/${c.blocked}`);
1065
1133
  }
1134
+ for (const b of r.composed ?? []) {
1135
+ lines.push(`${b.branch}, composed commits within ${r.window}: ${b.commits.length}`);
1136
+ for (const c of b.commits) {
1137
+ const what = c.made
1138
+ ? `${dryRun ? "would make" : "made"} ${c.made.tag.slice(0, c.made.tag.lastIndexOf("/"))}`
1139
+ : c.before !== "none"
1140
+ ? c.before
1141
+ : c.verdict?.verdict === "fail"
1142
+ ? `failed: phase ${c.verdict.failure!.phase}`
1143
+ : "undecided";
1144
+ lines.push(` ${c.sha.slice(0, 12)} ${what}`);
1145
+ }
1146
+ }
1066
1147
  if (r.signer && r.made.length > 0) lines.push(`signed as ${r.signer}`);
1067
1148
  for (const q of r.requested) lines.push(`${dryRun ? "would ask" : "asked"} for ${q.phases.join(", ")} at ${q.commit.slice(0, 12)} (${q.reason}): ${q.tag}`);
1068
1149
  for (const x of r.retried) lines.push(`${dryRun ? "would run" : "ran"} ${x.run} at ${x.sha.slice(0, 12)} again`);
@@ -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
+ });