package-registry-manager 0.24.0 → 0.25.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.
@@ -1,3 +1,4 @@
1
+ import { cratesPermit, delay, retryDelay } from "./request-policy.mjs";
1
2
  import {
2
3
  compareRepositories,
3
4
  identityCommand,
@@ -118,6 +119,14 @@ export async function probeRegistryState(inspection, options = {}) {
118
119
  if (state.trusted !== undefined) {
119
120
  packageInfo.trusted_publishing = state.trusted;
120
121
  }
122
+ for (const key of [
123
+ "registry_http_status",
124
+ "publisher_settings_reason",
125
+ ]) {
126
+ if (state[key] !== undefined) {
127
+ packageInfo[key] = state[key];
128
+ }
129
+ }
121
130
  const evidence = { ...packageInfo, ...state };
122
131
  if (state.configured_publishers !== undefined) {
123
132
  packageInfo.configured_publishers = state.configured_publishers;
@@ -184,9 +193,16 @@ export async function probePackage(packageInfo, options = {}) {
184
193
  if (!url) {
185
194
  return {};
186
195
  }
187
- const document = await getJson(url, options);
196
+ let httpStatus;
197
+ const document = await getJson(url, {
198
+ ...options,
199
+ onError: (error) => {
200
+ httpStatus = error.http_status;
201
+ options.onError?.(error);
202
+ },
203
+ });
188
204
  if (document === undefined) {
189
- return {};
205
+ return httpStatus ? { registry_http_status: httpStatus } : {};
190
206
  }
191
207
  if (document === null) {
192
208
  return packageInfo.registry === "docker-hub"
@@ -212,11 +228,15 @@ export async function probePackage(packageInfo, options = {}) {
212
228
  (document.versions ?? []).find(
213
229
  (item) => item.num === document.crate?.max_version,
214
230
  ) ?? document.versions?.[0];
215
- const settings = await getJson(
216
- `${registryEndpoint("crates-io", env)}/trusted_publishing/github_configs?crate=${encodeURIComponent(packageInfo.name)}`,
217
- options,
218
- );
231
+ const settings = options.cratesPublisherSettings
232
+ ? await getJson(
233
+ `${registryEndpoint("crates-io", env)}/trusted_publishing/github_configs?crate=${encodeURIComponent(packageInfo.name)}`,
234
+ options,
235
+ )
236
+ : undefined;
219
237
  return {
238
+ publisher_settings_reason:
239
+ "publisher settings require an authenticated crates.io session; public provenance is independent",
220
240
  exists: true,
221
241
  trusted: Boolean(latest?.trustpub_data),
222
242
  provenance_repositories: publisherIdentities(latest?.trustpub_data).map(
@@ -257,25 +277,42 @@ async function pypiProvenance(document, options) {
257
277
  */
258
278
  export async function getJson(url, options = {}) {
259
279
  const fetcher = options.fetch ?? globalThis.fetch;
280
+ const crates = new URL(url).hostname === "crates.io";
281
+ const attempts = crates ? 4 : 1;
260
282
  try {
261
- const response = await fetcher(url, {
262
- headers: { accept: "application/json", "user-agent": USER_AGENT },
263
- signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
264
- });
265
- if (options.verbose) {
266
- console.error(`GET ${url} -> ${response.status}`);
267
- }
268
- if (response.status === 404) {
269
- return null;
270
- }
271
- if (!response.ok) {
272
- options.onError?.({
273
- stage: "registry",
274
- message: `registry lookup returned HTTP ${response.status}`,
283
+ for (let attempt = 0; attempt < attempts; attempt++) {
284
+ // Test fetches do not contact the service. An injected limiter can exercise pacing.
285
+ if (crates) {
286
+ await (options.requestLimiter ?? cratesPermit)();
287
+ }
288
+ const response = await fetcher(url, {
289
+ headers: { accept: "application/json", "user-agent": USER_AGENT },
290
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
275
291
  });
276
- return undefined;
292
+ if (options.verbose) {
293
+ console.error(
294
+ `GET ${url} -> ${response.status} (attempt ${attempt + 1})`,
295
+ );
296
+ }
297
+ if ([403, 429].includes(response.status) && attempt + 1 < attempts) {
298
+ const wait = retryDelay(response.headers?.get("retry-after"), attempt);
299
+ await response.body?.cancel();
300
+ await (options.sleep ?? delay)(wait);
301
+ continue;
302
+ }
303
+ if (response.status === 404) {
304
+ return null;
305
+ }
306
+ if (!response.ok) {
307
+ options.onError?.({
308
+ stage: "registry",
309
+ http_status: response.status,
310
+ message: `registry lookup returned HTTP ${response.status}`,
311
+ });
312
+ return undefined;
313
+ }
314
+ return await response.json();
277
315
  }
278
- return await response.json();
279
316
  } catch (error) {
280
317
  options.onError?.({
281
318
  stage: "registry",
@@ -284,6 +321,6 @@ export async function getJson(url, options = {}) {
284
321
  if (options.verbose) {
285
322
  console.error(`GET ${url} failed: ${error.message}`);
286
323
  }
287
- return undefined;
288
324
  }
325
+ return undefined;
289
326
  }
@@ -0,0 +1,27 @@
1
+ /** A queue shared by every client in this process. Request starts are >=1s apart. */
2
+ export function createRequestLimiter({ now = Date.now, sleep = delay } = {}) {
3
+ let queue = Promise.resolve();
4
+ let next = 0;
5
+ return () => {
6
+ const permit = queue.then(async () => {
7
+ const wait = next - now();
8
+ if (wait > 0) {
9
+ await sleep(wait);
10
+ }
11
+ next = now() + 1000;
12
+ });
13
+ queue = permit.catch(() => {});
14
+ return permit;
15
+ };
16
+ }
17
+ export const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
18
+ export const cratesPermit = createRequestLimiter();
19
+
20
+ /** Retry-After supports delay seconds and HTTP dates. Never retry early. */
21
+ export function retryDelay(value, attempt, now = Date.now()) {
22
+ const seconds = value !== null && value !== undefined ? Number(value) : NaN;
23
+ const until = Number.isFinite(seconds)
24
+ ? seconds * 1000
25
+ : Date.parse(value) - now;
26
+ return Math.max(1000 * 2 ** attempt, Number.isFinite(until) ? until : 0);
27
+ }
package/src/setup.mjs CHANGED
@@ -695,23 +695,30 @@ export class SetupSession {
695
695
  }
696
696
  return;
697
697
  }
698
- await (await this.automatedPage()).goto(url);
698
+ await (await this.visiblePage()).goto(url);
699
699
  }
700
700
 
701
701
  /**
702
702
  * The automated page, launched once. The crates.io sign-in step offers
703
703
  * `--browser-import default|auto` itself, once it knows a sign-in is missing.
704
704
  */
705
- async automatedPage(browser = this.options.browserOptions, quiet = false) {
705
+ async automatedPage(
706
+ browser = this.options.browserOptions,
707
+ quiet = false,
708
+ headless = false,
709
+ ) {
706
710
  quiet ||= this.options.quietBrowser;
707
711
  if (!this.automation) {
708
712
  const domains = this.options.domains ?? signInDomains(this.plan.registry);
709
- this.automation = await connectAutomation({
713
+ this.automation = await (
714
+ this.options.connectAutomation ?? connectAutomation
715
+ )({
710
716
  browser: await launchBrowser(browser, domains, {
711
717
  deferred: this.plan.registry === "crates-io",
712
718
  verbose: quiet ? false : this.options.verbose,
713
719
  ...this.options.importDiscovery,
714
720
  }),
721
+ headless,
715
722
  profile: this.options.browserProfile,
716
723
  verbose: quiet ? false : this.options.verbose,
717
724
  domains,
@@ -720,6 +727,15 @@ export class SetupSession {
720
727
  return this.automation;
721
728
  }
722
729
 
730
+ /** Bring authentication and approval work into a visible browser. */
731
+ async visiblePage() {
732
+ if (this.automation?.headless) {
733
+ await this.automation.close();
734
+ this.automation = null;
735
+ }
736
+ return this.automatedPage();
737
+ }
738
+
723
739
  /** Relaunches the automated browser with other options, such as an import. */
724
740
  async reconnect(browser) {
725
741
  await this.automation?.close();
@@ -54,6 +54,8 @@ export function commandPosition(prefix) {
54
54
  segment,
55
55
  ) ||
56
56
  /(?:\$|shell)\s*`\s*$/.test(segment) ||
57
+ /^(?:export\s+)?\w+=`\s*$/.test(segment) ||
58
+ /\$\(\s*$/.test(segment) ||
57
59
  /(?:exec(?:Sync)?|spawn(?:Sync)?|run(?:_command)?|system|check_call|check_output|Popen)\s*\(\s*["'`]\s*$/.test(
58
60
  segment,
59
61
  )
package/src/tokens.mjs CHANGED
@@ -81,11 +81,13 @@ export function reportTokenSecrets(output, packageInfo) {
81
81
  const { inUse, leftover } = auditRegistryTokens(output, packageInfo);
82
82
  for (const name of inUse) {
83
83
  console.log(
84
- ` ${name} is still read by a workflow; switch that workflow to trusted publishing before deleting it.`,
84
+ ` ${name} is still read by a workflow; retained for authentication fallback.`,
85
85
  );
86
86
  }
87
87
  for (const name of leftover) {
88
- console.log(` ${name} is no longer needed with trusted publishing.`);
88
+ console.log(
89
+ ` ${name} is unused by workflows; retained unless cleanup is explicitly requested.`,
90
+ );
89
91
  }
90
92
  return leftover;
91
93
  }
@@ -36,7 +36,7 @@ export function bidiCookie(cookie) {
36
36
 
37
37
  /** Launches the selected installed Firefox variant and exposes the common page API. */
38
38
  export async function connectWebDriverAutomation(
39
- { browser, profile, verbose, domains },
39
+ { browser, profile, verbose, domains, headless = false },
40
40
  dependencies = {},
41
41
  ) {
42
42
  // Validation happens before profile creation, including programmatic callers.
@@ -65,7 +65,7 @@ export async function connectWebDriverAutomation(
65
65
  browser: "firefox",
66
66
  executablePath,
67
67
  userDataDir: profile,
68
- headless: false,
68
+ headless,
69
69
  bidi: true,
70
70
  verbose,
71
71
  });
@@ -101,6 +101,7 @@ export async function connectWebDriverAutomation(
101
101
  );
102
102
  }
103
103
  return {
104
+ headless,
104
105
  goto: (url) => connection.page.goto(url),
105
106
  evaluate: (script) => connection.page.evaluate(script),
106
107
  close: () => connection.close(),
@@ -0,0 +1,125 @@
1
+ /** OIDC runs first. A separate retry receives credentials only after that attempt fails. */
2
+ export function fallbackSteps(
3
+ step,
4
+ registry,
5
+ names,
6
+ level,
7
+ index,
8
+ removeCredentials,
9
+ ) {
10
+ const prefix = " ".repeat(level + 2);
11
+ const originalId = step
12
+ .map((line) => /^\s*(?:-\s*)?id:\s*([\w-]+)/.exec(line)?.[1])
13
+ .find(Boolean);
14
+ const id = originalId ?? `prm_oidc_${index + 1}`;
15
+ let oidc = removeCredentials(
16
+ step,
17
+ new Set([...names, ...(registry === "pypi" ? ["password", "user"] : [])]),
18
+ );
19
+ oidc = oidc.filter((line) => !/^\s*continue-on-error:/.test(line));
20
+ if (!originalId) {
21
+ oidc.push(`${prefix}id: ${id}`);
22
+ }
23
+ oidc.push(`${prefix}continue-on-error: true`);
24
+ const title = oidc.findIndex((line) => /^\s*(?:-\s*)?name:/.test(line));
25
+ if (title >= 0) {
26
+ oidc[title] = appendTitle(oidc[title], "OIDC");
27
+ } else {
28
+ oidc.push(`${prefix}name: Publish through OIDC`);
29
+ }
30
+ // Normalize a dash-prefixed id/condition so removing it keeps a valid step.
31
+ const normalized = [...step];
32
+ if (/^\s*-\s+(?:if|id|continue-on-error):/.test(normalized[0])) {
33
+ normalized[0] = normalized[0].replace(/- /, " ");
34
+ const next = normalized.findIndex(
35
+ (line, index) => index > 0 && line.search(/\S/) === level + 2,
36
+ );
37
+ if (next >= 0) {
38
+ normalized[next] =
39
+ `${" ".repeat(level)}- ${normalized[next].trimStart()}`;
40
+ }
41
+ }
42
+ const fallback = normalized.filter(
43
+ (line) => !/^\s*(?:if|id|continue-on-error):/.test(line),
44
+ );
45
+ // Remove a multiline condition together with its continuation lines.
46
+ const condition = step.findIndex((line) => /^\s*if:/.test(line));
47
+ if (condition >= 0 && /if:\s*[>|]/.test(step[condition])) {
48
+ for (
49
+ let i = condition + 1;
50
+ i < step.length && step[i].search(/\S/) > level + 2;
51
+ i++
52
+ ) {
53
+ const at = fallback.indexOf(step[i]);
54
+ if (at >= 0) {
55
+ fallback.splice(at, 1);
56
+ }
57
+ }
58
+ }
59
+ const fallbackTitle = fallback.findIndex((line) =>
60
+ /^\s*(?:-\s*)?name:/.test(line),
61
+ );
62
+ if (fallbackTitle >= 0) {
63
+ fallback[fallbackTitle] = appendTitle(
64
+ fallback[fallbackTitle],
65
+ "token fallback",
66
+ );
67
+ } else {
68
+ fallback.push(`${prefix}name: Publish with token fallback`);
69
+ }
70
+ fallback.push(
71
+ `${prefix}if: steps.${id}.outcome == 'failure'`,
72
+ `${prefix}id: ${id}_fallback`,
73
+ );
74
+ if (registry === "pypi") {
75
+ const cleaned = removeCredentials(fallback, new Set(["password", "user"]));
76
+ const withIndex = cleaned.findIndex((line) => /^\s*with:\s*$/.test(line));
77
+ const password = `${prefix} password: \${{ secrets.PYPI_API_TOKEN || secrets.PYPI_TOKEN }}`;
78
+ if (withIndex < 0) {
79
+ cleaned.push(`${prefix}with:`, password);
80
+ } else {
81
+ cleaned.splice(withIndex + 1, 0, password);
82
+ }
83
+ return [oidc, ["", ...cleaned]];
84
+ }
85
+ const env = (lines, value) => {
86
+ const updated = removeCredentials(
87
+ lines,
88
+ new Set([...names, "NODE_AUTH_TOKEN", "NPM_CONFIG_PROVENANCE"]),
89
+ );
90
+ const at = updated.findIndex((line) => /^\s*env:\s*$/.test(line));
91
+ const entries = [...new Set(["NODE_AUTH_TOKEN", ...names])].map(
92
+ (name) => `${prefix} ${name}: ${value}`,
93
+ );
94
+ entries.push(`${prefix} NPM_CONFIG_PROVENANCE: 'true'`);
95
+ if (at < 0) {
96
+ updated.push(`${prefix}env:`, ...entries);
97
+ } else {
98
+ updated.splice(at + 1, 0, ...entries);
99
+ }
100
+ return updated;
101
+ };
102
+ oidc = env(oidc, "''");
103
+ return [
104
+ oidc,
105
+ [
106
+ "",
107
+ ...env(
108
+ fallback,
109
+ "${{ secrets.NPM_TOKEN || secrets.NODE_AUTH_TOKEN || secrets.NPM_AUTH_TOKEN }}",
110
+ ),
111
+ ],
112
+ ];
113
+ }
114
+
115
+ function appendTitle(line, suffix) {
116
+ const value = line.slice(line.indexOf("name:") + "name:".length).trimStart();
117
+ const key = line.slice(0, line.length - value.length);
118
+ const quote = value[0];
119
+ return (
120
+ key +
121
+ ((quote === "'" || quote === '"') && value.endsWith(quote)
122
+ ? `${value.slice(0, -1)} (${suffix})${quote}`
123
+ : `${value} (${suffix})`)
124
+ );
125
+ }