Skip to content

Commit 71ca989

Browse files
authored
Merge pull request #463 from preactjs/ci/staged-npm-publish
ci: stage npm releases before publishing
2 parents f8ff11e + cb25e94 commit 71ca989

2 files changed

Lines changed: 156 additions & 5 deletions

File tree

.github/scripts/stage-package.mjs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import { appendFileSync } from 'node:fs';
2+
import { readFile } from 'node:fs/promises';
3+
import { spawnSync } from 'node:child_process';
4+
5+
async function hasPublishedVersion(pkg) {
6+
const response = await fetch(
7+
`https://registry.npmjs.org/${encodeURIComponent(pkg.name)}`,
8+
{ headers: { accept: 'application/vnd.npm.install-v1+json' } }
9+
);
10+
11+
if (response.status === 404) {
12+
return false;
13+
}
14+
15+
if (!response.ok) {
16+
throw new Error(
17+
`Failed to query ${pkg.name}: ${response.status} ${response.statusText}`
18+
);
19+
}
20+
21+
const metadata = await response.json();
22+
return Object.prototype.hasOwnProperty.call(
23+
metadata.versions ?? {},
24+
pkg.version
25+
);
26+
}
27+
28+
function getDistTag(version) {
29+
const prerelease = version.match(/-([0-9A-Za-z-]+)(?:\.|$)/);
30+
return prerelease ? prerelease[1] : 'latest';
31+
}
32+
33+
function collectStageIds(value, ids = new Set()) {
34+
if (!value || typeof value !== 'object') return ids;
35+
36+
for (const [key, child] of Object.entries(value)) {
37+
if (/stage[-_]?id/i.test(key) && typeof child === 'string') {
38+
ids.add(child);
39+
} else {
40+
collectStageIds(child, ids);
41+
}
42+
}
43+
44+
return ids;
45+
}
46+
47+
function runGit(args) {
48+
const result = spawnSync('git', args, {
49+
encoding: 'utf8',
50+
stdio: ['ignore', 'pipe', 'pipe']
51+
});
52+
53+
if (result.stdout) process.stdout.write(result.stdout);
54+
if (result.stderr) process.stderr.write(result.stderr);
55+
if (result.status !== 0) process.exit(result.status ?? 1);
56+
}
57+
58+
function hasLocalGitTag(tagName) {
59+
const result = spawnSync(
60+
'git',
61+
['rev-parse', '--verify', '--quiet', `refs/tags/${tagName}`],
62+
{ stdio: 'ignore' }
63+
);
64+
return result.status === 0;
65+
}
66+
67+
function createGitTag(tagName) {
68+
if (hasLocalGitTag(tagName)) {
69+
console.log(`Git tag ${tagName} already exists locally.`);
70+
} else {
71+
runGit(['tag', tagName, '-m', tagName]);
72+
}
73+
74+
// changesets/action parses this line, then pushes the tag and creates the GitHub release.
75+
console.log(`New tag: ${tagName}`);
76+
}
77+
78+
async function main() {
79+
const pkg = JSON.parse(await readFile('package.json', 'utf8'));
80+
const changesetConfig = JSON.parse(
81+
await readFile('.changeset/config.json', 'utf8')
82+
);
83+
84+
if (pkg.private) {
85+
console.log(`${pkg.name}@${pkg.version} is private; skipping staging`);
86+
return;
87+
}
88+
89+
if (await hasPublishedVersion(pkg)) {
90+
console.log(
91+
`${pkg.name}@${pkg.version} is already published; skipping staging`
92+
);
93+
return;
94+
}
95+
96+
const access =
97+
pkg.publishConfig?.access ?? changesetConfig.access ?? 'public';
98+
const tag = getDistTag(pkg.version);
99+
const args = [
100+
'stage',
101+
'publish',
102+
'.',
103+
'--access',
104+
access,
105+
'--tag',
106+
tag,
107+
'--json'
108+
];
109+
110+
console.log(`Staging ${pkg.name}@${pkg.version} with dist-tag ${tag}`);
111+
const result = spawnSync('npm', args, {
112+
encoding: 'utf8',
113+
stdio: ['ignore', 'pipe', 'pipe']
114+
});
115+
116+
if (result.stdout) process.stdout.write(result.stdout);
117+
if (result.stderr) process.stderr.write(result.stderr);
118+
119+
if (result.status !== 0) {
120+
process.exit(result.status ?? 1);
121+
}
122+
123+
createGitTag(`v${pkg.version}`);
124+
125+
let stageIds = [];
126+
try {
127+
stageIds = [...collectStageIds(JSON.parse(result.stdout))];
128+
} catch {
129+
// Keep the raw npm output above as the source of truth if the JSON shape changes.
130+
}
131+
132+
if (stageIds.length > 0) {
133+
const message = [
134+
`Staged ${pkg.name}@${pkg.version}.`,
135+
...stageIds.map((id) => `Stage ID: ${id}`),
136+
'Approve with `npm stage approve <stage-id>` after reviewing the staged package.'
137+
].join('\n');
138+
139+
console.log(message);
140+
141+
if (process.env.GITHUB_STEP_SUMMARY) {
142+
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${message}\n`);
143+
}
144+
}
145+
}
146+
147+
main().catch((error) => {
148+
console.error(error);
149+
process.exit(1);
150+
});

.github/workflows/release.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ jobs:
3434
registry-url: "https://registry.npmjs.org"
3535

3636
- name: Update npm
37-
run: npm install -g npm@11.11.1
37+
run: npm install -g npm@^11.15.0
3838

3939
- name: Install dependencies
4040
run: npm ci --ignore-scripts
@@ -54,7 +54,7 @@ jobs:
5454
run: node .github/scripts/has-unpublished-packages.mjs
5555

5656
publish:
57-
name: Publish
57+
name: Stage package
5858
needs: release
5959
if: needs.release.outputs.should_publish == 'true'
6060
environment:
@@ -78,18 +78,19 @@ jobs:
7878
registry-url: "https://registry.npmjs.org"
7979

8080
- name: Update npm
81-
run: npm install -g npm@11.11.1
81+
run: npm install -g npm@^11.15.0
8282

8383
- name: Install dependencies
8484
run: npm ci --ignore-scripts
8585

8686
- name: Build package
8787
run: npm run build
8888

89-
- name: Publish packages
89+
- name: Stage package
9090
uses: changesets/action@e0145edc7d9d8679003495b11f87bd8ef63c0cba # v1.5.3
9191
with:
92-
publish: npm exec -- changeset publish
92+
publish: node .github/scripts/stage-package.mjs
9393
commitMode: github-api
94+
createGithubReleases: true
9495
env:
9596
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

0 commit comments

Comments
 (0)