@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
@@ -13,6 +13,7 @@ import {
13
13
  emptyVerifier,
14
14
  gateNamesOf,
15
15
  planReleaseChangeSet,
16
+ planReleaseSet,
16
17
  provisionalMembers,
17
18
  RELEASE_GATE_OP,
18
19
  } from "./release-gate";
@@ -162,6 +163,52 @@ describe("planReleaseChangeSet", () => {
162
163
  });
163
164
  });
164
165
 
166
+ describe("planReleaseSet: a dependent plans against its upstream's planned outputs (#4168 step 4)", () => {
167
+ const r1 = release(sha("7"));
168
+ const reg = () => {
169
+ const r = registry();
170
+ r.register({
171
+ kind: "tf",
172
+ async run() { return {}; },
173
+ async plan(_ctx: unknown, input: { name: string; vpc?: unknown }) {
174
+ return {
175
+ member: input.name,
176
+ planDigest: sha(input.vpc === "vpc-new" ? "b" : input.vpc === undefined ? "c" : "a"),
177
+ outputs: input.name === "net" ? { known: { id: "vpc-new" }, unknown: ["arn"] } : undefined,
178
+ };
179
+ },
180
+ async outputs() { return { id: "vpc-old", arn: "arn:old" }; },
181
+ } as never);
182
+ return r;
183
+ };
184
+ const net: DriverComponent = { name: "net", deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "tf", name: "net" }] }] };
185
+ const svc = (ref: string): DriverComponent => ({
186
+ name: "svc",
187
+ dependsOn: ["net"],
188
+ deploy: [{ phase: "Apply", steps: [{ kind: "gate", gate: "prod-release" }, { kind: "tf", name: "svc", vpc: { stackOutput: { stack: "net", name: ref } } }] }],
189
+ });
190
+
191
+ test("an upstream the promote plans is read from its plan, and the read is kept", async () => {
192
+ const set = await planReleaseSet({ release: r1, env: "prod", components: [svc("id"), net], digests: new Map(), registry: reg(), current: new Map() });
193
+ expect(set.doc.members.find((m) => m.member === "svc")?.planDigest).toBe(sha("b"));
194
+ expect(set.reads.svc).toEqual({ net: { id: "vpc-new" } });
195
+ expect(set.plannedOutputs).toEqual({ net: ["id"] });
196
+ expect(set.unknownInputs).toBeUndefined();
197
+ });
198
+
199
+ test("an output the upstream's plan can't say is an unknown input", async () => {
200
+ const set = await planReleaseSet({ release: r1, env: "prod", components: [svc("arn"), net], digests: new Map(), registry: reg(), current: new Map() });
201
+ expect(set.unknownInputs).toEqual({ svc: ["net.arn"] });
202
+ });
203
+
204
+ test("an upstream outside the promote is read as it stands", async () => {
205
+ const set = await planReleaseSet({ release: r1, env: "prod", components: [svc("id"), net], digests: new Map(), registry: reg(), current: new Map(), only: new Set(["svc"]), seededOutputs: { net: { id: "vpc-old" } } });
206
+ expect(set.doc.members.map((m) => m.member)).toEqual(["svc"]);
207
+ expect(set.reads.svc).toEqual({ net: { id: "vpc-old" } });
208
+ expect(set.plannedOutputs).toBeUndefined();
209
+ });
210
+ });
211
+
165
212
  describe("empty change sets", () => {
166
213
  const r1 = release(sha("7"));
167
214
  const emptyDoc = (): ChangeSetDocument =>
@@ -39,6 +39,14 @@
39
39
  * planned by that capability, as a pull request's plan is. A plan that
40
40
  * returns no change-set part is a hole, since nothing says what it changes.
41
41
  *
42
+ * A dependency the promote also deploys is read from its plan, not from the
43
+ * estate (#4168 step 4, the rule #3999 gave a pull request): a dependent is
44
+ * planned against the outputs its upstream's plan says it will expose, and a
45
+ * reference the plan can't say is listed in `unknownInputs`. Any other
46
+ * dependency is read as it stands. What each component was planned against is
47
+ * kept in `reads`, so the promote can hold it back when an upstream applies
48
+ * as another value (`./promote-rounds.ts`).
49
+ *
42
50
  * Any other component is a `chant` member compared with what the
43
51
  * environment runs now: its latest deploy record and the release that record
44
52
  * names. The artifact digest, each template the release's target records for
@@ -52,8 +60,8 @@
52
60
  * recorded since (#4035): its record names what ran before that run.
53
61
  */
54
62
 
55
- import type { CapabilityRegistry } from "./capability";
56
- import { deployContext, resolveComponentGraph, resolveStepInput, type DriverComponent, type DriverGate, type DriverPhase, type DriverStep } from "./driver";
63
+ import type { CapabilityRegistry, PlannedOutputs } from "./capability";
64
+ import { deployContext, resolveComponentGraph, resolveStepInput, unresolvedWiringReferences, type DriverComponent, type DriverGate, type DriverPhase, type DriverStep } from "./driver";
57
65
  import { componentPlanDigest } from "./gate-plan";
58
66
  import { gateName, RELEASE_GATE_OP } from "../op/gate-name";
59
67
  import { capabilitySteps, planContext, readComponentOutputs } from "./wave-plan";
@@ -123,6 +131,23 @@ export interface ReleaseChangeSetInput {
123
131
  seededOutputs?: Record<string, Record<string, unknown>>;
124
132
  /** The chant version to record in the document. */
125
133
  chant?: string;
134
+ /**
135
+ * Plan only these components (a later round or wave, ./promote-rounds.ts).
136
+ * The others are not planned; a dependency among them is read from
137
+ * `seededOutputs`, which then holds what the run left. Default: every one.
138
+ */
139
+ only?: ReadonlySet<string>;
140
+ }
141
+
142
+ /** A release's change set for an environment, and what each component was planned against. */
143
+ export interface ReleasePlan {
144
+ doc: ChangeSetDocument;
145
+ /** Per planned component, the outputs of its dependencies as its plan read them. */
146
+ reads: Record<string, Record<string, Record<string, unknown>>>;
147
+ /** Per dependency read from its plan, the names of the outputs the plan knows, sorted. Absent when none. */
148
+ plannedOutputs?: Record<string, string[]>;
149
+ /** Per component, the references to a promoted upstream that no value stands for until it applies, sorted. Absent when none. */
150
+ unknownInputs?: Record<string, string[]>;
126
151
  }
127
152
 
128
153
  const byString = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
@@ -240,65 +265,105 @@ export function chantComponentPart(input: {
240
265
  * that fails to plan: it becomes a failed member.
241
266
  */
242
267
  export async function planReleaseChangeSet(input: ReleaseChangeSetInput): Promise<ChangeSetDocument> {
268
+ return (await planReleaseSet(input)).doc;
269
+ }
270
+
271
+ /**
272
+ * {@link planReleaseChangeSet}, with what each component read. Components are
273
+ * planned a wave at a time, so a dependency the promote deploys has planned
274
+ * before its dependents read its outputs.
275
+ */
276
+ export async function planReleaseSet(input: ReleaseChangeSetInput): Promise<ReleasePlan> {
243
277
  const { release, env, registry } = input;
244
278
  const components = [...input.components];
245
279
  const byName = new Map(components.map((c) => [c.name, c]));
246
280
  const outputs: Record<string, Record<string, unknown>> = { ...(input.seededOutputs ?? {}) };
247
281
  const read = new Set(Object.keys(outputs));
248
282
  const parts: ChangeSetPart[] = [];
249
- const { order } = resolveComponentGraph(components.map((c) => ({ ...c, dependsOn: (c.dependsOn ?? []).filter((d) => byName.has(d)) })));
250
- for (const name of order) {
251
- const component = byName.get(name)!;
252
- const digest = input.digests.get(name) ?? "";
253
- const plannable = capabilitySteps(component.deploy).filter((s) => registry.has(s.kind) && registry.resolve(s.kind).plan !== undefined);
254
- if (plannable.length === 0) {
255
- parts.push(chantComponentPart({ component, release, env, digest, current: input.current.get(name), unsettled: input.unsettled?.get(name) }));
256
- continue;
257
- }
258
- const ctx = deployContext({ env }, name);
259
- try {
260
- for (const dep of component.dependsOn ?? []) {
261
- if (read.has(dep) || !byName.has(dep)) continue;
262
- read.add(dep);
263
- const found = await readComponentOutputs(byName.get(dep)!, registry, deployContext({ env }, dep), outputs);
264
- if (found) outputs[dep] = found;
283
+ const plannedBy = new Map<string, PlannedOutputs | undefined>();
284
+ const planned: Record<string, string[]> = {};
285
+ const unknownInputs: Record<string, string[]> = {};
286
+ const reads: ReleasePlan["reads"] = {};
287
+ const inPlan = (name: string): boolean => input.only === undefined || input.only.has(name);
288
+ const isPlannable = (c: DriverComponent): boolean =>
289
+ capabilitySteps(c.deploy).some((s) => registry.has(s.kind) && registry.resolve(s.kind).plan !== undefined);
290
+ const { waves } = resolveComponentGraph(components.map((c) => ({ ...c, dependsOn: (c.dependsOn ?? []).filter((d) => byName.has(d)) })));
291
+ for (const wave of waves) {
292
+ const planning = wave.filter(inPlan);
293
+ for (const name of planning) {
294
+ const component = byName.get(name)!;
295
+ const digest = input.digests.get(name) ?? "";
296
+ const plannable = capabilitySteps(component.deploy).filter((s) => registry.has(s.kind) && registry.resolve(s.kind).plan !== undefined);
297
+ if (plannable.length === 0) {
298
+ parts.push(chantComponentPart({ component, release, env, digest, current: input.current.get(name), unsettled: input.unsettled?.get(name) }));
299
+ continue;
265
300
  }
266
- for (const step of plannable) {
267
- const { kind, ...rest } = step;
268
- const planned = await registry.resolve(kind).plan!(planContext(ctx, rest, outputs), resolveStepInput(rest, {}, outputs) as never);
269
- parts.push(
270
- planned.changeSet ?? {
271
- member: {
272
- member: planned.member,
273
- lexicon: "chant",
274
- planner: "chant",
275
- status: "planned",
276
- planDigest: planned.planDigest,
277
- holes: [{ address: planned.member, reason: `its ${kind} plan describes no changes, so what it changes is unknown` }],
301
+ const ctx = deployContext({ env }, name);
302
+ try {
303
+ for (const dep of component.dependsOn ?? []) {
304
+ if (read.has(dep) || !byName.has(dep)) continue;
305
+ read.add(dep);
306
+ // A dependency this promote plans exposes what its plan says (#3999).
307
+ if (plannedBy.has(dep) && isPlannable(byName.get(dep)!)) {
308
+ const known = plannedBy.get(dep)?.known ?? {};
309
+ outputs[dep] = { ...known };
310
+ planned[dep] = Object.keys(known).sort();
311
+ continue;
312
+ }
313
+ const found = await readComponentOutputs(byName.get(dep)!, registry, deployContext({ env }, dep), outputs);
314
+ if (found) outputs[dep] = found;
315
+ }
316
+ reads[name] = Object.fromEntries((component.dependsOn ?? []).filter((d) => d in outputs).map((d) => [d, structuredClone(outputs[d])]));
317
+ const unknown = new Set<string>();
318
+ let exposes: PlannedOutputs | undefined;
319
+ for (const step of plannable) {
320
+ const { kind, ...rest } = step;
321
+ for (const r of unresolvedWiringReferences(rest, outputs)) if (plannedBy.has(r.source) && !r.publish) unknown.add(r.ref);
322
+ const result = await registry.resolve(kind).plan!(planContext(ctx, rest, outputs), resolveStepInput(rest, {}, outputs) as never);
323
+ if (result.outputs) exposes = { known: { ...(exposes?.known ?? {}), ...result.outputs.known }, unknown: [...new Set([...(exposes?.unknown ?? []), ...result.outputs.unknown])].sort() };
324
+ parts.push(
325
+ result.changeSet ?? {
326
+ member: {
327
+ member: result.member,
328
+ lexicon: "chant",
329
+ planner: "chant",
330
+ status: "planned",
331
+ planDigest: result.planDigest,
332
+ holes: [{ address: result.member, reason: `its ${kind} plan describes no changes, so what it changes is unknown` }],
333
+ },
334
+ entries: [],
278
335
  },
279
- entries: [],
336
+ );
337
+ }
338
+ plannedBy.set(name, exposes);
339
+ if (unknown.size > 0) unknownInputs[name] = [...unknown].sort();
340
+ } catch (err) {
341
+ plannedBy.set(name, undefined);
342
+ parts.push({
343
+ member: {
344
+ member: name,
345
+ lexicon: "chant",
346
+ planner: "chant",
347
+ status: "failed",
348
+ error: err instanceof Error ? err.message : String(err),
349
+ planDigest: null,
350
+ holes: [],
280
351
  },
281
- );
352
+ entries: [],
353
+ });
282
354
  }
283
- } catch (err) {
284
- parts.push({
285
- member: {
286
- member: name,
287
- lexicon: "chant",
288
- planner: "chant",
289
- status: "failed",
290
- error: err instanceof Error ? err.message : String(err),
291
- planDigest: null,
292
- holes: [],
293
- },
294
- entries: [],
295
- });
296
355
  }
297
356
  }
298
- return composeChangeSet(parts, {
357
+ const doc = composeChangeSet(parts, {
299
358
  ...(input.chant !== undefined ? { chant: input.chant } : {}),
300
359
  planned: { releaseDigest: release.releaseDigest, environment: env },
301
360
  });
361
+ return {
362
+ doc,
363
+ reads,
364
+ ...(Object.keys(planned).length > 0 ? { plannedOutputs: planned } : {}),
365
+ ...(Object.keys(unknownInputs).length > 0 ? { unknownInputs } : {}),
366
+ };
302
367
  }
303
368
 
304
369
  /** The members a release gate refuses: provisional ones, which no approval of the document covers (`change-set.mdx`). */
@@ -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. */
@@ -0,0 +1,40 @@
1
+ import { describe, test, expect, vi } from "vitest";
2
+
3
+ /**
4
+ * Side-by-side stacks keep their exports and imports (#4202). The live overlay
5
+ * builds the declared canvas per stack, and that canvas dropped the member's
6
+ * exports, so a workspace viewer had nothing to join another member's import
7
+ * to once an environment was picked.
8
+ */
9
+ vi.mock("./discovery/index", () => ({ discover: vi.fn(async () => ({ entities: new Map(), errors: [] })) }));
10
+ vi.mock("./graph-ir", () => ({
11
+ buildGraphIr: vi.fn(() => ({
12
+ nodes: [
13
+ { id: "webAlb", kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", lexicon: "aws" },
14
+ { id: "zoneId", kind: "AWS::CloudFormation::Parameter", lexicon: "aws" },
15
+ ],
16
+ edges: [],
17
+ groups: {},
18
+ exports: [{ name: "awsEndpoint", node: "webAlb", attr: "DNSName" }],
19
+ imports: [{ name: "zoneId", node: "zoneId" }],
20
+ })),
21
+ }));
22
+
23
+ const { buildDeclaredPerStack } = await import("./graph-declared");
24
+
25
+ describe("buildDeclaredPerStack keeps exports and imports (#4202)", () => {
26
+ test("an export's node is qualified like the nodes, and its name is unchanged", async () => {
27
+ const ir = await buildDeclaredPerStack([{ name: "three-cloud-aws", src: "src" }], "/project");
28
+ expect(ir.nodes.map((n) => n.id)).toContain("three-cloud-aws::webAlb");
29
+ expect(ir.exports).toEqual([{ name: "awsEndpoint", node: "three-cloud-aws::webAlb", attr: "DNSName" }]);
30
+ expect(ir.imports).toEqual([{ name: "zoneId", node: "three-cloud-aws::zoneId" }]);
31
+ const ids = new Set(ir.nodes.map((n) => n.id));
32
+ for (const e of ir.exports ?? []) expect(ids.has(e.node!)).toBe(true);
33
+ });
34
+
35
+ test("a stack without src brings no exports", async () => {
36
+ const ir = await buildDeclaredPerStack([{ name: "unbuilt" }], "/project");
37
+ expect(ir.exports).toBeUndefined();
38
+ expect(ir.imports).toBeUndefined();
39
+ });
40
+ });
@@ -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
  /**
@@ -13,15 +14,25 @@ import { buildGraphIr, type GraphIR, type IRNode } from "./graph-ir";
13
14
  * that live side, build each stack's `src` in isolation and qualify its node ids
14
15
  * and edge endpoints the same way. Stacks are merged into one graph.
15
16
  *
17
+ * Each stack's exports and imports come along with their `node` qualified the
18
+ * same way (#4202), so a workspace viewer can still match an import in another
19
+ * member to an export here once an environment is picked.
20
+ *
16
21
  * A stack without `src` contributes nothing here — it has no declared source to
17
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).
18
26
  */
19
27
  export async function buildDeclaredPerStack(
20
28
  stacks: Array<{ name: string; src?: string }>,
21
29
  projectPath: string,
30
+ withEdges?: (ir: GraphIR, entities: Map<string, Declarable>) => GraphIR,
22
31
  ): Promise<GraphIR> {
23
32
  const nodes: IRNode[] = [];
24
33
  const edges: GraphIR["edges"] = [];
34
+ const exports: NonNullable<GraphIR["exports"]> = [];
35
+ const imports: NonNullable<GraphIR["imports"]> = [];
25
36
  // Which nodes belong to which stack (#1433). This is not derived or guessed:
26
37
  // the stack is declared in config and every node here is being renamed with
27
38
  // its name on the line below. The membership was always known and thrown
@@ -30,7 +41,9 @@ export async function buildDeclaredPerStack(
30
41
  const byStack: Record<string, string[]> = {};
31
42
  for (const st of stacks) {
32
43
  if (!st.src) continue;
33
- 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;
34
47
  const q = (id: string) => `${st.name}::${id}`;
35
48
  for (const n of g.nodes) {
36
49
  const id = q(n.id);
@@ -38,8 +51,10 @@ export async function buildDeclaredPerStack(
38
51
  (byStack[st.name] ??= []).push(id);
39
52
  }
40
53
  for (const e of g.edges) edges.push({ ...e, from: q(e.from), to: q(e.to) });
54
+ for (const e of g.exports ?? []) exports.push({ ...e, ...(e.node ? { node: q(e.node) } : {}) });
55
+ for (const i of g.imports ?? []) imports.push({ ...i, node: q(i.node) });
41
56
  }
42
57
  for (const ids of Object.values(byStack)) ids.sort();
43
58
  const groups: GraphIR["groups"] = Object.keys(byStack).length ? { byStack } : {};
44
- return { nodes, edges, groups };
59
+ return { nodes, edges, groups, ...(exports.length ? { exports } : {}), ...(imports.length ? { imports } : {}) };
45
60
  }
@@ -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];
@@ -168,7 +168,8 @@ const { execFileSync } = require("child_process");
168
168
  const args = process.argv.slice(2);
169
169
  const token = process.env.NODE_AUTH_TOKEN || "";
170
170
  const regFile = ${JSON.stringify(log)} + ".registry.json";
171
- fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args, tokenSet: token !== "" }) + "\\n");
171
+ const userconfig = process.env.NPM_CONFIG_USERCONFIG ? fs.readFileSync(process.env.NPM_CONFIG_USERCONFIG, "utf-8") : "";
172
+ fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ args, tokenSet: token !== "", ...(userconfig ? { userconfig } : {}) }) + "\\n");
172
173
  console.log("npm notice using //registry.example/:_authToken=" + token);
173
174
  console.error("npm notice token " + token);
174
175
  const reg = fs.existsSync(regFile) ? JSON.parse(fs.readFileSync(regFile, "utf-8")) : { packages: {} };
@@ -177,6 +178,9 @@ const flag = (name) => { const i = args.indexOf(name); return i < 0 ? null : arg
177
178
  const split = (spec) => { const at = spec.lastIndexOf("@"); return [spec.slice(0, at), spec.slice(at + 1)]; };
178
179
  if (args[0] === "publish") {
179
180
  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); }
181
+ // A package whose trusted-publisher record is broken: only a token in the user config publishes it.
182
+ 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); }
183
+ 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
184
  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
185
  const { name, version } = JSON.parse(text);
182
186
  const p = reg.packages[name] || (reg.packages[name] = { versions: [], distTags: {} });