@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.
- package/dist/cli.gjs.mjs +28 -27
- package/lib/commands/onboard.js +11 -0
- package/lib/commands/publish.d.ts +41 -0
- package/lib/commands/publish.js +222 -2
- package/lib/utils/publish-readback.d.ts +132 -0
- package/lib/utils/publish-readback.js +273 -0
- package/lib/utils/ship/layout.js +8 -4
- package/package.json +19 -19
package/lib/commands/onboard.js
CHANGED
|
@@ -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';
|
package/lib/commands/publish.js
CHANGED
|
@@ -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;
|