feat: convert toolchain to Bun and ship standalone binaries - #27
Merged
Conversation
Phase 1 of the Bun conversion: pure tooling, no runtime source changes. - bun.lock generated via bun install (migrated from package-lock.json, preserving resolutions); package-lock.json removed - tsx devDependency dropped; dev scripts run TypeScript natively via bun - npm prebuild/postbuild lifecycle hooks inlined into build (bun run does not execute npm-style pre/post hooks; keeps dist/cli.js exec bit) - build stays tsc for .d.ts declaration output (npm library consumers) - test scripts intentionally untouched (vitest until phase 2) - scripts/pack-baseline.txt records the pre-conversion npm-pack file list; CI diffs against it from phase 4 onward - codegen output verified byte-identical under bun (git diff clean on src/workos/generated after gen:events + gen:shapes) Note: committed unsigned — 1Password SSH agent unavailable in this headless session. Re-sign with: git commit --amend -S --no-edit Review: passed cycle 1 of 3, no findings. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 2 of the Bun conversion: all 50 spec files now run under Bun's native test runner and the vitest dependency tree is gone. - import sweep: from 'vitest' -> from 'bun:test' across 50 spec files (spec counted 51 -- off-by-one at planning time; nothing was deleted) - fake timers (jwt, store specs): vi.useFakeTimers/vi.setSystemTime -> bun:test setSystemTime; afterEach restores real time via no-arg call - fetch spies (event-bus spec): both vi.spyOn sites -> bun:test spyOn, restored in afterEach so a failing test cannot leak a mocked fetch across files in Bun's single-process runner - vi.waitFor (e2e spec) -> inlined poll helper - scripts: test/test:watch/test:coverage now bun test; vitest and @vitest/coverage-v8 dropped; @types/bun added - tsconfig.tests.json: editor/typecheck project for spec files with bun + node types (main tsconfig deliberately stays node-only so library code cannot grow Bun-only APIs) - vitest.config.ts deleted 516 tests pass across 50 files; the 8 real-boot specs prove @hono/node-server and node:crypto work under the Bun runtime. Note: committed unsigned -- 1Password SSH agent unavailable in this headless session. Re-sign with: git commit --amend -S --no-edit Review: passed cycle 2 of 3 (cycle-1 high finding fixed: fetch-spy restore moved to afterEach per spec). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 3 of the Bun conversion: standalone-binary compile pipeline plus the two smoke scripts that become permanent CI gates in phase 4. - build:binary compiles src/cli.ts into dist/workos-emulate via bun build --compile (host platform only; cross-compile is phase 5) - scripts/smoke-binary.sh boots the compiled binary and asserts /oauth2/jwks serves a non-empty keys array -- exercises RSA keygen + JWK export, the highest-risk node:crypto surface under Bun - scripts/smoke-node.mjs boots dist/index.js under plain Node and fetches JWKS -- guards the npm library path against Bun-only APIs leaking into dist/ - .gitignore already covers dist/workos-emulate (dist/) -- no change Verified: binary boots on default port 4100 with banner, SIGINT exits cleanly, seed auto-detection and --validate-config work cwd-relative from a scratch dir; 516 tests pass; typecheck + lint clean. Note: committed unsigned -- 1Password SSH agent unavailable in this headless session. Re-sign with: git commit --amend -S --no-edit Review: passed cycle 1 of 3 (2 low, non-blocking). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Phase 4 of the Bun conversion: CI now runs the same toolchain the repo uses locally, plus the guards phases 1-3 created. - ci.yml: Node 22/24 npm matrix -> single Bun test job (install --frozen-lockfile, typecheck, build, bun test, npm-pack baseline diff, binary smoke, 5-target cross-compile dry-run) and a separate node-smoke job that builds with Bun but executes scripts/smoke-node.mjs under Node 22 -- the engines.node floor - lint.yml: setup-node/npm ci -> setup-bun/bun install --frozen-lockfile; lint + fmt:check via bun run - oven-sh/setup-bun SHA-pinned (v2.2.0) per repo convention; bun-version pinned to 1.3.11 so compiled binaries embed a reproducible runtime - pack-baseline diff normalizes both sides with LC_ALL=C sort: the baseline is macOS-locale-sorted and would mis-compare under the runners' C.UTF-8 collation - release.yml untouched (phase 5 scope) Verified locally: install/typecheck/build clean, 516 tests pass, pack-baseline diff empty, both smoke scripts OK, linux-x64 cross-compile produces an ELF binary, actionlint clean on both workflows. Note: committed unsigned -- 1Password SSH agent unavailable in this headless session. Re-sign with: git commit --amend -S --no-edit Review: passed cycle 1 of 3 (3 low, non-blocking). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The emulator's promise is "point any SDK at localhost", but running it required a Node toolchain — a non-starter for Ruby, Python, Go, and PHP developers. Releases now attach self-contained executables for 5 platforms and bump the workos/homebrew-tap formula, so `brew install workos/tap/workos-emulate` works without Node. Non-obvious choices: - The homebrew job re-downloads checksums from the release instead of rebuilding, so the formula always describes the exact uploaded artifacts. - The publish job's install/build move to bun because deleting package-lock.json broke `npm ci`; `npm publish --provenance` stays on npm deliberately — OIDC trusted publishing has no bun equivalent. - release-please.yml passes HOMEBREW_TAP_TOKEN through explicitly: reusable workflows do not inherit caller secrets. HOMEBREW_TAP_TOKEN (fine-grained PAT, contents:write on workos/homebrew-tap) must be provisioned before the first post-merge release; the homebrew job fails fast until then.
A long-lived HOMEBREW_TAP_TOKEN PAT needs manual creation, rotation, and expiry babysitting. Minting a short-lived installation token from the org SDK bot app (`actions/create-github-app-token`, scoped to workos/homebrew-tap with contents:write only) removes the standing credential entirely — matching the release pipeline in workos/cli (workos/cli#196). Also gates the homebrew job to stable tags: prerelease versions publish to npm under the beta dist-tag but should never reach `brew upgrade`.
Greptile SummaryThis PR migrates the project toolchain and test suite to Bun while adding standalone binary distribution.
Confidence Score: 5/5The PR appears safe to merge. No blocking failure remains; the shared typecheck script now invokes the dedicated test project, and CI runs that script. Important Files Changed
|
Comment on lines
+1
to
+10
| { | ||
| "extends": "./tsconfig.json", | ||
| "compilerOptions": { | ||
| "noEmit": true, | ||
| "rootDir": ".", | ||
| "types": ["bun", "node"] | ||
| }, | ||
| "include": ["src/**/*.spec.ts", "scripts/**/*.spec.ts", "src/**/*.test-utils.ts"], | ||
| "exclude": ["node_modules", "dist"] | ||
| } |
There was a problem hiding this comment.
The main TypeScript project excludes spec and test-utility files, while CI runs only tsc --noEmit against that main configuration. Because nothing invokes this new Bun-aware test project, type errors in the migrated tests are not caught automatically.
Prompt To Fix With AI
This is a comment left during a code review.
Path: tsconfig.tests.json
Line: 1-10
Comment:
**Test project is not checked**
The main TypeScript project excludes spec and test-utility files, while CI runs only `tsc --noEmit` against that main configuration. Because nothing invokes this new Bun-aware test project, type errors in the migrated tests are not caught automatically.
How can I resolve this? If you propose a fix, please make it concise.Greptile review finding on #27: tsconfig.tests.json existed but no command ever invoked it, so type errors in the migrated specs were never caught. `typecheck` now checks both projects, which surfaced 33 latent errors: - bare res.json() sites cast to any per the existing spec idiom (Bun's fetch types return unknown, vitest's returned any) - sessions.spec.ts fixtures gain the required WorkOSSession fields (auth_method, status, expires_at, ended_at), mirroring the production insert in routes/auth.ts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Compiled executables autoload .env and bunfig.toml from whatever directory they run in, letting local config silently change a shipped binary's behavior; the --no-compile-autoload-* flags close that. The packageManager pin keeps local and CI Bun versions in lockstep.
The shell harness needed bash, curl, jq, and a fixed port, so it could not run on the Windows runners or Alpine musl containers where release binaries need validating, and the fixed port made it flaky under parallel CI. A Node harness runs everywhere Node does and asks the emulator for an ephemeral port instead. Booting and serving JWKS also proved little about the published library; the Node suite now exercises the surfaces most likely to depend on Bun-only APIs: HTTP serving, HMAC webhook signatures, authentication, error hooks, and reset.
Cross-compiled binaries were uploaded without ever being executed, so a target that failed to boot would ship silently — and npm published before binaries existed, leaving a half-released version with no safe retry when a build failed late. Every artifact now runs on matching hardware (Alpine containers for musl) before anything is uploaded, npm and Homebrew wait for validated assets, and publishing is idempotent so a tagged release can be re-run via workflow_dispatch. x64 targets use Bun's baseline variant so binaries work on older CPUs without AVX2; musl and Windows arm64 round out the supported platforms.
Detect npm, Homebrew, and direct-download installations and check the appropriate release source without affecting machine-readable output. Replace the brittle npm pack file snapshot with an installed-tarball contract test.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
bun.lockreplacespackage-lock.json,tsxis dropped in favor of Bun's native TypeScript execution, and npm-style pre/post build hooks are inlined sincebun rundoesn't execute them.buildstays ontscso npm library consumers keep.d.tsoutput.tsconfig.tests.jsonkeeps Bun types out of the main project so library code can't grow Bun-only APIs.bun build --compileproduces self-contained executables so Ruby, Python, Go, etc developers can run the emulator without a Node toolchain. Smoke scripts boot both the compiled binary and the plain-Nodedist/build and assert/oauth2/jwksserves keys — the highest-risknode:cryptosurface under Bun.node-smokejob that enforces theengines.nodefloor.oven-sh/setup-bunis SHA-pinned andbun-versionis pinned to 1.3.11 so compiled binaries embed a reproducible runtime.workos/homebrew-tapformula bump, sobrew install workos/tap/workos-emulateworks without Node. The tap push mints a short-lived GitHub App installation token instead of a long-lived PAT, and the Homebrew job is gated to stable tags so betas never reachbrew upgrade.