v0.2.20 — staged publishing, self-hosted suite, concurrent tests, ESM-only, CLI-only surface
Added
-
Concurrent tests with correct snapshot attribution, via AsyncContext. Run-time current-test tracking is now an
AsyncContext.Variable(@b9g/async-context, the TC39-shaped shim overAsyncLocalStorage) instead of a module global, so interleaved test bodies each see their own name:toMatchSnapshot()insidetest.concurrent(bun) or options-formtest(name, {concurrency}, fn)(node) files each snapshot under its own key - previously concurrent bodies would have silently misattributed each other's snapshots through the shared global, and.concurrentbodies weren't name-tracked or registration-counted at all.test.concurrentis also shimmed where the runtime lacks it (node backend, browser runner) as plain registration - sequential execution is a conforming implementation of concurrent semantics - so bun-authored concurrent suites run unchanged everywhere. Adds a@b9g/async-contextdependency;node:async_hooksjoins the browser-bundle stubs..eachis now implemented once by libuild for every runtime rather than delegated: rows register through the wrapped block, so each row gets its formatted name, a tracked body, and an accurate count identically on bun and node (delegating left rows sharing one snapshot bucket numbered in execution order - fine sequentially, order-dependent under concurrency). Sub-methods are wrapped recursively, so chains (test.concurrent.each,test.skip.each) survive and runtime-specific variants (skipIf,todoIf) get tracking for free.Known limitation: an
asyncdescribe body suspends with its prefix pushed, so a sibling describe registering during that suspension inherits it. The prefix cannot be async-scoped: registering abun:testtest from inside anAsyncLocalStoragescope makes bun hang waiting for adonecallback (verified on bun 1.3.14). Sync describe bodies - every real-world case - are unaffected. -
libuild stage: staged publishing as its own command. Builds and runsnpm stage publishinsidedist/- the version is uploaded to the registry in a not-installable state until a human runsnpm stage approve <stage-id>, which always prompts for 2FA and therefore cannot be automated. A separate command rather than apublishflag: staging is a different operation, not a variant - the artifact does not go live. Shares publish's whitelist-validated flag parsing,--access publicfor scoped names, and--savesemantics. Two preflights fail fast with clear errors before anything builds: staged publishing needs npm >= 11.15.0, and the package must already exist on the registry (a first release must be a normallibuild publish). The release workflow now runslibuild stage --provenance, so with a stage-only trusted publisher CI can never make a version live on its own. -
--concurrency <n>: max test files running at once per platform (default: CPU count - 1). A suite whose every test spawns its own processes (builds, servers) multiplies at full parallelism and starves itself - libuild's own suite runs at 4.
Changed
- libuild's own suite is self-hosted:
npm testruns it throughlibuild teston bun AND node. "libuild works under node" is now a gate, not a claim - and the migration immediately caught four runner bugs consumers would have hit (below:__dirname, the require-shim collision,NODE_TEST_CONTEXTinheritance, and the missing concurrency knob). Rawbun teststill works (test:bun): the suite imports the platform dispatcher, which picks bun's backend natively there. typescriptpeer range widened to^5.0.0 || ^6.0.0- installs alongside TypeScript 6 no longer ERESOLVE.
Fixed
__dirname/__filenamework in test files - raw bun provides the CJS globals even in transpiled TS, so jest-heritage suites are full of them, and ESM bundles have neither. They join the per-fileimport.metarewrite, pointing at each source file's real location.- The require-shim banner no longer collides with consumer imports of
createRequire- the banner now importsnode:moduleunder a private namespace, so a test file (or its imports) usingcreateRequireitself bundles cleanly. - Spawned
node --testshards no longer inheritNODE_TEST_CONTEXT- when libuild test is itself invoked from inside a node:test run, the inherited marker made every child conclude it was recursing and exit 0 having run nothing ("skipping running files"); the completed-guard turned that into loud failures, but now it just works. The env var is stripped from shard environments. import.meta.url/.dirname/.filenamepoint at the source test file, not the bundle (reported by the fold migration). Bundling collapsed every module's location to.libuild-test/bundle-*.js, silently breaking the fixtures-next-to-the-test pattern (new URL("./fixtures/x.json", import.meta.url)) with failures that read as bugs in the code under test. On node/bun bundles, esbuild'sdefinenow maps the three path-shaped members to per-file bindings injected (one line, no line-number shift) into each file that mentionsimport.meta. Browser bundles are unchanged - a page has no source filesystem. (import.metaused bare, or other properties, still see the bundle.)- A file that registers no tests counts as
0 passed, not1(fold'stests/helpers.js). node fabricates one passing test named with the file's path when a file registers nothing, and includes it in# pass; the synthetic entry is now recognized and excluded, so shared helpers swept up by the test glob report honestly.
Removed
- The
././libuildroot export is gone: libuild is a CLI, not a library.build()/publish()were never a designed API - the root export existed because zero-config discovery promoted a top-level implementation file. The implementation now lives insrc/internal/(with the other internals: esbuild recovery, snapshots, the test runners) and the package surface is exactly thelibuildbin plus@b9g/libuild/test. Importing@b9g/libuildnow fails withERR_PACKAGE_PATH_NOT_EXPORTED. - Main-entry detection no longer guesses. With nothing declared (no
exports["."],main,module, orindexentry, multiple entries, no name match), the build now emits NO root export instead of alphabetically electing one - the old fallback would have silently made.point atcli.jshere. Single-entry and name-matching packages are unaffected.