@decohere/ci-tools 0.0.1-dev.20261105.76 → 0.0.1-dev.20261105.83

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.76",
3
+ "version": "0.0.1-dev.20261105.83",
4
4
  "description": "CI utility scripts for badge generation, coverage reporting, pages artifact assembly, and GitHub API tooling",
5
5
  "keywords": [
6
6
  "typescript",
@@ -43,7 +43,7 @@
43
43
  "./github/ensure-metadata": "./src/github/ensure-metadata.mjs",
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
- "./github/oidc-bootstrap-nudge": "./src/github/oidc-bootstrap-nudge.mjs",
46
+ "./github/first-publish-check": "./src/github/first-publish-check.mjs",
47
47
  "./github/npm-bootstrap": "./src/github/npm-bootstrap.mjs",
48
48
  "./github/smoke-create-launcher": "./src/github/smoke-create-launcher.mjs"
49
49
  },
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Finds packages in this workspace that aren't on npm yet. A first publish needs an owner's npm
4
+ * login, so CI can't do it. On a PR that's only a notice, so a contributor can propose a package
5
+ * with no npm access. In the merge queue it fails, because Publish could not upload that package.
6
+ */
7
+ import { resolve } from "node:path";
8
+ import { pathToFileURL } from "node:url";
9
+ import { listPublishablePackages } from "./publish-verify.mjs";
10
+
11
+ const REGISTRY = "https://registry.npmjs.org";
12
+
13
+ /**
14
+ * @param {{
15
+ * packages: string[],
16
+ * blocking: boolean,
17
+ * fetch: typeof globalThis.fetch,
18
+ * log?: (message: string) => void,
19
+ * }} options
20
+ * @returns {Promise<number>} exit code
21
+ */
22
+ export async function checkFirstPublish(options) {
23
+ const { packages, blocking, fetch } = options;
24
+ const log = options.log ?? console.log;
25
+
26
+ const results = await Promise.all(
27
+ packages.map(async (name) => {
28
+ // Not /-/package/<name>/dist-tags: for a name that does not exist it answers 401, not 404.
29
+ const url = `${REGISTRY}/${name.replace("/", "%2f")}`;
30
+ const response = await fetch(url, { signal: AbortSignal.timeout(30_000) });
31
+ // Anything else, like an outage, says nothing about whether the name exists.
32
+ if (!response.ok && response.status !== 404) {
33
+ throw new Error(`Could not check ${name} on npm: ${response.status}`);
34
+ }
35
+ return { name, missing: response.status === 404 };
36
+ }),
37
+ );
38
+
39
+ const missing = results.filter((result) => result.missing).map((result) => result.name);
40
+ if (missing.length === 0) {
41
+ log(`All ${packages.length} packages are on npm.`);
42
+ return 0;
43
+ }
44
+ const names = missing.join(" ");
45
+ if (blocking) {
46
+ log(
47
+ `::error::Not on npm yet: ${names}. An owner runs \`pnpm npm:bootstrap --otp <code> ${names}\`, ` +
48
+ "then adds this PR to the merge queue again.",
49
+ );
50
+ return 1;
51
+ }
52
+ log(
53
+ `::notice::New package, not on npm yet: ${names}. Contributors have nothing to do: a maintainer ` +
54
+ `runs \`pnpm npm:bootstrap --otp <code> ${names}\` before this merges.`,
55
+ );
56
+ return 0;
57
+ }
58
+
59
+ if (
60
+ process.argv[1] != null &&
61
+ import.meta.url === pathToFileURL(resolve(process.argv[1])).href
62
+ ) {
63
+ process.exit(
64
+ await checkFirstPublish({
65
+ packages: listPublishablePackages(process.cwd()).map((pkg) => pkg.name),
66
+ blocking: process.env.GITHUB_EVENT_NAME === "merge_group",
67
+ fetch: globalThis.fetch,
68
+ }),
69
+ );
70
+ }
@@ -169,7 +169,7 @@ export async function runBootstrap(options = {}) {
169
169
  "\nFor each new package, also run `pnpm npm:trust-promote --otp <code>`, so Promote to latest can move its tag.",
170
170
  );
171
171
  log(
172
- "\nDone. Approve the waiting npm-oidc-bootstrap deployment on the Publish run, or rerun it: `gh run rerun --failed <run-id>`.",
172
+ "\nDone. Add the PR to the merge queue. If Publish already failed on this, rerun it: `gh run rerun --failed <run-id>`.",
173
173
  );
174
174
  return 0;
175
175
  }
@@ -199,8 +199,6 @@ if (isCli) {
199
199
  appendFileSync(process.env.GITHUB_OUTPUT, `${body}\n`);
200
200
  }
201
201
  if (result.unpublished.length) {
202
- process.stderr.write(
203
- `Never-published packages (bootstrap OIDC before PUT): ${result.unpublished.join(", ")}\n`,
204
- );
202
+ process.stderr.write(`Not on npm yet: ${result.unpublished.join(", ")}\n`);
205
203
  }
206
204
  }
@@ -0,0 +1,51 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { checkFirstPublish } from "../../src/github/first-publish-check.mjs";
3
+
4
+ function fakeRegistry(statusByName) {
5
+ return async (url) => {
6
+ const name = decodeURIComponent(new URL(url).pathname.slice(1));
7
+ return new Response("{}", { status: statusByName[name] ?? 200 });
8
+ };
9
+ }
10
+
11
+ async function check(statusByName, blocking) {
12
+ const messages = [];
13
+ const code = await checkFirstPublish({
14
+ packages: ["@decohere/cli", "@decohere/shiny"],
15
+ blocking,
16
+ fetch: fakeRegistry(statusByName),
17
+ log: (message) => messages.push(message),
18
+ });
19
+ return { code, output: messages.join("\n") };
20
+ }
21
+
22
+ describe("checkFirstPublish", () => {
23
+ it("passes when every package is on npm", async () => {
24
+ const { code, output } = await check({}, true);
25
+
26
+ expect(code).toBe(0);
27
+ expect(output).toContain("All 2 packages are on npm");
28
+ });
29
+
30
+ it("only leaves a notice on a PR, so a contributor can propose a package", async () => {
31
+ const { code, output } = await check({ "@decohere/shiny": 404 }, false);
32
+
33
+ expect(code).toBe(0);
34
+ expect(output).toContain("::notice::");
35
+ expect(output).toContain("pnpm npm:bootstrap --otp <code> @decohere/shiny");
36
+ });
37
+
38
+ it("fails in the merge queue, naming the package and the command", async () => {
39
+ const { code, output } = await check({ "@decohere/shiny": 404 }, true);
40
+
41
+ expect(code).toBe(1);
42
+ expect(output).toContain("::error::");
43
+ expect(output).toContain("pnpm npm:bootstrap --otp <code> @decohere/shiny");
44
+ });
45
+
46
+ it("doesn't mistake an npm outage for a new package", async () => {
47
+ await expect(check({ "@decohere/shiny": 503 }, false)).rejects.toThrow(
48
+ "Could not check @decohere/shiny on npm: 503",
49
+ );
50
+ });
51
+ });
@@ -1,214 +0,0 @@
1
- import { resolve } from "node:path";
2
- import { pathToFileURL } from "node:url";
3
- import { upsertIssue } from "../lib/github-issue.mjs";
4
- import { needsPrereleaseTag, TRUSTED_PUBLISHER } from "./npm-bootstrap.mjs";
5
- import { listPublishablePackages } from "./publish-verify.mjs";
6
-
7
- export { needsPrereleaseTag } from "./npm-bootstrap.mjs";
8
-
9
- const GITHUB_API = process.env.GITHUB_API_URL ?? "https://api.github.com";
10
- const ISSUE_TITLE = "deploy(publish): OIDC bootstrap unpublished packages";
11
- const BOOTSTRAP_ENV = "npm-oidc-bootstrap";
12
- const DEFAULT_REPO = "typesmiths/decohere";
13
-
14
- export function issueTitle() {
15
- return ISSUE_TITLE;
16
- }
17
-
18
- /** @param {string | undefined} raw */
19
- export function parseUnpublished(raw) {
20
- return String(raw ?? "")
21
- .split(/\s+/)
22
- .map((name) => name.trim())
23
- .filter(Boolean);
24
- }
25
-
26
- /**
27
- * @param {{ name: string, dir: string, version?: string }[]} packages
28
- * @param {string} [channel]
29
- */
30
- export function firstPublishCommands(packages, channel = "dev") {
31
- if (packages.length === 0) {
32
- return [`npm publish ./<package-dir> --tag ${channel}`];
33
- }
34
- return packages.map((pkg) => {
35
- const tag = needsPrereleaseTag(pkg.version) ? ` --tag ${channel}` : "";
36
- return `npm publish ./projects/${pkg.dir} --access public${tag}`;
37
- });
38
- }
39
-
40
- /**
41
- * @param {unknown} environment
42
- * @returns {string[]}
43
- */
44
- export function extractGitHubReviewerLogins(environment) {
45
- const rules =
46
- environment && typeof environment === "object" ? environment.protection_rules : null;
47
- if (!Array.isArray(rules)) {
48
- return [];
49
- }
50
- const logins = [];
51
- for (const rule of rules) {
52
- const reviewers = rule?.reviewers;
53
- if (!Array.isArray(reviewers)) {
54
- continue;
55
- }
56
- for (const entry of reviewers) {
57
- const login = entry?.reviewer?.login;
58
- if (typeof login === "string" && login.length > 0 && entry?.type !== "Team") {
59
- logins.push(login);
60
- }
61
- }
62
- }
63
- return [...new Set(logins)];
64
- }
65
-
66
- /**
67
- * @param {{
68
- * unpublished: string[],
69
- * packages: { name: string, dir: string, version?: string }[],
70
- * repo?: string,
71
- * runUrl?: string,
72
- * channel?: string,
73
- * }} options
74
- */
75
- export function buildDescription(options) {
76
- const repo = options.repo || DEFAULT_REPO;
77
- const names = options.unpublished;
78
- const channel = options.channel || "dev";
79
- const packages = options.packages.filter((pkg) => names.includes(pkg.name));
80
- const [owner, repoName] = repo.split("/");
81
- return [
82
- "## Summary",
83
- "",
84
- "These package names are not on npm yet, so OIDC `npm publish` cannot run until each name exists and has a Trusted Publisher:",
85
- "",
86
- ...names.map((name) => `- \`${name}\``),
87
- "",
88
- "## What to do",
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
- "",
98
- "1. First-publish each name **public** (`--access public`: scoped names default to restricted). 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:",
99
- "",
100
- "```bash",
101
- ...firstPublishCommands(packages, channel),
102
- "```",
103
- "",
104
- "2. npm → package settings → Trusted Publisher → GitHub Actions:",
105
- "",
106
- `- Organization: \`${owner ?? "typesmiths"}\``,
107
- `- Repository: \`${repoName ?? "decohere"}\``,
108
- `- Workflow filename: \`${TRUSTED_PUBLISHER.file}\``,
109
- `- Environment: \`${TRUSTED_PUBLISHER.environment}\``,
110
- "- Allowed action: `npm publish` (not staged-only)",
111
- "",
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:",
113
- "",
114
- "```bash",
115
- `gh run list --repo ${repo} --workflow Publish --limit 5`,
116
- `gh run rerun --failed <run-id> --repo ${repo}`,
117
- "```",
118
- "",
119
- options.runUrl ? `- Workflow run: ${options.runUrl}` : null,
120
- ]
121
- .filter((line) => line != null)
122
- .join("\n");
123
- }
124
-
125
- function runUrl() {
126
- if (
127
- process.env.GITHUB_SERVER_URL &&
128
- process.env.GITHUB_REPOSITORY &&
129
- process.env.GITHUB_RUN_ID
130
- ) {
131
- return `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
132
- }
133
- return "";
134
- }
135
-
136
- /**
137
- * @param {{ fetch?: typeof fetch, token?: string, repo?: string, environment?: string }} options
138
- */
139
- export async function githubEnvironmentReviewerLogins(options = {}) {
140
- const token = options.token ?? process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? "";
141
- const repo = options.repo ?? process.env.GITHUB_REPOSITORY ?? "";
142
- const environment = options.environment ?? BOOTSTRAP_ENV;
143
- const fetchFn = options.fetch ?? fetch;
144
- if (!token || !repo) {
145
- return [];
146
- }
147
- const response = await fetchFn(
148
- `${GITHUB_API}/repos/${repo}/environments/${encodeURIComponent(environment)}`,
149
- {
150
- headers: {
151
- Authorization: `Bearer ${token}`,
152
- Accept: "application/vnd.github+json",
153
- "X-GitHub-Api-Version": "2022-11-28",
154
- },
155
- },
156
- );
157
- if (!response.ok) {
158
- process.stderr.write(
159
- `Could not list ${environment} reviewers (${response.status}); the issue will have no assignees\n`,
160
- );
161
- return [];
162
- }
163
- return extractGitHubReviewerLogins(await response.json());
164
- }
165
-
166
- export async function runNudge(env = process.env, io = {}) {
167
- const unpublished = parseUnpublished(env.UNPUBLISHED);
168
- if (unpublished.length === 0) {
169
- process.stdout.write("No unpublished package names; skipping OIDC bootstrap issue\n");
170
- return 0;
171
- }
172
-
173
- const root = io.root ?? process.cwd();
174
- const packages = (io.listPackages ?? listPublishablePackages)(root);
175
- const assignees = await (io.githubReviewers ?? githubEnvironmentReviewerLogins)({
176
- token: env.GITHUB_TOKEN ?? env.GH_TOKEN,
177
- repo: env.GITHUB_REPOSITORY,
178
- });
179
- const repo = env.GITHUB_REPOSITORY || DEFAULT_REPO;
180
-
181
- const result = (io.upsert ?? upsertIssue)({
182
- title: issueTitle(),
183
- body: buildDescription({
184
- unpublished,
185
- packages,
186
- repo,
187
- runUrl: io.runUrl ?? runUrl(),
188
- channel: env.PUBLISH_CHANNEL || env.CHANNEL || "dev",
189
- }),
190
- assignees,
191
- });
192
- process.stdout.write(
193
- `${result.created ? "Opened" : "Updated"} issue #${result.number} ${result.url}\n`,
194
- );
195
- if (assignees.length > 0) {
196
- process.stdout.write(`Assigned ${assignees.join(", ")}\n`);
197
- } else {
198
- process.stdout.write(
199
- `No assignees (add required reviewers on the ${BOOTSTRAP_ENV} environment)\n`,
200
- );
201
- }
202
- if (env.GITHUB_OUTPUT) {
203
- const { appendFileSync } = await import("node:fs");
204
- appendFileSync(env.GITHUB_OUTPUT, `issue_url=${result.url}\n`);
205
- }
206
- return 0;
207
- }
208
-
209
- const isCli =
210
- process.argv[1] != null && import.meta.url === pathToFileURL(resolve(process.argv[1])).href;
211
-
212
- if (isCli) {
213
- process.exit(await runNudge());
214
- }
@@ -1,89 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- buildDescription,
4
- extractGitHubReviewerLogins,
5
- firstPublishCommands,
6
- issueTitle,
7
- needsPrereleaseTag,
8
- parseUnpublished,
9
- runNudge,
10
- } from "../../src/github/oidc-bootstrap-nudge.mjs";
11
-
12
- describe("oidc-bootstrap-nudge", () => {
13
- it("parses unpublished names", () => {
14
- expect(parseUnpublished(" @decohere/scaffold @decohere/cli ")).toEqual([
15
- "@decohere/scaffold",
16
- "@decohere/cli",
17
- ]);
18
- expect(issueTitle()).toBe("deploy(publish): OIDC bootstrap unpublished packages");
19
- });
20
-
21
- it("extracts GitHub user reviewers and skips teams", () => {
22
- expect(
23
- extractGitHubReviewerLogins({
24
- protection_rules: [
25
- { type: "branch_policy", reviewers: [] },
26
- {
27
- type: "required_reviewers",
28
- reviewers: [
29
- { type: "User", reviewer: { login: "das974" } },
30
- { type: "Team", reviewer: { slug: "publishers", login: "should-skip" } },
31
- ],
32
- },
33
- ],
34
- }),
35
- ).toEqual(["das974"]);
36
- });
37
-
38
- it("puts first-publish commands and Trusted Publisher fields in the task body", () => {
39
- const pkg = { name: "@decohere/scaffold", dir: "scaffold", version: "0.0.1-dev.18" };
40
- const body = buildDescription({
41
- unpublished: ["@decohere/scaffold"],
42
- packages: [pkg],
43
- repo: "typesmiths/decohere",
44
- runUrl: "https://github.com/typesmiths/decohere/actions/runs/9",
45
- channel: "dev",
46
- });
47
- expect(needsPrereleaseTag("0.0.1-dev.18")).toBe(true);
48
- expect(needsPrereleaseTag("1.2.3")).toBe(false);
49
- expect(firstPublishCommands([pkg], "dev")).toEqual([
50
- "npm publish ./projects/scaffold --access public --tag dev",
51
- ]);
52
- expect(firstPublishCommands([{ ...pkg, version: "1.0.0" }], "dev")).toEqual([
53
- "npm publish ./projects/scaffold --access public",
54
- ]);
55
- expect(body).toContain("npm publish ./projects/scaffold --access public --tag dev");
56
- expect(body).toContain("pnpm npm:bootstrap --otp <code> @decohere/scaffold");
57
- expect(body).not.toMatch(/# @decohere\/scaffold/);
58
- expect(body).toContain("First-publish each name **public**");
59
- expect(body).toContain("Workflow filename: `publish.yml`");
60
- expect(body).toContain("Environment: `npm-publish`");
61
- expect(body).toContain("gh run rerun --failed");
62
- expect(body).toContain("https://github.com/typesmiths/decohere/actions/runs/9");
63
- });
64
-
65
- it("assigns the bootstrap environment's reviewers to the issue", async () => {
66
- /** @type {object[]} */
67
- const upserts = [];
68
- const code = await runNudge(
69
- { UNPUBLISHED: "@decohere/scaffold", GITHUB_REPOSITORY: "typesmiths/decohere" },
70
- {
71
- listPackages: () => [{ name: "@decohere/scaffold", dir: "scaffold" }],
72
- githubReviewers: async () => ["das974"],
73
- upsert: (options) => {
74
- upserts.push(options);
75
- return {
76
- number: 5,
77
- url: "https://github.com/typesmiths/decohere/issues/5",
78
- created: true,
79
- };
80
- },
81
- runUrl: "https://github.com/typesmiths/decohere/actions/runs/1",
82
- },
83
- );
84
- expect(code).toBe(0);
85
- expect(upserts[0].title).toBe(issueTitle());
86
- expect(upserts[0].assignees).toEqual(["das974"]);
87
- expect(upserts[0].body).toContain("`@decohere/scaffold`");
88
- });
89
- });