@gjsify/cli 0.59.1 → 0.60.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 +123 -121
- package/lib/commands/publish.d.ts +25 -0
- package/lib/commands/publish.js +108 -2
- package/lib/utils/discover-showcases.js +5 -0
- package/lib/utils/publish-hold.d.ts +89 -0
- package/lib/utils/publish-hold.js +213 -0
- package/lib/utils/publish-status.d.ts +83 -0
- package/lib/utils/publish-status.js +193 -0
- package/package.json +20 -20
- package/showcases.json +0 -42
|
@@ -2,6 +2,8 @@ import type { Command } from '../types/index.js';
|
|
|
2
2
|
import { type Diagnose404Result } from '../utils/publish-diagnose.js';
|
|
3
3
|
import { type ReadbackResult } from '../utils/publish-readback.js';
|
|
4
4
|
import { type WorkspacePin } from '../utils/publish-pins.js';
|
|
5
|
+
import { type HoldReason } from '../utils/publish-hold.js';
|
|
6
|
+
import { type Classification } from '../utils/publish-status.js';
|
|
5
7
|
import { OtpProvider } from '../utils/npm-otp.js';
|
|
6
8
|
interface PublishOptions {
|
|
7
9
|
path?: string;
|
|
@@ -12,6 +14,9 @@ interface PublishOptions {
|
|
|
12
14
|
'tolerate-untrusted-new'?: boolean;
|
|
13
15
|
'verify-timeout'?: number;
|
|
14
16
|
'verify-defer'?: boolean;
|
|
17
|
+
'hold-timeout'?: number;
|
|
18
|
+
'report-ledger'?: boolean;
|
|
19
|
+
ref?: string;
|
|
15
20
|
provenance?: boolean;
|
|
16
21
|
'dry-run'?: boolean;
|
|
17
22
|
json?: boolean;
|
|
@@ -60,6 +65,11 @@ export interface PublishWorkspaceInput {
|
|
|
60
65
|
* success, which is the pre-v0.46.0 behaviour and why this defaults on.
|
|
61
66
|
*/
|
|
62
67
|
verifyBudgetMs?: number;
|
|
68
|
+
/**
|
|
69
|
+
* How long to wait for an intra-repo pin the registry does not serve before
|
|
70
|
+
* holding this package. Default {@link DEFAULT_HOLD_WAIT_MS}.
|
|
71
|
+
*/
|
|
72
|
+
holdWaitMs?: number;
|
|
63
73
|
}
|
|
64
74
|
/** Discriminated result of {@link publishWorkspace}; the caller owns presentation. */
|
|
65
75
|
export type PublishOutcome = {
|
|
@@ -101,6 +111,21 @@ export type PublishOutcome = {
|
|
|
101
111
|
putUrl: string;
|
|
102
112
|
payloadBytes: number;
|
|
103
113
|
readback: ReadbackResult;
|
|
114
|
+
/** What the unserved version IS (validating / staged / blocked / …). */
|
|
115
|
+
classification: Classification;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The tarball was NOT sent, because an intra-repo pin in it names a version
|
|
119
|
+
* the registry does not serve — an accepted-but-"validating" upload, say.
|
|
120
|
+
* `ok` is the tolerance, as for `blocked-missing-dependency`.
|
|
121
|
+
*/
|
|
122
|
+
| {
|
|
123
|
+
ok: false;
|
|
124
|
+
action: 'held-unpublished-dependency';
|
|
125
|
+
name: string;
|
|
126
|
+
version: string;
|
|
127
|
+
registry: string;
|
|
128
|
+
reasons: HoldReason[];
|
|
104
129
|
} | {
|
|
105
130
|
ok: true;
|
|
106
131
|
action: 'republish-tolerated';
|
package/lib/commands/publish.js
CHANGED
|
@@ -50,6 +50,8 @@ import { getNpmTrustedToken, hasGithubOidcEnv, OidcExchangeError, OidcUnavailabl
|
|
|
50
50
|
import { diagnose404, is404DiagnosticCandidate } from '../utils/publish-diagnose.js';
|
|
51
51
|
import { DEFAULT_VERIFY_BUDGET_MS, formatUnconfirmedPublish, probePackageName, verifyPublishedVersion, } from '../utils/publish-readback.js';
|
|
52
52
|
import { collectWorkspacePins, REQUIRED_BLOCK } from '../utils/publish-pins.js';
|
|
53
|
+
import { appendLedger, DEFAULT_HOLD_WAIT_MS, findUnavailablePins, holdsPublish, readLedger, recordUnserved, renderLedgerSummary, summariseReasons, } from '../utils/publish-hold.js';
|
|
54
|
+
import { describeClass, statusTokenFromEnv } from '../utils/publish-status.js';
|
|
53
55
|
import { loadNpmrc } from '../utils/load-npmrc.js';
|
|
54
56
|
import { OtpProvider, withOtpRetry, isOtpChallenge } from '../utils/npm-otp.js';
|
|
55
57
|
import { promptLine } from '../utils/prompt.js';
|
|
@@ -90,6 +92,20 @@ export const publishCommand = {
|
|
|
90
92
|
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.',
|
|
91
93
|
type: 'boolean',
|
|
92
94
|
default: false,
|
|
95
|
+
})
|
|
96
|
+
.option('hold-timeout', {
|
|
97
|
+
description: 'Seconds to wait for an intra-repo dependency pin whose exact version the registry does not serve (yet) before HOLDING this package instead of publishing it. A version npm accepted but never serves (status "validating") would make every dependent uninstallable; the write lag measured on v0.46.0 reaches 252s, hence the default. `0` probes once and holds without waiting. A hold exits 1, or 0 under `--verify-defer`.',
|
|
98
|
+
type: 'number',
|
|
99
|
+
default: DEFAULT_HOLD_WAIT_MS / 1000,
|
|
100
|
+
})
|
|
101
|
+
.option('report-ledger', {
|
|
102
|
+
description: 'Print the job-summary markdown for the publish ledger named by $GJSIFY_PUBLISH_LEDGER (versions npm accepted but does not serve, packages held because of them, the recovery command) and exit. Pair with `--ref <tag>`.',
|
|
103
|
+
type: 'boolean',
|
|
104
|
+
default: false,
|
|
105
|
+
})
|
|
106
|
+
.option('ref', {
|
|
107
|
+
description: 'With --report-ledger: the git ref to put in the printed recovery command.',
|
|
108
|
+
type: 'string',
|
|
93
109
|
})
|
|
94
110
|
.option('provenance', {
|
|
95
111
|
description: "Pass-through flag — recorded in the payload but no signing happens (gjsify doesn't ship a sigstore signer yet).",
|
|
@@ -142,6 +158,17 @@ export const publishCommand = {
|
|
|
142
158
|
const checkTrustedOnly = args['check-trusted'] === true;
|
|
143
159
|
const trustedFlag = args.trusted;
|
|
144
160
|
const verbose = Boolean(process.env.GJSIFY_PUBLISH_DEBUG);
|
|
161
|
+
if (args['report-ledger'] === true) {
|
|
162
|
+
const summary = renderLedgerSummary(readLedger(process.env.GJSIFY_PUBLISH_LEDGER), args.ref ?? '<tag>');
|
|
163
|
+
if (summary)
|
|
164
|
+
process.stdout.write(`${summary}\n`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const holdSeconds = args['hold-timeout'] ?? DEFAULT_HOLD_WAIT_MS / 1000;
|
|
168
|
+
if (!Number.isFinite(holdSeconds) || holdSeconds < 0) {
|
|
169
|
+
console.error(`gjsify publish: --hold-timeout must be a non-negative number of seconds, got ${JSON.stringify(args['hold-timeout'])}`);
|
|
170
|
+
return process.exit(2);
|
|
171
|
+
}
|
|
145
172
|
if (provenance) {
|
|
146
173
|
console.warn('gjsify publish: --provenance recorded but not signed (no sigstore integration yet).');
|
|
147
174
|
}
|
|
@@ -250,6 +277,7 @@ export const publishCommand = {
|
|
|
250
277
|
otpProvider,
|
|
251
278
|
seedOtpFirst: otp !== undefined,
|
|
252
279
|
verifyBudgetMs,
|
|
280
|
+
holdWaitMs: Math.round(holdSeconds * 1000),
|
|
253
281
|
});
|
|
254
282
|
reportPublishOutcome(outcome, args.json === true, deferUnconfirmed);
|
|
255
283
|
},
|
|
@@ -355,6 +383,43 @@ export async function publishWorkspace(input) {
|
|
|
355
383
|
};
|
|
356
384
|
}
|
|
357
385
|
}
|
|
386
|
+
// 5b. Do not PUT a tarball whose pin names a VERSION the registry does not
|
|
387
|
+
// serve (v0.59.x: adwaita-fonts "Validating" while its dependents shipped).
|
|
388
|
+
// Unlike 5 this waits, because a version is late for minutes after its own
|
|
389
|
+
// 2xx; see publish-hold.ts.
|
|
390
|
+
const ledgerPath = process.env.GJSIFY_PUBLISH_LEDGER;
|
|
391
|
+
if (pins.length > 0) {
|
|
392
|
+
const reasons = await findUnavailablePins({
|
|
393
|
+
registry: registryClean,
|
|
394
|
+
pins,
|
|
395
|
+
ledger: readLedger(ledgerPath),
|
|
396
|
+
ledgerPath,
|
|
397
|
+
authorization: headers['authorization'],
|
|
398
|
+
statusToken: statusTokenFromEnv(process.env),
|
|
399
|
+
waitBudgetMs: input.holdWaitMs ?? DEFAULT_HOLD_WAIT_MS,
|
|
400
|
+
log: verbose ? (m) => console.error(m) : undefined,
|
|
401
|
+
});
|
|
402
|
+
for (const r of reasons.filter((x) => !holdsPublish(x))) {
|
|
403
|
+
console.error(`gjsify publish: ${r.block}.${r.name}@${r.version} is not served (${r.detail}) — optional edge, publishing anyway`);
|
|
404
|
+
}
|
|
405
|
+
const holding = reasons.filter(holdsPublish);
|
|
406
|
+
if (holding.length > 0) {
|
|
407
|
+
appendLedger(ledgerPath, {
|
|
408
|
+
ev: 'held',
|
|
409
|
+
name: packed.name,
|
|
410
|
+
version: packed.version,
|
|
411
|
+
reasons: holding,
|
|
412
|
+
});
|
|
413
|
+
return {
|
|
414
|
+
ok: false,
|
|
415
|
+
action: 'held-unpublished-dependency',
|
|
416
|
+
name: packed.name,
|
|
417
|
+
version: packed.version,
|
|
418
|
+
registry: registryClean,
|
|
419
|
+
reasons: holding,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
}
|
|
358
423
|
// Trusted Publishing (OIDC) path — `--trusted` forces it, `undefined`
|
|
359
424
|
// auto-detects from the GitHub OIDC env. Unused by `gjsify onboard` (local
|
|
360
425
|
// token auth), preserved verbatim for `gjsify publish` in CI.
|
|
@@ -529,6 +594,14 @@ export async function publishWorkspace(input) {
|
|
|
529
594
|
putUrl: url,
|
|
530
595
|
payloadBytes: bodyStr.length,
|
|
531
596
|
readback,
|
|
597
|
+
classification: await recordUnserved({
|
|
598
|
+
ledgerPath,
|
|
599
|
+
registry: registryClean,
|
|
600
|
+
name: packed.name,
|
|
601
|
+
version: packed.version,
|
|
602
|
+
verdict: readback.verdict,
|
|
603
|
+
statusToken: statusTokenFromEnv(process.env),
|
|
604
|
+
}),
|
|
532
605
|
};
|
|
533
606
|
}
|
|
534
607
|
return {
|
|
@@ -576,6 +649,14 @@ export async function publishWorkspace(input) {
|
|
|
576
649
|
putUrl: url,
|
|
577
650
|
payloadBytes: bodyStr.length,
|
|
578
651
|
readback,
|
|
652
|
+
classification: await recordUnserved({
|
|
653
|
+
ledgerPath,
|
|
654
|
+
registry: registryClean,
|
|
655
|
+
name: packed.name,
|
|
656
|
+
version: packed.version,
|
|
657
|
+
verdict: readback.verdict,
|
|
658
|
+
statusToken: statusTokenFromEnv(process.env),
|
|
659
|
+
}),
|
|
579
660
|
};
|
|
580
661
|
}
|
|
581
662
|
return {
|
|
@@ -701,8 +782,31 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
|
|
|
701
782
|
warnUnverified(outcome.name, outcome.version, outcome.registry);
|
|
702
783
|
return;
|
|
703
784
|
}
|
|
785
|
+
case 'held-unpublished-dependency': {
|
|
786
|
+
const because = summariseReasons(outcome.reasons);
|
|
787
|
+
if (asJson) {
|
|
788
|
+
process.stdout.write(`${JSON.stringify({
|
|
789
|
+
ok: false,
|
|
790
|
+
action: 'held-unpublished-dependency',
|
|
791
|
+
name: outcome.name,
|
|
792
|
+
version: outcome.version,
|
|
793
|
+
registry: outcome.registry,
|
|
794
|
+
reasons: outcome.reasons,
|
|
795
|
+
deferred: deferUnconfirmed,
|
|
796
|
+
}, null, 2)}\n`);
|
|
797
|
+
}
|
|
798
|
+
else {
|
|
799
|
+
process.stderr.write(`~ ${outcome.name}@${outcome.version} (HELD — not published: ${because})\n`);
|
|
800
|
+
}
|
|
801
|
+
if (process.env.GITHUB_ACTIONS) {
|
|
802
|
+
process.stderr.write(`::warning title=Package held::${outcome.name}@${outcome.version} was NOT published: ${because}\n`);
|
|
803
|
+
}
|
|
804
|
+
// The sweep defers like an unconfirmed publish: the closure job goes red
|
|
805
|
+
// on the missing package, and the job summary carries the recovery.
|
|
806
|
+
return deferUnconfirmed ? undefined : process.exit(1);
|
|
807
|
+
}
|
|
704
808
|
case 'publish-unconfirmed': {
|
|
705
|
-
const message = formatUnconfirmedPublish(outcome)
|
|
809
|
+
const message = `${formatUnconfirmedPublish(outcome)}\n state: ${describeClass(outcome.classification.cls)} — ${outcome.classification.detail}`;
|
|
706
810
|
if (asJson) {
|
|
707
811
|
process.stdout.write(`${JSON.stringify({
|
|
708
812
|
ok: false,
|
|
@@ -720,6 +824,8 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
|
|
|
720
824
|
verdictDetail: outcome.readback.verdictDetail,
|
|
721
825
|
recordedAt: outcome.readback.recordedAt ?? null,
|
|
722
826
|
deferred: deferUnconfirmed,
|
|
827
|
+
class: outcome.classification.cls,
|
|
828
|
+
classDetail: outcome.classification.detail,
|
|
723
829
|
}, null, 2)}\n`);
|
|
724
830
|
}
|
|
725
831
|
else {
|
|
@@ -736,7 +842,7 @@ function reportPublishOutcome(outcome, asJson, deferUnconfirmed = false) {
|
|
|
736
842
|
const claimed = outcome.claim === 'already-published' ? 'was reported already published' : 'was accepted';
|
|
737
843
|
process.stderr.write(`::warning title=Publish unconfirmed::${outcome.name}@${outcome.version} ${claimed} ` +
|
|
738
844
|
`(HTTP ${outcome.putStatus}) but did not resolve on ${outcome.registry} within ` +
|
|
739
|
-
`${(outcome.readback.elapsedMs / 1000).toFixed(1)}s [${outcome.readback.verdict}] — ` +
|
|
845
|
+
`${(outcome.readback.elapsedMs / 1000).toFixed(1)}s [${outcome.readback.verdict}; ${describeClass(outcome.classification.cls)}] — ` +
|
|
740
846
|
'the release-closure job must confirm it\n');
|
|
741
847
|
}
|
|
742
848
|
return;
|
|
@@ -7,6 +7,11 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
function manifestPath() {
|
|
10
|
+
// Test seam: e2e points this at a fixture catalog (a gjs-only workspace showcase),
|
|
11
|
+
// since the curated list no longer holds one.
|
|
12
|
+
const override = process.env.GJSIFY_SHOWCASES_MANIFEST;
|
|
13
|
+
if (override)
|
|
14
|
+
return override;
|
|
10
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
16
|
// `showcases.json` sits at the CLI package root, but its depth below this
|
|
12
17
|
// module depends on how the CLI was loaded: two levels from
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type DependencyBlock, type WorkspacePin } from './publish-pins.js';
|
|
2
|
+
import { classifyUnserved, type Classification, type UnservedClass } from './publish-status.js';
|
|
3
|
+
/** Wait for a pin this long before holding. Covers the measured 252 s write lag (publish-readback.ts). */
|
|
4
|
+
export declare const DEFAULT_HOLD_WAIT_MS = 300000;
|
|
5
|
+
export interface HoldReason {
|
|
6
|
+
/** The dependency that is not installable. */
|
|
7
|
+
name: string;
|
|
8
|
+
version: string;
|
|
9
|
+
block: DependencyBlock;
|
|
10
|
+
/** `held` = the dependency was itself held in this run, so nothing of it was published. */
|
|
11
|
+
cls: UnservedClass | 'held';
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
export type LedgerEntry =
|
|
15
|
+
/** This run PUT the version, npm accepted it, and the packument does not serve it. */
|
|
16
|
+
{
|
|
17
|
+
ev: 'unserved';
|
|
18
|
+
name: string;
|
|
19
|
+
version: string;
|
|
20
|
+
cls: UnservedClass;
|
|
21
|
+
detail: string;
|
|
22
|
+
}
|
|
23
|
+
/** This run did NOT publish the package, because of the listed dependencies. */
|
|
24
|
+
| {
|
|
25
|
+
ev: 'held';
|
|
26
|
+
name: string;
|
|
27
|
+
version: string;
|
|
28
|
+
reasons: HoldReason[];
|
|
29
|
+
}
|
|
30
|
+
/** A later probe found a version an earlier `unserved` entry recorded now served; it supersedes that entry. */
|
|
31
|
+
| {
|
|
32
|
+
ev: 'served';
|
|
33
|
+
name: string;
|
|
34
|
+
version: string;
|
|
35
|
+
};
|
|
36
|
+
export declare function appendLedger(path: string | undefined, entry: LedgerEntry): void;
|
|
37
|
+
/** Tolerates a missing file (no entries yet) and skips unparseable lines rather than losing the rest. */
|
|
38
|
+
export declare function readLedger(path: string | undefined): LedgerEntry[];
|
|
39
|
+
/**
|
|
40
|
+
* The version a rewritten pin asks for, when the spec names exactly one:
|
|
41
|
+
* `0.59.1`, `^0.59.1`, `~0.59.1`. `undefined` for a range, tag or URL — those are
|
|
42
|
+
* not decidable against one version and the name-level guard covers them.
|
|
43
|
+
*/
|
|
44
|
+
export declare function pinVersion(spec: string): string | undefined;
|
|
45
|
+
export interface FindUnavailablePinsInput {
|
|
46
|
+
registry: string;
|
|
47
|
+
pins: readonly WorkspacePin[];
|
|
48
|
+
ledger: readonly LedgerEntry[];
|
|
49
|
+
/** Where a re-probe that finds a ledger-`unserved` version served records it, so later dependents skip the probe. */
|
|
50
|
+
ledgerPath?: string;
|
|
51
|
+
/** Forwarded to the packument GETs, as the publish's own read-back does. */
|
|
52
|
+
authorization?: string;
|
|
53
|
+
statusToken?: string;
|
|
54
|
+
/** How long to wait for a pin that may merely be late. 0 = one probe, no waiting. */
|
|
55
|
+
waitBudgetMs: number;
|
|
56
|
+
fetchImpl?: typeof fetch;
|
|
57
|
+
sleep?: (ms: number) => Promise<void>;
|
|
58
|
+
now?: () => number;
|
|
59
|
+
log?: (msg: string) => void;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Every pin whose exact version the registry does not serve (after waiting for
|
|
63
|
+
* one that may be late), with the reason. Empty = safe to publish.
|
|
64
|
+
*
|
|
65
|
+
* Three cheap exits come first, in order: the ledger already knows this version
|
|
66
|
+
* is unserved or held; one packument probe finds it served (nine in ten); the
|
|
67
|
+
* status endpoint says it can never be served. Only a pin that is none of those —
|
|
68
|
+
* possibly just late — pays the wait.
|
|
69
|
+
*/
|
|
70
|
+
export declare function findUnavailablePins(input: FindUnavailablePinsInput): Promise<HoldReason[]>;
|
|
71
|
+
/** Only a `dependencies` edge holds a publish, as in the name-level guard; the rest are reported. */
|
|
72
|
+
export declare const holdsPublish: (reason: HoldReason) => boolean;
|
|
73
|
+
export declare function summariseReasons(reasons: readonly HoldReason[]): string;
|
|
74
|
+
/** Classify a version THIS process PUT and could not read back, and write it to the ledger. */
|
|
75
|
+
export declare function recordUnserved(input: {
|
|
76
|
+
ledgerPath: string | undefined;
|
|
77
|
+
registry: string;
|
|
78
|
+
name: string;
|
|
79
|
+
version: string;
|
|
80
|
+
verdict: Parameters<typeof classifyUnserved>[0];
|
|
81
|
+
statusToken?: string;
|
|
82
|
+
fetchImpl?: typeof fetch;
|
|
83
|
+
}): Promise<Classification>;
|
|
84
|
+
/**
|
|
85
|
+
* The job-summary markdown for a ledger: what is unserved and why, what was held
|
|
86
|
+
* and why, and the exact command that publishes the held set once their
|
|
87
|
+
* dependencies are installable. `undefined` when nothing needs saying.
|
|
88
|
+
*/
|
|
89
|
+
export declare function renderLedgerSummary(entries: readonly LedgerEntry[], ref: string): string | undefined;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Do not publish a package whose dependency closure contains a version nobody can install.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT (v0.59.0 / v0.59.1). `@gjsify/adwaita-fonts@0.59.x` was accepted
|
|
4
|
+
// by npm (202) and never served — it sits in "Validating". The sweep published
|
|
5
|
+
// its dependents anyway: `publish-pins.ts` asks whether the NAME exists, and
|
|
6
|
+
// `adwaita-fonts` has existed since 0.5x, so the guard passed. `adwaita-web` and
|
|
7
|
+
// `adwaita-nativescript` 0.59.x went live pinning a version the registry does not
|
|
8
|
+
// serve, and are uninstallable. A name guard cannot see that; a VERSION guard can,
|
|
9
|
+
// at the price the name guard was designed to avoid — a version is eventually
|
|
10
|
+
// consistent for minutes after its own 2xx (see `publish-readback.ts`), so this
|
|
11
|
+
// one WAITS for the pin before it holds, and consults the status endpoint
|
|
12
|
+
// (`publish-status.ts`) so a version that is terminally unavailable does not cost
|
|
13
|
+
// the wait.
|
|
14
|
+
//
|
|
15
|
+
// The sweep is one `gjsify publish` PROCESS per package (`foreach --exec`), so a
|
|
16
|
+
// verdict reached by one process cannot reach the next in memory. The ledger is
|
|
17
|
+
// how it does: a JSONL file named by `GJSIFY_PUBLISH_LEDGER`, appended by every
|
|
18
|
+
// process that finds a version unserved or holds a package. A dependent of a held
|
|
19
|
+
// package then holds at once from the ledger instead of waiting out the budget
|
|
20
|
+
// again — but only for a TERMINAL class; a `validating` entry is re-probed, and a
|
|
21
|
+
// confirmed re-probe appends `served`. The end-of-job summary is rendered from
|
|
22
|
+
// the same file.
|
|
23
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs';
|
|
24
|
+
import { REQUIRED_BLOCK } from './publish-pins.js';
|
|
25
|
+
import { verifyPublishedVersion } from './publish-readback.js';
|
|
26
|
+
import { classifyUnserved, describeClass, fetchVersionStatus, isTerminalClass, } from './publish-status.js';
|
|
27
|
+
/** Wait for a pin this long before holding. Covers the measured 252 s write lag (publish-readback.ts). */
|
|
28
|
+
export const DEFAULT_HOLD_WAIT_MS = 300_000;
|
|
29
|
+
/** `verifyPublishedVersion` needs a budget > 0; this makes it exactly one probe. */
|
|
30
|
+
const SINGLE_PROBE_BUDGET_MS = 1;
|
|
31
|
+
export function appendLedger(path, entry) {
|
|
32
|
+
if (!path)
|
|
33
|
+
return;
|
|
34
|
+
appendFileSync(path, `${JSON.stringify(entry)}\n`);
|
|
35
|
+
}
|
|
36
|
+
/** Tolerates a missing file (no entries yet) and skips unparseable lines rather than losing the rest. */
|
|
37
|
+
export function readLedger(path) {
|
|
38
|
+
if (!path || !existsSync(path))
|
|
39
|
+
return [];
|
|
40
|
+
const entries = [];
|
|
41
|
+
for (const line of readFileSync(path, 'utf-8').split('\n')) {
|
|
42
|
+
if (!line.trim())
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
const parsed = JSON.parse(line);
|
|
46
|
+
if (parsed.ev === 'unserved' || parsed.ev === 'held' || parsed.ev === 'served')
|
|
47
|
+
entries.push(parsed);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
/* a torn line from a killed process must not hide the entries around it */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return entries;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The version a rewritten pin asks for, when the spec names exactly one:
|
|
57
|
+
* `0.59.1`, `^0.59.1`, `~0.59.1`. `undefined` for a range, tag or URL — those are
|
|
58
|
+
* not decidable against one version and the name-level guard covers them.
|
|
59
|
+
*/
|
|
60
|
+
export function pinVersion(spec) {
|
|
61
|
+
const match = /^[\^~]?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/.exec(spec.trim());
|
|
62
|
+
return match?.[1];
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Every pin whose exact version the registry does not serve (after waiting for
|
|
66
|
+
* one that may be late), with the reason. Empty = safe to publish.
|
|
67
|
+
*
|
|
68
|
+
* Three cheap exits come first, in order: the ledger already knows this version
|
|
69
|
+
* is unserved or held; one packument probe finds it served (nine in ten); the
|
|
70
|
+
* status endpoint says it can never be served. Only a pin that is none of those —
|
|
71
|
+
* possibly just late — pays the wait.
|
|
72
|
+
*/
|
|
73
|
+
export async function findUnavailablePins(input) {
|
|
74
|
+
const reasons = [];
|
|
75
|
+
const seen = new Map();
|
|
76
|
+
for (const pin of input.pins) {
|
|
77
|
+
const version = pinVersion(pin.spec);
|
|
78
|
+
if (!version) {
|
|
79
|
+
input.log?.(`gjsify publish: ${pin.name}@${pin.spec} names no single version — not checked for hold`);
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const key = `${pin.name}@${version}`;
|
|
83
|
+
// An optional/peer edge is reported but never holds (see `holdsPublish`),
|
|
84
|
+
// so it does not earn the wait either: one probe.
|
|
85
|
+
const cacheKey = pin.block === REQUIRED_BLOCK ? key : `${key}#nowait`;
|
|
86
|
+
if (!seen.has(cacheKey)) {
|
|
87
|
+
const probe = pin.block === REQUIRED_BLOCK ? input : { ...input, waitBudgetMs: 0 };
|
|
88
|
+
seen.set(cacheKey, await judgePin(probe, pin.name, version));
|
|
89
|
+
}
|
|
90
|
+
const verdict = seen.get(cacheKey);
|
|
91
|
+
// One reason per EDGE: the same target in two blocks costs different things
|
|
92
|
+
// at install time, and the operator should see both.
|
|
93
|
+
if (verdict)
|
|
94
|
+
reasons.push({ ...verdict, block: pin.block });
|
|
95
|
+
}
|
|
96
|
+
return reasons;
|
|
97
|
+
}
|
|
98
|
+
async function judgePin(input, name, version) {
|
|
99
|
+
const base = { name, version, block: 'dependencies' };
|
|
100
|
+
// The LAST entry wins: a `served` entry supersedes the `unserved` one before it.
|
|
101
|
+
const known = [...input.ledger].reverse().find((e) => e.name === name && e.version === version);
|
|
102
|
+
if (known?.ev === 'served')
|
|
103
|
+
return null;
|
|
104
|
+
// Only a terminal class short-circuits. npm holds EVERY new version in
|
|
105
|
+
// `validating` for 30–90 s, and the publisher's own read-back runs inside
|
|
106
|
+
// that window, so a non-terminal entry is stale by the time a dependent
|
|
107
|
+
// looks (v0.60.0: 92 of 215 packages held on it). Fall through and re-probe.
|
|
108
|
+
if (known?.ev === 'unserved' && isTerminalClass(known.cls)) {
|
|
109
|
+
return { ...base, cls: known.cls, detail: known.detail };
|
|
110
|
+
}
|
|
111
|
+
if (known?.ev === 'held') {
|
|
112
|
+
return {
|
|
113
|
+
...base,
|
|
114
|
+
cls: 'held',
|
|
115
|
+
detail: `was itself held — ${summariseReasons(known.reasons)}`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
const probeInput = {
|
|
119
|
+
registry: input.registry,
|
|
120
|
+
name,
|
|
121
|
+
version,
|
|
122
|
+
authorization: input.authorization,
|
|
123
|
+
fetchImpl: input.fetchImpl,
|
|
124
|
+
sleep: input.sleep,
|
|
125
|
+
now: input.now,
|
|
126
|
+
log: input.log,
|
|
127
|
+
};
|
|
128
|
+
let readback = await verifyPublishedVersion({
|
|
129
|
+
...probeInput,
|
|
130
|
+
budgetMs: SINGLE_PROBE_BUDGET_MS,
|
|
131
|
+
});
|
|
132
|
+
if (readback.confirmed)
|
|
133
|
+
return confirmedServed(input, known, name, version);
|
|
134
|
+
const ask = () => fetchVersionStatus({
|
|
135
|
+
registry: input.registry,
|
|
136
|
+
name,
|
|
137
|
+
version,
|
|
138
|
+
token: input.statusToken,
|
|
139
|
+
fetchImpl: input.fetchImpl,
|
|
140
|
+
});
|
|
141
|
+
let classification = classifyUnserved(readback.verdict, await ask());
|
|
142
|
+
if (!classification.terminal && input.waitBudgetMs > 0) {
|
|
143
|
+
readback = await verifyPublishedVersion({
|
|
144
|
+
...probeInput,
|
|
145
|
+
budgetMs: input.waitBudgetMs,
|
|
146
|
+
});
|
|
147
|
+
if (readback.confirmed)
|
|
148
|
+
return confirmedServed(input, known, name, version);
|
|
149
|
+
classification = classifyUnserved(readback.verdict, await ask());
|
|
150
|
+
}
|
|
151
|
+
return { ...base, cls: classification.cls, detail: classification.detail };
|
|
152
|
+
}
|
|
153
|
+
function confirmedServed(input, known, name, version) {
|
|
154
|
+
if (known?.ev === 'unserved')
|
|
155
|
+
appendLedger(input.ledgerPath, { ev: 'served', name, version });
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
/** Only a `dependencies` edge holds a publish, as in the name-level guard; the rest are reported. */
|
|
159
|
+
export const holdsPublish = (reason) => reason.block === REQUIRED_BLOCK;
|
|
160
|
+
export function summariseReasons(reasons) {
|
|
161
|
+
return reasons
|
|
162
|
+
.map((r) => `${r.name}@${r.version} is ${r.cls === 'held' ? 'held' : describeClass(r.cls)}`)
|
|
163
|
+
.join('; ');
|
|
164
|
+
}
|
|
165
|
+
/** Classify a version THIS process PUT and could not read back, and write it to the ledger. */
|
|
166
|
+
export async function recordUnserved(input) {
|
|
167
|
+
const status = await fetchVersionStatus({
|
|
168
|
+
registry: input.registry,
|
|
169
|
+
name: input.name,
|
|
170
|
+
version: input.version,
|
|
171
|
+
token: input.statusToken,
|
|
172
|
+
fetchImpl: input.fetchImpl,
|
|
173
|
+
});
|
|
174
|
+
const classification = classifyUnserved(input.verdict, status);
|
|
175
|
+
appendLedger(input.ledgerPath, {
|
|
176
|
+
ev: 'unserved',
|
|
177
|
+
name: input.name,
|
|
178
|
+
version: input.version,
|
|
179
|
+
cls: classification.cls,
|
|
180
|
+
detail: classification.detail,
|
|
181
|
+
});
|
|
182
|
+
return classification;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* The job-summary markdown for a ledger: what is unserved and why, what was held
|
|
186
|
+
* and why, and the exact command that publishes the held set once their
|
|
187
|
+
* dependencies are installable. `undefined` when nothing needs saying.
|
|
188
|
+
*/
|
|
189
|
+
export function renderLedgerSummary(entries, ref) {
|
|
190
|
+
const served = new Set(entries.filter((e) => e.ev === 'served').map((e) => `${e.name}@${e.version}`));
|
|
191
|
+
const unserved = entries.filter((e) => e.ev === 'unserved' && !served.has(`${e.name}@${e.version}`));
|
|
192
|
+
const held = entries.filter((e) => e.ev === 'held');
|
|
193
|
+
if (unserved.length === 0 && held.length === 0)
|
|
194
|
+
return undefined;
|
|
195
|
+
const lines = ['### npm versions that were accepted but are not installable', ''];
|
|
196
|
+
if (unserved.length > 0) {
|
|
197
|
+
lines.push('| Package | Version | State | Detail |', '| --- | --- | --- | --- |');
|
|
198
|
+
for (const e of unserved) {
|
|
199
|
+
lines.push(`| \`${e.name}\` | ${e.version} | ${describeClass(e.cls)} | ${e.detail} |`);
|
|
200
|
+
}
|
|
201
|
+
lines.push('');
|
|
202
|
+
}
|
|
203
|
+
if (held.length > 0) {
|
|
204
|
+
lines.push('### Held — NOT published in this run', '', 'Each package below depends on a version npm does not serve, so publishing it would have shipped a package that cannot be installed.', '', '| Package | Version | Because |', '| --- | --- | --- |');
|
|
205
|
+
for (const e of held)
|
|
206
|
+
lines.push(`| \`${e.name}\` | ${e.version} | ${summariseReasons(e.reasons)} |`);
|
|
207
|
+
lines.push('', 'Once the dependencies above are served by npm (a `validating` version appears on its own, or is approved/released by a maintainer), publish the held set with:', '', '```sh', `gh workflow run release.yml --ref ${ref} -f skip_publish=false -f only='${[...new Set(held.map((e) => e.name))].join(' ')}'`, '```');
|
|
208
|
+
if (entries.some((e) => e.ev === 'unserved' && (e.cls === 'blocked' || e.cls === 'deleted'))) {
|
|
209
|
+
lines.push('', 'A `blocked` or `deleted` version is a spent number: re-running the release cannot serve it, the package needs a version bump.');
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return lines.join('\n');
|
|
213
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { ReadbackVerdict } from './publish-readback.js';
|
|
2
|
+
/** The lifecycle states the status endpoint documents. */
|
|
3
|
+
export type VersionStatus = 'published' | 'validating' | 'staged' | 'blocked' | 'deleted';
|
|
4
|
+
export type StatusUnavailableReason =
|
|
5
|
+
/** No token to ask with — the OIDC-only CI case. */
|
|
6
|
+
'no-credential'
|
|
7
|
+
/** 401: the token is wrong or lacks the scope. */
|
|
8
|
+
| 'rejected'
|
|
9
|
+
/** 403: the endpoint's per-package rollout gate is closed for this package. */
|
|
10
|
+
| 'not-enabled'
|
|
11
|
+
/** 404: the version does not exist, OR this token cannot see the package. Indistinguishable by design. */
|
|
12
|
+
| 'not-found'
|
|
13
|
+
/** 429 / 5xx / timeout / unparseable body. */
|
|
14
|
+
| 'error';
|
|
15
|
+
export type StatusProbe = {
|
|
16
|
+
state: 'known';
|
|
17
|
+
status: VersionStatus;
|
|
18
|
+
} | {
|
|
19
|
+
state: 'unavailable';
|
|
20
|
+
reason: StatusUnavailableReason;
|
|
21
|
+
detail: string;
|
|
22
|
+
};
|
|
23
|
+
export interface FetchVersionStatusInput {
|
|
24
|
+
registry: string;
|
|
25
|
+
name: string;
|
|
26
|
+
version: string;
|
|
27
|
+
/** A bare npm access token / granular access token. Absent → `no-credential`, no request is sent. */
|
|
28
|
+
token?: string;
|
|
29
|
+
probeTimeoutMs?: number;
|
|
30
|
+
fetchImpl?: typeof fetch;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The token to ask the status endpoint with: the dedicated `NPM_STATUS_TOKEN`
|
|
34
|
+
* first, then a `NODE_AUTH_TOKEN` that is a real token and not the
|
|
35
|
+
* `XXXXX-XXXXX-XXXXX-XXXXX` literal `actions/setup-node` injects when given a
|
|
36
|
+
* `registry-url`. Empty string and the placeholder both mean "none".
|
|
37
|
+
*/
|
|
38
|
+
export declare function statusTokenFromEnv(env: Record<string, string | undefined>): string | undefined;
|
|
39
|
+
/** `GET {registry}/-/package/{name}/version/{version}/status`, classified. Never throws. */
|
|
40
|
+
export declare function fetchVersionStatus(input: FetchVersionStatusInput): Promise<StatusProbe>;
|
|
41
|
+
/**
|
|
42
|
+
* What a version the packument does not serve IS. The first five come from the
|
|
43
|
+
* registry's own status; the last two are what we can say without it.
|
|
44
|
+
*/
|
|
45
|
+
export type UnservedClass =
|
|
46
|
+
/** Automated review has not finished; the version will appear — or turn `blocked`. */
|
|
47
|
+
'validating'
|
|
48
|
+
/** Waits for a maintainer to approve it; nothing automatic will change this. */
|
|
49
|
+
| 'staged'
|
|
50
|
+
/** Failed automated validation. Will never be served. */
|
|
51
|
+
| 'blocked'
|
|
52
|
+
/** Deleted. Will never be served, and the number cannot be re-used. */
|
|
53
|
+
| 'deleted'
|
|
54
|
+
/** The registry says `published` but the install document does not carry it yet (write lag). */
|
|
55
|
+
| 'published-lagging'
|
|
56
|
+
/** The registry has no record of the version: the PUT did not land. */
|
|
57
|
+
| 'missing'
|
|
58
|
+
/** Not served and the status endpoint could not be asked; the read-back verdict is all there is. */
|
|
59
|
+
| 'unchecked';
|
|
60
|
+
export interface Classification {
|
|
61
|
+
cls: UnservedClass;
|
|
62
|
+
/** One line for the log and the summary. */
|
|
63
|
+
detail: string;
|
|
64
|
+
/**
|
|
65
|
+
* Waiting can no longer change the answer. `validating`, `published-lagging`
|
|
66
|
+
* and `unchecked` can still resolve on their own; the rest cannot.
|
|
67
|
+
*/
|
|
68
|
+
terminal: boolean;
|
|
69
|
+
}
|
|
70
|
+
/** `classifyUnserved(...).terminal` for a class read back from the ledger. */
|
|
71
|
+
export declare function isTerminalClass(cls: UnservedClass): boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Fold the registry's lifecycle status and the read-back verdict into ONE class.
|
|
74
|
+
*
|
|
75
|
+
* The status wins when it answered. When it did not, the read-back verdict is
|
|
76
|
+
* kept in the detail but never promoted to `missing`: `not-published` from the
|
|
77
|
+
* packument alone is exactly the observation a `validating` version produces, so
|
|
78
|
+
* "missing — re-publish" would send the operator into a 409. Only a status 404
|
|
79
|
+
* (the registry itself has no such version) earns `missing`.
|
|
80
|
+
*/
|
|
81
|
+
export declare function classifyUnserved(verdict: ReadbackVerdict, probe: StatusProbe): Classification;
|
|
82
|
+
/** The class as a human label for tables. */
|
|
83
|
+
export declare function describeClass(cls: UnservedClass): string;
|