@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.
@@ -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';
@@ -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;