@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
@@ -221,6 +221,12 @@ export interface CiGreen {
221
221
  phases: CiPhase[];
222
222
  /** The phases a commit must pass, by name, in file order. */
223
223
  require: string[];
224
+ /**
225
+ * The phases that must pass before the branch moves (#4168 4.4), by name,
226
+ * in file order: the quick checks a pull request, a merge queue or a local
227
+ * run gives before a change lands. Absent or empty when the declaration names none.
228
+ */
229
+ preMerge?: string[];
224
230
  /**
225
231
  * The full sha of the first commit on the branch whose ci tags need a
226
232
  * statement signed by a runner key (#4031). A tag on a strict
@@ -244,9 +250,20 @@ export interface CiGreen {
244
250
  /** Where `ci tick` reads phase results (#4044): forge check runs, or chant Op runs. */
245
251
  export type CiSource = "checks" | "runs";
246
252
 
253
+ /**
254
+ * A prescribed delivery workflow (#4168 section 7). `trunk-curated`: a merge
255
+ * records the plan and deploys nothing, and releases are cut from green
256
+ * commits and promoted through the environments. `trunk-curated+apply-on-merge`
257
+ * adds the apply on merge as an opt-in. `chant workspace check` reports what a
258
+ * member under one still lacks (./checks/ci-workflow.ts, WSP151).
259
+ */
260
+ export type CiWorkflow = "trunk-curated" | "trunk-curated+apply-on-merge";
261
+
247
262
  /** The declaration's `ci` block (#3573). */
248
263
  export interface CiDeclaration {
249
264
  green: CiGreen | null;
265
+ /** `ci.workflow` (#4168 step 6): the workflow every member follows unless it names its own; absent when the declaration sets none. */
266
+ workflow?: CiWorkflow;
250
267
  /**
251
268
  * `ci.reportUrl` (#4160): where the pull-request jobs copy their reports
252
269
  * and `chant ci run` keeps the run index, `<reportUrl>/runs/index.json`.
@@ -573,6 +590,8 @@ export interface Member {
573
590
  travel: boolean;
574
591
  /** The security groups this member adds to the declaration's top-level `security` (#4168 step 7), in file order; empty when it names none. */
575
592
  security: SecurityGroup[];
593
+ /** The member's own `ci.workflow` (#4168 step 6), which wins over the top level's; null when it names none. */
594
+ ciWorkflow: CiWorkflow | null;
576
595
  suppress: Suppression[];
577
596
  /** The entry's JSON Pointer in the file, for messages. */
578
597
  pointer: string;
@@ -869,6 +888,7 @@ export function parseDeclaration(text: string, file: string, reader: string = re
869
888
  because: (e.because as string | undefined) ?? null,
870
889
  travel: e.travel === true,
871
890
  security: [...((e.security as SecurityGroup[] | undefined) ?? [])],
891
+ ciWorkflow: ((e.ci as { workflow?: CiWorkflow } | undefined)?.workflow) ?? null,
872
892
  suppress,
873
893
  pointer,
874
894
  };
@@ -1116,7 +1136,8 @@ function retentionOf(raw: unknown): RetentionOverride | null {
1116
1136
 
1117
1137
  function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocation): CiDeclaration | null {
1118
1138
  if (raw === undefined) return null;
1119
- const block = raw as { reportUrl?: string; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; signedSince?: string; source?: CiSource } };
1139
+ const block = raw as { reportUrl?: string; workflow?: CiWorkflow; green?: { branch: string; window?: string; phases: Record<string, string[] | { runs: string[]; skipped?: CiSkipped; build?: CiBuild }>; require: string[]; preMerge?: string[]; signedSince?: string; source?: CiSource } };
1140
+ const workflow = block.workflow !== undefined ? { workflow: block.workflow } : {};
1120
1141
  let reportUrl: string | null = null;
1121
1142
  if (block.reportUrl !== undefined) {
1122
1143
  // #4160: refused here as pr-plan and ci run refuse the flag, so a reader never gets a URL no job can write to.
@@ -1124,7 +1145,7 @@ function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocatio
1124
1145
  if ("error" in target) throw new WorkspaceReadError("declaration-invalid", target.error, at("/ci/reportUrl"));
1125
1146
  reportUrl = target.url;
1126
1147
  }
1127
- if (block.green === undefined) return { green: null, reportUrl };
1148
+ if (block.green === undefined) return { green: null, ...workflow, reportUrl };
1128
1149
  const g = block.green;
1129
1150
  const phases: CiPhase[] = Object.entries(g.phases).map(([name, p]) => ({
1130
1151
  name,
@@ -1141,7 +1162,15 @@ function ciOf(raw: unknown, at: (pointer: string, key?: boolean) => ErrorLocatio
1141
1162
  at(`/ci/green/require/${i}`),
1142
1163
  );
1143
1164
  }
1144
- return { green: { branch: g.branch, window: g.window ?? DEFAULT_CI_WINDOW, phases, require: [...g.require], signedSince: g.signedSince ?? null, source: g.source ?? "checks", pointer: "/ci/green" }, reportUrl };
1165
+ for (const [i, name] of (g.preMerge ?? []).entries()) {
1166
+ if (phases.some((p) => p.name === name)) continue;
1167
+ throw new WorkspaceReadError(
1168
+ "declaration-invalid",
1169
+ `ci.green.preMerge names the phase ${JSON.stringify(name)}, which ci.green.phases does not declare; declared phases: ${phases.map((p) => p.name).join(", ")}`,
1170
+ at(`/ci/green/preMerge/${i}`),
1171
+ );
1172
+ }
1173
+ return { green: { branch: g.branch, window: g.window ?? DEFAULT_CI_WINDOW, phases, require: [...g.require], ...(g.preMerge !== undefined ? { preMerge: [...g.preMerge] } : {}), signedSince: g.signedSince ?? null, source: g.source ?? "checks", pointer: "/ci/green" }, ...workflow, reportUrl };
1145
1174
  }
1146
1175
 
1147
1176
  function agentsOf(raw: unknown, members: Member[], at: (pointer: string, key?: boolean) => ErrorLocation): AgentDeclaration[] {
@@ -15,7 +15,9 @@
15
15
  * resolutions, for a deploy that names its release only through its
16
16
  * admission (`releaseOfDeploy`, #4043);
17
17
  * - the counted `ci/revoked/<sha>` tags, judged as `release cut` judges them
18
- * (`readRevokedCommits`, #4033), and each commit's committer time from git.
18
+ * (`readRevokedCommits`, #4033), and each commit's committer time from git;
19
+ * a composed commit (#4168 4.3) takes the earliest time of the trunk
20
+ * commits it carries.
19
21
  *
20
22
  * Outside a workspace the project's own ledger is read as one member, `null`.
21
23
  * It reads the local branch and the local tags, and never fetches.
@@ -139,11 +141,18 @@ export async function deliveryMetrics(query: MetricsQuery): Promise<DeliveryMetr
139
141
  const { readRevokedCommits } = await import("../lifecycle/release-revocation");
140
142
  const revoked = await readRevokedCommits(root);
141
143
 
144
+ const { compositionOf } = await import("../lifecycle/compose-refs");
142
145
  const commitTimes = new Map<string, number | null>();
146
+ const ownTime = (sha: string): number | null => {
147
+ const iso = tryGit(top, ["show", "-s", "--format=%cI", `${sha}^{commit}`]);
148
+ return iso ? Date.parse(iso) : null;
149
+ };
143
150
  const commitTime = (sha: string): number | null => {
144
151
  if (!commitTimes.has(sha)) {
145
- const iso = tryGit(top, ["show", "-s", "--format=%cI", `${sha}^{commit}`]);
146
- commitTimes.set(sha, iso ? Date.parse(iso) : null);
152
+ // #4168 4.3: a composed commit's lead time runs from the earliest trunk commit it carries.
153
+ const sources = compositionOf(top, sha)?.sources ?? [];
154
+ const times = sources.map(ownTime).filter((t): t is number => t !== null);
155
+ commitTimes.set(sha, times.length > 0 ? Math.min(...times) : ownTime(sha));
147
156
  }
148
157
  return commitTimes.get(sha)!;
149
158
  };
@@ -221,10 +221,11 @@ describe("chant workspace graph on the chant repo (#2557)", () => {
221
221
  // reference workspace runs its own chant workspace graph (#2551);
222
222
  // read-contract.test.ts covers that. Here both are left out, so nothing runs.
223
223
  const notRun = (JSON.parse(readFileSync(join(REPO, "chant.workspace.json"), "utf-8")) as { members: { name: string; kind: string }[] }).members
224
- .filter((m) => m.kind !== "terraform" && m.kind !== "choudoufu" && m.kind !== "workspace")
224
+ // npm (release/, #4040) is the one kind chant member; leaving it out keeps this a read with nothing to build.
225
+ .filter((m) => m.kind !== "terraform" && m.kind !== "choudoufu" && m.kind !== "workspace" && m.kind !== "chant")
225
226
  .map((m) => m.name);
226
227
 
227
- test("validates, and lists every member but the terraform one as skipped: none is kind chant", async () => {
228
+ test("validates, and lists every member but the terraform one and npm as skipped: no other is kind chant", async () => {
228
229
  const { doc, failed } = await workspaceGraph({ cwd: join(REPO, "packages", "core"), members: notRun });
229
230
  const g = result(doc);
230
231
  expectValid(g);
@@ -559,7 +559,7 @@ export async function runWorkspaceInit(ctx: CommandContext): Promise<number> {
559
559
  console.error(
560
560
  formatError({
561
561
  message: `${join(root, existing[0])} already exists`,
562
- hint: "Edit it by hand, or run chant workspace ls to see what it declares.",
562
+ hint: "Edit it by hand, or run chant workspace ls to see what it declares. To set it up for a delivery workflow, run chant ci init --workflow trunk-curated.",
563
563
  }),
564
564
  );
565
565
  return 1;
@@ -612,7 +612,7 @@ export async function runWorkspaceInit(ctx: CommandContext): Promise<number> {
612
612
  return 0;
613
613
  }
614
614
  writeFileSync(target, text, { flag: "wx" });
615
- console.log(formatSuccess(`Wrote ${target}. chant workspace ls lists what it declares.`));
615
+ console.log(formatSuccess(`Wrote ${target}. chant workspace ls lists what it declares, and chant ci init --workflow trunk-curated sets up merging and releases.`));
616
616
  return 0;
617
617
  }
618
618
 
@@ -112,22 +112,22 @@ describe("resolveToolchain", () => {
112
112
  });
113
113
 
114
114
  describe("the chant repo's own declaration (#2557)", () => {
115
- test("declares no chant members, so build runs the example groups under one toolchain", () => {
115
+ test("build runs the one chant member, npm (release/, #4040), and the example groups under one toolchain", () => {
116
116
  const plan = planMembers("build", REPO);
117
117
  expect(plan.workspace.name).toBe("chant");
118
118
  expect(plan.unreadable).toEqual([]);
119
119
  expect(plan.groups).toHaveLength(1);
120
120
  const units = plan.groups[0].units;
121
- expect(units.every((u) => u.group)).toBe(true);
121
+ expect(units.filter((u) => !u.group).map((u) => u.id)).toEqual(["npm"]);
122
122
  expect(units.map((u) => u.id)).toContain("fixtures:test/leftness");
123
123
  expect(plan.skipped.every((s) => s.reason.code === "kind-not-run")).toBe(true);
124
124
  expect(plan.skipped.map((s) => s.name)).toContain("core");
125
125
  });
126
126
 
127
- test("graph and audit run nothing there: every member is kind other", () => {
127
+ test("graph and audit run only npm there: every other member is kind other", () => {
128
128
  for (const verb of ["graph", "audit"] as const) {
129
129
  const plan = planMembers(verb, REPO);
130
- expect(plan.groups, verb).toEqual([]);
130
+ expect(plan.groups.flatMap((g) => g.units.map((u) => u.id)), verb).toEqual(["npm"]);
131
131
  }
132
132
  });
133
133
 
@@ -164,6 +164,13 @@ describe("member command lines", () => {
164
164
  expect(memberArgv("graph", unit, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir", "--env", "prod"]);
165
165
  });
166
166
 
167
+ test("graph hands each member the detail tier (#4228)", () => {
168
+ expect(memberArgv("graph", unit, args({ detail: 1, live: true, overlay: true, env: "local" }))).toEqual([
169
+ "graph", ".", "--format", "ir", "--env", "local", "--live", "--overlay", "--detail", "1",
170
+ ]);
171
+ expect(memberArgv("graph", unit, args({ detail: 0 }))).toEqual(["graph", ".", "--format", "ir", "--detail", "0"]);
172
+ });
173
+
167
174
  test("a reader unit takes --env only with --live, which needs one (#2874, #2875)", () => {
168
175
  const reader = { ...unit, reader: { lexicon: "terraform", config: {}, packageDir: "/p" } } as typeof unit;
169
176
  expect(memberArgv("graph", reader, args({ env: "prod" }))).toEqual(["graph", ".", "--format", "ir"]);
@@ -305,6 +305,9 @@ export function memberArgv(verb: WorkspaceVerb, unit: RunUnit, args: ParsedArgs)
305
305
  if (args.live) argv.push("--live");
306
306
  if (args.overlay) argv.push("--overlay");
307
307
  if (args.traffic) argv.push("--traffic", args.traffic);
308
+ // The detail tier too, so `workspace graph --detail 1` composes collapsed
309
+ // composites with their rolled-up status (#4228).
310
+ if (args.detail !== undefined) argv.push("--detail", String(args.detail));
308
311
  }
309
312
  return argv;
310
313
  }
@@ -69,6 +69,10 @@ describe("readNestedDocument", () => {
69
69
  "workspace", "graph", "/w/inner", "--at", "abc", "--env", "dev", "--live", "--overlay", "--traffic", "10 rps", "--no-cache",
70
70
  ]);
71
71
  });
72
+
73
+ test("passes the detail tier on (#4228)", () => {
74
+ expect(nestedArgv("/w/inner", { detail: 1 }, null)).toEqual(["workspace", "graph", "/w/inner", "--detail", "1"]);
75
+ });
72
76
  });
73
77
 
74
78
  describe("foldNested", () => {
@@ -43,6 +43,7 @@ export function nestedArgv(dir: string, args: Partial<ParsedArgs>, at: string |
43
43
  ...(args.live ? ["--live"] : []),
44
44
  ...(args.overlay ? ["--overlay"] : []),
45
45
  ...(args.traffic !== undefined ? ["--traffic", String(args.traffic)] : []),
46
+ ...(args.detail !== undefined ? ["--detail", String(args.detail)] : []),
46
47
  ...(args.noCache ? ["--no-cache"] : []),
47
48
  ];
48
49
  }
@@ -29,6 +29,7 @@ import { WORK_WARNING_CODES } from "./work";
29
29
  import { BOX_FINDING_CODES } from "./checks/boxes";
30
30
  import { DIAGRAM_FINDING_CODES } from "./checks/diagrams";
31
31
  import { SECURITY_FINDING_CODES } from "./checks/security";
32
+ import { CI_WORKFLOW_FINDING_CODES } from "./ci-workflow";
32
33
  import { WORK_ERROR_CODES, WORK_HISTORY_ERROR_CODES, WORK_LEASE_REFUSALS } from "./work-cli";
33
34
  import { RUN_STATEMENT_ERROR_CODES, RUNS_ERROR_CODES, RUNS_REASON_CODES, RUNS_WRITE_ERROR_CODES } from "./runs-cli";
34
35
  import { RUN_STATEMENT_FAILURE_CODES } from "./trust/run-statement";
@@ -68,6 +69,7 @@ const PER_COMMAND: Record<string, readonly string[]> = {
68
69
  BOX_FINDING_CODES,
69
70
  DIAGRAM_FINDING_CODES,
70
71
  SECURITY_FINDING_CODES,
72
+ CI_WORKFLOW_FINDING_CODES,
71
73
  STATUS_REASON_CODES,
72
74
  STATUS_ERROR_CODES,
73
75
  STATUS_GATE_REASON_CODES,
@@ -247,6 +247,16 @@ export const REASONS = {
247
247
  "security-runner-start-no-key": "Under isolated-runners, .chant/trust.json lists no key under runners for a provisioner to sign runner-start statements with.",
248
248
  "security-runner-start-no-role": "Under isolated-runners, no principal under runners in .chant/trust.json holds the runner role.",
249
249
  "security-trust-unreadable": "Under isolated-runners, .chant/trust.json can't be read, so no runner-start statement can be checked.",
250
+ // The ci.workflow requirements (check, #4168 step 6). Each is also a WSP151 finding.
251
+ "ci-workflow-green-undeclared": "Under ci.workflow, the declaration has no ci.green block, so no commit gets a verdict and no release can be cut.",
252
+ "ci-workflow-no-pre-merge": "Under ci.workflow, ci.green.preMerge names no phase, so nothing says which checks run before the branch moves.",
253
+ "ci-workflow-apply-in-green": "Under ci.workflow, a phase in ci.green.require matches an apply job, which would wait on its own verdict.",
254
+ "ci-workflow-no-components": "Under ci.workflow, a member declares no component, so nothing of it can be cut or promoted.",
255
+ "ci-workflow-no-environments": "Under ci.workflow, a member's chant.config.ts declares no environments, so a release has nowhere to be promoted to.",
256
+ "ci-workflow-ungated-env": "Under ci.workflow, a component has no gate, so a promote to the last environment waits on no approval.",
257
+ "ci-workflow-pipeline-missing": "Under ci.workflow, no pipeline file runs the plan job, the merge job or chant ci tick.",
258
+ "ci-workflow-pipeline-differs": "Under ci.workflow, the merge job does not match the workflow: it applies under trunk-curated, or records under trunk-curated+apply-on-merge.",
259
+ "ci-workflow-publish-missing": "Under ci.workflow, no pipeline file runs chant release publish.",
250
260
  // A box that holds a credential (check, #2726). Each is also a WSP finding.
251
261
  "box-credential-declared": "A file in a box member's directory carries a literal secret: a credential's shape, or a literal where a credential goes. A variable or secret-manager reference is not one.",
252
262
  "box-capability-unbrokered": "A capability in a member's box block names no broker, so the box would hold its credential.",
@@ -212,6 +212,7 @@
212
212
  "newFor",
213
213
  "cut",
214
214
  "changes",
215
+ "groups",
215
216
  "flags"
216
217
  ],
217
218
  "properties": {
@@ -245,6 +246,13 @@
245
246
  },
246
247
  "description": "The first-parent commits after the previous candidate, up to and including this one, newest first."
247
248
  },
249
+ "groups": {
250
+ "type": "array",
251
+ "items": {
252
+ "$ref": "#/$defs/group"
253
+ },
254
+ "description": "The same changes by work item, then pull request, then commit (#4168 4.4). Work items come first, in the order the oldest change names them, then `work: null` for changes that name none. A change with two work items is under both."
255
+ },
248
256
  "flags": {
249
257
  "type": "array",
250
258
  "items": {
@@ -254,6 +262,49 @@
254
262
  }
255
263
  }
256
264
  },
265
+ "group": {
266
+ "type": "object",
267
+ "required": [
268
+ "work",
269
+ "pullRequests"
270
+ ],
271
+ "properties": {
272
+ "work": {
273
+ "type": [
274
+ "string",
275
+ "null"
276
+ ],
277
+ "description": "The work item id a `Chant-Record: work:<id>` trailer names, or null."
278
+ },
279
+ "pullRequests": {
280
+ "type": "array",
281
+ "items": {
282
+ "type": "object",
283
+ "required": [
284
+ "pr",
285
+ "commits"
286
+ ],
287
+ "properties": {
288
+ "pr": {
289
+ "type": [
290
+ "integer",
291
+ "null"
292
+ ],
293
+ "description": "The pull request, or null for commits that landed without one."
294
+ },
295
+ "commits": {
296
+ "type": "array",
297
+ "items": {
298
+ "type": "string"
299
+ },
300
+ "description": "Newest first."
301
+ }
302
+ }
303
+ },
304
+ "description": "Pull requests in the order their oldest commit landed, then `pr: null`."
305
+ }
306
+ }
307
+ },
257
308
  "change": {
258
309
  "type": "object",
259
310
  "required": [
@@ -11,7 +11,7 @@ import { writeFileSync } from "node:fs";
11
11
  import { join } from "node:path";
12
12
  import { contract } from "./__fixtures__/contract-repo";
13
13
  import candidatesSchema from "./release-candidates.schema.json";
14
- import { prFromSubject, recordTrailers, releaseCandidatesDocument, RELEASE_CANDIDATES_SCHEMA_ID } from "./release-candidates";
14
+ import { groupChanges, mergeRecordPr, prFromSubject, recordTrailers, releaseCandidatesDocument, RELEASE_CANDIDATES_SCHEMA_ID, type CandidateChange } from "./release-candidates";
15
15
  import { appendCut, makeRelease, persistRelease } from "../lifecycle/release-store";
16
16
  import { appendReleaseRecord } from "../lifecycle/release-ledger";
17
17
  import { writeLedgerFiles } from "../lifecycle/git";
@@ -46,6 +46,33 @@ describe("recordTrailers and prFromSubject", () => {
46
46
  });
47
47
  });
48
48
 
49
+ describe("groupChanges (#4168 4.4)", () => {
50
+ const change = (commit: string, work: string[], pr: number | null): CandidateChange => ({ commit, subject: commit, date: "", work, records: work.map((w) => `work:${w}`), pr, merge: null });
51
+
52
+ test("groups by work item, then pull request, then commit, with the ungrouped last", () => {
53
+ // Newest first, as a candidate's changes are.
54
+ const groups = groupChanges([
55
+ change("f", [], null),
56
+ change("e", ["w-2"], null),
57
+ change("d", ["w-1"], 4),
58
+ change("c", [], 3),
59
+ change("b", ["w-1", "w-2"], 2),
60
+ change("a", ["w-1"], 2),
61
+ ]);
62
+ expect(groups).toEqual([
63
+ { work: "w-1", pullRequests: [{ pr: 2, commits: ["b", "a"] }, { pr: 4, commits: ["d"] }] },
64
+ { work: "w-2", pullRequests: [{ pr: 2, commits: ["b"] }, { pr: null, commits: ["e"] }] },
65
+ { work: null, pullRequests: [{ pr: 3, commits: ["c"] }, { pr: null, commits: ["f"] }] },
66
+ ]);
67
+ });
68
+
69
+ test("a merge record names its pull request in source", () => {
70
+ expect(mergeRecordPr({ source: { kind: "pull-request", pr: 12 } })).toBe(12);
71
+ expect(mergeRecordPr({ source: { kind: "direct-push" } })).toBeNull();
72
+ expect(mergeRecordPr(null)).toBeNull();
73
+ });
74
+ });
75
+
49
76
  describe("releaseCandidatesDocument", () => {
50
77
  test("partitions the walk at green commits, skips revoked ones, and says which environments each is new for", async () => {
51
78
  await withTestDir(async (dir) => {
@@ -63,7 +90,7 @@ describe("releaseCandidatesDocument", () => {
63
90
  { cwd: dir },
64
91
  );
65
92
  // A merge record for d names its pull request.
66
- await writeLedgerFiles([{ path: `_merges/${d}.json`, content: JSON.stringify({ pr: 40, approvedDigest: "sha256:x" }) }], "merge record", { cwd: dir });
93
+ await writeLedgerFiles([{ path: `_merges/${d}.json`, content: JSON.stringify({ source: { kind: "pull-request", pr: 40 }, approvedDigest: "sha256:x" }) }], "merge record", { cwd: dir });
67
94
 
68
95
  const doc = await releaseCandidatesDocument({ cwd: dir });
69
96
  expectValid(doc);
@@ -78,7 +105,7 @@ describe("releaseCandidatesDocument", () => {
78
105
  expect(doc.candidates.map((x) => x.commit)).toEqual([d, b, init]);
79
106
  expect(doc.candidates[0]).toMatchObject({ newFor: ["staging", "prod"], cut: [] });
80
107
  expect(doc.candidates[0].changes.map((x) => x.commit)).toEqual([d, c]);
81
- expect(doc.candidates[0].changes[0]).toMatchObject({ pr: 40, merge: { pr: 40, approvedDigest: "sha256:x" } });
108
+ expect(doc.candidates[0].changes[0]).toMatchObject({ pr: 40, merge: { source: { kind: "pull-request", pr: 40 }, approvedDigest: "sha256:x" } });
82
109
  expect(doc.candidates[1].changes.map((x) => x.commit)).toEqual([b, a]);
83
110
  expect(doc.candidates[1].changes[0].work).toEqual(["w-7"]);
84
111
  expect(doc.candidates[1].changes[1].pr).toBe(1);
@@ -24,6 +24,11 @@
24
24
  * flags whose record files changed in its range, from the declared flag kinds
25
25
  * (#4024).
26
26
  *
27
+ * Each candidate also groups its changes (#4168 4.4) by work item, then by
28
+ * pull request, then by commit: a change lands under every work item its
29
+ * trailers name, or under no work item, and within that under its pull
30
+ * request, or under none for a commit that landed without one.
31
+ *
27
32
  * It reads local refs and tags only and never fetches. Git is read through a
28
33
  * local `git` subprocess.
29
34
  */
@@ -76,6 +81,20 @@ export interface CandidateChange {
76
81
  merge: Record<string, unknown> | null;
77
82
  }
78
83
 
84
+ /** A candidate's changes under one pull request, or under none (`pr: null`) for commits that landed without one. */
85
+ export interface CandidatePullRequestGroup {
86
+ pr: number | null;
87
+ /** Newest first. */
88
+ commits: string[];
89
+ }
90
+
91
+ /** A candidate's changes under one work item, or under none (`work: null`). Work items come first, in the order the oldest change names them. */
92
+ export interface CandidateGroup {
93
+ work: string | null;
94
+ /** Pull requests in the order their oldest commit landed, then `pr: null`. */
95
+ pullRequests: CandidatePullRequestGroup[];
96
+ }
97
+
79
98
  export interface CandidateFlag {
80
99
  id: string;
81
100
  member: string;
@@ -95,6 +114,8 @@ export interface Candidate {
95
114
  /** The labels of releases already cut from it. */
96
115
  cut: string[];
97
116
  changes: CandidateChange[];
117
+ /** The same changes by work item, then pull request, then commit (#4168 4.4). */
118
+ groups: CandidateGroup[];
98
119
  flags: CandidateFlag[];
99
120
  }
100
121
 
@@ -156,6 +177,34 @@ export function prFromSubject(subject: string): number | null {
156
177
  return m ? Number(m[1]) : null;
157
178
  }
158
179
 
180
+ /** The pull request a merge record names in its `source`, or null. */
181
+ export function mergeRecordPr(merge: Record<string, unknown> | null): number | null {
182
+ const source = merge?.source as { kind?: unknown; pr?: unknown } | undefined;
183
+ return source?.kind === "pull-request" && typeof source.pr === "number" ? source.pr : null;
184
+ }
185
+
186
+ /**
187
+ * Group changes, given newest first, by work item, then pull request, then
188
+ * commit. A change with work items lands in each one's group; the rest land
189
+ * in the `work: null` group, last. Within a group, pull requests come in the
190
+ * order their oldest commit landed, and commits without one last.
191
+ */
192
+ export function groupChanges(changes: readonly CandidateChange[]): CandidateGroup[] {
193
+ const oldestFirst = [...changes].reverse();
194
+ const groups = new Map<string | null, Map<number | null, string[]>>();
195
+ const add = (work: string | null, pr: number | null, commit: string): void => {
196
+ const byPr = groups.get(work) ?? new Map<number | null, string[]>();
197
+ groups.set(work, byPr);
198
+ byPr.set(pr, [commit, ...(byPr.get(pr) ?? [])]);
199
+ };
200
+ for (const c of oldestFirst) for (const work of c.work.length > 0 ? c.work : [null]) add(work, c.pr, c.commit);
201
+ const nullLast = <K>(keys: K[]): K[] => [...keys.filter((k) => k !== null), ...keys.filter((k) => k === null)];
202
+ return nullLast([...groups.keys()]).map((work) => {
203
+ const byPr = groups.get(work)!;
204
+ return { work, pullRequests: nullLast([...byPr.keys()]).map((pr) => ({ pr, commits: byPr.get(pr)! })) };
205
+ });
206
+ }
207
+
159
208
  /** Read the release candidates of the member at `query.cwd`. */
160
209
  export async function releaseCandidatesDocument(query: ReleaseCandidatesQuery): Promise<ReleaseCandidatesDocument> {
161
210
  const head = { $schema: RELEASE_CANDIDATES_SCHEMA_ID, contract: RELEASE_CANDIDATES_CONTRACT_VERSION, chant: readerVersion() };
@@ -240,7 +289,8 @@ export async function releaseCandidatesDocument(query: ReleaseCandidatesQuery):
240
289
  const d = details.get(sha);
241
290
  const { work, records } = recordTrailers(d?.trailers ?? {});
242
291
  const merge = merges.get(sha) ?? null;
243
- const mergePr = merge && typeof merge.pr === "number" ? merge.pr : null;
292
+ // The merge record names its pull request in `source` (../lifecycle/merge-record.ts).
293
+ const mergePr = mergeRecordPr(merge);
244
294
  const subject = d?.subject ?? "";
245
295
  return { commit: sha, subject, date: d?.date ?? "", work, records, pr: mergePr ?? prFromSubject(subject), merge };
246
296
  };
@@ -265,13 +315,15 @@ export async function releaseCandidatesDocument(query: ReleaseCandidatesQuery):
265
315
  }
266
316
  if (!tags.green.has(sha)) continue;
267
317
  const own = change(sha);
318
+ const changes = open.map(change).reverse();
268
319
  candidates.push({
269
320
  commit: sha,
270
321
  subject: own.subject,
271
322
  date: own.date,
272
323
  newFor: environments.filter((e) => i < newestRun.get(e.env)!).map((e) => e.env),
273
324
  cut: cutLabels.get(sha) ?? [],
274
- changes: open.map(change).reverse(),
325
+ changes,
326
+ groups: groupChanges(changes),
275
327
  flags: flagReader(open),
276
328
  });
277
329
  open = [];