Skip to content

Commit 1bccfd5

Browse files
authored
fix: preserve lockfile graph during versioning (#487)
1 parent 0274053 commit 1bccfd5

8 files changed

Lines changed: 278 additions & 48 deletions

File tree

.ai/local/agents.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ the browser, built on ProseMirror. Two published packages plus a dev playground:
4141
differential parity gate — extend them when you change parsing, layout, or
4242
editor interactions.
4343
- Return minimal data from public APIs; do not export types that have no consumer.
44+
- **Never delete or regenerate `bun.lock` to apply package version bumps.** Run
45+
`bun scripts/check-lockfile-workspace-versions.ts --write`, then
46+
`bun install --frozen-lockfile`. The synchronizer is the sole owner of cached
47+
workspace self-versions; dependency-graph changes belong in an explicit install.
4448

4549
### Fidelity Consolidation
4650

.changeset/README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ without one of the above.
2828
1. PRs merge to `main`, each carrying its changeset(s).
2929
2. `release-pr.yml` maintains a **"Version Packages"** PR that applies the
3030
pending changesets: it bumps the affected `package.json` versions, updates the
31-
changelogs, and re-syncs `bun.lock`.
31+
changelogs, and surgically synchronizes workspace self-versions in `bun.lock`
32+
before a frozen install. The resolved dependency graph is never regenerated
33+
as a side effect of versioning.
3234
3. Merging that PR lands the version bumps on `main`. `publish.yml` builds and
3335
packs all six public packages without a publishing credential, then delegates
3436
registry state, dependency ordering, OIDC publishing, and per-package GitHub

.github/workflows/ci.yml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,8 @@ jobs:
7474
# `--frozen-lockfile` above only checks that the dependency graph still
7575
# satisfies bun.lock; it does not notice a workspace's cached "version"
7676
# field drifting behind its package.json (a plain `bun install` doesn't
77-
# rewrite that field either — only a full lockfile regenerate does). See
78-
# scripts/check-lockfile-workspace-versions.ts for the incident this
79-
# guards against.
77+
# rewrite that field either. The release path uses the same script in
78+
# byte-preserving --write mode; CI keeps it in check-only mode.
8079
- name: Lockfile workspace-version check
8180
run: bun run check:lockfile-versions
8281

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@ the browser, built on ProseMirror. Two published packages plus a dev playground:
307307
differential parity gate — extend them when you change parsing, layout, or
308308
editor interactions.
309309
- Return minimal data from public APIs; do not export types that have no consumer.
310+
- **Never delete or regenerate `bun.lock` to apply package version bumps.** Run
311+
`bun scripts/check-lockfile-workspace-versions.ts --write`, then
312+
`bun install --frozen-lockfile`. The synchronizer is the sole owner of cached
313+
workspace self-versions; dependency-graph changes belong in an explicit install.
310314

311315
### Fidelity Consolidation
312316

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@
5656
"validate-dist:vue": "bun scripts/validate-dist.ts vue",
5757
"api:update": "bun scripts/api-reports.ts --update",
5858
"api:check": "bun scripts/api-reports.ts",
59-
"changeset:version": "changeset version && rm -f bun.lock && bun install",
59+
"changeset:version": "changeset version && bun scripts/check-lockfile-workspace-versions.ts --write && bun install --frozen-lockfile",
6060
"bench": "bun benchmarks/index.ts",
6161
"bench:cross-language": "bun benchmarks/cross-language/run.ts",
6262
"parity": "bun parity/cli.ts",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, expect, test } from "bun:test";
2+
import packageJson from "../package.json";
3+
import { syncWorkspaceVersions } from "./lib/bun-lock-workspace-versions";
4+
5+
const fixture = `{
6+
"lockfileVersion": 1,
7+
"workspaces": {
8+
"packages/core": {
9+
"name": "@stll/core",
10+
"version": "1.0.0",
11+
"dependencies": { "version": "do-not-touch" },
12+
},
13+
"packages/escaped\\u002dname": { "version": "2.0.0" },
14+
},
15+
"packages": [{ "version": "also-do-not-touch" }],
16+
}\n`;
17+
18+
describe("bun.lock workspace self-version synchronization", () => {
19+
test("changes only the exact workspace version string spans", () => {
20+
const result = syncWorkspaceVersions(
21+
fixture,
22+
new Map([
23+
["packages/core", "1.1.0"],
24+
["packages/escaped-name", "2.1.0"],
25+
]),
26+
);
27+
28+
expect(result.mismatches).toHaveLength(2);
29+
expect(result.text).toBe(
30+
fixture
31+
.replace('"version": "1.0.0"', '"version": "1.1.0"')
32+
.replace('"version": "2.0.0"', '"version": "2.1.0"'),
33+
);
34+
expect(result.text).toContain('"version": "do-not-touch"');
35+
expect(result.text).toContain('"version": "also-do-not-touch"');
36+
});
37+
38+
test("version-up/version-down is byte-identical", () => {
39+
const up = syncWorkspaceVersions(fixture, new Map([["packages/core", "1.1.0"]])).text;
40+
const down = syncWorkspaceVersions(up, new Map([["packages/core", "1.0.0"]])).text;
41+
42+
expect(down).toBe(fixture);
43+
});
44+
45+
test("refuses to invent missing workspace structure", () => {
46+
const result = syncWorkspaceVersions(fixture, new Map([["packages/missing", "1.0.0"]]));
47+
48+
expect(result.text).toBe(fixture);
49+
expect(result.mismatches).toEqual([
50+
{ workspace: "packages/missing", expected: "1.0.0", actual: null },
51+
]);
52+
});
53+
54+
test("release versioning cannot delete or regenerate bun.lock", () => {
55+
const command = packageJson.scripts["changeset:version"];
56+
57+
expect(command).not.toMatch(/\brm\b/);
58+
expect(command).toContain("check-lockfile-workspace-versions.ts --write");
59+
expect(command).toEndWith("bun install --frozen-lockfile");
60+
});
61+
});

scripts/check-lockfile-workspace-versions.ts

Lines changed: 36 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,13 @@
1414
// dependency range (see the @stll/docx-core ^0.4.0-vs-0.5.0 incident this
1515
// script was added to prevent).
1616
//
17-
// The only fix once it drifts is `rm bun.lock && bun install` (a full
18-
// regenerate). This script cross-checks each packages/*/package.json
19-
// `version` against the version bun.lock has cached for that workspace, so
20-
// the drift itself gets caught in CI instead of silently persisting.
17+
// This script is the single owner of workspace self-version synchronization:
18+
// check-only by default for CI, or byte-preserving repair with `--write`.
2119

2220
import { panic } from "better-result";
2321
import { readdir } from "node:fs/promises";
2422
import { join } from "node:path";
23+
import { syncWorkspaceVersions } from "./lib/bun-lock-workspace-versions";
2524

2625
const ROOT = join(import.meta.dirname, "..");
2726

@@ -37,31 +36,13 @@ const workspaceDirs = entries
3736

3837
const lockText = await Bun.file(join(ROOT, "bun.lock")).text();
3938

40-
// bun.lock is JSON-with-trailing-commas ("JSONC"-flavored), not strict JSON,
41-
// so a plain JSON.parse fails on it as-is. Trailing commas only ever appear
42-
// directly before a closing `}`/`]`, and that exact sequence cannot occur
43-
// inside any of bun.lock's own string values (workspace paths, package
44-
// names/versions/specifiers, or the base64 `sha512-...` integrity hashes),
45-
// so stripping them is a safe, structure-preserving normalize. Parsing the
46-
// whole file once and reading `workspaces[dir].version` from the resulting
47-
// object is immune to bun's key ordering or nested-object shape — unlike a
48-
// per-block regex, there is no block to mis-extract.
49-
const isRecord = (value: unknown): value is Record<string, unknown> =>
50-
typeof value === "object" && value !== null && !Array.isArray(value);
51-
52-
const parsedLock: unknown = JSON.parse(lockText.replace(/,(\s*[}\]])/g, "$1"));
53-
if (!isRecord(parsedLock) || !isRecord(parsedLock.workspaces)) {
54-
panic("bun.lock did not parse into the expected { workspaces: {...} } shape");
39+
const args = process.argv.slice(2);
40+
if (args.some((arg) => arg !== "--write") || args.filter((arg) => arg === "--write").length > 1) {
41+
panic("Usage: bun scripts/check-lockfile-workspace-versions.ts [--write]");
5542
}
56-
const { workspaces: lockWorkspaces } = parsedLock;
57-
58-
const versionForWorkspace = (workspacePath: string): string | null => {
59-
const entry = lockWorkspaces[workspacePath];
60-
if (!isRecord(entry) || typeof entry.version !== "string") return null;
61-
return entry.version;
62-
};
63-
64-
const mismatches: string[] = [];
43+
const write = args[0] === "--write";
44+
const expectedVersions = new Map<string, string>();
45+
const packageNames = new Map<string, string>();
6546

6647
for (const workspaceDir of workspaceDirs) {
6748
// A directory under packages/ is not necessarily a real workspace: skip
@@ -73,35 +54,47 @@ for (const workspaceDir of workspaceDirs) {
7354
const version = pkg.version;
7455
if (typeof name !== "string" || typeof version !== "string") continue;
7556

76-
const lockedVersion = versionForWorkspace(workspaceDir);
77-
if (lockedVersion === null) {
78-
mismatches.push(`${name} (${workspaceDir}): no bun.lock entry found`);
79-
continue;
80-
}
81-
if (lockedVersion !== version) {
82-
mismatches.push(
83-
`${name} (${workspaceDir}): package.json is ${version}, bun.lock has ${lockedVersion}`,
84-
);
85-
}
57+
expectedVersions.set(workspaceDir, version);
58+
packageNames.set(workspaceDir, name);
8659
}
8760

61+
const result = syncWorkspaceVersions(lockText, expectedVersions);
62+
const unrepairable = result.mismatches.filter(({ actual }) => actual === null);
63+
64+
if (write && unrepairable.length === 0 && result.text !== lockText) {
65+
await Bun.write(join(ROOT, "bun.lock"), result.text);
66+
console.log(`bun.lock workspace-version sync: updated ${result.mismatches.length} workspace(s).`);
67+
process.exit(0);
68+
}
69+
70+
const mismatches = result.mismatches.map(({ workspace, expected, actual }) =>
71+
actual === null
72+
? `${packageNames.get(workspace)} (${workspace}): no writable bun.lock version entry found`
73+
: `${packageNames.get(workspace)} (${workspace}): package.json is ${expected}, bun.lock has ${actual}`,
74+
);
75+
8876
if (mismatches.length > 0) {
8977
console.error(
9078
[
9179
"bun.lock workspace-version drift detected:",
9280
"",
9381
...mismatches.map((line) => ` - ${line}`),
9482
"",
95-
"A plain `bun install` will not fix this (it doesn't rewrite cached",
96-
"workspace versions for entries that already exist). Regenerate the",
97-
"lockfile instead:",
83+
write
84+
? "The lockfile shape is incomplete; workspace entries must exist before they can be synchronized."
85+
: "A plain `bun install` will not fix cached workspace self-versions. Synchronize them with:",
9886
"",
99-
" rm bun.lock && bun install",
87+
" bun scripts/check-lockfile-workspace-versions.ts --write",
88+
" bun install --frozen-lockfile",
10089
"",
10190
"Then commit the refreshed bun.lock.",
10291
].join("\n"),
10392
);
10493
process.exit(1);
10594
}
10695

107-
console.log("bun.lock workspace-version check: all workspace versions match. OK.");
96+
console.log(
97+
write
98+
? "bun.lock workspace-version sync: already current. OK."
99+
: "bun.lock workspace-version check: all workspace versions match. OK.",
100+
);

0 commit comments

Comments
 (0)