@decohere/ci-tools 0.0.1-dev.80 → 0.0.1-dev.82

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/ci-tools",
3
- "version": "0.0.1-dev.80",
3
+ "version": "0.0.1-dev.82",
4
4
  "description": "CI utility scripts for badge generation, coverage reporting, pages artifact assembly, and GitHub API tooling",
5
5
  "keywords": [
6
6
  "typescript",
@@ -36,7 +36,8 @@
36
36
  "./github/create-or-update-pr": "./src/github/create-or-update-pr.mjs",
37
37
  "./github/publish-verify": "./src/github/publish-verify.mjs",
38
38
  "./github/credential-nudge": "./src/github/credential-nudge.mjs",
39
- "./github/oidc-bootstrap-nudge": "./src/github/oidc-bootstrap-nudge.mjs"
39
+ "./github/oidc-bootstrap-nudge": "./src/github/oidc-bootstrap-nudge.mjs",
40
+ "./github/smoke-create-launcher": "./src/github/smoke-create-launcher.mjs"
40
41
  },
41
42
  "scripts": {
42
43
  "test": "vitest run"
@@ -5,8 +5,9 @@ import { listPublishablePackages } from "./publish-verify.mjs";
5
5
  const LIST_ID = process.env.CLICKUP_LIST_ID ?? "901220171084";
6
6
  const API = "https://api.clickup.com/api/v2";
7
7
  const GITHUB_API = process.env.GITHUB_API_URL ?? "https://api.github.com";
8
- const CHORE_TYPE = 1004;
9
- const TASK_NAME = "chore(publish): OIDC bootstrap unpublished packages";
8
+ const DEPLOYMENT_TYPE = 1005;
9
+ const TASK_NAME = "deploy(publish): OIDC bootstrap unpublished packages";
10
+ const LEGACY_TASK_NAMES = ["chore(publish): OIDC bootstrap unpublished packages"];
10
11
  const BOOTSTRAP_ENV = "npm-oidc-bootstrap";
11
12
  const DEFAULT_REPO = "typesmiths/decohere";
12
13
 
@@ -14,6 +15,10 @@ export function taskName() {
14
15
  return TASK_NAME;
15
16
  }
16
17
 
18
+ export function bootstrapTaskNames() {
19
+ return [TASK_NAME, ...LEGACY_TASK_NAMES];
20
+ }
21
+
17
22
  /** @param {string | undefined} raw */
18
23
  export function parseUnpublished(raw) {
19
24
  return String(raw ?? "")
@@ -31,15 +36,24 @@ export function parseAssigneeIds(raw) {
31
36
  }
32
37
 
33
38
  /**
34
- * @param {{ name: string, dir: string }[]} packages
39
+ * @param {string | undefined} version
35
40
  */
36
- export function firstPublishCommands(packages) {
41
+ export function needsPrereleaseTag(version) {
42
+ return String(version ?? "").includes("-");
43
+ }
44
+
45
+ /**
46
+ * @param {{ name: string, dir: string, version?: string }[]} packages
47
+ * @param {string} [channel]
48
+ */
49
+ export function firstPublishCommands(packages, channel = "dev") {
37
50
  if (packages.length === 0) {
38
- return [
39
- "npm publish # from the unpublished package directory (do not pass --access public)",
40
- ];
51
+ return [`npm publish ./<package-dir> --tag ${channel}`];
41
52
  }
42
- return packages.map((pkg) => `npm publish projects/${pkg.dir} # ${pkg.name}`);
53
+ return packages.map((pkg) => {
54
+ const tag = needsPrereleaseTag(pkg.version) ? ` --tag ${channel}` : "";
55
+ return `npm publish ./projects/${pkg.dir}${tag}`;
56
+ });
43
57
  }
44
58
 
45
59
  /**
@@ -94,14 +108,16 @@ export function mapLoginsToClickUpIds(logins, members) {
94
108
  /**
95
109
  * @param {{
96
110
  * unpublished: string[],
97
- * packages: { name: string, dir: string }[],
111
+ * packages: { name: string, dir: string, version?: string }[],
98
112
  * repo?: string,
99
113
  * runUrl?: string,
114
+ * channel?: string,
100
115
  * }} options
101
116
  */
102
117
  export function buildDescription(options) {
103
118
  const repo = options.repo || DEFAULT_REPO;
104
119
  const names = options.unpublished;
120
+ const channel = options.channel || "dev";
105
121
  const packages = options.packages.filter((pkg) => names.includes(pkg.name));
106
122
  const [owner, repoName] = repo.split("/");
107
123
  return [
@@ -113,10 +129,10 @@ export function buildDescription(options) {
113
129
  "",
114
130
  "## What to do",
115
131
  "",
116
- "1. First-publish each name **restricted** (scoped default — do **not** pass `--access public`). From a trusted machine, `npm login` to the org, then:",
132
+ "1. First-publish each name **restricted** (scoped default — do **not** pass `--access public`). From a trusted machine, `npm login` to the org, then from the **repo root**. Use `./` so npm does not treat the path as a GitHub `owner/repo`. `--tag` is required for prerelease versions (`x.y.z-dev.N`). Missing `bin`/`es6` warnings are OK for this name-only bootstrap; CI `forge publish` ships the built tarball later:",
117
133
  "",
118
134
  "```bash",
119
- ...firstPublishCommands(packages),
135
+ ...firstPublishCommands(packages, channel),
120
136
  "```",
121
137
  "",
122
138
  "2. npm → package settings → Trusted Publisher → GitHub Actions:",
@@ -162,7 +178,8 @@ async function clickup(method, path, body) {
162
178
  return text.length === 0 ? undefined : JSON.parse(text);
163
179
  }
164
180
 
165
- async function findTaskByName(name) {
181
+ async function findTaskByName(names) {
182
+ const wanted = new Set(Array.isArray(names) ? names : [names]);
166
183
  for (let page = 0; page < 10; page += 1) {
167
184
  const qs = new URLSearchParams({
168
185
  include_closed: "true",
@@ -171,7 +188,7 @@ async function findTaskByName(name) {
171
188
  });
172
189
  const data = await clickup("GET", `/list/${LIST_ID}/task?${qs}`);
173
190
  const tasks = Array.isArray(data) ? data : (data?.tasks ?? []);
174
- const match = tasks.find((task) => task.name === name);
191
+ const match = tasks.find((task) => wanted.has(task.name));
175
192
  if (match) {
176
193
  return match;
177
194
  }
@@ -248,16 +265,16 @@ export async function clickupWorkspaceMembers() {
248
265
  }
249
266
 
250
267
  /**
251
- * @param {{ unpublished: string[], packages: { name: string, dir: string }[], assigneeIds: number[], repo?: string, runUrl?: string }} options
268
+ * @param {{ unpublished: string[], packages: { name: string, dir: string, version?: string }[], assigneeIds: number[], repo?: string, runUrl?: string, channel?: string }} options
252
269
  */
253
270
  export async function upsertBootstrapTask(options) {
254
271
  const name = taskName();
255
- const existing = await findTaskByName(name);
272
+ const existing = await findTaskByName(bootstrapTaskNames());
256
273
  const markdown = buildDescription(options);
257
274
  const payload = {
258
275
  name,
259
276
  markdown_description: markdown,
260
- custom_item_id: CHORE_TYPE,
277
+ custom_item_id: DEPLOYMENT_TYPE,
261
278
  priority: 1,
262
279
  };
263
280
  if (options.assigneeIds.length > 0 && !existing) {
@@ -266,7 +283,9 @@ export async function upsertBootstrapTask(options) {
266
283
 
267
284
  if (existing) {
268
285
  const update = {
286
+ name,
269
287
  markdown_description: markdown,
288
+ custom_item_id: DEPLOYMENT_TYPE,
270
289
  priority: 1,
271
290
  };
272
291
  if (existing.closed || existing.status?.type === "closed") {
@@ -318,6 +337,7 @@ export async function runNudge(env = process.env, io = {}) {
318
337
  assigneeIds,
319
338
  repo: env.GITHUB_REPOSITORY || DEFAULT_REPO,
320
339
  runUrl: io.runUrl ?? runUrl(),
340
+ channel: env.PUBLISH_CHANNEL || env.CHANNEL || "dev",
321
341
  });
322
342
  const url = result.url ?? "";
323
343
  process.stdout.write(
@@ -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/cli/src/cli.js";
11
10
  const DECOHERE_CONFIG = process.env.DECOHERE_CONFIG || ".decohere.js";
12
11
 
13
- const baseCmd = `node "${TERMINAL_CLI}" --log-level info --config-file "${DECOHERE_CONFIG}" forge dependencies --configuration release sync`;
12
+ const baseCmd = `pnpm exec flux run projects/cli/src/forge.ts -- --log-level info --config-file "${DECOHERE_CONFIG}" dependencies --configuration release sync`;
14
13
 
15
14
  try {
16
15
  if (DRY_RUN) {
@@ -1,9 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
+ bootstrapTaskNames,
3
4
  buildDescription,
4
5
  extractGitHubReviewerLogins,
5
6
  firstPublishCommands,
6
7
  mapLoginsToClickUpIds,
8
+ needsPrereleaseTag,
7
9
  parseAssigneeIds,
8
10
  parseUnpublished,
9
11
  runNudge,
@@ -18,7 +20,10 @@ describe("oidc-bootstrap-nudge", () => {
18
20
  ]);
19
21
  expect(parseAssigneeIds("308432336, 99")).toEqual([308432336, 99]);
20
22
  expect(parseAssigneeIds("")).toEqual([]);
21
- expect(taskName()).toBe("chore(publish): OIDC bootstrap unpublished packages");
23
+ expect(taskName()).toBe("deploy(publish): OIDC bootstrap unpublished packages");
24
+ expect(bootstrapTaskNames()).toContain(
25
+ "chore(publish): OIDC bootstrap unpublished packages",
26
+ );
22
27
  });
23
28
 
24
29
  it("extracts GitHub user reviewers and maps login to ClickUp email local-part", () => {
@@ -48,20 +53,26 @@ describe("oidc-bootstrap-nudge", () => {
48
53
  });
49
54
 
50
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" };
51
57
  const body = buildDescription({
52
58
  unpublished: ["@decohere/scaffold"],
53
- packages: [{ name: "@decohere/scaffold", dir: "scaffold" }],
59
+ packages: [pkg],
54
60
  repo: "typesmiths/decohere",
55
61
  runUrl: "https://github.com/typesmiths/decohere/actions/runs/9",
62
+ channel: "dev",
56
63
  });
57
- expect(firstPublishCommands([{ name: "@decohere/scaffold", dir: "scaffold" }])).toEqual([
58
- "npm publish projects/scaffold # @decohere/scaffold",
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",
59
71
  ]);
60
- expect(body).toContain("npm publish projects/scaffold");
72
+ expect(body).toContain("npm publish ./projects/scaffold --tag dev");
73
+ expect(body).not.toMatch(/# @decohere\/scaffold/);
61
74
  expect(body).toContain("do **not** pass `--access public`");
62
- expect(
63
- firstPublishCommands([{ name: "@decohere/scaffold", dir: "scaffold" }]).join("\n"),
64
- ).not.toContain("--access public");
75
+ expect(firstPublishCommands([pkg], "dev").join("\n")).not.toContain("--access public");
65
76
  expect(body).toContain("Workflow filename: `publish.yml`");
66
77
  expect(body).toContain("Environment: `npm-publish`");
67
78
  expect(body).toContain("gh run rerun --failed");
@@ -0,0 +1,71 @@
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
+ authToken: "tok",
19
+ tmpdir: () => "/tmp",
20
+ mkdtempSync: (prefix) => `${prefix}abc`,
21
+ writeFileSync: (path, body) => {
22
+ written.push({ path, body });
23
+ },
24
+ spawnSync: (cmd, args, opts) => {
25
+ calls.push({ cmd, args, opts });
26
+ return { status: 0 };
27
+ },
28
+ stdio: "pipe",
29
+ });
30
+
31
+ expect(result.workDir).toBe("/tmp/decohere-create-smoke-abc");
32
+ expect(result.workDir).not.toBe(process.cwd());
33
+ expect(calls).toHaveLength(1);
34
+ expect(calls[0].cmd).toBe("npx");
35
+ expect(calls[0].args).toEqual(["--yes", "@decohere/create@0.0.1-dev.80", "--help"]);
36
+ expect(calls[0].opts.cwd).toBe("/tmp/decohere-create-smoke-abc");
37
+ expect(calls[0].opts.cwd).not.toBe(process.cwd());
38
+ expect(written).toEqual([
39
+ {
40
+ path: "/tmp/decohere-create-smoke-abc/.npmrc",
41
+ body: "@decohere:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=tok\n",
42
+ },
43
+ ]);
44
+ });
45
+
46
+ it("fails closed without version or token", () => {
47
+ expect(() => runCreateLauncherSmoke({ createVersion: "", authToken: "tok" })).toThrow(
48
+ "CREATE_VERSION is required",
49
+ );
50
+ expect(() =>
51
+ runCreateLauncherSmoke({ createVersion: "0.0.1-dev.80", authToken: "" }),
52
+ ).toThrow("NODE_AUTH_TOKEN is required");
53
+ });
54
+ });
55
+
56
+ describe("publish.yml create smoke + tag", () => {
57
+ const yml = fs.readFileSync(PUBLISH_YML, "utf8");
58
+
59
+ it("wires create smoke through the isolated launcher", () => {
60
+ expect(yml).toContain("node projects/ci-tools/src/github/smoke-create-launcher.mjs");
61
+ expect(yml).toContain("CREATE_VERSION:");
62
+ expect(yml).not.toContain("npx --yes");
63
+ });
64
+
65
+ it("grants the tag job actions:read so publish-prepare can download CI artifacts", () => {
66
+ const tag = yml.slice(yml.indexOf("\n tag:"));
67
+ const perms = tag.slice(tag.indexOf("permissions:"), tag.indexOf("steps:"));
68
+ expect(perms).toMatch(/contents:\s*write/);
69
+ expect(perms).toMatch(/actions:\s*read/);
70
+ });
71
+ });