From 6eab742ecf0d4e266eb70f6947d8ed44bd05f54c Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 3 Aug 2026 15:02:25 -0400 Subject: [PATCH 1/2] ci: enforce Node SDK napi conformance --- .github/workflows/ci.yml | 15 ++++++- .github/workflows/publish.yml | 35 ++++++++++++---- packages/sdk-node/CHANGELOG.md | 1 + packages/sdk-node/src/binding.cjs | 48 ++++++++++------------ packages/sdk-node/test/conformance.test.js | 26 ++---------- packages/sdk-node/test/helpers/napi.js | 34 +++++++++++++++ packages/sdk-node/test/no-onlog.test.js | 34 +++++++-------- 7 files changed, 115 insertions(+), 78 deletions(-) create mode 100644 packages/sdk-node/test/helpers/napi.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cce3235e..086c4a84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,8 +37,21 @@ jobs: - name: Build npm wrappers run: pnpm -r --if-present run build + - name: Build native Node SDK binding + # The release/cross-platform napi matrix is path-filtered and does not + # run for changes such as MCP-only PRs. Build one native debug binding + # here so the always-on Node test job exercises the SDK facade without + # adding an uncached release build to its critical path. + run: pnpm run build:napi:debug + - name: Run npm tests - run: pnpm run test + env: + HOME: ${{ runner.temp }}/relayburn-node-tests/home + RELAYBURN_HOME: ${{ runner.temp }}/relayburn-node-tests/ledger + RELAYBURN_SDK_NAPI_BUILT: '1' + run: | + mkdir -p "$HOME" "$RELAYBURN_HOME" + pnpm run test cargo-build-and-test: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1ca6602d..93a6418b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -298,8 +298,34 @@ jobs: - name: Build workspace run: pnpm -r run build + # Reuse the napi matrix outputs for both conformance tests and package + # staging. The x64 Linux artifact is native to this publish runner. + - name: Download SDK napi artifacts + uses: actions/download-artifact@v4 + with: + path: /tmp/sdk-artifacts + pattern: relayburn-sdk-* + merge-multiple: false + + - name: Stage native SDK binding for tests + run: | + set -euo pipefail + sdk_test_dir="/tmp/sdk-artifacts/relayburn-sdk-linux-x64-gnu" + sdk_test_node=$(find "$sdk_test_dir" -maxdepth 1 -type f -name '*.node' -print -quit) + if [ -z "$sdk_test_node" ]; then + echo "::error title=Missing SDK test artifact::expected a .node file under $sdk_test_dir" >&2 + exit 1 + fi + cp "$sdk_test_node" packages/sdk-node/src/index.linux-x64-gnu.node + - name: Run tests - run: pnpm run test + env: + HOME: ${{ runner.temp }}/relayburn-node-tests/home + RELAYBURN_HOME: ${{ runner.temp }}/relayburn-node-tests/ledger + RELAYBURN_SDK_NAPI_BUILT: '1' + run: | + mkdir -p "$HOME" "$RELAYBURN_HOME" + pnpm run test # Rust port build + test gates the whole publish job. If the Rust # tree is red at this commit, abort before npm ships anything so the @@ -916,13 +942,6 @@ jobs: pattern: relayburn-cli-* merge-multiple: false - - name: Download SDK napi artifacts - uses: actions/download-artifact@v4 - with: - path: /tmp/sdk-artifacts - pattern: relayburn-sdk-* - merge-multiple: false - - name: Stage prebuilt binaries into platform packages run: | set -euo pipefail diff --git a/packages/sdk-node/CHANGELOG.md b/packages/sdk-node/CHANGELOG.md index 666de257..75024f0c 100644 --- a/packages/sdk-node/CHANGELOG.md +++ b/packages/sdk-node/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] +- Local napi builds load ahead of installed platform packages, so development and conformance tests use the current checkout's native binding. - Cost calculations recognize Claude 5 and GPT-5.6 models, prefer first-party tariffs, and apply long-context price tiers. - `hotspots()` findings identify unknown pricing and rank unpriced sessions by token volume instead of $0.00. diff --git a/packages/sdk-node/src/binding.cjs b/packages/sdk-node/src/binding.cjs index 61afac5d..4235b7d7 100644 --- a/packages/sdk-node/src/binding.cjs +++ b/packages/sdk-node/src/binding.cjs @@ -1,26 +1,22 @@ -// Native-binding loader. At publish time, `napi build` (via `@napi-rs/cli`) -// regenerates this file to dispatch to the right per-platform package +// Native-binding loader. Dispatches to a local napi build when present, then +// falls back to the matching per-platform package // (`@relayburn/sdk-darwin-arm64`, `@relayburn/sdk-linux-x64-gnu`, etc.) based -// on `process.platform` + `process.arch` + libc detection. The generated -// version pulls the prebuilt `.node` file out of `optionalDependencies` so -// installs don't need a Rust toolchain. +// on `process.platform` + `process.arch` + libc detection. Published installs +// pull the prebuilt `.node` file out of `optionalDependencies`, so consumers +// don't need a Rust toolchain. // // **File extension note:** this file is `.cjs` (not `.js`) because the // umbrella package is `"type": "module"`, which would make Node treat a // bare `.js` as ESM and reject the `module.exports` below at load time. -// `napi build` is invoked with `--js src/binding.cjs` (see -// `package.json` scripts + `.github/workflows/napi-build.yml`) so the -// regeneration writes back to the `.cjs` path; both `src/index.js` -// (ESM facade) and `src/index.cjs` (CJS facade) `require('./binding.cjs')`. +// Both `src/index.js` (ESM facade) and `src/index.cjs` (CJS facade) +// `require('./binding.cjs')`. // -// This stub matches the napi-rs-generated dispatcher *shape* so the umbrella -// package's TS facade (`src/index.js`) can import from it during local dev / -// CI conformance scaffolding before the prebuilt binaries exist. While -// #247-a is in flight, we throw a clear "binding not built" error instead of -// requiring `*.node` artifacts that don't exist yet. +// This hand-written dispatcher matches the napi-rs loader shape while keeping +// a clear error for fresh checkouts where neither a local build nor a +// platform package is available. // -// Once `napi build` runs in CI for the first time, this file is overwritten; -// see `.github/workflows/napi-build.yml`. +// `napi build ... src` emits `src/index..node` next to this loader; +// see the package scripts and `.github/workflows/napi-build.yml`. const { existsSync, readFileSync } = require('node:fs'); const { join } = require('node:path'); @@ -44,9 +40,9 @@ let nativeBinding = null; let loadError = null; function tryRequire(specifier, localFile) { - // Prefer the optional-dep platform package; fall back to a sibling .node - // that `napi build --release` drops next to this loader during local dev. - const localPath = localFile ? join(__dirname, '..', localFile) : null; + // Prefer the sibling .node emitted by a local build; published installs + // fall back to the optional-dependency platform package. + const localPath = localFile ? join(__dirname, localFile) : null; if (localPath && existsSync(localPath)) { try { return require(localPath); @@ -63,20 +59,18 @@ function tryRequire(specifier, localFile) { } if (platform === 'darwin' && arch === 'arm64') { - nativeBinding = tryRequire('@relayburn/sdk-darwin-arm64', 'relayburn-sdk.darwin-arm64.node'); + nativeBinding = tryRequire('@relayburn/sdk-darwin-arm64', 'index.darwin-arm64.node'); } else if (platform === 'darwin' && arch === 'x64') { - nativeBinding = tryRequire('@relayburn/sdk-darwin-x64', 'relayburn-sdk.darwin-x64.node'); + nativeBinding = tryRequire('@relayburn/sdk-darwin-x64', 'index.darwin-x64.node'); } else if (platform === 'linux' && arch === 'arm64' && !isMusl()) { - nativeBinding = tryRequire('@relayburn/sdk-linux-arm64-gnu', 'relayburn-sdk.linux-arm64-gnu.node'); + nativeBinding = tryRequire('@relayburn/sdk-linux-arm64-gnu', 'index.linux-arm64-gnu.node'); } else if (platform === 'linux' && arch === 'x64' && !isMusl()) { - nativeBinding = tryRequire('@relayburn/sdk-linux-x64-gnu', 'relayburn-sdk.linux-x64-gnu.node'); + nativeBinding = tryRequire('@relayburn/sdk-linux-x64-gnu', 'index.linux-x64-gnu.node'); } if (!nativeBinding) { - // Surface a clear actionable error. While #247-a is still merging, this is - // the failure mode CI / dev machines will hit; the conformance test - // `test/conformance.test.js` checks for it and skips so the suite stays - // green until bindings land. + // Surface a clear actionable error for fresh local checkouts and broken + // optional-dependency installs. const detail = loadError ? `\nUnderlying error: ${loadError.message}` : ''; diff --git a/packages/sdk-node/test/conformance.test.js b/packages/sdk-node/test/conformance.test.js index 56b68722..1d186b19 100644 --- a/packages/sdk-node/test/conformance.test.js +++ b/packages/sdk-node/test/conformance.test.js @@ -3,8 +3,8 @@ // These tests run the napi-rs facade against the committed cli-golden ledger. // They are intentionally shape-level checks now that the old TypeScript SDK // package has been removed from the workspace. Set RELAYBURN_SDK_NAPI_BUILT=1 -// after `pnpm run build:napi` to execute them; without a native binding they -// skip cleanly so package-level JS tests still work on a fresh checkout. +// after `pnpm run build:napi` to execute them. A fresh local checkout skips +// cleanly, while CI fails if the gate or native binding is missing. import { test } from 'node:test'; import assert from 'node:assert/strict'; @@ -12,31 +12,11 @@ import { mkdtempSync, rmSync, cpSync, mkdirSync, readdirSync, readFileSync } fro import { tmpdir } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { loadNapiSdk } from './helpers/napi.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, '../../..'); const FIXTURE_LEDGER = join(REPO_ROOT, 'tests', 'fixtures', 'cli-golden', 'ledger'); -const NAPI_READY = process.env.RELAYBURN_SDK_NAPI_BUILT === '1'; - -function bindingMissing(err) { - return /native binding not found/i.test(String(err && err.message)); -} - -async function loadNapiSdk(t) { - if (!NAPI_READY) { - t.skip('napi-rs binding not built; set RELAYBURN_SDK_NAPI_BUILT=1'); - return null; - } - try { - return await import(join(__dirname, '..', 'src', 'index.js')); - } catch (err) { - if (bindingMissing(err)) { - t.skip('napi-rs binding load failed; build artifact missing'); - return null; - } - throw err; - } -} function makeLedgerHome() { const home = mkdtempSync(join(tmpdir(), 'relayburn-sdk-ledger-')); diff --git a/packages/sdk-node/test/helpers/napi.js b/packages/sdk-node/test/helpers/napi.js new file mode 100644 index 00000000..5b859260 --- /dev/null +++ b/packages/sdk-node/test/helpers/napi.js @@ -0,0 +1,34 @@ +import { createRequire } from 'node:module'; +import { dirname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const NAPI_READY = process.env.RELAYBURN_SDK_NAPI_BUILT === '1'; +const IN_CI = Boolean(process.env.CI); +const SDK_SRC = join(__dirname, '..', '..', 'src'); + +export async function loadNapiSdk(t) { + if (!NAPI_READY) { + if (IN_CI) { + throw new Error( + 'RELAYBURN_SDK_NAPI_BUILT=1 is required in CI; run pnpm run build:napi first', + ); + } + t.skip('napi-rs binding not built; set RELAYBURN_SDK_NAPI_BUILT=1'); + return null; + } + + // When the caller claims the binding is ready, a missing or unloadable + // artifact is a test failure rather than another silent skip. + const sdk = await import(join(SDK_SRC, 'index.js')); + const loadedNodeModules = Object.keys(require.cache).filter((path) => path.endsWith('.node')); + const localPrefix = `${SDK_SRC}${sep}`; + if (!loadedNodeModules.some((path) => path.startsWith(localPrefix))) { + throw new Error( + `Node SDK conformance must load the locally built binding under ${SDK_SRC}; ` + + `loaded native modules: ${loadedNodeModules.join(', ') || '(none)'}`, + ); + } + return sdk; +} diff --git a/packages/sdk-node/test/no-onlog.test.js b/packages/sdk-node/test/no-onlog.test.js index 3bbae985..a73047f6 100644 --- a/packages/sdk-node/test/no-onlog.test.js +++ b/packages/sdk-node/test/no-onlog.test.js @@ -11,9 +11,11 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { loadNapiSdk } from './helpers/napi.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const DTS_PATH = resolve(__dirname, '..', 'src', 'index.d.ts'); @@ -33,24 +35,18 @@ test('public option types no longer declare onLog (#374)', () => { }); test('verbs tolerate a stray onLog property at runtime', async (t) => { - if (process.env.RELAYBURN_SDK_NAPI_BUILT !== '1') { - t.skip('napi-rs binding not built — set RELAYBURN_SDK_NAPI_BUILT=1'); - return; - } - let sdk; - try { - sdk = await import(join(__dirname, '..', 'src', 'index.js')); - } catch (err) { - if (/native binding not found/i.test(String(err && err.message))) { - t.skip('napi-rs binding load failed — build artifact missing'); - return; - } - throw err; - } + const sdk = await loadNapiSdk(t); + if (!sdk) return; + // Cast through any-shape to bypass the now-stricter option types: the // contract under test is the runtime forgiveness, not the TS shape. - const stray = { onLog: () => {} }; - await assert.doesNotReject(() => sdk.summary(stray)); - await assert.doesNotReject(() => sdk.sessionCost(stray)); - await assert.doesNotReject(() => sdk.hotspots(stray)); + const ledgerHome = mkdtempSync(join(tmpdir(), 'relayburn-sdk-onlog-')); + const stray = { ledgerHome, onLog: () => {} }; + try { + await assert.doesNotReject(() => sdk.summary(stray)); + await assert.doesNotReject(() => sdk.sessionCost(stray)); + await assert.doesNotReject(() => sdk.hotspots(stray)); + } finally { + rmSync(ledgerHome, { recursive: true, force: true }); + } }); From df1b550b21f1b6f0670d197bf997bd78b8c7b628 Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Mon, 3 Aug 2026 15:10:12 -0400 Subject: [PATCH 2/2] ci: harden Node conformance prerequisites --- .github/workflows/ci.yml | 16 ++++++++++++++++ .github/workflows/napi-build.yml | 2 +- .github/workflows/publish.yml | 8 +++++++- packages/sdk-node/src/binding.cjs | 2 +- packages/sdk-node/test/esbuild-smoke.test.js | 3 ++- 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 086c4a84..6cfc7269 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,22 @@ jobs: - name: Build npm wrappers run: pnpm -r --if-present run build + - name: Setup Rust toolchain + # Keep the napi build on the same pinned toolchain as the Rust job; + # upgrade a stale preinstalled stable before cargo resolves it. + run: rustup toolchain install + + - name: Restore cargo registry + target cache + uses: actions/cache/restore@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: cargo-${{ runner.os }}-${{ hashFiles('**/Cargo.lock', '**/Cargo.toml', 'rust-toolchain.toml') }} + restore-keys: | + cargo-${{ runner.os }}- + - name: Build native Node SDK binding # The release/cross-platform napi matrix is path-filtered and does not # run for changes such as MCP-only PRs. Build one native debug binding diff --git a/.github/workflows/napi-build.yml b/.github/workflows/napi-build.yml index 58c09f18..a624a651 100644 --- a/.github/workflows/napi-build.yml +++ b/.github/workflows/napi-build.yml @@ -185,7 +185,7 @@ jobs: with: name: relayburn-sdk-${{ matrix.short }} path: packages/sdk-node/src/*.node - if-no-files-found: warn + if-no-files-found: error retention-days: 7 - name: Bundle smoke test (esbuild) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 93a6418b..39b41d46 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -311,9 +311,15 @@ jobs: run: | set -euo pipefail sdk_test_dir="/tmp/sdk-artifacts/relayburn-sdk-linux-x64-gnu" - sdk_test_node=$(find "$sdk_test_dir" -maxdepth 1 -type f -name '*.node' -print -quit) + if [ ! -d "$sdk_test_dir" ]; then + echo "::error title=Missing SDK test artifact dir::expected $sdk_test_dir" >&2 + ls -la /tmp/sdk-artifacts/ 2>/dev/null || true + exit 1 + fi + sdk_test_node=$(find "$sdk_test_dir" -maxdepth 2 -type f -name '*.node' -print -quit) if [ -z "$sdk_test_node" ]; then echo "::error title=Missing SDK test artifact::expected a .node file under $sdk_test_dir" >&2 + find "$sdk_test_dir" -maxdepth 2 -type f -print >&2 || true exit 1 fi cp "$sdk_test_node" packages/sdk-node/src/index.linux-x64-gnu.node diff --git a/packages/sdk-node/src/binding.cjs b/packages/sdk-node/src/binding.cjs index 4235b7d7..c8f4d7ea 100644 --- a/packages/sdk-node/src/binding.cjs +++ b/packages/sdk-node/src/binding.cjs @@ -18,7 +18,7 @@ // `napi build ... src` emits `src/index..node` next to this loader; // see the package scripts and `.github/workflows/napi-build.yml`. -const { existsSync, readFileSync } = require('node:fs'); +const { existsSync } = require('node:fs'); const { join } = require('node:path'); const { platform, arch } = process; diff --git a/packages/sdk-node/test/esbuild-smoke.test.js b/packages/sdk-node/test/esbuild-smoke.test.js index 12b282fa..8c5c2d48 100644 --- a/packages/sdk-node/test/esbuild-smoke.test.js +++ b/packages/sdk-node/test/esbuild-smoke.test.js @@ -64,7 +64,8 @@ test('esbuild bundles the @relayburn/sdk umbrella facade cleanly', async (t) => let esbuild; try { esbuild = await import('esbuild'); - } catch (_) { + } catch (err) { + if (process.env.CI) throw err; t.skip('esbuild not installed — run `pnpm install` first'); return; }