@decohere/ci-tools 0.0.1-dev.119 → 0.0.1-dev.12

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 (36) hide show
  1. package/package.json +3 -8
  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/src/audit/format.mjs +0 -133
  16. package/src/bench/file-regressions.mjs +0 -76
  17. package/src/bench/history.mjs +0 -248
  18. package/src/bench/nightly.mjs +0 -118
  19. package/src/bench/regression-issue.mjs +0 -44
  20. package/src/github/credential-nudge.mjs +0 -245
  21. package/src/github/oidc-bootstrap-nudge.mjs +0 -210
  22. package/src/github/publish-verify.mjs +0 -206
  23. package/src/github/smoke-create-launcher.mjs +0 -70
  24. package/src/lib/github-issue.mjs +0 -75
  25. package/src/lib/workspace-packages.mjs +0 -30
  26. package/src/pages/bench-page.mjs +0 -323
  27. package/src/pages/restore-badges.mjs +0 -58
  28. package/tests/audit/format.test.mjs +0 -115
  29. package/tests/bench/history.test.mjs +0 -279
  30. package/tests/bench/regression-issue.test.mjs +0 -42
  31. package/tests/github/credential-nudge.test.mjs +0 -116
  32. package/tests/github/oidc-bootstrap-nudge.test.mjs +0 -89
  33. package/tests/github/publish-verify.test.mjs +0 -115
  34. package/tests/github/smoke-create-launcher.test.mjs +0 -71
  35. package/tests/lib/github-issue.test.mjs +0 -85
  36. package/tests/pages/bench-page.test.mjs +0 -123
@@ -1,279 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- appendRun,
4
- assertValid,
5
- compare,
6
- contextId,
7
- compareRounds,
8
- latestRun,
9
- pruneStale,
10
- formatComparison,
11
- fromVitest,
12
- getContext,
13
- normalize,
14
- parseResults,
15
- previousRun,
16
- RESULT_PREFIX,
17
- } from "../../src/bench/history.mjs";
18
-
19
- const fakeOs = {
20
- cpus: () => [{ model: " Test CPU " }, { model: "Test CPU" }],
21
- platform: () => "linux",
22
- arch: () => "x64",
23
- totalmem: () => 16 * 1024 ** 3,
24
- };
25
- const ctx = getContext(
26
- fakeOs,
27
- { BENCH_ENV: "gh-actions" },
28
- "v24.1.0",
29
- new Date("2026-10-20T03:17:00Z"),
30
- );
31
-
32
- const result = (id, median, extra = {}) => ({
33
- id,
34
- label: id,
35
- unit: "ms",
36
- median,
37
- p90: median * 1.2,
38
- min: median * 0.9,
39
- max: median * 1.3,
40
- samples: 5,
41
- ...extra,
42
- });
43
-
44
- const vitestOutput = {
45
- files: [
46
- {
47
- groups: [
48
- {
49
- benchmarks: [
50
- {
51
- name: "forge build es6 + types (18 files)",
52
- mean: 70,
53
- median: 64,
54
- min: 60,
55
- max: 80,
56
- sampleCount: 5,
57
- },
58
- {
59
- name: "forge build es6 only (18 files)",
60
- mean: 10,
61
- min: 8,
62
- max: 12,
63
- sampleCount: 5,
64
- },
65
- ],
66
- },
67
- ],
68
- },
69
- ],
70
- };
71
-
72
- describe("getContext / contextId", () => {
73
- it("describes the machine and names the CI environment with the Node major", () => {
74
- expect(ctx).toEqual({
75
- benchEnv: "gh-actions-node24",
76
- date: "2026-10-20T03:17:00.000Z",
77
- os: { platform: "linux", arch: "x64" },
78
- cpu: { model: "Test CPU", count: 2 },
79
- memoryGb: 16,
80
- node: "v24.1.0",
81
- });
82
- expect(getContext(fakeOs, {}, "v24.1.0").benchEnv).toBe("(local)");
83
- });
84
-
85
- it("gives the same id to the same machine regardless of date or env label", () => {
86
- const later = getContext(fakeOs, {}, "v24.1.0", new Date("2027-01-01"));
87
- expect(contextId(later)).toBe(contextId(ctx));
88
- expect(contextId({ ...ctx, cpu: { ...ctx.cpu, count: 4 } })).not.toBe(contextId(ctx));
89
- });
90
- });
91
-
92
- describe("parseResults", () => {
93
- it("reads the last result line among other output", () => {
94
- const results = [result("flux.load.cold", 250)];
95
- const output = `flux load: median 250 ms\n${RESULT_PREFIX}[]\n${RESULT_PREFIX}${JSON.stringify(results)}\n`;
96
- expect(parseResults(output, "flux-load")).toEqual(results);
97
- });
98
-
99
- it("fails, naming the suite, when there is no result line", () => {
100
- expect(() => parseResults("crashed\n", "flux-serve")).toThrow(/flux-serve/);
101
- });
102
- });
103
-
104
- describe("fromVitest", () => {
105
- it("maps forge benchmarks to stable ids, keeping the file count out of the label", () => {
106
- expect(fromVitest(vitestOutput)).toEqual([
107
- {
108
- id: "forge.build.local",
109
- label: "forge build es6 + types",
110
- unit: "ms",
111
- median: 64,
112
- min: 60,
113
- max: 80,
114
- samples: 5,
115
- files: 18,
116
- },
117
- // No median from vitest: the mean stands in.
118
- expect.objectContaining({ id: "forge.build.debug", median: 10, files: 18 }),
119
- ]);
120
- });
121
-
122
- it("fails on a benchmark it has no id for, instead of charting it under a moving name", () => {
123
- const output = {
124
- files: [{ groups: [{ benchmarks: [{ name: "something new", mean: 1 }] }] }],
125
- };
126
- expect(() => fromVitest(output)).toThrow(/something new/);
127
- });
128
- });
129
-
130
- describe("assertValid", () => {
131
- it("passes real results and rejects NaN or empty ones, naming them", () => {
132
- expect(() => assertValid([result("a", 1)])).not.toThrow();
133
- expect(() =>
134
- assertValid([result("a", 1), result("b", Number.NaN), result("c", 2, { samples: 0 })]),
135
- ).toThrow(/b, c/);
136
- });
137
- });
138
-
139
- describe("normalize", () => {
140
- it("keeps a version 2 history as it is", () => {
141
- const history = { version: 2, contexts: { x: { context: ctx, runs: [] } } };
142
- expect(normalize(history)).toBe(history);
143
- });
144
-
145
- it("converts version 1 forge runs so their trend carries on", () => {
146
- const v1 = {
147
- runs: {
148
- abc: {
149
- context: ctx,
150
- runs: [{ date: "2026-08-12T14:18:10Z", context: ctx, vitestOutput }],
151
- },
152
- empty: { context: ctx, runs: [] },
153
- },
154
- };
155
- const history = normalize(v1);
156
- expect(Object.keys(history.contexts)).toEqual(["abc"]);
157
- expect(history.contexts.abc.runs[0]).toMatchObject({
158
- date: "2026-08-12T14:18:10Z",
159
- sha: null,
160
- results: [{ id: "forge.build.local" }, { id: "forge.build.debug" }],
161
- });
162
- });
163
-
164
- it("starts empty when there is no history", () => {
165
- expect(normalize(null)).toEqual({ version: 2, contexts: {} });
166
- });
167
- });
168
-
169
- describe("appendRun / previousRun", () => {
170
- const run = (day) => ({
171
- date: `2026-10-${day}T03:17:00Z`,
172
- sha: `sha${day}`,
173
- results: [result("a", Number(day))],
174
- });
175
-
176
- it("puts the new run first, trims to the limit, and leaves the input untouched", () => {
177
- const empty = normalize(null);
178
- const one = appendRun(empty, ctx, run("18"), 2);
179
- const two = appendRun(one, ctx, run("19"), 2);
180
- const three = appendRun(two, ctx, run("20"), 2);
181
-
182
- expect(empty.contexts).toEqual({});
183
- expect(one.contexts[contextId(ctx)].runs).toHaveLength(1);
184
- expect(three.contexts[contextId(ctx)].runs.map((r) => r.sha)).toEqual(["sha20", "sha19"]);
185
- expect(previousRun(three, contextId(ctx))?.sha).toBe("sha20");
186
- expect(previousRun(three, "other-machine")).toBeUndefined();
187
- });
188
- });
189
-
190
- describe("compare / formatComparison", () => {
191
- it("reports the change in median against the previous run, and n/a for new benchmarks", () => {
192
- const rows = compare([result("a", 120), result("new", 5)], {
193
- results: [result("a", 100)],
194
- });
195
- expect(rows).toEqual([
196
- { id: "a", current: 120, previous: 100, delta: 20, percent: 20 },
197
- { id: "new", current: 5 },
198
- ]);
199
-
200
- const table = formatComparison(rows).split("\n");
201
- expect(table[0]).toMatch(/^Benchmark \| +Median \| Previous \| +Delta \| +%$/);
202
- expect(table[2]).toMatch(/^a +\| +120\.0 ms \| +100\.0 ms \| +\+20\.0 ms \| \+20\.0%$/);
203
- expect(table[3]).toMatch(/^new +\| +5\.0 ms \| +n\/a \| +n\/a \| +n\/a$/);
204
- });
205
-
206
- it("compares against nothing on a machine's first run", () => {
207
- expect(compare([result("a", 1)], undefined)).toEqual([{ id: "a", current: 1 }]);
208
- });
209
- });
210
-
211
- describe("compareRounds", () => {
212
- const round = (...medians) => medians.map((median, index) => result(`b${index}`, median));
213
-
214
- it("flags a benchmark slower on head than base in every round, with both rounds' numbers", () => {
215
- const [regression] = compareRounds([round(130, 50)], [round(100, 50)], {
216
- threshold: 0.2,
217
- rounds: 1,
218
- });
219
- expect(regression).toMatchObject({
220
- id: "b0",
221
- base: 100,
222
- head: 130,
223
- rounds: [{ base: 100, head: 130 }],
224
- });
225
- expect(regression.percent).toBeCloseTo(30);
226
- });
227
-
228
- it("needs every round slower: one slow round is as likely the runner as the code", () => {
229
- expect(compareRounds([round(130), round(105)], [round(100), round(100)])).toEqual([]);
230
- expect(compareRounds([round(130), round(126)], [round(100), round(100)])).toHaveLength(1);
231
- });
232
-
233
- it("ignores slowdowns within the threshold", () => {
234
- expect(compareRounds([round(115), round(118)], [round(100), round(100)])).toEqual([]);
235
- });
236
-
237
- it("skips benchmarks only one side has, and compares nothing without enough rounds", () => {
238
- const head = [
239
- [result("new", 500), result("old", 100)],
240
- [result("new", 500), result("old", 100)],
241
- ];
242
- const base = [[result("old", 100)], [result("old", 100)]];
243
- expect(compareRounds(head, base)).toEqual([]);
244
- expect(compareRounds([round(200)], [round(100)])).toEqual([]);
245
- });
246
- });
247
-
248
- describe("pruneStale / latestRun", () => {
249
- const at = (date, sha) => ({ date, sha, results: [] });
250
- const history = {
251
- version: 2,
252
- contexts: {
253
- old: { context: ctx, runs: [at("2026-08-12T03:17:00Z", "aaa")] },
254
- recent: {
255
- context: ctx,
256
- runs: [at("2026-10-20T03:17:00Z", "ccc"), at("2026-10-19T03:17:00Z", "bbb")],
257
- },
258
- },
259
- };
260
-
261
- it("drops machine types with no run in the last 30 days", () => {
262
- expect(
263
- Object.keys(pruneStale(history, new Date("2026-10-21T00:00:00Z")).contexts),
264
- ).toEqual(["recent"]);
265
- expect(
266
- Object.keys(pruneStale(history, new Date("2026-09-01T00:00:00Z")).contexts),
267
- ).toEqual(["old", "recent"]);
268
- });
269
-
270
- it("prunes when a run is appended", () => {
271
- const next = appendRun(history, ctx, at("2026-10-21T03:17:00Z", "ddd"));
272
- expect(Object.keys(next.contexts)).not.toContain("old");
273
- });
274
-
275
- it("finds the newest run on any machine: the commit the previous nightly measured", () => {
276
- expect(latestRun(history)?.sha).toBe("ccc");
277
- expect(latestRun({ version: 2, contexts: {} })).toBeUndefined();
278
- });
279
- });
@@ -1,42 +0,0 @@
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
- });
@@ -1,116 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- assessTokenLifetime,
4
- buildDescription,
5
- CREDENTIALS,
6
- credentialProfile,
7
- ghScopeFlags,
8
- issueTitle,
9
- parseExpiresAt,
10
- rotationCommands,
11
- runNudge,
12
- suggestedNextExpiry,
13
- } from "../../src/github/credential-nudge.mjs";
14
-
15
- /** Records each upsert and answers like a newly opened issue. */
16
- function recordUpserts() {
17
- /** @type {{ title: string, body: string }[]} */
18
- const upserts = [];
19
- const upsert = (options) => {
20
- upserts.push(options);
21
- return {
22
- number: 9,
23
- url: "https://github.com/typesmiths/decohere/issues/9",
24
- created: true,
25
- };
26
- };
27
- return { upserts, upsert };
28
- }
29
-
30
- function inDays(days) {
31
- return suggestedNextExpiry(Date.now(), days);
32
- }
33
-
34
- describe("credential-nudge", () => {
35
- it("parses YYYY-MM-DD as a UTC day", () => {
36
- expect(parseExpiresAt("2026-11-19")).toBe(Date.UTC(2026, 10, 19));
37
- expect(parseExpiresAt("")).toBeNull();
38
- });
39
-
40
- it("is ok more than 14 days out, warns inside the window, expires on the day", () => {
41
- const expiresAtMs = Date.UTC(2026, 10, 19);
42
- const now = (day) => Date.UTC(2026, 10, day, 15);
43
- expect(assessTokenLifetime({ now: now(1), expiresAtMs }).kind).toBe("ok");
44
- expect(assessTokenLifetime({ now: now(5), expiresAtMs })).toEqual({
45
- kind: "warn",
46
- daysLeft: 14,
47
- });
48
- expect(assessTokenLifetime({ now: now(18), expiresAtMs }).kind).toBe("warn");
49
- expect(assessTokenLifetime({ now: now(19), expiresAtMs })).toEqual({
50
- kind: "expired",
51
- daysLeft: 0,
52
- });
53
- expect(assessTokenLifetime({ now: now(20), expiresAtMs }).kind).toBe("expired");
54
- expect(assessTokenLifetime({ expiresAtMs: null }).kind).toBe("missing");
55
- });
56
-
57
- it("parameterizes GHCR vs npm rotate commands without duplicating date logic", () => {
58
- const ghcr = credentialProfile("ghcr");
59
- const npm = credentialProfile("npm");
60
- expect(ghScopeFlags(ghcr)).toBe("--env ghcr-publish ");
61
- expect(ghScopeFlags(npm)).toBe("");
62
- expect(rotationCommands(ghcr, "2027-02-17", "typesmiths/decohere")).toContain(
63
- "gh secret set GHCR_PUSH_TOKEN --env ghcr-publish --repo typesmiths/decohere",
64
- );
65
- expect(rotationCommands(npm, "2027-02-17", "typesmiths/decohere")).toContain(
66
- "gh secret set NODE_AUTH_TOKEN --repo typesmiths/decohere",
67
- );
68
- expect(rotationCommands(npm, "2027-02-17", "typesmiths/decohere")).toContain(
69
- "gh variable set NODE_AUTH_TOKEN_EXPIRES_AT --repo typesmiths/decohere --body 2027-02-17",
70
- );
71
- expect(issueTitle(npm, "2026-11-19")).toBe(
72
- "chore(security): rotate NODE_AUTH_TOKEN (expires 2026-11-19)",
73
- );
74
- const body = buildDescription(ghcr, {
75
- kind: "warn",
76
- expiresYmd: "2026-11-19",
77
- daysLeft: 14,
78
- rejected: false,
79
- });
80
- expect(body).toContain("Docker login/push and the `v*` tag will **fail**");
81
- expect(CREDENTIALS.npm.secret).toBe("NODE_AUTH_TOKEN");
82
- expect(suggestedNextExpiry(Date.UTC(2026, 7, 21), 90)).toBe("2026-11-19");
83
- expect(() => credentialProfile("nope")).toThrow(/Unknown NUDGE_CREDENTIAL/);
84
- });
85
-
86
- it("files nothing and passes while the token has more than 14 days left", async () => {
87
- const { upserts, upsert } = recordUpserts();
88
- const env = { NUDGE_CREDENTIAL: "npm", NODE_AUTH_TOKEN_EXPIRES_AT: inDays(30) };
89
- expect(await runNudge(env, upsert)).toBe(0);
90
- expect(upserts).toEqual([]);
91
- });
92
-
93
- it("files a rotation issue but lets Publish continue inside the warning window", async () => {
94
- const { upserts, upsert } = recordUpserts();
95
- const expires = inDays(10);
96
- const env = { NUDGE_CREDENTIAL: "npm", NODE_AUTH_TOKEN_EXPIRES_AT: expires };
97
- expect(await runNudge(env, upsert)).toBe(0);
98
- expect(upserts).toHaveLength(1);
99
- expect(upserts[0].title).toBe(
100
- `chore(security): rotate NODE_AUTH_TOKEN (expires ${expires})`,
101
- );
102
- expect(upserts[0].body).toContain("Close this issue, then retry Publish");
103
- });
104
-
105
- it("files a rotation issue and stops Publish once the token has expired or was rejected", async () => {
106
- const expired = recordUpserts();
107
- const env = { NUDGE_CREDENTIAL: "ghcr", GHCR_TOKEN_EXPIRES_AT: inDays(0) };
108
- expect(await runNudge(env, expired.upsert)).toBe(1);
109
- expect(expired.upserts).toHaveLength(1);
110
-
111
- const rejected = recordUpserts();
112
- const rejectedEnv = { ...env, GHCR_TOKEN_EXPIRES_AT: inDays(60), NUDGE_REJECTED: "1" };
113
- expect(await runNudge(rejectedEnv, rejected.upsert)).toBe(1);
114
- expect(rejected.upserts[0].body).toContain(CREDENTIALS.ghcr.rejectedWhy);
115
- });
116
- });
@@ -1,89 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- buildDescription,
4
- extractGitHubReviewerLogins,
5
- firstPublishCommands,
6
- issueTitle,
7
- needsPrereleaseTag,
8
- parseUnpublished,
9
- runNudge,
10
- } from "../../src/github/oidc-bootstrap-nudge.mjs";
11
-
12
- describe("oidc-bootstrap-nudge", () => {
13
- it("parses unpublished names", () => {
14
- expect(parseUnpublished(" @decohere/scaffold @decohere/cli ")).toEqual([
15
- "@decohere/scaffold",
16
- "@decohere/cli",
17
- ]);
18
- expect(issueTitle()).toBe("deploy(publish): OIDC bootstrap unpublished packages");
19
- });
20
-
21
- it("extracts GitHub user reviewers and skips teams", () => {
22
- expect(
23
- extractGitHubReviewerLogins({
24
- protection_rules: [
25
- { type: "branch_policy", reviewers: [] },
26
- {
27
- type: "required_reviewers",
28
- reviewers: [
29
- { type: "User", reviewer: { login: "das974" } },
30
- { type: "Team", reviewer: { slug: "publishers", login: "should-skip" } },
31
- ],
32
- },
33
- ],
34
- }),
35
- ).toEqual(["das974"]);
36
- });
37
-
38
- it("puts first-publish commands and Trusted Publisher fields in the task body", () => {
39
- const pkg = { name: "@decohere/scaffold", dir: "scaffold", version: "0.0.1-dev.18" };
40
- const body = buildDescription({
41
- unpublished: ["@decohere/scaffold"],
42
- packages: [pkg],
43
- repo: "typesmiths/decohere",
44
- runUrl: "https://github.com/typesmiths/decohere/actions/runs/9",
45
- channel: "dev",
46
- });
47
- expect(needsPrereleaseTag("0.0.1-dev.18")).toBe(true);
48
- expect(needsPrereleaseTag("1.2.3")).toBe(false);
49
- expect(firstPublishCommands([pkg], "dev")).toEqual([
50
- "npm publish ./projects/scaffold --tag dev",
51
- ]);
52
- expect(firstPublishCommands([{ ...pkg, version: "1.0.0" }], "dev")).toEqual([
53
- "npm publish ./projects/scaffold",
54
- ]);
55
- expect(body).toContain("npm publish ./projects/scaffold --tag dev");
56
- expect(body).not.toMatch(/# @decohere\/scaffold/);
57
- expect(body).toContain("do **not** pass `--access public`");
58
- expect(firstPublishCommands([pkg], "dev").join("\n")).not.toContain("--access public");
59
- expect(body).toContain("Workflow filename: `publish.yml`");
60
- expect(body).toContain("Environment: `npm-publish`");
61
- expect(body).toContain("gh run rerun --failed");
62
- expect(body).toContain("https://github.com/typesmiths/decohere/actions/runs/9");
63
- });
64
-
65
- it("assigns the bootstrap environment's reviewers to the issue", async () => {
66
- /** @type {object[]} */
67
- const upserts = [];
68
- const code = await runNudge(
69
- { UNPUBLISHED: "@decohere/scaffold", GITHUB_REPOSITORY: "typesmiths/decohere" },
70
- {
71
- listPackages: () => [{ name: "@decohere/scaffold", dir: "scaffold" }],
72
- githubReviewers: async () => ["das974"],
73
- upsert: (options) => {
74
- upserts.push(options);
75
- return {
76
- number: 5,
77
- url: "https://github.com/typesmiths/decohere/issues/5",
78
- created: true,
79
- };
80
- },
81
- runUrl: "https://github.com/typesmiths/decohere/actions/runs/1",
82
- },
83
- );
84
- expect(code).toBe(0);
85
- expect(upserts[0].title).toBe(issueTitle());
86
- expect(upserts[0].assignees).toEqual(["das974"]);
87
- expect(upserts[0].body).toContain("`@decohere/scaffold`");
88
- });
89
- });
@@ -1,115 +0,0 @@
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
- });