@decohere/ci-tools 0.0.1-dev.20 → 0.0.1-dev.20261105.54
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/LICENSE +15 -0
- package/package.json +17 -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 +135 -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/npm-bootstrap.mjs +179 -0
- package/src/github/oidc-bootstrap-nudge.mjs +214 -0
- package/src/github/publish-verify.mjs +206 -0
- package/src/github/smoke-create-launcher.mjs +85 -0
- package/src/lib/github-issue.mjs +75 -0
- package/src/lib/lcov.mjs +1 -1
- package/src/lib/workspace-packages.mjs +30 -0
- package/src/pages/artifact.mjs +38 -183
- package/src/pages/bench-page.mjs +324 -0
- package/src/pages/restore-badges.mjs +35 -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/ci-workflow.test.mjs +19 -0
- package/tests/github/npm-bootstrap.test.mjs +125 -0
- package/tests/github/oidc-bootstrap-nudge.test.mjs +89 -0
- package/tests/github/package-metadata.test.mjs +69 -0
- package/tests/github/publish-verify.test.mjs +115 -0
- package/tests/github/smoke-create-launcher.test.mjs +132 -0
- package/tests/lib/github-issue.test.mjs +85 -0
- package/tests/lib/lcov.test.mjs +8 -8
- 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,214 @@
|
|
|
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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run `npx @decohere/create@version --help` from an empty temp directory.
|
|
10
|
+
* Must not use the repo cwd — after `@decohere/cli` is pinned, pnpm installs
|
|
11
|
+
* `@decohere/create` into the workspace tree and npm's npx misses the bin.
|
|
12
|
+
* No token: the packages are public, so this installs the launcher the way a user does.
|
|
13
|
+
* Anonymous reads go through npm's cache, which can lag minutes behind a publish, so it
|
|
14
|
+
* first waits until npm serves the version (`attempts` tries, `intervalMs` apart).
|
|
15
|
+
*
|
|
16
|
+
* @param {{
|
|
17
|
+
* createVersion: string,
|
|
18
|
+
* attempts?: number,
|
|
19
|
+
* intervalMs?: number,
|
|
20
|
+
* sleep?: (ms: number) => void,
|
|
21
|
+
* mkdtempSync?: typeof mkdtempSync,
|
|
22
|
+
* writeFileSync?: typeof writeFileSync,
|
|
23
|
+
* spawnSync?: typeof spawnSync,
|
|
24
|
+
* tmpdir?: () => string,
|
|
25
|
+
* stdio?: import("node:child_process").StdioOptions,
|
|
26
|
+
* }} options
|
|
27
|
+
*/
|
|
28
|
+
export function runCreateLauncherSmoke(options) {
|
|
29
|
+
const version = options.createVersion?.trim() ?? "";
|
|
30
|
+
if (!version) {
|
|
31
|
+
throw new Error("CREATE_VERSION is required");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const mkdtemp = options.mkdtempSync ?? mkdtempSync;
|
|
35
|
+
const writeFile = options.writeFileSync ?? writeFileSync;
|
|
36
|
+
const spawn = options.spawnSync ?? spawnSync;
|
|
37
|
+
const tmp = options.tmpdir ?? tmpdir;
|
|
38
|
+
const sleep = options.sleep ?? sleepSync;
|
|
39
|
+
const attempts = options.attempts ?? 20;
|
|
40
|
+
const intervalMs = options.intervalMs ?? 15_000;
|
|
41
|
+
|
|
42
|
+
const workDir = mkdtemp(join(tmp(), "decohere-create-smoke-"));
|
|
43
|
+
const npmrc = join(workDir, ".npmrc");
|
|
44
|
+
writeFile(npmrc, "@decohere:registry=https://registry.npmjs.org/\n");
|
|
45
|
+
|
|
46
|
+
const env = { ...process.env, NPM_CONFIG_USERCONFIG: npmrc };
|
|
47
|
+
const spec = `@decohere/create@${version}`;
|
|
48
|
+
|
|
49
|
+
for (let attempt = 1; ; attempt++) {
|
|
50
|
+
const view = spawn("npm", ["view", spec, "version"], { cwd: workDir, env, stdio: "pipe" });
|
|
51
|
+
if (view.status === 0) break;
|
|
52
|
+
if (attempt >= attempts) {
|
|
53
|
+
console.error(`${spec} is still not served by npm after ${attempts} tries`);
|
|
54
|
+
return { workDir, status: 1 };
|
|
55
|
+
}
|
|
56
|
+
console.log(`${spec} not served by npm yet (try ${attempt} of ${attempts}); waiting`);
|
|
57
|
+
sleep(intervalMs);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const result = spawn("npx", ["--yes", spec, "--help"], {
|
|
61
|
+
cwd: workDir,
|
|
62
|
+
env,
|
|
63
|
+
stdio: options.stdio ?? "inherit",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
workDir,
|
|
68
|
+
status: result.status ?? 1,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {number} ms */
|
|
73
|
+
function sleepSync(ms) {
|
|
74
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isCli =
|
|
78
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
79
|
+
|
|
80
|
+
if (isCli) {
|
|
81
|
+
const { status } = runCreateLauncherSmoke({
|
|
82
|
+
createVersion: process.env.CREATE_VERSION ?? "",
|
|
83
|
+
});
|
|
84
|
+
process.exit(status);
|
|
85
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One GitHub issue per subject, found again by its exact title and updated in place: CI files
|
|
3
|
+
* a reminder once and keeps it current instead of opening a new issue every run. Needs `gh` and
|
|
4
|
+
* GH_TOKEN (or GITHUB_TOKEN) with issues: write.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { spawnSync } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string[]} args
|
|
11
|
+
* @param {string} [input]
|
|
12
|
+
*/
|
|
13
|
+
export function gh(args, input) {
|
|
14
|
+
const result = spawnSync("gh", args, { encoding: "utf8", input, env: process.env });
|
|
15
|
+
if (result.status !== 0) {
|
|
16
|
+
throw new Error(result.stderr || result.stdout || `gh ${args.join(" ")} failed`);
|
|
17
|
+
}
|
|
18
|
+
return result.stdout;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The issue titled exactly `title`, preferring an open one over a closed one.
|
|
23
|
+
*
|
|
24
|
+
* @param {{ number: number, title: string, state: string, url: string }[]} issues
|
|
25
|
+
* @param {string} title
|
|
26
|
+
*/
|
|
27
|
+
export function pickIssue(issues, title) {
|
|
28
|
+
const matches = issues.filter((issue) => issue.title === title);
|
|
29
|
+
return matches.find((issue) => issue.state === "OPEN") ?? matches[0] ?? null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {{ title: string, body: string, assignees?: string[], repo?: string }} options
|
|
34
|
+
* @param {typeof gh} [run]
|
|
35
|
+
* @returns {{ number: number, url: string, created: boolean }}
|
|
36
|
+
*/
|
|
37
|
+
export function upsertIssue(options, run = gh) {
|
|
38
|
+
const { title, body, assignees = [] } = options;
|
|
39
|
+
const repo = options.repo ? ["--repo", options.repo] : [];
|
|
40
|
+
const issues = JSON.parse(
|
|
41
|
+
run([
|
|
42
|
+
"issue",
|
|
43
|
+
"list",
|
|
44
|
+
...repo,
|
|
45
|
+
"--state",
|
|
46
|
+
"all",
|
|
47
|
+
"--limit",
|
|
48
|
+
"200",
|
|
49
|
+
"--search",
|
|
50
|
+
// Quoted: GitHub's search can't match a title like `security(ci): …` unquoted.
|
|
51
|
+
// The exact-title match in pickIssue still decides.
|
|
52
|
+
`"${title.replaceAll('"', "")}" in:title`,
|
|
53
|
+
"--json",
|
|
54
|
+
"number,title,state,url",
|
|
55
|
+
]),
|
|
56
|
+
);
|
|
57
|
+
const assign = assignees.flatMap((login) => ["--add-assignee", login]);
|
|
58
|
+
const existing = pickIssue(issues, title);
|
|
59
|
+
if (existing != null) {
|
|
60
|
+
if (existing.state !== "OPEN") {
|
|
61
|
+
run(["issue", "reopen", String(existing.number), ...repo]);
|
|
62
|
+
}
|
|
63
|
+
run(
|
|
64
|
+
["issue", "edit", String(existing.number), ...repo, "--body-file", "-", ...assign],
|
|
65
|
+
body,
|
|
66
|
+
);
|
|
67
|
+
return { number: existing.number, url: existing.url, created: false };
|
|
68
|
+
}
|
|
69
|
+
const url = run(
|
|
70
|
+
["issue", "create", ...repo, "--title", title, "--body-file", "-", ...assign],
|
|
71
|
+
body,
|
|
72
|
+
).trim();
|
|
73
|
+
const number = Number(url.split("/").pop());
|
|
74
|
+
return { number, url, created: true };
|
|
75
|
+
}
|
package/src/lib/lcov.mjs
CHANGED
|
@@ -13,7 +13,7 @@ function normalizePath(filePath) {
|
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Extracts the top-level project name from a source path like
|
|
16
|
-
* `/workspace/projects/
|
|
16
|
+
* `/workspace/projects/core/src/foo.ts` → `"core"`.
|
|
17
17
|
* @param {string} sourcePath
|
|
18
18
|
* @returns {string | undefined}
|
|
19
19
|
*/
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// The same rule as WorkspaceDefaults in @decohere/workspace (`projects/**/package.json`, minus these
|
|
5
|
+
// folders, and a package.json inside another project is not a project). CI scripts run on plain
|
|
6
|
+
// Node before any install, so they can't import the workspace package itself.
|
|
7
|
+
const SKIP = new Set(["node_modules", "__fixtures__", "dist"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Every project directory under `<root>/projects`, at any depth (`projects/cli`,
|
|
11
|
+
* `projects/data/data-typeorm`).
|
|
12
|
+
* @param {string} root
|
|
13
|
+
* @returns {string[]} absolute paths
|
|
14
|
+
*/
|
|
15
|
+
export function listProjectDirs(root) {
|
|
16
|
+
const found = [];
|
|
17
|
+
const walk = (dir) => {
|
|
18
|
+
if (existsSync(join(dir, "package.json"))) {
|
|
19
|
+
found.push(dir);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
23
|
+
if (entry.isDirectory() && !SKIP.has(entry.name) && !entry.name.startsWith(".")) {
|
|
24
|
+
walk(join(dir, entry.name));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
walk(join(root, "projects"));
|
|
29
|
+
return found;
|
|
30
|
+
}
|