diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f32a75f..0e3c5e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -488,3 +488,64 @@ jobs: & $btExe --version $env:PATH = "$binDir;$env:PATH" & $btExe self update --check --channel stable + + publish-npm: + needs: + - plan + - announce + if: ${{ needs.announce.result == 'success' }} + runs-on: ubuntu-22.04 + environment: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false + + - name: Download release archives + env: + TAG: ${{ needs.plan.outputs.tag }} + shell: bash + run: | + set -euo pipefail + mkdir -p archives + gh release download "$TAG" \ + --repo "${{ github.repository }}" \ + --pattern 'bt-*.tar.gz' \ + --pattern 'bt-*.zip' \ + --dir archives + ls -la archives + + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "20" + registry-url: "https://registry.npmjs.org" + + - name: Build npm packages + run: | + node npm/scripts/build-platform-packages.mjs \ + --version "${{ needs.plan.outputs.release-version }}" \ + --archives-dir archives \ + --out-dir npm/dist + + - name: Publish platform packages + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + for dir in npm/dist/bt-*; do + name="$(node -p "require('./$dir/package.json').name")" + version="$(node -p "require('./$dir/package.json').version")" + # Skip already-published versions so a partial publish can be re-run. + # Test output, not exit code: npm view is empty for a missing version. + published="$(npm view "$name@$version" version 2>/dev/null || true)" + if [ -n "$published" ]; then + echo "Skipping $name@$version (already published)" + continue + fi + echo "Publishing $name@$version" + (cd "$dir" && npm publish --access public --provenance) + done diff --git a/npm/scripts/build-platform-packages.mjs b/npm/scripts/build-platform-packages.mjs new file mode 100644 index 0000000..0dd508c --- /dev/null +++ b/npm/scripts/build-platform-packages.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +// Builds the per-platform npm packages from cargo-dist release archives. +// +// --version version to stamp into every package.json (required) +// --archives-dir directory containing cargo-dist archives +// (bt-.tar.gz / bt-.zip), required +// --out-dir directory to write packages into (default: npm/dist) +// +// Emits /bt-/ (one per target), each ready to `npm publish`. +// The `bt` command is exposed via the `braintrust` SDK, which lists these +// packages as optionalDependencies and ships a launcher that resolves the +// matching binary. + +import { execFileSync } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const NPM_DIR = resolve(__dirname, ".."); + +function parseArgs(argv) { + const args = {}; + for (let i = 0; i < argv.length; i++) { + const k = argv[i]; + if (k.startsWith("--")) args[k.slice(2)] = argv[++i]; + } + return args; +} + +const args = parseArgs(process.argv.slice(2)); +const version = args.version; +const archivesDir = args["archives-dir"] && resolve(args["archives-dir"]); +const outDir = resolve(args["out-dir"] ?? join(NPM_DIR, "dist")); + +if (!version) throw new Error("--version is required"); +if (!archivesDir) throw new Error("--archives-dir is required"); +if (!existsSync(archivesDir)) + throw new Error(`archives-dir not found: ${archivesDir}`); + +const targets = JSON.parse(readFileSync(join(NPM_DIR, "targets.json"), "utf8")); + +if (existsSync(outDir)) rmSync(outDir, { recursive: true, force: true }); +mkdirSync(outDir, { recursive: true }); + +function extract(archive, dest) { + mkdirSync(dest, { recursive: true }); + if (archive.endsWith(".tar.gz")) { + execFileSync("tar", ["-xzf", archive, "-C", dest], { stdio: "inherit" }); + } else if (archive.endsWith(".zip")) { + execFileSync("unzip", ["-o", "-q", archive, "-d", dest], { + stdio: "inherit", + }); + } else { + throw new Error(`Unsupported archive: ${archive}`); + } +} + +function findBinary(rootDir, binName) { + const stack = [rootDir]; + while (stack.length) { + const cur = stack.pop(); + for (const entry of readdirSync(cur)) { + const full = join(cur, entry); + const s = statSync(full); + if (s.isDirectory()) stack.push(full); + else if (entry === binName) return full; + } + } + throw new Error(`Binary ${binName} not found under ${rootDir}`); +} + +// --- Per-platform packages --- +let built = 0; +for (const [target, spec] of Object.entries(targets)) { + const archiveName = `bt-${target}.${spec.archiveExt}`; + const archive = join(archivesDir, archiveName); + if (!existsSync(archive)) { + // Fail hard, don't skip: the SDK pins each package at an exact version, so a + // missing platform would break installs for that platform at runtime. + throw new Error(`Archive not found for ${target}: ${archive}`); + } + + const stagingDir = join(outDir, ".staging", target); + extract(archive, stagingDir); + const binPath = findBinary(stagingDir, spec.bin); + + const pkgName = `@braintrust/bt-${spec.pkg}`; + const pkgOut = join(outDir, `bt-${spec.pkg}`); + const pkgBin = join(pkgOut, "bin"); + mkdirSync(pkgBin, { recursive: true }); + cpSync(binPath, join(pkgBin, spec.bin)); + if (spec.os !== "win32") chmodSync(join(pkgBin, spec.bin), 0o755); + + const platformPkg = { + name: pkgName, + version, + description: `Prebuilt bt binary for ${spec.os}-${spec.cpu}${spec.libc ? `-${spec.libc}` : ""}`, + homepage: "https://github.com/braintrustdata/bt", + repository: { + type: "git", + url: "git+https://github.com/braintrustdata/bt.git", + }, + license: "Apache-2.0", + author: "Braintrust engineering ", + files: ["bin/"], + os: [spec.os], + cpu: [spec.cpu], + publishConfig: { + access: "public", + provenance: true, + }, + preferUnplugged: true, + }; + if (spec.libc) platformPkg.libc = [spec.libc]; + + writeFileSync( + join(pkgOut, "package.json"), + JSON.stringify(platformPkg, null, 2) + "\n", + ); + writeFileSync( + join(pkgOut, "README.md"), + `# ${pkgName}\n\nPrebuilt \`bt\` binary for ${spec.os}-${spec.cpu}${spec.libc ? ` (${spec.libc})` : ""}.\n\nInstalled automatically as an optional dependency of [\`braintrust\`](https://www.npmjs.com/package/braintrust), which exposes the \`bt\` command. Install that package instead.\n`, + ); + + console.log(`Built ${pkgName} -> ${pkgOut}`); + built++; +} + +rmSync(join(outDir, ".staging"), { recursive: true, force: true }); + +const expected = Object.keys(targets).length; +if (built !== expected) { + throw new Error(`Built ${built} packages but expected ${expected}`); +} +console.log(`\nAll ${built} packages written to ${outDir}`); diff --git a/npm/targets.json b/npm/targets.json new file mode 100644 index 0000000..21b6cda --- /dev/null +++ b/npm/targets.json @@ -0,0 +1,54 @@ +{ + "aarch64-apple-darwin": { + "pkg": "darwin-arm64", + "os": "darwin", + "cpu": "arm64", + "bin": "bt", + "archiveExt": "tar.gz" + }, + "x86_64-apple-darwin": { + "pkg": "darwin-x64", + "os": "darwin", + "cpu": "x64", + "bin": "bt", + "archiveExt": "tar.gz" + }, + "aarch64-unknown-linux-gnu": { + "pkg": "linux-arm64", + "os": "linux", + "cpu": "arm64", + "libc": "glibc", + "bin": "bt", + "archiveExt": "tar.gz" + }, + "x86_64-unknown-linux-gnu": { + "pkg": "linux-x64", + "os": "linux", + "cpu": "x64", + "libc": "glibc", + "bin": "bt", + "archiveExt": "tar.gz" + }, + "x86_64-unknown-linux-musl": { + "pkg": "linux-x64-musl", + "os": "linux", + "cpu": "x64", + "libc": "musl", + "bin": "bt", + "archiveExt": "tar.gz" + }, + "aarch64-pc-windows-msvc": { + "pkg": "win32-arm64", + "os": "win32", + "cpu": "arm64", + "bin": "bt.exe", + "archiveExt": "zip" + }, + "x86_64-pc-windows-msvc": { + "pkg": "win32-x64", + "os": "win32", + "cpu": "x64", + "bin": "bt.exe", + "archiveExt": "zip" + } +}