@decohere/ci-tools 0.0.1-dev.125 → 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,206 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,70 +0,0 @@
|
|
|
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
|
-
*
|
|
13
|
-
* @param {{
|
|
14
|
-
* createVersion: string,
|
|
15
|
-
* authToken: string,
|
|
16
|
-
* mkdtempSync?: typeof mkdtempSync,
|
|
17
|
-
* writeFileSync?: typeof writeFileSync,
|
|
18
|
-
* spawnSync?: typeof spawnSync,
|
|
19
|
-
* tmpdir?: () => string,
|
|
20
|
-
* stdio?: import("node:child_process").StdioOptions,
|
|
21
|
-
* }} options
|
|
22
|
-
*/
|
|
23
|
-
export function runCreateLauncherSmoke(options) {
|
|
24
|
-
const version = options.createVersion?.trim() ?? "";
|
|
25
|
-
const token = options.authToken?.trim() ?? "";
|
|
26
|
-
if (!version) {
|
|
27
|
-
throw new Error("CREATE_VERSION is required");
|
|
28
|
-
}
|
|
29
|
-
if (!token) {
|
|
30
|
-
throw new Error("NODE_AUTH_TOKEN is required");
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const mkdtemp = options.mkdtempSync ?? mkdtempSync;
|
|
34
|
-
const writeFile = options.writeFileSync ?? writeFileSync;
|
|
35
|
-
const spawn = options.spawnSync ?? spawnSync;
|
|
36
|
-
const tmp = options.tmpdir ?? tmpdir;
|
|
37
|
-
|
|
38
|
-
const workDir = mkdtemp(join(tmp(), "decohere-create-smoke-"));
|
|
39
|
-
const npmrc = join(workDir, ".npmrc");
|
|
40
|
-
writeFile(
|
|
41
|
-
npmrc,
|
|
42
|
-
`@decohere:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=${token}\n`,
|
|
43
|
-
);
|
|
44
|
-
|
|
45
|
-
const result = spawn("npx", ["--yes", `@decohere/create@${version}`, "--help"], {
|
|
46
|
-
cwd: workDir,
|
|
47
|
-
env: {
|
|
48
|
-
...process.env,
|
|
49
|
-
NPM_CONFIG_USERCONFIG: npmrc,
|
|
50
|
-
NODE_AUTH_TOKEN: token,
|
|
51
|
-
},
|
|
52
|
-
stdio: options.stdio ?? "inherit",
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
return {
|
|
56
|
-
workDir,
|
|
57
|
-
status: result.status ?? 1,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const isCli =
|
|
62
|
-
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
63
|
-
|
|
64
|
-
if (isCli) {
|
|
65
|
-
const { status } = runCreateLauncherSmoke({
|
|
66
|
-
createVersion: process.env.CREATE_VERSION ?? "",
|
|
67
|
-
authToken: process.env.NODE_AUTH_TOKEN ?? "",
|
|
68
|
-
});
|
|
69
|
-
process.exit(status);
|
|
70
|
-
}
|
package/src/lib/github-issue.mjs
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
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 (#681).
|
|
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
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
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
|
-
}
|