package-registry-manager 0.22.11 → 0.23.1

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/CHANGELOG.md CHANGED
@@ -35,7 +35,10 @@ currently shares its release version with the Rust implementation.
35
35
  outside any repository (`~/Library/Application Support` on macOS,
36
36
  `$XDG_STATE_HOME` or `~/.local/state` on Linux, `%LOCALAPPDATA%` on Windows,
37
37
  each followed by `package-registry-manager/browser-profile`) (#8).
38
- - `browser-commander` is required at `^0.22.0`, its latest release (#28).
38
+ - `browser-commander` is required at `^0.33.2`, its latest release (#28). A
39
+ launch still names its channel (`chrome` by default), so the upstream switch
40
+ of channel-less launches to Chrome for Testing does not change which browser
41
+ opens.
39
42
 
40
43
  ### Fixed
41
44
 
package/README.md CHANGED
@@ -35,6 +35,21 @@ one browser, groups npm approvals, and uses CI evidence to preserve healthy toke
35
35
  or verify replacements before revocation. `--secret-name '{REGISTRY}_TOKEN_{REPO}'`
36
36
  selects custom credential names.
37
37
 
38
+ For legacy crates.io consumers, use `setup --org ORG --registry crates-io
39
+ --rotate-token CARGO_TOKEN --execute --browser-import auto`. Rotation preserves
40
+ scopes and GitHub visibility, stores an expiry companion secret, verifies every
41
+ consumer in CI before revocation, and lists migration targets. Ordinary crates.io
42
+ setup uses trusted publishing. `inspect --org ORG --token-expiry-days 7` opens the
43
+ private browser token inventory for advance expiry warnings. See the
44
+ [rotation configuration](../README.md#configuration) for matching and failure behavior.
45
+
46
+ `setup --org ORG --registry crates-io --migrate --execute --browser-import auto`
47
+ proposes a reviewed conversion of existing jobs and reusable callers. After merging,
48
+ rerun it to register trust, and rerun after a real OIDC release to remove verified
49
+ legacy secrets. Shared organization secrets remain while other consumers exist.
50
+ The same flow supports `--registry npm` and `--registry pypi`; see the
51
+ [migration workflow](../README.md#configuration) for supported layouts and safeguards.
52
+
38
53
  Account scans print progress to stderr (`[37/169] owner/repository`) and stream
39
54
  text inspection results as each repository finishes. JSON inspection remains one
40
55
  report on stdout. Repository, manifest, registry lookup, and release-log failures are
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.22.11",
3
+ "version": "0.23.1",
4
4
  "description": "Inspect repositories and guide secure package-registry setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -41,7 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@link-foundation/gh-manager": "https://codeload.github.com/link-foundation/gh-manager/tar.gz/29808e088ac8fd8374361920bbebab07a4d63ac6",
44
- "browser-commander": "^0.31.2",
44
+ "browser-commander": "^0.33.2",
45
45
  "command-stream": "^2.4.0",
46
46
  "playwright": "^1.63.0",
47
47
  "selenium-webdriver": "^4.50.0"
@@ -48,13 +48,18 @@ export function completeRotation(state, repos, secret, id) {
48
48
  }
49
49
 
50
50
  /** CI drives rotation. Uncertain tests preserve every existing credential. */
51
- export async function cycleCredential(adapter) {
51
+ export async function cycleCredential(adapter, { force = false } = {}) {
52
52
  const health = await adapter.health();
53
- if (health.status === "ok") {
53
+ if (!force && health.status === "ok") {
54
54
  return { status: "ok", changed: false };
55
55
  }
56
56
  const previous = await adapter.metadata();
57
- if (health.status === "unknown" && previous && previous.present !== false) {
57
+ if (
58
+ !force &&
59
+ health.status === "unknown" &&
60
+ previous &&
61
+ previous.present !== false
62
+ ) {
58
63
  const tested = await adapter.test();
59
64
  if (tested.status === "ok") {
60
65
  return { status: "ok", changed: false };
package/src/cli-main.mjs CHANGED
@@ -35,6 +35,9 @@ Global options:
35
35
  --repository <path> Inspect only this repository (default: widest scope)
36
36
  --org <org> | --user <login> Scan repositories through gh-manager
37
37
  --secret-name <template> Secret name; supports {REGISTRY}, {REPO}, {ORG}
38
+ --rotate-token <secret> Explicitly replace a legacy crates.io token
39
+ --migrate Convert token jobs in PRs, then verify OIDC and clean up secrets
40
+ --token-expiry-days <N> Read browser token inventory in account scans
38
41
  --format <text|json> Output format (default: text)
39
42
  --offline Do not look up packages on registries
40
43
  --verbose Print command details and output, and
@@ -111,6 +114,9 @@ export async function main(args = process.argv.slice(2)) {
111
114
  org: { type: "string" },
112
115
  user: { type: "string" },
113
116
  "secret-name": { type: "string" },
117
+ "rotate-token": { type: "string" },
118
+ migrate: { type: "boolean", default: false },
119
+ "token-expiry-days": { type: "string" },
114
120
  format: { type: "string", default: "text" },
115
121
  verbose: { type: "boolean", default: false },
116
122
  offline: { type: "boolean", default: false },
@@ -174,6 +180,47 @@ export async function main(args = process.argv.slice(2)) {
174
180
  }
175
181
  const repository = path.resolve(values.repository ?? ".");
176
182
  const command = positionals[0];
183
+ const rotateToken = values["rotate-token"];
184
+ if (
185
+ values.migrate &&
186
+ (command !== "setup" ||
187
+ rotateToken !== undefined ||
188
+ !values.registry?.length ||
189
+ (values.registry ?? []).some(
190
+ (registry) => !["crates-io", "npm", "pypi"].includes(registry),
191
+ ))
192
+ ) {
193
+ throw new Error(
194
+ "--migrate requires setup with crates-io, npm or pypi and cannot use --rotate-token",
195
+ );
196
+ }
197
+ if (
198
+ rotateToken !== undefined &&
199
+ (command !== "setup" ||
200
+ (values.registry ?? []).map(parseRegistry).join() !== "crates-io" ||
201
+ !/^[A-Z_][A-Z0-9_]{0,244}$/.test(rotateToken) ||
202
+ rotateToken.startsWith("GITHUB_"))
203
+ ) {
204
+ throw new Error(
205
+ "--rotate-token requires setup --registry crates-io and a valid secret name",
206
+ );
207
+ }
208
+ const tokenExpiryDays =
209
+ values["token-expiry-days"] === undefined
210
+ ? undefined
211
+ : Number(values["token-expiry-days"]);
212
+ if (
213
+ tokenExpiryDays !== undefined &&
214
+ (!/^\d+$/.test(values["token-expiry-days"]) ||
215
+ !Number.isInteger(tokenExpiryDays) ||
216
+ tokenExpiryDays < 0 ||
217
+ tokenExpiryDays > 365 ||
218
+ !(values.org || values.user))
219
+ ) {
220
+ throw new Error(
221
+ "--token-expiry-days requires an account scan and an integer between 0 and 365",
222
+ );
223
+ }
177
224
  if (values.org || values.user) {
178
225
  if (values.org && values.user) {
179
226
  throw new Error("--org and --user are mutually exclusive");
@@ -184,7 +231,10 @@ export async function main(args = process.argv.slice(2)) {
184
231
  if (!["inspect", "plan", "setup"].includes(command)) {
185
232
  throw new Error(`unknown command '${command}'`);
186
233
  }
187
- if (command === "setup" && (!values.all || values.package)) {
234
+ if (
235
+ command === "setup" &&
236
+ ((!values.all && !rotateToken && !values.migrate) || values.package)
237
+ ) {
188
238
  throw new Error("account setup requires --all without --package");
189
239
  }
190
240
  if (values["dry-run"] && values.execute) {
@@ -215,6 +265,9 @@ export async function main(args = process.argv.slice(2)) {
215
265
  yes: values.yes,
216
266
  noBrowser: values["no-browser"],
217
267
  secretName: values["secret-name"],
268
+ rotateToken,
269
+ migrate: values.migrate,
270
+ tokenExpiryDays,
218
271
  package: values.package,
219
272
  browser: values.browser,
220
273
  browserOptions,
@@ -228,6 +281,7 @@ export async function main(args = process.argv.slice(2)) {
228
281
  workflow: values.workflow,
229
282
  publisherEnvironment: values.environment,
230
283
  addPublishJob: values["add-publish-job"],
284
+ migrate: values.migrate,
231
285
  manual: values.manual,
232
286
  verifyRelease: values["verify-release"],
233
287
  },
@@ -269,6 +323,8 @@ export async function main(args = process.argv.slice(2)) {
269
323
  ),
270
324
  });
271
325
  const planOptions = {
326
+ rotateToken,
327
+ migrate: values.migrate,
272
328
  ref: values.ref,
273
329
  verifyRelease: values["verify-release"],
274
330
  manual: values.manual,
@@ -327,8 +383,12 @@ export async function main(args = process.argv.slice(2)) {
327
383
  openWith: nonEmpty(values["open-with"], "--open-with"),
328
384
  keepSession: values["keep-session"],
329
385
  secretName: values["secret-name"],
386
+ rotateToken,
387
+ migrate: values.migrate,
330
388
  quietBrowser: selected.some((plan) =>
331
- plan.steps.some((step) => step.id === "manage-registry-token"),
389
+ plan.steps.some((step) =>
390
+ ["manage-registry-token", "rotate-crates-token"].includes(step.id),
391
+ ),
332
392
  ),
333
393
  browser: values.browser,
334
394
  browserOptions,
@@ -104,15 +104,16 @@ export async function readSession(page) {
104
104
  * unverified one stops here with the settings link.
105
105
  */
106
106
  export async function signIn(page, options = {}) {
107
+ const log = options.log ?? console.log;
107
108
  await page.goto(CRATES_IO_URL);
108
109
  let session = await readSession(page);
109
110
  const signedInBefore = session.signedIn;
110
111
  if (!session.signedIn) {
111
- console.log(
112
+ log(
112
113
  " Not signed in to crates.io; finish the GitHub sign-in in the automated browser.",
113
114
  );
114
115
  if (!(await page.evaluate(LOGIN_CLICK_SCRIPT))) {
115
- console.log(' Click "Log in with GitHub" on crates.io.');
116
+ log(' Click "Log in with GitHub" on crates.io.');
116
117
  }
117
118
  const deadline = Date.now() + (options.timeoutMs ?? SIGN_IN_TIMEOUT_MS);
118
119
  const interval = options.pollIntervalMs ?? 2_000;
@@ -124,9 +125,7 @@ export async function signIn(page, options = {}) {
124
125
  session = await readSession(page);
125
126
  }
126
127
  }
127
- console.log(
128
- ` Signed in to crates.io as ${session.login ?? "your account"}.`,
129
- );
128
+ log(` Signed in to crates.io as ${session.login ?? "your account"}.`);
130
129
  if (!session.emailVerified) {
131
130
  const sent = session.emailSent
132
131
  ? " A verification email was sent; follow its link,"
@@ -0,0 +1,265 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { githubServices } from "./github.mjs";
4
+ import {
5
+ cycleCredential,
6
+ secretName,
7
+ trackedTokenIds,
8
+ recordTokenId,
9
+ completeRotation,
10
+ } from "./ci-credential-cycle.mjs";
11
+ import { pageFetchScript, runCratesApiStep } from "./crates-api.mjs";
12
+ import {
13
+ listCratesTokens,
14
+ selectCratesToken,
15
+ replacementTokenRequest,
16
+ } from "./crates-tokens.mjs";
17
+ import { AUTH_FAILURE_PATTERNS } from "./publishing-policy.mjs";
18
+
19
+ /** Explicit maintenance of existing legacy secrets; ordinary crates setup uses OIDC. */
20
+ export async function rotateCratesToken(session) {
21
+ const { options, plan } = session;
22
+ const secret = secretName(
23
+ options.rotateToken,
24
+ "crates-io",
25
+ `${plan.repository.github_owner}/${plan.repository.github_repository}`,
26
+ );
27
+ if (
28
+ options.noBrowser ||
29
+ options.browserOptions?.attach ||
30
+ options.browserOptions?.importScope === "full"
31
+ ) {
32
+ throw new Error(
33
+ "token rotation requires a dedicated profile with domain-scoped sign-in import",
34
+ );
35
+ }
36
+ const settings =
37
+ JSON.parse(
38
+ await readFile(
39
+ path.join(options.repository, ".package-registry-manager.json"),
40
+ "utf8",
41
+ ).catch((error) => {
42
+ if (error.code === "ENOENT") {
43
+ return "{}";
44
+ }
45
+ throw error;
46
+ }),
47
+ ).tokens?.["crates-io"] ?? {};
48
+ if (settings.level && !["repo", "org"].includes(settings.level)) {
49
+ throw new Error("token secret level must be repo or org");
50
+ }
51
+ const github = options.github ?? githubServices(options);
52
+ const owner = plan.repository.github_owner;
53
+ const slug = `${owner}/${plan.repository.github_repository}`;
54
+ if (!owner || !plan.repository.github_repository) {
55
+ throw new Error("token rotation requires GitHub repository coordinates");
56
+ }
57
+ const key = `${settings.level === "repo" ? slug : owner}:${secret}`;
58
+ if (options.rotatedSecrets?.has(key)) {
59
+ const outcome = options.rotatedSecrets.get(key);
60
+ if (!outcome) {
61
+ throw new Error(
62
+ "previous rotation could not be verified; credentials retained",
63
+ );
64
+ }
65
+ session.outcome = outcome;
66
+ return;
67
+ }
68
+ options.rotatedSecrets?.set(key, null);
69
+ const targets =
70
+ settings.level === "repo"
71
+ ? [slug]
72
+ : (options.secretRepositories?.[`crates-io:${secret}`] ?? [slug]);
73
+ const scope = settings.level === "repo" ? { repo: slug } : { org: owner };
74
+ let metadata;
75
+ try {
76
+ metadata = await github.secrets(scope).getMetadata(secret);
77
+ } catch (error) {
78
+ if (![403, 404, 422].includes(error.status)) {
79
+ throw error;
80
+ }
81
+ }
82
+ metadata ??= await github.secrets({ repo: slug }).getMetadata(secret);
83
+ if (!metadata) {
84
+ throw new Error(`existing secret ${secret} was not found`);
85
+ }
86
+ // Keep access unchanged and test consumers beyond the current plan selection too.
87
+ const granted =
88
+ metadata.selected_repositories?.map((repo) => repo.full_name) ?? [];
89
+ const consumers = [...new Set([...targets, ...granted])];
90
+ if (
91
+ scope.org &&
92
+ ["all", "private"].includes(metadata.visibility) &&
93
+ !options.accountScan
94
+ ) {
95
+ throw new Error(
96
+ "rotation of an all/private organization secret requires --org to discover every consumer",
97
+ );
98
+ }
99
+ if (options.accountScan && options.scanErrors) {
100
+ throw new Error("token rotation requires a complete account scan");
101
+ }
102
+ if (
103
+ !options.yes &&
104
+ !/^y(?:es)?$/i.test(
105
+ await session.prompt(
106
+ "Replace the legacy token, verify consumers in CI, then revoke the old token? [y/N] ",
107
+ ),
108
+ )
109
+ ) {
110
+ throw new Error("credential creation declined");
111
+ }
112
+ await runCratesApiStep(session, { id: "crates-sign-in" });
113
+ const page = await session.automatedPage(options.browserOptions, true);
114
+ const previous = selectCratesToken(
115
+ await listCratesTokens(page),
116
+ secret,
117
+ metadata,
118
+ settings.token_id,
119
+ );
120
+ const request = replacementTokenRequest(previous, settings.expiry_days ?? 30);
121
+ const stateFile =
122
+ options.browserProfile &&
123
+ path.join(path.dirname(options.browserProfile), "credential-ids.json");
124
+ const state = stateFile
125
+ ? JSON.parse(
126
+ await readFile(stateFile, "utf8").catch((error) => {
127
+ if (error.code === "ENOENT") {
128
+ return "{}";
129
+ }
130
+ throw error;
131
+ }),
132
+ )
133
+ : {};
134
+ const persist = async () => {
135
+ if (stateFile) {
136
+ await mkdir(path.dirname(stateFile), { recursive: true });
137
+ await writeFile(stateFile, JSON.stringify(state));
138
+ }
139
+ };
140
+ const previousId = String(previous.id);
141
+ const retained = Object.entries(state).some(
142
+ ([entry, ids]) =>
143
+ !consumers.some((repo) => entry === `${repo}:${secret}`) &&
144
+ ids.includes(previousId),
145
+ );
146
+ if (options.verbose) {
147
+ console.error(
148
+ `Rotate crates.io token ${previousId}; verify ${consumers.join(", ")}`,
149
+ );
150
+ }
151
+ const result = await cycleCredential(
152
+ {
153
+ health: async () => ({ status: "ok" }),
154
+ metadata: async () => ({
155
+ token_id: retained ? undefined : previousId,
156
+ token_ids: trackedTokenIds(state, consumers, secret),
157
+ }),
158
+ create: async () => {
159
+ const created = await page.evaluate(
160
+ pageFetchScript("PUT", "/api/v1/me/tokens", request),
161
+ );
162
+ const token = created.body?.api_token;
163
+ if (
164
+ created.status !== 200 ||
165
+ !token?.token ||
166
+ token.id === undefined ||
167
+ token.id === null
168
+ ) {
169
+ throw new Error(
170
+ `crates.io token creation failed (${created.status})`,
171
+ );
172
+ }
173
+ return {
174
+ id: String(token.id),
175
+ value: token.token,
176
+ expires_at: token.expired_at ?? request.api_token.expired_at,
177
+ };
178
+ },
179
+ ensure: async (credential) => {
180
+ const broad =
181
+ scope.org && ["all", "private"].includes(metadata.visibility);
182
+ const stored = broad
183
+ ? {
184
+ ...(await github.secrets(scope).set(secret, credential.value, {
185
+ visibility: metadata.visibility,
186
+ expiresAt: credential.expires_at,
187
+ })),
188
+ path: "organization",
189
+ }
190
+ : await github.secrets(scope).ensure(secret, {
191
+ ...(scope.org
192
+ ? { repos: consumers, visibility: "selected" }
193
+ : {}),
194
+ health: { status: "auth-failing" },
195
+ rotateBeforeMs: 0,
196
+ acquire: async () => ({
197
+ value: credential.value,
198
+ expiresAt: credential.expires_at,
199
+ }),
200
+ });
201
+ for (const repo of consumers) {
202
+ if (
203
+ stored.path === "organization" &&
204
+ (await github.secrets({ repo }).getMetadata(secret))
205
+ ) {
206
+ await github.secrets({ repo }).set(secret, credential.value, {
207
+ expiresAt: credential.expires_at,
208
+ });
209
+ }
210
+ }
211
+ if (stored.valueChanged === false) {
212
+ throw new Error(
213
+ "gh-manager did not store the replacement credential",
214
+ );
215
+ }
216
+ recordTokenId(state, consumers, secret, credential.id);
217
+ await persist();
218
+ return stored;
219
+ },
220
+ test: async () => {
221
+ for (const repo of consumers) {
222
+ const result = await github.health.test(secret, {
223
+ scope: { repo },
224
+ failurePatterns: AUTH_FAILURE_PATTERNS["crates-io"],
225
+ });
226
+ if (result.status !== "ok") {
227
+ return { status: "unknown" };
228
+ }
229
+ }
230
+ return { status: "ok" };
231
+ },
232
+ revoke: async (id) => {
233
+ const revoked = await page.evaluate(
234
+ pageFetchScript(
235
+ "DELETE",
236
+ `/api/v1/me/tokens/${encodeURIComponent(id)}`,
237
+ ),
238
+ );
239
+ if (![200, 204].includes(revoked.status)) {
240
+ throw new Error(
241
+ `crates.io token revocation failed (${revoked.status})`,
242
+ );
243
+ }
244
+ },
245
+ revoked: async (id) =>
246
+ !(await listCratesTokens(page)).some(
247
+ (token) => String(token.id) === id,
248
+ ),
249
+ },
250
+ { force: true },
251
+ );
252
+ completeRotation(state, consumers, secret, result.token_id);
253
+ await persist();
254
+ session.outcome = {
255
+ status: "configured",
256
+ secret,
257
+ secret_scope: result.path,
258
+ fallback_reason: result.fallbackReason,
259
+ token_id: result.token_id,
260
+ };
261
+ options.rotatedSecrets?.set(key, session.outcome);
262
+ console.log(
263
+ `Migrate these repositories to crates.io trusted publishing before deleting the legacy token secret: ${consumers.join(", ")}`,
264
+ );
265
+ }
@@ -0,0 +1,110 @@
1
+ import { pageFetchScript } from "./crates-api.mjs";
2
+
3
+ /** Fetch private token metadata through the authenticated browser, without values. */
4
+ export async function listCratesTokens(page) {
5
+ const result = await page.evaluate(
6
+ pageFetchScript("GET", "/api/v1/me/tokens?expired_days=365"),
7
+ );
8
+ if (result.status !== 200 || !Array.isArray(result.body?.api_tokens)) {
9
+ throw new Error(`crates.io token inventory unavailable (${result.status})`);
10
+ }
11
+ return result.body.api_tokens.map(
12
+ ({ id, name, created_at, expired_at, crate_scopes, endpoint_scopes }) => ({
13
+ id,
14
+ name,
15
+ created_at,
16
+ expired_at,
17
+ crate_scopes,
18
+ endpoint_scopes,
19
+ }),
20
+ );
21
+ }
22
+
23
+ /** GitHub cannot reveal a secret value: refuse absent or ambiguous metadata matches. */
24
+ export function selectCratesToken(tokens, secret, metadata, id) {
25
+ const updated = Date.parse(metadata?.updated_at ?? "");
26
+ let matches;
27
+ if (id !== undefined && id !== null) {
28
+ matches = tokens.filter((token) => String(token.id) === String(id));
29
+ } else {
30
+ const named = tokens.filter(
31
+ (token) => token.name?.toUpperCase() === secret.toUpperCase(),
32
+ );
33
+ matches = named.length ? named : tokens;
34
+ if (Number.isFinite(updated)) {
35
+ matches = matches.filter(
36
+ (token) => Math.abs(Date.parse(token.created_at) - updated) <= 300_000,
37
+ );
38
+ } else if (!named.length) {
39
+ matches = [];
40
+ }
41
+ }
42
+ if (
43
+ matches.length !== 1 ||
44
+ matches[0].id === undefined ||
45
+ matches[0].id === null
46
+ ) {
47
+ throw new Error(
48
+ `cannot uniquely identify crates.io token for ${secret}; configure tokens.crates-io.token_id`,
49
+ );
50
+ }
51
+ return matches[0];
52
+ }
53
+
54
+ /** Preserve even legacy null scopes; only the name and expiry change. */
55
+ export function replacementTokenRequest(previous, days = 30, now = Date.now()) {
56
+ if (!Number.isInteger(days) || days < 1 || days > 90) {
57
+ throw new Error("token expiry_days must be between 1 and 90");
58
+ }
59
+ for (const key of ["crate_scopes", "endpoint_scopes"]) {
60
+ if (
61
+ previous[key] !== null &&
62
+ (!Array.isArray(previous[key]) ||
63
+ previous[key].some((scope) => typeof scope !== "string"))
64
+ ) {
65
+ throw new Error(
66
+ `crates.io token has no usable ${key}; refusing to broaden scopes`,
67
+ );
68
+ }
69
+ }
70
+ return {
71
+ api_token: {
72
+ name: previous.name,
73
+ crate_scopes: previous.crate_scopes,
74
+ endpoint_scopes: previous.endpoint_scopes,
75
+ expired_at: new Date(now + days * 86_400_000).toISOString(),
76
+ },
77
+ };
78
+ }
79
+
80
+ /** Include expired tokens and the inclusive lead-time boundary. */
81
+ export function tokenExpiryFindings(
82
+ tokens,
83
+ account,
84
+ days = 7,
85
+ now = Date.now(),
86
+ ) {
87
+ if (!Number.isInteger(days) || days < 0 || days > 365) {
88
+ throw new Error("--token-expiry-days must be an integer between 0 and 365");
89
+ }
90
+ return tokens.flatMap((token) => {
91
+ const expiry = Date.parse(token.expired_at ?? "");
92
+ return Number.isFinite(expiry) && expiry <= now + days * 86_400_000
93
+ ? [
94
+ {
95
+ repository: account,
96
+ registry: "crates-io",
97
+ type:
98
+ expiry <= now
99
+ ? "registry-token-expired"
100
+ : "registry-token-expiring",
101
+ evidence: {
102
+ token_id: token.id,
103
+ name: token.name,
104
+ expires_at: token.expired_at,
105
+ },
106
+ },
107
+ ]
108
+ : [];
109
+ });
110
+ }
package/src/github.mjs CHANGED
@@ -25,6 +25,7 @@ export function githubServices(options = {}) {
25
25
  };
26
26
  const context = { rest, log };
27
27
  return {
28
+ rest,
28
29
  repos: createRepoManager(context),
29
30
  runs: createRunManager(context),
30
31
  health: createSecretHealth(context),