@intentius/chant 0.124.35 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant",
3
- "version": "0.124.35",
3
+ "version": "0.124.36",
4
4
  "description": "Declarative infrastructure-as-code toolkit — TypeScript on Node.js",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://intentius.io/chant",
@@ -160,6 +160,9 @@ describe("chant release publish with targets", () => {
160
160
  ["publish", join(dir, "packages/cli"), "--tag", "next", "--access", "public", "--provenance"],
161
161
  ["dist-tag", "add", "@acme/core@1.0.0", "latest"],
162
162
  ["dist-tag", "add", "@acme/cli@1.0.0", "latest"],
163
+ // #4244: the reveal waits until the registry serves each version it published.
164
+ ["view", "@acme/core@1.0.0", "version"],
165
+ ["view", "@acme/cli@1.0.0", "version"],
163
166
  // #4049: in CI, what was published is installed in an empty directory and its signatures and attestations audited.
164
167
  ["install", "--ignore-scripts", "--no-audit", "--no-fund"],
165
168
  ["audit", "signatures"],
@@ -212,7 +215,7 @@ describe("chant release publish with targets", () => {
212
215
  const moved = await chant(dir, ["release", "publish", "v1", "--yes"], { ...path, ...secrets, ...oidc });
213
216
  expect(moved.code).toBe(1);
214
217
  expect(moved.stderr).toMatch(/\[release-head-mismatch\]/);
215
- expect(calls()).toHaveLength(8);
218
+ expect(calls()).toHaveLength(10);
216
219
  },
217
220
  240_000,
218
221
  );
@@ -155,6 +155,11 @@ export function seedFakeNpmRegistry(log: string, registry: FakeNpmRegistry): voi
155
155
  * its registry at that version, and installs nothing.
156
156
  * - `audit signatures` (#4049) prints what npm prints for verified
157
157
  * signatures and attestations, and exits 1 when `$FAKE_NPM_AUDIT_FAIL` is set.
158
+ * - `$FAKE_NPM_LAG` (#4244), `<name>=<n>[,<name>=<n>]`: after a publish of
159
+ * that name, the next `n` views of the version find nothing, as a registry
160
+ * that accepted a publish but does not serve it yet.
161
+ * - `$FAKE_NPM_INSTALL_ETARGET` (#4244), `<n>`: the first `n` installs fail
162
+ * with ETARGET.
158
163
  */
159
164
  export function writeFakeNpm(binDir: string, log: string): string {
160
165
  mkdirSync(binDir, { recursive: true });
@@ -186,6 +191,10 @@ if (args[0] === "publish") {
186
191
  const p = reg.packages[name] || (reg.packages[name] = { versions: [], distTags: {} });
187
192
  if (p.versions.includes(version)) { console.error("npm error 403 You cannot publish over the previously published versions: " + version); process.exit(1); }
188
193
  p.versions.push(version);
194
+ for (const pair of (process.env.FAKE_NPM_LAG || "").split(",").filter(Boolean)) {
195
+ const eq = pair.lastIndexOf("=");
196
+ if (pair.slice(0, eq) === name) (p.lag || (p.lag = {}))[version] = Number(pair.slice(eq + 1));
197
+ }
189
198
  p.distTags[flag("--tag") || "latest"] = version;
190
199
  if (!p.distTags.latest) p.distTags.latest = version;
191
200
  save();
@@ -194,7 +203,8 @@ if (args[0] === "publish") {
194
203
  const [name, version] = split(args[1]);
195
204
  const p = reg.packages[name];
196
205
  if (!p) { console.error("npm error 404 Not Found - " + name); process.exit(1); }
197
- if (p.versions.includes(version)) console.log(version);
206
+ if (p.lag && p.lag[version] > 0) { p.lag[version] -= 1; save(); }
207
+ else if (p.versions.includes(version)) console.log(version);
198
208
  } else if (args[0] === "dist-tag" && args[1] === "add") {
199
209
  const [name, version] = split(args[2]);
200
210
  if (process.env.FAKE_NPM_FAIL_TAG && name.includes(process.env.FAKE_NPM_FAIL_TAG)) { console.error("npm error 403 Forbidden - no permission to change dist-tags"); process.exit(1); }
@@ -204,6 +214,10 @@ if (args[0] === "publish") {
204
214
  save();
205
215
  console.log("+" + args[3] + ": " + args[2]);
206
216
  } else if (args[0] === "install") {
217
+ if (Number(process.env.FAKE_NPM_INSTALL_ETARGET || 0) > (reg.installs || 0)) {
218
+ reg.installs = (reg.installs || 0) + 1; save();
219
+ console.error("npm error code ETARGET"); console.error("npm error notarget No matching version found for a package"); process.exit(1);
220
+ }
207
221
  const deps = JSON.parse(fs.readFileSync("package.json", "utf-8")).dependencies || {};
208
222
  for (const [name, version] of Object.entries(deps)) {
209
223
  const p = reg.packages[name];
@@ -60,6 +60,12 @@ export const NpmPublishSchema = z
60
60
  * token rather than failing the release. Unset or empty, there is no retry.
61
61
  */
62
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(),
63
69
  })
64
70
  .strict();
65
71
 
@@ -160,8 +160,17 @@ function run(bin: string, args: string[], ctx: PublishContext, cwd: string = ctx
160
160
  });
161
161
  }
162
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
+
163
170
  export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): PublishTarget {
164
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;
165
174
  const registry = decl.registry ?? "the registry npm's config names";
166
175
  const shown = (tag: string) => `${registry}, dist-tag ${tag}`;
167
176
  const revealTag = (ctx: PublishContext) => ctx.tag ?? "latest";
@@ -216,15 +225,50 @@ export function npmTarget(decl: NpmPublishDeclaration, deps: PublishDeps): Publi
216
225
  * `npm audit signatures` there (#4049), which checks each package's
217
226
  * registry signature and provenance attestation.
218
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
+ };
219
254
  const audit = async (ctx: PublishContext, pkgs: NpmPackage[]): Promise<void> => {
255
+ await served(ctx, pkgs);
220
256
  const dir = mkdtempSync(join(tmpdir(), "chant-npm-audit-"));
221
257
  try {
222
258
  const dependencies = Object.fromEntries(pkgs.map((p) => [p.name, p.version]));
223
259
  writeFileSync(join(dir, "package.json"), `${JSON.stringify({ name: "chant-publish-audit", version: "0.0.0", private: true, dependencies }, null, 2)}\n`);
224
260
  for (const args of [npmAuditInstallArgs(decl), npmAuditSignaturesArgs(decl)]) {
225
261
  ctx.log(`npm ${args.join(" ")}`);
226
- const result = await npm(ctx, args, dir);
262
+ let result = await npm(ctx, args, dir);
227
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
+ }
228
272
  if (result.code !== 0) {
229
273
  throw new PublishTargetError(
230
274
  "publish-npm-audit-failed",
@@ -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"],
@@ -179,8 +183,8 @@ describe("npm", () => {
179
183
  ["publish", join(dir, "packages/core"), "--tag", "latest", "--access", "public", "--provenance"],
180
184
  ["publish", join(dir, "kit.tgz"), "--tag", "latest", "--access", "public", "--provenance"],
181
185
  ]);
182
- // The reveal only audits.
183
- expect(calls(log).slice(before).map((c) => c.args[0])).toEqual(["install", "audit"]);
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"]);
184
188
  expect(done.items.map((i) => i.destination)).toEqual(Array(2).fill("the registry npm's config names, dist-tag latest"));
185
189
  expect(readFakeNpmRegistry(log).packages["@acme/core"]!.distTags.next).toBeUndefined();
186
190
  });
@@ -481,6 +485,65 @@ describe("SBOMs and provenance (#4049)", () => {
481
485
  expect(readFileSync(log, "utf-8")).toContain(`"args":["audit","signatures"]`);
482
486
  });
483
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
+
484
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 () => {
485
548
  const reg = await startFakeRegistry({ username: "robot", password: "pw-attach", token: "tok-attach" });
486
549
  try {
@@ -129,6 +129,10 @@ export interface PublishDeps {
129
129
  fetch?: PublishFetch;
130
130
  /** The npm executable. Default: `npm` on `PATH`. */
131
131
  npm?: string;
132
+ /** How a target waits between polls of a registry (#4244). Default: a timer. */
133
+ sleep?: (ms: number) => Promise<void>;
134
+ /** The clock a target's wait reads (#4244). Default: `Date.now`. */
135
+ now?: () => number;
132
136
  }
133
137
 
134
138
  /** The subset of the WHATWG `fetch` the targets use. */