Skip to content

Commit 9fed420

Browse files
committed
ci: use npm staged publishing
1 parent 676ed9d commit 9fed420

2 files changed

Lines changed: 126 additions & 3 deletions

File tree

.github/scripts/stage-packages.mjs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
#!/usr/bin/env node
2+
import { spawnSync } from "node:child_process";
3+
import { existsSync, readFileSync, readdirSync } from "node:fs";
4+
import { dirname, join, relative } from "node:path";
5+
6+
const root = process.cwd();
7+
const config = JSON.parse(readFileSync(join(root, ".changeset/config.json"), "utf8"));
8+
const ignored = new Set(config.ignore || []);
9+
const access = config.access || "public";
10+
11+
function readJson(path) {
12+
return JSON.parse(readFileSync(path, "utf8"));
13+
}
14+
15+
function packageJsonPathsFromPnpmWorkspace() {
16+
const workspace = join(root, "pnpm-workspace.yaml");
17+
if (!existsSync(workspace)) return [];
18+
19+
const patterns = [];
20+
for (const rawLine of readFileSync(workspace, "utf8").split(/\r?\n/)) {
21+
const line = rawLine.trim();
22+
if (!line.startsWith("- ")) continue;
23+
const pattern = line.slice(2).replace(/^['\"]|['\"]$/g, "");
24+
if (!pattern || pattern.startsWith("!")) continue;
25+
patterns.push(pattern);
26+
}
27+
28+
const paths = [];
29+
for (const pattern of patterns) {
30+
if (pattern.endsWith("/*")) {
31+
const dir = join(root, pattern.slice(0, -2));
32+
if (!existsSync(dir)) continue;
33+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
34+
if (entry.isDirectory()) paths.push(join(dir, entry.name, "package.json"));
35+
}
36+
} else if (pattern.endsWith("/**")) {
37+
// These workspaces are docs/examples and not published packages in this release flow.
38+
continue;
39+
} else {
40+
paths.push(join(root, pattern, "package.json"));
41+
}
42+
}
43+
44+
return paths;
45+
}
46+
47+
function packageJsonPaths() {
48+
const paths = new Set(packageJsonPathsFromPnpmWorkspace());
49+
paths.add(join(root, "package.json"));
50+
return [...paths].filter(existsSync);
51+
}
52+
53+
function versionExists(name, version) {
54+
const result = spawnSync("npm", ["view", `${name}@${version}`, "version", "--json"], {
55+
encoding: "utf8",
56+
stdio: ["ignore", "pipe", "pipe"],
57+
});
58+
59+
if (result.status === 0) return true;
60+
const output = `${result.stdout}\n${result.stderr}`;
61+
if (output.includes("E404") || output.includes("No match found")) return false;
62+
63+
process.stdout.write(result.stdout);
64+
process.stderr.write(result.stderr);
65+
throw new Error(`Could not check npm version for ${name}@${version}`);
66+
}
67+
68+
function distTag(version) {
69+
const prerelease = version.match(/^[^-]+-([0-9A-Za-z-]+)/);
70+
return prerelease ? prerelease[1] : "latest";
71+
}
72+
73+
const staged = [];
74+
for (const packageJsonPath of packageJsonPaths()) {
75+
const pkg = readJson(packageJsonPath);
76+
if (!pkg.name || !pkg.version || pkg.private || ignored.has(pkg.name)) continue;
77+
if (versionExists(pkg.name, pkg.version)) {
78+
console.log(`Skipping ${pkg.name}@${pkg.version}; already published.`);
79+
continue;
80+
}
81+
82+
const packageDir = dirname(packageJsonPath);
83+
const tag = distTag(pkg.version);
84+
const args = [
85+
"stage",
86+
"publish",
87+
packageDir,
88+
"--provenance",
89+
"--access",
90+
pkg.publishConfig?.access || access,
91+
"--tag",
92+
tag,
93+
"--json",
94+
];
95+
96+
console.log(`Staging ${pkg.name}@${pkg.version} with dist-tag ${tag}...`);
97+
const result = spawnSync("npm", args, {
98+
cwd: root,
99+
encoding: "utf8",
100+
stdio: ["ignore", "pipe", "pipe"],
101+
});
102+
process.stdout.write(result.stdout);
103+
process.stderr.write(result.stderr);
104+
if (result.status !== 0) process.exit(result.status || 1);
105+
106+
const stageId = result.stdout.match(/"stageId"\s*:\s*"([^"]+)"/)?.[1];
107+
staged.push({
108+
name: pkg.name,
109+
version: pkg.version,
110+
path: relative(root, packageDir) || ".",
111+
stageId,
112+
});
113+
}
114+
115+
if (staged.length === 0) {
116+
console.log("No unpublished packages to stage.");
117+
} else {
118+
console.log("Staged packages:");
119+
for (const pkg of staged) {
120+
console.log(`- ${pkg.name}@${pkg.version}${pkg.stageId ? ` (${pkg.stageId})` : ""}`);
121+
}
122+
console.log("Approve staged packages with `npm stage approve <stage-id>` after review.");
123+
}

.github/workflows/release.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
registry-url: "https://registry.npmjs.org"
3333

3434
- name: Update npm
35-
run: npm install -g npm@11.11.1
35+
run: npm install -g npm@11.15.0
3636

3737
- name: Install dependencies
3838
run: npm ci --ignore-scripts
@@ -76,7 +76,7 @@ jobs:
7676
registry-url: "https://registry.npmjs.org"
7777

7878
- name: Update npm
79-
run: npm install -g npm@11.11.1
79+
run: npm install -g npm@11.15.0
8080

8181
- name: Install dependencies
8282
run: npm ci --ignore-scripts
@@ -87,7 +87,7 @@ jobs:
8787
- name: Publish packages
8888
uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba # v1.5.3
8989
with:
90-
publish: npm exec -- changeset publish
90+
publish: node .github/scripts/stage-packages.mjs
9191
commitMode: github-api
9292
env:
9393
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)