Skip to content
Open
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
13 changes: 13 additions & 0 deletions docs/pm/lockfile.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,19 @@ Bun v1.2 changed the default lockfile format to the text-based `bun.lock`. To mi

For more on the format, see [the blog post](https://bun.com/blog/bun-lock-text-lockfile).

#### Lockfile format versions

`bun.lock` carries a `"lockfileVersion"` field. Bun 1.4 writes version 2 by default for a fresh install or migration. Re-saving an existing version 1 lockfile preserves its version (version 0 is upgraded to version 1), so a project that already has a `bun.lock` is unaffected.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Bun releases before 1.4 cannot read version 2 lockfiles. If your project is installed by a mix of Bun versions, set `lockfileVersion` in `bunfig.toml` to cap the written version:

```toml bunfig.toml icon="settings"
[install.lockfile]
lockfileVersion = 1
```

With this set, `bun install` writes a version 1 lockfile and downgrades an existing version 2 lockfile on the next install.

#### Automatic lockfile migration

When running `bun install` in a project without a `bun.lock`, Bun automatically migrates existing lockfiles:
Expand Down
7 changes: 7 additions & 0 deletions docs/runtime/bunfig.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -643,6 +643,13 @@ Whether to generate a non-Bun lockfile alongside `bun.lock`. (A `bun.lock` is al
print = "yarn"
```

Cap the `"lockfileVersion"` written to `bun.lock`. Set this to `1` in a project that is also installed by Bun `< 1.4`, which cannot read lockfile version 2.

```toml title="bunfig.toml" icon="settings"
[install.lockfile]
lockfileVersion = 1
```

### `install.linker`

Configure the linker strategy: how `bun install` lays out dependencies in `node_modules`. Defaults to `"isolated"` for new workspaces, `"hoisted"` for new single-package projects and existing projects (made pre-v1.3.2).
Expand Down
5 changes: 5 additions & 0 deletions src/bunfig/bunfig.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1402,6 +1402,11 @@ impl<'a> Parser<'a> {
{
install.save_lockfile_path = Some(v.into());
}
if let Some(v) = lockfile_expr.get(b"lockfileVersion") {
if let Some(n) = v.as_number() {
install.lockfile_format_version = Some(num_to_u32(n));
}
}
}

if let Some(v) = install_obj.get(b"optional").and_then(|e| e.as_bool()) {
Expand Down
10 changes: 10 additions & 0 deletions src/install/PackageManager/PackageManagerOptions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ pub struct Options {
pub(crate) os: Npm::OperatingSystem,

pub(crate) config_version: Option<ConfigVersion>,

/// `[install.lockfile] lockfileVersion` — caps the written `lockfileVersion`.
pub(crate) lockfile_format_version: Option<crate::lockfile::bun_lock::Version>,
}

impl Default for Options {
Expand Down Expand Up @@ -153,6 +156,7 @@ impl Default for Options {
cpu: Npm::Architecture::CURRENT,
os: Npm::OperatingSystem::CURRENT,
config_version: None,
lockfile_format_version: None,
}
}
}
Expand Down Expand Up @@ -526,6 +530,12 @@ impl Options {
self.save_text_lockfile = Some(save_text_lockfile);
}

if let Some(n) = config.lockfile_format_version {
use crate::lockfile::bun_lock::Version;
// Floor to V1 (the writer never emits v0 content).
self.lockfile_format_version = Version::from_int(n.max(Version::V1 as u32));
}

if let Some(jobs) = config.concurrent_scripts {
self.max_concurrent_lifecycle_scripts = jobs as usize;
}
Expand Down
7 changes: 6 additions & 1 deletion src/install/PackageManager/install_with_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,12 @@ pub fn install_with_manager(
&& (load_result.ok().migrated != lockfile::Migrated::None
// if loaded from binary and save-text-lockfile is passed
|| (load_result.ok().format == lockfile::Format::Binary
&& manager.options.save_text_lockfile.unwrap_or(false)))),
&& manager.options.save_text_lockfile.unwrap_or(false))
// if bunfig lockfileVersion caps below the loaded version
|| (load_result.ok().format == lockfile::Format::Text
&& manager.options.lockfile_format_version.is_some_and(|cap| {
!cap.at_least(load_result.ok().lockfile.text_lockfile_version)
})))),
Comment thread
robobun marked this conversation as resolved.
);

// this defaults to false
Expand Down
16 changes: 11 additions & 5 deletions src/install/lockfile/bun.lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,20 @@ impl Stringifier {
/// are actually serialized are considered, not every entry in the in-memory
/// `pkg_resolutions` buffer (migration can leave pruned/unreferenced entries
/// there that never reach the written `packages` object).
fn version_to_write(lockfile: &BinaryLockfile) -> Version {
fn version_to_write(lockfile: &BinaryLockfile, cap: Option<Version>) -> Version {
// An older on-disk lockfile keeps its version; only a no-prior-version
// lockfile (the `Version::CURRENT` default) is a candidate for v2. v0 is
// the exception: the writer can't emit v0-format workspace entries, so a
// v0 lockfile is upgraded to v1 rather than preserved verbatim.
let loaded = lockfile.text_lockfile_version;
if !loaded.at_least(Version::CURRENT) {
return if loaded.at_least(Version::V1) {
loaded
let target = match cap {
Some(c) if !loaded.at_least(c) => loaded,
Some(c) => c,
None => loaded,
};
if !target.at_least(Version::CURRENT) {
return if target.at_least(Version::V1) {
target
} else {
Version::V1
};
Expand Down Expand Up @@ -365,7 +370,8 @@ impl Stringifier {
writer.write_all(b"{\n")?;
Self::inc_indent(writer, indent)?;
{
let lockfile_version = Self::version_to_write(lockfile);
let lockfile_version =
Self::version_to_write(lockfile, options.lockfile_format_version);
writeln!(writer, "\"lockfileVersion\": {},", lockfile_version as u32)?;
Self::write_indent(writer, *indent)?;

Expand Down
2 changes: 2 additions & 0 deletions src/options_types/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,8 @@ pub mod api {
pub minimum_release_age_excludes: Option<Vec<Box<[u8]>>>,
pub public_hoist_pattern: Option<PnpmMatcher>,
pub hoist_pattern: Option<PnpmMatcher>,
/// `[install.lockfile] lockfileVersion`
pub lockfile_format_version: Option<u32>,
}

/// Open `enum(u8)` in the wire schema. Generated body emits `_` open
Expand Down
170 changes: 170 additions & 0 deletions test/cli/install/lockfile-version-2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,176 @@ it("a freshly written text lockfile defaults to version 2", async () => {
expect(exitCode).toBe(0);
});

// `[install.lockfile] lockfileVersion = 1` caps the written lockfileVersion, so
// a fresh install on a newer Bun still produces a lockfile an older Bun can read.
it("bunfig [install.lockfile] lockfileVersion = 1 writes a v1 lockfile for a fresh install", async () => {
using dir = tempDir("lockfile-bunfig-cap-v1", {
"package.json": JSON.stringify({ name: "root", dependencies: { dep: "file:./dep" } }),
"dep/package.json": JSON.stringify({ name: "dep", version: "1.0.0" }),
"bunfig.toml": `[install.lockfile]\nlockfileVersion = 1\n`,
});

await using proc = spawn({
cmd: [bunExe(), "install", "--save-text-lockfile"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const lockfile = await file(join(String(dir), "bun.lock")).text();
expect(err).not.toContain("error:");
expect(lockfile).toContain(`"lockfileVersion": 1,`);
expect(lockfile).not.toContain(`"lockfileVersion": 2,`);
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// The cap alone is enough to trigger a re-save: a v2 lockfile + bunfig cap 1,
// with no package.json change, is rewritten as v1 on the next install.
it("bunfig [install.lockfile] lockfileVersion = 1 downgrades an existing v2 lockfile on install", async () => {
using dir = tempDir("lockfile-bunfig-downgrade", {
"package.json": JSON.stringify({ name: "root", dependencies: { a: "file:./a" } }),
"a/package.json": JSON.stringify({ name: "a", version: "1.0.0" }),
});

await using first = spawn({
cmd: [bunExe(), "install", "--save-text-lockfile"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [, firstErr, firstExit] = await Promise.all([first.stdout.text(), first.stderr.text(), first.exited]);
expect(firstErr).not.toContain("error:");
expect(firstExit).toBe(0);
const before = await file(join(String(dir), "bun.lock")).text();
expect(before).toContain(`"lockfileVersion": 2,`);

await Bun.write(join(String(dir), "bunfig.toml"), `[install.lockfile]\nlockfileVersion = 1\n`);

await using second = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [, err, exitCode] = await Promise.all([second.stdout.text(), second.stderr.text(), second.exited]);

const after = await file(join(String(dir), "bun.lock")).text();
expect(err).not.toContain("error:");
expect(after).toContain(`"lockfileVersion": 1,`);
expect(after).not.toContain(`"lockfileVersion": 2,`);
expect(exitCode).toBe(0);
});

// The cap is a ceiling, not a floor: `lockfileVersion = 2` on a loaded v1
// lockfile keeps it at v1 (preserve-loaded-version still wins).
it("bunfig [install.lockfile] lockfileVersion = 2 does not bump a loaded v1 lockfile", async () => {
const v1Lockfile =
JSON.stringify(
{
lockfileVersion: 1,
configVersion: 1,
workspaces: { "": { name: "root", dependencies: { a: "file:./a" } } },
packages: { a: ["a@file:a", {}] },
},
null,
2,
) + "\n";

using dir = tempDir("lockfile-bunfig-cap-no-bump", {
"package.json": JSON.stringify({ name: "root", dependencies: { a: "file:./a", b: "file:./b" } }),
"a/package.json": JSON.stringify({ name: "a", version: "1.0.0" }),
"b/package.json": JSON.stringify({ name: "b", version: "1.0.0" }),
"bun.lock": v1Lockfile,
"bunfig.toml": `[install.lockfile]\nlockfileVersion = 2\n`,
});

await using proc = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const after = await file(join(String(dir), "bun.lock")).text();
expect(err).not.toContain("error:");
expect(after).toContain(`"b": ["b@file:b"`);
expect(after).toContain(`"lockfileVersion": 1,`);
expect(after).not.toContain(`"lockfileVersion": 2,`);
expect(exitCode).toBe(0);
});

// cap = 0 is floored to 1 at config-load time, so a v1 lockfile with no other
// change is not spuriously re-saved on every install.
it("bunfig [install.lockfile] lockfileVersion = 0 does not re-save an unchanged v1 lockfile", async () => {
using dir = tempDir("lockfile-bunfig-cap0-noop", {
"package.json": JSON.stringify({ name: "root", dependencies: { a: "file:./a" } }),
"a/package.json": JSON.stringify({ name: "a", version: "1.0.0" }),
"bunfig.toml": `[install.lockfile]\nlockfileVersion = 0\n`,
});

await using first = spawn({
cmd: [bunExe(), "install", "--save-text-lockfile"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
await Promise.all([first.stdout.text(), first.stderr.text(), first.exited]);
const before = await file(join(String(dir), "bun.lock")).text();
expect(before).toContain(`"lockfileVersion": 1,`);

await using second = spawn({
cmd: [bunExe(), "install"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [, err, exitCode] = await Promise.all([second.stdout.text(), second.stderr.text(), second.exited]);

expect(err).not.toContain("error:");
expect(err).not.toContain("Saved lockfile");
expect(await file(join(String(dir), "bun.lock")).text()).toBe(before);
expect(exitCode).toBe(0);
});

// 0 is floored to 1 (the writer cannot emit v0 content). Equal to or above the
// current version is a no-op cap.
it.each([
[0, 1],
[2, 2],
[99, 2],
])(
"bunfig [install.lockfile] lockfileVersion = %d writes lockfileVersion %d for a fresh install",
async (n, expected) => {
using dir = tempDir("lockfile-bunfig-cap-edge", {
"package.json": JSON.stringify({ name: "root", dependencies: { dep: "file:./dep" } }),
"dep/package.json": JSON.stringify({ name: "dep", version: "1.0.0" }),
"bunfig.toml": `[install.lockfile]\nlockfileVersion = ${n}\n`,
});

await using proc = spawn({
cmd: [bunExe(), "install", "--save-text-lockfile"],
cwd: String(dir),
env,
stdout: "pipe",
stderr: "pipe",
});
const [, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

const lockfile = await file(join(String(dir), "bun.lock")).text();
expect(err).not.toContain("error:");
expect(lockfile).toContain(`"lockfileVersion": ${expected},`);
expect(exitCode).toBe(0);
},
);

// Re-saving an existing lockfile must never bump its version. A v1 `bun.lock`
// that is rewritten — here because a new dependency is added — keeps
// `lockfileVersion: 1`, even though every entry would satisfy the v2 invariants.
Expand Down
Loading