@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
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Composed commits (#4168 4.3), as git holds them. A composed release lives
3
+ * on a pushed `chant/compose/<name>` branch whose commits each cherry-pick
4
+ * one trunk commit and name it in a `Chant-Composed-From: <sha>` trailer.
5
+ * The base is the commit the first composed commit was picked onto.
6
+ *
7
+ * Kept free of other chant imports, so the ci tick, the cut, the metrics
8
+ * and the compose command read composed commits the same way.
9
+ */
10
+
11
+ import { execFileSync } from "node:child_process";
12
+
13
+ /** The branch prefix a composed release lives under. */
14
+ export const COMPOSE_BRANCH_PREFIX = "chant/compose/";
15
+
16
+ /** The trailer naming the trunk commit a composed commit picks. */
17
+ export const COMPOSED_FROM_TRAILER = "Chant-Composed-From";
18
+
19
+ /** What a composed commit carries: its branch when one is known, its base, and the trunk commits picked onto the base, oldest first. */
20
+ export interface Composition {
21
+ branch: string | null;
22
+ base: string;
23
+ sources: string[];
24
+ }
25
+
26
+ function tryGit(top: string, args: string[]): string | null {
27
+ try {
28
+ return execFileSync("git", args, { cwd: top, encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 64 * 1024 * 1024 }).trim();
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ /** Whether `branch` names a compose branch. */
35
+ export function isComposeBranch(branch: unknown): branch is string {
36
+ return typeof branch === "string" && branch.startsWith(COMPOSE_BRANCH_PREFIX) && branch.length > COMPOSE_BRANCH_PREFIX.length;
37
+ }
38
+
39
+ /** The trunk commits one commit's `Chant-Composed-From` trailers name. */
40
+ export function composedFrom(top: string, sha: string): string[] {
41
+ const out = tryGit(top, ["show", "-s", `--format=%(trailers:key=${COMPOSED_FROM_TRAILER},valueonly,separator=%x00)`, sha]);
42
+ return (out ?? "")
43
+ .split("\0")
44
+ .map((s) => s.trim())
45
+ .filter((s) => /^[0-9a-f]{40}$/.test(s));
46
+ }
47
+
48
+ /**
49
+ * The composition `sha` ends: walk its first parents while each commit
50
+ * carries a `Chant-Composed-From` trailer. Null when `sha` carries none.
51
+ */
52
+ export function compositionOf(top: string, sha: string, branch: string | null = null): Composition | null {
53
+ const sources: string[] = [];
54
+ let at = sha;
55
+ for (;;) {
56
+ const from = composedFrom(top, at);
57
+ if (from.length === 0) break;
58
+ sources.unshift(...from);
59
+ const parent = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
60
+ if (!parent) break;
61
+ at = parent;
62
+ }
63
+ if (sources.length === 0) return null;
64
+ return { branch, base: at, sources };
65
+ }
66
+
67
+ /** The compose branches this clone knows on `remote`, as `{ branch, ref }`, by name. */
68
+ export function remoteComposeBranches(top: string, remote = "origin"): Array<{ branch: string; ref: string }> {
69
+ const prefix = `refs/remotes/${remote}/`;
70
+ const out = tryGit(top, ["for-each-ref", "--format=%(refname)", `${prefix}${COMPOSE_BRANCH_PREFIX}`]);
71
+ return (out ?? "")
72
+ .split("\n")
73
+ .filter(Boolean)
74
+ .map((ref) => ({ branch: ref.slice(prefix.length), ref }))
75
+ .sort((a, b) => (a.branch < b.branch ? -1 : 1));
76
+ }
77
+
78
+ /**
79
+ * What a commit of a compose branch carries beyond trunk: walk its first
80
+ * parents back to the first commit `trunkRef` reaches, which is the base,
81
+ * collecting the `Chant-Composed-From` trailers on the way. A commit a
82
+ * person made on the branch, such as a resolved conflict, carries none and
83
+ * is walked past. Null when `sha` is on trunk or no trunk commit is found.
84
+ */
85
+ export function compositionOnTrunk(top: string, sha: string, trunkRef: string, branch: string | null = null): Composition | null {
86
+ const onTrunk = (c: string) => tryGit(top, ["merge-base", "--is-ancestor", c, trunkRef]) !== null;
87
+ if (onTrunk(sha)) return null;
88
+ const sources: string[] = [];
89
+ let at: string | null = sha;
90
+ while (at && !onTrunk(at)) {
91
+ sources.unshift(...composedFrom(top, at));
92
+ at = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
93
+ }
94
+ return at ? { branch, base: at, sources } : null;
95
+ }
96
+
97
+ /** The pushed compose branch `sha` is a first-parent commit of, as this clone last fetched it, or null. */
98
+ export function composeBranchContaining(top: string, sha: string, remote = "origin"): { branch: string; ref: string } | null {
99
+ for (const b of remoteComposeBranches(top, remote)) {
100
+ const line = tryGit(top, ["rev-list", "--first-parent", b.ref]);
101
+ if (line?.split("\n").includes(sha)) return b;
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /** The composed commits on the pushed compose branches that pick `trunkSha`. */
107
+ export function composedIncluding(top: string, trunkSha: string, remote = "origin"): Array<{ branch: string; commit: string }> {
108
+ const out: Array<{ branch: string; commit: string }> = [];
109
+ for (const b of remoteComposeBranches(top, remote)) {
110
+ const tip = tryGit(top, ["rev-parse", "--verify", "--quiet", `${b.ref}^{commit}`]);
111
+ if (!tip) continue;
112
+ const composition = compositionOf(top, tip, b.branch);
113
+ if (!composition?.sources.includes(trunkSha)) continue;
114
+ // The composed commit that picked it: walk the composed commits for its trailer.
115
+ let at: string | null = tip;
116
+ while (at && at !== composition.base) {
117
+ if (composedFrom(top, at).includes(trunkSha)) {
118
+ out.push({ branch: b.branch, commit: at });
119
+ break;
120
+ }
121
+ at = tryGit(top, ["rev-parse", "--verify", "--quiet", `${at}^1`]);
122
+ }
123
+ }
124
+ return out;
125
+ }
@@ -108,6 +108,26 @@ describe("diffDeepObservation", () => {
108
108
  expect(result.unchanged).toEqual(["r"]);
109
109
  });
110
110
 
111
+ test("prepare rewrites each raw tree, both sides, before paths are taken (#4231)", () => {
112
+ const seen: string[] = [];
113
+ const rekey: DeepNormalizationHooks = {
114
+ prepare(tree, { side }) {
115
+ seen.push(side);
116
+ const ids = tree.ids as Record<string, unknown> | undefined;
117
+ if (!ids) return tree;
118
+ return { ...tree, ids: Object.fromEntries(Object.entries(ids).map(([k, v]) => [k.toLowerCase(), v])) };
119
+ },
120
+ };
121
+ const result = diffDeepObservation(
122
+ entities({ r: { entityType: "T", props: { ids: { "A/B": {} } } } }),
123
+ { resources: { r: { type: "T", properties: { ids: { "a/b": {} } } } }, unobserved: {} },
124
+ rekey,
125
+ );
126
+ expect(seen.sort()).toEqual(["declared", "live"]);
127
+ expect(result.drifted).toEqual([]);
128
+ expect(result.unchanged).toEqual(["r"]);
129
+ });
130
+
111
131
  test("a declared property at its default is still compared", () => {
112
132
  const result = diffDeepObservation(
113
133
  entities({ r: { entityType: "AWS::IAM::Role", props: { Path: "/" } } }),
@@ -139,8 +139,10 @@ export function diffDeepObservation(
139
139
 
140
140
  for (const [name, entity] of entities) {
141
141
  const liveEntity = live.resources[name];
142
- const declaredRaw = entity.props ?? {};
143
- const liveRaw = liveEntity?.properties ?? {};
142
+ const prepare = (tree: Record<string, unknown>, side: "declared" | "live") =>
143
+ hooks?.prepare ? hooks.prepare(tree, { entityType: entity.entityType, side }) : tree;
144
+ const declaredRaw = prepare(entity.props ?? {}, "declared");
145
+ const liveRaw = prepare(liveEntity?.properties ?? {}, "live");
144
146
  const declaredPaths = deepPathSet(declaredRaw);
145
147
  const livePaths = deepPathSet(liveRaw);
146
148
 
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Applied-green records (#4168 step 9): the green commit apply on merge last
3
+ * applied to an environment, kept on `chant/lifecycle` so the next apply
4
+ * measures its change from there.
5
+ *
6
+ * _green-applies/<env>.json one record per environment, rewritten by each apply that finishes
7
+ *
8
+ * Apply on merge applies green commits, not each merge. `pr-apply --green`
9
+ * plans at the green commit, measured from the one recorded here, and writes
10
+ * this record once everything applied. A workspace member's record sits under
11
+ * `_members/<member>/_green-applies/` through the same `ledgerDir` prefix the
12
+ * merge records use.
13
+ */
14
+
15
+ import { sortedJsonReplacer } from "../utils";
16
+ import { ledgerDir, readBlobFromPath, readPathSha, RefCASConflictError, writeLedgerFiles, type LedgerLocation } from "./git";
17
+
18
+ /** The directory on `chant/lifecycle` that holds applied-green records. */
19
+ export const GREEN_APPLIES_DIR = "_green-applies";
20
+
21
+ export const GREEN_APPLY_CONTRACT = 1;
22
+
23
+ export interface GreenApplyRecord {
24
+ contract: number;
25
+ env: string;
26
+ member?: string;
27
+ /** The green commit applied. */
28
+ commit: string;
29
+ /** The commit the change was measured from: the green commit applied before, or the first apply's base. */
30
+ base: string;
31
+ /** The change set's digest. */
32
+ digest: string;
33
+ /** `merges` when the merged changes' approvals covered the plan, `gate` when the combined plan was approved at its own gate. */
34
+ approval: "merges" | "gate";
35
+ approvedBy: string[];
36
+ /** The first-parent commits from `base` (exclusive) to `commit`, oldest first. */
37
+ merges: string[];
38
+ appliedAt: string;
39
+ }
40
+
41
+ function recordFile(env: string): string {
42
+ return `${env}.json`;
43
+ }
44
+
45
+ /** The last green commit applied to `env`, or null when none is recorded. Reads the local `chant/lifecycle` and never fetches. */
46
+ export async function readGreenApply(env: string, opts?: LedgerLocation): Promise<GreenApplyRecord | null> {
47
+ const text = await readBlobFromPath(GREEN_APPLIES_DIR, recordFile(env), opts);
48
+ if (text === null) return null;
49
+ try {
50
+ const doc = JSON.parse(text) as GreenApplyRecord;
51
+ return doc?.contract === GREEN_APPLY_CONTRACT && typeof doc.commit === "string" ? doc : null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** Write `record` for its environment. Does not push: call `pushLifecycleReport` (./git.ts) after. */
58
+ export async function writeGreenApply(record: GreenApplyRecord, opts?: LedgerLocation): Promise<string> {
59
+ const file = recordFile(record.env);
60
+ const content = `${JSON.stringify(record, sortedJsonReplacer)}\n`;
61
+ for (let attempt = 0; attempt < 3; attempt++) {
62
+ const prior = await readPathSha(GREEN_APPLIES_DIR, file, opts);
63
+ const dir = await ledgerDir(GREEN_APPLIES_DIR, opts);
64
+ try {
65
+ return await writeLedgerFiles([{ path: `${dir}/${file}`, content, expectPriorSha: prior }], `Applied green ${record.commit} to ${record.env}`, opts);
66
+ } catch (err) {
67
+ if (!(err instanceof RefCASConflictError)) throw err;
68
+ }
69
+ }
70
+ throw new Error(`could not write the applied-green record for ${record.env}: chant/lifecycle kept moving`);
71
+ }
@@ -45,6 +45,27 @@ export const NpmPublishSchema = z
45
45
  * `sbom.cyclonedx.json` it holds. `false` publishes without one.
46
46
  */
47
47
  sbom: sbomField.optional(),
48
+ /**
49
+ * `false` publishes each package straight to the reveal tag (`latest`
50
+ * unless the publish names another), with no `next` stage and no
51
+ * `npm dist-tag` move (#4040). For credentials that may publish but not
52
+ * move a dist-tag, such as npm trusted publishing without "Allow npm
53
+ * dist-tag" on the package's record (#3904). Default `true`.
54
+ */
55
+ stage: z.boolean().optional(),
56
+ /**
57
+ * A variable holding an npm token to retry with when a package's publish
58
+ * fails to authenticate (#4040), as chant's own publish-packages.sh does:
59
+ * a package whose trusted-publisher record is broken publishes with the
60
+ * token rather than failing the release. Unset or empty, there is no retry.
61
+ */
62
+ tokenEnv: envName.optional(),
63
+ /**
64
+ * Seconds the reveal waits for the registry to serve every version it
65
+ * published before installing them to audit (#4244). A registry accepts a
66
+ * publish before it serves it. Default 600.
67
+ */
68
+ registryWait: z.number().int().min(0).optional(),
48
69
  })
49
70
  .strict();
50
71
 
@@ -42,10 +42,13 @@
42
42
  * npm makes the first version of a new package `latest` whatever tag it is
43
43
  * published under, so a package's first release is visible when it stages.
44
44
  *
45
- * Credentials are npm's own. chant reads no npm token and writes no `.npmrc`:
46
- * npm authenticates with the registry's `_authToken` in the user's or the
47
- * CI's npm config (`actions/setup-node` writes one that reads
48
- * `NODE_AUTH_TOKEN`), or by trusted publishing. npm runs with the run's
45
+ * Credentials are npm's own: npm authenticates with the registry's
46
+ * `_authToken` in the user's or the CI's npm config (`actions/setup-node`
47
+ * writes one that reads `NODE_AUTH_TOKEN`), or by trusted publishing. The one
48
+ * exception is `tokenEnv` (#4040): when a publish fails to authenticate, the
49
+ * package is published again with that variable's token in a throwaway npm
50
+ * user config, never on the command line, and without `--provenance` if the
51
+ * registry refuses the attestation on the token path. npm runs with the run's
49
52
  * environment, and everything it prints is redacted before chant prints it.
50
53
  */
51
54
 
@@ -58,6 +61,9 @@ import type { NpmPublishDeclaration } from "./publish-declaration";
58
61
  import { checkReleaseDigest, fileDigest, isDir, memberFile, publishTargetKey } from "./publish-targets";
59
62
  import { NPM_STAGE_TAG, PublishTargetError, type PublishContext, type PublishDeps, type PublishItem, type PublishTarget } from "./release-publish";
60
63
 
64
+ /** npm's output when a publish could not authenticate, as publish-packages.sh matches it. */
65
+ const NPM_AUTH_FAILED = /ENEEDAUTH|E401|need auth/;
66
+
61
67
  interface NpmPackage {
62
68
  path: string;
63
69
  abs: string;
@@ -133,9 +139,9 @@ export function npmAuditSignaturesArgs(decl: Pick<NpmPublishDeclaration, "regist
133
139
  return ["audit", "signatures", ...(decl.registry ? ["--registry", decl.registry] : [])];
134
140
  }
135
141
 
136
- function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx.cwd): Promise<{ code: number; output: string[] }> {
142
+ function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx.cwd, env: NodeJS.ProcessEnv = ctx.env): Promise<{ code: number; output: string[] }> {
137
143
  return new Promise((resolvePromise, reject) => {
138
- const child = spawn(bin, args, { cwd, env: ctx.env, stdio: ["ignore", "pipe", "pipe"] });
144
+ const child = spawn(bin, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
139
145
  const output: string[] = [];
140
146
  let partial = { out: "", err: "" };
141
147
  const take = (key: "out" | "err") => (chunk: Buffer) => {
@@ -154,14 +160,26 @@ function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx
154
160
  });
155
161
  }
156
162
 
163
+ /** Seconds the reveal waits for the registry to serve what it published, by default (#4244). */
164
+ export const NPM_REGISTRY_WAIT_SECONDS = 600;
165
+ /** Between polls of the registry while it waits. */
166
+ export const NPM_REGISTRY_POLL_MS = 10_000;
167
+ /** npm's errors for a version the registry does not serve yet. */
168
+ const NPM_NOT_SERVED = /\b(ETARGET|ERESOLVE|notarget)\b|No matching version found/;
169
+
157
170
  export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): PublishTarget {
158
171
  const bin = deps.npm ?? "npm";
172
+ const sleep = deps.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
173
+ const now = deps.now ?? Date.now;
159
174
  const registry = decl.registry ?? "the registry npm's config names";
160
175
  const shown = (tag: string) => `${registry}, dist-tag ${tag}`;
161
176
  const revealTag = (ctx: PublishContext) => ctx.tag ?? "latest";
162
- const npm = async (ctx: PublishContext, args: string[], cwd?: string): Promise<{ code: number; output: string[] }> => {
177
+ // Under `stage: false` a package goes straight to the reveal tag, and the reveal moves no tag (#4040).
178
+ const staging = decl.stage !== false;
179
+ const stageTag = (ctx: PublishContext) => (staging ? NPM_STAGE_TAG : revealTag(ctx));
180
+ const npm = async (ctx: PublishContext, args: string[], cwd?: string, env?: NodeJS.ProcessEnv): Promise<{ code: number; output: string[] }> => {
163
181
  try {
164
- return await run(bin, args, ctx, cwd);
182
+ return await run(bin, args, ctx, cwd, env);
165
183
  } catch (err) {
166
184
  throw new PublishTargetError("publish-npm-failed", ctx.redactor.redact(`npm could not be run: ${err instanceof Error ? err.message : String(err)}`), "Install npm, or put it on PATH.");
167
185
  }
@@ -171,24 +189,86 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
171
189
  const r = await npm(ctx, ["view", `${pkg.name}@${pkg.version}`, "version", ...(decl.registry ? ["--registry", decl.registry] : [])]);
172
190
  return r.code === 0 && r.output.some((l) => l.trim() === pkg.version || l.trim() === JSON.stringify(pkg.version));
173
191
  };
192
+ /**
193
+ * The `tokenEnv` retry (#4040): a publish that failed to authenticate runs
194
+ * again with the token in a throwaway user config, and once more without
195
+ * `--provenance` when the token path refuses the attestation.
196
+ */
197
+ const withToken = async (ctx: PublishContext, pkg: NpmPackage, args: string[], failed: { code: number; output: string[] }) => {
198
+ const token = decl.tokenEnv ? ctx.env[decl.tokenEnv] : undefined;
199
+ if (!token || !failed.output.some((l) => NPM_AUTH_FAILED.test(l))) return failed;
200
+ ctx.log(`${pkg.name}@${pkg.version} could not authenticate; publishing again with ${decl.tokenEnv}`);
201
+ const dir = mkdtempSync(join(tmpdir(), "chant-npm-token-"));
202
+ try {
203
+ const userconfig = join(dir, ".npmrc");
204
+ const host = (decl.registry ?? "https://registry.npmjs.org/").replace(/^https?:/, "").replace(/\/?$/, "/");
205
+ writeFileSync(userconfig, `${host}:_authToken=${token}\n`, { mode: 0o600 });
206
+ const env = { ...ctx.env, NPM_CONFIG_USERCONFIG: userconfig };
207
+ let result = await npm(ctx, args, undefined, env);
208
+ for (const line of result.output) ctx.log(` ${line}`);
209
+ if (result.code !== 0 && args.includes("--provenance") && result.output.some((l) => /^npm (error|ERR!).*(provenance|attestation)/i.test(l))) {
210
+ ctx.log(`${pkg.name}@${pkg.version}: the registry refused provenance on the token path; publishing without an attestation`);
211
+ result = await npm(ctx, args.filter((a) => a !== "--provenance"), undefined, env);
212
+ for (const line of result.output) ctx.log(` ${line}`);
213
+ }
214
+ return result;
215
+ } finally {
216
+ rmSync(dir, { recursive: true, force: true });
217
+ }
218
+ };
174
219
  const read = (ctx: PublishContext) => decl.packages.map((p) => readPackage(ctx, p, decl.sbom));
175
220
  const extra = (pkg: NpmPackage) => ({ ...(pkg.digest ? { digest: pkg.digest } : {}), ...(pkg.sbom ? { sbom: pkg.sbom } : {}) });
176
221
  const plan = async (ctx: PublishContext): Promise<PublishItem[]> =>
177
- read(ctx).map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: `${shown(NPM_STAGE_TAG)}, then ${revealTag(ctx)}`, ...extra(pkg) }));
222
+ read(ctx).map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: staging ? `${shown(NPM_STAGE_TAG)}, then ${revealTag(ctx)}` : shown(revealTag(ctx)), ...extra(pkg) }));
178
223
  /**
179
224
  * In CI, install what was published into an empty directory and run
180
225
  * `npm audit signatures` there (#4049), which checks each package's
181
226
  * registry signature and provenance attestation.
182
227
  */
228
+ /**
229
+ * Wait until the registry serves every version (#4244): npm accepts a
230
+ * publish minutes before `npm view` and `npm install` find it, so an
231
+ * install straight after fails with ETARGET or ERESOLVE.
232
+ */
233
+ const served = async (ctx: PublishContext, pkgs: NpmPackage[]): Promise<void> => {
234
+ const wait = (decl.registryWait ?? NPM_REGISTRY_WAIT_SECONDS) * 1000;
235
+ const start = now();
236
+ let missing = pkgs;
237
+ for (;;) {
238
+ const still: NpmPackage[] = [];
239
+ for (const pkg of missing) if (!(await onRegistry(ctx, pkg))) still.push(pkg);
240
+ missing = still;
241
+ if (missing.length === 0) return;
242
+ const names = missing.map((p) => `${p.name}@${p.version}`).join(", ");
243
+ if (now() - start >= wait) {
244
+ throw new PublishTargetError(
245
+ "publish-npm-not-served",
246
+ `the registry did not serve ${names} within ${Math.round(wait / 1000)}s of the publish, so nothing was audited`,
247
+ "Each package was published. Once npm serves them, run the publish again: it publishes nothing twice and audits again. `registryWait` sets the wait.",
248
+ );
249
+ }
250
+ ctx.log(`waiting for the registry to serve ${names}`);
251
+ await sleep(NPM_REGISTRY_POLL_MS);
252
+ }
253
+ };
183
254
  const audit = async (ctx: PublishContext, pkgs: NpmPackage[]): Promise<void> => {
255
+ await served(ctx, pkgs);
184
256
  const dir = mkdtempSync(join(tmpdir(), "chant-npm-audit-"));
185
257
  try {
186
258
  const dependencies = Object.fromEntries(pkgs.map((p) => [p.name, p.version]));
187
259
  writeFileSync(join(dir, "package.json"), `${JSON.stringify({ name: "chant-publish-audit", version: "0.0.0", private: true, dependencies }, null, 2)}\n`);
188
260
  for (const args of [npmAuditInstallArgs(decl), npmAuditSignaturesArgs(decl)]) {
189
261
  ctx.log(`npm ${args.join(" ")}`);
190
- const result = await npm(ctx, args, dir);
262
+ let result = await npm(ctx, args, dir);
191
263
  for (const line of result.output) ctx.log(` ${line}`);
264
+ // `npm view` can answer before every mirror `npm install` reads does: wait once more, then install again.
265
+ if (result.code !== 0 && args[0] === "install" && result.output.some((l) => NPM_NOT_SERVED.test(l))) {
266
+ ctx.log("the registry does not serve every version to an install yet; waiting, then installing again");
267
+ await sleep(NPM_REGISTRY_POLL_MS);
268
+ await served(ctx, pkgs);
269
+ result = await npm(ctx, args, dir);
270
+ for (const line of result.output) ctx.log(` ${line}`);
271
+ }
192
272
  if (result.code !== 0) {
193
273
  throw new PublishTargetError(
194
274
  "publish-npm-audit-failed",
@@ -212,21 +292,22 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
212
292
  const pkgs = read(ctx);
213
293
  const items: PublishItem[] = [];
214
294
  for (const pkg of pkgs) {
215
- const item: PublishItem = { name: `${pkg.name}@${pkg.version}`, destination: shown(NPM_STAGE_TAG), ...extra(pkg) };
295
+ const item: PublishItem = { name: `${pkg.name}@${pkg.version}`, destination: shown(stageTag(ctx)), ...extra(pkg) };
216
296
  if (await onRegistry(ctx, pkg)) {
217
297
  ctx.log(`${item.name} is already on the registry; not published again`);
218
298
  items.push({ ...item, present: true });
219
299
  continue;
220
300
  }
221
- const args = npmPublishArgs(decl, pkg.abs, ctx);
301
+ const args = npmPublishArgs(decl, pkg.abs, ctx, stageTag(ctx));
222
302
  ctx.log(`npm publish ${pkg.path} ${args.slice(2).join(" ")}`);
223
- const result = await npm(ctx, args);
303
+ let result = await npm(ctx, args);
224
304
  for (const line of result.output) ctx.log(` ${line}`);
305
+ if (result.code !== 0) result = await withToken(ctx, pkg, args, result);
225
306
  if (result.code !== 0) {
226
307
  const done = items.map((i) => i.name);
227
308
  throw new PublishTargetError(
228
309
  "publish-npm-failed",
229
- `npm publish of ${item.name} exited ${result.code}${done.length > 0 ? `, after ${done.join(", ")} were staged under ${NPM_STAGE_TAG}` : ", before any package was staged"}: ${result.output.at(-1) ?? "npm printed nothing"}`,
310
+ `npm publish of ${item.name} exited ${result.code}${done.length > 0 ? `, after ${done.join(", ")} were ${staging ? "staged" : "published"} under ${stageTag(ctx)}` : `, before any package was ${staging ? "staged" : "published"}`}: ${result.output.at(-1) ?? "npm printed nothing"}`,
230
311
  );
231
312
  }
232
313
  items.push(item);
@@ -237,6 +318,11 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
237
318
  const tag = revealTag(ctx);
238
319
  const pkgs = read(ctx);
239
320
  const items: PublishItem[] = [];
321
+ if (!staging) {
322
+ // Already under the reveal tag since the stage; only the audit is left.
323
+ if (ctx.provenance.oidc) await audit(ctx, pkgs);
324
+ return { target: this.name, destination: this.destination, items: pkgs.map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: shown(tag), ...extra(pkg) })) };
325
+ }
240
326
  // The declared order: the first package is the set's core, and its tag moves first.
241
327
  for (const pkg of pkgs) {
242
328
  const args = npmDistTagArgs(decl, pkg.name, pkg.version, tag);
@@ -136,6 +136,10 @@ describe("npm", () => {
136
136
  ["dist-tag", "add", "@acme/core@1.4.0", "latest"],
137
137
  ["dist-tag", "add", "@acme/cli@1.4.0", "latest"],
138
138
  ["dist-tag", "add", "@acme/kit@1.4.0", "latest"],
139
+ // #4244: it first asks the registry for each version it published.
140
+ ["view", "@acme/core@1.4.0", "version"],
141
+ ["view", "@acme/cli@1.4.0", "version"],
142
+ ["view", "@acme/kit@1.4.0", "version"],
139
143
  // #4049: with an OIDC token, what was published is installed in an empty directory and its signatures audited.
140
144
  ["install", "--ignore-scripts", "--no-audit", "--no-fund"],
141
145
  ["audit", "signatures"],
@@ -166,6 +170,51 @@ describe("npm", () => {
166
170
  expect(readFakeNpmRegistry(log).packages["@acme/kit"]!.distTags).toEqual({ latest: "1.3.0", next: "1.4.0", beta: "1.4.0" });
167
171
  });
168
172
 
173
+ test("stage: false publishes each package straight to latest in the order declared and moves no dist-tag (#4040)", async () => {
174
+ const { dir, npm, log } = setup();
175
+ const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "kit.tgz"], access: "public", stage: false })], { npm });
176
+ const ctx = context(dir, { env: { PATH: process.env.PATH }, provenance: OIDC });
177
+ expect((await target!.plan(ctx))[0]!.destination).toBe("the registry npm's config names, dist-tag latest");
178
+ const staged = await target!.stage(ctx);
179
+ expect(latest(log)).toEqual({ "@acme/core": "1.4.0", "@acme/cli": "1.3.0", "@acme/kit": "1.4.0" });
180
+ const before = calls(log).length;
181
+ const done = await target!.reveal(ctx, staged);
182
+ expect(calls(log).filter((c) => c.args[0] === "publish").map((c) => c.args)).toEqual([
183
+ ["publish", join(dir, "packages/core"), "--tag", "latest", "--access", "public", "--provenance"],
184
+ ["publish", join(dir, "kit.tgz"), "--tag", "latest", "--access", "public", "--provenance"],
185
+ ]);
186
+ // The reveal only audits, once the registry serves both (#4244).
187
+ expect(calls(log).slice(before).map((c) => c.args[0])).toEqual(["view", "view", "install", "audit"]);
188
+ expect(done.items.map((i) => i.destination)).toEqual(Array(2).fill("the registry npm's config names, dist-tag latest"));
189
+ expect(readFakeNpmRegistry(log).packages["@acme/core"]!.distTags.next).toBeUndefined();
190
+ });
191
+
192
+ test("tokenEnv: a package that can't authenticate publishes again with the token from a throwaway user config, then without provenance if the token path refuses it (#4040)", async () => {
193
+ const { dir, npm, log } = setup();
194
+ const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "kit.tgz"], stage: false, tokenEnv: "NPM_TOKEN" })], { npm });
195
+ const env = { PATH: process.env.PATH, NPM_TOKEN: "npm_fallback99", FAKE_NPM_NEEDAUTH: "kit.tgz", FAKE_NPM_TOKEN_NO_PROVENANCE: "1" };
196
+ const ctx = context(dir, { env, provenance: OIDC });
197
+ await target!.stage(ctx);
198
+ const publishes = calls(log).filter((c) => c.args[0] === "publish") as Array<{ args: string[]; userconfig?: string }>;
199
+ expect(publishes.map((c) => [c.args[1], c.args.includes("--provenance"), Boolean(c.userconfig)])).toEqual([
200
+ [join(dir, "packages/core"), true, false],
201
+ [join(dir, "kit.tgz"), true, false],
202
+ [join(dir, "kit.tgz"), true, true],
203
+ [join(dir, "kit.tgz"), false, true],
204
+ ]);
205
+ expect(publishes[2]!.userconfig).toBe("//registry.npmjs.org/:_authToken=npm_fallback99\n");
206
+ expect(latest(log)["@acme/kit"]).toBe("1.4.0");
207
+ expect(ctx.lines.join("\n")).not.toContain("npm_fallback99");
208
+ });
209
+
210
+ test("tokenEnv: with the variable unset, or a failure that is not authentication, there is no retry", async () => {
211
+ const { dir, npm, log } = setup();
212
+ const [target] = await createPublishTargets([decl({ type: "npm", packages: ["kit.tgz"], stage: false, tokenEnv: "NPM_TOKEN" })], { npm });
213
+ await expect(target!.stage(context(dir, { env: { PATH: process.env.PATH, FAKE_NPM_NEEDAUTH: "kit.tgz" } }))).rejects.toThrow(/exited 1/);
214
+ await expect(target!.stage(context(dir, { env: { PATH: process.env.PATH, NPM_TOKEN: "t", FAKE_NPM_FAIL: "kit.tgz" } }))).rejects.toThrow(/403/);
215
+ expect(calls(log).filter((c) => c.args[0] === "publish")).toHaveLength(2);
216
+ });
217
+
169
218
  test("a stage that failed part way names what it staged and, run again, publishes only the rest; a tag move that fails names what moved", async () => {
170
219
  const { dir, npm, log } = setup();
171
220
  const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "packages/cli", "packages/kit"] })], { npm });
@@ -436,6 +485,65 @@ describe("SBOMs and provenance (#4049)", () => {
436
485
  expect(readFileSync(log, "utf-8")).toContain(`"args":["audit","signatures"]`);
437
486
  });
438
487
 
488
+ describe("npm: the reveal waits for the registry to serve what it published (#4244)", () => {
489
+ function lagged(env: Record<string, string>, registryWait?: number) {
490
+ const dir = temp();
491
+ writePackage(join(dir, "core"), "@acme/core", "1.0.0");
492
+ writePackage(join(dir, "cli"), "@acme/cli", "1.0.0");
493
+ const log = join(dir, "npm.log");
494
+ const npm = writeFakeNpm(join(dir, "bin"), log);
495
+ let clock = 0;
496
+ const sleeps: number[] = [];
497
+ const deps = { npm, now: () => clock, sleep: async (ms: number) => { sleeps.push(ms); clock += ms; } };
498
+ const ctx = context(dir, { env: { PATH: process.env.PATH, ...env }, provenance: OIDC });
499
+ const d = decl({ type: "npm", packages: ["core", "cli"], stage: false, ...(registryWait !== undefined ? { registryWait } : {}) });
500
+ return { log, deps, ctx, d, sleeps };
501
+ }
502
+ const installs = (log: string) => readFileSync(log, "utf-8").split("\n").filter((l) => l.includes(`"args":["install"`)).length;
503
+
504
+ test("every version served at once: no wait, one install", async () => {
505
+ const { log, deps, ctx, d, sleeps } = lagged({});
506
+ const [target] = await createPublishTargets([d], deps);
507
+ await target!.reveal(ctx, await target!.stage(ctx));
508
+ expect(sleeps).toEqual([]);
509
+ expect(installs(log)).toBe(1);
510
+ });
511
+
512
+ test("one version served late: it waits, names it, then installs once", async () => {
513
+ const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_LAG: "@acme/cli=2" });
514
+ const [target] = await createPublishTargets([d], deps);
515
+ await target!.reveal(ctx, await target!.stage(ctx));
516
+ expect(sleeps).toEqual([10_000, 10_000]);
517
+ expect(ctx.lines).toContain("waiting for the registry to serve @acme/cli@1.0.0");
518
+ expect(installs(log)).toBe(1);
519
+ });
520
+
521
+ test("a version never served: the wait ends with an error naming it, before any install", async () => {
522
+ const { log, deps, ctx, d } = lagged({ FAKE_NPM_LAG: "@acme/core=1000" }, 30);
523
+ const [target] = await createPublishTargets([d], deps);
524
+ const err = await refused(target!.reveal(ctx, await target!.stage(ctx)));
525
+ expect(err.code).toBe("publish-npm-not-served");
526
+ expect(err.message).toBe("the registry did not serve @acme/core@1.0.0 within 30s of the publish, so nothing was audited");
527
+ expect(installs(log)).toBe(0);
528
+ });
529
+
530
+ test("an install that fails with ETARGET waits and installs once more", async () => {
531
+ const { log, deps, ctx, d, sleeps } = lagged({ FAKE_NPM_INSTALL_ETARGET: "1" });
532
+ const [target] = await createPublishTargets([d], deps);
533
+ await target!.reveal(ctx, await target!.stage(ctx));
534
+ expect(installs(log)).toBe(2);
535
+ expect(sleeps).toEqual([10_000]);
536
+ });
537
+
538
+ test("a second ETARGET is the audit's failure, not a third install", async () => {
539
+ const { log, deps, ctx, d } = lagged({ FAKE_NPM_INSTALL_ETARGET: "2" });
540
+ const [target] = await createPublishTargets([d], deps);
541
+ const err = await refused(target!.reveal(ctx, await target!.stage(ctx)));
542
+ expect(err.code).toBe("publish-npm-audit-failed");
543
+ expect(installs(log)).toBe(2);
544
+ });
545
+ });
546
+
439
547
  test("OCI: the provenance statement carries the cut's signature, says a workstation's provenance is the signature, and is the same bytes every run; a declared SBOM is pushed as it is", async () => {
440
548
  const reg = await startFakeRegistry({ username: "robot", password: "pw-attach", token: "tok-attach" });
441
549
  try {