@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
@@ -0,0 +1,135 @@
1
+ /**
2
+ * WSP151 (#4168 step 6): what a member under `ci.workflow` still lacks.
3
+ *
4
+ * A declaration that names no workflow gets no finding from this file. The
5
+ * requirements and their codes are {@link CI_WORKFLOW_REQUIREMENTS}
6
+ * (../ci-workflow.ts). Like the security checklists (./security.ts), the
7
+ * check reads files and never runs them: `chant.config.ts` for the
8
+ * environments (its literal `environments`, as `workspace status` reads it),
9
+ * `*.component.ts` for components and gates, and the forge pipeline files for
10
+ * the jobs. A generated file that differs from what its generator writes is
11
+ * WSP101 to WSP106's to report; this check asks only whether the jobs the
12
+ * workflow needs are there.
13
+ */
14
+
15
+ import type { WorkspaceCheck, WorkspaceDiagnostic } from "../checks";
16
+ import type { CiWorkflow, Member } from "../declaration";
17
+ import { appliesOnMerge, STUB_MARKER, workflowOf, type CiWorkflowCode } from "../ci-workflow";
18
+ import { matchesCheckRun } from "../ci-green";
19
+ import { staticPromotionOrder } from "../promotion-order";
20
+ import { joinPath } from "../tree";
21
+ import { allPipelineFiles, commandLines, componentFiles, read } from "./security";
22
+
23
+ export const WSP_CI_WORKFLOW = "WSP151";
24
+
25
+ const memberDir = (dir: string) => (dir === "." ? "" : dir);
26
+
27
+ const PLAN = /\bci\s+run\s+plan\b|\bcomponents\s+pr-plan\b/;
28
+ const RECORD = /\bci\s+run\s+record\b|\bcomponents\s+pr-record\b/;
29
+ const APPLY = /\bci\s+run\s+apply\b|\bcomponents\s+pr-apply\b/;
30
+ const TICK = /\bci\s+tick\b/;
31
+ const PUBLISH = /\brelease\s+publish\b/;
32
+
33
+ /** The apply job names a required phase could match. */
34
+ const APPLY_JOB_NAMES = ["apply", "pr-apply"];
35
+
36
+ export const CI_WORKFLOW_CHECKS: readonly WorkspaceCheck[] = [
37
+ {
38
+ id: WSP_CI_WORKFLOW,
39
+ name: "ci-workflow",
40
+ description: "Under ci.workflow: ci.green with pre-merge phases and no apply job required, each member with a component, environments and a gate on each component, and pipeline files that plan, record (or apply under apply on merge), tick and publish.",
41
+ severity: "warning",
42
+ configurable: true,
43
+ check(ctx) {
44
+ const d = ctx.declaration;
45
+ const under = d.members
46
+ .map((m) => ({ member: m, workflow: workflowOf(d, m) }))
47
+ .filter((u): u is { member: Member; workflow: CiWorkflow } => u.workflow !== null)
48
+ // The top level's workflow covers the members that build with chant; a member that names one itself is held to it.
49
+ .filter((u) => u.member.ciWorkflow !== null || ctx.tree.stat(joinPath(memberDir(u.member.dir), "chant.config.ts")) === "file");
50
+ if (under.length === 0 && !d.ci?.workflow) return [];
51
+ const pointer = d.ci?.workflow ? "/ci/workflow" : `${under[0]!.member.pointer}/ci/workflow`;
52
+ const out: WorkspaceDiagnostic[] = [];
53
+ const finding = (code: CiWorkflowCode, message: string, at: { pointer?: string; entity?: string; treeFile?: string; line?: number } = {}): void => {
54
+ out.push({
55
+ checkId: this.id,
56
+ severity: this.severity,
57
+ code,
58
+ message: `${code}: ${message}`,
59
+ pointer: at.pointer ?? pointer,
60
+ ...(at.entity !== undefined ? { entity: at.entity } : {}),
61
+ ...(at.treeFile !== undefined ? { treeFile: at.treeFile, line: at.line ?? 1, column: 1 } : {}),
62
+ });
63
+ };
64
+
65
+ const green = d.ci?.green ?? null;
66
+ if (!green) {
67
+ finding("ci-workflow-green-undeclared", "the workspace follows a ci.workflow and declares no ci.green, so no commit gets a verdict and no release can be cut; run chant ci init or add a ci.green block");
68
+ } else {
69
+ if ((green.preMerge ?? []).length === 0) {
70
+ finding("ci-workflow-no-pre-merge", "ci.green.preMerge names no phase, so nothing says which checks run before the branch moves; name the quick ones", { pointer: green.pointer });
71
+ }
72
+ for (const name of green.require) {
73
+ const phase = green.phases.find((p) => p.name === name);
74
+ const hit = phase?.runs.find((pattern) => APPLY_JOB_NAMES.some((job) => matchesCheckRun(pattern, job)));
75
+ if (phase && hit) {
76
+ finding("ci-workflow-apply-in-green", `the required phase ${name} matches the apply job with ${JSON.stringify(hit)}, so an apply that waits for a green commit would wait on itself; drop the pattern`, { pointer: phase.pointer });
77
+ }
78
+ }
79
+ }
80
+
81
+ for (const { member } of under) {
82
+ const dir = memberDir(member.dir);
83
+ const components = componentFiles(ctx.tree, dir);
84
+ if (components.length === 0) {
85
+ finding("ci-workflow-no-components", `member ${member.name} declares no component, so nothing of it can be cut or promoted; chant ci init writes a stub`, { pointer: member.pointer, entity: member.name });
86
+ } else if (components.every((f) => (read(ctx.tree, f) ?? "").includes(STUB_MARKER))) {
87
+ finding("ci-workflow-no-components", `member ${member.name}'s only component is the stub chant ci init wrote (${components[0]}); say how it is built and deployed, then delete its ${STUB_MARKER} line`, { pointer: member.pointer, entity: member.name, treeFile: components[0] });
88
+ }
89
+ const configPath = joinPath(dir, "chant.config.ts");
90
+ const config = read(ctx.tree, configPath);
91
+ // A config whose environments aren't literals can't be read without running it; say nothing rather than guess.
92
+ const order = config === null ? [] : staticPromotionOrder(config);
93
+ if (order !== undefined && order.length === 0) {
94
+ finding("ci-workflow-no-environments", `member ${member.name}'s chant.config.ts declares no environments, so a release has nowhere to be promoted to`, { pointer: member.pointer, entity: member.name, ...(config !== null ? { treeFile: configPath } : {}) });
95
+ }
96
+ for (const file of components) {
97
+ const text = read(ctx.tree, file) ?? "";
98
+ if (/\bgate\s*\(|kind\s*:\s*["']gate["']/.test(text)) continue;
99
+ const last = order && order.length > 0 ? order[order.length - 1]!.name : "the last environment";
100
+ finding("ci-workflow-ungated-env", `${file} has no gate, so a promote to ${last} waits on no approval; add a gate() step`, { pointer: member.pointer, entity: member.name, treeFile: file });
101
+ }
102
+ }
103
+
104
+ const files = allPipelineFiles(ctx);
105
+ const has = (re: RegExp) => files.some((f) => f.lines.some((l) => re.test(l)));
106
+ const applyOnMerge = under.some((u) => appliesOnMerge(u.workflow)) || (d.ci?.workflow ? appliesOnMerge(d.ci.workflow) : false);
107
+ const missing = [
108
+ ...(has(PLAN) ? [] : ["a plan job (ci run plan)"]),
109
+ // #4168 step 9: every merge is recorded, apply on merge or not, since the apply reads the merge records.
110
+ ...(has(RECORD) ? [] : ["a record job (ci run record)"]),
111
+ ...(has(TICK) ? [] : ["chant ci tick"]),
112
+ ];
113
+ if (missing.length > 0) {
114
+ finding("ci-workflow-pipeline-missing", `no pipeline file runs ${missing.join(", ")}; generate them with chant build --components --generate <forge> --pr-loop and chant ci workflow`);
115
+ }
116
+ if (!applyOnMerge) {
117
+ for (const l of commandLines(ctx, APPLY)) {
118
+ finding("ci-workflow-pipeline-differs", `${l.path} applies on merge, and the workflow is trunk-curated, where a merge records the plan and deploys nothing; regenerate without --apply-on-merge, or set ci.workflow to trunk-curated+apply-on-merge`, { treeFile: l.path, line: l.line });
119
+ }
120
+ } else if (!has(APPLY) && has(RECORD)) {
121
+ const l = commandLines(ctx, RECORD)[0]!;
122
+ finding("ci-workflow-pipeline-differs", `${l.path} records on merge, and the workflow is trunk-curated+apply-on-merge; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
123
+ } else {
124
+ // #4168 step 9: an apply measured from a push's base applies each merge; the workflow applies green commits.
125
+ for (const l of commandLines(ctx, APPLY).filter((c) => /--base\b/.test(c.text) || (/\bcomponents\s+pr-apply\b/.test(c.text) && !/--green\b/.test(c.text)))) {
126
+ finding("ci-workflow-pipeline-differs", `${l.path} applies each merge, and under trunk-curated+apply-on-merge the apply applies the newest green commit; regenerate with --apply-on-merge`, { treeFile: l.path, line: l.line });
127
+ }
128
+ }
129
+ if (!has(PUBLISH)) {
130
+ finding("ci-workflow-publish-missing", "no pipeline file runs chant release publish, so a cut release is never published; generate one with chant release workflow");
131
+ }
132
+ return out;
133
+ },
134
+ },
135
+ ];
@@ -83,7 +83,7 @@ function groupPointer(declaration: Declaration, group: SecurityGroup): string {
83
83
  return m ? `${m.pointer}/security/${m.security.indexOf(group)}` : "/security";
84
84
  }
85
85
 
86
- function read(tree: WorkspaceTree, path: string): string | null {
86
+ export function read(tree: WorkspaceTree, path: string): string | null {
87
87
  if (tree.stat(path) !== "file") return null;
88
88
  try {
89
89
  return tree.read(path);
@@ -109,18 +109,18 @@ function pipelineFiles(tree: WorkspaceTree, dir: string): { path: string; lines:
109
109
  }
110
110
 
111
111
  /** Every pipeline file of the workspace: its root's, and each member's. */
112
- function allPipelineFiles(ctx: WorkspaceCheckContext): { path: string; lines: string[] }[] {
112
+ export function allPipelineFiles(ctx: WorkspaceCheckContext): { path: string; lines: string[] }[] {
113
113
  const dirs = new Set(["", ...ctx.declaration.members.map((m) => memberDir(m.dir))]);
114
114
  return [...dirs].flatMap((d) => pipelineFiles(ctx.tree, d));
115
115
  }
116
116
 
117
117
  /** The lines of the pipeline files that run a chant command, `command` naming it, such as `release publish`. */
118
- function commandLines(ctx: WorkspaceCheckContext, command: RegExp): { path: string; line: number; text: string }[] {
118
+ export function commandLines(ctx: WorkspaceCheckContext, command: RegExp): { path: string; line: number; text: string }[] {
119
119
  return allPipelineFiles(ctx).flatMap((f) => f.lines.flatMap((text, i) => (command.test(text) ? [{ path: f.path, line: i + 1, text }] : [])));
120
120
  }
121
121
 
122
122
  /** The member's `*.component.ts` files, walked a few levels down, past node_modules and build output. */
123
- function componentFiles(tree: WorkspaceTree, dir: string, depth = 4): string[] {
123
+ export function componentFiles(tree: WorkspaceTree, dir: string, depth = 4): string[] {
124
124
  const out: string[] = [];
125
125
  for (const e of tree.list(dir) ?? []) {
126
126
  if (e.type === "dir") {
@@ -37,7 +37,7 @@
37
37
  * | WSP126, WSP127 | box intent: the decision record a box names exists, and constrains a member or path of this workspace (#2850, #2857) |
38
38
  * | WSP141, WSP142 | member links resolved against a live read, with `--live` (#2549) |
39
39
  * | WSP131 to WSP133 | diagram artifacts: source and render exist, a recorded source hash still matches (#2764) |
40
- * | WSP151 | reserved for the `ci.workflow` preset (#4168 step 6) |
40
+ * | WSP151 | what a member under `ci.workflow` still lacks (#4168 step 6) |
41
41
  * | WSP161 to WSP165 | the security groups the declaration opts into: what each still lacks (#4168 step 7) |
42
42
  */
43
43
 
@@ -61,6 +61,7 @@ import { RECORD_CHECKS, type RecordFacts } from "./checks/records";
61
61
  import { BOX_CHECKS } from "./checks/boxes";
62
62
  import { DIAGRAM_CHECKS } from "./checks/diagrams";
63
63
  import { SECURITY_CHECKS } from "./checks/security";
64
+ import { CI_WORKFLOW_CHECKS } from "./checks/ci-workflow";
64
65
  import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
65
66
  import { resolveBoxIntents, type ResolvedBoxIntent } from "./box-intent";
66
67
  import { gatherLexiconOutputs, type MemberLexiconOutputs } from "./lexicon-outputs";
@@ -374,6 +375,8 @@ export const WORKSPACE_CHECKS: readonly WorkspaceCheck[] = [
374
375
  ...DIAGRAM_CHECKS,
375
376
  // Links resolved against a live read, with --live (#2549), WSP141 and WSP142.
376
377
  ...LIVE_CHECKS,
378
+ // What a member under ci.workflow still lacks (#4168 step 6), WSP151.
379
+ ...CI_WORKFLOW_CHECKS,
377
380
  // The security groups the declaration opts into (#4168 step 7), WSP161 to WSP165.
378
381
  ...SECURITY_CHECKS,
379
382
  ];
@@ -154,7 +154,9 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
154
154
  const sources = readWorkflowSources(repoRoot, fromRepo);
155
155
  const scan = workflowsHolding(green, sources);
156
156
  // #4169: the triggers and concurrency of the workflows holding a phase that sets build. Refused before anything is written.
157
- const edits = buildTriggerEdits(green, sources);
157
+ // #4220: under a ci.workflow, composed releases' branches run the phases and tick too.
158
+ const compose = declaration.ci?.workflow !== undefined || declaration.members.some((m) => m.ciWorkflow !== null);
159
+ const edits = buildTriggerEdits(green, sources, { compose });
158
160
  const extra = ctx.args.ciWorkflows ?? [];
159
161
  const workflows = [...new Set([...scan.workflows, ...extra])].sort();
160
162
  for (const f of scan.unreadable) console.error(`warning: ${f} is not valid YAML, so its jobs were not read`);
@@ -180,10 +182,10 @@ export async function runCiWorkflow(ctx: CommandContext): Promise<number> {
180
182
  const workspaceRoot = relative(repoRoot, root).split(sep).join("/") || ".";
181
183
  // The install runs in the workspace root, so its lockfile there keys setup-node's npm cache.
182
184
  const npmLock = ctx.args.ciInstall && existsSync(join(root, "package-lock.json")) ? (workspaceRoot === "." ? "package-lock.json" : `${workspaceRoot}/package-lock.json`) : undefined;
183
- const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}) });
185
+ const yaml = renderCiGreenWorkflow({ green, workflows, chantVersion: readerVersion(), workspaceRoot, chant: ctx.args.ciChant, install: ctx.args.ciInstall, npmLock, push, ...(runnerKeySecret ? { runnerKeySecret } : {}), ...(compose ? { compose } : {}) });
184
186
  const written = writeWorkflowFile(target, ciGreenHeader(command, member ? "member" : "root") + yaml);
185
187
  if (member) recordGeneratedFiles(member.memberRoot, [{ path: fromRepo, command }]);
186
- console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}, and every 15 minutes.`);
188
+ console.log(`Wrote ${written}: ticks when ${workflows.join(", ")} complete${workflows.length === 1 ? "s" : ""} on ${green.branch}${compose ? " or a chant/compose/ branch" : ""}, and every 15 minutes.`);
187
189
  for (const e of edits) {
188
190
  if (!e.changed) continue;
189
191
  const path = writeWorkflowFile(join(repoRoot, e.path), e.text);
@@ -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`);