@decohere/ci-tools 0.0.1-dev.20261105.103 → 0.0.1-dev.20261105.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decohere/ci-tools",
3
- "version": "0.0.1-dev.20261105.103",
3
+ "version": "0.0.1-dev.20261105.110",
4
4
  "description": "CI utility scripts for badge generation, coverage reporting, pages artifact assembly, and GitHub API tooling",
5
5
  "keywords": [
6
6
  "typescript",
@@ -44,8 +44,7 @@
44
44
  "./github/create-or-update-pr": "./src/github/create-or-update-pr.mjs",
45
45
  "./github/publish-verify": "./src/github/publish-verify.mjs",
46
46
  "./github/first-publish-check": "./src/github/first-publish-check.mjs",
47
- "./github/npm-bootstrap": "./src/github/npm-bootstrap.mjs",
48
- "./github/smoke-create-launcher": "./src/github/smoke-create-launcher.mjs"
47
+ "./github/npm-bootstrap": "./src/github/npm-bootstrap.mjs"
49
48
  },
50
49
  "scripts": {
51
50
  "test": "vitest run"
@@ -0,0 +1,47 @@
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 PUBLISH_YML = fileURLToPath(
6
+ new URL("../../../../.github/workflows/publish.yml", import.meta.url),
7
+ );
8
+
9
+ describe("publish.yml", () => {
10
+ const yml = fs.readFileSync(PUBLISH_YML, "utf8");
11
+
12
+ it("only tags a version once npm and the images have shipped", () => {
13
+ const tag = yml.slice(yml.indexOf("\n tag:"));
14
+ const needs = /needs: \[(.+)\]/.exec(tag)?.[1].split(", ");
15
+
16
+ expect(needs).toContain("npm");
17
+ expect(needs).toContain("check-images");
18
+ });
19
+
20
+ it("checks the images with no login, as a stranger would", () => {
21
+ const job = yml.slice(yml.indexOf("\n check-images:"), yml.indexOf("\n tag:"));
22
+
23
+ expect(job).toContain("permissions: {}");
24
+ expect(job).toContain("docker manifest inspect");
25
+ expect(job).not.toContain("docker login");
26
+ });
27
+
28
+ it("reads npm without a token: the packages are public and publishing is OIDC", () => {
29
+ const workflows = fileURLToPath(new URL("../../../../.github/", import.meta.url));
30
+ const files = fs
31
+ .readdirSync(workflows, { recursive: true })
32
+ .filter((file) => /\.ya?ml$/.test(file));
33
+ expect(files.length).toBeGreaterThan(0);
34
+ for (const file of files) {
35
+ expect(fs.readFileSync(`${workflows}${file}`, "utf8"), file).not.toMatch(
36
+ /NODE_AUTH_TOKEN|_authToken/,
37
+ );
38
+ }
39
+ });
40
+
41
+ it("grants the tag job actions:read so publish-prepare can download CI artifacts", () => {
42
+ const tag = yml.slice(yml.indexOf("\n tag:"));
43
+ const perms = tag.slice(tag.indexOf("permissions:"), tag.indexOf("steps:"));
44
+ expect(perms).toMatch(/contents:\s*write/);
45
+ expect(perms).toMatch(/actions:\s*read/);
46
+ });
47
+ });
@@ -1,85 +0,0 @@
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
- * No token: the packages are public, so this installs the launcher the way a user does.
13
- * Anonymous reads go through npm's cache, which can lag minutes behind a publish, so it
14
- * first waits until npm serves the version (`attempts` tries, `intervalMs` apart).
15
- *
16
- * @param {{
17
- * createVersion: string,
18
- * attempts?: number,
19
- * intervalMs?: number,
20
- * sleep?: (ms: number) => void,
21
- * mkdtempSync?: typeof mkdtempSync,
22
- * writeFileSync?: typeof writeFileSync,
23
- * spawnSync?: typeof spawnSync,
24
- * tmpdir?: () => string,
25
- * stdio?: import("node:child_process").StdioOptions,
26
- * }} options
27
- */
28
- export function runCreateLauncherSmoke(options) {
29
- const version = options.createVersion?.trim() ?? "";
30
- if (!version) {
31
- throw new Error("CREATE_VERSION is required");
32
- }
33
-
34
- const mkdtemp = options.mkdtempSync ?? mkdtempSync;
35
- const writeFile = options.writeFileSync ?? writeFileSync;
36
- const spawn = options.spawnSync ?? spawnSync;
37
- const tmp = options.tmpdir ?? tmpdir;
38
- const sleep = options.sleep ?? sleepSync;
39
- const attempts = options.attempts ?? 20;
40
- const intervalMs = options.intervalMs ?? 15_000;
41
-
42
- const workDir = mkdtemp(join(tmp(), "decohere-create-smoke-"));
43
- const npmrc = join(workDir, ".npmrc");
44
- writeFile(npmrc, "@decohere:registry=https://registry.npmjs.org/\n");
45
-
46
- const env = { ...process.env, NPM_CONFIG_USERCONFIG: npmrc };
47
- const spec = `@decohere/create@${version}`;
48
-
49
- for (let attempt = 1; ; attempt++) {
50
- const view = spawn("npm", ["view", spec, "version"], { cwd: workDir, env, stdio: "pipe" });
51
- if (view.status === 0) break;
52
- if (attempt >= attempts) {
53
- console.error(`${spec} is still not served by npm after ${attempts} tries`);
54
- return { workDir, status: 1 };
55
- }
56
- console.log(`${spec} not served by npm yet (try ${attempt} of ${attempts}); waiting`);
57
- sleep(intervalMs);
58
- }
59
-
60
- const result = spawn("npx", ["--yes", spec, "--help"], {
61
- cwd: workDir,
62
- env,
63
- stdio: options.stdio ?? "inherit",
64
- });
65
-
66
- return {
67
- workDir,
68
- status: result.status ?? 1,
69
- };
70
- }
71
-
72
- /** @param {number} ms */
73
- function sleepSync(ms) {
74
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
75
- }
76
-
77
- const isCli =
78
- process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
79
-
80
- if (isCli) {
81
- const { status } = runCreateLauncherSmoke({
82
- createVersion: process.env.CREATE_VERSION ?? "",
83
- });
84
- process.exit(status);
85
- }
@@ -1,132 +0,0 @@
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
- });