@decohere/ci-tools 0.0.1-dev.19 → 0.0.1-dev.20261105.103

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 (43) hide show
  1. package/LICENSE +15 -0
  2. package/package.json +17 -3
  3. package/src/audit/enforce.mjs +7 -5
  4. package/src/audit/format.mjs +133 -0
  5. package/src/audit/summarize.mjs +10 -32
  6. package/src/audit/upsert-issue.mjs +25 -74
  7. package/src/bench/bench.mjs +135 -368
  8. package/src/bench/file-regressions.mjs +76 -0
  9. package/src/bench/history.mjs +248 -0
  10. package/src/bench/nightly.mjs +118 -0
  11. package/src/bench/regression-issue.mjs +44 -0
  12. package/src/bench/trace.mjs +18 -14
  13. package/src/github/first-publish-check.mjs +70 -0
  14. package/src/github/npm-bootstrap.mjs +182 -0
  15. package/src/github/npm-trust-promote.mjs +139 -0
  16. package/src/github/publish-verify.mjs +204 -0
  17. package/src/github/smoke-create-launcher.mjs +85 -0
  18. package/src/lib/github-issue.mjs +75 -0
  19. package/src/lib/lcov.mjs +1 -1
  20. package/src/lib/workspace-packages.mjs +30 -0
  21. package/src/pages/artifact.mjs +38 -183
  22. package/src/pages/bench-page.mjs +324 -0
  23. package/src/pages/restore-badges.mjs +35 -0
  24. package/src/pages/restore-bench-history.mjs +15 -28
  25. package/src/release/npm-links.mjs +6 -13
  26. package/src/release/promote-latest.mjs +122 -0
  27. package/src/release/publish-to-temporary-registry.mjs +65 -0
  28. package/src/release/sync-dependencies.mjs +1 -2
  29. package/tests/audit/format.test.mjs +115 -0
  30. package/tests/bench/history.test.mjs +279 -0
  31. package/tests/bench/regression-issue.test.mjs +42 -0
  32. package/tests/github/ci-workflow.test.mjs +30 -0
  33. package/tests/github/first-publish-check.test.mjs +51 -0
  34. package/tests/github/npm-bootstrap.test.mjs +125 -0
  35. package/tests/github/package-metadata.test.mjs +69 -0
  36. package/tests/github/publish-verify.test.mjs +115 -0
  37. package/tests/github/smoke-create-launcher.test.mjs +132 -0
  38. package/tests/lib/github-issue.test.mjs +85 -0
  39. package/tests/lib/lcov.test.mjs +8 -8
  40. package/tests/pages/bench-page.test.mjs +123 -0
  41. package/src/coverage/enforce-thresholds.mjs +0 -144
  42. package/src/pages/clear-bench-history.mjs +0 -27
  43. package/src/pages/keep-prior-bench-badge.mjs +0 -38
@@ -0,0 +1,42 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { issueBody, issueTitle } from "../../src/bench/regression-issue.mjs";
3
+
4
+ const regression = {
5
+ id: "flux.load.cold",
6
+ label: "flux load, first import (cold process)",
7
+ base: 300,
8
+ head: 400,
9
+ percent: 33.3,
10
+ rounds: [
11
+ { base: 300, head: 395 },
12
+ { base: 300, head: 405 },
13
+ ],
14
+ };
15
+ const context = {
16
+ repoUrl: "https://github.com/typesmiths/decohere",
17
+ runUrl: "https://github.com/typesmiths/decohere/actions/runs/1",
18
+ machine: "gh-actions-node24, Test CPU × 4",
19
+ threshold: 0.2,
20
+ base: "aaaaaaaaaaaaaaaa",
21
+ head: "cccccccccccccccc",
22
+ };
23
+
24
+ describe("regression issue", () => {
25
+ it("has one stable title per benchmark, so the nightly run finds and updates it", () => {
26
+ expect(issueTitle(regression)).toBe("perf(bench): flux.load.cold is slower");
27
+ });
28
+
29
+ it("gives both rounds, the commit range and how to reproduce", () => {
30
+ const body = issueBody(regression, context);
31
+ expect(body).toContain(
32
+ "is 33% slower on [`ccccccc`](https://github.com/typesmiths/decohere/commit/cccccccccccccccc)",
33
+ );
34
+ expect(body).toContain("| 1 | 300.0 ms | 395.0 ms |");
35
+ expect(body).toContain("| 2 | 300.0 ms | 405.0 ms |");
36
+ expect(body).toContain(
37
+ "(https://github.com/typesmiths/decohere/compare/aaaaaaaaaaaaaaaa...cccccccccccccccc)",
38
+ );
39
+ expect(body).toContain("check out `aaaaaaa`, run `pnpm bench:save`");
40
+ expect(body).toContain(context.runUrl);
41
+ });
42
+ });
@@ -0,0 +1,30 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ const fs = await vi.importActual("node:fs");
5
+ const CI_YML = fileURLToPath(new URL("../../../../.github/workflows/ci.yml", import.meta.url));
6
+
7
+ describe("ci.yml and the merge queue", () => {
8
+ const yml = fs.readFileSync(CI_YML, "utf8");
9
+ const triggers = yml.slice(yml.indexOf("\non:"), yml.indexOf("\nconcurrency:"));
10
+ const gate = yml.slice(yml.indexOf("- id: gate"), yml.indexOf("# Static gates"));
11
+
12
+ it("runs for queued pull requests, or the queue waits forever for the required check", () => {
13
+ expect(triggers).toMatch(/^\s+merge_group:/m);
14
+ });
15
+
16
+ it("runs the whole suite in the queue: the docs-only shortcut is only for pull requests", () => {
17
+ expect(gate).toContain('"${{ github.event_name }}" == "merge_group"');
18
+ });
19
+
20
+ it("makes the required check wait for every job, so a failing job can't be missed", () => {
21
+ const jobs = yml.slice(yml.indexOf("\njobs:"));
22
+ const names = [...jobs.matchAll(/^ {2}([a-z-]+):$/gm)].map((match) => match[1]);
23
+ const needs = /^ {2}ci:\n {4}needs: \[(.+)\]$/m.exec(jobs)?.[1].split(", ");
24
+
25
+ // `release` follows `ci`.
26
+ expect(needs?.toSorted()).toEqual(
27
+ names.filter((name) => name !== "ci" && name !== "release").toSorted(),
28
+ );
29
+ });
30
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkFirstPublish } from "../../src/github/first-publish-check.mjs";
3
+
4
+ function fakeRegistry(statusByName) {
5
+ return async (url) => {
6
+ const name = decodeURIComponent(new URL(url).pathname.slice(1));
7
+ return new Response("{}", { status: statusByName[name] ?? 200 });
8
+ };
9
+ }
10
+
11
+ async function check(statusByName, blocking) {
12
+ const messages = [];
13
+ const code = await checkFirstPublish({
14
+ packages: ["@decohere/cli", "@decohere/shiny"],
15
+ blocking,
16
+ fetch: fakeRegistry(statusByName),
17
+ log: (message) => messages.push(message),
18
+ });
19
+ return { code, output: messages.join("\n") };
20
+ }
21
+
22
+ describe("checkFirstPublish", () => {
23
+ it("passes when every package is on npm", async () => {
24
+ const { code, output } = await check({}, true);
25
+
26
+ expect(code).toBe(0);
27
+ expect(output).toContain("All 2 packages are on npm");
28
+ });
29
+
30
+ it("only leaves a notice on a PR, so a contributor can propose a package", async () => {
31
+ const { code, output } = await check({ "@decohere/shiny": 404 }, false);
32
+
33
+ expect(code).toBe(0);
34
+ expect(output).toContain("::notice::");
35
+ expect(output).toContain("pnpm npm:bootstrap --otp <code> @decohere/shiny");
36
+ });
37
+
38
+ it("fails in the merge queue, naming the package and the command", async () => {
39
+ const { code, output } = await check({ "@decohere/shiny": 404 }, true);
40
+
41
+ expect(code).toBe(1);
42
+ expect(output).toContain("::error::");
43
+ expect(output).toContain("pnpm npm:bootstrap --otp <code> @decohere/shiny");
44
+ });
45
+
46
+ it("doesn't mistake an npm outage for a new package", async () => {
47
+ await expect(check({ "@decohere/shiny": 503 }, false)).rejects.toThrow(
48
+ "Could not check @decohere/shiny on npm: 503",
49
+ );
50
+ });
51
+ });
@@ -0,0 +1,125 @@
1
+ import { vol } from "memfs";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { runBootstrap } from "../../src/github/npm-bootstrap.mjs";
4
+
5
+ const root = "/workspace";
6
+
7
+ function seedWorkspace() {
8
+ vol.fromJSON({
9
+ [`${root}/package.json`]: JSON.stringify({ version: "0.0.1-dev.5" }),
10
+ [`${root}/projects/scribe/scribe/package.json`]: JSON.stringify({
11
+ name: "@decohere/scribe",
12
+ version: "0.0.1-dev.5",
13
+ }),
14
+ [`${root}/projects/scribe/scribe-console/package.json`]: JSON.stringify({
15
+ name: "@decohere/scribe-console",
16
+ version: "0.0.1-dev.5",
17
+ }),
18
+ });
19
+ }
20
+
21
+ /**
22
+ * A fake registry: `published` names exist, and `trusted` names have a Trusted
23
+ * Publisher. Each call is recorded as its arguments, plus the code npm would
24
+ * get through NPM_CONFIG_OTP.
25
+ */
26
+ function fakeNpm({ published = [], trusted = [], loggedIn = true } = {}) {
27
+ const calls = [];
28
+ const npm = (args, { otp } = {}) => {
29
+ calls.push(`${args.join(" ")}${otp ? ` [otp ${otp}]` : ""}`);
30
+ if (args[0] === "whoami") {
31
+ return { status: loggedIn ? 0 : 1, output: "" };
32
+ }
33
+ if (args[0] === "trust" && args[1] === "list") {
34
+ return trusted.includes(args[2])
35
+ ? { status: 0, output: '[{"repository":"typesmiths/decohere","file":"publish.yml"}]' }
36
+ : { status: 0, output: "[]" };
37
+ }
38
+ return { status: 0, output: "" };
39
+ };
40
+ return { npm, calls, nameExists: (name) => published.includes(name) };
41
+ }
42
+
43
+ function run(argv, registry) {
44
+ const lines = [];
45
+ return runBootstrap({
46
+ argv,
47
+ root,
48
+ io: {
49
+ npm: registry.npm,
50
+ nameExists: registry.nameExists,
51
+ log: (line) => lines.push(line),
52
+ },
53
+ }).then((code) => ({ code, lines }));
54
+ }
55
+
56
+ afterEach(() => {
57
+ vol.reset();
58
+ });
59
+
60
+ describe("npm-bootstrap", () => {
61
+ it("first-publishes unpublished names public, then sets the Trusted Publisher", async () => {
62
+ seedWorkspace();
63
+ const registry = fakeNpm({ published: ["@decohere/scribe"] });
64
+
65
+ const { code } = await run(["--otp", "123456"], registry);
66
+
67
+ expect(code).toBe(0);
68
+ expect(registry.calls).toEqual([
69
+ "whoami",
70
+ "publish ./projects/scribe/scribe-console --access public --tag dev [otp 123456]",
71
+ "trust list @decohere/scribe-console --json [otp 123456]",
72
+ "trust github @decohere/scribe-console --file publish.yml --repo typesmiths/decohere --env npm-publish --allow-publish --yes [otp 123456]",
73
+ ]);
74
+ });
75
+
76
+ it("skips the steps that are already done", async () => {
77
+ seedWorkspace();
78
+ const registry = fakeNpm({
79
+ published: ["@decohere/scribe-console"],
80
+ trusted: ["@decohere/scribe-console"],
81
+ });
82
+
83
+ const { code } = await run(["@decohere/scribe-console"], registry);
84
+
85
+ expect(code).toBe(0);
86
+ expect(registry.calls).toEqual(["whoami", "trust list @decohere/scribe-console --json"]);
87
+ });
88
+
89
+ /** `npm trust` rejects `--otp`: the code goes through the environment. */
90
+ it("never passes the code as an argument, and lets npm ask when none is given", async () => {
91
+ seedWorkspace();
92
+ const withCode = fakeNpm({ published: ["@decohere/scribe"] });
93
+ await run(["--otp", "604381"], withCode);
94
+ expect(withCode.calls.join("\n")).not.toContain("--otp");
95
+
96
+ const withoutCode = fakeNpm({ published: ["@decohere/scribe"] });
97
+ expect((await run([], withoutCode)).code).toBe(0);
98
+ expect(withoutCode.calls.join("\n")).not.toContain("otp");
99
+ });
100
+
101
+ it("prints the commands on a dry run, calling npm only to check the login", async () => {
102
+ seedWorkspace();
103
+ const registry = fakeNpm({ published: ["@decohere/scribe"] });
104
+
105
+ const { code, lines } = await run(["--dry-run"], registry);
106
+
107
+ expect(code).toBe(0);
108
+ expect(registry.calls).toEqual(["whoami"]);
109
+ expect(lines).toContain(
110
+ "[dry run] npm publish ./projects/scribe/scribe-console --access public --tag dev",
111
+ );
112
+ });
113
+
114
+ it("stops when not logged in, or for a name that is not a package", async () => {
115
+ seedWorkspace();
116
+ const loggedOut = fakeNpm({ loggedIn: false });
117
+ expect((await run(["--otp", "1"], loggedOut)).code).toBe(1);
118
+ expect(loggedOut.calls).toEqual(["whoami"]);
119
+
120
+ const typo = fakeNpm();
121
+ const { code, lines } = await run(["@decohere/scribe-consol"], typo);
122
+ expect(code).toBe(1);
123
+ expect(lines).toEqual(["Not a publishable package in projects/: @decohere/scribe-consol"]);
124
+ });
125
+ });
@@ -0,0 +1,69 @@
1
+ import { join, relative } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { vol } from "memfs";
4
+ import { afterEach, describe, expect, it, vi } from "vitest";
5
+ import { listPublishablePackages } from "../../src/github/publish-verify.mjs";
6
+
7
+ const fs = await vi.importActual("node:fs");
8
+ const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
9
+ const REPO = "https://github.com/typesmiths/decohere";
10
+
11
+ /** The repo's real package.json files, loaded into the in-memory fs the tests run on. */
12
+ function loadRealManifests() {
13
+ /** @type {Record<string, string>} */
14
+ const files = {};
15
+ const walk = (dir) => {
16
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
17
+ const path = join(dir, entry.name);
18
+ if (entry.isDirectory() && !["node_modules", "dist"].includes(entry.name)) {
19
+ walk(path);
20
+ } else if (entry.name === "package.json") {
21
+ files[join("/repo", relative(REPO_ROOT, path))] = fs.readFileSync(path, "utf8");
22
+ }
23
+ }
24
+ };
25
+ walk(join(REPO_ROOT, "projects"));
26
+ return files;
27
+ }
28
+
29
+ afterEach(() => {
30
+ vol.reset();
31
+ });
32
+
33
+ // npm shows these on each package's page, and Trusted Publishing's provenance is
34
+ // checked against `repository.url`, so a package without it fails to publish.
35
+ describe("published package metadata", () => {
36
+ vol.fromJSON(loadRealManifests());
37
+ const packages = listPublishablePackages("/repo").map((pkg) => ({
38
+ ...pkg,
39
+ json: JSON.parse(String(vol.readFileSync(`/repo/projects/${pkg.dir}/package.json`))),
40
+ }));
41
+ vol.reset();
42
+
43
+ it("finds the publishable packages", () => {
44
+ expect(packages.length).toBeGreaterThan(0);
45
+ });
46
+
47
+ it.each(packages.map((pkg) => [pkg.name, pkg]))(
48
+ "%s links back to the repo",
49
+ (_name, pkg) => {
50
+ expect(pkg.json.repository).toEqual({
51
+ type: "git",
52
+ url: `git+${REPO}.git`,
53
+ directory: `projects/${pkg.dir}`,
54
+ });
55
+ expect(pkg.json.homepage).toBe(`${REPO}/tree/main/projects/${pkg.dir}#readme`);
56
+ expect(pkg.json.bugs).toEqual({ url: `${REPO}/issues` });
57
+ },
58
+ );
59
+
60
+ it.each(packages.map((pkg) => [pkg.name, pkg]))(
61
+ "%s has a description, keywords, license and author",
62
+ (_name, pkg) => {
63
+ expect(pkg.json.description).toBeTruthy();
64
+ expect(pkg.json.keywords?.length).toBeGreaterThan(0);
65
+ expect(pkg.json.license).toBe("ISC");
66
+ expect(pkg.json.author).toBeTruthy();
67
+ },
68
+ );
69
+ });
@@ -0,0 +1,115 @@
1
+ import { vol } from "memfs";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import {
4
+ collectPublishVerify,
5
+ formatGithubOutput,
6
+ listPublishablePackages,
7
+ parseGhcrRepository,
8
+ } from "../../src/github/publish-verify.mjs";
9
+
10
+ function seedWorkspace() {
11
+ const root = "/workspace";
12
+ vol.fromJSON({
13
+ [`${root}/package.json`]: JSON.stringify({ version: "0.0.1-dev.79" }),
14
+ [`${root}/projects/cli/package.json`]: JSON.stringify({
15
+ name: "@decohere/cli",
16
+ version: "0.0.1-dev.79",
17
+ }),
18
+ [`${root}/projects/scaffold/package.json`]: JSON.stringify({
19
+ name: "@decohere/scaffold",
20
+ version: "0.0.1-dev.79",
21
+ private: true,
22
+ }),
23
+ [`${root}/projects/create/package.json`]: JSON.stringify({
24
+ name: "@decohere/create",
25
+ version: "0.0.1-dev.79",
26
+ decohere: { dockerImageRepository: "ghcr.io/typesmiths/decohere/create" },
27
+ }),
28
+ [`${root}/projects/devcontainer/package.json`]: JSON.stringify({
29
+ name: "@decohere/devcontainer",
30
+ private: true,
31
+ decohere: { dockerImageRepository: "ghcr.io/typesmiths/decohere/devcontainer" },
32
+ }),
33
+ });
34
+ return root;
35
+ }
36
+
37
+ afterEach(() => {
38
+ vol.reset();
39
+ });
40
+
41
+ describe("publish-verify", () => {
42
+ it("lists non-private workspace packages", () => {
43
+ const root = seedWorkspace();
44
+ expect(listPublishablePackages(root).map((pkg) => pkg.name)).toEqual([
45
+ "@decohere/cli",
46
+ "@decohere/create",
47
+ ]);
48
+ });
49
+
50
+ it("lists grouped packages (projects/<group>/<name>) and skips nested and build folders", () => {
51
+ const root = seedWorkspace();
52
+ vol.fromJSON({
53
+ [`${root}/projects/data/data/package.json`]: JSON.stringify({ name: "@decohere/data" }),
54
+ [`${root}/projects/data/data-typeorm/package.json`]: JSON.stringify({
55
+ name: "@decohere/data-typeorm",
56
+ }),
57
+ [`${root}/projects/cli/examples/demo/package.json`]: JSON.stringify({ name: "nested" }),
58
+ [`${root}/projects/__fixtures__/app/package.json`]: JSON.stringify({ name: "fixture" }),
59
+ [`${root}/projects/node_modules/dep/package.json`]: JSON.stringify({ name: "dep" }),
60
+ [`${root}/projects/dist/out/package.json`]: JSON.stringify({ name: "built" }),
61
+ });
62
+ expect(listPublishablePackages(root).map((pkg) => pkg.name)).toEqual([
63
+ "@decohere/cli",
64
+ "@decohere/create",
65
+ "@decohere/data",
66
+ "@decohere/data-typeorm",
67
+ ]);
68
+ });
69
+
70
+ it("parses GHCR repository coordinates", () => {
71
+ expect(parseGhcrRepository("ghcr.io/typesmiths/decohere/create")).toEqual({
72
+ org: "typesmiths",
73
+ packageName: "decohere/create",
74
+ });
75
+ });
76
+
77
+ it("marks names missing from npm as unpublished and skip_npm when every version exists", async () => {
78
+ const root = seedWorkspace();
79
+ const views = [];
80
+ const spawnSync = (_cmd, args) => {
81
+ views.push(args[1]);
82
+ if (args[1] === "@decohere/cli") {
83
+ return { status: 1 };
84
+ }
85
+ return { status: 0 };
86
+ };
87
+ const result = await collectPublishVerify({
88
+ root,
89
+ spawnSync,
90
+ githubToken: "",
91
+ });
92
+ expect(result.unpublished).toEqual(["@decohere/cli"]);
93
+ expect(result.skipNpm).toBe(false);
94
+ expect(views).toContain("@decohere/cli");
95
+ expect(views).toContain("@decohere/create");
96
+ expect(views).toContain("@decohere/create@0.0.1-dev.79");
97
+ });
98
+
99
+ it("sets skip_docker when both GHCR tags exist", async () => {
100
+ const root = seedWorkspace();
101
+ const fetch = async () => ({
102
+ ok: true,
103
+ status: 200,
104
+ json: async () => [{ metadata: { container: { tags: ["0.0.1-dev.79"] } } }],
105
+ });
106
+ const result = await collectPublishVerify({
107
+ root,
108
+ spawnSync: () => ({ status: 0 }),
109
+ githubToken: "ghs_test",
110
+ fetch,
111
+ });
112
+ expect(result.skipDocker).toBe(true);
113
+ expect(formatGithubOutput(result)).toContain("skip_docker=true");
114
+ });
115
+ });
@@ -0,0 +1,132 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { runCreateLauncherSmoke } from "../../src/github/smoke-create-launcher.mjs";
4
+
5
+ const fs = await vi.importActual("node:fs");
6
+ const PUBLISH_YML = fileURLToPath(
7
+ new URL("../../../../.github/workflows/publish.yml", import.meta.url),
8
+ );
9
+
10
+ describe("runCreateLauncherSmoke", () => {
11
+ it("execs npx from a temp directory, not the repo root", () => {
12
+ /** @type {{ cmd: string, args: string[], opts: { cwd?: string } }[]} */
13
+ const calls = [];
14
+ const written = [];
15
+
16
+ const result = runCreateLauncherSmoke({
17
+ createVersion: "0.0.1-dev.80",
18
+ tmpdir: () => "/tmp",
19
+ mkdtempSync: (prefix) => `${prefix}abc`,
20
+ writeFileSync: (path, body) => {
21
+ written.push({ path, body });
22
+ },
23
+ spawnSync: (cmd, args, opts) => {
24
+ calls.push({ cmd, args, opts });
25
+ return { status: 0 };
26
+ },
27
+ stdio: "pipe",
28
+ });
29
+
30
+ expect(result.workDir).toBe("/tmp/decohere-create-smoke-abc");
31
+ expect(result.workDir).not.toBe(process.cwd());
32
+ const npx = calls.at(-1);
33
+ expect(npx.cmd).toBe("npx");
34
+ expect(npx.args).toEqual(["--yes", "@decohere/create@0.0.1-dev.80", "--help"]);
35
+ expect(npx.opts.cwd).toBe("/tmp/decohere-create-smoke-abc");
36
+ expect(npx.opts.cwd).not.toBe(process.cwd());
37
+ expect(written).toEqual([
38
+ {
39
+ path: "/tmp/decohere-create-smoke-abc/.npmrc",
40
+ body: "@decohere:registry=https://registry.npmjs.org/\n",
41
+ },
42
+ ]);
43
+ // The packages are public: the smoke installs them the way a user does, with no token.
44
+ expect(npx.opts.env.NODE_AUTH_TOKEN).toBeUndefined();
45
+ });
46
+
47
+ // npm's public cache can lag minutes behind a publish: the first token-free Publish ran npx
48
+ // 90 seconds after publishing and got ETARGET for a version the registry already had.
49
+ it("waits until npm serves the new version before running npx", () => {
50
+ /** @type {{ cmd: string, args: string[] }[]} */
51
+ const calls = [];
52
+ const sleeps = [];
53
+ let views = 0;
54
+
55
+ const result = runCreateLauncherSmoke({
56
+ createVersion: "0.0.1-dev.127",
57
+ tmpdir: () => "/tmp",
58
+ mkdtempSync: (prefix) => `${prefix}abc`,
59
+ writeFileSync: () => {},
60
+ spawnSync: (cmd, args) => {
61
+ calls.push({ cmd, args });
62
+ if (cmd === "npm") return { status: ++views < 3 ? 1 : 0 };
63
+ return { status: 0 };
64
+ },
65
+ sleep: (ms) => sleeps.push(ms),
66
+ stdio: "pipe",
67
+ });
68
+
69
+ expect(result.status).toBe(0);
70
+ expect(calls.map((call) => call.cmd)).toEqual(["npm", "npm", "npm", "npx"]);
71
+ expect(calls[0].args).toEqual(["view", "@decohere/create@0.0.1-dev.127", "version"]);
72
+ expect(sleeps).toHaveLength(2);
73
+ });
74
+
75
+ it("fails without running npx when npm never serves the version", () => {
76
+ /** @type {string[]} */
77
+ const commands = [];
78
+
79
+ const result = runCreateLauncherSmoke({
80
+ createVersion: "0.0.1-dev.127",
81
+ tmpdir: () => "/tmp",
82
+ mkdtempSync: (prefix) => `${prefix}abc`,
83
+ writeFileSync: () => {},
84
+ spawnSync: (cmd) => {
85
+ commands.push(cmd);
86
+ return { status: 1 };
87
+ },
88
+ sleep: () => {},
89
+ attempts: 3,
90
+ stdio: "pipe",
91
+ });
92
+
93
+ expect(result.status).toBe(1);
94
+ expect(commands).toEqual(["npm", "npm", "npm"]);
95
+ });
96
+
97
+ it("fails closed without a version", () => {
98
+ expect(() => runCreateLauncherSmoke({ createVersion: "" })).toThrow(
99
+ "CREATE_VERSION is required",
100
+ );
101
+ });
102
+ });
103
+
104
+ describe("publish.yml create smoke + tag", () => {
105
+ const yml = fs.readFileSync(PUBLISH_YML, "utf8");
106
+
107
+ it("wires create smoke through the isolated launcher", () => {
108
+ expect(yml).toContain("node projects/ci-tools/src/github/smoke-create-launcher.mjs");
109
+ expect(yml).toContain("CREATE_VERSION:");
110
+ expect(yml).not.toContain("npx --yes");
111
+ });
112
+
113
+ it("reads npm without a token: the packages are public and publishing is OIDC", () => {
114
+ const workflows = fileURLToPath(new URL("../../../../.github/", import.meta.url));
115
+ const files = fs
116
+ .readdirSync(workflows, { recursive: true })
117
+ .filter((file) => /\.ya?ml$/.test(file));
118
+ expect(files.length).toBeGreaterThan(0);
119
+ for (const file of files) {
120
+ expect(fs.readFileSync(`${workflows}${file}`, "utf8"), file).not.toMatch(
121
+ /NODE_AUTH_TOKEN|_authToken/,
122
+ );
123
+ }
124
+ });
125
+
126
+ it("grants the tag job actions:read so publish-prepare can download CI artifacts", () => {
127
+ const tag = yml.slice(yml.indexOf("\n tag:"));
128
+ const perms = tag.slice(tag.indexOf("permissions:"), tag.indexOf("steps:"));
129
+ expect(perms).toMatch(/contents:\s*write/);
130
+ expect(perms).toMatch(/actions:\s*read/);
131
+ });
132
+ });
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { pickIssue, upsertIssue } from "../../src/lib/github-issue.mjs";
3
+
4
+ const TITLE = "security(ci): high or critical npm audit findings";
5
+
6
+ /** A fake `gh` that answers `issue list` with `issues` and records every call. */
7
+ function fakeGh(issues) {
8
+ /** @type {{ args: string[], input?: string }[]} */
9
+ const calls = [];
10
+ const run = (args, input) => {
11
+ calls.push({ args, input });
12
+ if (args[1] === "list") return JSON.stringify(issues);
13
+ if (args[1] === "create") return "https://github.com/typesmiths/decohere/issues/42\n";
14
+ return "";
15
+ };
16
+ return { run, calls };
17
+ }
18
+
19
+ describe("github-issue", () => {
20
+ it("matches the exact title only and prefers an open issue", () => {
21
+ const issues = [
22
+ { number: 1, title: TITLE, state: "CLOSED", url: "u1" },
23
+ { number: 2, title: `${TITLE} (old)`, state: "OPEN", url: "u2" },
24
+ { number: 3, title: TITLE, state: "OPEN", url: "u3" },
25
+ ];
26
+ expect(pickIssue(issues, TITLE)?.number).toBe(3);
27
+ expect(pickIssue(issues.slice(0, 2), TITLE)?.number).toBe(1);
28
+ expect(pickIssue([], TITLE)).toBeNull();
29
+ });
30
+
31
+ // GitHub's search can't match `security(ci): …` unquoted, so the lookup found nothing
32
+ // and a new issue was opened on every run.
33
+ it("searches for the title as a quoted phrase", () => {
34
+ const { run, calls } = fakeGh([]);
35
+ upsertIssue({ title: "security(ci): npm audit vulnerabilities detected", body: "b" }, run);
36
+ const args = calls[0].args;
37
+ expect(args[args.indexOf("--search") + 1]).toBe(
38
+ '"security(ci): npm audit vulnerabilities detected" in:title',
39
+ );
40
+ });
41
+
42
+ it("opens an issue with assignees when none has the title", () => {
43
+ const { run, calls } = fakeGh([]);
44
+ const result = upsertIssue({ title: TITLE, body: "body", assignees: ["das974"] }, run);
45
+ expect(result).toEqual({
46
+ number: 42,
47
+ url: "https://github.com/typesmiths/decohere/issues/42",
48
+ created: true,
49
+ });
50
+ expect(calls[1]).toEqual({
51
+ args: [
52
+ "issue",
53
+ "create",
54
+ "--title",
55
+ TITLE,
56
+ "--body-file",
57
+ "-",
58
+ "--add-assignee",
59
+ "das974",
60
+ ],
61
+ input: "body",
62
+ });
63
+ });
64
+
65
+ it("updates an open issue's body in place", () => {
66
+ const { run, calls } = fakeGh([{ number: 7, title: TITLE, state: "OPEN", url: "u7" }]);
67
+ const result = upsertIssue({ title: TITLE, body: "new body" }, run);
68
+ expect(result).toEqual({ number: 7, url: "u7", created: false });
69
+ expect(calls.map((call) => call.args[1])).toEqual(["list", "edit"]);
70
+ expect(calls[1]).toEqual({
71
+ args: ["issue", "edit", "7", "--body-file", "-"],
72
+ input: "new body",
73
+ });
74
+ });
75
+
76
+ it("reopens a closed issue before updating it", () => {
77
+ const { run, calls } = fakeGh([{ number: 7, title: TITLE, state: "CLOSED", url: "u7" }]);
78
+ upsertIssue({ title: TITLE, body: "b", repo: "typesmiths/decohere" }, run);
79
+ expect(calls.map((call) => call.args.slice(0, 3))).toEqual([
80
+ ["issue", "list", "--repo"],
81
+ ["issue", "reopen", "7"],
82
+ ["issue", "edit", "7"],
83
+ ]);
84
+ });
85
+ });