@decohere/ci-tools 0.0.1-dev.9 → 0.0.1-dev.91
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 -2
- 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-clickup.mjs +117 -0
- package/src/audit/upsert-issue.mjs +22 -37
- package/src/bench/bench.mjs +1 -1
- package/src/bench/trace.mjs +1 -1
- package/src/github/credential-nudge.mjs +320 -0
- package/src/github/oidc-bootstrap-nudge.mjs +368 -0
- package/src/github/publish-verify.mjs +209 -0
- package/src/github/smoke-create-launcher.mjs +70 -0
- package/src/release/sync-dependencies.mjs +1 -2
- package/tests/audit/format.test.mjs +115 -0
- package/tests/github/credential-nudge.test.mjs +65 -0
- package/tests/github/oidc-bootstrap-nudge.test.mjs +104 -0
- package/tests/github/publish-verify.test.mjs +95 -0
- package/tests/github/smoke-create-launcher.test.mjs +71 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
|
|
4
|
+
const LIST_ID = process.env.CLICKUP_LIST_ID ?? "901220171084";
|
|
5
|
+
const API = "https://api.clickup.com/api/v2";
|
|
6
|
+
const WARN_DAYS = 14;
|
|
7
|
+
const MS_PER_DAY = 86_400_000;
|
|
8
|
+
const CHORE_TYPE = 1004;
|
|
9
|
+
const DEFAULT_REPO = "typesmiths/decohere";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* One profile per credential. Date math, ClickUp upsert, and fail-closed live below.
|
|
13
|
+
* `ghEnv` unset means repo secret / repo variable (not an Actions environment).
|
|
14
|
+
*
|
|
15
|
+
* @typedef {{
|
|
16
|
+
* id: string,
|
|
17
|
+
* secret: string,
|
|
18
|
+
* expiresVar: string,
|
|
19
|
+
* ghEnv?: string,
|
|
20
|
+
* mint: string,
|
|
21
|
+
* failImpact: string,
|
|
22
|
+
* rejectedWhy: string,
|
|
23
|
+
* revokeUrl: string,
|
|
24
|
+
* }} CredentialProfile
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** @type {Record<string, CredentialProfile>} */
|
|
28
|
+
export const CREDENTIALS = {
|
|
29
|
+
ghcr: {
|
|
30
|
+
id: "ghcr",
|
|
31
|
+
secret: "GHCR_PUSH_TOKEN",
|
|
32
|
+
expiresVar: "GHCR_TOKEN_EXPIRES_AT",
|
|
33
|
+
ghEnv: "ghcr-publish",
|
|
34
|
+
mint: "Create a **classic** GitHub PAT (fine-grained tokens cannot push to GHCR): https://github.com/settings/tokens/new?scopes=write:packages",
|
|
35
|
+
failImpact:
|
|
36
|
+
"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`.",
|
|
37
|
+
rejectedWhy: "GHCR `docker login` returned 401 (expired, revoked, or wrong secret name).",
|
|
38
|
+
revokeUrl: "https://github.com/settings/tokens",
|
|
39
|
+
},
|
|
40
|
+
npm: {
|
|
41
|
+
id: "npm",
|
|
42
|
+
secret: "NODE_AUTH_TOKEN",
|
|
43
|
+
expiresVar: "NODE_AUTH_TOKEN_EXPIRES_AT",
|
|
44
|
+
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.",
|
|
45
|
+
failImpact:
|
|
46
|
+
"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.",
|
|
47
|
+
rejectedWhy: "`npm whoami` failed (expired, revoked, or missing `NODE_AUTH_TOKEN`).",
|
|
48
|
+
revokeUrl: "https://www.npmjs.com/settings/~/tokens",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function utcDayMs(date) {
|
|
53
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** @param {string | undefined} raw */
|
|
57
|
+
export function parseExpiresAt(raw) {
|
|
58
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(raw ?? "").trim());
|
|
59
|
+
if (!match) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* @param {{ now?: number, expiresAtMs: number | null, warnDays?: number }} options
|
|
67
|
+
* @returns {{ kind: "missing" | "ok" | "warn" | "expired", daysLeft: number | null }}
|
|
68
|
+
*/
|
|
69
|
+
export function assessTokenLifetime(options) {
|
|
70
|
+
const { expiresAtMs, warnDays = WARN_DAYS } = options;
|
|
71
|
+
if (expiresAtMs == null) {
|
|
72
|
+
return { kind: "missing", daysLeft: null };
|
|
73
|
+
}
|
|
74
|
+
const today = utcDayMs(new Date(options.now ?? Date.now()));
|
|
75
|
+
const daysLeft = Math.round((expiresAtMs - today) / MS_PER_DAY);
|
|
76
|
+
if (daysLeft <= 0) {
|
|
77
|
+
return { kind: "expired", daysLeft };
|
|
78
|
+
}
|
|
79
|
+
if (daysLeft <= warnDays) {
|
|
80
|
+
return { kind: "warn", daysLeft };
|
|
81
|
+
}
|
|
82
|
+
return { kind: "ok", daysLeft };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @param {CredentialProfile} credential */
|
|
86
|
+
export function resolveRepo() {
|
|
87
|
+
return process.env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** @param {CredentialProfile} credential */
|
|
91
|
+
export function ghScopeFlags(credential) {
|
|
92
|
+
return credential.ghEnv ? `--env ${credential.ghEnv} ` : "";
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @param {string} id */
|
|
96
|
+
export function credentialProfile(id) {
|
|
97
|
+
const credential = CREDENTIALS[id];
|
|
98
|
+
if (!credential) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Unknown NUDGE_CREDENTIAL=${id}. Expected one of: ${Object.keys(CREDENTIALS).join(", ")}`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
return credential;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** @param {CredentialProfile} credential */
|
|
107
|
+
export function taskName(credential, expiresYmd) {
|
|
108
|
+
return `chore(security): rotate ${credential.secret} (expires ${expiresYmd})`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** @param {CredentialProfile} credential */
|
|
112
|
+
export function rotationCommands(credential, nextExpiresYmd, repo = resolveRepo()) {
|
|
113
|
+
const next = nextExpiresYmd ?? "YYYY-MM-DD";
|
|
114
|
+
const flags = ghScopeFlags(credential);
|
|
115
|
+
return [
|
|
116
|
+
credential.mint,
|
|
117
|
+
"",
|
|
118
|
+
"Then from a trusted machine (do not paste the token into Actions or this task):",
|
|
119
|
+
"",
|
|
120
|
+
"```bash",
|
|
121
|
+
`gh secret set ${credential.secret} ${flags}--repo ${repo}`,
|
|
122
|
+
`gh variable set ${credential.expiresVar} ${flags}--repo ${repo} --body ${next}`,
|
|
123
|
+
"```",
|
|
124
|
+
"",
|
|
125
|
+
`Revoke the old token: ${credential.revokeUrl}`,
|
|
126
|
+
"Mark this task complete, then retry Publish:",
|
|
127
|
+
"",
|
|
128
|
+
"```bash",
|
|
129
|
+
`gh run list --repo ${repo} --workflow Publish --limit 5`,
|
|
130
|
+
`gh run rerun --failed <run-id> --repo ${repo}`,
|
|
131
|
+
"```",
|
|
132
|
+
].join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function suggestedNextExpiry(fromMs, days = 90) {
|
|
136
|
+
const start = utcDayMs(new Date(fromMs));
|
|
137
|
+
const next = new Date(start + days * MS_PER_DAY);
|
|
138
|
+
const y = next.getUTCFullYear();
|
|
139
|
+
const m = String(next.getUTCMonth() + 1).padStart(2, "0");
|
|
140
|
+
const d = String(next.getUTCDate()).padStart(2, "0");
|
|
141
|
+
return `${y}-${m}-${d}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** @param {CredentialProfile} credential */
|
|
145
|
+
export function buildDescription(credential, options) {
|
|
146
|
+
const { kind, expiresYmd, daysLeft, rejected, runUrl } = options;
|
|
147
|
+
const next = suggestedNextExpiry(Date.now());
|
|
148
|
+
const why = rejected
|
|
149
|
+
? credential.rejectedWhy
|
|
150
|
+
: kind === "expired"
|
|
151
|
+
? `${credential.expiresVar} (${expiresYmd}) is today or in the past. Rotate ${credential.secret} and this date before Publish can continue.`
|
|
152
|
+
: `${credential.secret} expires on ${expiresYmd} (${daysLeft} day(s) left). Rotate before that date so Publish does not fail closed.`;
|
|
153
|
+
return [
|
|
154
|
+
"## Summary",
|
|
155
|
+
"",
|
|
156
|
+
why,
|
|
157
|
+
"",
|
|
158
|
+
"## If this is not done in time",
|
|
159
|
+
"",
|
|
160
|
+
credential.failImpact,
|
|
161
|
+
"",
|
|
162
|
+
"## Commands",
|
|
163
|
+
"",
|
|
164
|
+
rotationCommands(credential, next),
|
|
165
|
+
"",
|
|
166
|
+
runUrl ? `- Workflow run: ${runUrl}` : null,
|
|
167
|
+
]
|
|
168
|
+
.filter((line) => line != null)
|
|
169
|
+
.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function clickup(method, path, body) {
|
|
173
|
+
const key = process.env.CLICKUP_API_KEY;
|
|
174
|
+
if (key == null || key.length === 0) {
|
|
175
|
+
throw new Error("Missing CLICKUP_API_KEY");
|
|
176
|
+
}
|
|
177
|
+
const response = await fetch(`${API}${path}`, {
|
|
178
|
+
method,
|
|
179
|
+
headers: {
|
|
180
|
+
Authorization: key,
|
|
181
|
+
"Content-Type": "application/json",
|
|
182
|
+
},
|
|
183
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
184
|
+
});
|
|
185
|
+
const text = await response.text();
|
|
186
|
+
if (!response.ok) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
`ClickUp ${method} ${path} failed (${response.status}): ${text.slice(0, 500)}`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return text.length === 0 ? undefined : JSON.parse(text);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function findTaskByName(name) {
|
|
195
|
+
for (let page = 0; page < 10; page += 1) {
|
|
196
|
+
const qs = new URLSearchParams({
|
|
197
|
+
include_closed: "true",
|
|
198
|
+
subtasks: "false",
|
|
199
|
+
page: String(page),
|
|
200
|
+
});
|
|
201
|
+
const data = await clickup("GET", `/list/${LIST_ID}/task?${qs}`);
|
|
202
|
+
const tasks = Array.isArray(data) ? data : (data?.tasks ?? []);
|
|
203
|
+
const match = tasks.find((task) => task.name === name);
|
|
204
|
+
if (match) {
|
|
205
|
+
return match;
|
|
206
|
+
}
|
|
207
|
+
if (tasks.length < 100) {
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function firstOpenStatus() {
|
|
215
|
+
const list = await clickup("GET", `/list/${LIST_ID}`);
|
|
216
|
+
return (list.statuses ?? []).find((status) => status.type === "open")?.status;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function runUrl() {
|
|
220
|
+
if (
|
|
221
|
+
process.env.GITHUB_SERVER_URL &&
|
|
222
|
+
process.env.GITHUB_REPOSITORY &&
|
|
223
|
+
process.env.GITHUB_RUN_ID
|
|
224
|
+
) {
|
|
225
|
+
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
226
|
+
}
|
|
227
|
+
return "";
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** @param {CredentialProfile} credential */
|
|
231
|
+
export async function upsertRotationTask(credential, options) {
|
|
232
|
+
const { expiresYmd, kind, daysLeft, rejected } = options;
|
|
233
|
+
const name = taskName(credential, expiresYmd ?? "unknown");
|
|
234
|
+
const existing = await findTaskByName(name);
|
|
235
|
+
const dueMs = parseExpiresAt(expiresYmd);
|
|
236
|
+
const payload = {
|
|
237
|
+
name,
|
|
238
|
+
markdown_description: buildDescription(credential, {
|
|
239
|
+
kind,
|
|
240
|
+
expiresYmd,
|
|
241
|
+
daysLeft,
|
|
242
|
+
rejected,
|
|
243
|
+
runUrl: runUrl(),
|
|
244
|
+
}),
|
|
245
|
+
custom_item_id: CHORE_TYPE,
|
|
246
|
+
priority: rejected || kind === "expired" ? 1 : 2,
|
|
247
|
+
due_date: dueMs == null ? undefined : dueMs,
|
|
248
|
+
due_date_time: false,
|
|
249
|
+
};
|
|
250
|
+
const assignee = process.env.CLICKUP_ASSIGNEE_ID?.trim();
|
|
251
|
+
if (assignee) {
|
|
252
|
+
payload.assignees = [Number(assignee)];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (existing) {
|
|
256
|
+
const update = {
|
|
257
|
+
markdown_description: payload.markdown_description,
|
|
258
|
+
priority: payload.priority,
|
|
259
|
+
due_date: payload.due_date,
|
|
260
|
+
due_date_time: false,
|
|
261
|
+
};
|
|
262
|
+
if (existing.closed || existing.status?.type === "closed") {
|
|
263
|
+
const status = await firstOpenStatus();
|
|
264
|
+
if (status) {
|
|
265
|
+
update.status = status;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
await clickup("PUT", `/task/${existing.id}`, update);
|
|
269
|
+
return { id: existing.id, url: existing.url, created: false };
|
|
270
|
+
}
|
|
271
|
+
const created = await clickup("POST", `/list/${LIST_ID}/task`, payload);
|
|
272
|
+
return { id: created.id, url: created.url, created: true };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function runNudge(env = process.env) {
|
|
276
|
+
const credential = credentialProfile(String(env.NUDGE_CREDENTIAL ?? "").trim());
|
|
277
|
+
const rejected = env.NUDGE_REJECTED === "1";
|
|
278
|
+
const expiresYmd = String(env[credential.expiresVar] ?? "").trim();
|
|
279
|
+
const expiresAtMs = parseExpiresAt(expiresYmd);
|
|
280
|
+
const assessment = assessTokenLifetime({ expiresAtMs });
|
|
281
|
+
const flags = ghScopeFlags(credential);
|
|
282
|
+
const repo = env.GITHUB_REPOSITORY?.trim() || DEFAULT_REPO;
|
|
283
|
+
|
|
284
|
+
if (assessment.kind === "missing" && !rejected) {
|
|
285
|
+
process.stderr.write(
|
|
286
|
+
`${credential.ghEnv ? `${credential.ghEnv} environment` : "repository"} variable ${credential.expiresVar}=YYYY-MM-DD is required\n` +
|
|
287
|
+
` gh variable set ${credential.expiresVar} ${flags}--repo ${repo} --body YYYY-MM-DD\n`,
|
|
288
|
+
);
|
|
289
|
+
return 1;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (rejected || assessment.kind === "warn" || assessment.kind === "expired") {
|
|
293
|
+
const result = await upsertRotationTask(credential, {
|
|
294
|
+
expiresYmd: expiresYmd || "unknown",
|
|
295
|
+
kind: assessment.kind,
|
|
296
|
+
daysLeft: assessment.daysLeft,
|
|
297
|
+
rejected,
|
|
298
|
+
});
|
|
299
|
+
process.stdout.write(
|
|
300
|
+
`${result.created ? "Created" : "Updated"} ClickUp ${result.id} ${result.url ?? ""}\n`,
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (rejected || assessment.kind === "expired" || assessment.kind === "missing") {
|
|
305
|
+
if (assessment.kind === "expired") {
|
|
306
|
+
process.stderr.write(
|
|
307
|
+
`${credential.secret} calendar expiry ${expiresYmd} has been reached — refusing to continue\n`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
return 1;
|
|
311
|
+
}
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const isCli =
|
|
316
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
317
|
+
|
|
318
|
+
if (isCli) {
|
|
319
|
+
process.exit(await runNudge());
|
|
320
|
+
}
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { listPublishablePackages } from "./publish-verify.mjs";
|
|
4
|
+
|
|
5
|
+
const LIST_ID = process.env.CLICKUP_LIST_ID ?? "901220171084";
|
|
6
|
+
const API = "https://api.clickup.com/api/v2";
|
|
7
|
+
const GITHUB_API = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
8
|
+
const DEPLOYMENT_TYPE = 1005;
|
|
9
|
+
const TASK_NAME = "deploy(publish): OIDC bootstrap unpublished packages";
|
|
10
|
+
const LEGACY_TASK_NAMES = ["chore(publish): OIDC bootstrap unpublished packages"];
|
|
11
|
+
const BOOTSTRAP_ENV = "npm-oidc-bootstrap";
|
|
12
|
+
const DEFAULT_REPO = "typesmiths/decohere";
|
|
13
|
+
|
|
14
|
+
export function taskName() {
|
|
15
|
+
return TASK_NAME;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function bootstrapTaskNames() {
|
|
19
|
+
return [TASK_NAME, ...LEGACY_TASK_NAMES];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {string | undefined} raw */
|
|
23
|
+
export function parseUnpublished(raw) {
|
|
24
|
+
return String(raw ?? "")
|
|
25
|
+
.split(/\s+/)
|
|
26
|
+
.map((name) => name.trim())
|
|
27
|
+
.filter(Boolean);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** @param {string | undefined} raw */
|
|
31
|
+
export function parseAssigneeIds(raw) {
|
|
32
|
+
return String(raw ?? "")
|
|
33
|
+
.split(/[,\s]+/)
|
|
34
|
+
.map((id) => Number(id.trim()))
|
|
35
|
+
.filter((id) => Number.isInteger(id) && id > 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string | undefined} version
|
|
40
|
+
*/
|
|
41
|
+
export function needsPrereleaseTag(version) {
|
|
42
|
+
return String(version ?? "").includes("-");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {{ name: string, dir: string, version?: string }[]} packages
|
|
47
|
+
* @param {string} [channel]
|
|
48
|
+
*/
|
|
49
|
+
export function firstPublishCommands(packages, channel = "dev") {
|
|
50
|
+
if (packages.length === 0) {
|
|
51
|
+
return [`npm publish ./<package-dir> --tag ${channel}`];
|
|
52
|
+
}
|
|
53
|
+
return packages.map((pkg) => {
|
|
54
|
+
const tag = needsPrereleaseTag(pkg.version) ? ` --tag ${channel}` : "";
|
|
55
|
+
return `npm publish ./projects/${pkg.dir}${tag}`;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {unknown} environment
|
|
61
|
+
* @returns {string[]}
|
|
62
|
+
*/
|
|
63
|
+
export function extractGitHubReviewerLogins(environment) {
|
|
64
|
+
const rules =
|
|
65
|
+
environment && typeof environment === "object" ? environment.protection_rules : null;
|
|
66
|
+
if (!Array.isArray(rules)) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
const logins = [];
|
|
70
|
+
for (const rule of rules) {
|
|
71
|
+
const reviewers = rule?.reviewers;
|
|
72
|
+
if (!Array.isArray(reviewers)) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
for (const entry of reviewers) {
|
|
76
|
+
const login = entry?.reviewer?.login;
|
|
77
|
+
if (typeof login === "string" && login.length > 0 && entry?.type !== "Team") {
|
|
78
|
+
logins.push(login);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return [...new Set(logins)];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string[]} logins
|
|
87
|
+
* @param {{ id?: number, username?: string, email?: string }[]} members
|
|
88
|
+
*/
|
|
89
|
+
export function mapLoginsToClickUpIds(logins, members) {
|
|
90
|
+
const ids = new Set();
|
|
91
|
+
for (const login of logins) {
|
|
92
|
+
const key = login.toLowerCase();
|
|
93
|
+
for (const member of members) {
|
|
94
|
+
const username = String(member.username ?? "").toLowerCase();
|
|
95
|
+
const email = String(member.email ?? "").toLowerCase();
|
|
96
|
+
const local = email.split("@")[0] ?? "";
|
|
97
|
+
if (username === key || local === key || email === key) {
|
|
98
|
+
const id = Number(member.id);
|
|
99
|
+
if (Number.isInteger(id) && id > 0) {
|
|
100
|
+
ids.add(id);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return [...ids];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* @param {{
|
|
110
|
+
* unpublished: string[],
|
|
111
|
+
* packages: { name: string, dir: string, version?: string }[],
|
|
112
|
+
* repo?: string,
|
|
113
|
+
* runUrl?: string,
|
|
114
|
+
* channel?: string,
|
|
115
|
+
* }} options
|
|
116
|
+
*/
|
|
117
|
+
export function buildDescription(options) {
|
|
118
|
+
const repo = options.repo || DEFAULT_REPO;
|
|
119
|
+
const names = options.unpublished;
|
|
120
|
+
const channel = options.channel || "dev";
|
|
121
|
+
const packages = options.packages.filter((pkg) => names.includes(pkg.name));
|
|
122
|
+
const [owner, repoName] = repo.split("/");
|
|
123
|
+
return [
|
|
124
|
+
"## Summary",
|
|
125
|
+
"",
|
|
126
|
+
"These package names are not on npm yet, so OIDC `npm publish` cannot run until each name exists and has a Trusted Publisher:",
|
|
127
|
+
"",
|
|
128
|
+
...names.map((name) => `- \`${name}\``),
|
|
129
|
+
"",
|
|
130
|
+
"## What to do",
|
|
131
|
+
"",
|
|
132
|
+
"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:",
|
|
133
|
+
"",
|
|
134
|
+
"```bash",
|
|
135
|
+
...firstPublishCommands(packages, channel),
|
|
136
|
+
"```",
|
|
137
|
+
"",
|
|
138
|
+
"2. npm → package settings → Trusted Publisher → GitHub Actions:",
|
|
139
|
+
"",
|
|
140
|
+
`- Organization: \`${owner ?? "typesmiths"}\``,
|
|
141
|
+
`- Repository: \`${repoName ?? "decohere"}\``,
|
|
142
|
+
"- Workflow filename: `publish.yml`",
|
|
143
|
+
"- Environment: `npm-publish`",
|
|
144
|
+
"- Allowed action: `npm publish` (not staged-only)",
|
|
145
|
+
"",
|
|
146
|
+
"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:",
|
|
147
|
+
"",
|
|
148
|
+
"```bash",
|
|
149
|
+
`gh run list --repo ${repo} --workflow Publish --limit 5`,
|
|
150
|
+
`gh run rerun --failed <run-id> --repo ${repo}`,
|
|
151
|
+
"```",
|
|
152
|
+
"",
|
|
153
|
+
options.runUrl ? `- Workflow run: ${options.runUrl}` : null,
|
|
154
|
+
]
|
|
155
|
+
.filter((line) => line != null)
|
|
156
|
+
.join("\n");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function clickup(method, path, body) {
|
|
160
|
+
const key = process.env.CLICKUP_API_KEY;
|
|
161
|
+
if (key == null || key.length === 0) {
|
|
162
|
+
throw new Error("Missing CLICKUP_API_KEY");
|
|
163
|
+
}
|
|
164
|
+
const response = await fetch(`${API}${path}`, {
|
|
165
|
+
method,
|
|
166
|
+
headers: {
|
|
167
|
+
Authorization: key,
|
|
168
|
+
"Content-Type": "application/json",
|
|
169
|
+
},
|
|
170
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
171
|
+
});
|
|
172
|
+
const text = await response.text();
|
|
173
|
+
if (!response.ok) {
|
|
174
|
+
throw new Error(
|
|
175
|
+
`ClickUp ${method} ${path} failed (${response.status}): ${text.slice(0, 500)}`,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
return text.length === 0 ? undefined : JSON.parse(text);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function findTaskByName(names) {
|
|
182
|
+
const wanted = new Set(Array.isArray(names) ? names : [names]);
|
|
183
|
+
for (let page = 0; page < 10; page += 1) {
|
|
184
|
+
const qs = new URLSearchParams({
|
|
185
|
+
include_closed: "true",
|
|
186
|
+
subtasks: "false",
|
|
187
|
+
page: String(page),
|
|
188
|
+
});
|
|
189
|
+
const data = await clickup("GET", `/list/${LIST_ID}/task?${qs}`);
|
|
190
|
+
const tasks = Array.isArray(data) ? data : (data?.tasks ?? []);
|
|
191
|
+
const match = tasks.find((task) => wanted.has(task.name));
|
|
192
|
+
if (match) {
|
|
193
|
+
return match;
|
|
194
|
+
}
|
|
195
|
+
if (tasks.length < 100) {
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function firstOpenStatus() {
|
|
203
|
+
const list = await clickup("GET", `/list/${LIST_ID}`);
|
|
204
|
+
return (list.statuses ?? []).find((status) => status.type === "open")?.status;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function runUrl() {
|
|
208
|
+
if (
|
|
209
|
+
process.env.GITHUB_SERVER_URL &&
|
|
210
|
+
process.env.GITHUB_REPOSITORY &&
|
|
211
|
+
process.env.GITHUB_RUN_ID
|
|
212
|
+
) {
|
|
213
|
+
return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
|
|
214
|
+
}
|
|
215
|
+
return "";
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* @param {{ fetch?: typeof fetch, token?: string, repo?: string, environment?: string }} options
|
|
220
|
+
*/
|
|
221
|
+
export async function githubEnvironmentReviewerLogins(options = {}) {
|
|
222
|
+
const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
|
|
223
|
+
const repo = options.repo ?? process.env.GITHUB_REPOSITORY ?? "";
|
|
224
|
+
const environment = options.environment ?? BOOTSTRAP_ENV;
|
|
225
|
+
const fetchFn = options.fetch ?? fetch;
|
|
226
|
+
if (!token || !repo) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const response = await fetchFn(
|
|
230
|
+
`${GITHUB_API}/repos/${repo}/environments/${encodeURIComponent(environment)}`,
|
|
231
|
+
{
|
|
232
|
+
headers: {
|
|
233
|
+
Authorization: `Bearer ${token}`,
|
|
234
|
+
Accept: "application/vnd.github+json",
|
|
235
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
);
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
process.stderr.write(
|
|
241
|
+
`Could not list ${environment} reviewers (${response.status}); assigning from CLICKUP_ASSIGNEE_ID only\n`,
|
|
242
|
+
);
|
|
243
|
+
return [];
|
|
244
|
+
}
|
|
245
|
+
return extractGitHubReviewerLogins(await response.json());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function clickupWorkspaceMembers() {
|
|
249
|
+
const data = await clickup("GET", "/team");
|
|
250
|
+
const teams = Array.isArray(data?.teams) ? data.teams : [];
|
|
251
|
+
const members = [];
|
|
252
|
+
for (const team of teams) {
|
|
253
|
+
for (const entry of team.members ?? []) {
|
|
254
|
+
const user = entry?.user ?? entry;
|
|
255
|
+
if (user?.id) {
|
|
256
|
+
members.push({
|
|
257
|
+
id: Number(user.id),
|
|
258
|
+
username: user.username,
|
|
259
|
+
email: user.email,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return members;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* @param {{ unpublished: string[], packages: { name: string, dir: string, version?: string }[], assigneeIds: number[], repo?: string, runUrl?: string, channel?: string }} options
|
|
269
|
+
*/
|
|
270
|
+
export async function upsertBootstrapTask(options) {
|
|
271
|
+
const name = taskName();
|
|
272
|
+
const existing = await findTaskByName(bootstrapTaskNames());
|
|
273
|
+
const markdown = buildDescription(options);
|
|
274
|
+
const payload = {
|
|
275
|
+
name,
|
|
276
|
+
markdown_description: markdown,
|
|
277
|
+
custom_item_id: DEPLOYMENT_TYPE,
|
|
278
|
+
priority: 1,
|
|
279
|
+
};
|
|
280
|
+
if (options.assigneeIds.length > 0 && !existing) {
|
|
281
|
+
payload.assignees = options.assigneeIds;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (existing) {
|
|
285
|
+
const update = {
|
|
286
|
+
name,
|
|
287
|
+
markdown_description: markdown,
|
|
288
|
+
custom_item_id: DEPLOYMENT_TYPE,
|
|
289
|
+
priority: 1,
|
|
290
|
+
};
|
|
291
|
+
if (existing.closed || existing.status?.type === "closed") {
|
|
292
|
+
const status = await firstOpenStatus();
|
|
293
|
+
if (status) {
|
|
294
|
+
update.status = status;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (options.assigneeIds.length > 0) {
|
|
298
|
+
update.assignees = { add: options.assigneeIds };
|
|
299
|
+
}
|
|
300
|
+
await clickup("PUT", `/task/${existing.id}`, update);
|
|
301
|
+
return { id: existing.id, url: existing.url, created: false };
|
|
302
|
+
}
|
|
303
|
+
const created = await clickup("POST", `/list/${LIST_ID}/task`, payload);
|
|
304
|
+
return { id: created.id, url: created.url, created: true };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function runNudge(env = process.env, io = {}) {
|
|
308
|
+
const unpublished = parseUnpublished(env.UNPUBLISHED);
|
|
309
|
+
if (unpublished.length === 0) {
|
|
310
|
+
process.stdout.write("No unpublished package names; skipping ClickUp bootstrap task\n");
|
|
311
|
+
return 0;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const root = io.root ?? process.cwd();
|
|
315
|
+
const packages = (io.listPackages ?? listPublishablePackages)(root);
|
|
316
|
+
const logins = await (io.githubReviewers ?? githubEnvironmentReviewerLogins)({
|
|
317
|
+
token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
|
|
318
|
+
repo: env.GITHUB_REPOSITORY,
|
|
319
|
+
});
|
|
320
|
+
const members = await (io.clickupMembers ?? clickupWorkspaceMembers)();
|
|
321
|
+
const assigneeIds = [
|
|
322
|
+
...new Set([
|
|
323
|
+
...mapLoginsToClickUpIds(logins, members),
|
|
324
|
+
...parseAssigneeIds(env.CLICKUP_ASSIGNEE_ID),
|
|
325
|
+
]),
|
|
326
|
+
];
|
|
327
|
+
|
|
328
|
+
if (logins.length > 0 && mapLoginsToClickUpIds(logins, members).length === 0) {
|
|
329
|
+
process.stderr.write(
|
|
330
|
+
`GitHub ${BOOTSTRAP_ENV} reviewers (${logins.join(", ")}) did not match a ClickUp member (login ↔ username or email local-part)\n`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const result = await (io.upsert ?? upsertBootstrapTask)({
|
|
335
|
+
unpublished,
|
|
336
|
+
packages,
|
|
337
|
+
assigneeIds,
|
|
338
|
+
repo: env.GITHUB_REPOSITORY || DEFAULT_REPO,
|
|
339
|
+
runUrl: io.runUrl ?? runUrl(),
|
|
340
|
+
channel: env.PUBLISH_CHANNEL || env.CHANNEL || "dev",
|
|
341
|
+
});
|
|
342
|
+
const url = result.url ?? "";
|
|
343
|
+
process.stdout.write(
|
|
344
|
+
`${result.created ? "Created" : "Updated"} ClickUp ${result.id} ${url}\n`,
|
|
345
|
+
);
|
|
346
|
+
if (assigneeIds.length > 0) {
|
|
347
|
+
process.stdout.write(`Assigned ClickUp users ${assigneeIds.join(", ")}\n`);
|
|
348
|
+
} else {
|
|
349
|
+
process.stdout.write(
|
|
350
|
+
"No ClickUp assignees (add required reviewers on npm-oidc-bootstrap, or set vars.CLICKUP_ASSIGNEE_ID)\n",
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
if (env.GITHUB_OUTPUT) {
|
|
354
|
+
const { appendFileSync } = await import("node:fs");
|
|
355
|
+
appendFileSync(env.GITHUB_OUTPUT, `task_url=${url}\n`);
|
|
356
|
+
}
|
|
357
|
+
if (!url) {
|
|
358
|
+
throw new Error("ClickUp bootstrap task has no URL");
|
|
359
|
+
}
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const isCli =
|
|
364
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
365
|
+
|
|
366
|
+
if (isCli) {
|
|
367
|
+
process.exit(await runNudge());
|
|
368
|
+
}
|