@intentius/chant 0.124.34 → 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 (169) 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 +2 -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.map +1 -1
  45. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  46. package/dist/lifecycle/compose-refs.d.ts +52 -0
  47. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  48. package/dist/lifecycle/compose-refs.js +115 -0
  49. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  50. package/dist/lifecycle/deep-observe.js +3 -2
  51. package/dist/lifecycle/green-apply.d.ts +39 -0
  52. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  53. package/dist/lifecycle/green-apply.js +51 -0
  54. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  55. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  56. package/dist/lifecycle/publish-declaration.js +15 -0
  57. package/dist/lifecycle/publish-npm.d.ts +7 -4
  58. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  59. package/dist/lifecycle/publish-npm.js +60 -13
  60. package/dist/lifecycle/release-compose.d.ts +91 -0
  61. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  62. package/dist/lifecycle/release-compose.js +235 -0
  63. package/dist/lifecycle/release-cut.d.ts +8 -0
  64. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  65. package/dist/lifecycle/release-cut.js +2 -0
  66. package/dist/lifecycle/release-publish.d.ts +2 -0
  67. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  68. package/dist/lifecycle/release-publish.js +2 -1
  69. package/dist/lifecycle/release-store.d.ts +15 -0
  70. package/dist/lifecycle/release-store.d.ts.map +1 -1
  71. package/dist/lifecycle/release-store.js +5 -1
  72. package/dist/wave-runtime.mjs +225 -223
  73. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  74. package/dist/workspace/checks/ci-workflow.js +8 -1
  75. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  76. package/dist/workspace/ci-green-cli.js +5 -3
  77. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  78. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  79. package/dist/workspace/ci-green-workflow.js +27 -5
  80. package/dist/workspace/ci-green.d.ts +31 -0
  81. package/dist/workspace/ci-green.d.ts.map +1 -1
  82. package/dist/workspace/ci-green.js +78 -4
  83. package/dist/workspace/ci-run.d.ts +6 -0
  84. package/dist/workspace/ci-run.d.ts.map +1 -1
  85. package/dist/workspace/ci-run.js +2 -1
  86. package/dist/workspace/ci-serve.d.ts +94 -13
  87. package/dist/workspace/ci-serve.d.ts.map +1 -1
  88. package/dist/workspace/ci-serve.js +248 -30
  89. package/dist/workspace/ci-workflow.d.ts +4 -4
  90. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  91. package/dist/workspace/ci-workflow.js +2 -2
  92. package/dist/workspace/delivery-metrics.d.ts +3 -1
  93. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  94. package/dist/workspace/delivery-metrics.js +12 -3
  95. package/dist/workspace/member-commands.d.ts.map +1 -1
  96. package/dist/workspace/member-commands.js +4 -0
  97. package/dist/workspace/nested-graph.d.ts.map +1 -1
  98. package/dist/workspace/nested-graph.js +1 -0
  99. package/dist/workspace/release-candidates.d.ts +28 -0
  100. package/dist/workspace/release-candidates.d.ts.map +1 -1
  101. package/dist/workspace/release-candidates.js +38 -2
  102. package/dist/workspace/release-candidates.schema.json +51 -0
  103. package/dist/workspace/releases.schema.json +30 -0
  104. package/package.json +1 -1
  105. package/src/cli/handlers/graph.ts +2 -2
  106. package/src/cli/handlers/pr-green.test.ts +230 -0
  107. package/src/cli/handlers/pr.ts +148 -66
  108. package/src/cli/handlers/promote.test.ts +34 -0
  109. package/src/cli/handlers/promote.ts +22 -8
  110. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  111. package/src/cli/handlers/release.ts +112 -10
  112. package/src/cli/handlers/run.ts +12 -1
  113. package/src/cli/main.ts +19 -1
  114. package/src/cli/parse-args.ts +13 -0
  115. package/src/cli/registry.ts +14 -0
  116. package/src/codegen/release-wiring.test.ts +56 -9
  117. package/src/components/auto-release.test.ts +22 -0
  118. package/src/components/auto-release.ts +9 -2
  119. package/src/components/green-cover.test.ts +170 -0
  120. package/src/components/green-cover.ts +186 -0
  121. package/src/components/pr-pipeline.test.ts +14 -4
  122. package/src/components/pr-pipeline.ts +56 -5
  123. package/src/components/promote.test.ts +7 -0
  124. package/src/components/promote.ts +9 -4
  125. package/src/components/release-promote.test.ts +24 -1
  126. package/src/components/release-promote.ts +15 -0
  127. package/src/deep-observation.ts +8 -0
  128. package/src/graph-declared.test.ts +18 -0
  129. package/src/graph-declared.ts +8 -1
  130. package/src/graph-detail.test.ts +68 -1
  131. package/src/graph-detail.ts +29 -1
  132. package/src/graph-ir.ts +12 -4
  133. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  134. package/src/lifecycle/compose-refs.ts +125 -0
  135. package/src/lifecycle/deep-observe.test.ts +20 -0
  136. package/src/lifecycle/deep-observe.ts +4 -2
  137. package/src/lifecycle/green-apply.ts +71 -0
  138. package/src/lifecycle/publish-declaration.ts +15 -0
  139. package/src/lifecycle/publish-npm.ts +55 -13
  140. package/src/lifecycle/publish-targets.test.ts +45 -0
  141. package/src/lifecycle/release-compose.test.ts +125 -0
  142. package/src/lifecycle/release-compose.ts +312 -0
  143. package/src/lifecycle/release-cut.ts +6 -0
  144. package/src/lifecycle/release-publish.ts +4 -1
  145. package/src/lifecycle/release-store.ts +16 -1
  146. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  147. package/src/workspace/checks/ci-workflow.ts +7 -1
  148. package/src/workspace/ci-green-cli.ts +5 -3
  149. package/src/workspace/ci-green-workflow.test.ts +52 -0
  150. package/src/workspace/ci-green-workflow.ts +34 -5
  151. package/src/workspace/ci-green.test.ts +33 -0
  152. package/src/workspace/ci-green.ts +85 -4
  153. package/src/workspace/ci-run.test.ts +18 -13
  154. package/src/workspace/ci-run.ts +8 -1
  155. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  156. package/src/workspace/ci-serve-land.test.ts +176 -0
  157. package/src/workspace/ci-serve.test.ts +77 -43
  158. package/src/workspace/ci-serve.ts +268 -34
  159. package/src/workspace/ci-workflow.ts +4 -4
  160. package/src/workspace/delivery-metrics.ts +12 -3
  161. package/src/workspace/graph-contract.test.ts +3 -2
  162. package/src/workspace/member-commands.test.ts +11 -4
  163. package/src/workspace/member-commands.ts +3 -0
  164. package/src/workspace/nested-graph.test.ts +4 -0
  165. package/src/workspace/nested-graph.ts +1 -0
  166. package/src/workspace/release-candidates.schema.json +51 -0
  167. package/src/workspace/release-candidates.test.ts +30 -3
  168. package/src/workspace/release-candidates.ts +54 -2
  169. package/src/workspace/releases.schema.json +30 -0
@@ -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
+ });
@@ -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
+ });