@decohere/ci-tools 0.0.1-dev.20261105.64 → 0.0.1-dev.20261105.67
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.
|
|
3
|
+
"version": "0.0.1-dev.20261105.67",
|
|
4
4
|
"description": "CI utility scripts for badge generation, coverage reporting, pages artifact assembly, and GitHub API tooling",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"typescript",
|
|
@@ -165,6 +165,9 @@ export async function runBootstrap(options = {}) {
|
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
log(
|
|
169
|
+
"\nFor each new package, also run `pnpm npm:trust-promote --otp <code>`, so Promote to latest can move its tag.",
|
|
170
|
+
);
|
|
168
171
|
log(
|
|
169
172
|
"\nDone. Approve the waiting npm-oidc-bootstrap deployment on the Publish run, or rerun it: `gh run rerun --failed <run-id>`.",
|
|
170
173
|
);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gives every public @decohere package the Trusted Publisher `promote-latest.yml` needs, by copying
|
|
4
|
+
* it from one package an owner set up on npmjs.com. `npm trust` has no flag for the "Allow npm
|
|
5
|
+
* dist-tag" permission (`manageDistTags` in the registry), so this calls the same registry endpoint
|
|
6
|
+
* it uses. npm doesn't document that endpoint. It only adds entries, and packages that already have
|
|
7
|
+
* one are skipped.
|
|
8
|
+
*
|
|
9
|
+
* Reads the owner's npm login from NPM_TOKEN, and never prints it.
|
|
10
|
+
*
|
|
11
|
+
* NPM_TOKEN="$(sed -n 's#^//registry.npmjs.org/:_authToken=##p' ~/.npmrc)" \
|
|
12
|
+
* pnpm npm:trust-promote --otp <code> [--from @decohere/cli]
|
|
13
|
+
*/
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { pathToFileURL } from "node:url";
|
|
16
|
+
import { parseArgs } from "node:util";
|
|
17
|
+
import { listPublishablePackages } from "./publish-verify.mjs";
|
|
18
|
+
|
|
19
|
+
export const PROMOTE_WORKFLOW = "promote-latest.yml";
|
|
20
|
+
const REGISTRY = "https://registry.npmjs.org";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {{ type?: string, claims?: { workflow_ref?: { file?: string } }, permissions?: string[] }} TrustEntry
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {TrustEntry[]} entries
|
|
28
|
+
* @returns {TrustEntry | undefined}
|
|
29
|
+
*/
|
|
30
|
+
function findPromoteEntry(entries) {
|
|
31
|
+
return entries.find((entry) => entry.claims?.workflow_ref?.file === PROMOTE_WORKFLOW);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {{
|
|
36
|
+
* packages: string[],
|
|
37
|
+
* from: string,
|
|
38
|
+
* token: string,
|
|
39
|
+
* otp: string,
|
|
40
|
+
* fetch: typeof globalThis.fetch,
|
|
41
|
+
* log?: (message: string) => void,
|
|
42
|
+
* }} options
|
|
43
|
+
* @returns {Promise<number>} exit code
|
|
44
|
+
*/
|
|
45
|
+
export async function copyPromoteTrust(options) {
|
|
46
|
+
const { packages, from, token, otp, fetch } = options;
|
|
47
|
+
const log = options.log ?? console.log;
|
|
48
|
+
const headers = {
|
|
49
|
+
authorization: `Bearer ${token}`,
|
|
50
|
+
"npm-otp": otp,
|
|
51
|
+
"content-type": "application/json",
|
|
52
|
+
};
|
|
53
|
+
const trustUrl = (name) => `${REGISTRY}/-/package/${name.replace("/", "%2f")}/trust`;
|
|
54
|
+
|
|
55
|
+
const list = async (name) => {
|
|
56
|
+
const response = await fetch(trustUrl(name), { headers });
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
throw new Error(`reading ${name}: ${response.status} ${await response.text()}`);
|
|
59
|
+
}
|
|
60
|
+
return /** @type {TrustEntry[]} */ (await response.json());
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
let source;
|
|
64
|
+
try {
|
|
65
|
+
source = findPromoteEntry(await list(from));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
log(`Nothing changed. ${String(error)}`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
if (source == null) {
|
|
71
|
+
log(
|
|
72
|
+
`${from} has no Trusted Publisher for ${PROMOTE_WORKFLOW}. On npmjs.com, in its settings, add ` +
|
|
73
|
+
`one: GitHub Actions, organization typesmiths, repository decohere, workflow ${PROMOTE_WORKFLOW}, ` +
|
|
74
|
+
"no environment, Allow npm publish off, Allow npm dist-tag on. Then run this again. " +
|
|
75
|
+
"If you set it up on another package, pass that one with --from. Nothing changed.",
|
|
76
|
+
);
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
log(`Copying from ${from}, with permissions: ${(source.permissions ?? []).join(", ")}`);
|
|
80
|
+
|
|
81
|
+
const entry = { type: source.type, claims: source.claims, permissions: source.permissions };
|
|
82
|
+
const others = packages.filter((name) => name !== from);
|
|
83
|
+
const failures = [];
|
|
84
|
+
// All at once, so one OTP code covers them before it expires.
|
|
85
|
+
await Promise.all(
|
|
86
|
+
others.map(async (name) => {
|
|
87
|
+
try {
|
|
88
|
+
if (findPromoteEntry(await list(name)) != null) {
|
|
89
|
+
log(`already set: ${name}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
const response = await fetch(trustUrl(name), {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers,
|
|
95
|
+
body: JSON.stringify([entry]),
|
|
96
|
+
});
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
failures.push(`${name}: ${response.status} ${await response.text()}`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
log(`set: ${name}`);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
failures.push(`${name}: ${String(error)}`);
|
|
104
|
+
}
|
|
105
|
+
}),
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (failures.length > 0) {
|
|
109
|
+
log(`Failed:\n${failures.join("\n")}\nRun it again with a new code to finish.`);
|
|
110
|
+
return 1;
|
|
111
|
+
}
|
|
112
|
+
log(`Done: ${packages.length} packages have a Trusted Publisher for ${PROMOTE_WORKFLOW}.`);
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
117
|
+
const { values } = parseArgs({
|
|
118
|
+
options: {
|
|
119
|
+
otp: { type: "string" },
|
|
120
|
+
from: { type: "string", default: "@decohere/cli" },
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
const token = process.env.NPM_TOKEN;
|
|
124
|
+
if (!token || !values.otp) {
|
|
125
|
+
console.error(
|
|
126
|
+
`Usage: NPM_TOKEN="$(sed -n 's#^//registry.npmjs.org/:_authToken=##p' ~/.npmrc)" pnpm npm:trust-promote --otp <code>`,
|
|
127
|
+
);
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
process.exit(
|
|
131
|
+
await copyPromoteTrust({
|
|
132
|
+
packages: listPublishablePackages(resolve(".")).map((pkg) => pkg.name),
|
|
133
|
+
from: values.from,
|
|
134
|
+
token,
|
|
135
|
+
otp: values.otp,
|
|
136
|
+
fetch: globalThis.fetch,
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Points npm's `latest` tag at a version on every public @decohere package. Run by
|
|
4
|
+
* `promote-latest.yml`, which authenticates over OIDC.
|
|
5
|
+
*
|
|
6
|
+
* Refuses a version that isn't on every package, has no `v*` tag (a version only gets one once it
|
|
7
|
+
* passed CI and fully shipped), or is under a day old, unless `allowYoung` is set: current pnpm
|
|
8
|
+
* skips versions that new and would install an older one. Safe to re-run: packages already there
|
|
9
|
+
* are skipped.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "node:child_process";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
|
+
import { listPublishablePackages } from "../github/publish-verify.mjs";
|
|
15
|
+
|
|
16
|
+
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
const LAUNCH_PACKAGE = "@decohere/cli";
|
|
18
|
+
|
|
19
|
+
/** @typedef {{ status: number | null, stdout: string }} RunResult */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{
|
|
23
|
+
* version?: string,
|
|
24
|
+
* allowYoung?: boolean,
|
|
25
|
+
* packages: string[],
|
|
26
|
+
* run: (command: string, args: string[]) => RunResult,
|
|
27
|
+
* now?: () => number,
|
|
28
|
+
* log?: (message: string) => void,
|
|
29
|
+
* }} options
|
|
30
|
+
* @returns {number} exit code
|
|
31
|
+
*/
|
|
32
|
+
export function promoteLatest(options) {
|
|
33
|
+
const { packages, run, allowYoung = false } = options;
|
|
34
|
+
const now = options.now ?? Date.now;
|
|
35
|
+
const log = options.log ?? console.log;
|
|
36
|
+
const npm = (...args) => run("npm", args);
|
|
37
|
+
|
|
38
|
+
const version =
|
|
39
|
+
options.version?.trim() || npm("view", LAUNCH_PACKAGE, "dist-tags.dev").stdout.trim();
|
|
40
|
+
if (!version) {
|
|
41
|
+
log(`Could not read the dev tag of ${LAUNCH_PACKAGE}. Nothing moved.`);
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
log(`Promoting ${version} to latest on ${packages.length} packages`);
|
|
45
|
+
|
|
46
|
+
const tag = run("git", ["tag", "--list", `v${version}`]).stdout.trim();
|
|
47
|
+
if (tag !== `v${version}`) {
|
|
48
|
+
log(`${version} has no v${version} tag, so it didn't fully ship. Nothing moved.`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const missing = packages.filter(
|
|
53
|
+
(name) => npm("view", `${name}@${version}`, "version").stdout.trim() !== version,
|
|
54
|
+
);
|
|
55
|
+
if (missing.length > 0) {
|
|
56
|
+
log(`Not on npm at ${version}: ${missing.join(", ")}. Nothing moved.`);
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (!allowYoung) {
|
|
61
|
+
const published = Date.parse(
|
|
62
|
+
npm("view", `${LAUNCH_PACKAGE}@${version}`, "time", "--json").stdout.match(
|
|
63
|
+
new RegExp(`"${version.replaceAll(".", "\\.")}":\\s*"([^"]+)"`),
|
|
64
|
+
)?.[1] ?? "",
|
|
65
|
+
);
|
|
66
|
+
if (Number.isNaN(published)) {
|
|
67
|
+
log(`Could not read when ${version} was published. Nothing moved.`);
|
|
68
|
+
return 1;
|
|
69
|
+
}
|
|
70
|
+
const hours = (now() - published) / (60 * 60 * 1000);
|
|
71
|
+
if (now() - published < ONE_DAY_MS) {
|
|
72
|
+
log(
|
|
73
|
+
`${version} is ${hours.toFixed(1)} hours old. Current pnpm skips versions under a day old ` +
|
|
74
|
+
"and would install an older one. Wait, or run with allow_young. Nothing moved.",
|
|
75
|
+
);
|
|
76
|
+
return 1;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** `npm dist-tag ls` reads fresh; `npm view` can lag for a minute or so after a move. */
|
|
81
|
+
const onLatest = (name) => {
|
|
82
|
+
const line = npm("dist-tag", "ls", name)
|
|
83
|
+
.stdout.split("\n")
|
|
84
|
+
.find((entry) => entry.startsWith("latest:"));
|
|
85
|
+
return line?.slice("latest:".length).trim() === version;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const failed = [];
|
|
89
|
+
for (const name of packages) {
|
|
90
|
+
if (onLatest(name)) {
|
|
91
|
+
log(`already there: ${name}`);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (npm("dist-tag", "add", `${name}@${version}`, "latest").status !== 0) failed.push(name);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const wrong = packages.filter((name) => !failed.includes(name) && !onLatest(name));
|
|
98
|
+
if (failed.length > 0 || wrong.length > 0) {
|
|
99
|
+
log(`Failed to move: ${[...failed, ...wrong].join(", ")}. Run it again to finish.`);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
log(`latest is now ${version} on all ${packages.length} packages`);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
107
|
+
const packages = listPublishablePackages(resolve(".")).map((pkg) => pkg.name);
|
|
108
|
+
process.exit(
|
|
109
|
+
promoteLatest({
|
|
110
|
+
version: process.env.PROMOTE_VERSION,
|
|
111
|
+
allowYoung: process.env.PROMOTE_ALLOW_YOUNG === "true",
|
|
112
|
+
packages,
|
|
113
|
+
run: (command, args) => {
|
|
114
|
+
const result = spawnSync(command, args, {
|
|
115
|
+
encoding: "utf8",
|
|
116
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
117
|
+
});
|
|
118
|
+
return { status: result.status, stdout: result.stdout ?? "" };
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
}
|