@decohere/ci-tools 0.0.1-dev.121 → 0.0.1-dev.122
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/LICENSE
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
ISC License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025-2026 Typesmiths
|
|
4
|
+
|
|
5
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
6
|
+
purpose with or without fee is hereby granted, provided that the above
|
|
7
|
+
copyright notice and this permission notice appear in all copies.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
|
10
|
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
|
11
|
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
|
12
|
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
|
13
|
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
|
14
|
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
|
15
|
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@decohere/ci-tools",
|
|
3
|
-
"version": "0.0.1-dev.
|
|
3
|
+
"version": "0.0.1-dev.122",
|
|
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,6 +36,7 @@
|
|
|
36
36
|
"./github/publish-verify": "./src/github/publish-verify.mjs",
|
|
37
37
|
"./github/credential-nudge": "./src/github/credential-nudge.mjs",
|
|
38
38
|
"./github/oidc-bootstrap-nudge": "./src/github/oidc-bootstrap-nudge.mjs",
|
|
39
|
+
"./github/npm-bootstrap": "./src/github/npm-bootstrap.mjs",
|
|
39
40
|
"./github/smoke-create-launcher": "./src/github/smoke-create-launcher.mjs"
|
|
40
41
|
},
|
|
41
42
|
"scripts": {
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-publish a new package name (restricted) and give it its Trusted Publisher,
|
|
3
|
+
* so `publish.yml` can release it over OIDC from then on.
|
|
4
|
+
*
|
|
5
|
+
* pnpm npm:bootstrap [--otp <code>] [--dry-run] [--channel dev] [<package-name>...]
|
|
6
|
+
*
|
|
7
|
+
* Without names: every publishable package whose name is not on npm yet.
|
|
8
|
+
* Run from the repo root after `npm login` to the org. Each step is skipped when
|
|
9
|
+
* it is already done (the name exists, the Trusted Publisher is set).
|
|
10
|
+
*
|
|
11
|
+
* npm runs on this terminal, so it does its own 2FA: it asks for a code, or
|
|
12
|
+
* opens the browser for web authentication. `--otp` hands it a code up front
|
|
13
|
+
* through `NPM_CONFIG_OTP`; `npm trust` takes no `--otp` flag of its own.
|
|
14
|
+
*/
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { resolve } from "node:path";
|
|
17
|
+
import { parseArgs } from "node:util";
|
|
18
|
+
import { pathToFileURL } from "node:url";
|
|
19
|
+
import { listPublishablePackages, npmNameExists } from "./publish-verify.mjs";
|
|
20
|
+
|
|
21
|
+
/** The Trusted Publisher every package gets: `publish.yml`'s npm job. */
|
|
22
|
+
export const TRUSTED_PUBLISHER = {
|
|
23
|
+
repo: "typesmiths/decohere",
|
|
24
|
+
file: "publish.yml",
|
|
25
|
+
environment: "npm-publish",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** @param {string | undefined} version */
|
|
29
|
+
export function needsPrereleaseTag(version) {
|
|
30
|
+
return String(version ?? "").includes("-");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* npm arguments for a name-only first publish. Restricted is the scoped default,
|
|
35
|
+
* so no `--access`. `./` stops npm reading the path as a GitHub `owner/repo`.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ dir: string, version?: string }} pkg
|
|
38
|
+
* @param {string} channel
|
|
39
|
+
*/
|
|
40
|
+
export function publishArgs(pkg, channel) {
|
|
41
|
+
const tag = needsPrereleaseTag(pkg.version) ? ["--tag", channel] : [];
|
|
42
|
+
return ["publish", `./projects/${pkg.dir}`, ...tag];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @param {string} name */
|
|
46
|
+
export function trustArgs(name) {
|
|
47
|
+
return [
|
|
48
|
+
"trust",
|
|
49
|
+
"github",
|
|
50
|
+
name,
|
|
51
|
+
"--file",
|
|
52
|
+
TRUSTED_PUBLISHER.file,
|
|
53
|
+
"--repo",
|
|
54
|
+
TRUSTED_PUBLISHER.repo,
|
|
55
|
+
"--env",
|
|
56
|
+
TRUSTED_PUBLISHER.environment,
|
|
57
|
+
"--allow-publish",
|
|
58
|
+
"--yes",
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} output `npm trust list <name> --json` */
|
|
63
|
+
export function hasTrustedPublisher(output) {
|
|
64
|
+
return output.includes(TRUSTED_PUBLISHER.file) && output.includes(TRUSTED_PUBLISHER.repo);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {{ capture?: boolean, otp?: string }} NpmOptions
|
|
69
|
+
* `capture` keeps stdout to read it (npm can then not prompt for 2FA);
|
|
70
|
+
* otherwise npm owns the terminal.
|
|
71
|
+
*/
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string[]} args
|
|
75
|
+
* @param {NpmOptions} [options]
|
|
76
|
+
* @returns {{ status: number, output: string }}
|
|
77
|
+
*/
|
|
78
|
+
function runNpm(args, { capture = false, otp } = {}) {
|
|
79
|
+
const result = spawnSync("npm", args, {
|
|
80
|
+
encoding: "utf8",
|
|
81
|
+
stdio: capture ? ["inherit", "pipe", "inherit"] : "inherit",
|
|
82
|
+
env: otp ? { ...process.env, NPM_CONFIG_OTP: otp } : process.env,
|
|
83
|
+
});
|
|
84
|
+
return { status: result.status ?? 1, output: result.stdout ?? "" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @param {{
|
|
89
|
+
* argv?: string[],
|
|
90
|
+
* root?: string,
|
|
91
|
+
* io?: {
|
|
92
|
+
* npm?: (args: string[], options?: NpmOptions) => { status: number, output: string },
|
|
93
|
+
* nameExists?: (name: string) => boolean,
|
|
94
|
+
* log?: (line: string) => void,
|
|
95
|
+
* },
|
|
96
|
+
* }} [options]
|
|
97
|
+
* @returns {Promise<number>} exit code
|
|
98
|
+
*/
|
|
99
|
+
export async function runBootstrap(options = {}) {
|
|
100
|
+
const { values, positionals } = parseArgs({
|
|
101
|
+
args: options.argv ?? process.argv.slice(2),
|
|
102
|
+
options: {
|
|
103
|
+
otp: { type: "string" },
|
|
104
|
+
"dry-run": { type: "boolean", default: false },
|
|
105
|
+
channel: { type: "string", default: "dev" },
|
|
106
|
+
},
|
|
107
|
+
allowPositionals: true,
|
|
108
|
+
});
|
|
109
|
+
const io = options.io ?? {};
|
|
110
|
+
const npm = io.npm ?? runNpm;
|
|
111
|
+
const nameExists = io.nameExists ?? ((name) => npmNameExists(name));
|
|
112
|
+
const log = io.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
113
|
+
const dryRun = values["dry-run"];
|
|
114
|
+
|
|
115
|
+
// Even on a dry run: the names are private, so `npm view` 404s without a login
|
|
116
|
+
// and every package would look unpublished.
|
|
117
|
+
if (npm(["whoami"]).status !== 0) {
|
|
118
|
+
log("Not logged in to npm: run `npm login` first.");
|
|
119
|
+
return 1;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const publishable = listPublishablePackages(options.root ?? process.cwd());
|
|
123
|
+
const unknown = positionals.filter((name) => !publishable.some((pkg) => pkg.name === name));
|
|
124
|
+
if (unknown.length > 0) {
|
|
125
|
+
log(`Not a publishable package in projects/: ${unknown.join(", ")}`);
|
|
126
|
+
return 1;
|
|
127
|
+
}
|
|
128
|
+
const packages =
|
|
129
|
+
positionals.length > 0
|
|
130
|
+
? publishable.filter((pkg) => positionals.includes(pkg.name))
|
|
131
|
+
: publishable.filter((pkg) => !nameExists(pkg.name));
|
|
132
|
+
if (packages.length === 0) {
|
|
133
|
+
log("Every package name is on npm already. Name one to check its Trusted Publisher.");
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// A code npm rejects (they last about 30 seconds) makes npm ask for a new one.
|
|
138
|
+
/** @param {string[]} args */
|
|
139
|
+
const change = (args) => {
|
|
140
|
+
if (dryRun) {
|
|
141
|
+
log(`[dry run] npm ${args.join(" ")}`);
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
return npm(args, { otp: values.otp }).status === 0;
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
for (const pkg of packages) {
|
|
148
|
+
log(`\n${pkg.name} (projects/${pkg.dir}, ${pkg.version})`);
|
|
149
|
+
|
|
150
|
+
if (nameExists(pkg.name)) {
|
|
151
|
+
log(" name is on npm: skip the first publish");
|
|
152
|
+
} else if (!change(publishArgs(pkg, values.channel))) {
|
|
153
|
+
log(` first publish failed: stopping`);
|
|
154
|
+
return 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const listed = dryRun
|
|
158
|
+
? { status: 1, output: "" }
|
|
159
|
+
: npm(["trust", "list", pkg.name, "--json"], { capture: true, otp: values.otp });
|
|
160
|
+
if (listed.status === 0 && hasTrustedPublisher(listed.output)) {
|
|
161
|
+
log(" Trusted Publisher is set: skip");
|
|
162
|
+
} else if (!change(trustArgs(pkg.name))) {
|
|
163
|
+
log(" Trusted Publisher failed: stopping");
|
|
164
|
+
return 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
log(
|
|
169
|
+
"\nDone. Approve the waiting npm-oidc-bootstrap deployment on the Publish run, or rerun it: `gh run rerun --failed <run-id>`.",
|
|
170
|
+
);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const isCli =
|
|
175
|
+
process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
|
|
176
|
+
|
|
177
|
+
if (isCli) {
|
|
178
|
+
process.exit(await runBootstrap());
|
|
179
|
+
}
|
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
3
|
import { upsertIssue } from "../lib/github-issue.mjs";
|
|
4
|
+
import { needsPrereleaseTag, TRUSTED_PUBLISHER } from "./npm-bootstrap.mjs";
|
|
4
5
|
import { listPublishablePackages } from "./publish-verify.mjs";
|
|
5
6
|
|
|
7
|
+
export { needsPrereleaseTag } from "./npm-bootstrap.mjs";
|
|
8
|
+
|
|
6
9
|
const GITHUB_API = process.env.GITHUB_API_URL ?? "https://api.github.com";
|
|
7
10
|
const ISSUE_TITLE = "deploy(publish): OIDC bootstrap unpublished packages";
|
|
8
11
|
const BOOTSTRAP_ENV = "npm-oidc-bootstrap";
|
|
@@ -20,13 +23,6 @@ export function parseUnpublished(raw) {
|
|
|
20
23
|
.filter(Boolean);
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
/**
|
|
24
|
-
* @param {string | undefined} version
|
|
25
|
-
*/
|
|
26
|
-
export function needsPrereleaseTag(version) {
|
|
27
|
-
return String(version ?? "").includes("-");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
26
|
/**
|
|
31
27
|
* @param {{ name: string, dir: string, version?: string }[]} packages
|
|
32
28
|
* @param {string} [channel]
|
|
@@ -91,6 +87,14 @@ export function buildDescription(options) {
|
|
|
91
87
|
"",
|
|
92
88
|
"## What to do",
|
|
93
89
|
"",
|
|
90
|
+
"From a trusted machine, `npm login` to the org, then from the **repo root** (`--otp` is optional: npm asks for a code, or the browser login, whenever it needs one):",
|
|
91
|
+
"",
|
|
92
|
+
"```bash",
|
|
93
|
+
`pnpm npm:bootstrap --otp <code> ${names.join(" ")}`,
|
|
94
|
+
"```",
|
|
95
|
+
"",
|
|
96
|
+
"That does steps 1 and 2 below, skipping what is already done. To do them by hand instead:",
|
|
97
|
+
"",
|
|
94
98
|
"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:",
|
|
95
99
|
"",
|
|
96
100
|
"```bash",
|
|
@@ -101,8 +105,8 @@ export function buildDescription(options) {
|
|
|
101
105
|
"",
|
|
102
106
|
`- Organization: \`${owner ?? "typesmiths"}\``,
|
|
103
107
|
`- Repository: \`${repoName ?? "decohere"}\``,
|
|
104
|
-
|
|
105
|
-
|
|
108
|
+
`- Workflow filename: \`${TRUSTED_PUBLISHER.file}\``,
|
|
109
|
+
`- Environment: \`${TRUSTED_PUBLISHER.environment}\``,
|
|
106
110
|
"- Allowed action: `npm publish` (not staged-only)",
|
|
107
111
|
"",
|
|
108
112
|
"3. Approve the waiting **npm-oidc-bootstrap** deployment on this Publish run. The job re-checks that the names exist, then npm + Docker + tag continue. If reviewers were not configured and the job already failed:",
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { vol } from "memfs";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { runBootstrap } from "../../src/github/npm-bootstrap.mjs";
|
|
4
|
+
|
|
5
|
+
const root = "/workspace";
|
|
6
|
+
|
|
7
|
+
function seedWorkspace() {
|
|
8
|
+
vol.fromJSON({
|
|
9
|
+
[`${root}/package.json`]: JSON.stringify({ version: "0.0.1-dev.5" }),
|
|
10
|
+
[`${root}/projects/scribe/scribe/package.json`]: JSON.stringify({
|
|
11
|
+
name: "@decohere/scribe",
|
|
12
|
+
version: "0.0.1-dev.5",
|
|
13
|
+
}),
|
|
14
|
+
[`${root}/projects/scribe/scribe-console/package.json`]: JSON.stringify({
|
|
15
|
+
name: "@decohere/scribe-console",
|
|
16
|
+
version: "0.0.1-dev.5",
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A fake registry: `published` names exist, and `trusted` names have a Trusted
|
|
23
|
+
* Publisher. Each call is recorded as its arguments, plus the code npm would
|
|
24
|
+
* get through NPM_CONFIG_OTP.
|
|
25
|
+
*/
|
|
26
|
+
function fakeNpm({ published = [], trusted = [], loggedIn = true } = {}) {
|
|
27
|
+
const calls = [];
|
|
28
|
+
const npm = (args, { otp } = {}) => {
|
|
29
|
+
calls.push(`${args.join(" ")}${otp ? ` [otp ${otp}]` : ""}`);
|
|
30
|
+
if (args[0] === "whoami") {
|
|
31
|
+
return { status: loggedIn ? 0 : 1, output: "" };
|
|
32
|
+
}
|
|
33
|
+
if (args[0] === "trust" && args[1] === "list") {
|
|
34
|
+
return trusted.includes(args[2])
|
|
35
|
+
? { status: 0, output: '[{"repository":"typesmiths/decohere","file":"publish.yml"}]' }
|
|
36
|
+
: { status: 0, output: "[]" };
|
|
37
|
+
}
|
|
38
|
+
return { status: 0, output: "" };
|
|
39
|
+
};
|
|
40
|
+
return { npm, calls, nameExists: (name) => published.includes(name) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function run(argv, registry) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
return runBootstrap({
|
|
46
|
+
argv,
|
|
47
|
+
root,
|
|
48
|
+
io: {
|
|
49
|
+
npm: registry.npm,
|
|
50
|
+
nameExists: registry.nameExists,
|
|
51
|
+
log: (line) => lines.push(line),
|
|
52
|
+
},
|
|
53
|
+
}).then((code) => ({ code, lines }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
vol.reset();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("npm-bootstrap", () => {
|
|
61
|
+
it("first-publishes unpublished names restricted, then sets the Trusted Publisher", async () => {
|
|
62
|
+
seedWorkspace();
|
|
63
|
+
const registry = fakeNpm({ published: ["@decohere/scribe"] });
|
|
64
|
+
|
|
65
|
+
const { code } = await run(["--otp", "123456"], registry);
|
|
66
|
+
|
|
67
|
+
expect(code).toBe(0);
|
|
68
|
+
expect(registry.calls).toEqual([
|
|
69
|
+
"whoami",
|
|
70
|
+
"publish ./projects/scribe/scribe-console --tag dev [otp 123456]",
|
|
71
|
+
"trust list @decohere/scribe-console --json [otp 123456]",
|
|
72
|
+
"trust github @decohere/scribe-console --file publish.yml --repo typesmiths/decohere --env npm-publish --allow-publish --yes [otp 123456]",
|
|
73
|
+
]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips the steps that are already done", async () => {
|
|
77
|
+
seedWorkspace();
|
|
78
|
+
const registry = fakeNpm({
|
|
79
|
+
published: ["@decohere/scribe-console"],
|
|
80
|
+
trusted: ["@decohere/scribe-console"],
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { code } = await run(["@decohere/scribe-console"], registry);
|
|
84
|
+
|
|
85
|
+
expect(code).toBe(0);
|
|
86
|
+
expect(registry.calls).toEqual(["whoami", "trust list @decohere/scribe-console --json"]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
/** #700 — `npm trust` rejects `--otp`: the code goes through the environment. */
|
|
90
|
+
it("never passes the code as an argument, and lets npm ask when none is given", async () => {
|
|
91
|
+
seedWorkspace();
|
|
92
|
+
const withCode = fakeNpm({ published: ["@decohere/scribe"] });
|
|
93
|
+
await run(["--otp", "604381"], withCode);
|
|
94
|
+
expect(withCode.calls.join("\n")).not.toContain("--otp");
|
|
95
|
+
|
|
96
|
+
const withoutCode = fakeNpm({ published: ["@decohere/scribe"] });
|
|
97
|
+
expect((await run([], withoutCode)).code).toBe(0);
|
|
98
|
+
expect(withoutCode.calls.join("\n")).not.toContain("otp");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("prints the commands on a dry run, calling npm only to check the login", async () => {
|
|
102
|
+
seedWorkspace();
|
|
103
|
+
const registry = fakeNpm({ published: ["@decohere/scribe"] });
|
|
104
|
+
|
|
105
|
+
const { code, lines } = await run(["--dry-run"], registry);
|
|
106
|
+
|
|
107
|
+
expect(code).toBe(0);
|
|
108
|
+
expect(registry.calls).toEqual(["whoami"]);
|
|
109
|
+
expect(lines).toContain(
|
|
110
|
+
"[dry run] npm publish ./projects/scribe/scribe-console --tag dev",
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("stops when not logged in, or for a name that is not a package", async () => {
|
|
115
|
+
seedWorkspace();
|
|
116
|
+
const loggedOut = fakeNpm({ loggedIn: false });
|
|
117
|
+
expect((await run(["--otp", "1"], loggedOut)).code).toBe(1);
|
|
118
|
+
expect(loggedOut.calls).toEqual(["whoami"]);
|
|
119
|
+
|
|
120
|
+
const typo = fakeNpm();
|
|
121
|
+
const { code, lines } = await run(["@decohere/scribe-consol"], typo);
|
|
122
|
+
expect(code).toBe(1);
|
|
123
|
+
expect(lines).toEqual(["Not a publishable package in projects/: @decohere/scribe-consol"]);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -53,6 +53,7 @@ describe("oidc-bootstrap-nudge", () => {
|
|
|
53
53
|
"npm publish ./projects/scaffold",
|
|
54
54
|
]);
|
|
55
55
|
expect(body).toContain("npm publish ./projects/scaffold --tag dev");
|
|
56
|
+
expect(body).toContain("pnpm npm:bootstrap --otp <code> @decohere/scaffold");
|
|
56
57
|
expect(body).not.toMatch(/# @decohere\/scaffold/);
|
|
57
58
|
expect(body).toContain("do **not** pass `--access public`");
|
|
58
59
|
expect(firstPublishCommands([pkg], "dev").join("\n")).not.toContain("--access public");
|