@decohere/ci-tools 0.0.1-dev.11 → 0.0.1-dev.110

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.
@@ -7,10 +7,9 @@
7
7
  import { execSync } from "node:child_process";
8
8
 
9
9
  const DRY_RUN = process.env.DRY_RUN === "true";
10
- const TERMINAL_CLI = process.env.TERMINAL_CLI || "dist/debug/projects/terminal/src/cli.js";
11
10
  const DECOHERE_CONFIG = process.env.DECOHERE_CONFIG || ".decohere.js";
12
11
 
13
- const baseCmd = `node "${TERMINAL_CLI}" --log-level trace --config-file "${DECOHERE_CONFIG}" forge dependencies --configuration release sync`;
12
+ const baseCmd = `pnpm forge --log-level info --config-file "${DECOHERE_CONFIG}" dependencies --configuration release sync`;
14
13
 
15
14
  try {
16
15
  if (DRY_RUN) {
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ formatLog,
4
+ formatMarkdown,
5
+ highCritical,
6
+ parseAudit,
7
+ } from "../../src/audit/format.mjs";
8
+
9
+ const fixture = {
10
+ metadata: {
11
+ vulnerabilities: { info: 0, low: 1, moderate: 0, high: 1, critical: 0, total: 2 },
12
+ },
13
+ advisories: {
14
+ 1: {
15
+ id: 1,
16
+ module_name: "nanoid",
17
+ severity: "high",
18
+ title: "nanoid: custom generators can loop indefinitely when size is zero",
19
+ url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
20
+ vulnerable_versions: "<3.3.18",
21
+ patched_versions: ">=3.3.18",
22
+ cves: [],
23
+ findings: [{ version: "3.3.17", paths: [". > vite > postcss > nanoid@3.3.17"] }],
24
+ },
25
+ 2: {
26
+ id: 2,
27
+ module_name: "esbuild",
28
+ severity: "low",
29
+ title: "esbuild Windows file read",
30
+ url: "https://github.com/advisories/GHSA-g7r4-m6w7-qqqr",
31
+ vulnerable_versions: "<0.28.1",
32
+ patched_versions: ">=0.28.1",
33
+ cves: [],
34
+ findings: [{ version: "0.27.3", paths: [". > vite > esbuild"] }],
35
+ },
36
+ },
37
+ };
38
+
39
+ describe("parseAudit", () => {
40
+ it("reads severity counts and sorts advisories critical/high first", () => {
41
+ const parsed = parseAudit(fixture);
42
+ expect(parsed.total).toBe(2);
43
+ expect(parsed.high).toBe(1);
44
+ expect(parsed.low).toBe(1);
45
+ expect(parsed.advisories.map((a) => a.module)).toEqual(["nanoid", "esbuild"]);
46
+ expect(parsed.advisories[0]?.severity).toBe("high");
47
+ expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
48
+ });
49
+ });
50
+
51
+ describe("formatLog", () => {
52
+ it("prints severity counts and advisory lines including GHSA", () => {
53
+ const log = formatLog(parseAudit(fixture));
54
+ expect(log).toContain("high=1");
55
+ expect(log).toContain("[high] nanoid GHSA-2v37-7h3g-55p8");
56
+ expect(log).toContain("[low] esbuild");
57
+ });
58
+ });
59
+
60
+ describe("formatMarkdown", () => {
61
+ it("includes a severity table and linked advisories", () => {
62
+ const md = formatMarkdown(parseAudit(fixture));
63
+ expect(md).toContain("| High | 1 |");
64
+ expect(md).toContain("**high** `nanoid`");
65
+ expect(md).toContain("GHSA-2v37-7h3g-55p8");
66
+ });
67
+ });
68
+
69
+ describe("highCritical", () => {
70
+ it("filters to high and critical only", () => {
71
+ expect(highCritical(parseAudit(fixture)).map((a) => a.module)).toEqual(["nanoid"]);
72
+ });
73
+ });
74
+
75
+ describe("parseAudit npm v2", () => {
76
+ it("dedupes GHSA rows from package via objects", () => {
77
+ const v2 = {
78
+ auditReportVersion: 2,
79
+ metadata: {
80
+ vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0, total: 1 },
81
+ },
82
+ vulnerabilities: {
83
+ nanoid: {
84
+ name: "nanoid",
85
+ severity: "high",
86
+ via: [
87
+ {
88
+ source: 1103848,
89
+ name: "nanoid",
90
+ title: "nanoid infinite loop",
91
+ url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
92
+ severity: "high",
93
+ range: "<3.3.18",
94
+ },
95
+ ],
96
+ range: "<3.3.18",
97
+ nodes: ["node_modules/.pnpm/nanoid@3.3.17/node_modules/nanoid"],
98
+ },
99
+ postcss: {
100
+ name: "postcss",
101
+ severity: "high",
102
+ via: ["nanoid"],
103
+ effects: [],
104
+ range: "*",
105
+ nodes: ["node_modules/.pnpm/postcss@8.5.26/node_modules/postcss"],
106
+ },
107
+ },
108
+ };
109
+ const parsed = parseAudit(v2);
110
+ expect(parsed.advisories).toHaveLength(1);
111
+ expect(parsed.advisories[0]?.module).toBe("nanoid");
112
+ expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
113
+ expect(parsed.advisories[0]?.vulnerable).toBe("<3.3.18");
114
+ });
115
+ });
@@ -0,0 +1,251 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ appendRun,
4
+ assertValid,
5
+ compare,
6
+ contextId,
7
+ findRegressions,
8
+ formatComparison,
9
+ fromVitest,
10
+ getContext,
11
+ normalize,
12
+ parseResults,
13
+ previousRun,
14
+ RESULT_PREFIX,
15
+ } from "../../src/bench/history.mjs";
16
+
17
+ const fakeOs = {
18
+ cpus: () => [{ model: " Test CPU " }, { model: "Test CPU" }],
19
+ platform: () => "linux",
20
+ arch: () => "x64",
21
+ totalmem: () => 16 * 1024 ** 3,
22
+ };
23
+ const ctx = getContext(
24
+ fakeOs,
25
+ { BENCH_ENV: "gh-actions" },
26
+ "v24.1.0",
27
+ new Date("2026-10-20T03:17:00Z"),
28
+ );
29
+
30
+ const result = (id, median, extra = {}) => ({
31
+ id,
32
+ label: id,
33
+ unit: "ms",
34
+ median,
35
+ p90: median * 1.2,
36
+ min: median * 0.9,
37
+ max: median * 1.3,
38
+ samples: 5,
39
+ ...extra,
40
+ });
41
+
42
+ const vitestOutput = {
43
+ files: [
44
+ {
45
+ groups: [
46
+ {
47
+ benchmarks: [
48
+ {
49
+ name: "forge build es6 + types (18 files)",
50
+ mean: 70,
51
+ median: 64,
52
+ min: 60,
53
+ max: 80,
54
+ sampleCount: 5,
55
+ },
56
+ {
57
+ name: "forge build es6 only (18 files)",
58
+ mean: 10,
59
+ min: 8,
60
+ max: 12,
61
+ sampleCount: 5,
62
+ },
63
+ ],
64
+ },
65
+ ],
66
+ },
67
+ ],
68
+ };
69
+
70
+ describe("getContext / contextId", () => {
71
+ it("describes the machine and names the CI environment with the Node major", () => {
72
+ expect(ctx).toEqual({
73
+ benchEnv: "gh-actions-node24",
74
+ date: "2026-10-20T03:17:00.000Z",
75
+ os: { platform: "linux", arch: "x64" },
76
+ cpu: { model: "Test CPU", count: 2 },
77
+ memoryGb: 16,
78
+ node: "v24.1.0",
79
+ });
80
+ expect(getContext(fakeOs, {}, "v24.1.0").benchEnv).toBe("(local)");
81
+ });
82
+
83
+ it("gives the same id to the same machine regardless of date or env label", () => {
84
+ const later = getContext(fakeOs, {}, "v24.1.0", new Date("2027-01-01"));
85
+ expect(contextId(later)).toBe(contextId(ctx));
86
+ expect(contextId({ ...ctx, cpu: { ...ctx.cpu, count: 4 } })).not.toBe(contextId(ctx));
87
+ });
88
+ });
89
+
90
+ describe("parseResults", () => {
91
+ it("reads the last result line among other output", () => {
92
+ const results = [result("flux.load.cold", 250)];
93
+ const output = `flux load: median 250 ms\n${RESULT_PREFIX}[]\n${RESULT_PREFIX}${JSON.stringify(results)}\n`;
94
+ expect(parseResults(output, "flux-load")).toEqual(results);
95
+ });
96
+
97
+ it("fails, naming the suite, when there is no result line", () => {
98
+ expect(() => parseResults("crashed\n", "flux-serve")).toThrow(/flux-serve/);
99
+ });
100
+ });
101
+
102
+ describe("fromVitest", () => {
103
+ it("maps forge benchmarks to stable ids, keeping the file count out of the label", () => {
104
+ expect(fromVitest(vitestOutput)).toEqual([
105
+ {
106
+ id: "forge.build.local",
107
+ label: "forge build es6 + types",
108
+ unit: "ms",
109
+ median: 64,
110
+ min: 60,
111
+ max: 80,
112
+ samples: 5,
113
+ files: 18,
114
+ },
115
+ // No median from vitest: the mean stands in.
116
+ expect.objectContaining({ id: "forge.build.debug", median: 10, files: 18 }),
117
+ ]);
118
+ });
119
+
120
+ it("fails on a benchmark it has no id for, instead of charting it under a moving name", () => {
121
+ const output = {
122
+ files: [{ groups: [{ benchmarks: [{ name: "something new", mean: 1 }] }] }],
123
+ };
124
+ expect(() => fromVitest(output)).toThrow(/something new/);
125
+ });
126
+ });
127
+
128
+ describe("assertValid", () => {
129
+ it("passes real results and rejects NaN or empty ones, naming them", () => {
130
+ expect(() => assertValid([result("a", 1)])).not.toThrow();
131
+ expect(() =>
132
+ assertValid([result("a", 1), result("b", Number.NaN), result("c", 2, { samples: 0 })]),
133
+ ).toThrow(/b, c/);
134
+ });
135
+ });
136
+
137
+ describe("normalize", () => {
138
+ it("keeps a version 2 history as it is", () => {
139
+ const history = { version: 2, contexts: { x: { context: ctx, runs: [] } } };
140
+ expect(normalize(history)).toBe(history);
141
+ });
142
+
143
+ it("converts version 1 forge runs so their trend carries on", () => {
144
+ const v1 = {
145
+ runs: {
146
+ abc: {
147
+ context: ctx,
148
+ runs: [{ date: "2026-08-12T14:18:10Z", context: ctx, vitestOutput }],
149
+ },
150
+ empty: { context: ctx, runs: [] },
151
+ },
152
+ };
153
+ const history = normalize(v1);
154
+ expect(Object.keys(history.contexts)).toEqual(["abc"]);
155
+ expect(history.contexts.abc.runs[0]).toMatchObject({
156
+ date: "2026-08-12T14:18:10Z",
157
+ sha: null,
158
+ results: [{ id: "forge.build.local" }, { id: "forge.build.debug" }],
159
+ });
160
+ });
161
+
162
+ it("starts empty when there is no history", () => {
163
+ expect(normalize(null)).toEqual({ version: 2, contexts: {} });
164
+ });
165
+ });
166
+
167
+ describe("appendRun / previousRun", () => {
168
+ const run = (day) => ({
169
+ date: `2026-10-${day}T03:17:00Z`,
170
+ sha: `sha${day}`,
171
+ results: [result("a", Number(day))],
172
+ });
173
+
174
+ it("puts the new run first, trims to the limit, and leaves the input untouched", () => {
175
+ const empty = normalize(null);
176
+ const one = appendRun(empty, ctx, run("18"), 2);
177
+ const two = appendRun(one, ctx, run("19"), 2);
178
+ const three = appendRun(two, ctx, run("20"), 2);
179
+
180
+ expect(empty.contexts).toEqual({});
181
+ expect(one.contexts[contextId(ctx)].runs).toHaveLength(1);
182
+ expect(three.contexts[contextId(ctx)].runs.map((r) => r.sha)).toEqual(["sha20", "sha19"]);
183
+ expect(previousRun(three, contextId(ctx))?.sha).toBe("sha20");
184
+ expect(previousRun(three, "other-machine")).toBeUndefined();
185
+ });
186
+ });
187
+
188
+ describe("compare / formatComparison", () => {
189
+ it("reports the change in median against the previous run, and n/a for new benchmarks", () => {
190
+ const rows = compare([result("a", 120), result("new", 5)], {
191
+ results: [result("a", 100)],
192
+ });
193
+ expect(rows).toEqual([
194
+ { id: "a", current: 120, previous: 100, delta: 20, percent: 20 },
195
+ { id: "new", current: 5 },
196
+ ]);
197
+
198
+ const table = formatComparison(rows).split("\n");
199
+ expect(table[0]).toMatch(/^Benchmark \| +Median \| Previous \| +Delta \| +%$/);
200
+ expect(table[2]).toMatch(/^a +\| +120\.0 ms \| +100\.0 ms \| +\+20\.0 ms \| \+20\.0%$/);
201
+ expect(table[3]).toMatch(/^new +\| +5\.0 ms \| +n\/a \| +n\/a \| +n\/a$/);
202
+ });
203
+
204
+ it("compares against nothing on a machine's first run", () => {
205
+ expect(compare([result("a", 1)], undefined)).toEqual([{ id: "a", current: 1 }]);
206
+ });
207
+ });
208
+
209
+ describe("findRegressions", () => {
210
+ // Newest first, like the history: one run per night with this median for "a".
211
+ const nights = (...medians) =>
212
+ medians.map((median, index) => ({
213
+ date: `2026-10-${String(30 - index).padStart(2, "0")}T03:17:00Z`,
214
+ sha: `sha${30 - index}`,
215
+ results: [result("a", median)],
216
+ }));
217
+
218
+ it("flags a benchmark over the threshold on the last two nights, against the runs before", () => {
219
+ const [regression] = findRegressions(nights(130, 125, 100, 101, 99, 100, 102));
220
+ expect(regression).toMatchObject({
221
+ id: "a",
222
+ baseline: 100,
223
+ recent: [
224
+ { sha: "sha30", median: 130 },
225
+ { sha: "sha29", median: 125 },
226
+ ],
227
+ lastGood: { sha: "sha28" },
228
+ });
229
+ expect(regression.percent).toBeCloseTo(30);
230
+ });
231
+
232
+ it("ignores one slow night, which is as likely the runner as the code", () => {
233
+ expect(findRegressions(nights(150, 100, 100, 101, 99, 100))).toEqual([]);
234
+ });
235
+
236
+ it("ignores slowdowns within the threshold", () => {
237
+ expect(findRegressions(nights(115, 118, 100, 100, 100))).toEqual([]);
238
+ });
239
+
240
+ it("waits for enough history to have a baseline", () => {
241
+ expect(findRegressions(nights(200, 200, 100, 100))).toEqual([]);
242
+ expect(findRegressions(nights(200))).toEqual([]);
243
+ });
244
+
245
+ it("uses only the baselineRuns runs before the slow ones", () => {
246
+ // Old runs at 300 would hide the regression if they were in the baseline.
247
+ const runs = nights(130, 130, 100, 100, 100, 300, 300, 300, 300, 300);
248
+ const options = { threshold: 0.2, confirmRuns: 2, baselineRuns: 3, minBaseline: 3 };
249
+ expect(findRegressions(runs, options).map((r) => r.baseline)).toEqual([100]);
250
+ });
251
+ });
@@ -0,0 +1,50 @@
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
+ baseline: 300,
8
+ percent: 33.3,
9
+ recent: [
10
+ { date: "2026-10-22T03:17:00Z", sha: "cccccccccccccccc", median: 400 },
11
+ { date: "2026-10-21T03:17:00Z", sha: "bbbbbbbbbbbbbbbb", median: 390 },
12
+ ],
13
+ lastGood: { date: "2026-10-20T03:17:00Z", sha: "aaaaaaaaaaaaaaaa" },
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
+ };
21
+
22
+ describe("regression issue", () => {
23
+ it("has one stable title per benchmark, so the nightly run finds and updates it", () => {
24
+ expect(issueTitle(regression)).toBe("perf(bench): flux.load.cold is slower");
25
+ });
26
+
27
+ it("gives the numbers, the commits to look at, and how to reproduce", () => {
28
+ const body = issueBody(regression, context);
29
+ expect(body).toContain(
30
+ "median 33% above its baseline, on each of the last 2 nightly runs",
31
+ );
32
+ expect(body).toContain("- Baseline (median of the runs before): **300.0 ms**");
33
+ expect(body).toContain(
34
+ "- 2026-10-22, [`ccccccc`](https://github.com/typesmiths/decohere/commit/cccccccccccccccc): **400.0 ms**",
35
+ );
36
+ expect(body).toContain(
37
+ "(https://github.com/typesmiths/decohere/compare/aaaaaaaaaaaaaaaa...cccccccccccccccc)",
38
+ );
39
+ expect(body).toContain("`pnpm bench:save`");
40
+ expect(body).toContain(context.runUrl);
41
+ });
42
+
43
+ it("says the range is unknown when the baseline run has no commit (converted old history)", () => {
44
+ const body = issueBody(
45
+ { ...regression, lastGood: { date: "2026-08-12T00:00:00Z", sha: null } },
46
+ context,
47
+ );
48
+ expect(body).toContain("unknown (no baseline commit recorded)");
49
+ });
50
+ });
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ assessTokenLifetime,
4
+ buildDescription,
5
+ CREDENTIALS,
6
+ credentialProfile,
7
+ ghScopeFlags,
8
+ parseExpiresAt,
9
+ rotationCommands,
10
+ suggestedNextExpiry,
11
+ taskName,
12
+ } from "../../src/github/credential-nudge.mjs";
13
+
14
+ describe("credential-nudge", () => {
15
+ it("parses YYYY-MM-DD as a UTC day", () => {
16
+ expect(parseExpiresAt("2026-11-19")).toBe(Date.UTC(2026, 10, 19));
17
+ expect(parseExpiresAt("")).toBeNull();
18
+ });
19
+
20
+ it("is ok more than 14 days out, warns inside the window, expires on the day", () => {
21
+ const expiresAtMs = Date.UTC(2026, 10, 19);
22
+ const now = (day) => Date.UTC(2026, 10, day, 15);
23
+ expect(assessTokenLifetime({ now: now(1), expiresAtMs }).kind).toBe("ok");
24
+ expect(assessTokenLifetime({ now: now(5), expiresAtMs })).toEqual({
25
+ kind: "warn",
26
+ daysLeft: 14,
27
+ });
28
+ expect(assessTokenLifetime({ now: now(18), expiresAtMs }).kind).toBe("warn");
29
+ expect(assessTokenLifetime({ now: now(19), expiresAtMs })).toEqual({
30
+ kind: "expired",
31
+ daysLeft: 0,
32
+ });
33
+ expect(assessTokenLifetime({ now: now(20), expiresAtMs }).kind).toBe("expired");
34
+ expect(assessTokenLifetime({ expiresAtMs: null }).kind).toBe("missing");
35
+ });
36
+
37
+ it("parameterizes GHCR vs npm rotate commands without duplicating date logic", () => {
38
+ const ghcr = credentialProfile("ghcr");
39
+ const npm = credentialProfile("npm");
40
+ expect(ghScopeFlags(ghcr)).toBe("--env ghcr-publish ");
41
+ expect(ghScopeFlags(npm)).toBe("");
42
+ expect(rotationCommands(ghcr, "2027-02-17", "typesmiths/decohere")).toContain(
43
+ "gh secret set GHCR_PUSH_TOKEN --env ghcr-publish --repo typesmiths/decohere",
44
+ );
45
+ expect(rotationCommands(npm, "2027-02-17", "typesmiths/decohere")).toContain(
46
+ "gh secret set NODE_AUTH_TOKEN --repo typesmiths/decohere",
47
+ );
48
+ expect(rotationCommands(npm, "2027-02-17", "typesmiths/decohere")).toContain(
49
+ "gh variable set NODE_AUTH_TOKEN_EXPIRES_AT --repo typesmiths/decohere --body 2027-02-17",
50
+ );
51
+ expect(taskName(npm, "2026-11-19")).toBe(
52
+ "chore(security): rotate NODE_AUTH_TOKEN (expires 2026-11-19)",
53
+ );
54
+ const body = buildDescription(ghcr, {
55
+ kind: "warn",
56
+ expiresYmd: "2026-11-19",
57
+ daysLeft: 14,
58
+ rejected: false,
59
+ });
60
+ expect(body).toContain("Docker login/push and the `v*` tag will **fail**");
61
+ expect(CREDENTIALS.npm.secret).toBe("NODE_AUTH_TOKEN");
62
+ expect(suggestedNextExpiry(Date.UTC(2026, 7, 21), 90)).toBe("2026-11-19");
63
+ expect(() => credentialProfile("nope")).toThrow(/Unknown NUDGE_CREDENTIAL/);
64
+ });
65
+ });
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ bootstrapTaskNames,
4
+ buildDescription,
5
+ extractGitHubReviewerLogins,
6
+ firstPublishCommands,
7
+ mapLoginsToClickUpIds,
8
+ needsPrereleaseTag,
9
+ parseAssigneeIds,
10
+ parseUnpublished,
11
+ runNudge,
12
+ taskName,
13
+ } from "../../src/github/oidc-bootstrap-nudge.mjs";
14
+
15
+ describe("oidc-bootstrap-nudge", () => {
16
+ it("parses unpublished names and assignee ids", () => {
17
+ expect(parseUnpublished(" @decohere/scaffold @decohere/cli ")).toEqual([
18
+ "@decohere/scaffold",
19
+ "@decohere/cli",
20
+ ]);
21
+ expect(parseAssigneeIds("308432336, 99")).toEqual([308432336, 99]);
22
+ expect(parseAssigneeIds("")).toEqual([]);
23
+ expect(taskName()).toBe("deploy(publish): OIDC bootstrap unpublished packages");
24
+ expect(bootstrapTaskNames()).toContain(
25
+ "chore(publish): OIDC bootstrap unpublished packages",
26
+ );
27
+ });
28
+
29
+ it("extracts GitHub user reviewers and maps login to ClickUp email local-part", () => {
30
+ expect(
31
+ extractGitHubReviewerLogins({
32
+ protection_rules: [
33
+ { type: "branch_policy", reviewers: [] },
34
+ {
35
+ type: "required_reviewers",
36
+ reviewers: [
37
+ { type: "User", reviewer: { login: "das974" } },
38
+ { type: "Team", reviewer: { slug: "publishers", login: "should-skip" } },
39
+ ],
40
+ },
41
+ ],
42
+ }),
43
+ ).toEqual(["das974"]);
44
+ expect(
45
+ mapLoginsToClickUpIds(
46
+ ["das974"],
47
+ [{ id: 308432336, username: "Daniel Scalamogna", email: "das974@gmail.com" }],
48
+ ),
49
+ ).toEqual([308432336]);
50
+ expect(mapLoginsToClickUpIds(["other"], [{ id: 1, email: "das974@gmail.com" }])).toEqual(
51
+ [],
52
+ );
53
+ });
54
+
55
+ it("puts first-publish commands and Trusted Publisher fields in the task body", () => {
56
+ const pkg = { name: "@decohere/scaffold", dir: "scaffold", version: "0.0.1-dev.18" };
57
+ const body = buildDescription({
58
+ unpublished: ["@decohere/scaffold"],
59
+ packages: [pkg],
60
+ repo: "typesmiths/decohere",
61
+ runUrl: "https://github.com/typesmiths/decohere/actions/runs/9",
62
+ channel: "dev",
63
+ });
64
+ expect(needsPrereleaseTag("0.0.1-dev.18")).toBe(true);
65
+ expect(needsPrereleaseTag("1.2.3")).toBe(false);
66
+ expect(firstPublishCommands([pkg], "dev")).toEqual([
67
+ "npm publish ./projects/scaffold --tag dev",
68
+ ]);
69
+ expect(firstPublishCommands([{ ...pkg, version: "1.0.0" }], "dev")).toEqual([
70
+ "npm publish ./projects/scaffold",
71
+ ]);
72
+ expect(body).toContain("npm publish ./projects/scaffold --tag dev");
73
+ expect(body).not.toMatch(/# @decohere\/scaffold/);
74
+ expect(body).toContain("do **not** pass `--access public`");
75
+ expect(firstPublishCommands([pkg], "dev").join("\n")).not.toContain("--access public");
76
+ expect(body).toContain("Workflow filename: `publish.yml`");
77
+ expect(body).toContain("Environment: `npm-publish`");
78
+ expect(body).toContain("gh run rerun --failed");
79
+ expect(body).toContain("https://github.com/typesmiths/decohere/actions/runs/9");
80
+ });
81
+
82
+ it("assigns mapped GitHub reviewers when upserting the ClickUp task", async () => {
83
+ /** @type {object[]} */
84
+ const upserts = [];
85
+ const code = await runNudge(
86
+ { UNPUBLISHED: "@decohere/scaffold", GITHUB_REPOSITORY: "typesmiths/decohere" },
87
+ {
88
+ listPackages: () => [{ name: "@decohere/scaffold", dir: "scaffold" }],
89
+ githubReviewers: async () => ["das974"],
90
+ clickupMembers: async () => [
91
+ { id: 308432336, username: "Daniel Scalamogna", email: "das974@gmail.com" },
92
+ ],
93
+ upsert: async (options) => {
94
+ upserts.push(options);
95
+ return { id: "abc", url: "https://app.clickup.com/t/abc", created: true };
96
+ },
97
+ runUrl: "https://github.com/typesmiths/decohere/actions/runs/1",
98
+ },
99
+ );
100
+ expect(code).toBe(0);
101
+ expect(upserts[0].assigneeIds).toEqual([308432336]);
102
+ expect(upserts[0].unpublished).toEqual(["@decohere/scaffold"]);
103
+ });
104
+ });
@@ -0,0 +1,95 @@
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("parses GHCR repository coordinates", () => {
51
+ expect(parseGhcrRepository("ghcr.io/typesmiths/decohere/create")).toEqual({
52
+ org: "typesmiths",
53
+ packageName: "decohere/create",
54
+ });
55
+ });
56
+
57
+ it("marks names missing from npm as unpublished and skip_npm when every version exists", async () => {
58
+ const root = seedWorkspace();
59
+ const views = [];
60
+ const spawnSync = (_cmd, args) => {
61
+ views.push(args[1]);
62
+ if (args[1] === "@decohere/cli") {
63
+ return { status: 1 };
64
+ }
65
+ return { status: 0 };
66
+ };
67
+ const result = await collectPublishVerify({
68
+ root,
69
+ spawnSync,
70
+ githubToken: "",
71
+ });
72
+ expect(result.unpublished).toEqual(["@decohere/cli"]);
73
+ expect(result.skipNpm).toBe(false);
74
+ expect(views).toContain("@decohere/cli");
75
+ expect(views).toContain("@decohere/create");
76
+ expect(views).toContain("@decohere/create@0.0.1-dev.79");
77
+ });
78
+
79
+ it("sets skip_docker when both GHCR tags exist", async () => {
80
+ const root = seedWorkspace();
81
+ const fetch = async () => ({
82
+ ok: true,
83
+ status: 200,
84
+ json: async () => [{ metadata: { container: { tags: ["0.0.1-dev.79"] } } }],
85
+ });
86
+ const result = await collectPublishVerify({
87
+ root,
88
+ spawnSync: () => ({ status: 0 }),
89
+ githubToken: "ghs_test",
90
+ fetch,
91
+ });
92
+ expect(result.skipDocker).toBe(true);
93
+ expect(formatGithubOutput(result)).toContain("skip_docker=true");
94
+ });
95
+ });