@decohere/ci-tools 0.0.1-dev.9 → 0.0.1-dev.90

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.
@@ -0,0 +1,209 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+
6
+ /**
7
+ * Workspace packages that `forge publish` will upload (not `"private": true`).
8
+ * @param {string} root
9
+ */
10
+ export function listPublishablePackages(root) {
11
+ const projectsDir = join(root, "projects");
12
+ const packages = [];
13
+ for (const dir of readdirSync(projectsDir)) {
14
+ const pkgPath = join(projectsDir, dir, "package.json");
15
+ if (!existsSync(pkgPath)) {
16
+ continue;
17
+ }
18
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
19
+ if (typeof pkg.name !== "string" || pkg.private === true) {
20
+ continue;
21
+ }
22
+ packages.push({
23
+ name: pkg.name,
24
+ version: pkg.version,
25
+ dir,
26
+ });
27
+ }
28
+ return packages.sort((a, b) => a.name.localeCompare(b.name));
29
+ }
30
+
31
+ export function dockerRepositories(root) {
32
+ const create = JSON.parse(readFileSync(join(root, "projects/create/package.json"), "utf8"));
33
+ const devcontainer = JSON.parse(
34
+ readFileSync(join(root, "projects/devcontainer/package.json"), "utf8"),
35
+ );
36
+ return {
37
+ create: create.decohere?.dockerImageRepository ?? "ghcr.io/typesmiths/decohere/create",
38
+ devcontainer:
39
+ devcontainer.decohere?.dockerImageRepository ??
40
+ "ghcr.io/typesmiths/decohere/devcontainer",
41
+ createVersion: create.version,
42
+ workspaceVersion: JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version,
43
+ };
44
+ }
45
+
46
+ /**
47
+ * @param {string} spec package or package@version
48
+ * @param {{ spawnSync?: typeof spawnSync }} [io]
49
+ */
50
+ export function npmNameExists(spec, io = {}) {
51
+ const spawn = io.spawnSync ?? spawnSync;
52
+ const result = spawn("npm", ["view", spec, "name"], {
53
+ encoding: "utf8",
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ });
56
+ return result.status === 0;
57
+ }
58
+
59
+ /**
60
+ * @param {string} spec package@version
61
+ * @param {{ spawnSync?: typeof spawnSync }} [io]
62
+ */
63
+ export function npmVersionExists(spec, io = {}) {
64
+ const spawn = io.spawnSync ?? spawnSync;
65
+ const result = spawn("npm", ["view", spec, "version"], {
66
+ encoding: "utf8",
67
+ stdio: ["ignore", "pipe", "pipe"],
68
+ });
69
+ return result.status === 0;
70
+ }
71
+
72
+ /**
73
+ * Parse `ghcr.io/org/name/image` into GitHub packages API coordinates.
74
+ * @param {string} repository
75
+ */
76
+ export function parseGhcrRepository(repository) {
77
+ const withoutHost = repository.replace(/^ghcr\.io\//, "");
78
+ const slash = withoutHost.indexOf("/");
79
+ if (slash < 0) {
80
+ throw new Error(`Not a GHCR repository: ${repository}`);
81
+ }
82
+ return {
83
+ org: withoutHost.slice(0, slash),
84
+ packageName: withoutHost.slice(slash + 1),
85
+ };
86
+ }
87
+
88
+ /**
89
+ * @param {string} repository ghcr.io/org/pkg
90
+ * @param {string} tag
91
+ * @param {{ token: string, fetch?: typeof fetch }} io
92
+ */
93
+ export async function ghcrTagExists(repository, tag, io) {
94
+ const { org, packageName } = parseGhcrRepository(repository);
95
+ const encoded = encodeURIComponent(packageName);
96
+ const headers = {
97
+ Authorization: `Bearer ${io.token}`,
98
+ Accept: "application/vnd.github+json",
99
+ "X-GitHub-Api-Version": "2022-11-28",
100
+ };
101
+ const fetchImpl = io.fetch ?? fetch;
102
+ for (let page = 1; page <= 20; page += 1) {
103
+ const url = `https://api.github.com/orgs/${org}/packages/container/${encoded}/versions?per_page=100&page=${page}`;
104
+ const response = await fetchImpl(url, { headers });
105
+ if (response.status === 404) {
106
+ return false;
107
+ }
108
+ if (!response.ok) {
109
+ throw new Error(`GHCR versions ${response.status} for ${repository}`);
110
+ }
111
+ const versions = await response.json();
112
+ if (!Array.isArray(versions) || versions.length === 0) {
113
+ return false;
114
+ }
115
+ for (const version of versions) {
116
+ const tags = version.metadata?.container?.tags ?? [];
117
+ if (tags.includes(tag)) {
118
+ return true;
119
+ }
120
+ }
121
+ if (versions.length < 100) {
122
+ return false;
123
+ }
124
+ }
125
+ return false;
126
+ }
127
+
128
+ /**
129
+ * @param {{
130
+ * root: string,
131
+ * spawnSync?: typeof spawnSync,
132
+ * fetch?: typeof fetch,
133
+ * githubToken?: string,
134
+ * }} options
135
+ */
136
+ export async function collectPublishVerify(options) {
137
+ const packages = listPublishablePackages(options.root);
138
+ const unpublished = [];
139
+ let publishedVersions = 0;
140
+ for (const pkg of packages) {
141
+ if (!npmNameExists(pkg.name, options)) {
142
+ unpublished.push(pkg.name);
143
+ continue;
144
+ }
145
+ if (npmVersionExists(`${pkg.name}@${pkg.version}`, options)) {
146
+ publishedVersions += 1;
147
+ }
148
+ }
149
+
150
+ const images = dockerRepositories(options.root);
151
+ const token = options.githubToken ?? "";
152
+ let skipDocker = false;
153
+ if (token) {
154
+ const createExists = await ghcrTagExists(images.create, images.workspaceVersion, {
155
+ token,
156
+ fetch: options.fetch,
157
+ });
158
+ const devExists = await ghcrTagExists(images.devcontainer, images.workspaceVersion, {
159
+ token,
160
+ fetch: options.fetch,
161
+ });
162
+ skipDocker = createExists && devExists;
163
+ }
164
+
165
+ return {
166
+ unpublished,
167
+ skipNpm: packages.length > 0 && publishedVersions === packages.length,
168
+ skipDocker,
169
+ imageTag: images.workspaceVersion,
170
+ createVersion: images.createVersion,
171
+ repository: images.create,
172
+ devcontainerRepository: images.devcontainer,
173
+ packageCount: packages.length,
174
+ publishedVersions,
175
+ };
176
+ }
177
+
178
+ export function formatGithubOutput(result) {
179
+ return [
180
+ `unpublished=${result.unpublished.join(" ")}`,
181
+ `skip_npm=${result.skipNpm ? "true" : "false"}`,
182
+ `skip_docker=${result.skipDocker ? "true" : "false"}`,
183
+ `image_tag=${result.imageTag}`,
184
+ `create_version=${result.createVersion}`,
185
+ `repository=${result.repository}`,
186
+ `devcontainer_repository=${result.devcontainerRepository}`,
187
+ ].join("\n");
188
+ }
189
+
190
+ const isCli =
191
+ process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
192
+
193
+ if (isCli) {
194
+ const result = await collectPublishVerify({
195
+ root: process.cwd(),
196
+ githubToken: process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN,
197
+ });
198
+ const body = formatGithubOutput(result);
199
+ process.stdout.write(`${body}\n`);
200
+ if (process.env.GITHUB_OUTPUT) {
201
+ const { appendFileSync } = await import("node:fs");
202
+ appendFileSync(process.env.GITHUB_OUTPUT, `${body}\n`);
203
+ }
204
+ if (result.unpublished.length) {
205
+ process.stderr.write(
206
+ `Never-published packages (bootstrap OIDC before PUT): ${result.unpublished.join(", ")}\n`,
207
+ );
208
+ }
209
+ }
@@ -0,0 +1,70 @@
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
+ }
@@ -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,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
+ });