@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.
Files changed (40) hide show
  1. package/package.json +3 -18
  2. package/src/audit/enforce.mjs +5 -7
  3. package/src/audit/summarize.mjs +32 -10
  4. package/src/audit/upsert-issue.mjs +74 -25
  5. package/src/bench/bench.mjs +368 -134
  6. package/src/bench/trace.mjs +14 -18
  7. package/src/coverage/enforce-thresholds.mjs +144 -0
  8. package/src/pages/artifact.mjs +183 -38
  9. package/src/pages/clear-bench-history.mjs +27 -0
  10. package/src/pages/keep-prior-bench-badge.mjs +38 -0
  11. package/src/pages/restore-bench-history.mjs +28 -15
  12. package/src/release/npm-links.mjs +13 -6
  13. package/src/release/sync-dependencies.mjs +2 -1
  14. package/tests/lib/lcov.test.mjs +4 -4
  15. package/LICENSE +0 -15
  16. package/src/audit/format.mjs +0 -133
  17. package/src/bench/file-regressions.mjs +0 -76
  18. package/src/bench/history.mjs +0 -248
  19. package/src/bench/nightly.mjs +0 -118
  20. package/src/bench/regression-issue.mjs +0 -44
  21. package/src/github/credential-nudge.mjs +0 -226
  22. package/src/github/npm-bootstrap.mjs +0 -179
  23. package/src/github/oidc-bootstrap-nudge.mjs +0 -214
  24. package/src/github/publish-verify.mjs +0 -206
  25. package/src/github/smoke-create-launcher.mjs +0 -70
  26. package/src/lib/github-issue.mjs +0 -75
  27. package/src/lib/workspace-packages.mjs +0 -30
  28. package/src/pages/bench-page.mjs +0 -323
  29. package/src/pages/restore-badges.mjs +0 -58
  30. package/tests/audit/format.test.mjs +0 -115
  31. package/tests/bench/history.test.mjs +0 -279
  32. package/tests/bench/regression-issue.test.mjs +0 -42
  33. package/tests/github/credential-nudge.test.mjs +0 -112
  34. package/tests/github/npm-bootstrap.test.mjs +0 -125
  35. package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
  36. package/tests/github/package-metadata.test.mjs +0 -69
  37. package/tests/github/publish-verify.test.mjs +0 -115
  38. package/tests/github/smoke-create-launcher.test.mjs +0 -71
  39. package/tests/lib/github-issue.test.mjs +0 -85
  40. 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
- }
@@ -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
- }