@decohere/ci-tools 0.0.1-dev.126 → 0.0.1-dev.13
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/package.json +3 -18
- package/src/audit/enforce.mjs +5 -7
- package/src/audit/summarize.mjs +32 -10
- package/src/audit/upsert-issue.mjs +74 -25
- package/src/bench/bench.mjs +368 -134
- package/src/bench/trace.mjs +14 -18
- package/src/coverage/enforce-thresholds.mjs +144 -0
- package/src/pages/artifact.mjs +183 -38
- package/src/pages/clear-bench-history.mjs +27 -0
- package/src/pages/keep-prior-bench-badge.mjs +38 -0
- package/src/pages/restore-bench-history.mjs +28 -15
- package/src/release/npm-links.mjs +13 -6
- package/src/release/sync-dependencies.mjs +2 -1
- package/tests/lib/lcov.test.mjs +4 -4
- package/LICENSE +0 -15
- package/src/audit/format.mjs +0 -133
- package/src/bench/file-regressions.mjs +0 -76
- package/src/bench/history.mjs +0 -248
- package/src/bench/nightly.mjs +0 -118
- package/src/bench/regression-issue.mjs +0 -44
- package/src/github/credential-nudge.mjs +0 -226
- package/src/github/npm-bootstrap.mjs +0 -179
- package/src/github/oidc-bootstrap-nudge.mjs +0 -214
- package/src/github/publish-verify.mjs +0 -206
- package/src/github/smoke-create-launcher.mjs +0 -70
- package/src/lib/github-issue.mjs +0 -75
- package/src/lib/workspace-packages.mjs +0 -30
- package/src/pages/bench-page.mjs +0 -323
- package/src/pages/restore-badges.mjs +0 -58
- package/tests/audit/format.test.mjs +0 -115
- package/tests/bench/history.test.mjs +0 -279
- package/tests/bench/regression-issue.test.mjs +0 -42
- package/tests/github/credential-nudge.test.mjs +0 -112
- package/tests/github/npm-bootstrap.test.mjs +0 -125
- package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
- package/tests/github/package-metadata.test.mjs +0 -69
- package/tests/github/publish-verify.test.mjs +0 -115
- package/tests/github/smoke-create-launcher.test.mjs +0 -71
- package/tests/lib/github-issue.test.mjs +0 -85
- package/tests/pages/bench-page.test.mjs +0 -123
|
@@ -1,226 +0,0 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { upsertIssue } from "../lib/github-issue.mjs";
|
|
4
|
-
|
|
5
|
-
const WARN_DAYS = 14;
|
|
6
|
-
const MS_PER_DAY = 86_400_000;
|
|
7
|
-
const DEFAULT_REPO = "typesmiths/decohere";
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* One profile per credential (repo secret + repo variable). Date math, GitHub issue
|
|
11
|
-
* upsert, and fail-closed live below.
|
|
12
|
-
*
|
|
13
|
-
* @typedef {{
|
|
14
|
-
* id: string,
|
|
15
|
-
* secret: string,
|
|
16
|
-
* expiresVar: string,
|
|
17
|
-
* mint: string,
|
|
18
|
-
* failImpact: string,
|
|
19
|
-
* rejectedWhy: string,
|
|
20
|
-
* revokeUrl: string,
|
|
21
|
-
* }} CredentialProfile
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
/** @type {Record<string, CredentialProfile>} */
|
|
25
|
-
export const CREDENTIALS = {
|
|
26
|
-
npm: {
|
|
27
|
-
id: "npm",
|
|
28
|
-
secret: "NODE_AUTH_TOKEN",
|
|
29
|
-
expiresVar: "NODE_AUTH_TOKEN_EXPIRES_AT",
|
|
30
|
-
mint: "Create an npm **read** token (granular/classic) at https://www.npmjs.com/settings/~/tokens — Publish PUTs are OIDC; this token is only for private `npm view` and create smoke.",
|
|
31
|
-
failImpact:
|
|
32
|
-
"Verify cannot see private packages, so skipIfPublished and create smoke are unsafe. Publish **fails closed** before OIDC/Docker. After you rotate, re-run the failed Publish jobs.",
|
|
33
|
-
rejectedWhy: "`npm whoami` failed (expired, revoked, or missing `NODE_AUTH_TOKEN`).",
|
|
34
|
-
revokeUrl: "https://www.npmjs.com/settings/~/tokens",
|
|
35
|
-
},
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export function utcDayMs(date) {
|
|
39
|
-
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/** @param {string | undefined} raw */
|
|
43
|
-
export function parseExpiresAt(raw) {
|
|
44
|
-
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(raw ?? "").trim());
|
|
45
|
-
if (!match) {
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @param {{ now?: number, expiresAtMs: number | null, warnDays?: number }} options
|
|
53
|
-
* @returns {{ kind: "missing" | "ok" | "warn" | "expired", daysLeft: number | null }}
|
|
54
|
-
*/
|
|
55
|
-
export function assessTokenLifetime(options) {
|
|
56
|
-
const { expiresAtMs, warnDays = WARN_DAYS } = options;
|
|
57
|
-
if (expiresAtMs == null) {
|
|
58
|
-
return { kind: "missing", daysLeft: null };
|
|
59
|
-
}
|
|
60
|
-
const today = utcDayMs(new Date(options.now ?? Date.now()));
|
|
61
|
-
const daysLeft = Math.round((expiresAtMs - today) / MS_PER_DAY);
|
|
62
|
-
if (daysLeft <= 0) {
|
|
63
|
-
return { kind: "expired", daysLeft };
|
|
64
|
-
}
|
|
65
|
-
if (daysLeft <= warnDays) {
|
|
66
|
-
return { kind: "warn", daysLeft };
|
|
67
|
-
}
|
|
68
|
-
return { kind: "ok", daysLeft };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/** @param {CredentialProfile} credential */
|
|
72
|
-
export function resolveRepo() {
|
|
73
|
-
return process.env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** @param {string} id */
|
|
77
|
-
export function credentialProfile(id) {
|
|
78
|
-
const credential = CREDENTIALS[id];
|
|
79
|
-
if (!credential) {
|
|
80
|
-
throw new Error(
|
|
81
|
-
`Unknown NUDGE_CREDENTIAL=${id}. Expected one of: ${Object.keys(CREDENTIALS).join(", ")}`,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
return credential;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** @param {CredentialProfile} credential */
|
|
88
|
-
export function issueTitle(credential, expiresYmd) {
|
|
89
|
-
return `chore(security): rotate ${credential.secret} (expires ${expiresYmd})`;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** @param {CredentialProfile} credential */
|
|
93
|
-
export function rotationCommands(credential, nextExpiresYmd, repo = resolveRepo()) {
|
|
94
|
-
const next = nextExpiresYmd ?? "YYYY-MM-DD";
|
|
95
|
-
return [
|
|
96
|
-
credential.mint,
|
|
97
|
-
"",
|
|
98
|
-
"Then from a trusted machine (do not paste the token into Actions or this issue):",
|
|
99
|
-
"",
|
|
100
|
-
"```bash",
|
|
101
|
-
`gh secret set ${credential.secret} --repo ${repo}`,
|
|
102
|
-
`gh variable set ${credential.expiresVar} --repo ${repo} --body ${next}`,
|
|
103
|
-
"```",
|
|
104
|
-
"",
|
|
105
|
-
`Revoke the old token: ${credential.revokeUrl}`,
|
|
106
|
-
"Close this issue, then retry Publish:",
|
|
107
|
-
"",
|
|
108
|
-
"```bash",
|
|
109
|
-
`gh run list --repo ${repo} --workflow Publish --limit 5`,
|
|
110
|
-
`gh run rerun --failed <run-id> --repo ${repo}`,
|
|
111
|
-
"```",
|
|
112
|
-
].join("\n");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
export function suggestedNextExpiry(fromMs, days = 90) {
|
|
116
|
-
const start = utcDayMs(new Date(fromMs));
|
|
117
|
-
const next = new Date(start + days * MS_PER_DAY);
|
|
118
|
-
const y = next.getUTCFullYear();
|
|
119
|
-
const m = String(next.getUTCMonth() + 1).padStart(2, "0");
|
|
120
|
-
const d = String(next.getUTCDate()).padStart(2, "0");
|
|
121
|
-
return `${y}-${m}-${d}`;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** @param {CredentialProfile} credential */
|
|
125
|
-
export function buildDescription(credential, options) {
|
|
126
|
-
const { kind, expiresYmd, daysLeft, rejected, runUrl } = options;
|
|
127
|
-
const next = suggestedNextExpiry(Date.now());
|
|
128
|
-
const why = rejected
|
|
129
|
-
? credential.rejectedWhy
|
|
130
|
-
: kind === "expired"
|
|
131
|
-
? `${credential.expiresVar} (${expiresYmd}) is today or in the past. Rotate ${credential.secret} and this date before Publish can continue.`
|
|
132
|
-
: `${credential.secret} expires on ${expiresYmd} (${daysLeft} day(s) left). Rotate before that date so Publish does not fail closed.`;
|
|
133
|
-
return [
|
|
134
|
-
"## Summary",
|
|
135
|
-
"",
|
|
136
|
-
why,
|
|
137
|
-
"",
|
|
138
|
-
"## If this is not done in time",
|
|
139
|
-
"",
|
|
140
|
-
credential.failImpact,
|
|
141
|
-
"",
|
|
142
|
-
"## Commands",
|
|
143
|
-
"",
|
|
144
|
-
rotationCommands(credential, next),
|
|
145
|
-
"",
|
|
146
|
-
runUrl ? `- Workflow run: ${runUrl}` : null,
|
|
147
|
-
]
|
|
148
|
-
.filter((line) => line != null)
|
|
149
|
-
.join("\n");
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
function runUrl() {
|
|
153
|
-
if (
|
|
154
|
-
process.env.GITHUB_SERVER_URL &&
|
|
155
|
-
process.env.GITHUB_REPOSITORY &&
|
|
156
|
-
process.env.GITHUB_RUN_ID
|
|
157
|
-
) {
|
|
158
|
-
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
159
|
-
}
|
|
160
|
-
return "";
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** @param {CredentialProfile} credential */
|
|
164
|
-
export function upsertRotationIssue(credential, options, upsert = upsertIssue) {
|
|
165
|
-
const { expiresYmd, kind, daysLeft, rejected } = options;
|
|
166
|
-
return upsert({
|
|
167
|
-
title: issueTitle(credential, expiresYmd ?? "unknown"),
|
|
168
|
-
body: buildDescription(credential, {
|
|
169
|
-
kind,
|
|
170
|
-
expiresYmd,
|
|
171
|
-
daysLeft,
|
|
172
|
-
rejected,
|
|
173
|
-
runUrl: runUrl(),
|
|
174
|
-
}),
|
|
175
|
-
});
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
export async function runNudge(env = process.env, upsert = upsertIssue) {
|
|
179
|
-
const credential = credentialProfile(String(env.NUDGE_CREDENTIAL ?? "").trim());
|
|
180
|
-
const rejected = env.NUDGE_REJECTED === "1";
|
|
181
|
-
const expiresYmd = String(env[credential.expiresVar] ?? "").trim();
|
|
182
|
-
const expiresAtMs = parseExpiresAt(expiresYmd);
|
|
183
|
-
const assessment = assessTokenLifetime({ expiresAtMs });
|
|
184
|
-
const repo = env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
185
|
-
|
|
186
|
-
if (assessment.kind === "missing" && !rejected) {
|
|
187
|
-
process.stderr.write(
|
|
188
|
-
`repository variable ${credential.expiresVar}=YYYY-MM-DD is required\n` +
|
|
189
|
-
` gh variable set ${credential.expiresVar} --repo ${repo} --body YYYY-MM-DD\n`,
|
|
190
|
-
);
|
|
191
|
-
return 1;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (rejected || assessment.kind === "warn" || assessment.kind === "expired") {
|
|
195
|
-
const result = upsertRotationIssue(
|
|
196
|
-
credential,
|
|
197
|
-
{
|
|
198
|
-
expiresYmd: expiresYmd || "unknown",
|
|
199
|
-
kind: assessment.kind,
|
|
200
|
-
daysLeft: assessment.daysLeft,
|
|
201
|
-
rejected,
|
|
202
|
-
},
|
|
203
|
-
upsert,
|
|
204
|
-
);
|
|
205
|
-
process.stdout.write(
|
|
206
|
-
`${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
|
|
207
|
-
);
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
if (rejected || assessment.kind === "expired" || assessment.kind === "missing") {
|
|
211
|
-
if (assessment.kind === "expired") {
|
|
212
|
-
process.stderr.write(
|
|
213
|
-
`${credential.secret} calendar expiry ${expiresYmd} has been reached — refusing to continue\n`,
|
|
214
|
-
);
|
|
215
|
-
}
|
|
216
|
-
return 1;
|
|
217
|
-
}
|
|
218
|
-
return 0;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
const isCli =
|
|
222
|
-
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
223
|
-
|
|
224
|
-
if (isCli) {
|
|
225
|
-
process.exit(await runNudge());
|
|
226
|
-
}
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* First-publish a new package name (public) and give it its Trusted Publisher,
|
|
3
|
-
* so `publish.yml` can release it over OIDC from then on.
|
|
4
|
-
*
|
|
5
|
-
* pnpm npm:bootstrap [--otp <code>] [--dry-run] [--channel dev] [<package-name>...]
|
|
6
|
-
*
|
|
7
|
-
* Without names: every publishable package whose name is not on npm yet.
|
|
8
|
-
* Run from the repo root after `npm login` to the org. Each step is skipped when
|
|
9
|
-
* it is already done (the name exists, the Trusted Publisher is set).
|
|
10
|
-
*
|
|
11
|
-
* npm runs on this terminal, so it does its own 2FA: it asks for a code, or
|
|
12
|
-
* opens the browser for web authentication. `--otp` hands it a code up front
|
|
13
|
-
* through `NPM_CONFIG_OTP`; `npm trust` takes no `--otp` flag of its own.
|
|
14
|
-
*/
|
|
15
|
-
import { spawnSync } from "node:child_process";
|
|
16
|
-
import { resolve } from "node:path";
|
|
17
|
-
import { parseArgs } from "node:util";
|
|
18
|
-
import { pathToFileURL } from "node:url";
|
|
19
|
-
import { listPublishablePackages, npmNameExists } from "./publish-verify.mjs";
|
|
20
|
-
|
|
21
|
-
/** The Trusted Publisher every package gets: `publish.yml`'s npm job. */
|
|
22
|
-
export const TRUSTED_PUBLISHER = {
|
|
23
|
-
repo: "typesmiths/decohere",
|
|
24
|
-
file: "publish.yml",
|
|
25
|
-
environment: "npm-publish",
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/** @param {string | undefined} version */
|
|
29
|
-
export function needsPrereleaseTag(version) {
|
|
30
|
-
return String(version ?? "").includes("-");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* npm arguments for a name-only first publish. Scoped names default to restricted,
|
|
35
|
-
* so `--access public`. `./` stops npm reading the path as a GitHub `owner/repo`.
|
|
36
|
-
*
|
|
37
|
-
* @param {{ dir: string, version?: string }} pkg
|
|
38
|
-
* @param {string} channel
|
|
39
|
-
*/
|
|
40
|
-
export function publishArgs(pkg, channel) {
|
|
41
|
-
const tag = needsPrereleaseTag(pkg.version) ? ["--tag", channel] : [];
|
|
42
|
-
return ["publish", `./projects/${pkg.dir}`, "--access", "public", ...tag];
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** @param {string} name */
|
|
46
|
-
export function trustArgs(name) {
|
|
47
|
-
return [
|
|
48
|
-
"trust",
|
|
49
|
-
"github",
|
|
50
|
-
name,
|
|
51
|
-
"--file",
|
|
52
|
-
TRUSTED_PUBLISHER.file,
|
|
53
|
-
"--repo",
|
|
54
|
-
TRUSTED_PUBLISHER.repo,
|
|
55
|
-
"--env",
|
|
56
|
-
TRUSTED_PUBLISHER.environment,
|
|
57
|
-
"--allow-publish",
|
|
58
|
-
"--yes",
|
|
59
|
-
];
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** @param {string} output `npm trust list <name> --json` */
|
|
63
|
-
export function hasTrustedPublisher(output) {
|
|
64
|
-
return output.includes(TRUSTED_PUBLISHER.file) && output.includes(TRUSTED_PUBLISHER.repo);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* @typedef {{ capture?: boolean, otp?: string }} NpmOptions
|
|
69
|
-
* `capture` keeps stdout to read it (npm can then not prompt for 2FA);
|
|
70
|
-
* otherwise npm owns the terminal.
|
|
71
|
-
*/
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* @param {string[]} args
|
|
75
|
-
* @param {NpmOptions} [options]
|
|
76
|
-
* @returns {{ status: number, output: string }}
|
|
77
|
-
*/
|
|
78
|
-
function runNpm(args, { capture = false, otp } = {}) {
|
|
79
|
-
const result = spawnSync("npm", args, {
|
|
80
|
-
encoding: "utf8",
|
|
81
|
-
stdio: capture ? ["inherit", "pipe", "inherit"] : "inherit",
|
|
82
|
-
env: otp ? { ...process.env, NPM_CONFIG_OTP: otp } : process.env,
|
|
83
|
-
});
|
|
84
|
-
return { status: result.status ?? 1, output: result.stdout ?? "" };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* @param {{
|
|
89
|
-
* argv?: string[],
|
|
90
|
-
* root?: string,
|
|
91
|
-
* io?: {
|
|
92
|
-
* npm?: (args: string[], options?: NpmOptions) => { status: number, output: string },
|
|
93
|
-
* nameExists?: (name: string) => boolean,
|
|
94
|
-
* log?: (line: string) => void,
|
|
95
|
-
* },
|
|
96
|
-
* }} [options]
|
|
97
|
-
* @returns {Promise<number>} exit code
|
|
98
|
-
*/
|
|
99
|
-
export async function runBootstrap(options = {}) {
|
|
100
|
-
const { values, positionals } = parseArgs({
|
|
101
|
-
args: options.argv ?? process.argv.slice(2),
|
|
102
|
-
options: {
|
|
103
|
-
otp: { type: "string" },
|
|
104
|
-
"dry-run": { type: "boolean", default: false },
|
|
105
|
-
channel: { type: "string", default: "dev" },
|
|
106
|
-
},
|
|
107
|
-
allowPositionals: true,
|
|
108
|
-
});
|
|
109
|
-
const io = options.io ?? {};
|
|
110
|
-
const npm = io.npm ?? runNpm;
|
|
111
|
-
const nameExists = io.nameExists ?? ((name) => npmNameExists(name));
|
|
112
|
-
const log = io.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
113
|
-
const dryRun = values["dry-run"];
|
|
114
|
-
|
|
115
|
-
// Even on a dry run: the names are private, so `npm view` 404s without a login
|
|
116
|
-
// and every package would look unpublished.
|
|
117
|
-
if (npm(["whoami"]).status !== 0) {
|
|
118
|
-
log("Not logged in to npm: run `npm login` first.");
|
|
119
|
-
return 1;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const publishable = listPublishablePackages(options.root ?? process.cwd());
|
|
123
|
-
const unknown = positionals.filter((name) => !publishable.some((pkg) => pkg.name === name));
|
|
124
|
-
if (unknown.length > 0) {
|
|
125
|
-
log(`Not a publishable package in projects/: ${unknown.join(", ")}`);
|
|
126
|
-
return 1;
|
|
127
|
-
}
|
|
128
|
-
const packages =
|
|
129
|
-
positionals.length > 0
|
|
130
|
-
? publishable.filter((pkg) => positionals.includes(pkg.name))
|
|
131
|
-
: publishable.filter((pkg) => !nameExists(pkg.name));
|
|
132
|
-
if (packages.length === 0) {
|
|
133
|
-
log("Every package name is on npm already. Name one to check its Trusted Publisher.");
|
|
134
|
-
return 0;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// A code npm rejects (they last about 30 seconds) makes npm ask for a new one.
|
|
138
|
-
/** @param {string[]} args */
|
|
139
|
-
const change = (args) => {
|
|
140
|
-
if (dryRun) {
|
|
141
|
-
log(`[dry run] npm ${args.join(" ")}`);
|
|
142
|
-
return true;
|
|
143
|
-
}
|
|
144
|
-
return npm(args, { otp: values.otp }).status === 0;
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
for (const pkg of packages) {
|
|
148
|
-
log(`\n${pkg.name} (projects/${pkg.dir}, ${pkg.version})`);
|
|
149
|
-
|
|
150
|
-
if (nameExists(pkg.name)) {
|
|
151
|
-
log(" name is on npm: skip the first publish");
|
|
152
|
-
} else if (!change(publishArgs(pkg, values.channel))) {
|
|
153
|
-
log(` first publish failed: stopping`);
|
|
154
|
-
return 1;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
const listed = dryRun
|
|
158
|
-
? { status: 1, output: "" }
|
|
159
|
-
: npm(["trust", "list", pkg.name, "--json"], { capture: true, otp: values.otp });
|
|
160
|
-
if (listed.status === 0 && hasTrustedPublisher(listed.output)) {
|
|
161
|
-
log(" Trusted Publisher is set: skip");
|
|
162
|
-
} else if (!change(trustArgs(pkg.name))) {
|
|
163
|
-
log(" Trusted Publisher failed: stopping");
|
|
164
|
-
return 1;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
log(
|
|
169
|
-
"\nDone. Approve the waiting npm-oidc-bootstrap deployment on the Publish run, or rerun it: `gh run rerun --failed <run-id>`.",
|
|
170
|
-
);
|
|
171
|
-
return 0;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const isCli =
|
|
175
|
-
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
176
|
-
|
|
177
|
-
if (isCli) {
|
|
178
|
-
process.exit(await runBootstrap());
|
|
179
|
-
}
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
import { resolve } from "node:path";
|
|
2
|
-
import { pathToFileURL } from "node:url";
|
|
3
|
-
import { upsertIssue } from "../lib/github-issue.mjs";
|
|
4
|
-
import { needsPrereleaseTag, TRUSTED_PUBLISHER } from "./npm-bootstrap.mjs";
|
|
5
|
-
import { listPublishablePackages } from "./publish-verify.mjs";
|
|
6
|
-
|
|
7
|
-
export { needsPrereleaseTag } from "./npm-bootstrap.mjs";
|
|
8
|
-
|
|
9
|
-
const GITHUB_API = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
10
|
-
const ISSUE_TITLE = "deploy(publish): OIDC bootstrap unpublished packages";
|
|
11
|
-
const BOOTSTRAP_ENV = "npm-oidc-bootstrap";
|
|
12
|
-
const DEFAULT_REPO = "typesmiths/decohere";
|
|
13
|
-
|
|
14
|
-
export function issueTitle() {
|
|
15
|
-
return ISSUE_TITLE;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/** @param {string | undefined} raw */
|
|
19
|
-
export function parseUnpublished(raw) {
|
|
20
|
-
return String(raw ?? "")
|
|
21
|
-
.split(/\s+/)
|
|
22
|
-
.map((name) => name.trim())
|
|
23
|
-
.filter(Boolean);
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* @param {{ name: string, dir: string, version?: string }[]} packages
|
|
28
|
-
* @param {string} [channel]
|
|
29
|
-
*/
|
|
30
|
-
export function firstPublishCommands(packages, channel = "dev") {
|
|
31
|
-
if (packages.length === 0) {
|
|
32
|
-
return [`npm publish ./<package-dir> --tag ${channel}`];
|
|
33
|
-
}
|
|
34
|
-
return packages.map((pkg) => {
|
|
35
|
-
const tag = needsPrereleaseTag(pkg.version) ? ` --tag ${channel}` : "";
|
|
36
|
-
return `npm publish ./projects/${pkg.dir} --access public${tag}`;
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/**
|
|
41
|
-
* @param {unknown} environment
|
|
42
|
-
* @returns {string[]}
|
|
43
|
-
*/
|
|
44
|
-
export function extractGitHubReviewerLogins(environment) {
|
|
45
|
-
const rules =
|
|
46
|
-
environment && typeof environment === "object" ? environment.protection_rules : null;
|
|
47
|
-
if (!Array.isArray(rules)) {
|
|
48
|
-
return [];
|
|
49
|
-
}
|
|
50
|
-
const logins = [];
|
|
51
|
-
for (const rule of rules) {
|
|
52
|
-
const reviewers = rule?.reviewers;
|
|
53
|
-
if (!Array.isArray(reviewers)) {
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
for (const entry of reviewers) {
|
|
57
|
-
const login = entry?.reviewer?.login;
|
|
58
|
-
if (typeof login === "string" && login.length > 0 && entry?.type !== "Team") {
|
|
59
|
-
logins.push(login);
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return [...new Set(logins)];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* @param {{
|
|
68
|
-
* unpublished: string[],
|
|
69
|
-
* packages: { name: string, dir: string, version?: string }[],
|
|
70
|
-
* repo?: string,
|
|
71
|
-
* runUrl?: string,
|
|
72
|
-
* channel?: string,
|
|
73
|
-
* }} options
|
|
74
|
-
*/
|
|
75
|
-
export function buildDescription(options) {
|
|
76
|
-
const repo = options.repo || DEFAULT_REPO;
|
|
77
|
-
const names = options.unpublished;
|
|
78
|
-
const channel = options.channel || "dev";
|
|
79
|
-
const packages = options.packages.filter((pkg) => names.includes(pkg.name));
|
|
80
|
-
const [owner, repoName] = repo.split("/");
|
|
81
|
-
return [
|
|
82
|
-
"## Summary",
|
|
83
|
-
"",
|
|
84
|
-
"These package names are not on npm yet, so OIDC `npm publish` cannot run until each name exists and has a Trusted Publisher:",
|
|
85
|
-
"",
|
|
86
|
-
...names.map((name) => `- \`${name}\``),
|
|
87
|
-
"",
|
|
88
|
-
"## What to do",
|
|
89
|
-
"",
|
|
90
|
-
"From a trusted machine, `npm login` to the org, then from the **repo root** (`--otp` is optional: npm asks for a code, or the browser login, whenever it needs one):",
|
|
91
|
-
"",
|
|
92
|
-
"```bash",
|
|
93
|
-
`pnpm npm:bootstrap --otp <code> ${names.join(" ")}`,
|
|
94
|
-
"```",
|
|
95
|
-
"",
|
|
96
|
-
"That does steps 1 and 2 below, skipping what is already done. To do them by hand instead:",
|
|
97
|
-
"",
|
|
98
|
-
"1. First-publish each name **public** (`--access public`: scoped names default to restricted). From a trusted machine, `npm login` to the org, then from the **repo root**. Use `./` so npm does not treat the path as a GitHub `owner/repo`. `--tag` is required for prerelease versions (`x.y.z-dev.N`). Missing `bin`/`es6` warnings are OK for this name-only bootstrap; CI `forge publish` ships the built tarball later:",
|
|
99
|
-
"",
|
|
100
|
-
"```bash",
|
|
101
|
-
...firstPublishCommands(packages, channel),
|
|
102
|
-
"```",
|
|
103
|
-
"",
|
|
104
|
-
"2. npm → package settings → Trusted Publisher → GitHub Actions:",
|
|
105
|
-
"",
|
|
106
|
-
`- Organization: \`${owner ?? "typesmiths"}\``,
|
|
107
|
-
`- Repository: \`${repoName ?? "decohere"}\``,
|
|
108
|
-
`- Workflow filename: \`${TRUSTED_PUBLISHER.file}\``,
|
|
109
|
-
`- Environment: \`${TRUSTED_PUBLISHER.environment}\``,
|
|
110
|
-
"- Allowed action: `npm publish` (not staged-only)",
|
|
111
|
-
"",
|
|
112
|
-
"3. Approve the waiting **npm-oidc-bootstrap** deployment on this Publish run. The job re-checks that the names exist, then npm + Docker + tag continue. If reviewers were not configured and the job already failed:",
|
|
113
|
-
"",
|
|
114
|
-
"```bash",
|
|
115
|
-
`gh run list --repo ${repo} --workflow Publish --limit 5`,
|
|
116
|
-
`gh run rerun --failed <run-id> --repo ${repo}`,
|
|
117
|
-
"```",
|
|
118
|
-
"",
|
|
119
|
-
options.runUrl ? `- Workflow run: ${options.runUrl}` : null,
|
|
120
|
-
]
|
|
121
|
-
.filter((line) => line != null)
|
|
122
|
-
.join("\n");
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function runUrl() {
|
|
126
|
-
if (
|
|
127
|
-
process.env.GITHUB_SERVER_URL &&
|
|
128
|
-
process.env.GITHUB_REPOSITORY &&
|
|
129
|
-
process.env.GITHUB_RUN_ID
|
|
130
|
-
) {
|
|
131
|
-
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
132
|
-
}
|
|
133
|
-
return "";
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* @param {{ fetch?: typeof fetch, token?: string, repo?: string, environment?: string }} options
|
|
138
|
-
*/
|
|
139
|
-
export async function githubEnvironmentReviewerLogins(options = {}) {
|
|
140
|
-
const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
|
|
141
|
-
const repo = options.repo ?? process.env.GITHUB_REPOSITORY ?? "";
|
|
142
|
-
const environment = options.environment ?? BOOTSTRAP_ENV;
|
|
143
|
-
const fetchFn = options.fetch ?? fetch;
|
|
144
|
-
if (!token || !repo) {
|
|
145
|
-
return [];
|
|
146
|
-
}
|
|
147
|
-
const response = await fetchFn(
|
|
148
|
-
`${GITHUB_API}/repos/${repo}/environments/${encodeURIComponent(environment)}`,
|
|
149
|
-
{
|
|
150
|
-
headers: {
|
|
151
|
-
Authorization: `Bearer ${token}`,
|
|
152
|
-
Accept: "application/vnd.github+json",
|
|
153
|
-
"X-GitHub-Api-Version": "2022-11-28",
|
|
154
|
-
},
|
|
155
|
-
},
|
|
156
|
-
);
|
|
157
|
-
if (!response.ok) {
|
|
158
|
-
process.stderr.write(
|
|
159
|
-
`Could not list ${environment} reviewers (${response.status}); the issue will have no assignees\n`,
|
|
160
|
-
);
|
|
161
|
-
return [];
|
|
162
|
-
}
|
|
163
|
-
return extractGitHubReviewerLogins(await response.json());
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
export async function runNudge(env = process.env, io = {}) {
|
|
167
|
-
const unpublished = parseUnpublished(env.UNPUBLISHED);
|
|
168
|
-
if (unpublished.length === 0) {
|
|
169
|
-
process.stdout.write("No unpublished package names; skipping OIDC bootstrap issue\n");
|
|
170
|
-
return 0;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const root = io.root ?? process.cwd();
|
|
174
|
-
const packages = (io.listPackages ?? listPublishablePackages)(root);
|
|
175
|
-
const assignees = await (io.githubReviewers ?? githubEnvironmentReviewerLogins)({
|
|
176
|
-
token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
|
|
177
|
-
repo: env.GITHUB_REPOSITORY,
|
|
178
|
-
});
|
|
179
|
-
const repo = env.GITHUB_REPOSITORY || DEFAULT_REPO;
|
|
180
|
-
|
|
181
|
-
const result = (io.upsert ?? upsertIssue)({
|
|
182
|
-
title: issueTitle(),
|
|
183
|
-
body: buildDescription({
|
|
184
|
-
unpublished,
|
|
185
|
-
packages,
|
|
186
|
-
repo,
|
|
187
|
-
runUrl: io.runUrl ?? runUrl(),
|
|
188
|
-
channel: env.PUBLISH_CHANNEL || env.CHANNEL || "dev",
|
|
189
|
-
}),
|
|
190
|
-
assignees,
|
|
191
|
-
});
|
|
192
|
-
process.stdout.write(
|
|
193
|
-
`${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
|
|
194
|
-
);
|
|
195
|
-
if (assignees.length > 0) {
|
|
196
|
-
process.stdout.write(`Assigned ${assignees.join(", ")}\n`);
|
|
197
|
-
} else {
|
|
198
|
-
process.stdout.write(
|
|
199
|
-
`No assignees (add required reviewers on the ${BOOTSTRAP_ENV} environment)\n`,
|
|
200
|
-
);
|
|
201
|
-
}
|
|
202
|
-
if (env.GITHUB_OUTPUT) {
|
|
203
|
-
const { appendFileSync } = await import("node:fs");
|
|
204
|
-
appendFileSync(env.GITHUB_OUTPUT, `issue_url=${result.url}\n`);
|
|
205
|
-
}
|
|
206
|
-
return 0;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const isCli =
|
|
210
|
-
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
211
|
-
|
|
212
|
-
if (isCli) {
|
|
213
|
-
process.exit(await runNudge());
|
|
214
|
-
}
|