@gjsify/cli 0.64.3-next.261 → 0.64.4

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.
@@ -113,7 +113,8 @@ export declare class BuildAction {
113
113
  /**
114
114
  * Post-bundle step for a `--app browser` HTML entry: write the processed
115
115
  * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
116
- * rewritten to point at the built bundle. Mirrors `applyShebang` — kept in
116
+ * rewritten to point at the built bundle and the page's linked icons copied
117
+ * to `assets/` beside it. Mirrors `applyShebang` — kept in
117
118
  * the CLI (not a Rolldown plugin) because the native rolldown engine
118
119
  * exposes no `emitFile`.
119
120
  */
@@ -6,10 +6,10 @@ import { resolveGlobalsList, writeRegisterInjectFile, detectAutoGlobals, detectN
6
6
  import { pnpPlugin } from '@gjsify/rolldown-plugin-pnp';
7
7
  import { basename, dirname, extname, join, resolve } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
- import { chmod, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
9
+ import { chmod, copyFile, mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
10
10
  import { normalizeBundlerOptions, mergeBundlerOptions } from '../utils/normalize-bundler-options.js';
11
11
  import { inputSourceDirs, isOutdirInsideSource, libraryOutputLeakError } from '../utils/library-output.js';
12
- import { detectHtmlEntry, parseHtmlEntry, emitBrowserHtml, htmlOutPathFor } from '../utils/html-entry.js';
12
+ import { collectLinkedAssets, detectHtmlEntry, emitBrowserHtml, htmlOutPathFor, parseHtmlEntry, } from '../utils/html-entry.js';
13
13
  import { assertGjsBundleLoadable, assertGjsBundleParses } from '../utils/gjs-bundle-guard.js';
14
14
  import { collectEntryPaths, describeJsxConfig, findJsxEntryPoint, jsxConfigMissingError } from '../utils/jsx-config.js';
15
15
  import { giSystemProbes } from '../utils/gi-runtime-paths.js';
@@ -489,7 +489,8 @@ export class BuildAction {
489
489
  /**
490
490
  * Post-bundle step for a `--app browser` HTML entry: write the processed
491
491
  * `index.html` beside the JS bundle, with the entry `<script>`'s `src`
492
- * rewritten to point at the built bundle. Mirrors `applyShebang` — kept in
492
+ * rewritten to point at the built bundle and the page's linked icons copied
493
+ * to `assets/` beside it. Mirrors `applyShebang` — kept in
493
494
  * the CLI (not a Rolldown plugin) because the native rolldown engine
494
495
  * exposes no `emitFile`.
495
496
  */
@@ -502,9 +503,15 @@ export class BuildAction {
502
503
  scriptSrc: htmlEntry.scriptSrc,
503
504
  jsOutPath,
504
505
  outHtmlPath,
506
+ assets: htmlEntry.assets,
505
507
  });
506
508
  await mkdir(dirname(outHtmlPath), { recursive: true });
507
509
  await writeFile(outHtmlPath, html);
510
+ for (const { source, target } of new Map(htmlEntry.assets.map((a) => [a.target, a])).values()) {
511
+ const dest = resolve(dirname(outHtmlPath), target);
512
+ await mkdir(dirname(dest), { recursive: true });
513
+ await copyFile(source, dest);
514
+ }
508
515
  if (verbose)
509
516
  console.debug(`[gjsify] --app browser: wrote ${outHtmlPath}`);
510
517
  }
@@ -667,7 +674,7 @@ export class BuildAction {
667
674
  const htmlSource = await readFile(htmlPath, 'utf-8');
668
675
  const { moduleEntry, scriptTag, scriptSrc } = parseHtmlEntry(htmlPath, htmlSource);
669
676
  userBundler.input = moduleEntry;
670
- htmlEntry = { htmlSource, scriptTag, scriptSrc };
677
+ htmlEntry = { htmlSource, scriptTag, scriptSrc, assets: collectLinkedAssets(htmlPath, htmlSource) };
671
678
  }
672
679
  }
673
680
  const formatRaw = userBundler.output?.format ??
@@ -5,6 +5,7 @@ import { type WorkspacePin } from '../utils/publish-pins.js';
5
5
  import { type HoldReason } from '../utils/publish-hold.js';
6
6
  import { type Classification } from '../utils/publish-status.js';
7
7
  import { OtpProvider } from '../utils/npm-otp.js';
8
+ import { type TransientRetryOptions } from '../utils/transient-retry.js';
8
9
  interface PublishOptions {
9
10
  path?: string;
10
11
  tag?: string;
@@ -16,6 +17,10 @@ interface PublishOptions {
16
17
  'verify-defer'?: boolean;
17
18
  'hold-timeout'?: number;
18
19
  'report-ledger'?: boolean;
20
+ 'fail-on-unsettled'?: boolean;
21
+ 'held-names'?: boolean;
22
+ 'settle-ledger'?: boolean;
23
+ 'settle-timeout'?: number;
19
24
  ref?: string;
20
25
  provenance?: boolean;
21
26
  'dry-run'?: boolean;
@@ -24,6 +29,12 @@ interface PublishOptions {
24
29
  'check-trusted'?: boolean;
25
30
  }
26
31
  export declare const publishCommand: Command<unknown, PublishOptions>;
32
+ /**
33
+ * Whether a 409 / 403 "previously published" answer counts as success. `--tolerate-republish`
34
+ * says yes; so does a PUT that was retried after a 5xx, since the first attempt may have landed
35
+ * and the retry then finds its own version.
36
+ */
37
+ export declare function acceptsRepublish(status: number, text: string, tolerate: boolean, retriedPut: boolean): boolean;
27
38
  /** Input to {@link publishWorkspace} — the token-auth-aware, OTP-shareable publish flow. */
28
39
  export interface PublishWorkspaceInput {
29
40
  /** Workspace directory to pack + publish. */
@@ -70,6 +81,8 @@ export interface PublishWorkspaceInput {
70
81
  * holding this package. Default {@link DEFAULT_HOLD_WAIT_MS}.
71
82
  */
72
83
  holdWaitMs?: number;
84
+ /** Retry policy for 5xx/429/network errors on the PUT. Tests pass `{ baseMs: 0 }`. */
85
+ putRetry?: TransientRetryOptions;
73
86
  }
74
87
  /** Discriminated result of {@link publishWorkspace}; the caller owns presentation. */
75
88
  export type PublishOutcome = {
@@ -50,11 +50,12 @@ 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';
53
+ import { appendLedger, DEFAULT_HOLD_WAIT_MS, findUnavailablePins, holdsPublish, markHeldPublished, pendingEntries, readLedger, recordUnserved, renderLedgerSummary, settleUnserved, summariseReasons, } from '../utils/publish-hold.js';
54
54
  import { describeClass, statusTokenFromEnv } from '../utils/publish-status.js';
55
55
  import { loadNpmrc } from '../utils/load-npmrc.js';
56
56
  import { OtpProvider, withOtpRetry, isOtpChallenge } from '../utils/npm-otp.js';
57
57
  import { promptLine } from '../utils/prompt.js';
58
+ import { fetchWithTransientRetry } from '../utils/transient-retry.js';
58
59
  export const publishCommand = {
59
60
  command: 'publish [path]',
60
61
  description: 'Pack + upload the workspace at <path> (default: cwd) to its npm registry. Drop-in for `npm publish` with workspace:^ rewrite handled automatically.',
@@ -102,6 +103,26 @@ export const publishCommand = {
102
103
  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
104
  type: 'boolean',
104
105
  default: false,
106
+ })
107
+ .option('fail-on-unsettled', {
108
+ description: 'With --report-ledger: exit 1 when any package is still HELD or any accepted version is still unserved, naming them and the recovery command. The end-of-sweep gate: a sweep that left something behind must be red, not a warning.',
109
+ type: 'boolean',
110
+ default: false,
111
+ })
112
+ .option('held-names', {
113
+ description: 'Print, one per line, the packages the ledger shows HELD and not yet published, and exit. Feeds the held-package second pass (.github/scripts/retry-held-publish.sh).',
114
+ type: 'boolean',
115
+ default: false,
116
+ })
117
+ .option('settle-ledger', {
118
+ description: 'Wait up to --settle-timeout for every version the ledger records as accepted-but-unserved to be served (the longer read-back the sweep defers), record each as served, and exit.',
119
+ type: 'boolean',
120
+ default: false,
121
+ })
122
+ .option('settle-timeout', {
123
+ description: 'With --settle-ledger: seconds to wait. Versions poll concurrently, so this is the longest lag tolerated, not a sum. Default 600 (the fatal read-back default; measured lags reach 368 s).',
124
+ type: 'number',
125
+ default: DEFAULT_VERIFY_BUDGET_MS / 1000,
105
126
  })
106
127
  .option('ref', {
107
128
  description: 'With --report-ledger: the git ref to put in the printed recovery command.',
@@ -158,10 +179,45 @@ export const publishCommand = {
158
179
  const checkTrustedOnly = args['check-trusted'] === true;
159
180
  const trustedFlag = args.trusted;
160
181
  const verbose = Boolean(process.env.GJSIFY_PUBLISH_DEBUG);
182
+ const ledgerPath = process.env.GJSIFY_PUBLISH_LEDGER;
183
+ if (args['held-names'] === true) {
184
+ for (const e of pendingEntries(readLedger(ledgerPath)).held)
185
+ process.stdout.write(`${e.name}\n`);
186
+ return;
187
+ }
188
+ if (args['settle-ledger'] === true) {
189
+ const settleSeconds = args['settle-timeout'] ?? DEFAULT_VERIFY_BUDGET_MS / 1000;
190
+ if (!Number.isFinite(settleSeconds) || settleSeconds < 0) {
191
+ console.error(`gjsify publish: --settle-timeout must be a non-negative number of seconds, got ${JSON.stringify(args['settle-timeout'])}`);
192
+ return process.exit(2);
193
+ }
194
+ const npmrc = await loadNpmrc(process.cwd());
195
+ const served = await settleUnserved({
196
+ entries: readLedger(ledgerPath),
197
+ ledgerPath,
198
+ registryFor: (name) => (process.env.npm_config_registry ?? registryFor(name, npmrc) ?? DEFAULT_REGISTRY).replace(/\/$/, ''),
199
+ budgetMs: Math.max(1, Math.round(settleSeconds * 1000)),
200
+ log: verbose ? (m) => console.error(m) : undefined,
201
+ });
202
+ for (const key of served)
203
+ process.stdout.write(`served after settling: ${key}\n`);
204
+ return;
205
+ }
161
206
  if (args['report-ledger'] === true) {
162
- const summary = renderLedgerSummary(readLedger(process.env.GJSIFY_PUBLISH_LEDGER), args.ref ?? '<tag>');
207
+ const entries = readLedger(ledgerPath);
208
+ const summary = renderLedgerSummary(entries, args.ref ?? '<tag>');
163
209
  if (summary)
164
210
  process.stdout.write(`${summary}\n`);
211
+ if (args['fail-on-unsettled'] === true) {
212
+ const { unserved, held } = pendingEntries(entries);
213
+ const names = [...new Set([...held, ...unserved].map((e) => e.name))];
214
+ if (names.length > 0) {
215
+ const ref = args.ref ?? '<tag>';
216
+ console.error(`gjsify publish: ${held.length} package(s) still HELD and ${unserved.length} version(s) still unserved after the second pass: ${names.join(' ')}\n` +
217
+ ` recover: re-run THIS run (\`gh run rerun <run-id>\` — keeps the version; never a new dispatch on the next channel, which mints a new one), or for a release: gh workflow run release.yml --ref ${ref} -f skip_publish=false -f only='${names.join(' ')}'`);
218
+ return process.exit(1);
219
+ }
220
+ }
165
221
  return;
166
222
  }
167
223
  const holdSeconds = args['hold-timeout'] ?? DEFAULT_HOLD_WAIT_MS / 1000;
@@ -277,11 +333,29 @@ export const publishCommand = {
277
333
  otpProvider,
278
334
  seedOtpFirst: otp !== undefined,
279
335
  verifyBudgetMs,
280
- holdWaitMs: Math.round(holdSeconds * 1000),
336
+ holdWaitMs: Math.min(Math.round(holdSeconds * 1000), remainingHoldDeadlineMs()),
281
337
  });
282
338
  reportPublishOutcome(outcome, args.json === true, deferUnconfirmed);
283
339
  },
284
340
  };
341
+ /**
342
+ * Milliseconds left until `GJSIFY_HOLD_DEADLINE` (epoch ms), or Infinity when unset. The
343
+ * held-package second pass is one `gjsify publish` PROCESS per package, so each would otherwise
344
+ * pay the full `--hold-timeout` on the same blocker; a shared deadline bounds the whole pass.
345
+ */
346
+ function remainingHoldDeadlineMs() {
347
+ const until = Number(process.env.GJSIFY_HOLD_DEADLINE);
348
+ return Number.isFinite(until) && until > 0 ? Math.max(0, until - Date.now()) : Infinity;
349
+ }
350
+ /**
351
+ * Whether a 409 / 403 "previously published" answer counts as success. `--tolerate-republish`
352
+ * says yes; so does a PUT that was retried after a 5xx, since the first attempt may have landed
353
+ * and the retry then finds its own version.
354
+ */
355
+ export function acceptsRepublish(status, text, tolerate, retriedPut) {
356
+ const conflict = status === 409 || (status === 403 && /previously published/i.test(text));
357
+ return conflict && (tolerate || retriedPut);
358
+ }
285
359
  /**
286
360
  * Pack + PUT a single workspace to its npm registry, returning a structured
287
361
  * outcome instead of writing/exiting. Extracted from the `publish` command so
@@ -465,13 +539,24 @@ export async function publishWorkspace(input) {
465
539
  const bodyStr = JSON.stringify(payload);
466
540
  // Each attempt clones the base headers and adds `npm-otp` (+ legacy
467
541
  // auth-type, matching buildPublishHeaders) when a code is supplied.
468
- const doPut = (code) => {
542
+ //
543
+ // A transient 5xx/429/network error is retried here, inside the OTP loop (a 401 challenge is
544
+ // not transient and passes straight through). The first attempt may have LANDED despite its
545
+ // 5xx, so `retriedPut` makes a later 409 / "previously published" count as success below.
546
+ let retriedPut = false;
547
+ const doPut = async (code) => {
469
548
  const reqHeaders = { ...headers };
470
549
  if (code) {
471
550
  reqHeaders['npm-otp'] = code;
472
551
  reqHeaders['npm-auth-type'] = 'legacy';
473
552
  }
474
- return fetch(url, { method: 'PUT', headers: reqHeaders, body: bodyStr });
553
+ const { response, retried } = await fetchWithTransientRetry(() => fetch(url, { method: 'PUT', headers: reqHeaders, body: bodyStr }), {
554
+ log: (m) => console.error(`gjsify publish: PUT ${packed.name}@${packed.version}: ${m}`),
555
+ ...input.putRetry,
556
+ });
557
+ if (retried > 0)
558
+ retriedPut = true;
559
+ return response;
475
560
  };
476
561
  const res = await withOtpRetry(doPut, otpProvider, { seedFirstAttempt: input.seedOtpFirst });
477
562
  if (verbose) {
@@ -604,6 +689,7 @@ export async function publishWorkspace(input) {
604
689
  }),
605
690
  };
606
691
  }
692
+ markHeldPublished(ledgerPath, packed.name, packed.version);
607
693
  return {
608
694
  ok: true,
609
695
  action: 'published',
@@ -633,8 +719,7 @@ export async function publishWorkspace(input) {
633
719
  // this is the path a `publish-unconfirmed` re-run always lands on: exempting
634
720
  // it would leave the documented remediation reporting a success nothing
635
721
  // checked. Cost is one GET, on a path that only runs on a re-publish.
636
- const isRepublishConflict = res.status === 409 || (res.status === 403 && /previously published/i.test(text));
637
- if (isRepublishConflict && tolerate) {
722
+ if (acceptsRepublish(res.status, text, tolerate, retriedPut)) {
638
723
  const readback = await readBack();
639
724
  if (readback && !readback.confirmed) {
640
725
  return {
@@ -659,6 +744,7 @@ export async function publishWorkspace(input) {
659
744
  }),
660
745
  };
661
746
  }
747
+ markHeldPublished(ledgerPath, packed.name, packed.version);
662
748
  return {
663
749
  ok: true,
664
750
  action: 'republish-tolerated',
@@ -18,6 +18,22 @@ export declare function detectHtmlEntry(input: RolldownOptions['input'] | undefi
18
18
  * path, with the module resolved against the HTML file's directory. Throws when there is none.
19
19
  */
20
20
  export declare function parseHtmlEntry(htmlPath: string, htmlSource: string): ParsedHtmlEntry;
21
+ export interface LinkedAsset {
22
+ /** The full matched `<link …>` tag text. */
23
+ linkTag: string;
24
+ /** The raw `href` attribute value from that tag (as authored in the HTML). */
25
+ href: string;
26
+ /** Absolute path to the referenced file. */
27
+ source: string;
28
+ /** Where the copy goes, relative to the emitted HTML's directory (`assets/<name>`). */
29
+ target: string;
30
+ }
31
+ /**
32
+ * Every `<link>` whose `rel` names an icon and whose `href` is a local path, resolved against
33
+ * the HTML file's directory, each with a unique `assets/` target. The same file linked twice
34
+ * shares one target; two files with one basename get `-1`, `-2`, … suffixes.
35
+ */
36
+ export declare function collectLinkedAssets(htmlPath: string, htmlSource: string): LinkedAsset[];
21
37
  export interface EmitBrowserHtmlInput {
22
38
  /** The original HTML source. */
23
39
  htmlSource: string;
@@ -29,11 +45,13 @@ export interface EmitBrowserHtmlInput {
29
45
  jsOutPath: string;
30
46
  /** Absolute path the emitted HTML will be written to. */
31
47
  outHtmlPath: string;
48
+ /** The page's linked icons from {@link collectLinkedAssets}; their `href`s point at the copies. */
49
+ assets?: readonly LinkedAsset[];
32
50
  }
33
51
  /**
34
52
  * The original source with the entry script's `src` rewritten to the built JS bundle, relative
35
- * to the output HTML's own directory so the page is portable wherever `dist/` is served from.
36
- * All other markup is preserved.
53
+ * to the output HTML's own directory so the page is portable wherever `dist/` is served from,
54
+ * and each linked icon's `href` rewritten to its `assets/` copy. All other markup is preserved.
37
55
  */
38
56
  export declare function emitBrowserHtml(input: EmitBrowserHtmlInput): string;
39
57
  /** Convenience: the emitted HTML filename for a given JS outfile (sibling `index.html`). */
@@ -8,9 +8,11 @@
8
8
  // plugin `emitFile`: the native `@gjsify/rolldown-native` engine implements no `emitFile`, so a
9
9
  // plugin-based emit would work on the npm engine (Node) and throw under the GJS-bundled CLI.
10
10
  // Parsing is regex-based and tolerant on purpose — this is a shell-page rewrite, not a full HTML
11
- // pipeline. CSS is NOT extracted here: it travels in the JS bundle as a string via
11
+ // pipeline. The page's own icons — a local `<link rel="icon" href>` and its kin — are copied
12
+ // beside the page under `assets/` with the `href` rewritten, as Vite does, so a favicon the HTML
13
+ // names is one the built page serves. CSS is NOT extracted here: it travels in the JS bundle as a string via
12
14
  // css-as-string and is injected by the app, the same pattern the `--app gjs` target uses.
13
- import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
15
+ import { basename, dirname, extname, isAbsolute, relative, resolve } from 'node:path';
14
16
  /**
15
17
  * The `.html` path when `input` is a SINGLE html entry (a literal `.html` string, a 1-element
16
18
  * array of one, or a 1-key record whose value is one) — else `null`. Multi-entry inputs and
@@ -54,20 +56,65 @@ export function parseHtmlEntry(htmlPath, htmlSource) {
54
56
  throw new Error(`gjsify build: ${htmlPath} has no local <script type="module" src="…"> to bundle. ` +
55
57
  `Add one pointing at your app entry (e.g. <script type="module" src="./main.ts">).`);
56
58
  }
59
+ const LINK_TAG_RE = /<link\b[^>]*>/gi;
60
+ const HREF_ATTR_RE = /\bhref\s*=\s*["']([^"']+)["']/i;
61
+ const REL_ATTR_RE = /\brel\s*=\s*["']([^"']+)["']/i;
62
+ /** `rel` tokens whose `href` is a file the page itself needs; `shortcut icon` splits to `icon`. */
63
+ const ASSET_RELS = new Set(['icon', 'apple-touch-icon', 'mask-icon']);
64
+ /** Hosted (`https://`, `//`), inline (`data:`, `blob:`) or root-relative (`/…`, the server's) hrefs stay as authored. */
65
+ const NOT_LOCAL_HREF_RE = /^(?:[a-z][a-z0-9+.-]*:|\/)/i;
66
+ /**
67
+ * Every `<link>` whose `rel` names an icon and whose `href` is a local path, resolved against
68
+ * the HTML file's directory, each with a unique `assets/` target. The same file linked twice
69
+ * shares one target; two files with one basename get `-1`, `-2`, … suffixes.
70
+ */
71
+ export function collectLinkedAssets(htmlPath, htmlSource) {
72
+ const assets = [];
73
+ const targetBySource = new Map();
74
+ const taken = new Set();
75
+ for (const match of htmlSource.matchAll(LINK_TAG_RE)) {
76
+ const linkTag = match[0];
77
+ const rel = REL_ATTR_RE.exec(linkTag)?.[1].toLowerCase().split(/\s+/) ?? [];
78
+ if (!rel.some((token) => ASSET_RELS.has(token)))
79
+ continue;
80
+ const href = HREF_ATTR_RE.exec(linkTag)?.[1];
81
+ if (!href || NOT_LOCAL_HREF_RE.test(href))
82
+ continue;
83
+ const path = href.replace(/[?#].*$/, '');
84
+ const source = isAbsolute(path) ? path : resolve(dirname(htmlPath), path);
85
+ let target = targetBySource.get(source);
86
+ if (!target) {
87
+ const ext = extname(source);
88
+ const stem = basename(source, ext);
89
+ target = `assets/${stem}${ext}`;
90
+ for (let n = 1; taken.has(target); n++)
91
+ target = `assets/${stem}-${n}${ext}`;
92
+ taken.add(target);
93
+ targetBySource.set(source, target);
94
+ }
95
+ assets.push({ linkTag, href, source, target });
96
+ }
97
+ return assets;
98
+ }
57
99
  /**
58
100
  * The original source with the entry script's `src` rewritten to the built JS bundle, relative
59
- * to the output HTML's own directory so the page is portable wherever `dist/` is served from.
60
- * All other markup is preserved.
101
+ * to the output HTML's own directory so the page is portable wherever `dist/` is served from,
102
+ * and each linked icon's `href` rewritten to its `assets/` copy. All other markup is preserved.
61
103
  */
62
104
  export function emitBrowserHtml(input) {
63
- const { htmlSource, scriptTag, scriptSrc, jsOutPath, outHtmlPath } = input;
105
+ const { htmlSource, scriptTag, scriptSrc, jsOutPath, outHtmlPath, assets = [] } = input;
64
106
  let rel = relative(dirname(outHtmlPath), jsOutPath).split(/[\\/]/).join('/');
65
107
  if (!rel.startsWith('.'))
66
108
  rel = `./${rel}`;
67
109
  // Rewrite the src WITHIN the matched tag, then swap that tag back in — so no other `src=`
68
110
  // in the document is touched.
69
111
  const newTag = scriptTag.replace(SRC_ATTR_RE, (whole) => whole.replace(scriptSrc, rel));
70
- return htmlSource.replace(scriptTag, newTag);
112
+ let html = htmlSource.replace(scriptTag, newTag);
113
+ for (const { linkTag, href, target } of new Map(assets.map((a) => [a.linkTag, a])).values()) {
114
+ const newLink = linkTag.replace(HREF_ATTR_RE, (whole) => whole.replace(href, `./${target}`));
115
+ html = html.split(linkTag).join(newLink);
116
+ }
117
+ return html;
71
118
  }
72
119
  /** Convenience: the emitted HTML filename for a given JS outfile (sibling `index.html`). */
73
120
  export function htmlOutPathFor(jsOutPath) {
@@ -14,7 +14,7 @@ interface OidcExchangeOptions {
14
14
  * `--tolerate-untrusted-new` and the misconfig hints still fire on the first response.
15
15
  */
16
16
  maxRetries?: number;
17
- /** Base backoff in ms for transient retries; doubles each attempt (capped 8s). Default 1000. Tests pass 0. */
17
+ /** Base backoff in ms for transient retries; doubles each attempt (capped 30s, `Retry-After` honored). Default 1000. Tests pass 0. */
18
18
  retryBaseMs?: number;
19
19
  }
20
20
  export interface OidcExchangeResult {
@@ -13,8 +13,7 @@
13
13
  //
14
14
  // Reference: refs/npm-cli/lib/utils/oidc.js
15
15
  // Original: Copyright (c) npm contributors. Artistic-2.0.
16
- /** HTTP statuses worth retrying — npm-side transient/overload conditions. */
17
- const TRANSIENT_EXCHANGE_STATUSES = new Set([408, 425, 429, 500, 502, 503, 504]);
16
+ import { isTransientStatus, retryDelayMs } from './transient-retry.js';
18
17
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
19
18
  export class OidcUnavailableError extends Error {
20
19
  reason;
@@ -146,7 +145,7 @@ export async function exchangeOidcForNpmToken(args) {
146
145
  catch (netErr) {
147
146
  // Network-level failure (DNS, connection reset, TLS): transient.
148
147
  if (attempt < maxRetries) {
149
- const delay = Math.min(retryBaseMs * 2 ** attempt, 8000);
148
+ const delay = retryDelayMs(attempt, retryBaseMs);
150
149
  log?.(`gjsify oidc: network error for ${packageName} (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms`);
151
150
  await sleep(delay);
152
151
  continue;
@@ -156,8 +155,9 @@ export async function exchangeOidcForNpmToken(args) {
156
155
  const text = await res.text().catch(() => '');
157
156
  if (!res.ok) {
158
157
  // Only TRANSIENT npm-side failures retry — see `maxRetries`.
159
- if (TRANSIENT_EXCHANGE_STATUSES.has(res.status) && attempt < maxRetries) {
160
- const delay = Math.min(retryBaseMs * 2 ** attempt, 8000);
158
+ if (isTransientStatus(res.status) && attempt < maxRetries) {
159
+ // `Retry-After` wins over the backoff when npm sends one (429/503 during an incident).
160
+ const delay = retryDelayMs(attempt, retryBaseMs, res.headers.get('retry-after'));
161
161
  log?.(`gjsify oidc: transient ${res.status} ${res.statusText} for ${packageName} (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${delay}ms`);
162
162
  await sleep(delay);
163
163
  continue;
@@ -27,7 +27,10 @@ export type LedgerEntry =
27
27
  version: string;
28
28
  reasons: HoldReason[];
29
29
  }
30
- /** A later probe found a version an earlier `unserved` entry recorded now served; it supersedes that entry. */
30
+ /**
31
+ * The version is now served; it supersedes an earlier `unserved` OR `held` entry (last entry
32
+ * wins) — a later probe found it, or the held-package second pass published it.
33
+ */
31
34
  | {
32
35
  ev: 'served';
33
36
  name: string;
@@ -86,4 +89,41 @@ export declare function recordUnserved(input: {
86
89
  * and why, and the exact command that publishes the held set once their
87
90
  * dependencies are installable. `undefined` when nothing needs saying.
88
91
  */
92
+ type UnservedEntry = Extract<LedgerEntry, {
93
+ ev: 'unserved';
94
+ }>;
95
+ type HeldEntry = Extract<LedgerEntry, {
96
+ ev: 'held';
97
+ }>;
98
+ /** One entry per `name@version` — the LAST one — keeping only those still not served. */
99
+ export declare function pendingEntries(entries: readonly LedgerEntry[]): {
100
+ unserved: UnservedEntry[];
101
+ held: HeldEntry[];
102
+ };
103
+ /**
104
+ * Record that a package this run once HELD has now been published, so its dependents stop
105
+ * holding on it (`judgePin` reads the last entry) and the end-of-job check stops naming it.
106
+ */
107
+ export declare function markHeldPublished(path: string | undefined, name: string, version: string): void;
108
+ export interface SettleUnservedInput {
109
+ entries: readonly LedgerEntry[];
110
+ ledgerPath: string | undefined;
111
+ /** Registry for a package name (scoped registries differ). */
112
+ registryFor: (name: string) => string;
113
+ /** One shared deadline: the entries poll concurrently, so the cost is the longest lag, not the sum. */
114
+ budgetMs: number;
115
+ authorization?: string;
116
+ fetchImpl?: typeof fetch;
117
+ sleep?: (ms: number) => Promise<void>;
118
+ now?: () => number;
119
+ log?: (msg: string) => void;
120
+ }
121
+ /**
122
+ * Wait for versions npm ACCEPTED but did not serve inside the sweep's 5 s read-back, and append
123
+ * `served` for each one that shows up. This is the longer read-back the sweep deliberately defers
124
+ * (publishing.md: lags reach 252–368 s). A TERMINAL class (blocked/deleted/staged/missing) is a
125
+ * spent number — waiting cannot help, so it is skipped. Returns the names confirmed.
126
+ */
127
+ export declare function settleUnserved(input: SettleUnservedInput): Promise<string[]>;
89
128
  export declare function renderLedgerSummary(entries: readonly LedgerEntry[], ref: string): string | undefined;
129
+ export {};
@@ -181,15 +181,55 @@ export async function recordUnserved(input) {
181
181
  });
182
182
  return classification;
183
183
  }
184
+ /** One entry per `name@version` — the LAST one — keeping only those still not served. */
185
+ export function pendingEntries(entries) {
186
+ const last = new Map();
187
+ for (const e of entries)
188
+ last.set(`${e.name}@${e.version}`, e);
189
+ const pending = [...last.values()];
190
+ return {
191
+ unserved: pending.filter((e) => e.ev === 'unserved'),
192
+ held: pending.filter((e) => e.ev === 'held'),
193
+ };
194
+ }
184
195
  /**
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.
196
+ * Record that a package this run once HELD has now been published, so its dependents stop
197
+ * holding on it (`judgePin` reads the last entry) and the end-of-job check stops naming it.
188
198
  */
199
+ export function markHeldPublished(path, name, version) {
200
+ const last = [...readLedger(path)].reverse().find((e) => e.name === name && e.version === version);
201
+ if (last?.ev === 'held')
202
+ appendLedger(path, { ev: 'served', name, version });
203
+ }
204
+ /**
205
+ * Wait for versions npm ACCEPTED but did not serve inside the sweep's 5 s read-back, and append
206
+ * `served` for each one that shows up. This is the longer read-back the sweep deliberately defers
207
+ * (publishing.md: lags reach 252–368 s). A TERMINAL class (blocked/deleted/staged/missing) is a
208
+ * spent number — waiting cannot help, so it is skipped. Returns the names confirmed.
209
+ */
210
+ export async function settleUnserved(input) {
211
+ const waiting = pendingEntries(input.entries).unserved.filter((e) => !isTerminalClass(e.cls));
212
+ const confirmed = await Promise.all(waiting.map(async (e) => {
213
+ const readback = await verifyPublishedVersion({
214
+ registry: input.registryFor(e.name),
215
+ name: e.name,
216
+ version: e.version,
217
+ budgetMs: input.budgetMs,
218
+ authorization: input.authorization,
219
+ fetchImpl: input.fetchImpl,
220
+ sleep: input.sleep,
221
+ now: input.now,
222
+ log: input.log,
223
+ });
224
+ if (!readback.confirmed)
225
+ return undefined;
226
+ appendLedger(input.ledgerPath, { ev: 'served', name: e.name, version: e.version });
227
+ return `${e.name}@${e.version}`;
228
+ }));
229
+ return confirmed.filter((x) => x !== undefined);
230
+ }
189
231
  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');
232
+ const { unserved, held } = pendingEntries(entries);
193
233
  if (unserved.length === 0 && held.length === 0)
194
234
  return undefined;
195
235
  const lines = ['### npm versions that were accepted but are not installable', ''];
@@ -205,7 +245,7 @@ export function renderLedgerSummary(entries, ref) {
205
245
  for (const e of held)
206
246
  lines.push(`| \`${e.name}\` | ${e.version} | ${summariseReasons(e.reasons)} |`);
207
247
  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'))) {
248
+ if (unserved.some((e) => e.cls === 'blocked' || e.cls === 'deleted')) {
209
249
  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
250
  }
211
251
  }
@@ -0,0 +1,37 @@
1
+ /** HTTP statuses worth retrying — npm-side transient/overload conditions. */
2
+ export declare const TRANSIENT_STATUSES: ReadonlySet<number>;
3
+ export declare const isTransientStatus: (status: number) => boolean;
4
+ /** Longest single wait, whether it came from the backoff or from a `Retry-After`. */
5
+ export declare const MAX_RETRY_DELAY_MS = 30000;
6
+ /**
7
+ * Milliseconds a `Retry-After` header asks for: delta-seconds or an HTTP date. `undefined` when
8
+ * absent or unparseable.
9
+ */
10
+ export declare function parseRetryAfter(value: string | null | undefined, now?: number): number | undefined;
11
+ /** The wait before retry `attempt` (0-based): `Retry-After` if the server sent one, else doubling backoff; capped. */
12
+ export declare function retryDelayMs(attempt: number, baseMs: number, retryAfter?: string | null): number;
13
+ export interface TransientRetryOptions {
14
+ /** Retries after the first attempt. Default {@link DEFAULT_TRANSIENT_RETRIES}. */
15
+ retries?: number;
16
+ /** Base backoff in ms, doubling per attempt. Default {@link DEFAULT_TRANSIENT_BASE_MS}. Tests pass 0. */
17
+ baseMs?: number;
18
+ sleep?: (ms: number) => Promise<void>;
19
+ log?: (msg: string) => void;
20
+ }
21
+ /**
22
+ * Five retries at 2/4/8/16/30 s: ~60 s of patience per package. Long enough to ride out a blip,
23
+ * short enough that a real outage fails the sweep in a minute instead of stalling 232 packages
24
+ * behind it — the failure is then a red job, recovered by a re-run (`--tolerate-republish`).
25
+ */
26
+ export declare const DEFAULT_TRANSIENT_RETRIES = 5;
27
+ export declare const DEFAULT_TRANSIENT_BASE_MS = 2000;
28
+ export interface TransientRetryResult {
29
+ response: Response;
30
+ /** How many retries were spent; >0 means an earlier attempt may have landed. */
31
+ retried: number;
32
+ }
33
+ /**
34
+ * Run `doFetch`, retrying a thrown network error or a transient status. Returns the last response
35
+ * once the budget is spent (the caller interprets the 5xx); rethrows the last network error.
36
+ */
37
+ export declare function fetchWithTransientRetry(doFetch: () => Promise<Response>, opts?: TransientRetryOptions): Promise<TransientRetryResult>;