@decohere/ci-tools 0.0.1-dev.12 → 0.0.1-dev.121
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 +8 -3
- package/src/audit/enforce.mjs +7 -5
- package/src/audit/format.mjs +133 -0
- package/src/audit/summarize.mjs +10 -32
- package/src/audit/upsert-issue.mjs +25 -74
- package/src/bench/bench.mjs +134 -368
- package/src/bench/file-regressions.mjs +76 -0
- package/src/bench/history.mjs +248 -0
- package/src/bench/nightly.mjs +118 -0
- package/src/bench/regression-issue.mjs +44 -0
- package/src/bench/trace.mjs +18 -14
- package/src/github/credential-nudge.mjs +245 -0
- package/src/github/oidc-bootstrap-nudge.mjs +210 -0
- package/src/github/publish-verify.mjs +206 -0
- package/src/github/smoke-create-launcher.mjs +70 -0
- package/src/lib/github-issue.mjs +75 -0
- package/src/lib/workspace-packages.mjs +30 -0
- package/src/pages/artifact.mjs +38 -183
- package/src/pages/bench-page.mjs +323 -0
- package/src/pages/restore-badges.mjs +58 -0
- package/src/pages/restore-bench-history.mjs +15 -28
- package/src/release/npm-links.mjs +6 -13
- package/src/release/sync-dependencies.mjs +1 -2
- package/tests/audit/format.test.mjs +115 -0
- package/tests/bench/history.test.mjs +279 -0
- package/tests/bench/regression-issue.test.mjs +42 -0
- package/tests/github/credential-nudge.test.mjs +116 -0
- package/tests/github/oidc-bootstrap-nudge.test.mjs +89 -0
- package/tests/github/publish-verify.test.mjs +115 -0
- package/tests/github/smoke-create-launcher.test.mjs +71 -0
- package/tests/lib/github-issue.test.mjs +85 -0
- package/tests/lib/lcov.test.mjs +4 -4
- package/tests/pages/bench-page.test.mjs +123 -0
- package/src/coverage/enforce-thresholds.mjs +0 -144
- package/src/pages/clear-bench-history.mjs +0 -27
- package/src/pages/keep-prior-bench-badge.mjs +0 -38
|
@@ -0,0 +1,245 @@
|
|
|
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. Date math, GitHub issue upsert, and fail-closed live below.
|
|
11
|
+
* `ghEnv` unset means repo secret / repo variable (not an Actions environment).
|
|
12
|
+
*
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* id: string,
|
|
15
|
+
* secret: string,
|
|
16
|
+
* expiresVar: string,
|
|
17
|
+
* ghEnv?: string,
|
|
18
|
+
* mint: string,
|
|
19
|
+
* failImpact: string,
|
|
20
|
+
* rejectedWhy: string,
|
|
21
|
+
* revokeUrl: string,
|
|
22
|
+
* }} CredentialProfile
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** @type {Record<string, CredentialProfile>} */
|
|
26
|
+
export const CREDENTIALS = {
|
|
27
|
+
ghcr: {
|
|
28
|
+
id: "ghcr",
|
|
29
|
+
secret: "GHCR_PUSH_TOKEN",
|
|
30
|
+
expiresVar: "GHCR_TOKEN_EXPIRES_AT",
|
|
31
|
+
ghEnv: "ghcr-publish",
|
|
32
|
+
mint: "Create a **classic** GitHub PAT (fine-grained tokens cannot push to GHCR): https://github.com/settings/tokens/new?scopes=write:packages",
|
|
33
|
+
failImpact:
|
|
34
|
+
"npm may already have published. Docker login/push and the `v*` tag will **fail**. The SHA stays untagged, so the next successful Publish of that tip can finish Docker + tag after you rotate. Images are not pushed with `GITHUB_TOKEN`.",
|
|
35
|
+
rejectedWhy: "GHCR `docker login` returned 401 (expired, revoked, or wrong secret name).",
|
|
36
|
+
revokeUrl: "https://github.com/settings/tokens",
|
|
37
|
+
},
|
|
38
|
+
npm: {
|
|
39
|
+
id: "npm",
|
|
40
|
+
secret: "NODE_AUTH_TOKEN",
|
|
41
|
+
expiresVar: "NODE_AUTH_TOKEN_EXPIRES_AT",
|
|
42
|
+
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.",
|
|
43
|
+
failImpact:
|
|
44
|
+
"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.",
|
|
45
|
+
rejectedWhy: "`npm whoami` failed (expired, revoked, or missing `NODE_AUTH_TOKEN`).",
|
|
46
|
+
revokeUrl: "https://www.npmjs.com/settings/~/tokens",
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function utcDayMs(date) {
|
|
51
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @param {string | undefined} raw */
|
|
55
|
+
export function parseExpiresAt(raw) {
|
|
56
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(raw ?? "").trim());
|
|
57
|
+
if (!match) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* @param {{ now?: number, expiresAtMs: number | null, warnDays?: number }} options
|
|
65
|
+
* @returns {{ kind: "missing" | "ok" | "warn" | "expired", daysLeft: number | null }}
|
|
66
|
+
*/
|
|
67
|
+
export function assessTokenLifetime(options) {
|
|
68
|
+
const { expiresAtMs, warnDays = WARN_DAYS } = options;
|
|
69
|
+
if (expiresAtMs == null) {
|
|
70
|
+
return { kind: "missing", daysLeft: null };
|
|
71
|
+
}
|
|
72
|
+
const today = utcDayMs(new Date(options.now ?? Date.now()));
|
|
73
|
+
const daysLeft = Math.round((expiresAtMs - today) / MS_PER_DAY);
|
|
74
|
+
if (daysLeft <= 0) {
|
|
75
|
+
return { kind: "expired", daysLeft };
|
|
76
|
+
}
|
|
77
|
+
if (daysLeft <= warnDays) {
|
|
78
|
+
return { kind: "warn", daysLeft };
|
|
79
|
+
}
|
|
80
|
+
return { kind: "ok", daysLeft };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** @param {CredentialProfile} credential */
|
|
84
|
+
export function resolveRepo() {
|
|
85
|
+
return process.env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** @param {CredentialProfile} credential */
|
|
89
|
+
export function ghScopeFlags(credential) {
|
|
90
|
+
return credential.ghEnv ? `--env ${credential.ghEnv} ` : "";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** @param {string} id */
|
|
94
|
+
export function credentialProfile(id) {
|
|
95
|
+
const credential = CREDENTIALS[id];
|
|
96
|
+
if (!credential) {
|
|
97
|
+
throw new Error(
|
|
98
|
+
`Unknown NUDGE_CREDENTIAL=${id}. Expected one of: ${Object.keys(CREDENTIALS).join(", ")}`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
return credential;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** @param {CredentialProfile} credential */
|
|
105
|
+
export function issueTitle(credential, expiresYmd) {
|
|
106
|
+
return `chore(security): rotate ${credential.secret} (expires ${expiresYmd})`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** @param {CredentialProfile} credential */
|
|
110
|
+
export function rotationCommands(credential, nextExpiresYmd, repo = resolveRepo()) {
|
|
111
|
+
const next = nextExpiresYmd ?? "YYYY-MM-DD";
|
|
112
|
+
const flags = ghScopeFlags(credential);
|
|
113
|
+
return [
|
|
114
|
+
credential.mint,
|
|
115
|
+
"",
|
|
116
|
+
"Then from a trusted machine (do not paste the token into Actions or this issue):",
|
|
117
|
+
"",
|
|
118
|
+
"```bash",
|
|
119
|
+
`gh secret set ${credential.secret} ${flags}--repo ${repo}`,
|
|
120
|
+
`gh variable set ${credential.expiresVar} ${flags}--repo ${repo} --body ${next}`,
|
|
121
|
+
"```",
|
|
122
|
+
"",
|
|
123
|
+
`Revoke the old token: ${credential.revokeUrl}`,
|
|
124
|
+
"Close this issue, then retry Publish:",
|
|
125
|
+
"",
|
|
126
|
+
"```bash",
|
|
127
|
+
`gh run list --repo ${repo} --workflow Publish --limit 5`,
|
|
128
|
+
`gh run rerun --failed <run-id> --repo ${repo}`,
|
|
129
|
+
"```",
|
|
130
|
+
].join("\n");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function suggestedNextExpiry(fromMs, days = 90) {
|
|
134
|
+
const start = utcDayMs(new Date(fromMs));
|
|
135
|
+
const next = new Date(start + days * MS_PER_DAY);
|
|
136
|
+
const y = next.getUTCFullYear();
|
|
137
|
+
const m = String(next.getUTCMonth() + 1).padStart(2, "0");
|
|
138
|
+
const d = String(next.getUTCDate()).padStart(2, "0");
|
|
139
|
+
return `${y}-${m}-${d}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** @param {CredentialProfile} credential */
|
|
143
|
+
export function buildDescription(credential, options) {
|
|
144
|
+
const { kind, expiresYmd, daysLeft, rejected, runUrl } = options;
|
|
145
|
+
const next = suggestedNextExpiry(Date.now());
|
|
146
|
+
const why = rejected
|
|
147
|
+
? credential.rejectedWhy
|
|
148
|
+
: kind === "expired"
|
|
149
|
+
? `${credential.expiresVar} (${expiresYmd}) is today or in the past. Rotate ${credential.secret} and this date before Publish can continue.`
|
|
150
|
+
: `${credential.secret} expires on ${expiresYmd} (${daysLeft} day(s) left). Rotate before that date so Publish does not fail closed.`;
|
|
151
|
+
return [
|
|
152
|
+
"## Summary",
|
|
153
|
+
"",
|
|
154
|
+
why,
|
|
155
|
+
"",
|
|
156
|
+
"## If this is not done in time",
|
|
157
|
+
"",
|
|
158
|
+
credential.failImpact,
|
|
159
|
+
"",
|
|
160
|
+
"## Commands",
|
|
161
|
+
"",
|
|
162
|
+
rotationCommands(credential, next),
|
|
163
|
+
"",
|
|
164
|
+
runUrl ? `- Workflow run: ${runUrl}` : null,
|
|
165
|
+
]
|
|
166
|
+
.filter((line) => line != null)
|
|
167
|
+
.join("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function runUrl() {
|
|
171
|
+
if (
|
|
172
|
+
process.env.GITHUB_SERVER_URL &&
|
|
173
|
+
process.env.GITHUB_REPOSITORY &&
|
|
174
|
+
process.env.GITHUB_RUN_ID
|
|
175
|
+
) {
|
|
176
|
+
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
177
|
+
}
|
|
178
|
+
return "";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** @param {CredentialProfile} credential */
|
|
182
|
+
export function upsertRotationIssue(credential, options, upsert = upsertIssue) {
|
|
183
|
+
const { expiresYmd, kind, daysLeft, rejected } = options;
|
|
184
|
+
return upsert({
|
|
185
|
+
title: issueTitle(credential, expiresYmd ?? "unknown"),
|
|
186
|
+
body: buildDescription(credential, {
|
|
187
|
+
kind,
|
|
188
|
+
expiresYmd,
|
|
189
|
+
daysLeft,
|
|
190
|
+
rejected,
|
|
191
|
+
runUrl: runUrl(),
|
|
192
|
+
}),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function runNudge(env = process.env, upsert = upsertIssue) {
|
|
197
|
+
const credential = credentialProfile(String(env.NUDGE_CREDENTIAL ?? "").trim());
|
|
198
|
+
const rejected = env.NUDGE_REJECTED === "1";
|
|
199
|
+
const expiresYmd = String(env[credential.expiresVar] ?? "").trim();
|
|
200
|
+
const expiresAtMs = parseExpiresAt(expiresYmd);
|
|
201
|
+
const assessment = assessTokenLifetime({ expiresAtMs });
|
|
202
|
+
const flags = ghScopeFlags(credential);
|
|
203
|
+
const repo = env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
204
|
+
|
|
205
|
+
if (assessment.kind === "missing" && !rejected) {
|
|
206
|
+
process.stderr.write(
|
|
207
|
+
`${credential.ghEnv ? `${credential.ghEnv} environment` : "repository"} variable ${credential.expiresVar}=YYYY-MM-DD is required\n` +
|
|
208
|
+
` gh variable set ${credential.expiresVar} ${flags}--repo ${repo} --body YYYY-MM-DD\n`,
|
|
209
|
+
);
|
|
210
|
+
return 1;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (rejected || assessment.kind === "warn" || assessment.kind === "expired") {
|
|
214
|
+
const result = upsertRotationIssue(
|
|
215
|
+
credential,
|
|
216
|
+
{
|
|
217
|
+
expiresYmd: expiresYmd || "unknown",
|
|
218
|
+
kind: assessment.kind,
|
|
219
|
+
daysLeft: assessment.daysLeft,
|
|
220
|
+
rejected,
|
|
221
|
+
},
|
|
222
|
+
upsert,
|
|
223
|
+
);
|
|
224
|
+
process.stdout.write(
|
|
225
|
+
`${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
if (rejected || assessment.kind === "expired" || assessment.kind === "missing") {
|
|
230
|
+
if (assessment.kind === "expired") {
|
|
231
|
+
process.stderr.write(
|
|
232
|
+
`${credential.secret} calendar expiry ${expiresYmd} has been reached — refusing to continue\n`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
return 1;
|
|
236
|
+
}
|
|
237
|
+
return 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const isCli =
|
|
241
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
242
|
+
|
|
243
|
+
if (isCli) {
|
|
244
|
+
process.exit(await runNudge());
|
|
245
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { upsertIssue } from "../lib/github-issue.mjs";
|
|
4
|
+
import { listPublishablePackages } from "./publish-verify.mjs";
|
|
5
|
+
|
|
6
|
+
const GITHUB_API = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
7
|
+
const ISSUE_TITLE = "deploy(publish): OIDC bootstrap unpublished packages";
|
|
8
|
+
const BOOTSTRAP_ENV = "npm-oidc-bootstrap";
|
|
9
|
+
const DEFAULT_REPO = "typesmiths/decohere";
|
|
10
|
+
|
|
11
|
+
export function issueTitle() {
|
|
12
|
+
return ISSUE_TITLE;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** @param {string | undefined} raw */
|
|
16
|
+
export function parseUnpublished(raw) {
|
|
17
|
+
return String(raw ?? "")
|
|
18
|
+
.split(/\s+/)
|
|
19
|
+
.map((name) => name.trim())
|
|
20
|
+
.filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {string | undefined} version
|
|
25
|
+
*/
|
|
26
|
+
export function needsPrereleaseTag(version) {
|
|
27
|
+
return String(version ?? "").includes("-");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{ name: string, dir: string, version?: string }[]} packages
|
|
32
|
+
* @param {string} [channel]
|
|
33
|
+
*/
|
|
34
|
+
export function firstPublishCommands(packages, channel = "dev") {
|
|
35
|
+
if (packages.length === 0) {
|
|
36
|
+
return [`npm publish ./<package-dir> --tag ${channel}`];
|
|
37
|
+
}
|
|
38
|
+
return packages.map((pkg) => {
|
|
39
|
+
const tag = needsPrereleaseTag(pkg.version) ? ` --tag ${channel}` : "";
|
|
40
|
+
return `npm publish ./projects/${pkg.dir}${tag}`;
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {unknown} environment
|
|
46
|
+
* @returns {string[]}
|
|
47
|
+
*/
|
|
48
|
+
export function extractGitHubReviewerLogins(environment) {
|
|
49
|
+
const rules =
|
|
50
|
+
environment && typeof environment === "object" ? environment.protection_rules : null;
|
|
51
|
+
if (!Array.isArray(rules)) {
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
const logins = [];
|
|
55
|
+
for (const rule of rules) {
|
|
56
|
+
const reviewers = rule?.reviewers;
|
|
57
|
+
if (!Array.isArray(reviewers)) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
for (const entry of reviewers) {
|
|
61
|
+
const login = entry?.reviewer?.login;
|
|
62
|
+
if (typeof login === "string" && login.length > 0 && entry?.type !== "Team") {
|
|
63
|
+
logins.push(login);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return [...new Set(logins)];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{
|
|
72
|
+
* unpublished: string[],
|
|
73
|
+
* packages: { name: string, dir: string, version?: string }[],
|
|
74
|
+
* repo?: string,
|
|
75
|
+
* runUrl?: string,
|
|
76
|
+
* channel?: string,
|
|
77
|
+
* }} options
|
|
78
|
+
*/
|
|
79
|
+
export function buildDescription(options) {
|
|
80
|
+
const repo = options.repo || DEFAULT_REPO;
|
|
81
|
+
const names = options.unpublished;
|
|
82
|
+
const channel = options.channel || "dev";
|
|
83
|
+
const packages = options.packages.filter((pkg) => names.includes(pkg.name));
|
|
84
|
+
const [owner, repoName] = repo.split("/");
|
|
85
|
+
return [
|
|
86
|
+
"## Summary",
|
|
87
|
+
"",
|
|
88
|
+
"These package names are not on npm yet, so OIDC `npm publish` cannot run until each name exists and has a Trusted Publisher:",
|
|
89
|
+
"",
|
|
90
|
+
...names.map((name) => `- \`${name}\``),
|
|
91
|
+
"",
|
|
92
|
+
"## What to do",
|
|
93
|
+
"",
|
|
94
|
+
"1. First-publish each name **restricted** (scoped default — do **not** pass `--access public`). 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:",
|
|
95
|
+
"",
|
|
96
|
+
"```bash",
|
|
97
|
+
...firstPublishCommands(packages, channel),
|
|
98
|
+
"```",
|
|
99
|
+
"",
|
|
100
|
+
"2. npm → package settings → Trusted Publisher → GitHub Actions:",
|
|
101
|
+
"",
|
|
102
|
+
`- Organization: \`${owner ?? "typesmiths"}\``,
|
|
103
|
+
`- Repository: \`${repoName ?? "decohere"}\``,
|
|
104
|
+
"- Workflow filename: `publish.yml`",
|
|
105
|
+
"- Environment: `npm-publish`",
|
|
106
|
+
"- Allowed action: `npm publish` (not staged-only)",
|
|
107
|
+
"",
|
|
108
|
+
"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:",
|
|
109
|
+
"",
|
|
110
|
+
"```bash",
|
|
111
|
+
`gh run list --repo ${repo} --workflow Publish --limit 5`,
|
|
112
|
+
`gh run rerun --failed <run-id> --repo ${repo}`,
|
|
113
|
+
"```",
|
|
114
|
+
"",
|
|
115
|
+
options.runUrl ? `- Workflow run: ${options.runUrl}` : null,
|
|
116
|
+
]
|
|
117
|
+
.filter((line) => line != null)
|
|
118
|
+
.join("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function runUrl() {
|
|
122
|
+
if (
|
|
123
|
+
process.env.GITHUB_SERVER_URL &&
|
|
124
|
+
process.env.GITHUB_REPOSITORY &&
|
|
125
|
+
process.env.GITHUB_RUN_ID
|
|
126
|
+
) {
|
|
127
|
+
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
128
|
+
}
|
|
129
|
+
return "";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {{ fetch?: typeof fetch, token?: string, repo?: string, environment?: string }} options
|
|
134
|
+
*/
|
|
135
|
+
export async function githubEnvironmentReviewerLogins(options = {}) {
|
|
136
|
+
const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
|
|
137
|
+
const repo = options.repo ?? process.env.GITHUB_REPOSITORY ?? "";
|
|
138
|
+
const environment = options.environment ?? BOOTSTRAP_ENV;
|
|
139
|
+
const fetchFn = options.fetch ?? fetch;
|
|
140
|
+
if (!token || !repo) {
|
|
141
|
+
return [];
|
|
142
|
+
}
|
|
143
|
+
const response = await fetchFn(
|
|
144
|
+
`${GITHUB_API}/repos/${repo}/environments/${encodeURIComponent(environment)}`,
|
|
145
|
+
{
|
|
146
|
+
headers: {
|
|
147
|
+
Authorization: `Bearer ${token}`,
|
|
148
|
+
Accept: "application/vnd.github+json",
|
|
149
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
process.stderr.write(
|
|
155
|
+
`Could not list ${environment} reviewers (${response.status}); the issue will have no assignees\n`,
|
|
156
|
+
);
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
return extractGitHubReviewerLogins(await response.json());
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export async function runNudge(env = process.env, io = {}) {
|
|
163
|
+
const unpublished = parseUnpublished(env.UNPUBLISHED);
|
|
164
|
+
if (unpublished.length === 0) {
|
|
165
|
+
process.stdout.write("No unpublished package names; skipping OIDC bootstrap issue\n");
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const root = io.root ?? process.cwd();
|
|
170
|
+
const packages = (io.listPackages ?? listPublishablePackages)(root);
|
|
171
|
+
const assignees = await (io.githubReviewers ?? githubEnvironmentReviewerLogins)({
|
|
172
|
+
token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
|
|
173
|
+
repo: env.GITHUB_REPOSITORY,
|
|
174
|
+
});
|
|
175
|
+
const repo = env.GITHUB_REPOSITORY || DEFAULT_REPO;
|
|
176
|
+
|
|
177
|
+
const result = (io.upsert ?? upsertIssue)({
|
|
178
|
+
title: issueTitle(),
|
|
179
|
+
body: buildDescription({
|
|
180
|
+
unpublished,
|
|
181
|
+
packages,
|
|
182
|
+
repo,
|
|
183
|
+
runUrl: io.runUrl ?? runUrl(),
|
|
184
|
+
channel: env.PUBLISH_CHANNEL || env.CHANNEL || "dev",
|
|
185
|
+
}),
|
|
186
|
+
assignees,
|
|
187
|
+
});
|
|
188
|
+
process.stdout.write(
|
|
189
|
+
`${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
|
|
190
|
+
);
|
|
191
|
+
if (assignees.length > 0) {
|
|
192
|
+
process.stdout.write(`Assigned ${assignees.join(", ")}\n`);
|
|
193
|
+
} else {
|
|
194
|
+
process.stdout.write(
|
|
195
|
+
`No assignees (add required reviewers on the ${BOOTSTRAP_ENV} environment)\n`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
if (env.GITHUB_OUTPUT) {
|
|
199
|
+
const { appendFileSync } = await import("node:fs");
|
|
200
|
+
appendFileSync(env.GITHUB_OUTPUT, `issue_url=${result.url}\n`);
|
|
201
|
+
}
|
|
202
|
+
return 0;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const isCli =
|
|
206
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
207
|
+
|
|
208
|
+
if (isCli) {
|
|
209
|
+
process.exit(await runNudge());
|
|
210
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { listProjectDirs } from "../lib/workspace-packages.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Workspace packages that `forge publish` will upload (not `"private": true`), including
|
|
9
|
+
* grouped ones such as `projects/data/data-typeorm`.
|
|
10
|
+
* @param {string} root
|
|
11
|
+
*/
|
|
12
|
+
export function listPublishablePackages(root) {
|
|
13
|
+
const packages = [];
|
|
14
|
+
for (const projectDir of listProjectDirs(root)) {
|
|
15
|
+
const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
|
|
16
|
+
if (typeof pkg.name !== "string" || pkg.private === true) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
packages.push({
|
|
20
|
+
name: pkg.name,
|
|
21
|
+
version: pkg.version,
|
|
22
|
+
dir: relative(join(root, "projects"), projectDir),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
return packages.sort((a, b) => a.name.localeCompare(b.name));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function dockerRepositories(root) {
|
|
29
|
+
const create = JSON.parse(readFileSync(join(root, "projects/create/package.json"), "utf8"));
|
|
30
|
+
const devcontainer = JSON.parse(
|
|
31
|
+
readFileSync(join(root, "projects/devcontainer/package.json"), "utf8"),
|
|
32
|
+
);
|
|
33
|
+
return {
|
|
34
|
+
create: create.decohere?.dockerImageRepository ?? "ghcr.io/typesmiths/decohere/create",
|
|
35
|
+
devcontainer:
|
|
36
|
+
devcontainer.decohere?.dockerImageRepository ??
|
|
37
|
+
"ghcr.io/typesmiths/decohere/devcontainer",
|
|
38
|
+
createVersion: create.version,
|
|
39
|
+
workspaceVersion: JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} spec package or package@version
|
|
45
|
+
* @param {{ spawnSync?: typeof spawnSync }} [io]
|
|
46
|
+
*/
|
|
47
|
+
export function npmNameExists(spec, io = {}) {
|
|
48
|
+
const spawn = io.spawnSync ?? spawnSync;
|
|
49
|
+
const result = spawn("npm", ["view", spec, "name"], {
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
52
|
+
});
|
|
53
|
+
return result.status === 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {string} spec package@version
|
|
58
|
+
* @param {{ spawnSync?: typeof spawnSync }} [io]
|
|
59
|
+
*/
|
|
60
|
+
export function npmVersionExists(spec, io = {}) {
|
|
61
|
+
const spawn = io.spawnSync ?? spawnSync;
|
|
62
|
+
const result = spawn("npm", ["view", spec, "version"], {
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
65
|
+
});
|
|
66
|
+
return result.status === 0;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Parse `ghcr.io/org/name/image` into GitHub packages API coordinates.
|
|
71
|
+
* @param {string} repository
|
|
72
|
+
*/
|
|
73
|
+
export function parseGhcrRepository(repository) {
|
|
74
|
+
const withoutHost = repository.replace(/^ghcr\.io\//, "");
|
|
75
|
+
const slash = withoutHost.indexOf("/");
|
|
76
|
+
if (slash < 0) {
|
|
77
|
+
throw new Error(`Not a GHCR repository: ${repository}`);
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
org: withoutHost.slice(0, slash),
|
|
81
|
+
packageName: withoutHost.slice(slash + 1),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} repository ghcr.io/org/pkg
|
|
87
|
+
* @param {string} tag
|
|
88
|
+
* @param {{ token: string, fetch?: typeof fetch }} io
|
|
89
|
+
*/
|
|
90
|
+
export async function ghcrTagExists(repository, tag, io) {
|
|
91
|
+
const { org, packageName } = parseGhcrRepository(repository);
|
|
92
|
+
const encoded = encodeURIComponent(packageName);
|
|
93
|
+
const headers = {
|
|
94
|
+
Authorization: `Bearer ${io.token}`,
|
|
95
|
+
Accept: "application/vnd.github+json",
|
|
96
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
97
|
+
};
|
|
98
|
+
const fetchImpl = io.fetch ?? fetch;
|
|
99
|
+
for (let page = 1; page <= 20; page += 1) {
|
|
100
|
+
const url = `https://api.github.com/orgs/${org}/packages/container/${encoded}/versions?per_page=100&page=${page}`;
|
|
101
|
+
const response = await fetchImpl(url, { headers });
|
|
102
|
+
if (response.status === 404) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
if (!response.ok) {
|
|
106
|
+
throw new Error(`GHCR versions ${response.status} for ${repository}`);
|
|
107
|
+
}
|
|
108
|
+
const versions = await response.json();
|
|
109
|
+
if (!Array.isArray(versions) || versions.length === 0) {
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
for (const version of versions) {
|
|
113
|
+
const tags = version.metadata?.container?.tags ?? [];
|
|
114
|
+
if (tags.includes(tag)) {
|
|
115
|
+
return true;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (versions.length < 100) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return false;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* @param {{
|
|
127
|
+
* root: string,
|
|
128
|
+
* spawnSync?: typeof spawnSync,
|
|
129
|
+
* fetch?: typeof fetch,
|
|
130
|
+
* githubToken?: string,
|
|
131
|
+
* }} options
|
|
132
|
+
*/
|
|
133
|
+
export async function collectPublishVerify(options) {
|
|
134
|
+
const packages = listPublishablePackages(options.root);
|
|
135
|
+
const unpublished = [];
|
|
136
|
+
let publishedVersions = 0;
|
|
137
|
+
for (const pkg of packages) {
|
|
138
|
+
if (!npmNameExists(pkg.name, options)) {
|
|
139
|
+
unpublished.push(pkg.name);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (npmVersionExists(`${pkg.name}@${pkg.version}`, options)) {
|
|
143
|
+
publishedVersions += 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const images = dockerRepositories(options.root);
|
|
148
|
+
const token = options.githubToken ?? "";
|
|
149
|
+
let skipDocker = false;
|
|
150
|
+
if (token) {
|
|
151
|
+
const createExists = await ghcrTagExists(images.create, images.workspaceVersion, {
|
|
152
|
+
token,
|
|
153
|
+
fetch: options.fetch,
|
|
154
|
+
});
|
|
155
|
+
const devExists = await ghcrTagExists(images.devcontainer, images.workspaceVersion, {
|
|
156
|
+
token,
|
|
157
|
+
fetch: options.fetch,
|
|
158
|
+
});
|
|
159
|
+
skipDocker = createExists && devExists;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
unpublished,
|
|
164
|
+
skipNpm: packages.length > 0 && publishedVersions === packages.length,
|
|
165
|
+
skipDocker,
|
|
166
|
+
imageTag: images.workspaceVersion,
|
|
167
|
+
createVersion: images.createVersion,
|
|
168
|
+
repository: images.create,
|
|
169
|
+
devcontainerRepository: images.devcontainer,
|
|
170
|
+
packageCount: packages.length,
|
|
171
|
+
publishedVersions,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function formatGithubOutput(result) {
|
|
176
|
+
return [
|
|
177
|
+
`unpublished=${result.unpublished.join(" ")}`,
|
|
178
|
+
`skip_npm=${result.skipNpm ? "true" : "false"}`,
|
|
179
|
+
`skip_docker=${result.skipDocker ? "true" : "false"}`,
|
|
180
|
+
`image_tag=${result.imageTag}`,
|
|
181
|
+
`create_version=${result.createVersion}`,
|
|
182
|
+
`repository=${result.repository}`,
|
|
183
|
+
`devcontainer_repository=${result.devcontainerRepository}`,
|
|
184
|
+
].join("\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const isCli =
|
|
188
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
189
|
+
|
|
190
|
+
if (isCli) {
|
|
191
|
+
const result = await collectPublishVerify({
|
|
192
|
+
root: process.cwd(),
|
|
193
|
+
githubToken: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
|
|
194
|
+
});
|
|
195
|
+
const body = formatGithubOutput(result);
|
|
196
|
+
process.stdout.write(`${body}\n`);
|
|
197
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
198
|
+
const { appendFileSync } = await import("node:fs");
|
|
199
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `${body}\n`);
|
|
200
|
+
}
|
|
201
|
+
if (result.unpublished.length) {
|
|
202
|
+
process.stderr.write(
|
|
203
|
+
`Never-published packages (bootstrap OIDC before PUT): ${result.unpublished.join(", ")}\n`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
}
|