@intentius/chant 0.124.34 → 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 (169) 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 +2 -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.map +1 -1
  45. package/dist/lifecycle/__fixtures__/publish-fakes.js +5 -1
  46. package/dist/lifecycle/compose-refs.d.ts +52 -0
  47. package/dist/lifecycle/compose-refs.d.ts.map +1 -0
  48. package/dist/lifecycle/compose-refs.js +115 -0
  49. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  50. package/dist/lifecycle/deep-observe.js +3 -2
  51. package/dist/lifecycle/green-apply.d.ts +39 -0
  52. package/dist/lifecycle/green-apply.d.ts.map +1 -0
  53. package/dist/lifecycle/green-apply.js +51 -0
  54. package/dist/lifecycle/publish-declaration.d.ts +4 -0
  55. package/dist/lifecycle/publish-declaration.d.ts.map +1 -1
  56. package/dist/lifecycle/publish-declaration.js +15 -0
  57. package/dist/lifecycle/publish-npm.d.ts +7 -4
  58. package/dist/lifecycle/publish-npm.d.ts.map +1 -1
  59. package/dist/lifecycle/publish-npm.js +60 -13
  60. package/dist/lifecycle/release-compose.d.ts +91 -0
  61. package/dist/lifecycle/release-compose.d.ts.map +1 -0
  62. package/dist/lifecycle/release-compose.js +235 -0
  63. package/dist/lifecycle/release-cut.d.ts +8 -0
  64. package/dist/lifecycle/release-cut.d.ts.map +1 -1
  65. package/dist/lifecycle/release-cut.js +2 -0
  66. package/dist/lifecycle/release-publish.d.ts +2 -0
  67. package/dist/lifecycle/release-publish.d.ts.map +1 -1
  68. package/dist/lifecycle/release-publish.js +2 -1
  69. package/dist/lifecycle/release-store.d.ts +15 -0
  70. package/dist/lifecycle/release-store.d.ts.map +1 -1
  71. package/dist/lifecycle/release-store.js +5 -1
  72. package/dist/wave-runtime.mjs +225 -223
  73. package/dist/workspace/checks/ci-workflow.d.ts.map +1 -1
  74. package/dist/workspace/checks/ci-workflow.js +8 -1
  75. package/dist/workspace/ci-green-cli.d.ts.map +1 -1
  76. package/dist/workspace/ci-green-cli.js +5 -3
  77. package/dist/workspace/ci-green-workflow.d.ts +18 -2
  78. package/dist/workspace/ci-green-workflow.d.ts.map +1 -1
  79. package/dist/workspace/ci-green-workflow.js +27 -5
  80. package/dist/workspace/ci-green.d.ts +31 -0
  81. package/dist/workspace/ci-green.d.ts.map +1 -1
  82. package/dist/workspace/ci-green.js +78 -4
  83. package/dist/workspace/ci-run.d.ts +6 -0
  84. package/dist/workspace/ci-run.d.ts.map +1 -1
  85. package/dist/workspace/ci-run.js +2 -1
  86. package/dist/workspace/ci-serve.d.ts +94 -13
  87. package/dist/workspace/ci-serve.d.ts.map +1 -1
  88. package/dist/workspace/ci-serve.js +248 -30
  89. package/dist/workspace/ci-workflow.d.ts +4 -4
  90. package/dist/workspace/ci-workflow.d.ts.map +1 -1
  91. package/dist/workspace/ci-workflow.js +2 -2
  92. package/dist/workspace/delivery-metrics.d.ts +3 -1
  93. package/dist/workspace/delivery-metrics.d.ts.map +1 -1
  94. package/dist/workspace/delivery-metrics.js +12 -3
  95. package/dist/workspace/member-commands.d.ts.map +1 -1
  96. package/dist/workspace/member-commands.js +4 -0
  97. package/dist/workspace/nested-graph.d.ts.map +1 -1
  98. package/dist/workspace/nested-graph.js +1 -0
  99. package/dist/workspace/release-candidates.d.ts +28 -0
  100. package/dist/workspace/release-candidates.d.ts.map +1 -1
  101. package/dist/workspace/release-candidates.js +38 -2
  102. package/dist/workspace/release-candidates.schema.json +51 -0
  103. package/dist/workspace/releases.schema.json +30 -0
  104. package/package.json +1 -1
  105. package/src/cli/handlers/graph.ts +2 -2
  106. package/src/cli/handlers/pr-green.test.ts +230 -0
  107. package/src/cli/handlers/pr.ts +148 -66
  108. package/src/cli/handlers/promote.test.ts +34 -0
  109. package/src/cli/handlers/promote.ts +22 -8
  110. package/src/cli/handlers/release-compose.e2e.test.ts +133 -0
  111. package/src/cli/handlers/release.ts +112 -10
  112. package/src/cli/handlers/run.ts +12 -1
  113. package/src/cli/main.ts +19 -1
  114. package/src/cli/parse-args.ts +13 -0
  115. package/src/cli/registry.ts +14 -0
  116. package/src/codegen/release-wiring.test.ts +56 -9
  117. package/src/components/auto-release.test.ts +22 -0
  118. package/src/components/auto-release.ts +9 -2
  119. package/src/components/green-cover.test.ts +170 -0
  120. package/src/components/green-cover.ts +186 -0
  121. package/src/components/pr-pipeline.test.ts +14 -4
  122. package/src/components/pr-pipeline.ts +56 -5
  123. package/src/components/promote.test.ts +7 -0
  124. package/src/components/promote.ts +9 -4
  125. package/src/components/release-promote.test.ts +24 -1
  126. package/src/components/release-promote.ts +15 -0
  127. package/src/deep-observation.ts +8 -0
  128. package/src/graph-declared.test.ts +18 -0
  129. package/src/graph-declared.ts +8 -1
  130. package/src/graph-detail.test.ts +68 -1
  131. package/src/graph-detail.ts +29 -1
  132. package/src/graph-ir.ts +12 -4
  133. package/src/lifecycle/__fixtures__/publish-fakes.ts +5 -1
  134. package/src/lifecycle/compose-refs.ts +125 -0
  135. package/src/lifecycle/deep-observe.test.ts +20 -0
  136. package/src/lifecycle/deep-observe.ts +4 -2
  137. package/src/lifecycle/green-apply.ts +71 -0
  138. package/src/lifecycle/publish-declaration.ts +15 -0
  139. package/src/lifecycle/publish-npm.ts +55 -13
  140. package/src/lifecycle/publish-targets.test.ts +45 -0
  141. package/src/lifecycle/release-compose.test.ts +125 -0
  142. package/src/lifecycle/release-compose.ts +312 -0
  143. package/src/lifecycle/release-cut.ts +6 -0
  144. package/src/lifecycle/release-publish.ts +4 -1
  145. package/src/lifecycle/release-store.ts +16 -1
  146. package/src/workspace/checks/ci-workflow.test.ts +12 -2
  147. package/src/workspace/checks/ci-workflow.ts +7 -1
  148. package/src/workspace/ci-green-cli.ts +5 -3
  149. package/src/workspace/ci-green-workflow.test.ts +52 -0
  150. package/src/workspace/ci-green-workflow.ts +34 -5
  151. package/src/workspace/ci-green.test.ts +33 -0
  152. package/src/workspace/ci-green.ts +85 -4
  153. package/src/workspace/ci-run.test.ts +18 -13
  154. package/src/workspace/ci-run.ts +8 -1
  155. package/src/workspace/ci-serve-land.e2e.test.ts +138 -0
  156. package/src/workspace/ci-serve-land.test.ts +176 -0
  157. package/src/workspace/ci-serve.test.ts +77 -43
  158. package/src/workspace/ci-serve.ts +268 -34
  159. package/src/workspace/ci-workflow.ts +4 -4
  160. package/src/workspace/delivery-metrics.ts +12 -3
  161. package/src/workspace/graph-contract.test.ts +3 -2
  162. package/src/workspace/member-commands.test.ts +11 -4
  163. package/src/workspace/member-commands.ts +3 -0
  164. package/src/workspace/nested-graph.test.ts +4 -0
  165. package/src/workspace/nested-graph.ts +1 -0
  166. package/src/workspace/release-candidates.schema.json +51 -0
  167. package/src/workspace/release-candidates.test.ts +30 -3
  168. package/src/workspace/release-candidates.ts +54 -2
  169. package/src/workspace/releases.schema.json +30 -0
@@ -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,21 @@ 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(),
48
63
  })
49
64
  .strict();
50
65
 
@@ -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) => {
@@ -159,9 +165,12 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
159
165
  const registry = decl.registry ?? "the registry npm's config names";
160
166
  const shown = (tag: string) => `${registry}, dist-tag ${tag}`;
161
167
  const revealTag = (ctx: PublishContext) => ctx.tag ?? "latest";
162
- const npm = async (ctx: PublishContext, args: string[], cwd?: string): Promise<{ code: number; output: string[] }> => {
168
+ // Under `stage: false` a package goes straight to the reveal tag, and the reveal moves no tag (#4040).
169
+ const staging = decl.stage !== false;
170
+ const stageTag = (ctx: PublishContext) => (staging ? NPM_STAGE_TAG : revealTag(ctx));
171
+ const npm = async (ctx: PublishContext, args: string[], cwd?: string, env?: NodeJS.ProcessEnv): Promise<{ code: number; output: string[] }> => {
163
172
  try {
164
- return await run(bin, args, ctx, cwd);
173
+ return await run(bin, args, ctx, cwd, env);
165
174
  } catch (err) {
166
175
  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
176
  }
@@ -171,10 +180,37 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
171
180
  const r = await npm(ctx, ["view", `${pkg.name}@${pkg.version}`, "version", ...(decl.registry ? ["--registry", decl.registry] : [])]);
172
181
  return r.code === 0 && r.output.some((l) => l.trim() === pkg.version || l.trim() === JSON.stringify(pkg.version));
173
182
  };
183
+ /**
184
+ * The `tokenEnv` retry (#4040): a publish that failed to authenticate runs
185
+ * again with the token in a throwaway user config, and once more without
186
+ * `--provenance` when the token path refuses the attestation.
187
+ */
188
+ const withToken = async (ctx: PublishContext, pkg: NpmPackage, args: string[], failed: { code: number; output: string[] }) => {
189
+ const token = decl.tokenEnv ? ctx.env[decl.tokenEnv] : undefined;
190
+ if (!token || !failed.output.some((l) => NPM_AUTH_FAILED.test(l))) return failed;
191
+ ctx.log(`${pkg.name}@${pkg.version} could not authenticate; publishing again with ${decl.tokenEnv}`);
192
+ const dir = mkdtempSync(join(tmpdir(), "chant-npm-token-"));
193
+ try {
194
+ const userconfig = join(dir, ".npmrc");
195
+ const host = (decl.registry ?? "https://registry.npmjs.org/").replace(/^https?:/, "").replace(/\/?$/, "/");
196
+ writeFileSync(userconfig, `${host}:_authToken=${token}\n`, { mode: 0o600 });
197
+ const env = { ...ctx.env, NPM_CONFIG_USERCONFIG: userconfig };
198
+ let result = await npm(ctx, args, undefined, env);
199
+ for (const line of result.output) ctx.log(` ${line}`);
200
+ if (result.code !== 0 && args.includes("--provenance") && result.output.some((l) => /^npm (error|ERR!).*(provenance|attestation)/i.test(l))) {
201
+ ctx.log(`${pkg.name}@${pkg.version}: the registry refused provenance on the token path; publishing without an attestation`);
202
+ result = await npm(ctx, args.filter((a) => a !== "--provenance"), undefined, env);
203
+ for (const line of result.output) ctx.log(` ${line}`);
204
+ }
205
+ return result;
206
+ } finally {
207
+ rmSync(dir, { recursive: true, force: true });
208
+ }
209
+ };
174
210
  const read = (ctx: PublishContext) => decl.packages.map((p) => readPackage(ctx, p, decl.sbom));
175
211
  const extra = (pkg: NpmPackage) => ({ ...(pkg.digest ? { digest: pkg.digest } : {}), ...(pkg.sbom ? { sbom: pkg.sbom } : {}) });
176
212
  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) }));
213
+ read(ctx).map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: staging ? `${shown(NPM_STAGE_TAG)}, then ${revealTag(ctx)}` : shown(revealTag(ctx)), ...extra(pkg) }));
178
214
  /**
179
215
  * In CI, install what was published into an empty directory and run
180
216
  * `npm audit signatures` there (#4049), which checks each package's
@@ -212,21 +248,22 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
212
248
  const pkgs = read(ctx);
213
249
  const items: PublishItem[] = [];
214
250
  for (const pkg of pkgs) {
215
- const item: PublishItem = { name: `${pkg.name}@${pkg.version}`, destination: shown(NPM_STAGE_TAG), ...extra(pkg) };
251
+ const item: PublishItem = { name: `${pkg.name}@${pkg.version}`, destination: shown(stageTag(ctx)), ...extra(pkg) };
216
252
  if (await onRegistry(ctx, pkg)) {
217
253
  ctx.log(`${item.name} is already on the registry; not published again`);
218
254
  items.push({ ...item, present: true });
219
255
  continue;
220
256
  }
221
- const args = npmPublishArgs(decl, pkg.abs, ctx);
257
+ const args = npmPublishArgs(decl, pkg.abs, ctx, stageTag(ctx));
222
258
  ctx.log(`npm publish ${pkg.path} ${args.slice(2).join(" ")}`);
223
- const result = await npm(ctx, args);
259
+ let result = await npm(ctx, args);
224
260
  for (const line of result.output) ctx.log(` ${line}`);
261
+ if (result.code !== 0) result = await withToken(ctx, pkg, args, result);
225
262
  if (result.code !== 0) {
226
263
  const done = items.map((i) => i.name);
227
264
  throw new PublishTargetError(
228
265
  "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"}`,
266
+ `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
267
  );
231
268
  }
232
269
  items.push(item);
@@ -237,6 +274,11 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
237
274
  const tag = revealTag(ctx);
238
275
  const pkgs = read(ctx);
239
276
  const items: PublishItem[] = [];
277
+ if (!staging) {
278
+ // Already under the reveal tag since the stage; only the audit is left.
279
+ if (ctx.provenance.oidc) await audit(ctx, pkgs);
280
+ return { target: this.name, destination: this.destination, items: pkgs.map((pkg) => ({ name: `${pkg.name}@${pkg.version}`, destination: shown(tag), ...extra(pkg) })) };
281
+ }
240
282
  // The declared order: the first package is the set's core, and its tag moves first.
241
283
  for (const pkg of pkgs) {
242
284
  const args = npmDistTagArgs(decl, pkg.name, pkg.version, tag);
@@ -166,6 +166,51 @@ describe("npm", () => {
166
166
  expect(readFakeNpmRegistry(log).packages["@acme/kit"]!.distTags).toEqual({ latest: "1.3.0", next: "1.4.0", beta: "1.4.0" });
167
167
  });
168
168
 
169
+ test("stage: false publishes each package straight to latest in the order declared and moves no dist-tag (#4040)", async () => {
170
+ const { dir, npm, log } = setup();
171
+ const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "kit.tgz"], access: "public", stage: false })], { npm });
172
+ const ctx = context(dir, { env: { PATH: process.env.PATH }, provenance: OIDC });
173
+ expect((await target!.plan(ctx))[0]!.destination).toBe("the registry npm's config names, dist-tag latest");
174
+ const staged = await target!.stage(ctx);
175
+ expect(latest(log)).toEqual({ "@acme/core": "1.4.0", "@acme/cli": "1.3.0", "@acme/kit": "1.4.0" });
176
+ const before = calls(log).length;
177
+ const done = await target!.reveal(ctx, staged);
178
+ expect(calls(log).filter((c) => c.args[0] === "publish").map((c) => c.args)).toEqual([
179
+ ["publish", join(dir, "packages/core"), "--tag", "latest", "--access", "public", "--provenance"],
180
+ ["publish", join(dir, "kit.tgz"), "--tag", "latest", "--access", "public", "--provenance"],
181
+ ]);
182
+ // The reveal only audits.
183
+ expect(calls(log).slice(before).map((c) => c.args[0])).toEqual(["install", "audit"]);
184
+ expect(done.items.map((i) => i.destination)).toEqual(Array(2).fill("the registry npm's config names, dist-tag latest"));
185
+ expect(readFakeNpmRegistry(log).packages["@acme/core"]!.distTags.next).toBeUndefined();
186
+ });
187
+
188
+ 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 () => {
189
+ const { dir, npm, log } = setup();
190
+ const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "kit.tgz"], stage: false, tokenEnv: "NPM_TOKEN" })], { npm });
191
+ const env = { PATH: process.env.PATH, NPM_TOKEN: "npm_fallback99", FAKE_NPM_NEEDAUTH: "kit.tgz", FAKE_NPM_TOKEN_NO_PROVENANCE: "1" };
192
+ const ctx = context(dir, { env, provenance: OIDC });
193
+ await target!.stage(ctx);
194
+ const publishes = calls(log).filter((c) => c.args[0] === "publish") as Array<{ args: string[]; userconfig?: string }>;
195
+ expect(publishes.map((c) => [c.args[1], c.args.includes("--provenance"), Boolean(c.userconfig)])).toEqual([
196
+ [join(dir, "packages/core"), true, false],
197
+ [join(dir, "kit.tgz"), true, false],
198
+ [join(dir, "kit.tgz"), true, true],
199
+ [join(dir, "kit.tgz"), false, true],
200
+ ]);
201
+ expect(publishes[2]!.userconfig).toBe("//registry.npmjs.org/:_authToken=npm_fallback99\n");
202
+ expect(latest(log)["@acme/kit"]).toBe("1.4.0");
203
+ expect(ctx.lines.join("\n")).not.toContain("npm_fallback99");
204
+ });
205
+
206
+ test("tokenEnv: with the variable unset, or a failure that is not authentication, there is no retry", async () => {
207
+ const { dir, npm, log } = setup();
208
+ const [target] = await createPublishTargets([decl({ type: "npm", packages: ["kit.tgz"], stage: false, tokenEnv: "NPM_TOKEN" })], { npm });
209
+ await expect(target!.stage(context(dir, { env: { PATH: process.env.PATH, FAKE_NPM_NEEDAUTH: "kit.tgz" } }))).rejects.toThrow(/exited 1/);
210
+ await expect(target!.stage(context(dir, { env: { PATH: process.env.PATH, NPM_TOKEN: "t", FAKE_NPM_FAIL: "kit.tgz" } }))).rejects.toThrow(/403/);
211
+ expect(calls(log).filter((c) => c.args[0] === "publish")).toHaveLength(2);
212
+ });
213
+
169
214
  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
215
  const { dir, npm, log } = setup();
171
216
  const [target] = await createPublishTargets([decl({ type: "npm", packages: ["packages/core", "packages/cli", "packages/kit"] })], { npm });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `release compose` (#4168 4.3): chosen trunk commits cherry-picked onto a
3
+ * base, each with a `Chant-Composed-From` trailer, on a pushed
4
+ * `chant/compose/<name>` branch, appended to and never rewritten.
5
+ */
6
+
7
+ import { describe, test, expect } from "vitest";
8
+ import { withTestDir } from "@intentius/chant-test-utils";
9
+ import { spawnSync } from "node:child_process";
10
+ import { mkdirSync, writeFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { composeRelease, ComposeError } from "./release-compose";
13
+ import { composeBranchContaining, composedFrom, composedIncluding, compositionOf } from "./compose-refs";
14
+
15
+ function git(dir: string, ...args: string[]): string {
16
+ const r = spawnSync("git", args, { cwd: dir, encoding: "utf-8" });
17
+ if (r.status !== 0) throw new Error(r.stderr);
18
+ return r.stdout.trim();
19
+ }
20
+
21
+ /** A clone of a bare remote with trunk: base, then #1 (a.txt), #2 (b.txt, work w-2), #3 (a.txt again). */
22
+ function trunk(dir: string): { clone: string; remote: string; base: string; c1: string; c2: string; c3: string } {
23
+ const remote = join(dir, "remote.git");
24
+ const clone = join(dir, "clone");
25
+ git(dir, "init", "-q", "--bare", "-b", "main", remote);
26
+ mkdirSync(clone);
27
+ for (const args of [["init", "-q", "-b", "main"], ["config", "user.email", "t@chant.dev"], ["config", "user.name", "T"], ["remote", "add", "origin", remote]]) git(clone, ...args);
28
+ const commit = (file: string, text: string, message: string): string => {
29
+ writeFileSync(join(clone, file), text);
30
+ git(clone, "add", file);
31
+ git(clone, "commit", "-q", "-m", message);
32
+ return git(clone, "rev-parse", "HEAD");
33
+ };
34
+ const base = commit("a.txt", "a0\n", "base");
35
+ const c1 = commit("a.txt", "a1\n", "fix a (#1)");
36
+ const c2 = commit("b.txt", "b\n", "add b (#2)\n\nChant-Record: work:w-2");
37
+ const c3 = commit("a.txt", "a3\n", "rework a (#3)");
38
+ git(clone, "push", "-q", "origin", "main");
39
+ git(clone, "fetch", "-q", "origin");
40
+ return { clone, remote, base, c1, c2, c3 };
41
+ }
42
+
43
+ const TRUNK = { remote: "origin", branch: "main" };
44
+
45
+ describe("composeRelease", () => {
46
+ test("picks the chosen changes in trunk order onto the base, with trailers, and pushes the branch", async () => {
47
+ await withTestDir(async (dir) => {
48
+ const t = trunk(dir);
49
+ const dry = await composeRelease({ cwd: t.clone, name: "hotfix", base: t.base, prs: [], works: ["w-2"], yes: false, trunk: TRUNK });
50
+ expect(dry.written).toBe(false);
51
+ expect(dry.picked.map((s) => s.commit)).toEqual([t.c2]);
52
+ expect(git(t.clone, "branch", "--list", "chant/compose/hotfix")).toBe("");
53
+
54
+ const r = await composeRelease({ cwd: t.clone, name: "hotfix", base: t.base, prs: [2, 1], works: [], yes: true, trunk: TRUNK });
55
+ expect(r.picked.map((s) => s.commit)).toEqual([t.c1, t.c2]);
56
+ expect(r.picked[1].work).toEqual(["w-2"]);
57
+ expect(r.written).toBe(true);
58
+ expect(r.pushed).toBe(true);
59
+ expect(git(t.remote, "rev-parse", "refs/heads/chant/compose/hotfix")).toBe(r.tip);
60
+ expect(composedFrom(t.clone, r.tip)).toEqual([t.c2]);
61
+ expect(compositionOf(t.clone, r.tip)).toEqual({ branch: null, base: t.base, sources: [t.c1, t.c2] });
62
+ expect(composeBranchContaining(t.clone, r.tip)?.branch).toBe("chant/compose/hotfix");
63
+ expect(composedIncluding(t.clone, t.c1)).toEqual([{ branch: "chant/compose/hotfix", commit: git(t.clone, "rev-parse", `${r.tip}^`) }]);
64
+ expect(git(t.clone, "show", `${r.tip}:a.txt`)).toBe("a1");
65
+ });
66
+ });
67
+
68
+ test("appends to the branch, skips what it carries, and refuses another base", async () => {
69
+ await withTestDir(async (dir) => {
70
+ const t = trunk(dir);
71
+ const first = await composeRelease({ cwd: t.clone, name: "x", base: t.base, prs: [1], works: [], yes: true, trunk: TRUNK });
72
+ const again = await composeRelease({ cwd: t.clone, name: "x", prs: [1, 2], works: [], yes: true, trunk: TRUNK });
73
+ expect(again.from).toBe(first.tip);
74
+ expect(again.already.map((s) => s.commit)).toEqual([t.c1]);
75
+ expect(again.picked.map((s) => s.commit)).toEqual([t.c2]);
76
+ expect(git(t.clone, "rev-parse", `${again.tip}^`)).toBe(first.tip);
77
+ await expect(composeRelease({ cwd: t.clone, name: "x", base: t.c1, prs: [3], works: [], yes: true, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-base-differs" });
78
+ });
79
+ });
80
+
81
+ test("stops on a conflict, names the change and the file, and writes nothing", async () => {
82
+ await withTestDir(async (dir) => {
83
+ const t = trunk(dir);
84
+ const err = await composeRelease({ cwd: t.clone, name: "y", base: t.base, prs: [3], works: [], yes: true, trunk: TRUNK }).catch((e: unknown) => e);
85
+ expect(err).toBeInstanceOf(ComposeError);
86
+ expect((err as ComposeError).code).toBe("compose-conflict");
87
+ expect((err as ComposeError).conflict).toMatchObject({ commit: t.c3, files: ["a.txt"] });
88
+ expect((err as ComposeError).message).toContain("#3");
89
+ expect(git(t.clone, "branch", "--list", "chant/compose/y")).toBe("");
90
+ expect(spawnSync("git", ["rev-parse", "--verify", "--quiet", "refs/heads/chant/compose/y"], { cwd: t.remote }).status).not.toBe(0);
91
+ expect(git(t.clone, "worktree", "list").split("\n")).toHaveLength(1);
92
+ });
93
+ });
94
+
95
+ test("refuses a change trunk does not have, a bad name, and no base for a new branch", async () => {
96
+ await withTestDir(async (dir) => {
97
+ const t = trunk(dir);
98
+ await expect(composeRelease({ cwd: t.clone, name: "z", base: t.base, prs: [9], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-source-unknown" });
99
+ await expect(composeRelease({ cwd: t.clone, name: "../z", base: t.base, prs: [1], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-name-invalid" });
100
+ await expect(composeRelease({ cwd: t.clone, name: "z", prs: [1], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-base-unknown" });
101
+ await expect(composeRelease({ cwd: t.clone, name: "z", base: t.base, prs: [], works: [], yes: false, trunk: TRUNK })).rejects.toMatchObject({ code: "compose-nothing-chosen" });
102
+ });
103
+ });
104
+ });
105
+
106
+ describe("composeRelease after a hand-made commit", () => {
107
+ test("keeps the branch's base when its tip carries no trailer", async () => {
108
+ await withTestDir(async (dir) => {
109
+ const t = trunk(dir);
110
+ const first = await composeRelease({ cwd: t.clone, name: "h", base: t.base, prs: [1], works: [], yes: true, trunk: TRUNK });
111
+ // A person resolves something on the branch and pushes it.
112
+ git(t.clone, "checkout", "-q", "chant/compose/h");
113
+ writeFileSync(join(t.clone, "c.txt"), "by hand\n");
114
+ git(t.clone, "add", "c.txt");
115
+ git(t.clone, "commit", "-q", "-m", "by hand");
116
+ git(t.clone, "push", "-q", "origin", "chant/compose/h");
117
+ git(t.clone, "checkout", "-q", "main");
118
+ const next = await composeRelease({ cwd: t.clone, name: "h", base: t.base, prs: [1, 2], works: [], yes: true, trunk: TRUNK });
119
+ expect(next.base).toBe(t.base);
120
+ expect(next.already.map((s) => s.commit)).toEqual([t.c1]);
121
+ expect(next.picked.map((s) => s.commit)).toEqual([t.c2]);
122
+ expect(git(t.clone, "rev-parse", `${next.tip}~2`)).toBe(first.tip);
123
+ });
124
+ });
125
+ });