package-registry-manager 0.22.5 → 0.22.7

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/README.md CHANGED
@@ -22,3 +22,4 @@ version after confirmation; later versions publish through CI. Missing CI
22
22
  publishing jobs are offered in a draft pull request, and PyPI builds select an
23
23
  interpreter satisfying `requires-python`. See the repository README for the
24
24
  registry walkthroughs and safety details.
25
+ See the [bootstrap, wrapper and credential guide](../docs/registry-setup.md) for branch/PR setup, dual npm names, registry policies and integration limits.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "package-registry-manager",
3
- "version": "0.22.5",
3
+ "version": "0.22.7",
4
4
  "description": "Inspect repositories and guide secure package-registry setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  "provenance": true
29
29
  },
30
30
  "scripts": {
31
- "syntax": "node --check src/cli.mjs && node --check src/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs && node --check src/python.mjs && node --check src/setup-batch.mjs && node --check src/workflow-proposal.mjs",
31
+ "syntax": "node --check src/cli.mjs && node --check src/direct-execution.mjs && node --check src/index.mjs && node --check src/browser.mjs && node --check src/browser-options.mjs && node --check src/browser-catalogue.mjs && node --check src/automation.mjs && node --check src/webdriver-automation.mjs && node --check src/profile.mjs && node --check src/setup.mjs && node --check src/auth-urls.mjs && node --check src/containers.mjs && node --check src/flows.mjs && node --check src/prerequisites.mjs && node --check src/registry-state.mjs && node --check src/workflows.mjs && node --check src/publishers.mjs && node --check src/skips.mjs && node --check src/tokens.mjs && node --check src/approvals.mjs && node --check src/default-browser.mjs && node --check src/pages.mjs && node --check src/crates-api.mjs && node --check src/sign-in-import.mjs && node --check scripts/publish-to-npm.mjs && node --check scripts/setup-npm.mjs && node --check scripts/sanitize-npm-userconfig.mjs && node --check src/python.mjs && node --check src/setup-batch.mjs && node --check src/workflow-proposal.mjs && node --check src/repository-identity.mjs && node --check src/repository-repair.mjs && node --check src/manifest-proposal.mjs",
32
32
  "lint": "eslint src test scripts --max-warnings 0",
33
33
  "lint:fix": "eslint src test scripts --fix --max-warnings 0",
34
34
  "format": "prettier --write .",
@@ -40,8 +40,8 @@
40
40
  "audit": "node scripts/npm-audit.mjs"
41
41
  },
42
42
  "dependencies": {
43
- "browser-commander": "^0.26.0",
44
- "command-stream": "^1.6.2",
43
+ "browser-commander": "^0.26.3",
44
+ "command-stream": "^2.0.0",
45
45
  "playwright": "^1.63.0",
46
46
  "selenium-webdriver": "^4.50.0"
47
47
  },
@@ -0,0 +1,76 @@
1
+ import { mkdtemp, rm } from "node:fs/promises";
2
+ import { spawnSync } from "node:child_process";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { inspectRepository } from "./discovery.mjs";
6
+
7
+ /** Translate a branch, PR number, or GitHub pull-request URL into a fetch ref. */
8
+ export function bootstrapRef(ref) {
9
+ const pull =
10
+ /^(?:https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/)?(\d+)$/.exec(ref);
11
+ const result = pull ? `refs/pull/${pull[1]}/head` : ref;
12
+ if (
13
+ !/^[\w][\w./-]*$/.test(result) ||
14
+ result.includes("..") ||
15
+ result.includes("//") ||
16
+ result.endsWith("/") ||
17
+ result.toLowerCase().endsWith(".lock")
18
+ ) {
19
+ throw new Error("invalid bootstrap ref");
20
+ }
21
+ return result;
22
+ }
23
+
24
+ /** Inspect a pushed ref in a disposable worktree without changing the checkout. */
25
+ export async function inspectReference(repository, ref, options) {
26
+ const run = (args) => {
27
+ const result = spawnSync("git", args, {
28
+ cwd: repository,
29
+ encoding: "utf8",
30
+ });
31
+ if (result.status !== 0) {
32
+ throw new Error(`git failed while inspecting ref: ${result.stderr}`);
33
+ }
34
+ return result.stdout.trim();
35
+ };
36
+ const temporary = await mkdtemp(path.join(os.tmpdir(), "prm-ref-"));
37
+ const checkout = path.join(temporary, "checkout");
38
+ let created = false;
39
+ try {
40
+ run(["fetch", "origin", "HEAD"]);
41
+ const main = run(["rev-parse", "FETCH_HEAD"]);
42
+ run(["fetch", "origin", bootstrapRef(ref)]);
43
+ run(["worktree", "add", "--detach", checkout, "FETCH_HEAD"]);
44
+ created = true;
45
+ const inspection = await inspectRepository(checkout, options);
46
+ for (const item of inspection.packages.filter(
47
+ (item) => item.registry === "npm",
48
+ )) {
49
+ const result = spawnSync("git", ["show", `${main}:${item.manifest}`], {
50
+ cwd: repository,
51
+ encoding: "utf8",
52
+ });
53
+ let relationship = "manifest is absent from remote main";
54
+ if (result.status === 0) {
55
+ const data = JSON.parse(result.stdout);
56
+ relationship = `remote main is ${data.name}@${data.version}`;
57
+ }
58
+ item.warnings = [
59
+ ...(item.warnings ?? []),
60
+ `bootstrap ${item.name}@${item.version} from ${ref}; ${relationship}; the next main release must use an unpublished version`,
61
+ ];
62
+ }
63
+ inspection.repository.root = await import("node:fs/promises").then(
64
+ ({ realpath }) => realpath(repository),
65
+ );
66
+ return inspection;
67
+ } finally {
68
+ try {
69
+ if (created) {
70
+ run(["worktree", "remove", "--force", checkout]);
71
+ }
72
+ } finally {
73
+ await rm(temporary, { recursive: true, force: true });
74
+ }
75
+ }
76
+ }
package/src/cli.mjs CHANGED
@@ -9,6 +9,7 @@ import { parseArgs } from "node:util";
9
9
  import { launchChannels } from "./browser-catalogue.mjs";
10
10
  import { parseBrowserOptions } from "./browser-options.mjs";
11
11
  import { isDirectExecution } from "./direct-execution.mjs";
12
+ import { inspectReference } from "./bootstrap-reference.mjs";
12
13
  import { inspectRepository } from "./discovery.mjs";
13
14
  import { parseRegistry } from "./model.mjs";
14
15
  import { buildPlans } from "./plan.mjs";
@@ -49,6 +50,7 @@ Plan and setup options:
49
50
  --add-publish-job Propose missing publishing jobs in a draft PR;
50
51
  --workflow chooses its target file
51
52
  --environment <name> GitHub environment of the trusted publisher
53
+ --ref <branch|pr> Bootstrap a pushed branch or PR head before merge
52
54
  --package <name> Plan only this package, or select it for
53
55
  setup when a registry has several
54
56
 
@@ -105,6 +107,7 @@ export async function main(args = process.argv.slice(2)) {
105
107
  allowPositionals: true,
106
108
  strict: true,
107
109
  options: {
110
+ ref: { type: "string" },
108
111
  repository: { type: "string", default: "." },
109
112
  format: { type: "string", default: "text" },
110
113
  verbose: { type: "boolean", default: false },
@@ -165,9 +168,11 @@ export async function main(args = process.argv.slice(2)) {
165
168
  });
166
169
 
167
170
  const repository = path.resolve(values.repository);
168
- const discovered = await inspectRepository(repository, {
169
- includeSkipped: values.verbose,
170
- });
171
+ const discovered = values.ref
172
+ ? await inspectReference(repository, values.ref, {
173
+ includeSkipped: values.verbose,
174
+ })
175
+ : await inspectRepository(repository, { includeSkipped: values.verbose });
171
176
  const inspection = values.offline
172
177
  ? discovered
173
178
  : await probeRegistryState(discovered, { verbose: values.verbose });
@@ -198,6 +203,7 @@ export async function main(args = process.argv.slice(2)) {
198
203
  ),
199
204
  });
200
205
  const planOptions = {
206
+ ref: values.ref,
201
207
  verifyRelease: values["verify-release"],
202
208
  manual: values.manual,
203
209
  workflow: await workflowOverride(
@@ -414,6 +420,9 @@ function outputPlans(plans, format) {
414
420
  for (const plan of plans) {
415
421
  const mode = plan.mode ? ` (${plan.mode})` : "";
416
422
  process.stdout.write(`${plan.registry}: ${plan.package.name}${mode}\n`);
423
+ process.stdout.write(
424
+ ` credentials: ${plan.credential_policy.description}\n`,
425
+ );
417
426
  if (plan.mode === "complete") {
418
427
  process.stdout.write(" trusted publishing is already in use\n");
419
428
  }
@@ -1,3 +1,5 @@
1
+ import { tokenSecrets } from "./publishers.mjs";
2
+ import { stripComments } from "./source-code.mjs";
1
3
  import { grantsPackagesWrite, publishingWorkflow } from "./workflows.mjs";
2
4
 
3
5
  export const CONTAINER_FILES = new Set(["Dockerfile", "Containerfile"]);
@@ -27,7 +29,8 @@ export function containerPackages(dockerfiles, workflows, coordinates) {
27
29
  continue;
28
30
  }
29
31
  const name =
30
- literalImage(workflow.contents, host) ?? defaultImage(coordinates);
32
+ literalImage(stripComments(workflow.contents), host) ??
33
+ defaultImage(coordinates);
31
34
  const item = {
32
35
  registry,
33
36
  name: name ?? "unknown-image",
@@ -36,6 +39,10 @@ export function containerPackages(dockerfiles, workflows, coordinates) {
36
39
  publishable: Boolean(name),
37
40
  workflow: workflow.name,
38
41
  };
42
+ const secrets = tokenSecrets(workflows, registry);
43
+ if (secrets.length) {
44
+ item.token_secrets = [...new Set(secrets.map((secret) => secret.secret))];
45
+ }
39
46
  if (!name) {
40
47
  item.problems = [
41
48
  "the image name could not be derived from the workflow or a GitHub remote",
@@ -0,0 +1,82 @@
1
+ /** Account pages used only in the dedicated automated browser. */
2
+ export const TOKEN_PROVIDERS = Object.freeze({
3
+ "docker-hub": {
4
+ url: "https://app.docker.com/settings/personal-access-tokens/create",
5
+ secret: "DOCKERHUB_TOKEN",
6
+ scope: "Read & Write",
7
+ domains: ["docker.com", "docker.io"],
8
+ },
9
+ "maven-central": {
10
+ url: "https://central.sonatype.com/usertoken",
11
+ secret: "MAVEN_CENTRAL_TOKEN",
12
+ scope: "publish",
13
+ domains: ["sonatype.com"],
14
+ },
15
+ "vscode-marketplace": {
16
+ url: "https://dev.azure.com/_usersSettings/tokens",
17
+ secret: "VSCE_PAT",
18
+ scope: "Marketplace (Manage)",
19
+ domains: ["dev.azure.com", "login.microsoftonline.com"],
20
+ },
21
+ "open-vsx": {
22
+ url: "https://open-vsx.org/user-settings/tokens",
23
+ secret: "OVSX_PAT",
24
+ scope: "publish",
25
+ domains: ["open-vsx.org", "github.com"],
26
+ },
27
+ "chrome-web-store": {
28
+ url: "https://developers.google.com/oauthplayground/",
29
+ secret: "CHROME_WEB_STORE_REFRESH_TOKEN",
30
+ scope: "https://www.googleapis.com/auth/chromewebstore",
31
+ domains: ["google.com"],
32
+ },
33
+ });
34
+
35
+ /** Fill only credential name, publishing scope, repository and expiry. Never widen scope. */
36
+ export function tokenFormScript(provider, name, expiresAt) {
37
+ return `(() => {
38
+ const values = ${JSON.stringify({ name, scope: provider.scope, expires: expiresAt.slice(0, 10) })};
39
+ const fields = { name: ['input[name="name"]', 'input[name="description"]', 'input[name="tokenName"]'], scope: ['select[name="scope"]', 'select[name="permissions"]'], expires: ['input[type="date"]', 'input[name="expires_at"]'] };
40
+ const filled = [];
41
+ for (const [key, selectors] of Object.entries(fields)) {
42
+ const field = selectors.map(selector => document.querySelector(selector)).find(Boolean);
43
+ if (!field) continue;
44
+ if (field.tagName === 'SELECT') {
45
+ const option = [...field.options].find(option => option.textContent.trim() === values[key] || option.value === values[key]);
46
+ if (!option) continue;
47
+ field.value = option.value;
48
+ } else field.value = values[key];
49
+ field.dispatchEvent(new Event('input', { bubbles: true }));
50
+ field.dispatchEvent(new Event('change', { bubbles: true }));
51
+ filled.push(key);
52
+ }
53
+ return { filled };
54
+ })()`;
55
+ }
56
+
57
+ /** Read the one-time value without returning it in any displayed diagnostic. */
58
+ export const READ_TOKEN = `(() => {
59
+ const read = selectors => selectors.map(selector => document.querySelector(selector)).find(Boolean);
60
+ const value = read(['input[data-testid="token-value"]', 'input[name="token"]', 'textarea[name="token"]', '[data-testid="generated-token"]', 'input[name="refresh_token"]']);
61
+ const id = read(['[data-token-id]', 'input[name="token_id"]']);
62
+ const expires = read(['input[name="expires_at"]', '[data-expires-at]']);
63
+ const rawExpiry = expires?.value || expires?.dataset?.expiresAt;
64
+ const expiry = rawExpiry && /^\\d{4}-\\d{2}-\\d{2}$/.test(rawExpiry) ? rawExpiry + 'T00:00:00Z' : rawExpiry;
65
+ return { value: value?.value || value?.textContent?.trim(), id: id?.dataset?.tokenId || id?.value, expires_at: expiry };
66
+ })()`;
67
+
68
+ /** Verify absence only when the token list itself is visible and identifies rows. */
69
+ export function revokedScript(id) {
70
+ if (typeof id !== "string" || id.length > 512) {
71
+ return "false";
72
+ }
73
+ const characters = Array.from(id);
74
+ if (characters.length === 0 || characters.length > 256) {
75
+ return "false";
76
+ }
77
+ // Only numeric literals enter generated code; registry text remains data.
78
+ const points = characters
79
+ .map((character) => character.codePointAt(0))
80
+ .join(",");
81
+ return `(() => { const id = String.fromCodePoint(${points}); const list = document.querySelector('[data-testid="tokens-list"], [data-token-list]'); if (!list) return false; return ![...list.querySelectorAll('[data-token-id]')].some(row => row.dataset.tokenId === id); })()`;
82
+ }
@@ -0,0 +1,91 @@
1
+ /** Registry-specific policy; tokens are never a fallback for OIDC registries. */
2
+ export function credentialPolicy(registry) {
3
+ if (
4
+ ["npm", "pypi", "crates-io", "rubygems", "nuget", "jsr"].includes(registry)
5
+ ) {
6
+ return {
7
+ mode: "trusted",
8
+ description: `${registry}: trusted publishing; remove unused long-lived tokens after verification`,
9
+ };
10
+ }
11
+ if (registry === "ghcr") {
12
+ return {
13
+ mode: "github-token",
14
+ description:
15
+ "ghcr: GITHUB_TOKEN with packages: write; no registry secret",
16
+ };
17
+ }
18
+ if (["go-modules", "packagist"].includes(registry)) {
19
+ return {
20
+ mode: "tags",
21
+ description: `${registry}: public repository tags; no publishing token secret`,
22
+ };
23
+ }
24
+ return {
25
+ mode: "token",
26
+ description: `${registry}: scoped expiring token; gh-manager stores selected-repository organization secrets by default`,
27
+ };
28
+ }
29
+
30
+ /** Unknown expiry also requires attention; GitHub cannot return secret values. */
31
+ export function needsRotation(metadata, now = Date.now(), leadDays = 7) {
32
+ const expires = Date.parse(metadata.expires_at ?? "");
33
+ return (
34
+ metadata.present !== true ||
35
+ metadata.valid === false ||
36
+ !Number.isFinite(expires) ||
37
+ expires <= now + leadDays * 86_400_000
38
+ );
39
+ }
40
+
41
+ /** Provision, store, validate, then revoke. A failed validation preserves the old token. */
42
+ export async function rotateCredential(previous, adapter) {
43
+ let created;
44
+ let stored = false;
45
+ try {
46
+ created = await adapter.create();
47
+ if (
48
+ !created.value ||
49
+ !created.id ||
50
+ !Number.isFinite(Date.parse(created.expires_at)) ||
51
+ Date.parse(created.expires_at) <= Date.now()
52
+ ) {
53
+ throw new Error("registry returned no usable expiring credential");
54
+ }
55
+ stored = true; // A failed ensure can have stored the secret before expiry metadata failed.
56
+ await adapter.store(created);
57
+ await adapter.verify();
58
+ if (previous.token_id && previous.token_id !== created.id) {
59
+ await adapter.revoke(previous.token_id);
60
+ if (!(await adapter.revoked(previous.token_id))) {
61
+ throw new Error("previous token revocation could not be verified");
62
+ }
63
+ }
64
+ return { token_id: created.id, expires_at: created.expires_at };
65
+ } catch (error) {
66
+ if (created?.id && (!stored || adapter.restore)) {
67
+ if (stored) {
68
+ await adapter.restore();
69
+ }
70
+ await adapter.revoke(created.id);
71
+ }
72
+ throw error;
73
+ } finally {
74
+ if (created) {
75
+ created.value = "";
76
+ }
77
+ }
78
+ }
79
+
80
+ /** Token-registry plan; setup obtains the value from its dedicated browser. */
81
+ export function credentialSteps(registry, secret) {
82
+ return [
83
+ {
84
+ id: "manage-registry-token",
85
+ title: `Ensure and rotate ${secret}`,
86
+ kind: "api",
87
+ description: `${credentialPolicy(registry).description}. Check expiry and validity, create the narrowest publishing credential in the browser, store through gh-manager stdin, verify in a dry-run workflow, then revoke and verify the replaced token. Requires gh-manager secret support and a configured verification workflow.`,
88
+ confirm: true,
89
+ },
90
+ ];
91
+ }
@@ -0,0 +1,210 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { verifyCredentialWorkflow } from "./credential-verification.mjs";
5
+ import { needsRotation, rotateCredential } from "./credential-cycle.mjs";
6
+ import {
7
+ TOKEN_PROVIDERS,
8
+ tokenFormScript,
9
+ READ_TOKEN,
10
+ revokedScript,
11
+ } from "./credential-browser.mjs";
12
+
13
+ /** Exact argv and stdin transport. Suppress all output from secret mutations. */
14
+ export function secretCommand(args, input, cwd) {
15
+ return new Promise((resolve, reject) => {
16
+ const child = spawn("gh-manager", args, {
17
+ cwd,
18
+ stdio: ["pipe", "pipe", "pipe"],
19
+ });
20
+ let output = "";
21
+ child.stdout.on("data", (chunk) => {
22
+ if (input === undefined) {
23
+ output += chunk;
24
+ }
25
+ });
26
+ child.stderr.on("data", () => {});
27
+ child.on("error", () =>
28
+ reject(
29
+ new Error(
30
+ "gh-manager with secret get-metadata/ensure support is required (gh-manager#6)",
31
+ ),
32
+ ),
33
+ );
34
+ child.stdin.on("error", () => {});
35
+ child.on("close", (code) =>
36
+ code === 0
37
+ ? resolve(output)
38
+ : reject(
39
+ new Error(
40
+ "gh-manager secret operation failed; verify installation, permissions and secret support (gh-manager#6)",
41
+ ),
42
+ ),
43
+ );
44
+ child.stdin.end(input ?? "");
45
+ });
46
+ }
47
+
48
+ /** Execute the token lifecycle without putting values in argv, diagnostics or files. */
49
+ export async function setupCredential(session) {
50
+ const { plan, options } = session;
51
+ const provider = TOKEN_PROVIDERS[plan.registry];
52
+ if (!provider) {
53
+ throw new Error(`no token provider for ${plan.registry}`);
54
+ }
55
+ const contents = await readFile(
56
+ path.join(options.repository, ".package-registry-manager.json"),
57
+ "utf8",
58
+ ).catch((error) => {
59
+ if (error.code === "ENOENT") {
60
+ return "{}";
61
+ }
62
+ throw error;
63
+ });
64
+ const settings = JSON.parse(contents).tokens?.[plan.registry] ?? {};
65
+ if (!/^[\w.-]+\.ya?ml$/.test(settings.verification_workflow ?? "")) {
66
+ throw new Error(
67
+ "configure tokens.<registry>.verification_workflow for a reviewed dry-run login workflow before creating credentials",
68
+ );
69
+ }
70
+ const { github_owner: owner, github_repository: repository } =
71
+ plan.repository;
72
+ if (!owner || !repository) {
73
+ throw new Error("token setup requires GitHub repository coordinates");
74
+ }
75
+ const secret =
76
+ settings.secret ?? plan.package.token_secrets?.[0] ?? provider.secret;
77
+ if (!/^[A-Z][A-Z0-9_]*$/.test(secret)) {
78
+ throw new Error("invalid registry secret name");
79
+ }
80
+ if (settings.level && !["repo", "org"].includes(settings.level)) {
81
+ throw new Error("token secret level must be repo or org");
82
+ }
83
+ const scope =
84
+ settings.level === "repo"
85
+ ? ["--repo", `${owner}/${repository}`]
86
+ : ["--org", owner];
87
+ const visibility =
88
+ settings.level === "repo"
89
+ ? []
90
+ : ["--visibility", "selected", "--repos", `${owner}/${repository}`];
91
+ const workflow = await readFile(
92
+ path.join(
93
+ options.repository,
94
+ ".github/workflows",
95
+ settings.verification_workflow,
96
+ ),
97
+ "utf8",
98
+ );
99
+ if (
100
+ !workflow.includes("workflow_dispatch") ||
101
+ !workflow.includes("inputs.prm_nonce") ||
102
+ !workflow.includes("run-name:")
103
+ ) {
104
+ throw new Error(
105
+ "verification workflow requires workflow_dispatch input prm_nonce and run-name containing inputs.prm_nonce",
106
+ );
107
+ }
108
+ const days = settings.expiry_days ?? 30;
109
+ if (!Number.isInteger(days) || days < 1 || days > 90) {
110
+ throw new Error("token expiry_days must be between 1 and 90");
111
+ }
112
+ if (options.noBrowser) {
113
+ throw new Error(
114
+ "registry token creation requires the dedicated automated browser",
115
+ );
116
+ }
117
+ if (
118
+ options.browserOptions?.attach ||
119
+ options.browserOptions?.importScope === "full"
120
+ ) {
121
+ throw new Error(
122
+ "token setup requires a dedicated profile with domain-scoped sign-in import",
123
+ );
124
+ }
125
+ const run = options.secretCommand ?? secretCommand;
126
+ const previous = JSON.parse(
127
+ await run(
128
+ ["secret", "get-metadata", secret, ...scope, "--json"],
129
+ undefined,
130
+ options.repository,
131
+ ),
132
+ );
133
+ const verify = () =>
134
+ verifyCredentialWorkflow(session, settings.verification_workflow);
135
+ if (!needsRotation(previous)) {
136
+ try {
137
+ await verify();
138
+ return;
139
+ } catch {
140
+ previous.valid = false;
141
+ }
142
+ }
143
+ if (
144
+ !options.yes &&
145
+ !/^y(?:es)?$/i.test(
146
+ await session.prompt(
147
+ "Create and store a scoped credential, verify it, then revoke the replaced token? [y/N] ",
148
+ ),
149
+ )
150
+ ) {
151
+ throw new Error("credential creation declined");
152
+ }
153
+ if (session.automation) {
154
+ await session.automation.close();
155
+ }
156
+ session.automation = null;
157
+ const page = await session.automatedPage(
158
+ { ...options.browserOptions, importScope: "domains" },
159
+ true,
160
+ );
161
+ const expiry = new Date(Date.now() + days * 86_400_000).toISOString();
162
+ await rotateCredential(previous, {
163
+ create: async () => {
164
+ await page.goto(provider.url);
165
+ await session.prompt(
166
+ `Sign in, then press Enter to fill the narrow publishing scope (${provider.scope})...`,
167
+ );
168
+ await page.evaluate(
169
+ tokenFormScript(provider, `prm-${repository}`, expiry),
170
+ );
171
+ await session.prompt(
172
+ `Review the scope and expiry, create the token in this browser, then press Enter (never paste its value)...`,
173
+ );
174
+ const credential = await page.evaluate(READ_TOKEN);
175
+ if (!credential.expires_at) {
176
+ throw new Error(
177
+ "registry did not provide a verifiable expiry; no secret was stored",
178
+ );
179
+ }
180
+ return credential;
181
+ },
182
+ store: (credential) =>
183
+ run(
184
+ [
185
+ "secret",
186
+ "ensure",
187
+ secret,
188
+ ...scope,
189
+ ...visibility,
190
+ "--expires-at",
191
+ credential.expires_at,
192
+ "--token-id",
193
+ credential.id,
194
+ "--registry",
195
+ plan.registry,
196
+ ],
197
+ credential.value,
198
+ options.repository,
199
+ ),
200
+ verify,
201
+ revoke: async (id) => {
202
+ await page.goto(provider.url.replace(/\/create$/, ""));
203
+ await session.prompt(
204
+ `Revoke registry token ${id} in this browser, then press Enter...`,
205
+ );
206
+ },
207
+ revoked: async (id) => page.evaluate(revokedScript(id)),
208
+ });
209
+ console.log(" Credential verified; replaced token revocation verified.");
210
+ }
@@ -0,0 +1,96 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+
4
+ /** Correlate dispatch by a unique run-name input, never by the latest unrelated run. */
5
+ export async function verifyCredentialWorkflow(session, workflow) {
6
+ const { github_owner: owner, github_repository: repository } =
7
+ session.plan.repository;
8
+ const repo = `${owner}/${repository}`;
9
+ const capture = async (args) => {
10
+ const result = await session.capture({
11
+ id: "verify-registry-login",
12
+ command: { program: "gh", args },
13
+ cwd: ".",
14
+ });
15
+ if (result.code !== 0) {
16
+ throw new Error(
17
+ "credential verification command failed; old token remains active",
18
+ );
19
+ }
20
+ return String(result.stdout);
21
+ };
22
+ const metadata = JSON.parse(
23
+ await capture(["repo", "view", repo, "--json", "defaultBranchRef"]),
24
+ );
25
+ const ref = metadata.defaultBranchRef?.name;
26
+ if (!ref) {
27
+ throw new Error("cannot determine verification workflow branch");
28
+ }
29
+ const nonce = session.options.verificationNonce ?? randomUUID();
30
+ await capture([
31
+ "workflow",
32
+ "run",
33
+ workflow,
34
+ "--repo",
35
+ repo,
36
+ "--ref",
37
+ ref,
38
+ "-f",
39
+ `prm_nonce=${nonce}`,
40
+ ]);
41
+ const deadline =
42
+ Date.now() + (session.options.verificationTimeoutMs ?? 300_000);
43
+ let runId;
44
+ while (Date.now() < deadline) {
45
+ if (!runId) {
46
+ const runs = JSON.parse(
47
+ await capture([
48
+ "run",
49
+ "list",
50
+ "--repo",
51
+ repo,
52
+ "--workflow",
53
+ workflow,
54
+ "--event",
55
+ "workflow_dispatch",
56
+ "--limit",
57
+ "50",
58
+ "--json",
59
+ "databaseId,displayTitle",
60
+ ]),
61
+ );
62
+ const matching = runs.filter((run) => run.displayTitle?.includes(nonce));
63
+ if (matching.length > 1) {
64
+ throw new Error(
65
+ "ambiguous credential verification runs; old token remains active",
66
+ );
67
+ }
68
+ runId = matching[0]?.databaseId;
69
+ }
70
+ if (runId) {
71
+ const run = JSON.parse(
72
+ await capture([
73
+ "run",
74
+ "view",
75
+ String(runId),
76
+ "--repo",
77
+ repo,
78
+ "--json",
79
+ "status,conclusion",
80
+ ]),
81
+ );
82
+ if (run.status === "completed") {
83
+ if (run.conclusion !== "success") {
84
+ throw new Error(
85
+ "registry rejected credential verification; old token remains active",
86
+ );
87
+ }
88
+ return;
89
+ }
90
+ }
91
+ await (session.options.verificationDelay ?? delay)(2000);
92
+ }
93
+ throw new Error(
94
+ "credential verification timed out; old token remains active",
95
+ );
96
+ }