package-registry-manager 0.22.5 → 0.22.6
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 +1 -0
- package/package.json +3 -3
- package/src/bootstrap-reference.mjs +76 -0
- package/src/cli.mjs +12 -3
- package/src/containers.mjs +8 -1
- package/src/credential-browser.mjs +82 -0
- package/src/credential-cycle.mjs +91 -0
- package/src/credential-setup.mjs +210 -0
- package/src/credential-verification.mjs +96 -0
- package/src/discovery.mjs +97 -7
- package/src/extra-flows.mjs +88 -0
- package/src/flows.mjs +29 -19
- package/src/model.mjs +15 -0
- package/src/npm-policy.mjs +69 -0
- package/src/package-coverage.mjs +106 -0
- package/src/plan.mjs +28 -30
- package/src/publishers.mjs +113 -27
- package/src/setup.mjs +45 -4
- package/src/sign-in-import.mjs +8 -0
- package/src/skips.mjs +3 -0
- package/src/source-code.mjs +61 -0
- package/src/tokens.mjs +8 -0
- package/src/version-guard.mjs +34 -0
- package/src/workflow-proposal.mjs +32 -3
- package/src/workflows.mjs +49 -10
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.
|
|
3
|
+
"version": "0.22.6",
|
|
4
4
|
"description": "Inspect repositories and guide secure package-registry setup",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"audit": "node scripts/npm-audit.mjs"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"browser-commander": "^0.26.
|
|
44
|
-
"command-stream": "^
|
|
43
|
+
"browser-commander": "^0.26.2",
|
|
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 =
|
|
169
|
-
|
|
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
|
}
|
package/src/containers.mjs
CHANGED
|
@@ -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) ??
|
|
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
|
+
}
|