Skip to content

Commit 6b0bc37

Browse files
committed
feat: automate prebuilt npm releases
1 parent e3017d0 commit 6b0bc37

10 files changed

Lines changed: 573 additions & 98 deletions
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
name: Release prebuilt npm packages
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
publish:
7+
description: Publish the staged prebuilt packages to npm
8+
required: true
9+
default: false
10+
type: boolean
11+
push:
12+
tags:
13+
- "v*"
14+
15+
concurrency:
16+
group: release-prebuilt-${{ github.ref }}
17+
cancel-in-progress: false
18+
19+
jobs:
20+
build-binaries:
21+
name: Build ${{ matrix.package_name }}
22+
runs-on: ${{ matrix.runner }}
23+
strategy:
24+
fail-fast: false
25+
matrix:
26+
include:
27+
- package_name: hunkdiff-linux-x64
28+
runner: ubuntu-latest
29+
- package_name: hunkdiff-darwin-x64
30+
runner: macos-13
31+
- package_name: hunkdiff-darwin-arm64
32+
runner: macos-14
33+
steps:
34+
- name: Check out repository
35+
uses: actions/checkout@v4
36+
37+
- name: Set up Bun
38+
uses: oven-sh/setup-bun@v2
39+
with:
40+
bun-version: 1.3.10
41+
42+
- name: Install dependencies
43+
run: bun install --frozen-lockfile
44+
45+
- name: Build host artifact
46+
run: |
47+
bun run build:bin
48+
bun run ./scripts/build-prebuilt-artifact.ts --expect-package "${{ matrix.package_name }}"
49+
50+
- name: Upload binary artifact
51+
uses: actions/upload-artifact@v4
52+
with:
53+
name: ${{ matrix.package_name }}
54+
path: dist/release/artifacts/${{ matrix.package_name }}
55+
if-no-files-found: error
56+
57+
stage-release:
58+
name: Stage prebuilt npm release
59+
runs-on: ubuntu-latest
60+
needs:
61+
- build-binaries
62+
steps:
63+
- name: Check out repository
64+
uses: actions/checkout@v4
65+
66+
- name: Set up Bun
67+
uses: oven-sh/setup-bun@v2
68+
with:
69+
bun-version: 1.3.10
70+
71+
- name: Set up Node
72+
uses: actions/setup-node@v4
73+
with:
74+
node-version: 22
75+
76+
- name: Install dependencies
77+
run: bun install --frozen-lockfile
78+
79+
- name: Verify tag matches package version
80+
if: github.event_name == 'push'
81+
run: bun run ./scripts/check-release-version.ts "${{ github.ref_name }}"
82+
83+
- name: Download platform artifacts
84+
uses: actions/download-artifact@v4
85+
with:
86+
path: dist/release/artifacts
87+
88+
- name: Show downloaded artifacts
89+
run: find dist/release/artifacts -maxdepth 3 -type f | sort
90+
91+
- name: Stage npm release directories
92+
run: bun run stage:prebuilt:release
93+
94+
- name: Verify staged packages
95+
run: bun run check:prebuilt-pack
96+
97+
- name: Dry-run npm publish order
98+
run: bun run publish:prebuilt:npm -- --dry-run
99+
100+
- name: Upload staged npm release
101+
uses: actions/upload-artifact@v4
102+
with:
103+
name: staged-prebuilt-npm-release
104+
path: dist/release/npm
105+
if-no-files-found: error
106+
107+
publish:
108+
name: Publish prebuilt npm release
109+
runs-on: ubuntu-latest
110+
needs:
111+
- stage-release
112+
if: github.event_name == 'push' || inputs.publish == true
113+
environment: npm
114+
steps:
115+
- name: Check out repository
116+
uses: actions/checkout@v4
117+
118+
- name: Set up Bun
119+
uses: oven-sh/setup-bun@v2
120+
with:
121+
bun-version: 1.3.10
122+
123+
- name: Set up Node
124+
uses: actions/setup-node@v4
125+
with:
126+
node-version: 22
127+
registry-url: https://registry.npmjs.org
128+
129+
- name: Install dependencies
130+
run: bun install --frozen-lockfile
131+
132+
- name: Download staged npm release
133+
uses: actions/download-artifact@v4
134+
with:
135+
name: staged-prebuilt-npm-release
136+
path: dist/release/npm
137+
138+
- name: Show staged packages
139+
run: find dist/release/npm -maxdepth 3 -type f | sort
140+
141+
- name: Verify npm auth
142+
env:
143+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
144+
run: npm whoami
145+
146+
- name: Publish packages
147+
env:
148+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
149+
run: bun run publish:prebuilt:npm

README.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,14 +242,25 @@ bun run build:npm
242242
bun run check:pack
243243
```
244244

245-
Stage the prototype prebuilt npm packages for the current host and smoke test the install path without Bun on `PATH`:
245+
Stage the prebuilt npm packages for the current host and smoke test the install path without Bun on `PATH`:
246246

247247
```bash
248248
bun run build:prebuilt:npm
249249
bun run check:prebuilt-pack
250250
bun run smoke:prebuilt-install
251251
```
252252

253+
Prepare the multi-platform release directories from downloaded build artifacts and dry-run the publish order:
254+
255+
```bash
256+
bun run build:prebuilt:artifact
257+
bun run stage:prebuilt:release
258+
bun run check:prebuilt-pack
259+
bun run publish:prebuilt:npm -- --dry-run
260+
```
261+
262+
The automated tag/manual release workflow lives in `.github/workflows/release-prebuilt-npm.yml`.
263+
253264
## License
254265

255266
[MIT](LICENSE)

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,16 @@
1919
"build:npm": "bash ./scripts/build-npm.sh",
2020
"build:bin": "bash ./scripts/build-bin.sh",
2121
"build:prebuilt:npm": "bun run build:bin && bun run ./scripts/stage-prebuilt-npm.ts",
22+
"build:prebuilt:artifact": "bun run build:bin && bun run ./scripts/build-prebuilt-artifact.ts",
23+
"stage:prebuilt:release": "bun run ./scripts/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts",
2224
"install:bin": "bash ./scripts/install-bin.sh",
2325
"typecheck": "tsc --noEmit",
2426
"test": "bun test",
2527
"test:tty-smoke": "bun test test/tty-render-smoke.test.ts",
2628
"check:pack": "bun run ./scripts/check-pack.ts",
2729
"check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts",
2830
"smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts",
31+
"publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts",
2932
"prepack": "bun run build:npm",
3033
"bench:bootstrap-load": "bun run test/bootstrap-load-benchmark.ts",
3134
"bench:highlight-prefetch": "bun run test/adjacent-highlight-prefetch-benchmark.ts",

scripts/build-prebuilt-artifact.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#!/usr/bin/env bun
2+
3+
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
4+
import path from "node:path";
5+
import { binaryFilenameForSpec, getHostPlatformPackageSpec, releaseArtifactsDir } from "./prebuilt-package-helpers";
6+
7+
function parseArgs(argv: string[]) {
8+
let outputRoot: string | undefined;
9+
let expectedPackage: string | undefined;
10+
11+
for (let index = 0; index < argv.length; index += 1) {
12+
const argument = argv[index];
13+
if (argument === "--output-root") {
14+
outputRoot = argv[index + 1];
15+
index += 1;
16+
continue;
17+
}
18+
19+
if (argument === "--expect-package") {
20+
expectedPackage = argv[index + 1];
21+
index += 1;
22+
continue;
23+
}
24+
25+
throw new Error(`Unknown argument: ${argument}`);
26+
}
27+
28+
return { outputRoot, expectedPackage };
29+
}
30+
31+
const repoRoot = path.resolve(import.meta.dir, "..");
32+
const options = parseArgs(process.argv.slice(2));
33+
const spec = getHostPlatformPackageSpec();
34+
const binaryName = binaryFilenameForSpec(spec);
35+
const compiledBinary = path.join(repoRoot, "dist", "hunk");
36+
const outputRoot = path.resolve(options.outputRoot ?? releaseArtifactsDir(repoRoot));
37+
const outputDir = path.join(outputRoot, spec.packageName);
38+
39+
if (options.expectedPackage && options.expectedPackage !== spec.packageName) {
40+
throw new Error(`Host build resolved to ${spec.packageName}, but the workflow expected ${options.expectedPackage}.`);
41+
}
42+
43+
if (!existsSync(compiledBinary)) {
44+
throw new Error(`Missing compiled binary at ${compiledBinary}. Run \`bun run build:bin\` first.`);
45+
}
46+
47+
rmSync(outputDir, { recursive: true, force: true });
48+
mkdirSync(outputDir, { recursive: true });
49+
cpSync(compiledBinary, path.join(outputDir, binaryName));
50+
writeFileSync(
51+
path.join(outputDir, "metadata.json"),
52+
`${JSON.stringify(
53+
{
54+
packageName: spec.packageName,
55+
os: spec.os,
56+
cpu: spec.cpu,
57+
binaryName,
58+
},
59+
null,
60+
2,
61+
)}\n`,
62+
);
63+
64+
console.log(`Prepared prebuilt artifact in ${outputDir}`);

scripts/check-prebuilt-pack.ts

Lines changed: 30 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#!/usr/bin/env bun
22

3+
import { existsSync, readdirSync } from "node:fs";
34
import path from "node:path";
4-
import { getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers";
5+
import { releaseNpmDir } from "./prebuilt-package-helpers";
56

67
interface PackedFile {
78
path: string;
@@ -43,29 +44,45 @@ function runPackDryRun(cwd: string) {
4344
return pack;
4445
}
4546

46-
function assertPaths(pack: PackResult, requiredPaths: string[], forbiddenPrefixes: string[] = []) {
47+
function assertPaths(pack: PackResult, requiredPaths: string[]) {
4748
const publishedPaths = new Set(pack.files.map((file) => file.path));
4849

4950
for (const requiredPath of requiredPaths) {
5051
if (!publishedPaths.has(requiredPath)) {
5152
throw new Error(`Expected ${pack.name} to include ${requiredPath}.`);
5253
}
5354
}
54-
55-
for (const file of pack.files) {
56-
if (forbiddenPrefixes.some((prefix) => file.path.startsWith(prefix))) {
57-
throw new Error(`Unexpected file in ${pack.name}: ${file.path}`);
58-
}
59-
}
6055
}
6156

6257
const repoRoot = path.resolve(import.meta.dir, "..");
6358
const releaseRoot = releaseNpmDir(repoRoot);
64-
const hostSpec = getHostPlatformPackageSpec();
65-
const metaPack = runPackDryRun(path.join(releaseRoot, "hunkdiff"));
66-
const hostPack = runPackDryRun(path.join(releaseRoot, hostSpec.packageName));
59+
const metaDir = path.join(releaseRoot, "hunkdiff");
6760

61+
if (!existsSync(metaDir)) {
62+
throw new Error(`Missing staged top-level package at ${metaDir}`);
63+
}
64+
65+
const metaPack = runPackDryRun(metaDir);
6866
assertPaths(metaPack, ["bin/hunk.cjs", "README.md", "LICENSE", "package.json"]);
69-
assertPaths(hostPack, ["bin/hunk", "LICENSE", "package.json"]);
7067

71-
console.log(`Verified prebuilt npm packages for ${metaPack.version}: ${metaPack.name} + ${hostPack.name}`);
68+
const packageDirectories = readdirSync(releaseRoot, { withFileTypes: true })
69+
.filter((entry) => entry.isDirectory() && entry.name !== "hunkdiff")
70+
.map((entry) => path.join(releaseRoot, entry.name))
71+
.sort();
72+
73+
if (packageDirectories.length === 0) {
74+
throw new Error(`No staged platform packages found in ${releaseRoot}`);
75+
}
76+
77+
const verifiedNames = [metaPack.name];
78+
for (const packageDirectory of packageDirectories) {
79+
const pack = runPackDryRun(packageDirectory);
80+
assertPaths(pack, ["LICENSE", "package.json"]);
81+
const binaryPath = pack.files.find((file) => file.path.startsWith("bin/"))?.path;
82+
if (!binaryPath) {
83+
throw new Error(`Expected ${pack.name} to publish one binary under bin/.`);
84+
}
85+
verifiedNames.push(pack.name);
86+
}
87+
88+
console.log(`Verified prebuilt npm packages for ${metaPack.version}: ${verifiedNames.join(", ")}`);

scripts/check-release-version.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env bun
2+
3+
import path from "node:path";
4+
5+
const repoRoot = path.resolve(import.meta.dir, "..");
6+
const packageJson = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) as { version: string };
7+
const refName = process.argv[2];
8+
9+
if (!refName) {
10+
throw new Error("Usage: bun run ./scripts/check-release-version.ts <tag>");
11+
}
12+
13+
const expectedTag = `v${packageJson.version}`;
14+
if (refName !== expectedTag) {
15+
throw new Error(`Tag ${refName} does not match package.json version ${packageJson.version} (${expectedTag}).`);
16+
}
17+
18+
console.log(`Verified release tag ${refName} matches package.json version ${packageJson.version}.`);

0 commit comments

Comments
 (0)