@gjsify/cli 0.46.0 → 0.47.0

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.
@@ -660,6 +660,17 @@ function describePublishFailure(pub) {
660
660
  return 'OIDC/token auth failed';
661
661
  case 'diagnostic':
662
662
  return `HTTP 404 (${pub.diag.reason})`;
663
+ case 'publish-unconfirmed':
664
+ // npm claimed the version and the registry does not serve it. Counted
665
+ // as a FAILURE here on purpose: onboard's next step is `configureTrust`
666
+ // on a package it just decided exists. `onboard` passes `tolerate:
667
+ // true`, so `already-published` is the claim it meets on a RE-RUN —
668
+ // and #1407 is exactly a re-run that counted a half-stored publish
669
+ // as done.
670
+ return (`npm ${pub.claim === 'already-published' ? 'reported it already published' : 'accepted the upload'} ` +
671
+ `(HTTP ${pub.putStatus}) but ${pub.name}@${pub.version} did not ` +
672
+ `resolve on ${pub.registry} after ${pub.readback.attempts} probe(s) over ` +
673
+ `${(pub.readback.elapsedMs / 1000).toFixed(1)}s`);
663
674
  case 'error':
664
675
  return `HTTP ${pub.status} ${pub.statusText}`;
665
676
  default:
@@ -1,5 +1,6 @@
1
1
  import type { Command } from '../types/index.js';
2
2
  import { type Diagnose404Result } from '../utils/publish-diagnose.js';
3
+ import { type ReadbackResult } from '../utils/publish-readback.js';
3
4
  import { OtpProvider } from '../utils/npm-otp.js';
4
5
  interface PublishOptions {
5
6
  path?: string;
@@ -8,6 +9,8 @@ interface PublishOptions {
8
9
  otp?: string;
9
10
  'tolerate-republish'?: boolean;
10
11
  'tolerate-untrusted-new'?: boolean;
12
+ 'verify-timeout'?: number;
13
+ 'verify-defer'?: boolean;
11
14
  provenance?: boolean;
12
15
  'dry-run'?: boolean;
13
16
  json?: boolean;
@@ -37,6 +40,13 @@ export interface PublishWorkspaceInput {
37
40
  otpProvider: OtpProvider;
38
41
  /** Send the cached code on the first PUT (publish `--otp`). */
39
42
  seedOtpFirst: boolean;
43
+ /**
44
+ * Polling budget for the post-PUT read-back, in ms. Default
45
+ * {@link DEFAULT_VERIFY_BUDGET_MS}; `0` skips the read-back entirely and the
46
+ * returned `published` outcome then carries no `readback` — an UNVERIFIED
47
+ * success, which is the pre-v0.46.0 behaviour and why this defaults on.
48
+ */
49
+ verifyBudgetMs?: number;
40
50
  }
41
51
  /** Discriminated result of {@link publishWorkspace}; the caller owns presentation. */
42
52
  export type PublishOutcome = {
@@ -49,12 +59,43 @@ export type PublishOutcome = {
49
59
  integrity: string;
50
60
  tag: string;
51
61
  registry: string;
62
+ /** The read-back that CONFIRMED it. `undefined` only when disabled. */
63
+ readback?: ReadbackResult;
64
+ }
65
+ /**
66
+ * npm returned 2xx and the registry does not serve the version. Its own
67
+ * outcome on purpose: not `published` (nothing is installable), not
68
+ * `republish-tolerated` (npm never claimed it existed), not `error` (the
69
+ * HTTP exchange succeeded). The v0.46.0 incident had no name at all — it
70
+ * came out as `+ name@version`, exit 0.
71
+ */
72
+ | {
73
+ ok: false;
74
+ action: 'publish-unconfirmed';
75
+ /**
76
+ * What npm claimed before the read-back disagreed: a 2xx (`accepted`)
77
+ * or a tolerated 409 (`already-published`). It selects the remediation
78
+ * sentence, which is the OPPOSITE one for a conflict — re-running
79
+ * answers the same 409.
80
+ */
81
+ claim: 'accepted' | 'already-published';
82
+ name: string;
83
+ version: string;
84
+ registry: string;
85
+ /** The PUT that was accepted. */
86
+ putStatus: number;
87
+ putStatusText: string;
88
+ putUrl: string;
89
+ payloadBytes: number;
90
+ readback: ReadbackResult;
52
91
  } | {
53
92
  ok: true;
54
93
  action: 'republish-tolerated';
55
94
  name: string;
56
95
  version: string;
57
96
  status: number;
97
+ /** The read-back that CONFIRMED the conflicting version. */
98
+ readback?: ReadbackResult;
58
99
  } | {
59
100
  ok: true;
60
101
  action: 'skipped-untrusted-new';
@@ -19,6 +19,12 @@
19
19
  // a sigstore signer that gjsify doesn't ship; we record the flag in the
20
20
  // publish manifest but don't sign yet. Surface a warning so callers know.
21
21
  // - --dry-run: pack only, no PUT.
22
+ // - --verify-timeout / --verify-defer: the post-PUT READ-BACK. npm's 2xx is an
23
+ // accepted write, not a durable one, so a successful PUT is confirmed by
24
+ // asking the registry for that exact version before `+ name@version` is
25
+ // printed. The incident, the measured lag distribution and the choice of
26
+ // window live in `utils/publish-readback.ts`; WHERE the read-back runs and
27
+ // what it costs is argued at its call site below.
22
28
  //
23
29
  // The request body matches npm's "publish" payload shape:
24
30
  // {
@@ -41,6 +47,7 @@ import { buildPublishHeaders, escapePackageName } from '../utils/publish-headers
41
47
  import { packWorkspace } from './pack.js';
42
48
  import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailableError } from '../utils/npm-oidc.js';
43
49
  import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
50
+ import { DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, verifyPublishedVersion, } from '../utils/publish-readback.js';
44
51
  import { loadNpmrc } from '../utils/load-npmrc.js';
45
52
  import { OtpProvider, withOtpRetry, isOtpChallenge } from '../utils/npm-otp.js';
46
53
  import { promptLine } from '../utils/prompt.js';
@@ -71,6 +78,16 @@ export const publishCommand = {
71
78
  description: 'Skip (exit 0) when OIDC token exchange returns `package not found` AND no fallback token is configured — i.e. a never-before-published `@scope/<name>` whose Trusted Publisher entry hasn\'t been set up on npmjs.com yet. Without this flag, one un-bootstrapped new package breaks the entire serialized `gjsify foreach publish` loop. Pair with `--tolerate-republish` in CI release workflows so a fresh-merged package gracefully skips its first CI publish, leaving the manual-bootstrap step to a maintainer (see AGENTS.md "New @gjsify/* package: first-publish + Trusted Publisher bootstrap").',
72
79
  type: 'boolean',
73
80
  default: false,
81
+ })
82
+ .option('verify-timeout', {
83
+ description: 'Seconds to keep asking the registry for the just-published version before giving up. npm answers 2xx to a write it has only ACCEPTED: in the v0.46.0 release 19 of 199 packages were recorded 56-252s after their 2xx and one was never recorded at all, so a shorter window turns normal queueing into a false red. `0` disables the read-back — for a registry with no packument read path.',
84
+ type: 'number',
85
+ default: DEFAULT_VERIFY_BUDGET_MS / 1000,
86
+ })
87
+ .option('verify-defer', {
88
+ description: 'Report an unconfirmed read-back and exit 0 instead of 1. ONLY for a caller that re-checks the same set afterwards — `gjsify run npm:publish` passes it because `scripts/verify-published-closure.mjs` sweeps the whole roster at the end of release.yml. Everywhere else an unconfirmed publish must fail the job that made it.',
89
+ type: 'boolean',
90
+ default: false,
74
91
  })
75
92
  .option('provenance', {
76
93
  description: "Pass-through flag — recorded in the payload but no signing happens (gjsify doesn't ship a sigstore signer yet).",
@@ -107,6 +124,17 @@ export const publishCommand = {
107
124
  const otp = args.otp;
108
125
  const tolerate = args['tolerate-republish'] === true;
109
126
  const tolerateUntrustedNew = args['tolerate-untrusted-new'] === true;
127
+ const verifySeconds = args['verify-timeout'] ?? DEFAULT_VERIFY_BUDGET_MS / 1000;
128
+ // A non-number reaches here as NaN (yargs coerces `--verify-timeout abc`
129
+ // silently), and NaN would fall through every `> 0` test and turn the
130
+ // read-back OFF without saying so — the failure mode this whole change
131
+ // exists to remove. Refuse it instead.
132
+ if (!Number.isFinite(verifySeconds) || verifySeconds < 0) {
133
+ console.error(`gjsify publish: --verify-timeout must be a non-negative number of seconds, got ${JSON.stringify(args['verify-timeout'])}`);
134
+ return process.exit(2);
135
+ }
136
+ const verifyBudgetMs = Math.round(verifySeconds * 1000);
137
+ const deferUnconfirmed = args['verify-defer'] === true;
110
138
  const provenance = args.provenance === true;
111
139
  const dryRun = args['dry-run'] === true;
112
140
  const checkTrustedOnly = args['check-trusted'] === true;
@@ -219,8 +247,9 @@ export const publishCommand = {
219
247
  json: args.json === true,
220
248
  otpProvider,
221
249
  seedOtpFirst: otp !== undefined,
250
+ verifyBudgetMs,
222
251
  });
223
- reportPublishOutcome(outcome, args.json === true);
252
+ reportPublishOutcome(outcome, args.json === true, deferUnconfirmed);
224
253
  },
225
254
  };
226
255
  /**
@@ -329,12 +358,128 @@ export async function publishWorkspace(input) {
329
358
  return fetch(url, { method: 'PUT', headers: reqHeaders, body: bodyStr });
330
359
  };
331
360
  const res = await withOtpRetry(doPut, otpProvider, { seedFirstAttempt: input.seedOtpFirst });
361
+ if (verbose) {
362
+ // The debug output used to print the request and then the `+` success
363
+ // line, so a 200 and a 201 were indistinguishable and no body was ever
364
+ // recorded — which is why reconstructing the v0.46.0 incident needed the
365
+ // registry rather than the log it already had. Print what npm answered.
366
+ // `clone()` because the error path below reads `res.text()` itself.
367
+ console.error(` response: ${res.status} ${res.statusText}`);
368
+ const peek = await res
369
+ .clone()
370
+ .text()
371
+ .catch(() => '');
372
+ if (peek)
373
+ console.error(` body: ${peek.slice(0, 300)}`);
374
+ }
375
+ /**
376
+ * Ask the registry for what we just PUT. `undefined` when the read-back is
377
+ * off (`--verify-timeout 0`).
378
+ *
379
+ * The PUT's own `authorization` goes with it: a registry that requires a
380
+ * credential to READ answers 401/403 to an anonymous packument GET, which is
381
+ * an `error` probe, so an intact publish to GitHub Packages or an
382
+ * authenticated Verdaccio would spend the whole budget and then report
383
+ * `publish-unconfirmed`. Same origin, same credential, one request later.
384
+ */
385
+ const readBack = async () => {
386
+ const budgetMs = input.verifyBudgetMs ?? DEFAULT_VERIFY_BUDGET_MS;
387
+ if (budgetMs <= 0)
388
+ return undefined;
389
+ return verifyPublishedVersion({
390
+ registry: registryClean,
391
+ name: packed.name,
392
+ version: packed.version,
393
+ budgetMs,
394
+ authorization: headers['authorization'],
395
+ log: verbose ? (m) => console.error(m) : undefined,
396
+ });
397
+ };
332
398
  // A surviving OTP challenge means the provider gave up (no code entered /
333
399
  // non-interactive). Surface it so the caller can point at --otp.
334
400
  if (await isOtpChallenge(res)) {
335
401
  return { ok: false, action: 'otp-required', name: packed.name, version: packed.version, status: res.status };
336
402
  }
337
403
  if (res.ok) {
404
+ // WHERE THE READ-BACK BELONGS — per package, here, immediately.
405
+ //
406
+ // This path runs ~209 times per release (199 in the serial
407
+ // `foreach --exec` sweep behind `npm:publish`, plus the per-bundle
408
+ // single-package jobs), so its cost is measured rather than assumed:
409
+ //
410
+ // CONFIRMED ON THE FIRST PROBE — 180 of 199 packages in v0.46.0. One
411
+ // abbreviated-packument GET, 0.59 s against registry.npmjs.org, so
412
+ // ~2 min across the whole sweep against a publish job that ran 33 min.
413
+ //
414
+ // NOT CONFIRMED YET — the other 19. Here the read-back waits out
415
+ // npm's own write queue, and those residual lags SUM TO 2554 s: a
416
+ // sweep that waited them all out with the 300 s default would have
417
+ // added ~43 min to that 33 min job. That single number decides the
418
+ // shape below.
419
+ //
420
+ // A BATCHED END-OF-SWEEP CHECK CANNOT LIVE IN THIS FILE. `npm:publish`
421
+ // is `gjsify foreach --exec -- gjsify publish`, one CHILD PROCESS per
422
+ // package: there is no in-process hook after the last one to collect a
423
+ // pending set in. So the batched half already sits where it can —
424
+ // `scripts/verify-published-closure.mjs`, its own job at the end of
425
+ // `release.yml`, rounds and retries over the whole roster. It is what
426
+ // named the missing package in the incident.
427
+ //
428
+ // AND A FATAL READ-BACK INSIDE THAT SWEEP WOULD ABORT THE RELEASE, which
429
+ // is the argument the 2554 s only quantifies. `foreach --exec` THROWS on
430
+ // a non-zero child (`foreach.ts`: `exited with code ${result.code}`),
431
+ // and pack time has already rewritten every `workspace:*` to the exact
432
+ // released version — so one lagging package would stop the sweep
433
+ // mid-roster and leave a PARTIAL publish, the state
434
+ // `verify-published-closure.mjs` exists because it is worse than no
435
+ // publish at all. A deferral here is not leniency; it is the only shape
436
+ // that does not trade this incident for that one.
437
+ //
438
+ // Hence BOTH, split along that cost. The read-back runs per package
439
+ // everywhere and is FATAL by default — that is what turns the
440
+ // incident's green single-package job red, for up to 300 s of
441
+ // patience on a job that otherwise ran 59 s. The
442
+ // 199-package sweep passes `--verify-defer --verify-timeout 5`: it
443
+ // still CONFIRMS nine packages in ten and still names the suspect
444
+ // against the package that PUT it, but the minutes-long tail is left to
445
+ // the closure job that re-asks the same question over the same set —
446
+ // which had to be MADE able to answer it (`dist.tarball`, and a
447
+ // propagation retry that never fired for a roster-only name).
448
+ //
449
+ // What the deferred annotation is NOT is the ledger. At a 5 s budget
450
+ // every one of a release's ~19 lagging packages emits one, and GitHub
451
+ // renders only the first few annotations per step (documented as 10 per
452
+ // level; not verified from a run here). The closure job is the fatal
453
+ // half; the annotation points a human at the name.
454
+ const readback = await readBack();
455
+ if (readback?.confirmed && verbose) {
456
+ // The CONFIRMED half of the read-back is printed too, and it is not
457
+ // decoration: it is the only client-side measurement of when a
458
+ // version became RESOLVABLE, as against `time[version]`, which is
459
+ // when the registry recorded the write. The two are assumed equal by
460
+ // the 4.2-minute figure and the 300 s window, and nothing had ever
461
+ // measured the gap. The pre-registered comparison — and what each
462
+ // outcome would change — is in #1509's description; this line, under
463
+ // GJSIFY_PUBLISH_DEBUG (which release.yml sets), is its input. Note
464
+ // the value is quantised by the backoff grid, so it is the UPPER end
465
+ // of a bracket whose lower end is the previous probe.
466
+ console.error(` read-back: confirmed after ${readback.attempts} probe(s) in ${readback.elapsedMs} ms`);
467
+ }
468
+ if (readback && !readback.confirmed) {
469
+ return {
470
+ ok: false,
471
+ action: 'publish-unconfirmed',
472
+ claim: 'accepted',
473
+ name: packed.name,
474
+ version: packed.version,
475
+ registry: registryClean,
476
+ putStatus: res.status,
477
+ putStatusText: res.statusText,
478
+ putUrl: url,
479
+ payloadBytes: bodyStr.length,
480
+ readback,
481
+ };
482
+ }
338
483
  return {
339
484
  ok: true,
340
485
  action: 'published',
@@ -345,18 +490,50 @@ export async function publishWorkspace(input) {
345
490
  integrity: packed.integrity,
346
491
  tag,
347
492
  registry: registryClean,
493
+ readback,
348
494
  };
349
495
  }
350
496
  const text = await res.text().catch(() => '<no body>');
351
497
  // "version already published" — 409 Conflict, or 403 + "previously published".
498
+ //
499
+ // READ BACK HERE TOO, and the reason it was first written the other way is
500
+ // worth keeping: a 2xx is npm accepting a write it has not necessarily
501
+ // durably applied, whereas a 409 is npm ASSERTING the version exists and
502
+ // refusing to overwrite it — sounding like a positive statement about the
503
+ // registry's own state rather than a promise about a queued one. The
504
+ // incident's own recovery says otherwise. Attempt 3 re-PUT
505
+ // @gjsify/node-runtime-darwin-arm64 and was answered `409 already
506
+ // published` at 09:48:49.19, while that packument's `time["0.46.0"]` is
507
+ // 09:49:07.419 — 18 s LATER — and the closure job had found the name absent
508
+ // at 09:44:28. So the assertion can precede the version being served, and
509
+ // this is the path a `publish-unconfirmed` re-run always lands on: exempting
510
+ // it would leave the documented remediation reporting a success nothing
511
+ // checked. Cost is one GET, on a path that only runs on a re-publish.
352
512
  const isRepublishConflict = res.status === 409 || (res.status === 403 && /previously published/i.test(text));
353
513
  if (isRepublishConflict && tolerate) {
514
+ const readback = await readBack();
515
+ if (readback && !readback.confirmed) {
516
+ return {
517
+ ok: false,
518
+ action: 'publish-unconfirmed',
519
+ claim: 'already-published',
520
+ name: packed.name,
521
+ version: packed.version,
522
+ registry: registryClean,
523
+ putStatus: res.status,
524
+ putStatusText: res.statusText,
525
+ putUrl: url,
526
+ payloadBytes: bodyStr.length,
527
+ readback,
528
+ };
529
+ }
354
530
  return {
355
531
  ok: true,
356
532
  action: 'republish-tolerated',
357
533
  name: packed.name,
358
534
  version: packed.version,
359
535
  status: res.status,
536
+ readback,
360
537
  };
361
538
  }
362
539
  // 404 diagnostic (token-auth): disambiguate dead-token vs missing-package.
@@ -384,8 +561,13 @@ export async function publishWorkspace(input) {
384
561
  /**
385
562
  * Present a {@link PublishOutcome} on stdout/stderr and exit non-zero on
386
563
  * failure — the single-command `gjsify publish` behaviour, reproduced exactly.
564
+ *
565
+ * `deferUnconfirmed` (`--verify-defer`) affects exactly one outcome: it downgrades
566
+ * `publish-unconfirmed` from exit 1 to a printed warning, for a caller that
567
+ * re-checks the same set afterwards. It is policy, so it lives here and not in
568
+ * {@link publishWorkspace}, which stays a pure fact-finder.
387
569
  */
388
- function reportPublishOutcome(outcome, asJson) {
570
+ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
389
571
  switch (outcome.action) {
390
572
  case 'published': {
391
573
  const out = {
@@ -404,6 +586,44 @@ function reportPublishOutcome(outcome, asJson) {
404
586
  process.stdout.write(`+ ${outcome.name}@${outcome.version}\n`);
405
587
  return;
406
588
  }
589
+ case 'publish-unconfirmed': {
590
+ const message = formatUnconfirmedPublish(outcome);
591
+ if (asJson) {
592
+ process.stdout.write(`${JSON.stringify({
593
+ ok: false,
594
+ action: 'publish-unconfirmed',
595
+ name: outcome.name,
596
+ version: outcome.version,
597
+ registry: outcome.registry,
598
+ claim: outcome.claim,
599
+ putStatus: outcome.putStatus,
600
+ probes: outcome.readback.attempts,
601
+ elapsedMs: outcome.readback.elapsedMs,
602
+ readbackUrl: outcome.readback.url,
603
+ readbackState: outcome.readback.last.state,
604
+ deferred: deferUnconfirmed,
605
+ }, null, 2)}\n`);
606
+ }
607
+ else {
608
+ process.stderr.write(`${message}\n`);
609
+ }
610
+ // Under `--verify-defer` the multi-line message is one entry in a
611
+ // serial log carrying a line per package, i.e. exactly the channel
612
+ // the incident's `+` line hid in. An annotation renders beside the
613
+ // job's own check and needs no writable file, the same reason
614
+ // `verify-published-closure.mjs` emits its notes as `::warning::`.
615
+ // On STDERR, because `--json` owns stdout.
616
+ if (deferUnconfirmed) {
617
+ if (process.env.GITHUB_ACTIONS) {
618
+ const claimed = outcome.claim === 'already-published' ? 'was reported already published' : 'was accepted';
619
+ process.stderr.write(`::warning title=Publish unconfirmed::${outcome.name}@${outcome.version} ${claimed} ` +
620
+ `(HTTP ${outcome.putStatus}) but did not resolve on ${outcome.registry} within ` +
621
+ `${(outcome.readback.elapsedMs / 1000).toFixed(1)}s — the release-closure job must confirm it\n`);
622
+ }
623
+ return;
624
+ }
625
+ return process.exit(1);
626
+ }
407
627
  case 'republish-tolerated': {
408
628
  const out = {
409
629
  ok: true,
@@ -0,0 +1,132 @@
1
+ /** One registry answer, classified. `absent` and `error` are NOT the same fact. */
2
+ export type ReadbackProbe = {
3
+ state: 'present';
4
+ status: number;
5
+ tarball: string;
6
+ } | {
7
+ state: 'absent';
8
+ status: number;
9
+ detail: string;
10
+ }
11
+ /**
12
+ * We do not KNOW. A 5xx, a timeout, unparseable JSON — treating any of them
13
+ * as "absent" would fabricate a "never published" verdict out of a registry
14
+ * hiccup, the same distinction `verify-published-closure.mjs` draws between
15
+ * `false` and an `Error`.
16
+ */
17
+ | {
18
+ state: 'error';
19
+ status?: number;
20
+ detail: string;
21
+ };
22
+ export interface ReadbackResult {
23
+ /** True iff a probe saw the exact version with a tarball on it. */
24
+ confirmed: boolean;
25
+ /** Probes actually sent (≥ 1). */
26
+ attempts: number;
27
+ elapsedMs: number;
28
+ /** The URL that was asked, verbatim — it belongs in the failure message. */
29
+ url: string;
30
+ /** What the registry said last. On `confirmed` this is the `present` probe. */
31
+ last: ReadbackProbe;
32
+ }
33
+ export interface VerifyPublishedVersionInput {
34
+ /** Registry URL, with or without a trailing slash. */
35
+ registry: string;
36
+ /** Full package name including scope. */
37
+ name: string;
38
+ version: string;
39
+ /** Total polling budget in ms. Must be > 0 — a disabled read-back is the caller's decision. */
40
+ budgetMs: number;
41
+ /** Per-probe request timeout. Default 30 s, matching the closure script. */
42
+ probeTimeoutMs?: number;
43
+ /**
44
+ * The `authorization` header the PUT used, forwarded verbatim.
45
+ *
46
+ * A registry that requires a credential to READ answers 401/403 to an
47
+ * anonymous packument GET, which classifies as `error` — so an intact
48
+ * publish to GitHub Packages, an Artifactory repo or a Verdaccio with
49
+ * `access: $authenticated` reports `publish-unconfirmed` and, on the fatal
50
+ * default, exits 1 after the whole budget on a publish that worked.
51
+ * Measured against the first version of this file: 15 read-back GETs, every
52
+ * one anonymous, one second after a PUT that carried a token. Same origin,
53
+ * same credential, one request later — it adds no exposure the PUT did not
54
+ * already have.
55
+ */
56
+ authorization?: string;
57
+ /** Injected in tests; resolved at call time so a `globalThis.fetch` stub is honoured. */
58
+ fetchImpl?: typeof fetch;
59
+ sleep?: (ms: number) => Promise<void>;
60
+ now?: () => number;
61
+ /** Called once per probe under `GJSIFY_PUBLISH_DEBUG`. */
62
+ log?: (msg: string) => void;
63
+ }
64
+ /** Delay before probe `round + 2`. Past the table's end the last value repeats. */
65
+ export declare function backoffFor(round: number): number;
66
+ /**
67
+ * A probe's request timeout: what was configured, never more than the budget has
68
+ * left to spend — but never less than a probe needs to answer.
69
+ *
70
+ * Configured and budget are otherwise independent, and that makes
71
+ * `--verify-timeout 5` — what the 199-package sweep passes — a promise the code
72
+ * does not keep: one unanswered request may block for the 30 s default, six times
73
+ * the budget it was given, and 199 of those cost ~100 min against the ~11% the
74
+ * deferral is argued on. Clamping bounds the wall clock at
75
+ * `budget + MIN_PROBE_TIMEOUT_MS` instead. The floor is the other half of the
76
+ * same honesty: clamping all the way to zero traded a 30 s overshoot for an
77
+ * `error` verdict on a probe that was about to return the 404 (measured — it
78
+ * turned the incident's own e2e row from `absent: 404` into `request failed
79
+ * (This operation was aborted)`).
80
+ */
81
+ export declare function probeTimeoutFor(configuredMs: number, remainingMs: number): number;
82
+ /**
83
+ * Default polling budget: 300 s.
84
+ *
85
+ * The measured maximum lag between a 2xx and the registry recording the version
86
+ * was 251.7 s (@gjsify/child_process, v0.46.0), so a budget below ~4.2 minutes
87
+ * turns npm's normal queueing into a false red — and a false red at release time
88
+ * costs a manual re-run of a workflow that did its job. 300 s clears that
89
+ * maximum and is still short enough that a genuinely lost write is reported by
90
+ * the job that lost it rather than by a later sweep.
91
+ *
92
+ * THAT MAXIMUM IS A SAMPLE MAXIMUM, and the sample does not look truncated by
93
+ * nature. The 19 lags fall in buckets ~20-30 s apart — 56(2), 76(3), 96(3),
94
+ * 127(4), 157(3), 189(1), 251(3) — and the TOP bucket is as populated as the
95
+ * middle ones, which is the shape of a distribution cut off by the observation
96
+ * rather than one decaying to zero. One release is one sample, so a lag past
97
+ * 300 s is not ruled out; what makes 300 s affordable anyway is that the
98
+ * remediation is verified rather than assumed. The re-run this reports lands on
99
+ * the 409 path, and that path READS BACK too (see the conflict branch in
100
+ * `commands/publish.ts`), so a window that turns out to be too short costs one
101
+ * re-run that confirms — it does not hand back an unverified success.
102
+ */
103
+ export declare const DEFAULT_VERIFY_BUDGET_MS = 300000;
104
+ /**
105
+ * Poll the registry until `name@version` resolves, or the budget runs out.
106
+ *
107
+ * No side effects, no exits, no printing beyond the injected `log` — the caller
108
+ * owns presentation, same contract as {@link import('./publish-diagnose.js')}.
109
+ */
110
+ export declare function verifyPublishedVersion(input: VerifyPublishedVersionInput): Promise<ReadbackResult>;
111
+ /**
112
+ * The unconfirmed-publish diagnostic, ready for stderr.
113
+ *
114
+ * The message has to answer three questions the incident log could not, which is
115
+ * why it needed the registry to reconstruct at all: what was PUT, what was asked,
116
+ * and what came back.
117
+ */
118
+ export declare function formatUnconfirmedPublish(opts: {
119
+ name: string;
120
+ version: string;
121
+ putUrl: string;
122
+ putStatus: number;
123
+ putStatusText: string;
124
+ payloadBytes: number;
125
+ readback: ReadbackResult;
126
+ /**
127
+ * What npm claimed before the read-back disagreed. `accepted` is a 2xx;
128
+ * `already-published` is a tolerated 409, where the remediation is the
129
+ * opposite one — a re-run answers the same conflict.
130
+ */
131
+ claim?: 'accepted' | 'already-published';
132
+ }): string;