Skip to content

feat: convert toolchain to Bun and ship standalone binaries - #27

Merged
gjtorikian merged 12 commits into
mainfrom
ideation/bun-conversion
Jul 27, 2026
Merged

feat: convert toolchain to Bun and ship standalone binaries#27
gjtorikian merged 12 commits into
mainfrom
ideation/bun-conversion

Conversation

@gjtorikian

@gjtorikian gjtorikian commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Tooling and scripts now run on Bunbun.lock replaces package-lock.json, tsx is dropped in favor of Bun's native TypeScript execution, and npm-style pre/post build hooks are inlined since bun run doesn't execute them. build stays on tsc so npm library consumers keep .d.ts output.
  • Test suite migrated from vitest to bun:test — all 50 spec files (516 tests), including fake-timer and fetch-spy rewrites; the vitest dependency tree is gone. A separate tsconfig.tests.json keeps Bun types out of the main project so library code can't grow Bun-only APIs.
  • Standalone binary pipelinebun build --compile produces 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-Node dist/ build and assert /oauth2/jwks serves keys — the highest-risk node:crypto surface under Bun.
  • CI and lint run the same toolchain as local dev — a single Bun test job (typecheck, build, tests, npm-pack baseline diff, binary smoke, 5-target cross-compile dry-run) plus a node-smoke job that enforces the engines.node floor. oven-sh/setup-bun is SHA-pinned and bun-version is pinned to 1.3.11 so compiled binaries embed a reproducible runtime.
  • Releases attach platform binaries and bump Homebrew — executables for 5 platforms plus a workos/homebrew-tap formula bump, so brew install workos/tap/workos-emulate works 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 reach brew upgrade.

gjtorikian and others added 6 commits July 24, 2026 13:21
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-apps

greptile-apps Bot commented Jul 26, 2026

Copy link
Copy Markdown

Greptile Summary

This PR migrates the project toolchain and test suite to Bun while adding standalone binary distribution.

  • Adds Bun-based dependency management, scripts, testing, coverage, and CI workflows.
  • Adds cross-platform binary compilation, native smoke testing, release uploads, and Homebrew formula updates.
  • Preserves Node compatibility and npm package validation.
  • Adds a dedicated test TypeScript project and invokes it from the shared typecheck script.

Confidence Score: 5/5

The 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

Filename Overview
package.json Migrates project scripts and dependencies to Bun while ensuring the typecheck command checks both production and test projects.
tsconfig.tests.json Defines a no-emit Bun-aware TypeScript project covering specifications and test utilities.
.github/workflows/ci.yml Runs the updated typecheck script before building, testing, packaging, and validating standalone binaries.
.github/workflows/release.yml Builds and smoke-tests platform binaries before publishing release, npm, and stable Homebrew artifacts.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Source[TypeScript source] --> Typecheck[Main and test typechecks]
  Source --> TSC[tsc library build]
  Source --> BunCompile[Bun standalone compilation]
  TSC --> NPM[npm package]
  BunCompile --> Smoke[Native platform smoke tests]
  Smoke --> Releases[GitHub release binaries]
  Releases --> Homebrew[Homebrew formula update]
Loading

Reviews (4): Last reviewed commit: "Update lint-pr-title.yml" | Re-trigger Greptile

Comment thread tsconfig.tests.json
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"]
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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.

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.

gjtorikian and others added 6 commits July 26, 2026 15:26
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.
@gjtorikian
gjtorikian merged commit 122a3aa into main Jul 27, 2026
7 checks passed
@gjtorikian
gjtorikian deleted the ideation/bun-conversion branch July 27, 2026 17:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant