diff --git a/.github/workflows/release-prebuilt-npm.yml b/.github/workflows/release-prebuilt-npm.yml new file mode 100644 index 0000000..b2a671e --- /dev/null +++ b/.github/workflows/release-prebuilt-npm.yml @@ -0,0 +1,149 @@ +name: Release prebuilt npm packages + +on: + workflow_dispatch: + inputs: + publish: + description: Publish the staged prebuilt packages to npm + required: true + default: false + type: boolean + push: + tags: + - "v*" + +concurrency: + group: release-prebuilt-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-binaries: + name: Build ${{ matrix.package_name }} + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - package_name: hunkdiff-linux-x64 + runner: ubuntu-latest + - package_name: hunkdiff-darwin-x64 + runner: macos-13 + - package_name: hunkdiff-darwin-arm64 + runner: macos-14 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build host artifact + run: | + bun run build:bin + bun run ./scripts/build-prebuilt-artifact.ts --expect-package "${{ matrix.package_name }}" + + - name: Upload binary artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.package_name }} + path: dist/release/artifacts/${{ matrix.package_name }} + if-no-files-found: error + + stage-release: + name: Stage prebuilt npm release + runs-on: ubuntu-latest + needs: + - build-binaries + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Verify tag matches package version + if: github.event_name == 'push' + run: bun run ./scripts/check-release-version.ts "${{ github.ref_name }}" + + - name: Download platform artifacts + uses: actions/download-artifact@v4 + with: + path: dist/release/artifacts + + - name: Show downloaded artifacts + run: find dist/release/artifacts -maxdepth 3 -type f | sort + + - name: Stage npm release directories + run: bun run stage:prebuilt:release + + - name: Verify staged packages + run: bun run check:prebuilt-pack + + - name: Dry-run npm publish order + run: bun run publish:prebuilt:npm -- --dry-run + + - name: Upload staged npm release + uses: actions/upload-artifact@v4 + with: + name: staged-prebuilt-npm-release + path: dist/release/npm + if-no-files-found: error + + publish: + name: Publish prebuilt npm release + runs-on: ubuntu-latest + needs: + - stage-release + if: github.event_name == 'push' || inputs.publish == true + environment: npm + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.3.10 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 22 + registry-url: https://registry.npmjs.org + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Download staged npm release + uses: actions/download-artifact@v4 + with: + name: staged-prebuilt-npm-release + path: dist/release/npm + + - name: Show staged packages + run: find dist/release/npm -maxdepth 3 -type f | sort + + - name: Verify npm auth + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: npm whoami + + - name: Publish packages + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: bun run publish:prebuilt:npm diff --git a/README.md b/README.md index 5904d45..a2738b5 100644 --- a/README.md +++ b/README.md @@ -242,7 +242,7 @@ bun run build:npm bun run check:pack ``` -Stage the prototype prebuilt npm packages for the current host and smoke test the install path without Bun on `PATH`: +Stage the prebuilt npm packages for the current host and smoke test the install path without Bun on `PATH`: ```bash bun run build:prebuilt:npm @@ -250,6 +250,17 @@ bun run check:prebuilt-pack bun run smoke:prebuilt-install ``` +Prepare the multi-platform release directories from downloaded build artifacts and dry-run the publish order: + +```bash +bun run build:prebuilt:artifact +bun run stage:prebuilt:release +bun run check:prebuilt-pack +bun run publish:prebuilt:npm -- --dry-run +``` + +The automated tag/manual release workflow lives in `.github/workflows/release-prebuilt-npm.yml`. + ## License [MIT](LICENSE) diff --git a/package.json b/package.json index 1ba1837..b043259 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "build:npm": "bash ./scripts/build-npm.sh", "build:bin": "bash ./scripts/build-bin.sh", "build:prebuilt:npm": "bun run build:bin && bun run ./scripts/stage-prebuilt-npm.ts", + "build:prebuilt:artifact": "bun run build:bin && bun run ./scripts/build-prebuilt-artifact.ts", + "stage:prebuilt:release": "bun run ./scripts/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts", "install:bin": "bash ./scripts/install-bin.sh", "typecheck": "tsc --noEmit", "test": "bun test", @@ -26,6 +28,7 @@ "check:pack": "bun run ./scripts/check-pack.ts", "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts", "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts", + "publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts", "prepack": "bun run build:npm", "bench:bootstrap-load": "bun run test/bootstrap-load-benchmark.ts", "bench:highlight-prefetch": "bun run test/adjacent-highlight-prefetch-benchmark.ts", diff --git a/scripts/build-prebuilt-artifact.ts b/scripts/build-prebuilt-artifact.ts new file mode 100644 index 0000000..e4bc062 --- /dev/null +++ b/scripts/build-prebuilt-artifact.ts @@ -0,0 +1,64 @@ +#!/usr/bin/env bun + +import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { binaryFilenameForSpec, getHostPlatformPackageSpec, releaseArtifactsDir } from "./prebuilt-package-helpers"; + +function parseArgs(argv: string[]) { + let outputRoot: string | undefined; + let expectedPackage: string | undefined; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--output-root") { + outputRoot = argv[index + 1]; + index += 1; + continue; + } + + if (argument === "--expect-package") { + expectedPackage = argv[index + 1]; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${argument}`); + } + + return { outputRoot, expectedPackage }; +} + +const repoRoot = path.resolve(import.meta.dir, ".."); +const options = parseArgs(process.argv.slice(2)); +const spec = getHostPlatformPackageSpec(); +const binaryName = binaryFilenameForSpec(spec); +const compiledBinary = path.join(repoRoot, "dist", "hunk"); +const outputRoot = path.resolve(options.outputRoot ?? releaseArtifactsDir(repoRoot)); +const outputDir = path.join(outputRoot, spec.packageName); + +if (options.expectedPackage && options.expectedPackage !== spec.packageName) { + throw new Error(`Host build resolved to ${spec.packageName}, but the workflow expected ${options.expectedPackage}.`); +} + +if (!existsSync(compiledBinary)) { + throw new Error(`Missing compiled binary at ${compiledBinary}. Run \`bun run build:bin\` first.`); +} + +rmSync(outputDir, { recursive: true, force: true }); +mkdirSync(outputDir, { recursive: true }); +cpSync(compiledBinary, path.join(outputDir, binaryName)); +writeFileSync( + path.join(outputDir, "metadata.json"), + `${JSON.stringify( + { + packageName: spec.packageName, + os: spec.os, + cpu: spec.cpu, + binaryName, + }, + null, + 2, + )}\n`, +); + +console.log(`Prepared prebuilt artifact in ${outputDir}`); diff --git a/scripts/check-prebuilt-pack.ts b/scripts/check-prebuilt-pack.ts index 26e0234..64bb5d8 100644 --- a/scripts/check-prebuilt-pack.ts +++ b/scripts/check-prebuilt-pack.ts @@ -1,7 +1,8 @@ #!/usr/bin/env bun +import { existsSync, readdirSync } from "node:fs"; import path from "node:path"; -import { getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers"; +import { releaseNpmDir } from "./prebuilt-package-helpers"; interface PackedFile { path: string; @@ -43,7 +44,7 @@ function runPackDryRun(cwd: string) { return pack; } -function assertPaths(pack: PackResult, requiredPaths: string[], forbiddenPrefixes: string[] = []) { +function assertPaths(pack: PackResult, requiredPaths: string[]) { const publishedPaths = new Set(pack.files.map((file) => file.path)); for (const requiredPath of requiredPaths) { @@ -51,21 +52,37 @@ function assertPaths(pack: PackResult, requiredPaths: string[], forbiddenPrefixe throw new Error(`Expected ${pack.name} to include ${requiredPath}.`); } } - - for (const file of pack.files) { - if (forbiddenPrefixes.some((prefix) => file.path.startsWith(prefix))) { - throw new Error(`Unexpected file in ${pack.name}: ${file.path}`); - } - } } const repoRoot = path.resolve(import.meta.dir, ".."); const releaseRoot = releaseNpmDir(repoRoot); -const hostSpec = getHostPlatformPackageSpec(); -const metaPack = runPackDryRun(path.join(releaseRoot, "hunkdiff")); -const hostPack = runPackDryRun(path.join(releaseRoot, hostSpec.packageName)); +const metaDir = path.join(releaseRoot, "hunkdiff"); +if (!existsSync(metaDir)) { + throw new Error(`Missing staged top-level package at ${metaDir}`); +} + +const metaPack = runPackDryRun(metaDir); assertPaths(metaPack, ["bin/hunk.cjs", "README.md", "LICENSE", "package.json"]); -assertPaths(hostPack, ["bin/hunk", "LICENSE", "package.json"]); -console.log(`Verified prebuilt npm packages for ${metaPack.version}: ${metaPack.name} + ${hostPack.name}`); +const packageDirectories = readdirSync(releaseRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name !== "hunkdiff") + .map((entry) => path.join(releaseRoot, entry.name)) + .sort(); + +if (packageDirectories.length === 0) { + throw new Error(`No staged platform packages found in ${releaseRoot}`); +} + +const verifiedNames = [metaPack.name]; +for (const packageDirectory of packageDirectories) { + const pack = runPackDryRun(packageDirectory); + assertPaths(pack, ["LICENSE", "package.json"]); + const binaryPath = pack.files.find((file) => file.path.startsWith("bin/"))?.path; + if (!binaryPath) { + throw new Error(`Expected ${pack.name} to publish one binary under bin/.`); + } + verifiedNames.push(pack.name); +} + +console.log(`Verified prebuilt npm packages for ${metaPack.version}: ${verifiedNames.join(", ")}`); diff --git a/scripts/check-release-version.ts b/scripts/check-release-version.ts new file mode 100644 index 0000000..2c6efc5 --- /dev/null +++ b/scripts/check-release-version.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env bun + +import path from "node:path"; + +const repoRoot = path.resolve(import.meta.dir, ".."); +const packageJson = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) as { version: string }; +const refName = process.argv[2]; + +if (!refName) { + throw new Error("Usage: bun run ./scripts/check-release-version.ts "); +} + +const expectedTag = `v${packageJson.version}`; +if (refName !== expectedTag) { + throw new Error(`Tag ${refName} does not match package.json version ${packageJson.version} (${expectedTag}).`); +} + +console.log(`Verified release tag ${refName} matches package.json version ${packageJson.version}.`); diff --git a/scripts/prebuilt-package-helpers.ts b/scripts/prebuilt-package-helpers.ts index 3e82ba1..330f9cd 100644 --- a/scripts/prebuilt-package-helpers.ts +++ b/scripts/prebuilt-package-helpers.ts @@ -25,36 +25,51 @@ const ARCH_NAME_MAP: Partial> = { arm64: "arm64", }; +/** Platforms we actually plan to publish in the first prebuilt-binary rollout. */ export const PLATFORM_PACKAGE_MATRIX: PlatformPackageSpec[] = [ { packageName: "hunkdiff-darwin-arm64", os: "darwin", cpu: "arm64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, { packageName: "hunkdiff-darwin-x64", os: "darwin", cpu: "x64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, - { packageName: "hunkdiff-linux-arm64", os: "linux", cpu: "arm64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, { packageName: "hunkdiff-linux-x64", os: "linux", cpu: "x64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, ] as const; +/** Normalize a Node platform string into Hunk's package naming vocabulary. */ +export function normalizeHostPlatform(platform: NodeJS.Platform) { + return PLATFORM_NAME_MAP[platform]; +} + +/** Normalize a Node architecture string into Hunk's package naming vocabulary. */ +export function normalizeHostArch(arch: NodeJS.Architecture) { + return ARCH_NAME_MAP[arch]; +} + +/** Find one known prebuilt package spec by package name. */ +export function getPlatformPackageSpecByName(packageName: string) { + return PLATFORM_PACKAGE_MATRIX.find((candidate) => candidate.packageName === packageName); +} + /** Return the Hunk package spec that matches the current machine. */ export function getHostPlatformPackageSpec() { - const normalizedPlatform = PLATFORM_NAME_MAP[os.platform()]; + const normalizedPlatform = normalizeHostPlatform(os.platform()); if (!normalizedPlatform) { throw new Error(`Unsupported host platform for prebuilt packaging: ${os.platform()}`); } - const normalizedArch = ARCH_NAME_MAP[os.arch()]; + const normalizedArch = normalizeHostArch(os.arch()); if (!normalizedArch) { throw new Error(`Unsupported host architecture for prebuilt packaging: ${os.arch()}`); } const spec = PLATFORM_PACKAGE_MATRIX.find((candidate) => candidate.os === normalizedPlatform && candidate.cpu === normalizedArch); if (!spec) { - throw new Error(`No prebuilt package spec matches ${normalizedPlatform}/${normalizedArch}`); + throw new Error(`No published prebuilt package spec matches ${normalizedPlatform}/${normalizedArch}`); } return spec; } /** Build the optional dependency map for the top-level hunkdiff package. */ -export function buildOptionalDependencyMap(version: string) { - return Object.fromEntries(PLATFORM_PACKAGE_MATRIX.map((spec) => [spec.packageName, version])); +export function buildOptionalDependencyMap(version: string, specs: readonly PlatformPackageSpec[] = PLATFORM_PACKAGE_MATRIX) { + return Object.fromEntries(specs.map((spec) => [spec.packageName, version])); } /** Return the executable filename for a platform package. */ @@ -66,3 +81,13 @@ export function binaryFilenameForSpec(spec: PlatformPackageSpec) { export function releaseNpmDir(repoRoot: string) { return path.join(repoRoot, "dist", "release", "npm"); } + +/** Resolve a path under the generated prebuilt binary artifact directory. */ +export function releaseArtifactsDir(repoRoot: string) { + return path.join(repoRoot, "dist", "release", "artifacts"); +} + +/** Sort package specs into stable npm publish order. */ +export function sortPlatformPackageSpecs(specs: readonly PlatformPackageSpec[]) { + return [...specs].sort((left, right) => left.packageName.localeCompare(right.packageName)); +} diff --git a/scripts/publish-prebuilt-npm.ts b/scripts/publish-prebuilt-npm.ts new file mode 100644 index 0000000..6c7173e --- /dev/null +++ b/scripts/publish-prebuilt-npm.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env bun + +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { releaseNpmDir } from "./prebuilt-package-helpers"; + +type PackageJson = { + name: string; + version: string; +}; + +function parseArgs(argv: string[]) { + return { + dryRun: argv.includes("--dry-run"), + }; +} + +function npmViewExists(name: string, version: string) { + const proc = Bun.spawnSync(["npm", "view", `${name}@${version}`, "version"], { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + env: process.env, + }); + + return proc.exitCode === 0; +} + +function publishDirectory(directory: string, dryRun: boolean) { + const packageJson = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")) as PackageJson; + + if (npmViewExists(packageJson.name, packageJson.version)) { + console.log( + dryRun + ? `Skipping npm publish dry-run for ${packageJson.name}@${packageJson.version}; that version already exists on npm.` + : `Skipping ${packageJson.name}@${packageJson.version}; already published.`, + ); + return; + } + + const args = ["publish", "--access", "public"]; + if (dryRun) { + args.push("--dry-run"); + } + + const proc = Bun.spawnSync(["npm", ...args], { + cwd: directory, + stdin: "ignore", + stdout: "inherit", + stderr: "inherit", + env: process.env, + }); + + if (proc.exitCode !== 0) { + throw new Error(`npm publish failed for ${packageJson.name}@${packageJson.version}`); + } +} + +const repoRoot = path.resolve(import.meta.dir, ".."); +const releaseRoot = releaseNpmDir(repoRoot); +const options = parseArgs(process.argv.slice(2)); + +if (!existsSync(releaseRoot)) { + throw new Error(`Missing staged npm release directory at ${releaseRoot}`); +} + +const directories = readdirSync(releaseRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => { + if (left === "hunkdiff") return 1; + if (right === "hunkdiff") return -1; + return left.localeCompare(right); + }) + .map((entry) => path.join(releaseRoot, entry)); + +if (directories.length === 0) { + throw new Error(`No staged packages found in ${releaseRoot}`); +} + +for (const directory of directories) { + publishDirectory(directory, options.dryRun); +} + +console.log(options.dryRun ? "Completed npm publish dry-run for staged prebuilt packages." : "Published staged prebuilt packages to npm."); diff --git a/scripts/stage-prebuilt-npm.ts b/scripts/stage-prebuilt-npm.ts index 8f197d2..39b6c64 100644 --- a/scripts/stage-prebuilt-npm.ts +++ b/scripts/stage-prebuilt-npm.ts @@ -1,8 +1,16 @@ #!/usr/bin/env bun -import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { binaryFilenameForSpec, buildOptionalDependencyMap, getHostPlatformPackageSpec, releaseNpmDir } from "./prebuilt-package-helpers"; +import { + binaryFilenameForSpec, + buildOptionalDependencyMap, + getHostPlatformPackageSpec, + getPlatformPackageSpecByName, + releaseNpmDir, + sortPlatformPackageSpecs, + type PlatformPackageSpec, +} from "./prebuilt-package-helpers"; type RootPackageJson = { name: string; @@ -16,81 +24,156 @@ type RootPackageJson = { engines?: Record; }; -const repoRoot = path.resolve(import.meta.dir, ".."); -const rootPackage = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) as RootPackageJson; -const hostSpec = getHostPlatformPackageSpec(); -const hostBinaryName = binaryFilenameForSpec(hostSpec); -const compiledBinary = path.join(repoRoot, "dist", "hunk"); -const releaseRoot = releaseNpmDir(repoRoot); -const metaDir = path.join(releaseRoot, rootPackage.name); -const hostPackageDir = path.join(releaseRoot, hostSpec.packageName); +interface BinaryArtifactMetadata { + packageName: string; +} + +function parseArgs(argv: string[]) { + let artifactRoot: string | undefined; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === "--artifact-root") { + artifactRoot = argv[index + 1]; + index += 1; + continue; + } + + throw new Error(`Unknown argument: ${argument}`); + } -if (!existsSync(compiledBinary)) { - throw new Error(`Missing compiled binary at ${compiledBinary}. Run \`bun run build:bin\` first.`); + return { + artifactRoot, + }; } -rmSync(releaseRoot, { recursive: true, force: true }); -mkdirSync(releaseRoot, { recursive: true }); - -mkdirSync(path.join(metaDir, "bin"), { recursive: true }); -cpSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); -cpSync(path.join(repoRoot, "README.md"), path.join(metaDir, "README.md")); -cpSync(path.join(repoRoot, "LICENSE"), path.join(metaDir, "LICENSE")); - -writeFileSync( - path.join(metaDir, "package.json"), - JSON.stringify( - { - name: rootPackage.name, - version: rootPackage.version, - description: rootPackage.description, - bin: { - hunk: "./bin/hunk.cjs", - }, - files: ["bin", "README.md", "LICENSE"], - keywords: rootPackage.keywords, - repository: rootPackage.repository, - homepage: rootPackage.homepage, - bugs: rootPackage.bugs, - engines: rootPackage.engines, - optionalDependencies: buildOptionalDependencyMap(rootPackage.version), - license: rootPackage.license, - publishConfig: { - access: "public", - }, +function loadRootPackage(repoRoot: string) { + return JSON.parse(readFileSync(path.join(repoRoot, "package.json"), "utf8")) as RootPackageJson; +} + +function ensureDirectory(directory: string) { + mkdirSync(directory, { recursive: true }); +} + +function writeJson(filePath: string, value: unknown) { + writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function stageMetaPackage(repoRoot: string, rootPackage: RootPackageJson, releaseRoot: string, specs: readonly PlatformPackageSpec[]) { + const metaDir = path.join(releaseRoot, rootPackage.name); + ensureDirectory(path.join(metaDir, "bin")); + cpSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); + cpSync(path.join(repoRoot, "README.md"), path.join(metaDir, "README.md")); + cpSync(path.join(repoRoot, "LICENSE"), path.join(metaDir, "LICENSE")); + + writeJson(path.join(metaDir, "package.json"), { + name: rootPackage.name, + version: rootPackage.version, + description: rootPackage.description, + bin: { + hunk: "./bin/hunk.cjs", }, - null, - 2, - ) + "\n", -); - -mkdirSync(path.join(hostPackageDir, "bin"), { recursive: true }); -cpSync(compiledBinary, path.join(hostPackageDir, "bin", hostBinaryName)); -cpSync(path.join(repoRoot, "LICENSE"), path.join(hostPackageDir, "LICENSE")); - -writeFileSync( - path.join(hostPackageDir, "package.json"), - JSON.stringify( - { - name: hostSpec.packageName, - version: rootPackage.version, - description: `${rootPackage.description} (${hostSpec.os} ${hostSpec.cpu} binary)`, - os: [hostSpec.os === "windows" ? "win32" : hostSpec.os], - cpu: [hostSpec.cpu], - files: ["bin", "LICENSE"], - bin: { - hunk: `./bin/${hostBinaryName}`, - }, - license: rootPackage.license, - publishConfig: { - access: "public", - }, + files: ["bin", "README.md", "LICENSE"], + keywords: rootPackage.keywords, + repository: rootPackage.repository, + homepage: rootPackage.homepage, + bugs: rootPackage.bugs, + engines: rootPackage.engines, + optionalDependencies: buildOptionalDependencyMap(rootPackage.version, specs), + license: rootPackage.license, + publishConfig: { + access: "public", + }, + }); +} + +function stagePlatformPackage( + rootPackage: RootPackageJson, + releaseRoot: string, + repoRoot: string, + spec: PlatformPackageSpec, + compiledBinary: string, +) { + if (!existsSync(compiledBinary)) { + throw new Error(`Missing compiled binary at ${compiledBinary}`); + } + + const packageDir = path.join(releaseRoot, spec.packageName); + const binaryName = binaryFilenameForSpec(spec); + + ensureDirectory(path.join(packageDir, "bin")); + cpSync(compiledBinary, path.join(packageDir, "bin", binaryName)); + cpSync(path.join(repoRoot, "LICENSE"), path.join(packageDir, "LICENSE")); + + writeJson(path.join(packageDir, "package.json"), { + name: spec.packageName, + version: rootPackage.version, + description: `${rootPackage.description} (${spec.os} ${spec.cpu} binary)`, + os: [spec.os === "windows" ? "win32" : spec.os], + cpu: [spec.cpu], + files: ["bin", "LICENSE"], + license: rootPackage.license, + publishConfig: { + access: "public", }, - null, - 2, - ) + "\n", -); + }); +} + +function collectArtifactSpecs(artifactRoot: string) { + const directories = readdirSync(artifactRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => path.join(artifactRoot, entry.name)); + + if (directories.length === 0) { + throw new Error(`No artifact directories found in ${artifactRoot}`); + } + + return directories.map((directory) => { + const metadata = JSON.parse(readFileSync(path.join(directory, "metadata.json"), "utf8")) as BinaryArtifactMetadata; + const spec = getPlatformPackageSpecByName(metadata.packageName); + if (!spec) { + throw new Error(`Unknown platform package in artifact metadata: ${metadata.packageName}`); + } + + return { + spec, + compiledBinary: path.join(directory, binaryFilenameForSpec(spec)), + }; + }); +} + +const repoRoot = path.resolve(import.meta.dir, ".."); +const options = parseArgs(process.argv.slice(2)); +const rootPackage = loadRootPackage(repoRoot); +const releaseRoot = releaseNpmDir(repoRoot); +const artifactRoot = options.artifactRoot ? path.resolve(options.artifactRoot) : undefined; + +rmSync(releaseRoot, { recursive: true, force: true }); +ensureDirectory(releaseRoot); + +const artifacts = artifactRoot + ? collectArtifactSpecs(artifactRoot) + : [ + { + spec: getHostPlatformPackageSpec(), + compiledBinary: path.join(repoRoot, "dist", "hunk"), + }, + ]; + +const stagedSpecs = sortPlatformPackageSpecs(artifacts.map((artifact) => artifact.spec)); +stageMetaPackage(repoRoot, rootPackage, releaseRoot, stagedSpecs); + +for (const artifact of artifacts) { + stagePlatformPackage(rootPackage, releaseRoot, repoRoot, artifact.spec, artifact.compiledBinary); +} console.log(`Staged prebuilt npm packages in ${releaseRoot}`); -console.log(`- ${metaDir}`); -console.log(`- ${hostPackageDir}`); +console.log(`- ${path.join(releaseRoot, rootPackage.name)}`); +for (const spec of stagedSpecs) { + console.log(`- ${path.join(releaseRoot, spec.packageName)}`); +} +if (artifactRoot) { + console.log(`Artifacts source: ${artifactRoot}`); +} else { + console.log(`Artifacts source: ${path.join(repoRoot, "dist", "hunk")}`); +} diff --git a/test/prebuilt-package-helpers.test.ts b/test/prebuilt-package-helpers.test.ts index 6a838bc..e5bb48d 100644 --- a/test/prebuilt-package-helpers.test.ts +++ b/test/prebuilt-package-helpers.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "bun:test"; -import { PLATFORM_PACKAGE_MATRIX, binaryFilenameForSpec, buildOptionalDependencyMap } from "../scripts/prebuilt-package-helpers"; +import { + PLATFORM_PACKAGE_MATRIX, + binaryFilenameForSpec, + buildOptionalDependencyMap, + getPlatformPackageSpecByName, + sortPlatformPackageSpecs, +} from "../scripts/prebuilt-package-helpers"; describe("prebuilt package helpers", () => { test("buildOptionalDependencyMap includes every supported platform package at one version", () => { @@ -11,9 +17,23 @@ describe("prebuilt package helpers", () => { }); test("binaryFilenameForSpec keeps unix package binaries extensionless", () => { - expect(binaryFilenameForSpec(PLATFORM_PACKAGE_MATRIX[0]!)).toBe("hunk"); - expect(binaryFilenameForSpec(PLATFORM_PACKAGE_MATRIX[1]!)).toBe("hunk"); - expect(binaryFilenameForSpec(PLATFORM_PACKAGE_MATRIX[2]!)).toBe("hunk"); - expect(binaryFilenameForSpec(PLATFORM_PACKAGE_MATRIX[3]!)).toBe("hunk"); + for (const spec of PLATFORM_PACKAGE_MATRIX) { + expect(binaryFilenameForSpec(spec)).toBe("hunk"); + } + }); + + test("getPlatformPackageSpecByName returns known package specs", () => { + expect(getPlatformPackageSpecByName("hunkdiff-linux-x64")?.cpu).toBe("x64"); + expect(getPlatformPackageSpecByName("hunkdiff-darwin-arm64")?.os).toBe("darwin"); + expect(getPlatformPackageSpecByName("hunkdiff-does-not-exist")).toBeUndefined(); + }); + + test("sortPlatformPackageSpecs keeps package publish order stable", () => { + const reversed = [...PLATFORM_PACKAGE_MATRIX].reverse(); + expect(sortPlatformPackageSpecs(reversed).map((spec) => spec.packageName)).toEqual([ + "hunkdiff-darwin-arm64", + "hunkdiff-darwin-x64", + "hunkdiff-linux-x64", + ]); }); });