From 1a3f61e002fa69bf6f35400262ffb873fa2ee9ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 28 May 2026 16:17:57 -0700 Subject: [PATCH 1/2] feat: watch bt releases on npm, update local bt version on update rum with a daily cron job or manually --- .changeset/add-bt-cli-bin.md | 5 + .github/workflows/sync-bt-version.yaml | 60 +++++++++++ js/bin/bt.cjs | 76 ++++++++++++++ js/package.json | 15 ++- pnpm-workspace.yaml | 1 + scripts/release/sync-bt-version.mjs | 136 +++++++++++++++++++++++++ 6 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 .changeset/add-bt-cli-bin.md create mode 100644 .github/workflows/sync-bt-version.yaml create mode 100755 js/bin/bt.cjs create mode 100644 scripts/release/sync-bt-version.mjs diff --git a/.changeset/add-bt-cli-bin.md b/.changeset/add-bt-cli-bin.md new file mode 100644 index 000000000..c0859c3a7 --- /dev/null +++ b/.changeset/add-bt-cli-bin.md @@ -0,0 +1,5 @@ +--- +"braintrust": minor +--- + +feat: ship the `bt` CLI with the SDK. Installing `braintrust` now exposes a `bt` command in `node_modules/.bin` that runs the prebuilt native binary for your platform (delivered via `@braintrust/bt-*` optional dependencies). diff --git a/.github/workflows/sync-bt-version.yaml b/.github/workflows/sync-bt-version.yaml new file mode 100644 index 000000000..91fd4b3a7 --- /dev/null +++ b/.github/workflows/sync-bt-version.yaml @@ -0,0 +1,60 @@ +name: Sync bt version + +# Keeps the @braintrust/bt-* optionalDependencies in js/package.json pinned to +# the latest published bt release. Polls npm and opens a PR when a newer release exists. + +on: + schedule: + - cron: "30 7 * * *" # daily + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +concurrency: + group: sync-bt-version + cancel-in-progress: false + +env: + HUSKY: "0" + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version-file: .tool-versions + - name: Check for a newer bt release + id: sync + run: node scripts/release/sync-bt-version.mjs + - name: Open or update bump PR + if: steps.sync.outputs.changed == 'true' + env: + GH_TOKEN: ${{ github.token }} + BRANCH: ${{ steps.sync.outputs.branch }} + VERSION: ${{ steps.sync.outputs.version }} + run: | + set -euo pipefail + + existing="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number')" + if [ -n "$existing" ]; then + echo "PR #$existing already open for $BRANCH; nothing to do." + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git switch --create "$BRANCH" + git add js/package.json .changeset + git commit -m "chore: bump bundled bt CLI to ${VERSION}" + git push --force-with-lease origin "HEAD:refs/heads/$BRANCH" + + gh pr create \ + --base main \ + --head "$BRANCH" \ + --title "chore: bump bundled bt CLI to ${VERSION}" \ + --body "Automated bump of the \`@braintrust/bt-*\` optional dependencies to \`${VERSION}\`, matching the latest published bt release. Merging cuts a patch release of \`braintrust\` via changesets." diff --git a/js/bin/bt.cjs b/js/bin/bt.cjs new file mode 100755 index 000000000..5dd1e3d54 --- /dev/null +++ b/js/bin/bt.cjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +"use strict"; + +// Launcher for the `bt` CLI. The native binary ships in a per-platform +// package (`@braintrust/bt-`) listed as an optionalDependency of +// `braintrust`; npm/pnpm install only the one matching the host. This script +// resolves that binary and forwards argv + exit code. + +const { spawnSync } = require("node:child_process"); + +const PLATFORM_PACKAGES = { + "darwin-arm64": "@braintrust/bt-darwin-arm64", + "darwin-x64": "@braintrust/bt-darwin-x64", + "linux-arm64": "@braintrust/bt-linux-arm64", + "linux-x64-glibc": "@braintrust/bt-linux-x64", + "linux-x64-musl": "@braintrust/bt-linux-x64-musl", + "win32-arm64": "@braintrust/bt-win32-arm64", + "win32-x64": "@braintrust/bt-win32-x64", +}; + +function detectLibc() { + if (process.platform !== "linux") return null; + try { + const report = process.report && process.report.getReport(); + if (report && report.header && report.header.glibcVersionRuntime) { + return "glibc"; + } + return "musl"; + } catch { + return "glibc"; + } +} + +function platformKey() { + const { platform, arch } = process; + if (platform === "linux" && arch === "x64") { + return `linux-x64-${detectLibc()}`; + } + return `${platform}-${arch}`; +} + +function binaryName() { + return process.platform === "win32" ? "bt.exe" : "bt"; +} + +function resolveBinary() { + const pkg = PLATFORM_PACKAGES[platformKey()]; + if (!pkg) { + throw new Error( + `No prebuilt bt binary for ${process.platform}-${process.arch}. ` + + `Supported platforms: ${Object.keys(PLATFORM_PACKAGES).join(", ")}.`, + ); + } + try { + return require.resolve(`${pkg}/bin/${binaryName()}`); + } catch (err) { + throw new Error( + `Failed to locate the bt binary from "${pkg}". It is an optional ` + + `dependency of "braintrust"; reinstall your dependencies to fetch ` + + `it (${err.message}).`, + ); + } +} + +try { + const result = spawnSync(resolveBinary(), process.argv.slice(2), { + stdio: "inherit", + windowsHide: true, + }); + if (result.error) throw result.error; + if (result.signal) process.kill(process.pid, result.signal); + process.exit(result.status ?? 1); +} catch (err) { + console.error(`bt: ${err.message}`); + process.exit(1); +} diff --git a/js/package.json b/js/package.json index 8361036e0..0bf568b95 100644 --- a/js/package.json +++ b/js/package.json @@ -19,7 +19,8 @@ "./dist/index.d.mts": "./dist/browser.d.mts" }, "bin": { - "braintrust": "./dist/cli.js" + "braintrust": "./dist/cli.js", + "bt": "./bin/bt.cjs" }, "exports": { "./package.json": "./package.json", @@ -127,7 +128,8 @@ "files": [ "dist/**/*", "dev/dist/**/*", - "util/dist/**/*" + "util/dist/**/*", + "bin/bt.cjs" ], "scripts": { "build": "cross-env NODE_OPTIONS=\"--max-old-space-size=8192\" tsup", @@ -224,6 +226,15 @@ "peerDependencies": { "zod": "^3.25.34 || ^4.0" }, + "optionalDependencies": { + "@braintrust/bt-darwin-arm64": "0.10.0", + "@braintrust/bt-darwin-x64": "0.10.0", + "@braintrust/bt-linux-arm64": "0.10.0", + "@braintrust/bt-linux-x64": "0.10.0", + "@braintrust/bt-linux-x64-musl": "0.10.0", + "@braintrust/bt-win32-arm64": "0.10.0", + "@braintrust/bt-win32-x64": "0.10.0" + }, "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/", diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 1f040496c..31bde25c3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,6 +10,7 @@ blockExoticSubdeps: true minimumReleaseAge: 4320 # 3 days (in minutes) minimumReleaseAgeExclude: - "@apm-js-collab/*" # instrumentation deps we need to pin closely + - "@braintrust/bt-*" # bt binary packages, published in lockstep with braintrust trustPolicy: no-downgrade # Ignore the check for packages published more than 30 days ago (pnpm 10.27+) # Useful for older packages that pre-date provenance support diff --git a/scripts/release/sync-bt-version.mjs b/scripts/release/sync-bt-version.mjs new file mode 100644 index 000000000..3f5656d44 --- /dev/null +++ b/scripts/release/sync-bt-version.mjs @@ -0,0 +1,136 @@ +// Keeps the `@braintrust/bt-*` optionalDependencies in js/package.json pinned +// to the latest published bt release. +// +// Reads the version published to npm, compares it to the current pin, and if a newer release exists rewrites the pins and drops a changeset. Emits GitHub Actions outputs so the calling workflow knows whether to open a PR. Run by .github/workflows/sync-bt-version.yaml. +// +// --dry-run compute and report, but don't write any files + +import { readFileSync, writeFileSync } from "node:fs"; + +import { + NPM_REGISTRY, + appendSummary, + parseArgs, + repoPath, + writeGithubOutput, +} from "./_shared.mjs"; + +// The package we query for the canonical "latest bt version". All bt platform +// packages are published in lockstep, so any one of them is authoritative. +const PROBE_PACKAGE = "@braintrust/bt-darwin-arm64"; +const BT_PIN_PREFIX = "@braintrust/bt-"; +const SEMVER_RE = /^\d+\.\d+\.\d+$/; + +function compareSemver(a, b) { + const pa = a.split(".").map(Number); + const pb = b.split(".").map(Number); + for (let i = 0; i < 3; i += 1) { + if (pa[i] !== pb[i]) return pa[i] - pb[i]; + } + return 0; +} + +async function fetchLatestVersion(pkg) { + const url = `${NPM_REGISTRY}${pkg}/latest`; + const res = await fetch(url, { + headers: { accept: "application/json" }, + }); + // Not published yet — treat as "no release to sync to" rather than an error, + // so the scheduled job stays green before bt's first npm publish. + if (res.status === 404) { + return null; + } + if (!res.ok) { + throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); + } + const body = await res.json(); + if (typeof body.version !== "string") { + throw new Error(`No version field in ${url} response`); + } + return body.version; +} + +async function main() { + const args = parseArgs(); + const dryRun = Boolean(args["dry-run"]); + + const pkgPath = repoPath("js", "package.json"); + const raw = readFileSync(pkgPath, "utf8"); + const pkg = JSON.parse(raw); + + const optionalDeps = pkg.optionalDependencies ?? {}; + const btPins = Object.keys(optionalDeps).filter((name) => + name.startsWith(BT_PIN_PREFIX), + ); + if (btPins.length === 0) { + throw new Error( + `No ${BT_PIN_PREFIX}* entries found in js/package.json optionalDependencies`, + ); + } + + const currentVersion = optionalDeps[`${BT_PIN_PREFIX}darwin-arm64`]; + if (!currentVersion) { + throw new Error( + `Expected ${BT_PIN_PREFIX}darwin-arm64 in optionalDependencies to read the current pin`, + ); + } + + const latestVersion = await fetchLatestVersion(PROBE_PACKAGE); + if (latestVersion === null) { + console.log(`${PROBE_PACKAGE} is not published yet; nothing to sync.`); + writeGithubOutput("changed", "false"); + return; + } + if (!SEMVER_RE.test(latestVersion)) { + // Ignore prereleases / non-standard tags; only track clean releases. + console.log( + `Latest published version "${latestVersion}" is not a plain x.y.z release; skipping.`, + ); + writeGithubOutput("changed", "false"); + return; + } + + console.log(`current pin: ${currentVersion}`); + console.log(`npm latest: ${latestVersion}`); + + if (compareSemver(latestVersion, currentVersion) <= 0) { + console.log("Pins are already at or ahead of the latest release."); + writeGithubOutput("changed", "false"); + return; + } + + // Rewrite every bt pin to the new version. + for (const name of btPins) { + optionalDeps[name] = latestVersion; + } + + const changesetPath = repoPath( + ".changeset", + `auto-bump-bt-${latestVersion}.md`, + ); + const changesetBody = `---\n"braintrust": patch\n---\n\nchore: bump bundled \`bt\` CLI to ${latestVersion}\n`; + + if (dryRun) { + console.log( + `[dry-run] would set ${btPins.length} pins to ${latestVersion}`, + ); + console.log(`[dry-run] would write ${changesetPath}`); + } else { + // Preserve 2-space indent + trailing newline to match the existing file. + writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); + writeFileSync(changesetPath, changesetBody); + } + + writeGithubOutput("changed", "true"); + writeGithubOutput("version", latestVersion); + writeGithubOutput("branch", `auto/bump-bt-${latestVersion}`); + appendSummary( + `Bumped \`@braintrust/bt-*\` pins: \`${currentVersion}\` → \`${latestVersion}\``, + ); + console.log(`Bumped ${btPins.length} pins to ${latestVersion}.`); +} + +main().catch((err) => { + console.error(err.message ?? err); + process.exit(1); +}); From 124dd2235310c0532c13c41482b907b51f5c8256 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 29 May 2026 16:36:43 -0700 Subject: [PATCH 2/2] chore: remove cron watching bt releases also use origin/main updates --- .github/workflows/sync-bt-version.yaml | 60 ----------- scripts/release/sync-bt-version.mjs | 136 ------------------------- 2 files changed, 196 deletions(-) delete mode 100644 .github/workflows/sync-bt-version.yaml delete mode 100644 scripts/release/sync-bt-version.mjs diff --git a/.github/workflows/sync-bt-version.yaml b/.github/workflows/sync-bt-version.yaml deleted file mode 100644 index 91fd4b3a7..000000000 --- a/.github/workflows/sync-bt-version.yaml +++ /dev/null @@ -1,60 +0,0 @@ -name: Sync bt version - -# Keeps the @braintrust/bt-* optionalDependencies in js/package.json pinned to -# the latest published bt release. Polls npm and opens a PR when a newer release exists. - -on: - schedule: - - cron: "30 7 * * *" # daily - workflow_dispatch: - -permissions: - contents: write - pull-requests: write - -concurrency: - group: sync-bt-version - cancel-in-progress: false - -env: - HUSKY: "0" - -jobs: - sync: - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 - with: - node-version-file: .tool-versions - - name: Check for a newer bt release - id: sync - run: node scripts/release/sync-bt-version.mjs - - name: Open or update bump PR - if: steps.sync.outputs.changed == 'true' - env: - GH_TOKEN: ${{ github.token }} - BRANCH: ${{ steps.sync.outputs.branch }} - VERSION: ${{ steps.sync.outputs.version }} - run: | - set -euo pipefail - - existing="$(gh pr list --head "$BRANCH" --state open --json number --jq '.[0].number')" - if [ -n "$existing" ]; then - echo "PR #$existing already open for $BRANCH; nothing to do." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git switch --create "$BRANCH" - git add js/package.json .changeset - git commit -m "chore: bump bundled bt CLI to ${VERSION}" - git push --force-with-lease origin "HEAD:refs/heads/$BRANCH" - - gh pr create \ - --base main \ - --head "$BRANCH" \ - --title "chore: bump bundled bt CLI to ${VERSION}" \ - --body "Automated bump of the \`@braintrust/bt-*\` optional dependencies to \`${VERSION}\`, matching the latest published bt release. Merging cuts a patch release of \`braintrust\` via changesets." diff --git a/scripts/release/sync-bt-version.mjs b/scripts/release/sync-bt-version.mjs deleted file mode 100644 index 3f5656d44..000000000 --- a/scripts/release/sync-bt-version.mjs +++ /dev/null @@ -1,136 +0,0 @@ -// Keeps the `@braintrust/bt-*` optionalDependencies in js/package.json pinned -// to the latest published bt release. -// -// Reads the version published to npm, compares it to the current pin, and if a newer release exists rewrites the pins and drops a changeset. Emits GitHub Actions outputs so the calling workflow knows whether to open a PR. Run by .github/workflows/sync-bt-version.yaml. -// -// --dry-run compute and report, but don't write any files - -import { readFileSync, writeFileSync } from "node:fs"; - -import { - NPM_REGISTRY, - appendSummary, - parseArgs, - repoPath, - writeGithubOutput, -} from "./_shared.mjs"; - -// The package we query for the canonical "latest bt version". All bt platform -// packages are published in lockstep, so any one of them is authoritative. -const PROBE_PACKAGE = "@braintrust/bt-darwin-arm64"; -const BT_PIN_PREFIX = "@braintrust/bt-"; -const SEMVER_RE = /^\d+\.\d+\.\d+$/; - -function compareSemver(a, b) { - const pa = a.split(".").map(Number); - const pb = b.split(".").map(Number); - for (let i = 0; i < 3; i += 1) { - if (pa[i] !== pb[i]) return pa[i] - pb[i]; - } - return 0; -} - -async function fetchLatestVersion(pkg) { - const url = `${NPM_REGISTRY}${pkg}/latest`; - const res = await fetch(url, { - headers: { accept: "application/json" }, - }); - // Not published yet — treat as "no release to sync to" rather than an error, - // so the scheduled job stays green before bt's first npm publish. - if (res.status === 404) { - return null; - } - if (!res.ok) { - throw new Error(`GET ${url} -> ${res.status} ${res.statusText}`); - } - const body = await res.json(); - if (typeof body.version !== "string") { - throw new Error(`No version field in ${url} response`); - } - return body.version; -} - -async function main() { - const args = parseArgs(); - const dryRun = Boolean(args["dry-run"]); - - const pkgPath = repoPath("js", "package.json"); - const raw = readFileSync(pkgPath, "utf8"); - const pkg = JSON.parse(raw); - - const optionalDeps = pkg.optionalDependencies ?? {}; - const btPins = Object.keys(optionalDeps).filter((name) => - name.startsWith(BT_PIN_PREFIX), - ); - if (btPins.length === 0) { - throw new Error( - `No ${BT_PIN_PREFIX}* entries found in js/package.json optionalDependencies`, - ); - } - - const currentVersion = optionalDeps[`${BT_PIN_PREFIX}darwin-arm64`]; - if (!currentVersion) { - throw new Error( - `Expected ${BT_PIN_PREFIX}darwin-arm64 in optionalDependencies to read the current pin`, - ); - } - - const latestVersion = await fetchLatestVersion(PROBE_PACKAGE); - if (latestVersion === null) { - console.log(`${PROBE_PACKAGE} is not published yet; nothing to sync.`); - writeGithubOutput("changed", "false"); - return; - } - if (!SEMVER_RE.test(latestVersion)) { - // Ignore prereleases / non-standard tags; only track clean releases. - console.log( - `Latest published version "${latestVersion}" is not a plain x.y.z release; skipping.`, - ); - writeGithubOutput("changed", "false"); - return; - } - - console.log(`current pin: ${currentVersion}`); - console.log(`npm latest: ${latestVersion}`); - - if (compareSemver(latestVersion, currentVersion) <= 0) { - console.log("Pins are already at or ahead of the latest release."); - writeGithubOutput("changed", "false"); - return; - } - - // Rewrite every bt pin to the new version. - for (const name of btPins) { - optionalDeps[name] = latestVersion; - } - - const changesetPath = repoPath( - ".changeset", - `auto-bump-bt-${latestVersion}.md`, - ); - const changesetBody = `---\n"braintrust": patch\n---\n\nchore: bump bundled \`bt\` CLI to ${latestVersion}\n`; - - if (dryRun) { - console.log( - `[dry-run] would set ${btPins.length} pins to ${latestVersion}`, - ); - console.log(`[dry-run] would write ${changesetPath}`); - } else { - // Preserve 2-space indent + trailing newline to match the existing file. - writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`); - writeFileSync(changesetPath, changesetBody); - } - - writeGithubOutput("changed", "true"); - writeGithubOutput("version", latestVersion); - writeGithubOutput("branch", `auto/bump-bt-${latestVersion}`); - appendSummary( - `Bumped \`@braintrust/bt-*\` pins: \`${currentVersion}\` → \`${latestVersion}\``, - ); - console.log(`Bumped ${btPins.length} pins to ${latestVersion}.`); -} - -main().catch((err) => { - console.error(err.message ?? err); - process.exit(1); -});