From 76c80e10585e656fa415fd8f0f0066bdd682074c Mon Sep 17 00:00:00 2001 From: Jan Kubica Date: Fri, 24 Jul 2026 14:50:19 +0200 Subject: [PATCH] fix: preserve lockfile graph during versioning --- .ai/local/agents.md | 4 + .github/workflows/ci.yml | 6 + AGENTS.md | 4 + package.json | 3 +- scripts/bun-lock-workspace-versions.test.ts | 71 ++++++++ scripts/check-lockfile-workspace-versions.ts | 107 ++++++++++++ scripts/lib/bun-lock-workspace-versions.ts | 175 +++++++++++++++++++ 7 files changed, 369 insertions(+), 1 deletion(-) create mode 100644 scripts/bun-lock-workspace-versions.test.ts create mode 100644 scripts/check-lockfile-workspace-versions.ts create mode 100644 scripts/lib/bun-lock-workspace-versions.ts diff --git a/.ai/local/agents.md b/.ai/local/agents.md index 0c96325..3adddc8 100644 --- a/.ai/local/agents.md +++ b/.ai/local/agents.md @@ -18,3 +18,7 @@ This repository publishes Stella shared TypeScript and oxlint configuration pack - Do not add stricter lint rules without checking the public repos that consume them. - Prefer typed config helpers over copied JSON fragments. - Fixture files should prove rule behavior and make unused disables fail when a rule regresses. +- **Never delete or regenerate `bun.lock` to apply package version bumps.** Run + `bun scripts/check-lockfile-workspace-versions.ts --write`, then + `bun install --frozen-lockfile`. The synchronizer is the sole owner of cached + workspace self-versions; dependency-graph changes belong in an explicit install. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ec2b7c..b032cb4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,12 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + # Bun validates the resolved graph but does not refresh or validate cached + # workspace self-versions. Release automation uses this same script in + # byte-preserving --write mode; CI keeps it in check-only mode. + - name: Lockfile workspace-version check + run: bun run check:lockfile-versions + - name: Typecheck run: bun run typecheck diff --git a/AGENTS.md b/AGENTS.md index 9bf6d22..09568a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -157,3 +157,7 @@ This repository publishes Stella shared TypeScript and oxlint configuration pack - Do not add stricter lint rules without checking the public repos that consume them. - Prefer typed config helpers over copied JSON fragments. - Fixture files should prove rule behavior and make unused disables fail when a rule regresses. +- **Never delete or regenerate `bun.lock` to apply package version bumps.** Run + `bun scripts/check-lockfile-workspace-versions.ts --write`, then + `bun install --frozen-lockfile`. The synchronizer is the sole owner of cached + workspace self-versions; dependency-graph changes belong in an explicit install. diff --git a/package.json b/package.json index 8869075..424f6eb 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "scripts": { "build": "bun --filter @stll/oxlint-config build", "changeset": "changeset", - "changeset:version": "changeset version && rm -f bun.lock && bun install", + "changeset:version": "changeset version && bun scripts/check-lockfile-workspace-versions.ts --write && bun install --frozen-lockfile", + "check:lockfile-versions": "bun scripts/check-lockfile-workspace-versions.ts", "typecheck": "bun --filter @stll/oxlint-config typecheck", "test": "bun test", "test:rust-lints": "cd rust-lints/stella_lints && cargo test", diff --git a/scripts/bun-lock-workspace-versions.test.ts b/scripts/bun-lock-workspace-versions.test.ts new file mode 100644 index 0000000..f685a2f --- /dev/null +++ b/scripts/bun-lock-workspace-versions.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; + +import packageJson from "../package.json"; +import { syncWorkspaceVersions } from "./lib/bun-lock-workspace-versions"; + +const fixture = `{ + "lockfileVersion": 1, + "workspaces": { + "packages/core": { + "name": "@stll/core", + "version": "1.0.0", + "dependencies": { "version": "do-not-touch" }, + }, + "packages/escaped\\u002dname": { "version": "2.0.0" }, + }, + "packages": [{ "version": "also-do-not-touch" }], +}\n`; + +describe("bun.lock workspace self-version synchronization", () => { + test("changes only the exact workspace version string spans", () => { + const result = syncWorkspaceVersions( + fixture, + new Map([ + ["packages/core", "1.1.0"], + ["packages/escaped-name", "2.1.0"], + ]), + ); + + expect(result.mismatches).toHaveLength(2); + expect(result.text).toBe( + fixture + .replace('"version": "1.0.0"', '"version": "1.1.0"') + .replace('"version": "2.0.0"', '"version": "2.1.0"'), + ); + expect(result.text).toContain('"version": "do-not-touch"'); + expect(result.text).toContain('"version": "also-do-not-touch"'); + }); + + test("version-up/version-down is byte-identical", () => { + const up = syncWorkspaceVersions( + fixture, + new Map([["packages/core", "1.1.0"]]), + ).text; + const down = syncWorkspaceVersions( + up, + new Map([["packages/core", "1.0.0"]]), + ).text; + + expect(down).toBe(fixture); + }); + + test("refuses to invent missing workspace structure", () => { + const result = syncWorkspaceVersions( + fixture, + new Map([["packages/missing", "1.0.0"]]), + ); + + expect(result.text).toBe(fixture); + expect(result.mismatches).toEqual([ + { workspace: "packages/missing", expected: "1.0.0", actual: null }, + ]); + }); + + test("release versioning cannot delete or regenerate bun.lock", () => { + const command = packageJson.scripts["changeset:version"]; + + expect(command).not.toMatch(/\brm\b/); + expect(command).toContain("check-lockfile-workspace-versions.ts --write"); + expect(command).toEndWith("bun install --frozen-lockfile"); + }); +}); diff --git a/scripts/check-lockfile-workspace-versions.ts b/scripts/check-lockfile-workspace-versions.ts new file mode 100644 index 0000000..694ca45 --- /dev/null +++ b/scripts/check-lockfile-workspace-versions.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env bun +// CI gate: catches stale workspace `"version"` fields cached in bun.lock. +// +// Why this exists: `bun install` (even non-frozen) does NOT rewrite the +// `"version"` field bun.lock records for an already-present workspace entry +// when only that package's own package.json version changed — it only +// re-resolves dependency ranges. `bun install --frozen-lockfile` (what CI +// runs everywhere) validates that the dependency graph still satisfies the +// lockfile; it does not compare workspace self-versions either. So neither +// the normal install path nor the frozen-lockfile CI gate ever notices a +// workspace's recorded version drifting behind its package.json — and +// `bun pm pack` reads the *lockfile's* cached version when resolving +// workspace:* ranges for publish, so a stale entry silently ships a wrong +// dependency range (see the @stll/docx-core ^0.4.0-vs-0.5.0 incident this +// script was added to prevent). +// +// This script is the single owner of workspace self-version synchronization: +// check-only by default for CI, or byte-preserving repair with `--write`. + +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; + +import { syncWorkspaceVersions } from "./lib/bun-lock-workspace-versions"; + +const ROOT = join(import.meta.dirname, ".."); + +const readJson = async (path: string): Promise> => + JSON.parse(await Bun.file(path).text()); + +const packagesDir = join(ROOT, "packages"); +const entries = await readdir(packagesDir, { withFileTypes: true }); +const workspaceDirs = entries + .filter((entry) => entry.isDirectory()) + .map((entry) => `packages/${entry.name}`) + .sort(); + +const lockText = await Bun.file(join(ROOT, "bun.lock")).text(); + +const args = process.argv.slice(2); +const invalidArgs = args.filter((arg) => arg !== "--write"); +if (invalidArgs.length > 0 || args.length > 1) { + throw new Error( + "Usage: bun scripts/check-lockfile-workspace-versions.ts [--write]", + ); +} +const write = args[0] === "--write"; +const expectedVersions = new Map(); +const packageNames = new Map(); + +for (const workspaceDir of workspaceDirs) { + // A directory under packages/ is not necessarily a real workspace: skip + // it (rather than crash the guard) if its package.json is missing or + // fails to parse. + const pkg = await readJson(join(ROOT, workspaceDir, "package.json")).catch( + () => null, + ); + if (pkg === null) continue; + const name = pkg.name; + const version = pkg.version; + if (typeof name !== "string" || typeof version !== "string") continue; + + expectedVersions.set(workspaceDir, version); + packageNames.set(workspaceDir, name); +} + +const result = syncWorkspaceVersions(lockText, expectedVersions); +const unrepairable = result.mismatches.filter(({ actual }) => actual === null); + +if (write && unrepairable.length === 0 && result.text !== lockText) { + await Bun.write(join(ROOT, "bun.lock"), result.text); + console.log( + `bun.lock workspace-version sync: updated ${result.mismatches.length} workspace(s).`, + ); + process.exit(0); +} + +const mismatches = result.mismatches.map(({ workspace, expected, actual }) => + actual === null + ? `${packageNames.get(workspace)} (${workspace}): no writable bun.lock version entry found` + : `${packageNames.get(workspace)} (${workspace}): package.json is ${expected}, bun.lock has ${actual}`, +); + +if (mismatches.length > 0) { + console.error( + [ + "bun.lock workspace-version drift detected:", + "", + ...mismatches.map((line) => ` - ${line}`), + "", + write + ? "The lockfile shape is incomplete; workspace entries must exist before they can be synchronized." + : "A plain `bun install` will not fix cached workspace self-versions. Synchronize them with:", + "", + " bun scripts/check-lockfile-workspace-versions.ts --write", + " bun install --frozen-lockfile", + "", + "Then commit the refreshed bun.lock.", + ].join("\n"), + ); + process.exit(1); +} + +console.log( + write + ? "bun.lock workspace-version sync: already current. OK." + : "bun.lock workspace-version check: all workspace versions match. OK.", +); diff --git a/scripts/lib/bun-lock-workspace-versions.ts b/scripts/lib/bun-lock-workspace-versions.ts new file mode 100644 index 0000000..54b0804 --- /dev/null +++ b/scripts/lib/bun-lock-workspace-versions.ts @@ -0,0 +1,175 @@ +type JsonNode = + | { kind: "object"; properties: Map } + | { kind: "string"; start: number; end: number; value: string } + | { kind: "other" }; + +export type WorkspaceVersionMismatch = { + workspace: string; + expected: string; + actual: string | null; +}; + +export type WorkspaceVersionSyncResult = { + text: string; + mismatches: WorkspaceVersionMismatch[]; +}; + +/** + * Synchronize only workspace self-version string values in a Bun lockfile. + * + * Bun does not refresh these cached values when package.json versions change. + * Re-generating the lockfile fixes them, but also needlessly rewrites the + * resolved dependency graph. This parser finds the exact JSON string spans and + * changes nothing else, making version-up/version-down operations reversible. + */ +export const syncWorkspaceVersions = ( + source: string, + expectedVersions: ReadonlyMap, +): WorkspaceVersionSyncResult => { + let cursor = 0; + + const fail = (message: string): never => { + throw new Error(`Invalid bun.lock at byte ${cursor}: ${message}`); + }; + + const skipTrivia = (): void => { + while (cursor < source.length) { + if (/\s/.test(source[cursor] ?? "")) { + cursor += 1; + continue; + } + if (source.startsWith("//", cursor)) { + const newline = source.indexOf("\n", cursor + 2); + cursor = newline === -1 ? source.length : newline + 1; + continue; + } + if (source.startsWith("/*", cursor)) { + const end = source.indexOf("*/", cursor + 2); + if (end === -1) fail("unterminated block comment"); + cursor = end + 2; + continue; + } + break; + } + }; + + const parseString = (): Extract => { + skipTrivia(); + const start = cursor; + if (source[cursor] !== '"') fail("expected a string"); + cursor += 1; + while (cursor < source.length) { + const char = source[cursor]; + if (char === "\\") { + cursor += 2; + continue; + } + cursor += 1; + if (char === '"') { + const raw = source.slice(start, cursor); + return { + kind: "string", + start, + end: cursor, + value: JSON.parse(raw) as string, + }; + } + } + return fail("unterminated string"); + }; + + const parseValue = (): JsonNode => { + skipTrivia(); + if (source[cursor] === '"') return parseString(); + if (source[cursor] === "{") return parseObject(); + if (source[cursor] === "[") { + cursor += 1; + skipTrivia(); + while (source[cursor] !== "]") { + parseValue(); + skipTrivia(); + if (source[cursor] === ",") { + cursor += 1; + skipTrivia(); + if (source[cursor] === "]") break; + } else if (source[cursor] !== "]") { + fail("expected ',' or ']' in array"); + } + } + if (source[cursor] !== "]") fail("unterminated array"); + cursor += 1; + return { kind: "other" }; + } + + const start = cursor; + while (cursor < source.length && !/[\s,}\]]/.test(source[cursor] ?? "")) + cursor += 1; + if (cursor === start) fail("expected a value"); + return { kind: "other" }; + }; + + const parseObject = (): Extract => { + skipTrivia(); + if (source[cursor] !== "{") fail("expected an object"); + cursor += 1; + const properties = new Map(); + skipTrivia(); + while (source[cursor] !== "}") { + const key = parseString().value; + skipTrivia(); + if (source[cursor] !== ":") fail("expected ':' after object key"); + cursor += 1; + properties.set(key, parseValue()); + skipTrivia(); + if (source[cursor] === ",") { + cursor += 1; + skipTrivia(); + if (source[cursor] === "}") break; + } else if (source[cursor] !== "}") { + fail("expected ',' or '}' in object"); + } + } + if (source[cursor] !== "}") fail("unterminated object"); + cursor += 1; + return { kind: "object", properties }; + }; + + const root = parseValue(); + skipTrivia(); + if (cursor !== source.length) fail("unexpected content after root value"); + if (root.kind !== "object") + throw new Error("Invalid bun.lock: root must be an object"); + const workspaces = root.properties.get("workspaces"); + if (workspaces?.kind !== "object") { + throw new Error( + "Invalid bun.lock: root workspaces property must be an object", + ); + } + + const mismatches: WorkspaceVersionMismatch[] = []; + const replacements: Array<{ start: number; end: number; value: string }> = []; + for (const [workspace, expected] of expectedVersions) { + const entry = workspaces.properties.get(workspace); + const version = + entry?.kind === "object" ? entry.properties.get("version") : undefined; + const actual = version?.kind === "string" ? version.value : null; + if (actual === expected) continue; + mismatches.push({ workspace, expected, actual }); + if (version?.kind === "string") { + replacements.push({ + start: version.start, + end: version.end, + value: JSON.stringify(expected), + }); + } + } + + let text = source; + for (const replacement of replacements.sort((a, b) => b.start - a.start)) { + text = + text.slice(0, replacement.start) + + replacement.value + + text.slice(replacement.end); + } + return { text, mismatches }; +};