From 141058a4b1b0f7215c3d032ca88c5fd4e9b43ceb Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:22:49 -0400 Subject: [PATCH 1/7] Finish the 0.3.0 renames: bunInstall, testForked, bunBundleFormat, env hooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript install task is canonically bunInstall; Mill's inherited npmInstall delegates to it and stays usable. All internal call sites use the canonical name, so install artifacts live in bunInstall.dest. Both module kinds share one test entrypoint name: testForked. The TypeScript test command `test` is a deprecated alias, and the Scala.js bunTest — which duplicated the inherited testForked exactly, since the overridden jsEnvConfig and testLinkTask already put every test run on Bun — is deprecated in its favor. TypeScript bunBundleFormat is Option[String], matching Scala.js. The type disagreement was the one rename no alias could bridge; 0.3.0 takes the one-time break, and None now lets `bun build` infer the format. BunWorkersModule.workerBundleFormat follows it directly. Environment hooks share one vocabulary: bunToolEnv (install, lock, build subprocesses) is defined once on BunToolchainModule and used by every runBun call site — Scala.js, TypeScript, workspace, publish, and workers modules previously mixed bunEnv() and bunToolEnv() — and the TypeScript bunRuntimeEnv is public instead of protected. Integration tests exercise the canonical names throughout, with explicit alias regression coverage: npmInstall must resolve to bunInstall.dest, and the deprecated test / bunTest commands must keep working until their removal at 1.0. MIGRATING-0.3.md documents the new rows and the bunBundleFormat break with a before/after example. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++ README.md | 4 +- docs/MIGRATING-0.3.md | 27 +++++++++ examples/build.mill | 2 +- .../resources/typescript-bundle/build.mill | 2 +- .../mill/bun/BunScalaJSIntegrationTests.scala | 8 ++- .../bun/BunTypeScriptIntegrationTests.scala | 48 ++++++++------- .../bun/BunWorkspaceIntegrationTests.scala | 4 +- millbun/src/mill/bun/BunToolchainModule.scala | 3 + millbun/src/mill/bun/BunWorkersModule.scala | 6 +- millbun/src/mill/bun/BunWorkspaceModule.scala | 4 +- .../bun/BunTypeScriptModule.scala | 60 ++++++++++++------- .../bun/BunTypeScriptWebModule.scala | 2 +- .../scalajslib/bun/BunPublishModule.scala | 2 +- .../scalajslib/bun/BunScalaJSModule.scala | 19 +++--- .../scalajslib/bun/BunScalaJSWebModule.scala | 2 +- 16 files changed, 134 insertions(+), 66 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1418fd4..68544c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Development dependencies are local tooling inputs and are no longer published transitively. - `bunPackageJsonExtras` rejects dependency fields now represented by typed settings. - Missing dependency versions are represented as `latest` instead of an empty package.json value. +- TypeScript `bunBundleFormat` is `Option[String]`, matching Scala.js; `None` lets `bun build` infer. +- `unmanagedDeps` entries are staged into `vendor/` and declared as `file:./vendor/` + dependencies, so local packages install under frozen lockfiles and locks stay portable. +- The TypeScript install task is canonically `bunInstall`; Mill's inherited `npmInstall` delegates to it. +- `bunToolEnv` is defined once on `BunToolchainModule` for all toolchain subprocesses, and the + TypeScript `bunRuntimeEnv` is public. ### Deprecated @@ -36,6 +42,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `bunOptionalDeps` in favor of `npmOptionalDeps`. - `managedBunExecutable` in favor of `bunExecutableOverride`. - The TypeScript `bunCompileExecutable: Boolean` switch in favor of the `compileExecutable` task. +- The TypeScript test command `test` and the Scala.js test command `bunTest`, both in favor of `testForked`. ## [0.2.1] - Overridable test-time JS env (2026-04-17) diff --git a/README.md b/README.md index aefe80d..eef6df6 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ Scala.js versions are explicit: choose the version your application tests agains ./mill app.run ./mill app.bundle ./mill app.compileExecutable -./mill app.test.test +./mill app.test.testForked ``` `BunScalaJSModule` delegates `fastLinkJS`, `fullLinkJS`, and test linking to Mill's standard `ScalaJSModule` hooks. That keeps the plugin compatible with Mill's linker lifecycle and removes its former private linker-worker coupling. @@ -76,7 +76,7 @@ object app extends BunTypeScriptModule { ./mill app.run ./mill app.bundle ./mill app.compileExecutable -./mill app.test.test +./mill app.test.testForked ``` ## Web applications diff --git a/docs/MIGRATING-0.3.md b/docs/MIGRATING-0.3.md index e38fa53..6dcd494 100644 --- a/docs/MIGRATING-0.3.md +++ b/docs/MIGRATING-0.3.md @@ -29,6 +29,25 @@ Subsequent installs are frozen. During a staged migration only, set `MILL_BUN_RE Use `npmDeps`, `npmDevDeps`, `npmOptionalDeps`, `npmPeerDeps`, and `npmOverrides`. `bunPackageJsonExtras` remains available for unmodeled fields such as `scripts`, but now rejects dependency sections so task invalidation and published metadata remain correct. +### `bunBundleFormat` is `Option[String]` on TypeScript modules + +Scala.js and TypeScript previously disagreed on this member's type — `Option[String]` versus +`String` — which no deprecation alias can bridge, so 0.3 takes the one-time break. Both are now +`T[Option[String]]`; `None` lets `bun build` infer the format. + +```scala +// 0.2 +override def bunBundleFormat = Task { "esm" } +// 0.3 +override def bunBundleFormat = Task { Some("esm") } +``` + +### Local packages via `unmanagedDeps` must be directories with a `package.json` + +Entries are staged into `vendor/` beside the generated package.json and declared as +`file:./vendor/` dependencies, so they now work under frozen lockfile installs and the +lockfile stays independent of the checkout path. Tarballs are no longer accepted — unpack them. + ## Renamed APIs | 0.2 name | 0.3 name | Status | @@ -39,9 +58,17 @@ Use `npmDeps`, `npmDevDeps`, `npmOptionalDeps`, `npmPeerDeps`, and `npmOverrides | `bunCompileExecutables` | `compileExecutables` | Compatibility alias retained | | `bunOptionalDeps` | `npmOptionalDeps` | Deprecated compatibility setting | | `managedBunExecutable` | `bunExecutableOverride` | Deprecated compatibility setting | +| `npmInstall` (TypeScript) | `bunInstall` | Mill's inherited name delegates to `bunInstall` and stays usable | +| `test` (TypeScript test modules) | `testForked` | Deprecated compatibility command | +| `bunTest` (Scala.js test modules) | `testForked` (inherited from Mill) | Deprecated compatibility command | The old names are planned for removal at 1.0. +Environment hooks share one vocabulary: `bunToolEnv` (toolchain subprocesses — install, lock, +build) is defined on `BunToolchainModule` for every module kind, and the TypeScript +`bunRuntimeEnv` (program and test processes) is now public. Scala.js keeps `bunJsEnv` / +`bunJsEnvArgs` / `bunTestJsEnv` for its Scala.js-test JS environment, unchanged. + ## Toolchain behavior The default is now checksum-verified managed Bun 1.4.0. To preserve the old PATH behavior: diff --git a/examples/build.mill b/examples/build.mill index a904533..12e20a9 100644 --- a/examples/build.mill +++ b/examples/build.mill @@ -79,7 +79,7 @@ object fullstack extends Module { // No moduleDir or sources overrides needed. // // ./mill tsLib.compile # TypeScript type-check -// ./mill tsLib.test.test # Bun-native tests +// ./mill tsLib.test.testForked # Bun-native tests // ./mill client.bundle # TS browser bundle (imports tsLib) // ./mill scalaClient.bundle # Scala.js browser bundle // ./mill backend.run # JVM Cask server on :8080 diff --git a/millbun/integration/resources/typescript-bundle/build.mill b/millbun/integration/resources/typescript-bundle/build.mill index 02c1ede..3b296da 100644 --- a/millbun/integration/resources/typescript-bundle/build.mill +++ b/millbun/integration/resources/typescript-bundle/build.mill @@ -18,5 +18,5 @@ object app extends BunTypeScriptModule { ) } override def bunBundleTarget = Task { "bun" } - override def bunBundleFormat = Task { "esm" } + override def bunBundleFormat = Task { Some("esm") } } diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index da59704..0836c27 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -123,10 +123,12 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(runBundledScript(mainJs) == "Hello from transitive scala.js bun") } - test("bunTest") { + test("testForked runs Scala.js tests on Bun") { val tester = this.tester("scalajs-test") - val res = tester.eval("app.test.bunTest") + val res = tester.eval("app.test.testForked") assert(res.isSuccess) + // The deprecated alias must keep resolving until removal. + assert(tester.eval("app.test.bunTest").isSuccess) } test("bunfig propagates to Scala.js workspaces without leaking .npmrc") { @@ -150,7 +152,7 @@ object BunScalaJSIntegrationTests extends TestSuite { assert(os.exists(compileWorkspace / "bunfig.toml")) assert(!os.exists(compileWorkspace / ".npmrc")) - val testRes = tester.eval("app.test.bunTest") + val testRes = tester.eval("app.test.testForked") assert(testRes.isSuccess) val testRoot = tester.workspacePath / "out" / "app" / "test" assert(os.exists(testRoot)) diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index f4854a8..8f4dfef 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -86,25 +86,25 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("strict installs require a source lock and bunLock creates it") { val tester = this.tester("typescript-lock") - val missingLock = tester.eval("app.npmInstall") + val missingLock = tester.eval("app.bunInstall") assert(!missingLock.isSuccess) val lockResult = tester.eval("app.bunLock") assert(lockResult.isSuccess) assert(os.exists(tester.workspacePath / "bun.lock")) - val installResult = tester.eval("app.npmInstall") + val installResult = tester.eval("app.bunInstall") assert(installResult.isSuccess) - val args = os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / ".bun-args") + val args = os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / ".bun-args") assert(args.contains("--frozen-lockfile")) } test("bun target ambient types are pinned") { val tester = this.tester("typescript-simple") - val res = tester.eval("app.npmInstall") + val res = tester.eval("app.bunInstall") assert(res.isSuccess) - val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / "package.json")) + val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / "package.json")) val devDeps = packageJson("devDependencies").obj // Pinned, and pinned to the Bun we ship: @types/bun is published in lockstep with Bun. @@ -118,7 +118,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val res = tester.eval("app.compile") assert(res.isSuccess) - val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / "package.json")) + val packageJson = ujson.read(os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / "package.json")) val devDeps = packageJson("devDependencies").obj assert(devDeps("typescript").str == "5.7.3") @@ -128,8 +128,12 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("bun test module") { val tester = this.tester("typescript-tests") - val res = tester.eval("app.test.test") + val res = tester.eval("app.test.testForked") assert(res.isSuccess) + // Deprecated and inherited aliases must keep resolving until removal. + assert(tester.eval("app.test.test").isSuccess) + assert(tester.eval("app.npmInstall").isSuccess) + assert(outputPath(tester, "app.npmInstall") == tester.workspacePath / "out" / "app" / "bunInstall.dest") } test("bundle workers") { @@ -165,7 +169,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val res = tester.eval("app.compile") assert(res.isSuccess) - val installDir = tester.workspacePath / "out" / "app" / "npmInstall.dest" + val installDir = tester.workspacePath / "out" / "app" / "bunInstall.dest" val compileDir = tester.workspacePath / "out" / "app" / "compile.dest" // Install workspace keeps both configs. @@ -183,16 +187,16 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(tester.eval("app.test.bunLock").isSuccess) // Outer module should have is-even in dependencies - val outerRes = tester.eval("app.npmInstall") + val outerRes = tester.eval("app.bunInstall") assert(outerRes.isSuccess) - val outerPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / "package.json")) + val outerPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / "package.json")) assert(outerPkg("dependencies").obj.contains("is-even")) assert(!outerPkg("dependencies").obj.contains("is-odd")) // Test module should have is-odd in devDependencies (not dependencies) - val testRes = tester.eval("app.test.npmInstall") + val testRes = tester.eval("app.test.bunInstall") assert(testRes.isSuccess) - val testPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "test" / "npmInstall.dest" / "package.json")) + val testPkg = ujson.read(os.read(tester.workspacePath / "out" / "app" / "test" / "bunInstall.dest" / "package.json")) assert(testPkg("devDependencies").obj.contains("is-odd")) assert(!testPkg("dependencies").obj.contains("is-odd")) assert(!testPkg("devDependencies").obj.contains("is-even")) @@ -200,7 +204,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(testPkg("dependencies").obj.contains("is-even")) // Tests should actually run (both deps available) - val runRes = tester.eval("app.test.test") + val runRes = tester.eval("app.test.testForked") assert(runRes.isSuccess) } @@ -211,7 +215,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val tester = this.tester("typescript-test-deps") // Without any lock, the install refuses and names the test module's own path. - val unlocked = tester.eval("app.test.npmInstall") + val unlocked = tester.eval("app.test.bunInstall") assert(!unlocked.isSuccess) assert(tester.eval("app.bunLock").isSuccess) @@ -234,8 +238,8 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(declaredDeps(testLock).contains("is-odd")) // The frozen install now succeeds against the test module's own lock. - assert(tester.eval("app.test.npmInstall").isSuccess) - assert(tester.eval("app.test.test").isSuccess) + assert(tester.eval("app.test.bunInstall").isSuccess) + assert(tester.eval("app.test.testForked").isSuccess) } test("unmanaged local packages install under a frozen lockfile") { @@ -248,8 +252,8 @@ object BunTypeScriptIntegrationTests extends TestSuite { // The lock must not record where this repository happens to be checked out. assert(!lock.contains(tester.workspacePath.toString)) - assert(tester.eval("app.npmInstall").isSuccess) - val installed = outputPath(tester, "app.npmInstall") + assert(tester.eval("app.bunInstall").isSuccess) + val installed = outputPath(tester, "app.bunInstall") assert(os.exists(installed / "node_modules" / "local-lib" / "package.json")) assert(tester.eval("app.bundle").isSuccess) @@ -261,12 +265,12 @@ object BunTypeScriptIntegrationTests extends TestSuite { test("test modules adding nothing reuse the outer install") { // A bare test module must not demand a second lockfile. val tester = this.tester("typescript-tests") - assert(tester.eval("app.test.npmInstall").isSuccess) + assert(tester.eval("app.test.bunInstall").isSuccess) assert(!os.exists(tester.workspacePath / "test" / "bun.lock")) // And must actually reuse the outer install — not run a second one that merely succeeds // because the lockfile requirement happens to be off in this suite. - val installPath = outputPath(tester, "app.test.npmInstall") - assert(installPath == tester.workspacePath / "out" / "app" / "npmInstall.dest") + val installPath = outputPath(tester, "app.test.bunInstall") + assert(installPath == tester.workspacePath / "out" / "app" / "bunInstall.dest") } test("bunEnv") { @@ -274,7 +278,7 @@ object BunTypeScriptIntegrationTests extends TestSuite { val res = tester.eval("app.bundle") assert(res.isSuccess) - val installLog = os.read(tester.workspacePath / "out" / "app" / "npmInstall.dest" / ".bun-env-log") + val installLog = os.read(tester.workspacePath / "out" / "app" / "bunInstall.dest" / ".bun-env-log") val compileLog = os.read(tester.workspacePath / "out" / "app" / "compile.dest" / ".bun-env-log") val bundleLog = os.read(tester.workspacePath / "out" / "app" / "bundle.dest" / ".bun-env-log") diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala index 51aee0f..6475eef 100644 --- a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -48,8 +48,8 @@ object BunWorkspaceIntegrationTests extends TestSuite: assert(!os.read(workspaceInstall / ".workspace-installed").contains("shared-local")) val scalaResult = tester.eval("scalaApp.bunInstall") - val typescriptResult = tester.eval("typescriptApp.npmInstall") + val typescriptResult = tester.eval("typescriptApp.bunInstall") assert(scalaResult.isSuccess) assert(typescriptResult.isSuccess) assert(os.isLink(outputPath(tester, "scalaApp.bunInstall") / "node_modules")) - assert(os.isLink(outputPath(tester, "typescriptApp.npmInstall") / "node_modules")) + assert(os.isLink(outputPath(tester, "typescriptApp.bunInstall") / "node_modules")) diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 5cb1f34..5db38be 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -535,6 +535,9 @@ trait BunToolchainModule extends Module { /** Environment passed to Bun subprocesses. */ def bunEnv: T[Map[String, String]] = Task { Map.empty } + /** Environment for Bun toolchain subprocesses: install, lock, build, and bundle. */ + def bunToolEnv: T[Map[String, String]] = Task { bunEnv() } + /** Explicit resolutions for conflicting transitive npm dependency declarations. */ def npmOverrides: T[Map[String, String]] = Task { Map.empty } diff --git a/millbun/src/mill/bun/BunWorkersModule.scala b/millbun/src/mill/bun/BunWorkersModule.scala index cb50d8a..db095c9 100644 --- a/millbun/src/mill/bun/BunWorkersModule.scala +++ b/millbun/src/mill/bun/BunWorkersModule.scala @@ -33,7 +33,7 @@ trait BunWorkersModule extends BunToolchainModule { this: BunTypeScriptModule => def workerBundleTarget: T[String] = Task { bunBundleTarget() } /** Output format for worker bundles. Defaults to the module format. */ - def workerBundleFormat: T[Option[String]] = Task { Some(bunBundleFormat()) } + def workerBundleFormat: T[Option[String]] = Task { bunBundleFormat() } /** Extra raw flags for worker bundling. */ def workerBundleArgs: T[Seq[String]] = Task { Seq.empty } @@ -61,7 +61,7 @@ trait BunWorkersModule extends BunToolchainModule { this: BunTypeScriptModule => val outDir = Task.dest / "workers" // Declared explicitly: the staged tree carries a node_modules symlink into this install, // and Mill's filesystem checker only permits reading a dest we depend on. - npmInstall() + bunInstall() BunToolchainModule.copyWorkspace(compile().path, workspace) os.makeDir.all(outDir) @@ -94,7 +94,7 @@ trait BunWorkersModule extends BunToolchainModule { this: BunTypeScriptModule => target ) ++ formatArgs ++ extraArgs, cwd = workspace, - env = bunEnv() + env = bunToolEnv() ) } diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala index fd5b4d5..dfc3892 100644 --- a/millbun/src/mill/bun/BunWorkspaceModule.scala +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -127,7 +127,7 @@ trait BunWorkspaceModule extends BunToolchainModule: updateLockfile = false ), cwd = Task.dest, - env = bunEnv() + env = bunToolEnv() ) PathRef(Task.dest) } @@ -147,7 +147,7 @@ trait BunWorkspaceModule extends BunToolchainModule: updateLockfile = true ), cwd = Task.dest, - env = bunEnv() + env = bunToolEnv() ) val generated = Task.dest / "bun.lock" diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index 6fcedde..a0490db 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -24,8 +24,13 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with /** Target used by `bun build`: browser | bun | node. */ def bunBundleTarget: T[String] = Task { "bun" } - /** Output format used by `bun build`. */ - def bunBundleFormat: T[String] = Task { if (enableEsm()) "esm" else "cjs" } + /** Output format passed to `bun build --format`; `None` lets bun infer. + * + * `Option[String]` to match the Scala.js module's member of the same name — a `T[String]` + * here made the two module kinds' shared vocabulary diverge on type, which no alias can + * bridge. 0.3.0 takes the one-time break. + */ + def bunBundleFormat: T[Option[String]] = Task { Some(if (enableEsm()) "esm" else "cjs") } /** Emit a standalone executable instead of a JS bundle. */ @deprecated("Use the compileExecutable task", "0.3.0") @@ -49,11 +54,8 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with /** Bun-only package.json fields not modeled by Mill's typed PackageJson. */ def bunPackageJsonExtras: T[ujson.Obj] = Task { ujson.Obj() } - /** Environment for Bun toolchain subprocesses such as install/build/test. */ - protected def bunToolEnv: T[Map[String, String]] = Task { bunEnv() } - /** Runtime environment for Bun-executed programs and tests. */ - protected def bunRuntimeEnv: T[Map[String, String]] = Task { bunEnv() ++ forkEnv() } + def bunRuntimeEnv: T[Map[String, String]] = Task { bunEnv() ++ forkEnv() } /** TypeScript version used for `bun x tsc`. */ def typeScriptVersion: T[String] = Task { "5.7.3" } @@ -146,8 +148,8 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with } } - /** Replace npm install with bun install. */ - override def npmInstall: T[PathRef] = Task { + /** Install dependencies with Bun. The canonical name; Mill's [[npmInstall]] delegates here. */ + def bunInstall: T[PathRef] = Task { val dest = Task.dest os.makeDir.all(dest) mkBunPackageJson() @@ -183,6 +185,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with PathRef(dest) } + /** Mill's inherited install name; delegates to [[bunInstall]]. */ + override def npmInstall: T[PathRef] = Task { bunInstall() } + /** Resolve dependencies and update the source-controlled `bun.lock`. */ def bunLock(): Command[PathRef] = Task.Command { if bunWorkspaceInstall().nonEmpty then @@ -222,7 +227,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with tscCopyGenSources() tscLinkResources() BunTypeScriptModule.removeInstallOnlyConfigs(Task.dest) - ensureInstallArtifacts(Task.dest, npmInstall().path, bunLockfiles()) + ensureInstallArtifacts(Task.dest, bunInstall().path, bunLockfiles()) BunTypeScriptModule.copyBunfigsTo(Task.dest, resolvedBunfigs()) mkTsconfig() @@ -237,7 +242,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with } override def createNodeModulesSymlink: Task[Unit] = Task.Anon { - ensureInstallArtifacts(Task.dest, npmInstall().path, bunLockfiles()) + ensureInstallArtifacts(Task.dest, bunInstall().path, bunLockfiles()) } /** Run the entrypoint directly with Bun. */ @@ -291,7 +296,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with // Declared explicitly: the staged tree carries a node_modules symlink into this // install, and Mill's filesystem checker only permits reading a dest we depend on. - npmInstall() + bunInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -309,10 +314,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with "--outfile", outFile.toString, "--target", - bunBundleTarget(), - "--format", - bunBundleFormat() - ) ++ packagesExternal ++ externalArgs ++ compileArgs ++ bunBuildArgs(), + bunBundleTarget() + ) ++ bunBundleFormat().toSeq.flatMap(format => Seq("--format", format)) + ++ packagesExternal ++ externalArgs ++ compileArgs ++ bunBuildArgs(), cwd = buildDir, env = bunToolEnv() ) @@ -329,7 +333,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with // Declared explicitly: the staged tree carries a node_modules symlink into this // install, and Mill's filesystem checker only permits reading a dest we depend on. - npmInstall() + bunInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -369,7 +373,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val mainFile = resolvedEntrypoint(mainFilePath(), compileDir).relativeTo(compileDir).toString // Declared explicitly: the staged tree carries a node_modules symlink into this // install, and Mill's filesystem checker only permits reading a dest we depend on. - npmInstall() + bunInstall() BunToolchainModule.copyWorkspace(compileDir, buildDir) BunTypeScriptModule.removeInstallOnlyConfigs(buildDir) BunTypeScriptModule.copyBunfigsTo(buildDir, resolvedBunfigs()) @@ -499,8 +503,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with bunTestPackageJson().render() == outer.bunWorkspacePackageJson().render() } - override def npmInstall: T[PathRef] = Task { - if (reusesOuterInstall()) outer.npmInstall() + /** Install this test module's dependencies with Bun; reuses the outer install when equal. */ + def bunInstall: T[PathRef] = Task { + if (reusesOuterInstall()) outer.bunInstall() else { val dest = Task.dest os.makeDir.all(dest) @@ -540,6 +545,9 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with } } + /** Mill's inherited install name; delegates to [[bunInstall]]. */ + override def npmInstall: T[PathRef] = Task { bunInstall() } + /** * Resolve this test module's dependencies and update its own `bun.lock`. * @@ -584,7 +592,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with val dest = Task.dest BunToolchainModule.copyWorkspace(this.compile().path, dest) BunTypeScriptModule.removeInstallOnlyConfigs(dest) - outer.ensureInstallArtifacts(dest, npmInstall().path, bunLockfiles()) + outer.ensureInstallArtifacts(dest, bunInstall().path, bunLockfiles()) BunTypeScriptModule.copyBunfigsTo(dest, outer.resolvedBunfigs()) PathRef(dest) } @@ -601,6 +609,18 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with bunTestArgs() ++ timeoutArgs ++ reporterArgs } + /** Run `bun test`. Named after Mill's standard test entrypoint, so both module kinds share it. */ + def testForked(args: mill.api.Args): Command[CommandResult] = Task.Command { + os.call( + Seq(bunExecutable(), "test") ++ resolvedTestFlags() ++ args.value, + cwd = preparedTestWorkspace().path, + env = outer.bunRuntimeEnv(), + stdout = os.Inherit, + stderr = os.Inherit + ) + } + + @deprecated("Use testForked", "0.3.0") def test(args: mill.api.Args): Command[CommandResult] = Task.Command { os.call( Seq(bunExecutable(), "test") ++ resolvedTestFlags() ++ args.value, diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala index 22c9ac6..98de0c3 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptWebModule.scala @@ -65,7 +65,7 @@ trait BunTypeScriptWebModule extends BunTypeScriptModule: sources() ++ generatedSources() ++ resources(), webEntryPoints(), webPublicSources(), - npmInstall().path, + bunInstall().path, bunfigFiles(), webScriptEntryPoint().path ) diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 74025de..0fb3d6f 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -98,7 +98,7 @@ trait BunPublishModule extends BunScalaJSModule { updateLockfile = false ), cwd = dest, - env = bunEnv() + env = bunToolEnv() ) PathRef(dest) diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 18316ba..2f29847 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -301,7 +301,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac updateLockfile = false ), cwd = dest, - env = bunEnv() + env = bunToolEnv() ) mergeVendoredNodeModules(vendoredEntries, dest / "node_modules") @@ -332,7 +332,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac updateLockfile = true ), cwd = dest, - env = bunEnv() + env = bunToolEnv() ) val generated = dest / "bun.lock" @@ -449,7 +449,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bytecodeArgs ++ bunBundleArgs(), cwd = linked.dest.path, - env = bunEnv() + env = bunToolEnv() ) PathRef(outDir) @@ -481,7 +481,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac splittingArgs ++ bunBundleArgs(), cwd = linked.dest.path, - env = bunEnv() + env = bunToolEnv() ) PathRef(outDir) @@ -518,7 +518,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bytecodeArgs ++ bunBundleArgs(), cwd = buildDir, - env = bunEnv() + env = bunToolEnv() ) PathRef(outFile) @@ -560,7 +560,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac bytecodeArgs ++ bunBundleArgs(), cwd = buildDir, - env = bunEnv() + env = bunToolEnv() ) target -> PathRef(outFile) @@ -624,7 +624,12 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac linked } - /** Run Scala.js tests through Mill's test bridge with Bun as the JS runtime. */ + /** Run Scala.js tests through Mill's test bridge with Bun as the JS runtime. + * + * The inherited `testForked` already does exactly this — the overridden [[jsEnvConfig]] and + * [[testLinkTask]] put every test run on Bun — so this alias adds nothing over it. + */ + @deprecated("Use the inherited testForked", "0.3.0") def bunTest(args: mill.api.Args): Command[(msg: String, results: Seq[mill.javalib.testrunner.TestResult])] = Task.Command { testTask( diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala index 68a2f34..e7dfb84 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSWebModule.scala @@ -94,7 +94,7 @@ trait BunScalaJSWebModule extends BunScalaJSModule: bunExecutable(), Seq("build") ++ entries.map(_.toString) ++ Seq("--minify", "--outdir", destination.toString) ++ bunBundleArgs(), cwd = stage, - env = bunEnv() + env = bunToolEnv() ) PathRef(destination) } From b992fdd3ee75b93069b8a0634e4b7eaf51aceb44 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:41:52 -0400 Subject: [PATCH 2/7] Run the integration suite on shipped defaults with committed lockfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build.mill forced MILL_BUN_REQUIRE_LOCKFILE=false across the entire integration suite, so the strict-lockfile default — the plugin's headline 0.3.0 behavior — was exercised by almost nothing. Every install-path bug fixed earlier in this stack lived in exactly that gap. All 13 real-bun dependency-bearing fixtures now commit a bun.lock generated by the pinned Bun 1.4.0 and install frozen in CI. The RegenerateFixtureLocks entry point re-locks every fixture in one command (MILL_BUN_REGENERATE_LOCKS=1 gates it; without the env it lists what it would do and touches nothing). The forkEnv default is removed; scalajs-dependency-manifests opts out in its own build.mill because its stub cannot produce a real lock. scalajs-transitive is restructured: lib and app both claimed the workspace root as moduleDir, so both resolved the same bun.lock and only one dependency set could be recorded there. lib now lives in lib/ with its own lockfile identity. Committed locks make the Bun pin load-bearing — bun.lock is forward- but not backward-compatible, and the raw UnknownLockfileVersion error from an older Bun never mentions how to recover. A frozen install against a lock the pinned Bun cannot read now fails before bun runs, telling the user to regenerate with bunLock or raise bunVersion. The supported-lockfile-version table lives beside the checksum table, and a unit test forces every bundled Bun to declare its entry. The new typescript-stale-lock fixture proves the guard fires before any bun subprocess is reached, and fails with the guard neutralized. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 + build.mill | 6 +- .../resources/scalajs-bundle/bun.lock | 15 +++++ .../scalajs-dependency-manifests/build.mill | 2 + .../resources/scalajs-transitive/build.mill | 4 +- .../resources/scalajs-transitive/bun.lock | 15 +++++ .../lib/{ => src}/LibWords.scala | 0 .../resources/scalajs-web/bun.lock | 15 +++++ .../resources/typescript-browser/bun.lock | 15 +++++ .../resources/typescript-bundle/bun.lock | 24 +++++++ .../resources/typescript-bunfig/app/bun.lock | 24 +++++++ .../resources/typescript-compile/bun.lock | 24 +++++++ .../resources/typescript-env/bun.lock | 24 +++++++ .../resources/typescript-simple/bun.lock | 24 +++++++ .../typescript-stale-lock/build.mill | 30 +++++++++ .../resources/typescript-stale-lock/bun.lock | 11 ++++ .../typescript-stale-lock/src/main.ts | 1 + .../resources/typescript-tests/bun.lock | 24 +++++++ .../resources/typescript-tsx/bun.lock | 24 +++++++ .../resources/typescript-web/bun.lock | 15 +++++ .../resources/typescript-workers/bun.lock | 32 ++++++++++ .../bun/BunTypeScriptIntegrationTests.scala | 12 ++++ .../src/mill/bun/RegenerateFixtureLocks.scala | 64 +++++++++++++++++++ millbun/src/mill/bun/BunToolchainModule.scala | 34 ++++++++++ millbun/src/mill/bun/BunWorkspaceModule.scala | 2 +- .../bun/BunTypeScriptModule.scala | 3 +- .../scalajslib/bun/BunPublishModule.scala | 2 +- .../scalajslib/bun/BunScalaJSModule.scala | 2 +- .../test/src/mill/bun/BunToolchainTests.scala | 23 +++++++ 29 files changed, 465 insertions(+), 8 deletions(-) create mode 100644 millbun/integration/resources/scalajs-bundle/bun.lock create mode 100644 millbun/integration/resources/scalajs-transitive/bun.lock rename millbun/integration/resources/scalajs-transitive/lib/{ => src}/LibWords.scala (100%) create mode 100644 millbun/integration/resources/scalajs-web/bun.lock create mode 100644 millbun/integration/resources/typescript-browser/bun.lock create mode 100644 millbun/integration/resources/typescript-bundle/bun.lock create mode 100644 millbun/integration/resources/typescript-bunfig/app/bun.lock create mode 100644 millbun/integration/resources/typescript-compile/bun.lock create mode 100644 millbun/integration/resources/typescript-env/bun.lock create mode 100644 millbun/integration/resources/typescript-simple/bun.lock create mode 100644 millbun/integration/resources/typescript-stale-lock/build.mill create mode 100644 millbun/integration/resources/typescript-stale-lock/bun.lock create mode 100644 millbun/integration/resources/typescript-stale-lock/src/main.ts create mode 100644 millbun/integration/resources/typescript-tests/bun.lock create mode 100644 millbun/integration/resources/typescript-tsx/bun.lock create mode 100644 millbun/integration/resources/typescript-web/bun.lock create mode 100644 millbun/integration/resources/typescript-workers/bun.lock create mode 100644 millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala diff --git a/CHANGELOG.md b/CHANGELOG.md index 68544c8..281e213 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `BunWorkspaceModule` for one install and lockfile across mixed Scala.js/TypeScript packages. - Published dependency manifest schema v2 with runtime, optional, and peer requirements. - `bunDoctor` diagnostics and managed-toolchain CI smoke coverage. +- A committed lockfile written by a newer Bun fails frozen installs with regeneration guidance, + instead of surfacing bun's raw `UnknownLockfileVersion`. ### Changed diff --git a/build.mill b/build.mill index 0219c36..ff46997 100644 --- a/build.mill +++ b/build.mill @@ -41,8 +41,10 @@ object millbun extends ScalaModule, PublishModule { // pins rather than whatever the developer happens to have on PATH. The shared // download cache means all fixtures reuse one archive. Export MILL_BUN_USE_SYSTEM=true // to run against a local Bun instead. - "MILL_BUN_USE_SYSTEM" -> Task.env.getOrElse("MILL_BUN_USE_SYSTEM", "false"), - "MILL_BUN_REQUIRE_LOCKFILE" -> Task.env.getOrElse("MILL_BUN_REQUIRE_LOCKFILE", "false") + "MILL_BUN_USE_SYSTEM" -> Task.env.getOrElse("MILL_BUN_USE_SYSTEM", "false") + // The lockfile requirement runs the SHIPPED default (strict): dependency-bearing + // fixtures commit a bun.lock, regenerated via mill.bun.RegenerateFixtureLocks, and + // fixtures that deliberately test the relaxed path opt out in their own build.mill. ) } diff --git a/millbun/integration/resources/scalajs-bundle/bun.lock b/millbun/integration/resources/scalajs-bundle/bun.lock new file mode 100644 index 0000000..5b62cfe --- /dev/null +++ b/millbun/integration/resources/scalajs-bundle/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "4.17.21", + }, + }, + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + } +} diff --git a/millbun/integration/resources/scalajs-dependency-manifests/build.mill b/millbun/integration/resources/scalajs-dependency-manifests/build.mill index d9b0660..1646f15 100644 --- a/millbun/integration/resources/scalajs-dependency-manifests/build.mill +++ b/millbun/integration/resources/scalajs-dependency-manifests/build.mill @@ -17,6 +17,8 @@ trait StubBunModule extends BunScalaJSModule { def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.CommonJSModule } override def bunExecutable = Task { "stub-bun" } + // The stub never produces a real lockfile; the strict path is covered by real-bun fixtures. + override def bunRequireLockfile = Task { false } override protected def runBun( bunExe: String, diff --git a/millbun/integration/resources/scalajs-transitive/build.mill b/millbun/integration/resources/scalajs-transitive/build.mill index 059a350..1cbbf76 100644 --- a/millbun/integration/resources/scalajs-transitive/build.mill +++ b/millbun/integration/resources/scalajs-transitive/build.mill @@ -10,13 +10,13 @@ import mill.scalajslib.* import mill.scalajslib.api.* import mill.scalajslib.bun.* +// lib keeps its own moduleDir (lib/): sharing the root with app would make both modules +// resolve the same bun.lock, and only one dependency set can be recorded there. object lib extends BunScalaJSModule { - override def moduleDir = build.moduleDir def scalaVersion = "3.8.2" def scalaJSVersion = "1.22.0" override def moduleKind = Task { ModuleKind.ESModule } - override def sources = Task.Sources(moduleDir / "lib") override def npmDeps = Task { Seq("lodash@4.17.21") } } diff --git a/millbun/integration/resources/scalajs-transitive/bun.lock b/millbun/integration/resources/scalajs-transitive/bun.lock new file mode 100644 index 0000000..5b62cfe --- /dev/null +++ b/millbun/integration/resources/scalajs-transitive/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "4.17.21", + }, + }, + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + } +} diff --git a/millbun/integration/resources/scalajs-transitive/lib/LibWords.scala b/millbun/integration/resources/scalajs-transitive/lib/src/LibWords.scala similarity index 100% rename from millbun/integration/resources/scalajs-transitive/lib/LibWords.scala rename to millbun/integration/resources/scalajs-transitive/lib/src/LibWords.scala diff --git a/millbun/integration/resources/scalajs-web/bun.lock b/millbun/integration/resources/scalajs-web/bun.lock new file mode 100644 index 0000000..5b62cfe --- /dev/null +++ b/millbun/integration/resources/scalajs-web/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "4.17.21", + }, + }, + }, + "packages": { + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + } +} diff --git a/millbun/integration/resources/typescript-browser/bun.lock b/millbun/integration/resources/typescript-browser/bun.lock new file mode 100644 index 0000000..23524fb --- /dev/null +++ b/millbun/integration/resources/typescript-browser/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + } +} diff --git a/millbun/integration/resources/typescript-bundle/bun.lock b/millbun/integration/resources/typescript-bundle/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-bundle/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-bunfig/app/bun.lock b/millbun/integration/resources/typescript-bunfig/app/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-bunfig/app/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-compile/bun.lock b/millbun/integration/resources/typescript-compile/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-compile/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-env/bun.lock b/millbun/integration/resources/typescript-env/bun.lock new file mode 100644 index 0000000..a7b8336 --- /dev/null +++ b/millbun/integration/resources/typescript-env/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-simple/bun.lock b/millbun/integration/resources/typescript-simple/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-simple/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-stale-lock/build.mill b/millbun/integration/resources/typescript-stale-lock/build.mill new file mode 100644 index 0000000..b60587c --- /dev/null +++ b/millbun/integration/resources/typescript-stale-lock/build.mill @@ -0,0 +1,30 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.javascriptlib.bun.* + +// Pins a Bun that cannot read the committed lockfileVersion (1.3.14 reads only v1; the lock +// says v2). The install must fail with regeneration guidance before bun ever runs — the stub +// proves no subprocess is reached. +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def bunVersion = Task { "1.3.14" } + override def bunVerifyVersion = Task { false } + override def bunExecutable = Task { "stub-bun" } + override def npmDeps = Task { Seq("is-even@1.0.0") } + + override protected def runBun( + bunExe: String, + args: Seq[String], + cwd: os.Path, + env: Map[String, String] + ): os.CommandResult = { + os.write.over(cwd / ".stub-bun-ran", args.mkString(" ")) + os.call(Seq("true"), cwd = cwd, env = env) + } +} diff --git a/millbun/integration/resources/typescript-stale-lock/bun.lock b/millbun/integration/resources/typescript-stale-lock/bun.lock new file mode 100644 index 0000000..4eed9da --- /dev/null +++ b/millbun/integration/resources/typescript-stale-lock/bun.lock @@ -0,0 +1,11 @@ +{ + "lockfileVersion": 2, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "is-even": "1.0.0", + }, + }, + }, +} diff --git a/millbun/integration/resources/typescript-stale-lock/src/main.ts b/millbun/integration/resources/typescript-stale-lock/src/main.ts new file mode 100644 index 0000000..8b179f2 --- /dev/null +++ b/millbun/integration/resources/typescript-stale-lock/src/main.ts @@ -0,0 +1 @@ +console.log("never installs"); diff --git a/millbun/integration/resources/typescript-tests/bun.lock b/millbun/integration/resources/typescript-tests/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-tests/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-tsx/bun.lock b/millbun/integration/resources/typescript-tsx/bun.lock new file mode 100644 index 0000000..4c189bf --- /dev/null +++ b/millbun/integration/resources/typescript-tsx/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-web/bun.lock b/millbun/integration/resources/typescript-web/bun.lock new file mode 100644 index 0000000..23524fb --- /dev/null +++ b/millbun/integration/resources/typescript-web/bun.lock @@ -0,0 +1,15 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "devDependencies": { + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + } +} diff --git a/millbun/integration/resources/typescript-workers/bun.lock b/millbun/integration/resources/typescript-workers/bun.lock new file mode 100644 index 0000000..1f06ab3 --- /dev/null +++ b/millbun/integration/resources/typescript-workers/bun.lock @@ -0,0 +1,32 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "lodash": "4.17.21", + }, + "devDependencies": { + "@types/bun": "1.4.0", + "@types/lodash": "4.17.20", + "typescript": "5.7.3", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/lodash": ["@types/lodash@4.17.20", "", {}, "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index 8f4dfef..d4d2bcc 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -99,6 +99,18 @@ object BunTypeScriptIntegrationTests extends TestSuite { assert(args.contains("--frozen-lockfile")) } + test("a lockfile from a newer Bun fails with regeneration guidance") { + // bun.lock is forward- but not backward-compatible; bun's own failure is a raw + // UnknownLockfileVersion that never mentions bunLock. The fixture's stub proves the + // guard fires before any bun subprocess is reached. + val tester = this.tester("typescript-stale-lock") + val res = tester.eval("app.bunInstall") + assert(!res.isSuccess) + // The message text is asserted at unit level (lockfileSkewError); in-memory evals expose + // no readable error stream, so here the contract is: fail, and never reach bun. + assert(!os.exists(tester.workspacePath / "out" / "app" / "bunInstall.dest" / ".stub-bun-ran")) + } + test("bun target ambient types are pinned") { val tester = this.tester("typescript-simple") val res = tester.eval("app.bunInstall") diff --git a/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala new file mode 100644 index 0000000..4f698c5 --- /dev/null +++ b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala @@ -0,0 +1,64 @@ +package mill.bun + +import mill.testkit.IntegrationTester +import utest.* + +/** + * Maintenance entry point, not a test: regenerates every committed fixture lockfile with the + * pinned Bun, so a dependency-default change or a `bunVersion` bump is one command instead of a + * hunt across fixtures. Without the env guard it reports what would run and does nothing. + * + * {{{ + * MILL_BUN_REGENERATE_LOCKS=1 ./mill millbun.integration.testOnly mill.bun.RegenerateFixtureLocks + * }}} + */ +object RegenerateFixtureLocks extends TestSuite: + val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" + val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) + + /** Fixture -> the bunLock commands that produce its committed lockfiles. */ + val lockedFixtures: Seq[(String, Seq[String])] = Seq( + "scalajs-bundle" -> Seq("app.bunLock"), + "scalajs-transitive" -> Seq("app.bunLock"), + "scalajs-web" -> Seq("app.bunLock"), + "typescript-browser" -> Seq("app.bunLock"), + "typescript-bundle" -> Seq("app.bunLock"), + "typescript-bunfig" -> Seq("app.bunLock"), + "typescript-compile" -> Seq("app.bunLock"), + "typescript-env" -> Seq("app.bunLock"), + "typescript-simple" -> Seq("app.bunLock"), + "typescript-tests" -> Seq("app.bunLock"), + "typescript-tsx" -> Seq("app.bunLock"), + "typescript-web" -> Seq("app.bunLock"), + "typescript-workers" -> Seq("app.bunLock") + ) + + def tests: Tests = Tests: + test("regenerate"): + if !sys.env.get("MILL_BUN_REGENERATE_LOCKS").exists(_.nonEmpty) then + println( + s"MILL_BUN_REGENERATE_LOCKS is not set; would regenerate locks for " + + s"${lockedFixtures.size} fixtures. Set it to 1 to actually rewrite them." + ) + else + lockedFixtures.foreach { case (fixture, commands) => + val tester = new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / fixture, + millExecutable = millExe, + useInMemory = true + ) + commands.foreach { command => + val result = tester.eval(command) + Predef.assert(result.isSuccess, s"$fixture: $command failed") + } + val generated = os + .walk(tester.workspacePath, skip = p => p.last == "out" || p.last == "node_modules") + .filter(_.last == "bun.lock") + Predef.assert(generated.nonEmpty, s"$fixture: no bun.lock produced") + generated.foreach { lock => + val target = resourceDir / fixture / lock.relativeTo(tester.workspacePath) + os.copy.over(lock, target, createFolders = true) + println(s"regenerated ${target.relativeTo(resourceDir)}") + } + } diff --git a/millbun/src/mill/bun/BunToolchainModule.scala b/millbun/src/mill/bun/BunToolchainModule.scala index 5db38be..83491bd 100644 --- a/millbun/src/mill/bun/BunToolchainModule.scala +++ b/millbun/src/mill/bun/BunToolchainModule.scala @@ -75,6 +75,33 @@ object BunToolchainModule { /** Versions with a complete bundled checksum table, for diagnostics. */ private[bun] def bundledVersions: Seq[String] = BundledChecksums.keys.toSeq.sorted + /** Highest `lockfileVersion` each bundled Bun can read. 1.4.0 writes v2; 1.3.14 fails on v2. */ + private val SupportedLockfileVersions: Map[String, Int] = Map( + "1.3.14" -> 1, + "1.4.0" -> 2 + ) + + private[bun] def supportedLockfileVersion(bunVersion: String): Option[Int] = + SupportedLockfileVersions.get(bunVersion) + + /** bun.lock is JSONC (trailing commas), so extract the version lexically rather than parsing. */ + private[bun] def lockfileVersion(lockText: String): Option[Int] = + """"lockfileVersion"\s*:\s*(\d+)""".r.findFirstMatchIn(lockText).map(_.group(1).toInt) + + /** Error text when the committed lock was written by a newer Bun; None when readable. */ + private[bun] def lockfileSkewError( + lockText: String, + lockPath: os.Path, + pinnedBunVersion: String + ): Option[String] = + for { + version <- lockfileVersion(lockText) + supported <- supportedLockfileVersion(pinnedBunVersion) + if version > supported + } yield s"$lockPath has lockfileVersion $version, which Bun $pinnedBunVersion cannot read " + + s"(it supports up to $supported). Regenerate the lockfile with this module's bunLock " + + "command, or raise bunVersion." + /** * Compose a Bun release asset name from the platform axes. * @@ -600,6 +627,7 @@ trait BunToolchainModule extends Module { hasInstallInputs: Boolean, lockfile: Option[PathRef], required: Boolean, + pinnedBunVersion: String, lockfilePath: os.Path = moduleDir / "bun.lock" ): Unit = { if (hasInstallInputs && required && lockfile.isEmpty) { @@ -607,6 +635,12 @@ trait BunToolchainModule extends Module { s"Missing $lockfilePath. Run this module's bunLock command and commit the generated lockfile." ) } + // bun.lock is forward- but not backward-compatible: a lock written by a newer Bun makes an + // older one fail with a raw UnknownLockfileVersion that never mentions how to recover. + lockfile.foreach { lock => + BunToolchainModule.lockfileSkewError(os.read(lock.path), lock.path, pinnedBunVersion) + .foreach(message => throw new RuntimeException(message)) + } } /** diff --git a/millbun/src/mill/bun/BunWorkspaceModule.scala b/millbun/src/mill/bun/BunWorkspaceModule.scala index dfc3892..f9eb152 100644 --- a/millbun/src/mill/bun/BunWorkspaceModule.scala +++ b/millbun/src/mill/bun/BunWorkspaceModule.scala @@ -115,7 +115,7 @@ trait BunWorkspaceModule extends BunToolchainModule: copyConfigs(Task.dest, npmRc().path, bunfigFiles()) val lockfile = bunLockfile() - requireBunLockfile(hasDependencyInputs(packages), lockfile, bunRequireLockfile()) + requireBunLockfile(hasDependencyInputs(packages), lockfile, bunRequireLockfile(), bunVersion()) copyBunLockfile(lockfile, Task.dest) runBun( diff --git a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala index a0490db..caed8b2 100644 --- a/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala +++ b/millbun/src/mill/javascriptlib/bun/BunTypeScriptModule.scala @@ -166,7 +166,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with } case None => val lockfile = bunLockfile() - requireBunLockfile(true, lockfile, bunRequireLockfile()) + requireBunLockfile(true, lockfile, bunRequireLockfile(), bunVersion()) copyBunLockfile(lockfile, dest) BunToolchainModule.stageUnmanagedDeps(transitiveUnmanagedDeps(), dest) @@ -521,6 +521,7 @@ trait BunTypeScriptModule extends TypeScriptModule with BunToolchainModule with hasInstallInputs = true, lockfile = lockfile, required = outer.bunRequireLockfile(), + pinnedBunVersion = outer.bunVersion(), lockfilePath = moduleDir / "bun.lock" ) outer.copyBunLockfile(lockfile, dest) diff --git a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala index 0fb3d6f..8956b06 100644 --- a/millbun/src/mill/scalajslib/bun/BunPublishModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunPublishModule.scala @@ -85,7 +85,7 @@ trait BunPublishModule extends BunScalaJSModule { val hasRuntimeInputs = deps.nonEmpty || optional.nonEmpty || unmanagedDeps().nonEmpty val lockfile = bunLockfile() - requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile()) + requireBunLockfile(hasRuntimeInputs, lockfile, bunRequireLockfile(), bunVersion()) copyBunLockfile(lockfile, dest) if hasRuntimeInputs then BunToolchainModule.stageUnmanagedDeps(unmanagedDeps(), dest) diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index 2f29847..eb0b739 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -287,7 +287,7 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac } case None => val lockfile = bunLockfile() - requireBunLockfile(hasInstallInputs, lockfile, bunRequireLockfile()) + requireBunLockfile(hasInstallInputs, lockfile, bunRequireLockfile(), bunVersion()) copyBunLockfile(lockfile, dest) if hasInstallInputs then diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index 94d9c12..e74f10b 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -151,6 +151,29 @@ object BunToolchainTests extends TestSuite: assert(os.read(cached) == "bun-binary") assert(os.list(cached / os.up) == Seq(cached)) + test("lockfile version is extracted lexically from JSONC"): + // bun.lock has trailing commas, so a JSON parser cannot be the extraction mechanism. + assert(BunToolchainModule.lockfileVersion("{\n \"lockfileVersion\": 2,\n}") == Some(2)) + assert(BunToolchainModule.lockfileVersion("not a lockfile") == None) + + test("a newer lock against an older Bun explains how to recover"): + val lock = "{\n \"lockfileVersion\": 2,\n}" + val error = BunToolchainModule.lockfileSkewError(lock, os.root / "bun.lock", "1.3.14") + assert(error.exists(_.contains("Regenerate the lockfile"))) + assert(error.exists(_.contains("lockfileVersion 2"))) + // Readable combinations stay silent: same version, older lock, or an unbundled Bun. + assert(BunToolchainModule.lockfileSkewError(lock, os.root / "bun.lock", "1.4.0").isEmpty) + val v1 = "{\n \"lockfileVersion\": 1,\n}" + assert(BunToolchainModule.lockfileSkewError(v1, os.root / "bun.lock", "1.3.14").isEmpty) + assert(BunToolchainModule.lockfileSkewError(lock, os.root / "bun.lock", "9.9.9").isEmpty) + + test("every bundled Bun declares its supported lockfile version"): + // Committed fixture locks make the pin load-bearing: a version added to the checksum + // table without a lockfile-version entry silently disables the skew guard for it. + BunToolchainModule.bundledVersions.foreach { version => + assert(BunToolchainModule.supportedLockfileVersion(version).nonEmpty) + } + test("unmanaged deps become file: specifiers under vendor"): val dep = os.temp.dir() / "local-lib" os.write(dep / "package.json", """{"name":"local-lib","version":"1.0.0"}""", createFolders = true) From fcfa17b91e080f85a933362c9eb4d4632c6b89c9 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:45:41 -0400 Subject: [PATCH 3/7] Add Windows to the CI matrix and cache the managed Bun download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin carries Windows-specific code — bun.exe extraction, executableCandidates, the mill.bat fork path in the integration harness — with zero coverage. Both jobs now run on windows-latest with fail-fast off, using bash as the step shell so one ./mill invocation works on all three platforms. The official Mill 1.1.5 mill.bat launcher is committed; build.mill already selects it on Windows, but it never existed, so the integration suite could not have forked a fixture build there at all. Both jobs also cache ~/.cache/mill-bun keyed on the toolchain source, so a managed-Bun run downloads each pinned archive once per platform rather than once per CI run. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 23 ++- mill.bat | 296 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+), 2 deletions(-) create mode 100644 mill.bat diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1694ba3..7411761 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,9 +7,14 @@ on: jobs: build: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + defaults: + run: + # One wrapper invocation everywhere: git-bash on the Windows runner runs ./mill too. + shell: bash steps: - uses: actions/checkout@v4 @@ -18,6 +23,11 @@ jobs: distribution: temurin java-version: '21' + - uses: actions/cache@v4 + with: + path: ~/.cache/mill-bun + key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + - uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.0 @@ -38,9 +48,13 @@ jobs: managed-bun: strategy: + fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash steps: - uses: actions/checkout@v4 @@ -49,6 +63,11 @@ jobs: distribution: temurin java-version: '21' + - uses: actions/cache@v4 + with: + path: ~/.cache/mill-bun + key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + - name: Managed Bun smoke test env: MILL_BUN_USE_SYSTEM: 'false' diff --git a/mill.bat b/mill.bat new file mode 100644 index 0000000..948392f --- /dev/null +++ b/mill.bat @@ -0,0 +1,296 @@ +@echo off + +setlocal enabledelayedexpansion + +if [!DEFAULT_MILL_VERSION!]==[] ( set "DEFAULT_MILL_VERSION=1.1.5" ) + +if [!MILL_GITHUB_RELEASE_CDN!]==[] ( set "MILL_GITHUB_RELEASE_CDN=" ) + +if [!MILL_MAIN_CLI!]==[] ( set "MILL_MAIN_CLI=%~f0" ) + +set "MILL_REPO_URL=https://github.com/com-lihaoyi/mill" + +SET MILL_BUILD_SCRIPT= + +if exist "build.mill" ( + set MILL_BUILD_SCRIPT=build.mill +) else ( + if exist "build.mill.scala" ( + set MILL_BUILD_SCRIPT=build.mill.scala + ) else ( + if exist "build.sc" ( + set MILL_BUILD_SCRIPT=build.sc + ) else ( + rem no-op + ) + ) +) + +if [!MILL_VERSION!]==[] ( + if exist .mill-version ( + set /p MILL_VERSION=<.mill-version + ) else ( + if exist .config\mill-version ( + set /p MILL_VERSION=<.config\mill-version + ) else ( + rem Determine which config file to use for version extraction + set "MILL_VERSION_CONFIG_FILE=" + set "MILL_VERSION_SEARCH_PATTERN=" + + if exist build.mill.yaml ( + set "MILL_VERSION_CONFIG_FILE=build.mill.yaml" + set "MILL_VERSION_SEARCH_PATTERN=mill-version:" + ) else ( + if not "%MILL_BUILD_SCRIPT%"=="" ( + set "MILL_VERSION_CONFIG_FILE=%MILL_BUILD_SCRIPT%" + set "MILL_VERSION_SEARCH_PATTERN=//\|.*mill-version" + ) + ) + + rem Process the config file if found + if not "!MILL_VERSION_CONFIG_FILE!"=="" ( + rem Find the line and process it + for /f "tokens=*" %%a in ('findstr /R /C:"!MILL_VERSION_SEARCH_PATTERN!" "!MILL_VERSION_CONFIG_FILE!"') do ( + set "line=%%a" + + rem --- 1. Replicate sed 's/.*://' --- + rem This removes everything up to and including the first colon + set "line=!line:*:=!" + + rem --- 2. Replicate sed 's/#.*//' --- + rem Split on '#' and keep the first part + for /f "tokens=1 delims=#" %%b in ("!line!") do ( + set "line=%%b" + ) + + rem --- 3. Replicate sed 's/['"]//g' --- + rem Remove all quotes + set "line=!line:'=!" + set "line=!line:"=!" + + rem --- 4. Replicate sed's trim/space removal --- + rem Remove all space characters from the result. This is more robust. + set "MILL_VERSION=!line: =!" + + rem We found the version, so we can exit the loop + goto :version_found + ) + + :version_found + rem no-op + ) + ) + ) +) + +if [!MILL_VERSION!]==[] ( + set MILL_VERSION=%DEFAULT_MILL_VERSION% +) + +if [!MILL_FINAL_DOWNLOAD_FOLDER!]==[] set MILL_FINAL_DOWNLOAD_FOLDER=%USERPROFILE%\.cache\mill\download + +rem without bat file extension, cmd doesn't seem to be able to run it + +set "MILL_NATIVE_SUFFIX=-native" +set "MILL_JVM_SUFFIX=-jvm" +set "MILL_FULL_VERSION=%MILL_VERSION%" +set "MILL_DOWNLOAD_EXT=.bat" +set "ARTIFACT_SUFFIX=" +REM Check if MILL_VERSION contains MILL_NATIVE_SUFFIX +echo !MILL_VERSION! | findstr /C:"%MILL_NATIVE_SUFFIX%" >nul +if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-native=%" + REM -native images compiled with graal do not support windows-arm + REM https://github.com/oracle/graal/issues/9215 + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) else ( + rem no-op + ) +) else ( + echo !MILL_VERSION! | findstr /C:"%MILL_JVM_SUFFIX%" >nul + if !errorlevel! equ 0 ( + set "MILL_VERSION=%MILL_VERSION:-jvm=%" + ) else ( + set "SKIP_VERSION=false" + set "MILL_PREFIX=%MILL_VERSION:~0,4%" + if "!MILL_PREFIX!"=="0.1." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.2." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.3." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.4." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.5." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.6." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.7." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.8." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.9." set "SKIP_VERSION=true" + set "MILL_PREFIX=%MILL_VERSION:~0,5%" + if "!MILL_PREFIX!"=="0.10." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.11." set "SKIP_VERSION=true" + if "!MILL_PREFIX!"=="0.12." set "SKIP_VERSION=true" + + if "!SKIP_VERSION!"=="false" ( + IF /I NOT "%PROCESSOR_ARCHITECTURE%"=="ARM64" ( + set "ARTIFACT_SUFFIX=-native-windows-amd64" + set "MILL_DOWNLOAD_EXT=.exe" + ) + ) else ( + rem no-op + ) + ) +) + +set MILL=%MILL_FINAL_DOWNLOAD_FOLDER%\!MILL_FULL_VERSION!!MILL_DOWNLOAD_EXT! + +set MILL_RESOLVE_DOWNLOAD= + +if not exist "%MILL%" ( + set MILL_RESOLVE_DOWNLOAD=true +) else ( + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + set MILL_RESOLVE_DOWNLOAD=true + ) else ( + rem no-op + ) +) + + +if [!MILL_RESOLVE_DOWNLOAD!]==[true] ( + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,4% + set MILL_SHORT_VERSION_PREFIX=%MILL_VERSION:~0,2% + rem Since 0.5.0 + set MILL_DOWNLOAD_SUFFIX=-assembly + rem Since 0.11.0 + set MILL_DOWNLOAD_FROM_MAVEN=1 + if [!MILL_VERSION_PREFIX!]==[0.0.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.1.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.2.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.3.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.4.] ( + set MILL_DOWNLOAD_SUFFIX= + set MILL_DOWNLOAD_FROM_MAVEN=0 + ) + if [!MILL_VERSION_PREFIX!]==[0.5.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.6.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.7.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.8.] set MILL_DOWNLOAD_FROM_MAVEN=0 + if [!MILL_VERSION_PREFIX!]==[0.9.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + if [!MILL_VERSION_PREFIX!]==[0.10.] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,8% + if [!MILL_VERSION_PREFIX!]==[0.11.0-M] set MILL_DOWNLOAD_FROM_MAVEN=0 + + set MILL_VERSION_PREFIX=%MILL_VERSION:~0,5% + set DOWNLOAD_EXT=exe + if [!MILL_SHORT_VERSION_PREFIX!]==[0.] set DOWNLOAD_EXT=jar + if [!MILL_VERSION_PREFIX!]==[0.12.] set DOWNLOAD_EXT=exe + if [!MILL_VERSION!]==[0.12.0] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.1] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.2] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.3] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.4] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.5] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.6] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.7] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.8] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.9] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.10] set DOWNLOAD_EXT=jar + if [!MILL_VERSION!]==[0.12.11] set DOWNLOAD_EXT=jar + + set MILL_VERSION_PREFIX= + set MILL_SHORT_VERSION_PREFIX= + + for /F "delims=- tokens=1" %%A in ("!MILL_VERSION!") do set MILL_VERSION_BASE=%%A + set MILL_VERSION_MILESTONE= + for /F "delims=- tokens=2" %%A in ("!MILL_VERSION!") do set MILL_VERSION_MILESTONE=%%A + set MILL_VERSION_MILESTONE_START=!MILL_VERSION_MILESTONE:~0,1! + if [!MILL_VERSION_MILESTONE_START!]==[M] ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE!-!MILL_VERSION_MILESTONE! + ) else ( + set MILL_VERSION_TAG=!MILL_VERSION_BASE! + ) + if [!MILL_DOWNLOAD_FROM_MAVEN!]==[1] ( + set MILL_DOWNLOAD_URL=https://repo1.maven.org/maven2/com/lihaoyi/mill-dist!ARTIFACT_SUFFIX!/!MILL_VERSION!/mill-dist!ARTIFACT_SUFFIX!-!MILL_VERSION!.!DOWNLOAD_EXT! + ) else ( + set MILL_DOWNLOAD_URL=!MILL_GITHUB_RELEASE_CDN!%MILL_REPO_URL%/releases/download/!MILL_VERSION_TAG!/!MILL_VERSION!!MILL_DOWNLOAD_SUFFIX! + ) + + if defined MILL_TEST_DRY_RUN_LAUNCHER_SCRIPT ( + echo !MILL_DOWNLOAD_URL! + echo !MILL! + exit /b 0 + ) + + rem there seems to be no way to generate a unique temporary file path (on native Windows) + if defined MILL_OUTPUT_DIR ( + set MILL_TEMP_DOWNLOAD_FILE=%MILL_OUTPUT_DIR%\mill-temp-download + if not exist "%MILL_OUTPUT_DIR%" mkdir "%MILL_OUTPUT_DIR%" + ) else ( + set MILL_TEMP_DOWNLOAD_FILE=out\mill-bootstrap-download + if not exist "out" mkdir "out" + ) + + echo Downloading mill !MILL_VERSION! from !MILL_DOWNLOAD_URL! ... 1>&2 + + curl -f -L "!MILL_DOWNLOAD_URL!" -o "!MILL_TEMP_DOWNLOAD_FILE!" + + if not exist "%MILL_FINAL_DOWNLOAD_FOLDER%" mkdir "%MILL_FINAL_DOWNLOAD_FOLDER%" + move /y "!MILL_TEMP_DOWNLOAD_FILE!" "%MILL%" + + set MILL_TEMP_DOWNLOAD_FILE= + set MILL_DOWNLOAD_SUFFIX= +) + +set MILL_FINAL_DOWNLOAD_FOLDER= +set MILL_VERSION= +set MILL_REPO_URL= + +rem Need to preserve the first position of those listed options +set MILL_FIRST_ARG= +if [%~1%]==[--bsp] ( + set MILL_FIRST_ARG=%1% +) else ( + if [%~1%]==[-i] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--interactive] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-server] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--no-daemon] ( + set MILL_FIRST_ARG=%1% + ) else ( + if [%~1%]==[--help] ( + set MILL_FIRST_ARG=%1% + ) + ) + ) + ) + ) +) +set "MILL_PARAMS=%*%" + +if not [!MILL_FIRST_ARG!]==[] ( + for /f "tokens=1*" %%a in ("%*") do ( + set "MILL_PARAMS=%%b" + ) +) + +rem -D mill.main.cli is for compatibility with Mill 0.10.9 - 0.13.0-M2 +"%MILL%" %MILL_FIRST_ARG% -D "mill.main.cli=%MILL_MAIN_CLI%" %MILL_PARAMS% From 21a6fc7d11f287ef1ed91b1a487b4c66de6e6ed5 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 19:54:56 -0400 Subject: [PATCH 4/7] Route Windows CI through mill.bat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sh wrapper fetches Mill's native launcher, which has no Windows build — every Windows step died instantly with "This native mill launcher supports only Linux and macOS." A launcher-selection step now points Windows at the committed mill.bat and everything else at ./mill. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7411761..1e89ccd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,15 +28,21 @@ jobs: path: ~/.cache/mill-bun key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + - name: Select Mill launcher + # The sh wrapper fetches Mill's native launcher, which has no Windows build + # ("This native mill launcher supports only Linux and macOS") — Windows goes + # through the committed mill.bat instead. + run: echo "MILL=./mill$([ "$RUNNER_OS" = "Windows" ] && echo .bat)" >> "$GITHUB_ENV" + - uses: oven-sh/setup-bun@v2 with: bun-version: 1.4.0 - name: Compile - run: ./mill --no-server millbun.compile + run: $MILL --no-server millbun.compile - name: Unit Tests - run: ./mill --no-server millbun.test + run: $MILL --no-server millbun.test # The suite defaults to the managed toolchain; this job pins system Bun to the same # version so `findOnPath` resolution and version verification keep CI coverage, while @@ -44,7 +50,7 @@ jobs: - name: Integration Tests env: MILL_BUN_USE_SYSTEM: 'true' - run: ./mill --no-server millbun.integration + run: $MILL --no-server millbun.integration managed-bun: strategy: @@ -68,7 +74,13 @@ jobs: path: ~/.cache/mill-bun key: mill-bun-${{ runner.os }}-${{ hashFiles('millbun/src/mill/bun/BunToolchainModule.scala') }} + - name: Select Mill launcher + # The sh wrapper fetches Mill's native launcher, which has no Windows build + # ("This native mill launcher supports only Linux and macOS") — Windows goes + # through the committed mill.bat instead. + run: echo "MILL=./mill$([ "$RUNNER_OS" = "Windows" ] && echo .bat)" >> "$GITHUB_ENV" + - name: Managed Bun smoke test env: MILL_BUN_USE_SYSTEM: 'false' - run: ./mill --no-server millbun.integration.testOnly mill.bun.BunManagedToolchainIntegrationTests + run: $MILL --no-server millbun.integration.testOnly mill.bun.BunManagedToolchainIntegrationTests From d5f6b61c76465a6589ca51ebcb9fe4c077a93f8d Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 20:00:27 -0400 Subject: [PATCH 5/7] Share the integration harness and cover the last untested features MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All five fixture suites copy-pasted the same tester/outputPath/ commandLogPath helpers and each re-encoded Mill's out// layout; BunIntegrationSuite now owns both, and the regenerator uses it too. bundle and bundleFast differed only in linker and --bytecode across 30 duplicated lines of args assembly; both now delegate to one Task.Anon body. bundleFast never bytecode-compiles — it exists for iteration speed — and its scaladoc says so. First-ever coverage for three shipped features: - BunSQLiteModule: typescript-compile mixes it in with a discovered data/app.db and asserts it lands in the compile workspace. The fixture chains bunCompileResources through super — an unqualified override silently drops the mixin's contribution, which is exactly the mistake a user would make, so the fixture models the right form. - npmOverrides end-to-end: the new typescript-overrides fixture has lib and app pinning conflicting is-odd specifiers; the override resolves the install against a committed lock, and the generated package.json carries both the resolved specifier and the overrides block. - bunDoctor: evaluated in the managed-toolchain suite. Also fixes the two scaladoc examples that still used Mill 0.x's millSourcePath (BunSQLiteModule, BunWorkersModule) — neither compiled. Re-running RegenerateFixtureLocks against the committed locks produces zero diffs, confirming lock generation is deterministic. Co-Authored-By: Claude Fable 5 --- .../resources/typescript-compile/build.mill | 9 +- .../resources/typescript-compile/data/app.db | 1 + .../resources/typescript-overrides/build.mill | 22 +++++ .../resources/typescript-overrides/bun.lock | 34 ++++++++ .../typescript-overrides/lib/src/index.ts | 3 + .../typescript-overrides/src/main.ts | 3 + ...unDependencyManifestIntegrationTests.scala | 16 +--- .../src/mill/bun/BunIntegrationSuite.scala | 39 +++++++++ .../BunManagedToolchainIntegrationTests.scala | 13 +-- .../mill/bun/BunScalaJSIntegrationTests.scala | 24 +----- .../bun/BunTypeScriptIntegrationTests.scala | 41 ++++----- .../bun/BunWorkspaceIntegrationTests.scala | 18 +--- .../src/mill/bun/RegenerateFixtureLocks.scala | 12 +-- millbun/src/mill/bun/BunSQLiteModule.scala | 2 +- millbun/src/mill/bun/BunWorkersModule.scala | 4 +- .../scalajslib/bun/BunScalaJSModule.scala | 84 ++++++++----------- 16 files changed, 173 insertions(+), 152 deletions(-) create mode 100644 millbun/integration/resources/typescript-compile/data/app.db create mode 100644 millbun/integration/resources/typescript-overrides/build.mill create mode 100644 millbun/integration/resources/typescript-overrides/bun.lock create mode 100644 millbun/integration/resources/typescript-overrides/lib/src/index.ts create mode 100644 millbun/integration/resources/typescript-overrides/src/main.ts create mode 100644 millbun/integration/src/mill/bun/BunIntegrationSuite.scala diff --git a/millbun/integration/resources/typescript-compile/build.mill b/millbun/integration/resources/typescript-compile/build.mill index 119dcd7..2dbf47d 100644 --- a/millbun/integration/resources/typescript-compile/build.mill +++ b/millbun/integration/resources/typescript-compile/build.mill @@ -6,9 +6,10 @@ package build import mill.* +import mill.bun.* import mill.javascriptlib.bun.* -object app extends BunTypeScriptModule { +object app extends BunTypeScriptModule with BunSQLiteModule { override def moduleDir = build.moduleDir override def npmDevDeps = Task { Seq("typescript@5.7.3") } override def compilerOptions = Task { @@ -18,5 +19,9 @@ object app extends BunTypeScriptModule { ) } override def bunBundleTarget = Task { "bun" } - override def bunCompileResources = Task.Sources("embedded") + // Chained through super so BunSQLiteModule's discovered databases stay in the set — an + // unqualified override here would silently drop the mixin's contribution. + def embeddedResources = Task.Sources("embedded") + override def bunCompileResources = Task { super.bunCompileResources() ++ embeddedResources() } + override def sqliteDatabaseDir = Task.Input { Some(PathRef(moduleDir / "data")) } } diff --git a/millbun/integration/resources/typescript-compile/data/app.db b/millbun/integration/resources/typescript-compile/data/app.db new file mode 100644 index 0000000..f7697b4 --- /dev/null +++ b/millbun/integration/resources/typescript-compile/data/app.db @@ -0,0 +1 @@ +not-a-real-sqlite-file-but-copying-is-what-we-test \ No newline at end of file diff --git a/millbun/integration/resources/typescript-overrides/build.mill b/millbun/integration/resources/typescript-overrides/build.mill new file mode 100644 index 0000000..f6c07ee --- /dev/null +++ b/millbun/integration/resources/typescript-overrides/build.mill @@ -0,0 +1,22 @@ +//| mill-version: 1.1.5 +//| mill-jvm-version: system +//| mvnDeps: +//| - com.tjclp::mill-bun_mill1:0.0.0-NIGHTLY + +package build + +import mill.* +import mill.javascriptlib.bun.* + +// lib and app pin different is-odd specifiers; without the override the install fails with a +// deterministic conflict, and npmOverrides is the documented escape hatch. +object lib extends BunTypeScriptModule { + override def npmDeps = Task { Seq("is-odd@^3.0.0") } +} + +object app extends BunTypeScriptModule { + override def moduleDir = build.moduleDir + override def moduleDeps = Seq(lib) + override def npmDeps = Task { Seq("is-odd@3.0.1") } + override def npmOverrides = Task { Map("is-odd" -> "3.0.1") } +} diff --git a/millbun/integration/resources/typescript-overrides/bun.lock b/millbun/integration/resources/typescript-overrides/bun.lock new file mode 100644 index 0000000..b4b59cf --- /dev/null +++ b/millbun/integration/resources/typescript-overrides/bun.lock @@ -0,0 +1,34 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "name": "app", + "dependencies": { + "is-odd": "3.0.1", + }, + "devDependencies": { + "@types/bun": "1.4.0", + "typescript": "5.7.3", + }, + }, + }, + "overrides": { + "is-odd": "3.0.1", + }, + "packages": { + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + + "@types/node": ["@types/node@26.3.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-L3fgrnchriRC2ExBflb8j4uZZURHZfQsmQeyVzhjcHW4kkwVyo8/0h1B2MVzMTrYUJYu6G7EWs14hW/L9putqw=="], + + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + + "is-number": ["is-number@6.0.0", "", {}, "sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg=="], + + "is-odd": ["is-odd@3.0.1", "", { "dependencies": { "is-number": "^6.0.0" } }, "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA=="], + + "typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="], + + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], + } +} diff --git a/millbun/integration/resources/typescript-overrides/lib/src/index.ts b/millbun/integration/resources/typescript-overrides/lib/src/index.ts new file mode 100644 index 0000000..8adf276 --- /dev/null +++ b/millbun/integration/resources/typescript-overrides/lib/src/index.ts @@ -0,0 +1,3 @@ +import isOdd from "is-odd"; + +export const oddCheck = (n: number): boolean => isOdd(n); diff --git a/millbun/integration/resources/typescript-overrides/src/main.ts b/millbun/integration/resources/typescript-overrides/src/main.ts new file mode 100644 index 0000000..f07f59d --- /dev/null +++ b/millbun/integration/resources/typescript-overrides/src/main.ts @@ -0,0 +1,3 @@ +import isOdd from "is-odd"; + +console.log(`three is odd: ${isOdd(3)}`); diff --git a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala index 5d72c45..b966b50 100644 --- a/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunDependencyManifestIntegrationTests.scala @@ -4,21 +4,7 @@ import mill.api.PathRef import mill.testkit.IntegrationTester import utest.* -object BunDependencyManifestIntegrationTests extends TestSuite { - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) - - private def tester(resource: String): IntegrationTester = - new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / resource, - millExecutable = millExe, - useInMemory = true - ) - - private def outputPath(tester: IntegrationTester, selector: String): os.Path = - tester.out(selector).value[PathRef].path - +object BunDependencyManifestIntegrationTests extends BunIntegrationSuite { def tests: Tests = Tests { test("invalid bun literal fails in build definitions") { diff --git a/millbun/integration/src/mill/bun/BunIntegrationSuite.scala b/millbun/integration/src/mill/bun/BunIntegrationSuite.scala new file mode 100644 index 0000000..1158cd3 --- /dev/null +++ b/millbun/integration/src/mill/bun/BunIntegrationSuite.scala @@ -0,0 +1,39 @@ +package mill.bun + +import mill.api.PathRef +import mill.testkit.IntegrationTester +import utest.TestSuite + +/** + * Shared harness for fixture-based integration suites. + * + * Every suite forks the same Mill executable against a fixture copied out of + * `millbun/integration/resources`, and most assertions navigate Mill's `out//` + * layout — this trait owns both, so the layout is encoded once instead of per suite. + */ +trait BunIntegrationSuite extends TestSuite { + val resourceDir: os.Path = + os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" + val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) + + protected def tester(resource: String): IntegrationTester = + new IntegrationTester( + daemonMode = false, + workspaceSourcePath = resourceDir / resource, + millExecutable = millExe, + useInMemory = true + ) + + /** The path a task's `PathRef` result points at. */ + protected def outputPath(tester: IntegrationTester, selector: String): os.Path = + tester.out(selector).value[PathRef].path + + /** Mill's per-command log file, where a forked command's console output lands. */ + protected def commandLogPath(tester: IntegrationTester, selector: String): os.Path = { + val segments = selector.split('.') + val rel = + if (segments.length <= 1) os.RelPath(".") + else os.RelPath(segments.dropRight(1).mkString("/")) + tester.workspacePath / "out" / rel / s"${segments.last}.log" + } +} diff --git a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala index 366c1c5..4d5a4a2 100644 --- a/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunManagedToolchainIntegrationTests.scala @@ -3,23 +3,18 @@ package mill.bun import mill.testkit.IntegrationTester import utest.* -object BunManagedToolchainIntegrationTests extends TestSuite: - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) +object BunManagedToolchainIntegrationTests extends BunIntegrationSuite: def tests: Tests = Tests: test("resolved toolchain runs the pinned Bun version"): - val tester = new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / "managed-bun", - millExecutable = millExe, - useInMemory = true - ) + val tester = this.tester("managed-bun") val result = tester.eval("app.bunExecutable") assert(result.isSuccess) val executable = tester.out("app.bunExecutable").value[String] val version = os.proc(executable, "--version").call(stdout = os.Pipe).out.text().trim assert(version == "1.4.0") + // First-ever coverage for the diagnostics command: it must at least evaluate. + assert(tester.eval("app.bunDoctor").isSuccess) test("an evicted download cache is repopulated, not trusted"): val cacheDir = os.temp.dir() diff --git a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala index 0836c27..9219895 100644 --- a/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunScalaJSIntegrationTests.scala @@ -4,29 +4,7 @@ import mill.api.PathRef import mill.testkit.IntegrationTester import utest._ -object BunScalaJSIntegrationTests extends TestSuite { - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) - - private def tester(resource: String): IntegrationTester = - new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / resource, - millExecutable = millExe, - useInMemory = true - ) - - private def outputPath(tester: IntegrationTester, selector: String): os.Path = - tester.out(selector).value[PathRef].path - - private def commandLogPath(tester: IntegrationTester, selector: String): os.Path = { - val segments = selector.split('.') - val rel = - if (segments.length <= 1) os.RelPath(".") - else os.RelPath(segments.dropRight(1).mkString("/")) - tester.workspacePath / "out" / rel / s"${segments.last}.log" - } - +object BunScalaJSIntegrationTests extends BunIntegrationSuite { private def bundledScript(dist: os.Path): os.Path = os.walk(dist) .find(path => os.isFile(path) && path.ext == "js") diff --git a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala index d4d2bcc..269aafc 100644 --- a/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunTypeScriptIntegrationTests.scala @@ -4,29 +4,7 @@ import mill.api.PathRef import mill.testkit.IntegrationTester import utest._ -object BunTypeScriptIntegrationTests extends TestSuite { - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) - - private def tester(resource: String): IntegrationTester = - new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / resource, - millExecutable = millExe, - useInMemory = true - ) - - private def outputPath(tester: IntegrationTester, selector: String): os.Path = - tester.out(selector).value[PathRef].path - - private def commandLogPath(tester: IntegrationTester, selector: String): os.Path = { - val segments = selector.split('.') - val rel = - if (segments.length <= 1) os.RelPath(".") - else os.RelPath(segments.dropRight(1).mkString("/")) - tester.workspacePath / "out" / rel / s"${segments.last}.log" - } - +object BunTypeScriptIntegrationTests extends BunIntegrationSuite { def tests: Tests = Tests { test("compile") { @@ -82,6 +60,23 @@ object BunTypeScriptIntegrationTests extends TestSuite { cwd = executable / os.up ) assert(run.out.text().trim == "Hello from compiled TypeScript executable!") + + // BunSQLiteModule feeds discovered databases into the compile workspace, alongside the + // module's own bunCompileResources (which must chain through super to keep them). + val workspace = tester.workspacePath / "out" / "app" / "compileExecutable.dest" / "workspace" + assert(os.exists(workspace / "data" / "app.db")) + } + + test("npmOverrides resolves conflicting transitive specifiers") { + // lib pins is-odd@^3.0.0 and app pins is-odd@3.0.1 — without the override this install + // fails with the deterministic conflict error; npmOverrides is the escape hatch. + val tester = this.tester("typescript-overrides") + assert(tester.eval("app.bunInstall").isSuccess) + + val packageJson = ujson.read(os.read(outputPath(tester, "app.bunInstall") / "package.json")) + assert(packageJson("dependencies").obj("is-odd").str == "3.0.1") + assert(packageJson("overrides").obj("is-odd").str == "3.0.1") + assert(os.exists(outputPath(tester, "app.bunInstall") / "node_modules" / "is-odd" / "package.json")) } test("strict installs require a source lock and bunLock creates it") { diff --git a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala index 6475eef..1bb1d3e 100644 --- a/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala +++ b/millbun/integration/src/mill/bun/BunWorkspaceIntegrationTests.scala @@ -4,24 +4,10 @@ import mill.api.PathRef import mill.testkit.IntegrationTester import utest.* -object BunWorkspaceIntegrationTests extends TestSuite: - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) - - private def tester(): IntegrationTester = - new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / "mixed-workspace", - millExecutable = millExe, - useInMemory = true - ) - - private def outputPath(tester: IntegrationTester, selector: String): os.Path = - tester.out(selector).value[PathRef].path - +object BunWorkspaceIntegrationTests extends BunIntegrationSuite: def tests: Tests = Tests: test("mixed Scala.js and TypeScript packages share one install"): - val tester = this.tester() + val tester = this.tester("mixed-workspace") val lockResult = tester.eval("workspace.bunLock") assert(lockResult.isSuccess) diff --git a/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala index 4f698c5..8676d9e 100644 --- a/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala +++ b/millbun/integration/src/mill/bun/RegenerateFixtureLocks.scala @@ -12,9 +12,7 @@ import utest.* * MILL_BUN_REGENERATE_LOCKS=1 ./mill millbun.integration.testOnly mill.bun.RegenerateFixtureLocks * }}} */ -object RegenerateFixtureLocks extends TestSuite: - val resourceDir: os.Path = os.Path(sys.env("MILL_WORKSPACE_ROOT")) / "millbun" / "integration" / "resources" - val millExe: os.Path = os.Path(sys.env("MILL_EXECUTABLE_PATH")) +object RegenerateFixtureLocks extends BunIntegrationSuite: /** Fixture -> the bunLock commands that produce its committed lockfiles. */ val lockedFixtures: Seq[(String, Seq[String])] = Seq( @@ -26,6 +24,7 @@ object RegenerateFixtureLocks extends TestSuite: "typescript-bunfig" -> Seq("app.bunLock"), "typescript-compile" -> Seq("app.bunLock"), "typescript-env" -> Seq("app.bunLock"), + "typescript-overrides" -> Seq("app.bunLock"), "typescript-simple" -> Seq("app.bunLock"), "typescript-tests" -> Seq("app.bunLock"), "typescript-tsx" -> Seq("app.bunLock"), @@ -42,12 +41,7 @@ object RegenerateFixtureLocks extends TestSuite: ) else lockedFixtures.foreach { case (fixture, commands) => - val tester = new IntegrationTester( - daemonMode = false, - workspaceSourcePath = resourceDir / fixture, - millExecutable = millExe, - useInMemory = true - ) + val tester = this.tester(fixture) commands.foreach { command => val result = tester.eval(command) Predef.assert(result.isSuccess, s"$fixture: $command failed") diff --git a/millbun/src/mill/bun/BunSQLiteModule.scala b/millbun/src/mill/bun/BunSQLiteModule.scala index 8e497e9..6c780de 100644 --- a/millbun/src/mill/bun/BunSQLiteModule.scala +++ b/millbun/src/mill/bun/BunSQLiteModule.scala @@ -15,7 +15,7 @@ import mill.* * Usage: * {{{ * object app extends BunTypeScriptModule with BunSQLiteModule { - * override def sqliteDatabases = Task { Seq(PathRef(millSourcePath / "data" / "app.db")) } + * override def sqliteDatabases = Task.Sources(moduleDir / "data" / "app.db") * } * }}} */ diff --git a/millbun/src/mill/bun/BunWorkersModule.scala b/millbun/src/mill/bun/BunWorkersModule.scala index db095c9..98f9d28 100644 --- a/millbun/src/mill/bun/BunWorkersModule.scala +++ b/millbun/src/mill/bun/BunWorkersModule.scala @@ -15,9 +15,7 @@ import mill.javascriptlib.bun.BunTypeScriptModule * Usage: * {{{ * object app extends BunTypeScriptModule with BunWorkersModule { - * override def workerEntryPoints = Task { - * Seq(PathRef(millSourcePath / "src" / "worker.ts")) - * } + * override def workerEntryPoints = Task.Sources(moduleDir / "src" / "worker.ts") * } * }}} */ diff --git a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala index eb0b739..39e7642 100644 --- a/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala +++ b/millbun/src/mill/scalajslib/bun/BunScalaJSModule.scala @@ -424,67 +424,49 @@ trait BunScalaJSModule extends ScalaJSModule with BunToolchainModule with BunPac ) } - /** Canonical production bundle task. */ - def bundle: T[PathRef] = Task { - val linked = fullLinkJS() + /** One body for [[bundle]] and [[bundleFast]]: they differ only in linker and bytecode. */ + private def bundleBuild(linkTask: Task[Report], bytecode: Task[Boolean]): Task[PathRef] = + Task.Anon { + val linked = linkTask() - val outDir = Task.dest / "dist" - os.makeDir.all(outDir) + val outDir = Task.dest / "dist" + os.makeDir.all(outDir) - val formatArgs = bunBundleFormat().toSeq.flatMap(fmt => Seq("--format", fmt)) - val sourcemapArgs = bunBundleSourcemap().toSeq.map(mode => s"--sourcemap=$mode") - val externalArgs = bunBundleExternal().flatMap(dep => Seq("--external", dep)) - val splittingArgs = if (bunBundleSplitting()) Seq("--splitting") else Nil - val bytecodeArgs = if (bunBundleBytecode()) Seq("--bytecode") else Nil + val formatArgs = bunBundleFormat().toSeq.flatMap(fmt => Seq("--format", fmt)) + val sourcemapArgs = bunBundleSourcemap().toSeq.map(mode => s"--sourcemap=$mode") + val externalArgs = bunBundleExternal().flatMap(dep => Seq("--external", dep)) + val splittingArgs = if (bunBundleSplitting()) Seq("--splitting") else Nil + val bytecodeArgs = if (bytecode()) Seq("--bytecode") else Nil - runBun( - bunExecutable(), - Seq("build") ++ - bundleEntrypoints(linked).map(_.toString) ++ - Seq("--outdir", outDir.toString, "--target", bunBundleTarget()) ++ - formatArgs ++ - sourcemapArgs ++ - externalArgs ++ - splittingArgs ++ - bytecodeArgs ++ - bunBundleArgs(), - cwd = linked.dest.path, - env = bunToolEnv() - ) + runBun( + bunExecutable(), + Seq("build") ++ + bundleEntrypoints(linked).map(_.toString) ++ + Seq("--outdir", outDir.toString, "--target", bunBundleTarget()) ++ + formatArgs ++ + sourcemapArgs ++ + externalArgs ++ + splittingArgs ++ + bytecodeArgs ++ + bunBundleArgs(), + cwd = linked.dest.path, + env = bunToolEnv() + ) + + PathRef(outDir) + } - PathRef(outDir) + /** Canonical production bundle task. */ + def bundle: T[PathRef] = Task { + bundleBuild(Task.Anon(fullLinkJS()), Task.Anon(bunBundleBytecode()))() } @deprecated("Use bundle", "0.3.0") def bunBundle: T[PathRef] = Task { bundle() } - /** Canonical fast-development bundle task. */ + /** Canonical fast-development bundle task. Never bytecode-compiles: it exists for iteration speed. */ def bundleFast: T[PathRef] = Task { - val linked = fastLinkJS() - - val outDir = Task.dest / "dist" - os.makeDir.all(outDir) - - val formatArgs = bunBundleFormat().toSeq.flatMap(fmt => Seq("--format", fmt)) - val sourcemapArgs = bunBundleSourcemap().toSeq.map(mode => s"--sourcemap=$mode") - val externalArgs = bunBundleExternal().flatMap(dep => Seq("--external", dep)) - val splittingArgs = if (bunBundleSplitting()) Seq("--splitting") else Nil - - runBun( - bunExecutable(), - Seq("build") ++ - bundleEntrypoints(linked).map(_.toString) ++ - Seq("--outdir", outDir.toString, "--target", bunBundleTarget()) ++ - formatArgs ++ - sourcemapArgs ++ - externalArgs ++ - splittingArgs ++ - bunBundleArgs(), - cwd = linked.dest.path, - env = bunToolEnv() - ) - - PathRef(outDir) + bundleBuild(Task.Anon(fastLinkJS()), Task.Anon(false))() } @deprecated("Use bundleFast", "0.3.0") From 3fb942ddeb2c4d530c2db6d9e06f2ebc7a17ce5e Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 20:09:51 -0400 Subject: [PATCH 6/7] Windows triage: POSIX permissions are not part of the publish contract there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit os.perms throws on NTFS, and bun.exe needs no executable bit — the permission half of the cross-filesystem publish test now runs only on POSIX. First genuine Windows finding; the managed-bun leg (download, extraction, shared cache, eviction recovery, mill.bat forking) already passes on windows-latest. Co-Authored-By: Claude Fable 5 --- millbun/test/src/mill/bun/BunToolchainTests.scala | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/millbun/test/src/mill/bun/BunToolchainTests.scala b/millbun/test/src/mill/bun/BunToolchainTests.scala index e74f10b..df23cb5 100644 --- a/millbun/test/src/mill/bun/BunToolchainTests.scala +++ b/millbun/test/src/mill/bun/BunToolchainTests.scala @@ -131,11 +131,14 @@ object BunToolchainTests extends TestSuite: val cached = root / "cache" / "def456" / "bun" val staged = root / "staged" / "bun" os.write(staged, "bun-binary", createFolders = true) - os.perms.set(staged, "rwxr-xr-x") + // NTFS has no POSIX permissions and os.perms throws there; Windows bun.exe needs no + // executable bit, so the permission half of this contract is POSIX-only. + val posix = !scala.util.Properties.isWin + if posix then os.perms.set(staged, "rwxr-xr-x") assert(BunToolchainModule.publishViaCopy(staged, cached) == cached) assert(os.read(cached) == "bun-binary") - assert(os.perms(cached).toString == "rwxr-xr-x") + if posix then assert(os.perms(cached).toString == "rwxr-xr-x") // The bytes must travel under a temp name and arrive by rename: a crash mid-publish can // never leave a partial file at the published path, and success leaves nothing behind. assert(os.list(cached / os.up) == Seq(cached)) From c12eef389506d86e103226a91985ac2998f57ac3 Mon Sep 17 00:00:00 2001 From: Richie Caputo Date: Wed, 26 Aug 2026 20:33:07 -0400 Subject: [PATCH 7/7] Windows triage: the typescript-env proxy needs a .cmd twin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fixture's bun-proxy is a POSIX sh script, which CreateProcess cannot execute — the only failure in the entire Windows integration suite. The fixture now carries bun-proxy.cmd with identical semantics (log first-arg:marker, delegate to PATH bun, propagate the exit code) and picks the right one per platform. Co-Authored-By: Claude Fable 5 --- millbun/integration/resources/typescript-env/build.mill | 5 ++++- .../integration/resources/typescript-env/bun-proxy.cmd | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 millbun/integration/resources/typescript-env/bun-proxy.cmd diff --git a/millbun/integration/resources/typescript-env/build.mill b/millbun/integration/resources/typescript-env/build.mill index 22ed549..686ac00 100644 --- a/millbun/integration/resources/typescript-env/build.mill +++ b/millbun/integration/resources/typescript-env/build.mill @@ -11,7 +11,10 @@ import mill.javascriptlib.bun.* object app extends BunTypeScriptModule { override def moduleDir = build.moduleDir override def npmDevDeps = Task { Seq("typescript@5.7.3") } - def bunProxy = Task.Source(moduleDir / "bun-proxy") + // The sh proxy cannot execute on Windows, so the fixture carries a .cmd twin. + def bunProxy = Task.Source( + if (scala.util.Properties.isWin) moduleDir / "bun-proxy.cmd" else moduleDir / "bun-proxy" + ) override def managedBunExecutable = Task { Some(PathRef(bunProxy().path)) } // The proxy execs whatever Bun is on PATH, so its --version reports the developer's Bun rather // than bunVersion. This fixture tests env propagation, not the version pin. diff --git a/millbun/integration/resources/typescript-env/bun-proxy.cmd b/millbun/integration/resources/typescript-env/bun-proxy.cmd new file mode 100644 index 0000000..3e105e3 --- /dev/null +++ b/millbun/integration/resources/typescript-env/bun-proxy.cmd @@ -0,0 +1,9 @@ +@echo off +setlocal +set "FIRST=%~1" +if "%FIRST%"=="" set "FIRST=unknown" +set "MARKER=%BUN_PROXY_MARKER%" +if "%MARKER%"=="" set "MARKER=missing" +>> "%CD%\.bun-env-log" echo %FIRST%:%MARKER% +bun %* +exit /b %ERRORLEVEL%