@intentius/chant 0.124.34 → 0.124.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (171) hide show
  1. package/dist/cli/handlers/graph.js +2 -2
  2. package/dist/cli/handlers/pr.d.ts.map +1 -1
  3. package/dist/cli/handlers/pr.js +147 -51
  4. package/dist/cli/handlers/promote.d.ts.map +1 -1
  5. package/dist/cli/handlers/promote.js +23 -8
  6. package/dist/cli/handlers/release.d.ts +12 -0
  7. package/dist/cli/handlers/release.d.ts.map +1 -1
  8. package/dist/cli/handlers/release.js +125 -10
  9. package/dist/cli/handlers/run.d.ts +3 -1
  10. package/dist/cli/handlers/run.d.ts.map +1 -1
  11. package/dist/cli/handlers/run.js +12 -2
  12. package/dist/cli/main.d.ts.map +1 -1
  13. package/dist/cli/main.js +19 -1
  14. package/dist/cli/parse-args.d.ts.map +1 -1
  15. package/dist/cli/parse-args.js +17 -0
  16. package/dist/cli/registry.d.ts +14 -0
  17. package/dist/cli/registry.d.ts.map +1 -1
  18. package/dist/components/auto-release.d.ts +8 -1
  19. package/dist/components/auto-release.d.ts.map +1 -1
  20. package/dist/components/auto-release.js +3 -2
  21. package/dist/components/green-cover.d.ts +77 -0
  22. package/dist/components/green-cover.d.ts.map +1 -0
  23. package/dist/components/green-cover.js +153 -0
  24. package/dist/components/pr-pipeline.d.ts +34 -0
  25. package/dist/components/pr-pipeline.d.ts.map +1 -1
  26. package/dist/components/pr-pipeline.js +39 -5
  27. package/dist/components/promote.d.ts +4 -1
  28. package/dist/components/promote.d.ts.map +1 -1
  29. package/dist/components/promote.js +6 -3
  30. package/dist/components/release-promote.d.ts +9 -0
  31. package/dist/components/release-promote.d.ts.map +1 -1
  32. package/dist/components/release-promote.js +15 -0
  33. package/dist/config.d.ts +3 -0
  34. package/dist/config.d.ts.map +1 -1
  35. package/dist/deep-observation.d.ts +11 -0
  36. package/dist/deep-observation.d.ts.map +1 -1
  37. package/dist/graph-declared.d.ts +5 -1
  38. package/dist/graph-declared.d.ts.map +1 -1
  39. package/dist/graph-declared.js +7 -2
  40. package/dist/graph-detail.d.ts.map +1 -1
  41. package/dist/graph-detail.js +30 -1
  42. package/dist/graph-ir.d.ts.map +1 -1
  43. package/dist/graph-ir.js +13 -2
  44. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts +5 -0
  45. package/dist/lifecycle/__fixtures__/publish-fakes.d.ts.map +1 -1
  46. package/dist/lifecycle/__fixtures__/publish-fakes.js +20 -2
  47. package/dist/lifecycle/compose-refs.d.ts +52 -0
  48. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  49. package/dist/lifecycle/compose-refs.js +115 -0
  50. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  51. package/dist/lifecycle/deep-observe.js +3 -2
  52. package/dist/lifecycle/green-apply.d.ts +39 -0
  53. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  54. package/dist/lifecycle/green-apply.js +51 -0
  55. package/dist/lifecycle/publish-declaration.d.ts +6 -0
  56. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  57. package/dist/lifecycle/publish-declaration.js +21 -0
  58. package/dist/lifecycle/publish-npm.d.ts +11 -4
  59. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  60. package/dist/lifecycle/publish-npm.js +104 -14
  61. package/dist/lifecycle/release-compose.d.ts +91 -0
  62. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  63. package/dist/lifecycle/release-compose.js +235 -0
  64. package/dist/lifecycle/release-cut.d.ts +8 -0
  65. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  66. package/dist/lifecycle/release-cut.js +2 -0
  67. package/dist/lifecycle/release-publish.d.ts +6 -0
  68. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  69. package/dist/lifecycle/release-publish.js +2 -1
  70. package/dist/lifecycle/release-store.d.ts +15 -0
  71. package/dist/lifecycle/release-store.d.ts.map +1 -1
  72. package/dist/lifecycle/release-store.js +5 -1
  73. package/dist/wave-runtime.mjs +225 -223
  74. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  75. package/dist/workspace/checks/ci-workflow.js +8 -1
  76. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  77. package/dist/workspace/ci-green-cli.js +5 -3
  78. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  79. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  80. package/dist/workspace/ci-green-workflow.js +27 -5
  81. package/dist/workspace/ci-green.d.ts +31 -0
  82. package/dist/workspace/ci-green.d.ts.map +1 -1
  83. package/dist/workspace/ci-green.js +78 -4
  84. package/dist/workspace/ci-run.d.ts +6 -0
  85. package/dist/workspace/ci-run.d.ts.map +1 -1
  86. package/dist/workspace/ci-run.js +2 -1
  87. package/dist/workspace/ci-serve.d.ts +94 -13
  88. package/dist/workspace/ci-serve.d.ts.map +1 -1
  89. package/dist/workspace/ci-serve.js +248 -30
  90. package/dist/workspace/ci-workflow.d.ts +4 -4
  91. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  92. package/dist/workspace/ci-workflow.js +2 -2
  93. package/dist/workspace/delivery-metrics.d.ts +3 -1
  94. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  95. package/dist/workspace/delivery-metrics.js +12 -3
  96. package/dist/workspace/member-commands.d.ts.map +1 -1
  97. package/dist/workspace/member-commands.js +4 -0
  98. package/dist/workspace/nested-graph.d.ts.map +1 -1
  99. package/dist/workspace/nested-graph.js +1 -0
  100. package/dist/workspace/release-candidates.d.ts +28 -0
  101. package/dist/workspace/release-candidates.d.ts.map +1 -1
  102. package/dist/workspace/release-candidates.js +38 -2
  103. package/dist/workspace/release-candidates.schema.json +51 -0
  104. package/dist/workspace/releases.schema.json +30 -0
  105. package/package.json +1 -1
  106. package/src/cli/handlers/graph.ts +2 -2
  107. package/src/cli/handlers/pr-green.test.ts +230 -0
  108. package/src/cli/handlers/pr.ts +148 -66
  109. package/src/cli/handlers/promote.test.ts +34 -0
  110. package/src/cli/handlers/promote.ts +22 -8
  111. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  112. package/src/cli/handlers/release-publish-targets.e2e.test.ts +4 -1
  113. package/src/cli/handlers/release.ts +112 -10
  114. package/src/cli/handlers/run.ts +12 -1
  115. package/src/cli/main.ts +19 -1
  116. package/src/cli/parse-args.ts +13 -0
  117. package/src/cli/registry.ts +14 -0
  118. package/src/codegen/release-wiring.test.ts +56 -9
  119. package/src/components/auto-release.test.ts +22 -0
  120. package/src/components/auto-release.ts +9 -2
  121. package/src/components/green-cover.test.ts +170 -0
  122. package/src/components/green-cover.ts +186 -0
  123. package/src/components/pr-pipeline.test.ts +14 -4
  124. package/src/components/pr-pipeline.ts +56 -5
  125. package/src/components/promote.test.ts +7 -0
  126. package/src/components/promote.ts +9 -4
  127. package/src/components/release-promote.test.ts +24 -1
  128. package/src/components/release-promote.ts +15 -0
  129. package/src/deep-observation.ts +8 -0
  130. package/src/graph-declared.test.ts +18 -0
  131. package/src/graph-declared.ts +8 -1
  132. package/src/graph-detail.test.ts +68 -1
  133. package/src/graph-detail.ts +29 -1
  134. package/src/graph-ir.ts +12 -4
  135. package/src/lifecycle/__fixtures__/publish-fakes.ts +20 -2
  136. package/src/lifecycle/compose-refs.ts +125 -0
  137. package/src/lifecycle/deep-observe.test.ts +20 -0
  138. package/src/lifecycle/deep-observe.ts +4 -2
  139. package/src/lifecycle/green-apply.ts +71 -0
  140. package/src/lifecycle/publish-declaration.ts +21 -0
  141. package/src/lifecycle/publish-npm.ts +100 -14
  142. package/src/lifecycle/publish-targets.test.ts +108 -0
  143. package/src/lifecycle/release-compose.test.ts +125 -0
  144. package/src/lifecycle/release-compose.ts +312 -0
  145. package/src/lifecycle/release-cut.ts +6 -0
  146. package/src/lifecycle/release-publish.ts +8 -1
  147. package/src/lifecycle/release-store.ts +16 -1
  148. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  149. package/src/workspace/checks/ci-workflow.ts +7 -1
  150. package/src/workspace/ci-green-cli.ts +5 -3
  151. package/src/workspace/ci-green-workflow.test.ts +52 -0
  152. package/src/workspace/ci-green-workflow.ts +34 -5
  153. package/src/workspace/ci-green.test.ts +33 -0
  154. package/src/workspace/ci-green.ts +85 -4
  155. package/src/workspace/ci-run.test.ts +18 -13
  156. package/src/workspace/ci-run.ts +8 -1
  157. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  158. package/src/workspace/ci-serve-land.test.ts +176 -0
  159. package/src/workspace/ci-serve.test.ts +77 -43
  160. package/src/workspace/ci-serve.ts +268 -34
  161. package/src/workspace/ci-workflow.ts +4 -4
  162. package/src/workspace/delivery-metrics.ts +12 -3
  163. package/src/workspace/graph-contract.test.ts +3 -2
  164. package/src/workspace/member-commands.test.ts +11 -4
  165. package/src/workspace/member-commands.ts +3 -0
  166. package/src/workspace/nested-graph.test.ts +4 -0
  167. package/src/workspace/nested-graph.ts +1 -0
  168. package/src/workspace/release-candidates.schema.json +51 -0
  169. package/src/workspace/release-candidates.test.ts +30 -3
  170. package/src/workspace/release-candidates.ts +54 -2
  171. package/src/workspace/releases.schema.json +30 -0
@@ -196,10 +196,23 @@ export interface PrLoopJob {
196
196
  env: Record<string, string>;
197
197
  /** Shell lines to run before the command, to settle values the forge may leave unusable. */
198
198
  setup?: string[];
199
+ /**
200
+ * Shell lines a generated job runs first, before `setup`, to check out the
201
+ * commit it works on (#4168 step 9): the apply checks out the newest green
202
+ * commit. `chant ci run` and `ci serve` already run the job at that commit,
203
+ * so they skip these.
204
+ */
205
+ checkout?: string[];
199
206
  /** With `build` (#4088): the build line and the lint line, run in this order before the command. */
200
207
  build?: string[];
201
208
  /** With `waveGate` (#4003): the wave a generated apply job runs, 1-based. */
202
209
  wave?: number;
210
+ /**
211
+ * The branch an apply job takes its green commit from (#4168 step 9). A
212
+ * wave after the first checks out the commit wave 1 took, which the job
213
+ * receives as `GREEN_SHA` (#4221).
214
+ */
215
+ greenBranch?: string;
203
216
  /**
204
217
  * What the generated job runs in place of `command` (#4156): `chant ci run
205
218
  * <job> --in-place` with the same environment, gate, forge, events and
@@ -241,7 +254,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
241
254
  // #4156: the flags `chant ci run` rebuilds `command` from. It finds the member from the directory it runs in.
242
255
  const ciRun = (job: PrLoopJob["jobName"]): string[] => [
243
256
  "chant", "ci", "run", job, "--in-place", ...(job === "plan" ? ["--no-push"] : []),
244
- "--base", '"$BASE_SHA"', ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
257
+ ...(job === "apply" ? [] : ["--base", '"$BASE_SHA"']), ...(job === "plan" ? ["--pr", '"$PR_NUMBER"'] : []),
245
258
  "--env", env, "--gate", gate, "--forge", forge,
246
259
  ...(job !== "record" && options.waveGate ? ["--wave-gate", options.waveGate] : []),
247
260
  ...(job !== "plan" && options.branch ? ["--branch", JSON.stringify(options.branch)] : []),
@@ -254,6 +267,7 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
254
267
  const planEnv = github
255
268
  ? { BASE_SHA: "${{ github.event.pull_request.base.sha }}", PR_NUMBER: "${{ github.event.pull_request.number }}", ...jobEnv }
256
269
  : { BASE_SHA: "$CI_MERGE_REQUEST_DIFF_BASE_SHA", PR_NUMBER: "$CI_MERGE_REQUEST_IID", ...jobEnv };
270
+ const greenBranch = github ? JSON.stringify(options.branch ?? "main") : options.branch ? JSON.stringify(options.branch) : "$CI_DEFAULT_BRANCH";
257
271
  const applyEnv = github ? { BASE_SHA: "${{ github.event.before }}", ...jobEnv } : { BASE_SHA: "$CI_COMMIT_BEFORE_SHA", ...jobEnv };
258
272
  return [
259
273
  {
@@ -265,17 +279,19 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
265
279
  run: { command: ciRun("plan").join(" "), env: runEnv(planEnv) },
266
280
  },
267
281
  {
282
+ // #4168 step 9: the apply applies the newest green commit, measured from the one applied before it.
268
283
  jobName: "apply",
269
284
  command: [
270
- "chant", "components", "pr-apply", "--base", '"$BASE_SHA"', ...common,
285
+ "chant", "components", "pr-apply", "--green", ...common,
271
286
  ...(options.requireReview === true ? ["--require-review"] : []),
272
287
  ...(options.waveGate ? ["--wave-gate", options.waveGate] : []),
273
288
  "--resume", PR_APPLY_RECORD,
274
289
  ].join(" "),
275
- env: applyEnv,
276
- setup: [github ? githubBaseFallback(options.branch) : gitlabBaseFallback(options.branch)],
290
+ env: jobEnv,
291
+ checkout: [greenCheckout(greenBranch)],
292
+ greenBranch,
277
293
  ...build,
278
- run: { command: ciRun("apply").join(" "), env: runEnv(applyEnv) },
294
+ run: { command: ciRun("apply").join(" "), env: runEnv(jobEnv) },
279
295
  },
280
296
  {
281
297
  jobName: "record",
@@ -288,6 +304,15 @@ export function prLoopJobs(forge: ForgeKind, env: string, options: PrLoopPipelin
288
304
  ];
289
305
  }
290
306
 
307
+ /**
308
+ * What starts the apply under apply on merge (#4168 step 9): on GitHub the
309
+ * tick's workflow finishing (`workflow_run`, since tags its token pushes start
310
+ * no workflow), elsewhere a pushed `ci/green/*` tag. The name is
311
+ * `CI_GREEN_WORKFLOW_NAME` (../workspace/ci-green-workflow.ts).
312
+ */
313
+ export const GREEN_TICK_WORKFLOW = "chant-ci-green";
314
+ export const GREEN_TAG_PATTERN = "ci/green/**";
315
+
291
316
  /** The job a push to the target branch runs (#4168): `record`, or `apply` when the pipeline opts into apply on merge. */
292
317
  export function prLoopMergeJob(jobs: readonly PrLoopJob[], options: PrLoopPipelineOptions = {}): PrLoopJob {
293
318
  return jobs.find((j) => j.jobName === (options.applyOnMerge ? "apply" : "record"))!;
@@ -326,6 +351,8 @@ export function prLoopWaveJobs(components: DriverComponent[], apply: PrLoopJob):
326
351
  ...apply,
327
352
  command: `${apply.command} --wave ${i + 1}`,
328
353
  wave: i + 1,
354
+ // #4221: every wave after the first applies the commit wave 1 took.
355
+ ...(i > 0 && apply.greenBranch !== undefined ? { checkout: [pinnedGreenCheckout(apply.greenBranch)] } : {}),
329
356
  // #4156: the job runs it through chant ci run, which appends the same --wave to pr-apply.
330
357
  run: { ...apply.run, command: `${apply.run.command} --wave ${i + 1}` },
331
358
  }));
@@ -381,6 +408,30 @@ function pushBaseFallback(target: string, commit: string, diffBase?: string): st
381
408
  );
382
409
  }
383
410
 
411
+ /**
412
+ * The line that checks out the newest green commit of `branch` (#4168 step
413
+ * 9), after fetching the branch and its tags, for the apply job. `chant ci
414
+ * last-green` exits 1 when no commit is green, which fails the job.
415
+ */
416
+ export function greenCheckout(branch: string): string {
417
+ return `${fetchBranch(branch)} && GREEN_SHA="$(chant ci last-green)" && git checkout --quiet --detach "$GREEN_SHA"`;
418
+ }
419
+
420
+ /**
421
+ * The line a wave after the first runs in place of {@link greenCheckout}
422
+ * (#4221): it checks out `GREEN_SHA`, the commit wave 1 took, so a commit
423
+ * that goes green while the waves run waits for a run of its own. An empty
424
+ * `GREEN_SHA` fails the job.
425
+ */
426
+ export function pinnedGreenCheckout(branch: string): string {
427
+ return `test -n "$GREEN_SHA" && ${fetchBranch(branch)} && git checkout --quiet --detach "$GREEN_SHA"`;
428
+ }
429
+
430
+ function fetchBranch(branch: string): string {
431
+ const name = branch.replace(/^"|"$/g, "");
432
+ return `git fetch --quiet --tags origin "+refs/heads/${name}:refs/remotes/origin/${name}"`;
433
+ }
434
+
384
435
  /**
385
436
  * GitLab sets `CI_COMMIT_BEFORE_SHA` to forty zeros on a branch's first push.
386
437
  * The line replaces it with the merge request's diff base when the pipeline
@@ -197,6 +197,13 @@ describe("taking the build out of a composition", () => {
197
197
  expect("error" in prepared && prepared.error).toMatch(/no publish step/);
198
198
  });
199
199
 
200
+ test("#4222: no publish step passes when a release records its templates, and says it is template-only", () => {
201
+ const c: DriverComponent = { name: "chart", deploy: [{ phase: "Apply", steps: [{ kind: "helm-upgrade" }] }] };
202
+ const prepared = withoutBuildSteps(c, { templatesPinned: true });
203
+ expect("error" in prepared).toBe(false);
204
+ expect("templateOnly" in prepared && prepared.templateOnly).toBe(true);
205
+ });
206
+
200
207
  test("two publish steps are refused", () => {
201
208
  const c = service("api");
202
209
  c.deploy[1].steps.push({ kind: "publish-artifact", from: "archive:x.jar" });
@@ -23,7 +23,7 @@
23
23
  *
24
24
  * A composition the transform cannot pin to one recorded digest is refused
25
25
  * before anything runs: one with no publish step (nothing would carry the
26
- * digest), one with more than one (the ledger records one digest per
26
+ * digest) unless a release records its templates (#4222), one with more than one (the ledger records one digest per
27
27
  * component), and one whose remaining steps read an output of a removed build
28
28
  * step (it would resolve to nothing).
29
29
  *
@@ -402,6 +402,8 @@ export interface PromotableComponent {
402
402
  component: DriverComponent;
403
403
  /** The kinds removed, in composition order. */
404
404
  removed: string[];
405
+ /** #4222: the component has no publish step and deploys the templates a release records, so its record carries their digest. */
406
+ templateOnly?: boolean;
405
407
  }
406
408
 
407
409
  /**
@@ -433,7 +435,7 @@ export const recordedDigestCapability: Capability<{ digest: string }, { digest:
433
435
  */
434
436
  export function withoutBuildSteps(
435
437
  component: DriverComponent,
436
- opts: { pinDigest?: string; verb?: string } = {},
438
+ opts: { pinDigest?: string; verb?: string; templatesPinned?: boolean } = {},
437
439
  ): PromotableComponent | { error: string } {
438
440
  const verb = opts.verb ?? (opts.pinDigest === undefined ? "a promote" : "a rollback");
439
441
  const removed: string[] = [];
@@ -487,7 +489,10 @@ export function withoutBuildSteps(
487
489
 
488
490
  const deploy = component.deploy.map(strip).filter((p): p is DriverPhase => p !== undefined);
489
491
 
490
- if (publishSteps === 0) {
492
+ // #4222: under a release that records this component's templates, the
493
+ // deploy reads them from the release (./release-promote.ts), so a component
494
+ // with no artifact deploys the release's content, not its current source.
495
+ if (publishSteps === 0 && !opts.templatesPinned) {
491
496
  return {
492
497
  error: `component "${component.name}" has no publish step, so nothing in its deploy carries a recorded digest; ` +
493
498
  `${verb} would deploy whatever its current source produces`,
@@ -519,7 +524,7 @@ export function withoutBuildSteps(
519
524
  }
520
525
  }
521
526
  }
522
- return { component: { ...component, deploy }, removed };
527
+ return { component: { ...component, deploy }, removed, ...(publishSteps === 0 ? { templateOnly: true } : {}) };
523
528
  }
524
529
 
525
530
  /** Every same-project wiring reference anywhere in a step's fields (cross-stack `stackOutput` objects excluded). */
@@ -7,7 +7,7 @@ import { describe, test, expect } from "vitest";
7
7
  import { contentDigest } from "../content-digest";
8
8
  import { makeRelease, templateBytesDigest } from "../lifecycle/release-store";
9
9
  import type { DriverComponent } from "./driver";
10
- import { isImplicitRelease, pinReleaseTemplates, releaseArtifactMismatches, removePinnedTemplates, targetFiles, targetFor, writePinnedTemplates, RELEASE_TEMPLATES_DIR } from "./release-promote";
10
+ import { isImplicitRelease, pinReleaseTemplates, releaseTemplatesDigest, releaseArtifactMismatches, removePinnedTemplates, targetFiles, targetFor, writePinnedTemplates, RELEASE_TEMPLATES_DIR } from "./release-promote";
11
11
  import type { ReleaseRecord } from "../lifecycle/release-ledger";
12
12
  import type { BuildArchiveManifest } from "./verbs/build-archive";
13
13
  import { withTestDir } from "@intentius/chant-test-utils";
@@ -210,3 +210,26 @@ describe("removePinnedTemplates (#4045)", () => {
210
210
  });
211
211
  });
212
212
  });
213
+
214
+ describe("releaseTemplatesDigest (#4222)", () => {
215
+ const target = {
216
+ env: "staging",
217
+ params: [],
218
+ templates: [
219
+ { component: "infra", path: "b.json", digest: "sha256:b" },
220
+ { component: "infra", path: "a.json", digest: "sha256:a" },
221
+ { component: "app", path: "c.json", digest: "sha256:c" },
222
+ ],
223
+ };
224
+
225
+ test("digests the component's templates sorted by path, whatever order the target lists them", () => {
226
+ const reordered = { ...target, templates: [target.templates[1], target.templates[2], target.templates[0]] };
227
+ expect(releaseTemplatesDigest(target, "infra")).toBe(contentDigest("a.json\tsha256:a\nb.json\tsha256:b\n"));
228
+ expect(releaseTemplatesDigest(reordered, "infra")).toBe(releaseTemplatesDigest(target, "infra"));
229
+ expect(releaseTemplatesDigest(target, "app")).not.toBe(releaseTemplatesDigest(target, "infra"));
230
+ });
231
+
232
+ test("is null for a component the target records no template for", () => {
233
+ expect(releaseTemplatesDigest(target, "worker")).toBeNull();
234
+ });
235
+ });
@@ -31,6 +31,7 @@ import { templateBytesDigest, type Release, type ReleaseTarget } from "../lifecy
31
31
  import { computePlanDigest } from "../lifecycle/plan-digest";
32
32
  import type { ReleaseRecord } from "../lifecycle/release-ledger";
33
33
  import type { BuildArchiveManifest } from "./verbs/build-archive";
34
+ import { contentDigest } from "../content-digest";
34
35
 
35
36
  /** Where a promote under a release writes the recorded templates, relative to the member's directory. */
36
37
  export const RELEASE_TEMPLATES_DIR = ".chant/release-templates";
@@ -56,6 +57,20 @@ export function targetFor(release: Release, env: string): { target: ReleaseTarge
56
57
  };
57
58
  }
58
59
 
60
+ /**
61
+ * The digest a release record carries for a component with no artifact
62
+ * (#4222): `sha256:` over the paths and digests of the templates `target`
63
+ * records for `component`, sorted by path. A template-only component (a
64
+ * `cfn-deploy` with no publish step) deployed under a release is recorded with
65
+ * it, so promote and rollback under a release find what the environment runs.
66
+ * Null when the target records no template for the component.
67
+ */
68
+ export function releaseTemplatesDigest(target: ReleaseTarget, component: string): string | null {
69
+ const own = target.templates.filter((t) => t.component === component).map((t) => `${t.path}\t${t.digest}`).sort();
70
+ if (own.length === 0) return null;
71
+ return contentDigest(`${own.join("\n")}\n`);
72
+ }
73
+
59
74
  /** Whether a release is of a project with no components (#4046): no artifact, and every template it records has `component: null`. */
60
75
  export function isImplicitRelease(release: Release): boolean {
61
76
  const templates = release.targets.flatMap((t) => t.templates);
@@ -334,6 +334,14 @@ export interface DeepNormalizationHooks {
334
334
  * or a value-derived hash.
335
335
  */
336
336
  mask?(node: DeepNode): boolean;
337
+ /**
338
+ * Rewrite one raw property tree before anything else reads it, on both
339
+ * sides. For a difference no per-node hook can reach, such as a map whose
340
+ * keys are references: an ARM `identity.userAssignedIdentities` is keyed by
341
+ * `[resourceId(...)]` in source and by the evaluated resource id live
342
+ * (#4231). Return the tree to compare; leave anything else as it was.
343
+ */
344
+ prepare?(tree: Record<string, unknown>, context: { entityType: string; side: DeepSide }): Record<string, unknown>;
337
345
  }
338
346
 
339
347
  /** Everything the pass needs besides the tree itself. */
@@ -84,3 +84,21 @@ describe("buildDeclaredPerStack groups by declared stack (#1433)", () => {
84
84
  }
85
85
  });
86
86
  });
87
+
88
+ describe("buildDeclaredPerStack takes edges from the lexicon hooks (#4232)", () => {
89
+ test("withEdges sees each stack's entities by bare key, and its edges are qualified with the stack", async () => {
90
+ const seen: string[][] = [];
91
+ const ir = await buildDeclaredPerStack(
92
+ [{ name: "network", src: "network/src" }, { name: "app", src: "app/src" }],
93
+ dir,
94
+ (g, entities) => {
95
+ seen.push([...entities.keys()].sort());
96
+ const [only] = g.nodes;
97
+ return only ? { ...g, edges: [...g.edges, { from: only.id, to: only.id, kind: "ref", viaAttr: "self" }] } : g;
98
+ },
99
+ );
100
+ expect(seen).toEqual([["vpc"], ["service"]]);
101
+ expect(ir.edges).toContainEqual({ from: "network::vpc", to: "network::vpc", kind: "ref", viaAttr: "self" });
102
+ expect(ir.edges).toContainEqual({ from: "app::service", to: "app::service", kind: "ref", viaAttr: "self" });
103
+ });
104
+ });
@@ -1,5 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import { discover } from "./discovery/index";
3
+ import type { Declarable } from "./declarable";
3
4
  import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
4
5
 
5
6
  /**
@@ -19,10 +20,14 @@ import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
19
20
  *
20
21
  * A stack without `src` contributes nothing here — it has no declared source to
21
22
  * scope to (its live nodes still show as foreign in the overlay).
23
+ *
24
+ * `withEdges` adds what the lexicons' `graphEdges` hooks resolve to each
25
+ * stack's graph before its ids are qualified (#4232).
22
26
  */
23
27
  export async function buildDeclaredPerStack(
24
28
  stacks: Array<{ name: string; src?: string }>,
25
29
  projectPath: string,
30
+ withEdges?: (ir: GraphIR, entities: Map<string, Declarable>) => GraphIR,
26
31
  ): Promise<GraphIR> {
27
32
  const nodes: IRNode[] = [];
28
33
  const edges: GraphIR["edges"] = [];
@@ -36,7 +41,9 @@ export async function buildDeclaredPerStack(
36
41
  const byStack: Record<string, string[]> = {};
37
42
  for (const st of stacks) {
38
43
  if (!st.src) continue;
39
- const g = buildGraphIr((await discover(resolve(projectPath, st.src))).entities, projectPath);
44
+ const { entities } = await discover(resolve(projectPath, st.src));
45
+ const built = buildGraphIr(entities, projectPath);
46
+ const g = withEdges ? withEdges(built, entities) : built;
40
47
  const q = (id: string) => `${st.name}::${id}`;
41
48
  for (const n of g.nodes) {
42
49
  const id = q(n.id);
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { applyDetail, DETAIL, detailInertNotice } from "./graph-detail";
3
- import type { GraphIR } from "./graph-ir";
3
+ import { overlayGraphs, sourceOverlayGraphs, type GraphIR } from "./graph-ir";
4
4
 
5
5
  // A small graph: a gcp vpc/subnet pair plus a k8s namespace and deployment that
6
6
  // came from one composite instance ("db"), with the deployment referencing the
@@ -294,3 +294,70 @@ describe("detailInertNotice (#1489)", () => {
294
294
  expect(detailInertNotice(edgeless, detailed)).toContain("no edges at all");
295
295
  });
296
296
  });
297
+
298
+ describe("T1 rolls a composite's overlay status up from its members (#4227)", () => {
299
+ // Two stacks' worth of one FargateAlb, keyed `${stack}::${logicalId}` as a
300
+ // multi-stack live read keys them (#1162).
301
+ const member = (id: string, attrs: Record<string, unknown> = {}) => ({
302
+ id: `three-cloud-aws::${id}`,
303
+ kind: "Resource",
304
+ lexicon: "aws",
305
+ compositeParent: "FargateAlb",
306
+ compositeInstance: "web",
307
+ attrs,
308
+ });
309
+ const declared: GraphIR = {
310
+ nodes: [member("webAlb"), member("webService"), member("webTaskDef")],
311
+ edges: [],
312
+ groups: { byLexicon: {} },
313
+ };
314
+ const live = (ids: string[], extra: GraphIR["nodes"] = []): GraphIR => ({
315
+ nodes: [...ids.map((id) => ({ id: `three-cloud-aws::${id}`, kind: "Resource", lexicon: "aws", attrs: {} })), ...extra],
316
+ edges: [],
317
+ groups: { byLexicon: {} },
318
+ });
319
+ const web = (ir: GraphIR) => applyDetail(ir, DETAIL.COMPOSITES).nodes.find((n) => n.id === "web")!;
320
+ const all = ["webAlb", "webService", "webTaskDef"];
321
+
322
+ test("every member good gives good, on either anchor", () => {
323
+ for (const ir of [sourceOverlayGraphs(declared, live(all)), overlayGraphs(live(all), declared)]) {
324
+ expect(web(ir).attrs).toEqual({ members: 3, _status: "good" });
325
+ }
326
+ });
327
+
328
+ test("an absent member makes the composite pending, and is named", () => {
329
+ for (const ir of [sourceOverlayGraphs(declared, live(["webAlb", "webTaskDef"])), overlayGraphs(live(["webAlb", "webTaskDef"]), declared)]) {
330
+ expect(web(ir).attrs).toEqual({ members: 3, _status: "accent", _memberStatus: { accent: ["three-cloud-aws::webService"] } });
331
+ }
332
+ });
333
+
334
+ test("an unobserved member gives neutral, and an absent one outranks it", () => {
335
+ const unobserved = { "three-cloud-aws::webTaskDef": { reason: "unsupported-kind" } } as never;
336
+ expect(web(sourceOverlayGraphs(declared, live(["webAlb", "webService"]), { unobserved })).attrs).toEqual({
337
+ members: 3,
338
+ _status: "neutral",
339
+ _memberStatus: { neutral: ["three-cloud-aws::webTaskDef"] },
340
+ });
341
+ expect(web(sourceOverlayGraphs(declared, live(["webAlb"]), { unobserved })).attrs).toEqual({
342
+ members: 3,
343
+ _status: "accent",
344
+ _memberStatus: { accent: ["three-cloud-aws::webService"], neutral: ["three-cloud-aws::webTaskDef"] },
345
+ });
346
+ });
347
+
348
+ test("a foreign resource stays its own node and leaves the composite good", () => {
349
+ const foreign = { id: "three-cloud-aws::strayBucket", kind: "Bucket", lexicon: "aws", attrs: {} };
350
+ const ir = applyDetail(sourceOverlayGraphs(declared, live(all, [foreign])), DETAIL.COMPOSITES);
351
+ expect(ir.nodes.find((n) => n.id === "web")!.attrs).toEqual({ members: 3, _status: "good" });
352
+ expect(ir.nodes.find((n) => n.id === foreign.id)!.attrs).toEqual({ _status: "warn" });
353
+ });
354
+
355
+ test("a member painted with a status the rollup does not know is never hidden", () => {
356
+ const ir: GraphIR = { ...declared, nodes: [member("webAlb", { _status: "good" }), member("webService", { _status: "drifted" })] };
357
+ expect(web(ir).attrs).toEqual({ members: 2, _status: "drifted", _memberStatus: { drifted: ["three-cloud-aws::webService"] } });
358
+ });
359
+
360
+ test("a graph read without --overlay carries no _status on the composite", () => {
361
+ expect(web(declared).attrs).toEqual({ members: 3 });
362
+ });
363
+ });
@@ -124,6 +124,34 @@ function toDeclarables(ir: GraphIR): GraphIR {
124
124
  return { ...ir, nodes: ir.nodes.map((n) => ({ ...n, attrs: resourceAttrs(n.attrs) })) };
125
125
  }
126
126
 
127
+ /** Overlay statuses from the least to the most in need of attention: a
128
+ * composite takes its worst member's (#4227). An unknown status ranks above
129
+ * `warn`, so a status this list does not know yet is never hidden. */
130
+ const STATUS_RANK = ["good", "runtime", "neutral", "accent", "warn"];
131
+
132
+ function statusRank(status: string): number {
133
+ const i = STATUS_RANK.indexOf(status);
134
+ return i === -1 ? STATUS_RANK.length : i;
135
+ }
136
+
137
+ /** A collapsed composite's overlay paint, rolled up from its members (#4227):
138
+ * `_status` is the worst member status, and `_memberStatus` lists the members
139
+ * that are not `good` under their status. Nothing when no member carries a
140
+ * `_status`, as in a graph read without `--overlay`. */
141
+ function rollupStatus(members: IRNode[]): Record<string, unknown> {
142
+ const painted = members.filter((m) => typeof m.attrs._status === "string");
143
+ if (painted.length === 0) return {};
144
+ let worst = "good";
145
+ const notGood: Record<string, string[]> = {};
146
+ for (const m of painted) {
147
+ const status = m.attrs._status as string;
148
+ if (statusRank(status) > statusRank(worst)) worst = status;
149
+ if (status !== "good") (notGood[status] ??= []).push(m.id);
150
+ }
151
+ for (const ids of Object.values(notGood)) ids.sort();
152
+ return { _status: worst, ...(Object.keys(notGood).length > 0 ? { _memberStatus: notGood } : {}) };
153
+ }
154
+
127
155
  /** T1 — collapse each composite instance to one node; internal edges disappear.
128
156
  * Topology view: surviving plain nodes keep only overlay paint, not properties. */
129
157
  function toComposites(ir: GraphIR): GraphIR {
@@ -169,7 +197,7 @@ function toComposites(ir: GraphIR): GraphIR {
169
197
  id: collapsedId.get(key)!,
170
198
  kind: types.size === 1 ? [...types][0] : "Composite",
171
199
  lexicon: lexicons.size === 1 ? [...lexicons][0] : "multi",
172
- attrs: { members: members.length },
200
+ attrs: { members: members.length, ...rollupStatus(members) },
173
201
  });
174
202
  }
175
203
 
package/src/graph-ir.ts CHANGED
@@ -1078,13 +1078,21 @@ function tagStatus(n: IRNode, status: OverlayNodeStatus, unobserved?: Unobserved
1078
1078
  * no live edges). Sorted; the live groups pass through unchanged.
1079
1079
  */
1080
1080
  export function overlayGraphs(live: GraphIR, declared: GraphIR, opts?: OverlayOptions): GraphIR {
1081
- const declaredIds = new Set(declared.nodes.map((n) => n.id));
1081
+ const declaredById = new Map(declared.nodes.map((n) => [n.id, n]));
1082
1082
  const liveIds = new Set(live.nodes.map((n) => n.id));
1083
1083
  const unobserved = opts?.unobserved ?? {};
1084
1084
 
1085
- const nodes: IRNode[] = live.nodes.map((n) =>
1086
- tagStatus(n, declaredIds.has(n.id) ? "good" : n.runtimeOwner ? "runtime" : "warn"),
1087
- );
1085
+ const nodes: IRNode[] = live.nodes.map((n) => {
1086
+ const d = declaredById.get(n.id);
1087
+ if (!d) return tagStatus(n, n.runtimeOwner ? "runtime" : "warn");
1088
+ // A managed node keeps the composite it was declared in, so `--detail 1`
1089
+ // collapses it with its siblings and rolls their status up (#4227).
1090
+ const membership = {
1091
+ ...(d.compositeInstance ? { compositeInstance: d.compositeInstance } : {}),
1092
+ ...(d.compositeParent ? { compositeParent: d.compositeParent } : {}),
1093
+ };
1094
+ return tagStatus({ ...n, ...membership }, "good");
1095
+ });
1088
1096
  for (const n of declared.nodes) {
1089
1097
  if (liveIds.has(n.id)) continue;
1090
1098
  const u = unobserved[n.id];
@@ -155,6 +155,11 @@ export function seedFakeNpmRegistry(log: string, registry: FakeNpmRegistry): voi
155
155
  * its registry at that version, and installs nothing.
156
156
  * - `audit signatures` (#4049) prints what npm prints for verified
157
157
  * signatures and attestations, and exits 1 when `$FAKE_NPM_AUDIT_FAIL` is set.
158
+ * - `$FAKE_NPM_LAG` (#4244), `<name>=<n>[,<name>=<n>]`: after a publish of
159
+ * that name, the next `n` views of the version find nothing, as a registry
160
+ * that accepted a publish but does not serve it yet.
161
+ * - `$FAKE_NPM_INSTALL_ETARGET` (#4244), `<n>`: the first `n` installs fail
162
+ * with ETARGET.
158
163
  */
159
164
  export function writeFakeNpm(binDir: string, log: string): string {
160
165
  mkdirSync(binDir, { recursive: true });
@@ -168,7 +173,8 @@ const { execFileSync } = require("child_process");
168
173
  const args = process.argv.slice(2);
169
174
  const token = process.env.NODE_AUTH_TOKEN || "";
170
175
  const regFile = ${JSON.stringify(log)} + ".registry.json";
171
- fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args, tokenSet: token !== "" }) + "\\n");
176
+ const userconfig = process.env.NPM_CONFIG_USERCONFIG ? fs.readFileSync(process.env.NPM_CONFIG_USERCONFIG, "utf-8") : "";
177
+ fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args, tokenSet: token !== "", ...(userconfig ? { userconfig } : {}) }) + "\\n");
172
178
  console.log("npm notice using //registry.example/:_authToken=" + token);
173
179
  console.error("npm notice token " + token);
174
180
  const reg = fs.existsSync(regFile) ? JSON.parse(fs.readFileSync(regFile, "utf-8")) : { packages: {} };
@@ -177,11 +183,18 @@ const flag = (name) => { const i = args.indexOf(name); return i < 0 ? null : arg
177
183
  const split = (spec) => { const at = spec.lastIndexOf("@"); return [spec.slice(0, at), spec.slice(at + 1)]; };
178
184
  if (args[0] === "publish") {
179
185
  if (process.env.FAKE_NPM_FAIL && args[1] && args[1].includes(process.env.FAKE_NPM_FAIL)) { console.error("npm error 403 Forbidden"); process.exit(1); }
186
+ // A package whose trusted-publisher record is broken: only a token in the user config publishes it.
187
+ if (process.env.FAKE_NPM_NEEDAUTH && args[1] && args[1].includes(process.env.FAKE_NPM_NEEDAUTH) && !userconfig.includes("_authToken=")) { console.error("npm error code ENEEDAUTH"); process.exit(1); }
188
+ if (process.env.FAKE_NPM_TOKEN_NO_PROVENANCE && userconfig && args.includes("--provenance")) { console.error("npm error 422 Unprocessable Entity - provenance statement does not match the publisher"); process.exit(1); }
180
189
  const text = fs.statSync(args[1]).isDirectory() ? fs.readFileSync(path.join(args[1], "package.json"), "utf-8") : execFileSync("tar", ["-xzOf", args[1], "package/package.json"], { encoding: "utf-8" });
181
190
  const { name, version } = JSON.parse(text);
182
191
  const p = reg.packages[name] || (reg.packages[name] = { versions: [], distTags: {} });
183
192
  if (p.versions.includes(version)) { console.error("npm error 403 You cannot publish over the previously published versions: " + version); process.exit(1); }
184
193
  p.versions.push(version);
194
+ for (const pair of (process.env.FAKE_NPM_LAG || "").split(",").filter(Boolean)) {
195
+ const eq = pair.lastIndexOf("=");
196
+ if (pair.slice(0, eq) === name) (p.lag || (p.lag = {}))[version] = Number(pair.slice(eq + 1));
197
+ }
185
198
  p.distTags[flag("--tag") || "latest"] = version;
186
199
  if (!p.distTags.latest) p.distTags.latest = version;
187
200
  save();
@@ -190,7 +203,8 @@ if (args[0] === "publish") {
190
203
  const [name, version] = split(args[1]);
191
204
  const p = reg.packages[name];
192
205
  if (!p) { console.error("npm error 404 Not Found - " + name); process.exit(1); }
193
- if (p.versions.includes(version)) console.log(version);
206
+ if (p.lag && p.lag[version] > 0) { p.lag[version] -= 1; save(); }
207
+ else if (p.versions.includes(version)) console.log(version);
194
208
  } else if (args[0] === "dist-tag" && args[1] === "add") {
195
209
  const [name, version] = split(args[2]);
196
210
  if (process.env.FAKE_NPM_FAIL_TAG && name.includes(process.env.FAKE_NPM_FAIL_TAG)) { console.error("npm error 403 Forbidden - no permission to change dist-tags"); process.exit(1); }
@@ -200,6 +214,10 @@ if (args[0] === "publish") {
200
214
  save();
201
215
  console.log("+" + args[3] + ": " + args[2]);
202
216
  } else if (args[0] === "install") {
217
+ if (Number(process.env.FAKE_NPM_INSTALL_ETARGET || 0) > (reg.installs || 0)) {
218
+ reg.installs = (reg.installs || 0) + 1; save();
219
+ console.error("npm error code ETARGET"); console.error("npm error notarget No matching version found for a package"); process.exit(1);
220
+ }
203
221
  const deps = JSON.parse(fs.readFileSync("package.json", "utf-8")).dependencies || {};
204
222
  for (const [name, version] of Object.entries(deps)) {
205
223
  const p = reg.packages[name];