Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,20 @@ 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/<name>`
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

- Scala.js `bunBundle*` and `bunCompile*` task names in favor of their canonical aliases.
- `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)

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions docs/MIGRATING-0.3.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>` 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 |
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion examples/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion millbun/integration/resources/typescript-bundle/build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,5 @@ object app extends BunTypeScriptModule {
)
}
override def bunBundleTarget = Task { "bun" }
override def bunBundleFormat = Task { "esm" }
override def bunBundleFormat = Task { Some("esm") }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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")
Expand All @@ -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") {
Expand Down Expand Up @@ -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.
Expand All @@ -183,24 +187,24 @@ 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"))
// Outer deps should also be present
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)
}

Expand All @@ -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)
Expand All @@ -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") {
Expand All @@ -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)
Expand All @@ -261,20 +265,20 @@ 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") {
val tester = this.tester("typescript-env")
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")

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
3 changes: 3 additions & 0 deletions millbun/src/mill/bun/BunToolchainModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
6 changes: 3 additions & 3 deletions millbun/src/mill/bun/BunWorkersModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -94,7 +94,7 @@ trait BunWorkersModule extends BunToolchainModule { this: BunTypeScriptModule =>
target
) ++ formatArgs ++ extraArgs,
cwd = workspace,
env = bunEnv()
env = bunToolEnv()
)
}

Expand Down
4 changes: 2 additions & 2 deletions millbun/src/mill/bun/BunWorkspaceModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ trait BunWorkspaceModule extends BunToolchainModule:
updateLockfile = false
),
cwd = Task.dest,
env = bunEnv()
env = bunToolEnv()
)
PathRef(Task.dest)
}
Expand All @@ -147,7 +147,7 @@ trait BunWorkspaceModule extends BunToolchainModule:
updateLockfile = true
),
cwd = Task.dest,
env = bunEnv()
env = bunToolEnv()
)

val generated = Task.dest / "bun.lock"
Expand Down
Loading
Loading