fix: prevent Bun downgrades during upstream merges

This commit is contained in:
Catriel Müller
2026-06-11 10:44:33 -03:00
parent 7edba02494
commit fee5cf441a
6 changed files with 158 additions and 3 deletions
+24
View File
@@ -0,0 +1,24 @@
# Prevent Bun downgrades during upstream merges
## Goal
Ensure `script/upstream/merge.ts` keeps the newer root `packageManager` Bun version across Kilo and upstream: accept an upstream upgrade, preserve Kilo when upstream is older, and fail before finalizing if the merged result is below either input.
## Implementation
1. Update `script/upstream/transforms/transform-package-json.ts` with a small, exported Bun package-manager reconciliation helper that:
- Applies only to the root `package.json`.
- Parses `bun@<semver>` values and compares them with the existing version comparison logic.
- Selects the newer value from Kilo/base and upstream, preserving the original full `packageManager` string.
- Treats malformed or missing values conservatively so an unparseable upstream value cannot replace a valid Kilo value.
- Records a transform change when it restores a newer Kilo version.
2. Invoke that helper in every root package reconciliation path: conflicted package transformation, pre-merge package transformation, and post-merge reconciliation. This makes an upstream `bun@1.3.15` upgrade flow through while preventing an upstream `bun@1.3.13` from replacing Kilo's `bun@1.3.14`.
3. Add a final guard in `script/upstream/merge.ts`, after successful package reconciliation and before finalization/push, that compares the working-tree Bun version with both the base commit and pristine upstream target. Abort with a clear error if the result is lower than the newest valid input, protecting against future transform/rerere regressions.
4. Extend `script/upstream/transforms/transform-package-json.test.ts` with focused cases for Kilo-newer, upstream-newer, equal, non-root, and malformed package-manager values. Cover the final comparison/guard through an exported pure assertion helper rather than mocking git.
5. Update `script/upstream/README.md` to document the newest-Bun-wins behavior and the no-downgrade final validation.
## Validation
- Run `bun test script/upstream/transforms/transform-package-json.test.ts`.
- Run the repository typecheck (`bun run typecheck`) to validate the updated transform and merge orchestration types.
- No changeset is needed because this only changes internal upstream-merge tooling.
+1 -1
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
"packageManager": "bun@1.3.13",
"packageManager": "bun@1.3.14",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:storybook": "bun --cwd packages/storybook storybook",
+5 -1
View File
@@ -48,7 +48,7 @@ bun run merge.ts --version v1.1.50 --base-branch catrielmuller/kilo-opencode-v1.
| `transforms/skip-files.ts` | Skip/remove files that shouldn't exist in Kilo |
| `transforms/transform-i18n.ts` | Transform i18n files with Kilo branding |
| `transforms/transform-take-theirs.ts` | Take upstream + apply Kilo branding for branding-only files |
| `transforms/transform-package-json.ts` | Enhanced package.json with Kilo dependency injection |
| `transforms/transform-package-json.ts` | Enhanced package.json with Kilo dependency injection and newest-Bun-wins reconciliation |
| `transforms/transform-scripts.ts` | Transform script files with GitHub API references |
| `transforms/transform-extensions.ts` | Transform extension files (Zed, etc.) |
| `transforms/transform-web.ts` | Transform web/docs files (.mdx) |
@@ -196,6 +196,10 @@ Now:
The only remaining conflicts are files with **actual code differences** - files with `kilocode_change` markers that contain Kilo-specific logic.
### Bun Version Safety
Root `package.json` reconciliation uses the newer valid `packageManager` Bun version from Kilo and upstream. An older upstream version cannot downgrade Kilo, while a newer upstream version is retained as an upgrade. Before the merge is finalized, `merge.ts` also validates the result against the pristine Kilo base and upstream commit and aborts if the merged Bun version is lower than either input.
## CLI Options
### merge.ts
+17
View File
@@ -37,6 +37,7 @@ import {
transformConflictedPackageJson,
transformAllPackageJson,
reconcileAllPackageJson,
assertBunPackageManager,
} from "./transforms/transform-package-json"
import { transformConflictedScripts, transformAllScripts } from "./transforms/transform-scripts"
import { transformConflictedExtensions, transformAllExtensions } from "./transforms/transform-extensions"
@@ -206,6 +207,20 @@ async function getAuthor(): Promise<string> {
.replace(/\s+/g, "")
}
function manager(content: string): string | undefined {
const pkg: unknown = JSON.parse(content)
if (!pkg || typeof pkg !== "object" || !("packageManager" in pkg)) return undefined
return typeof pkg.packageManager === "string" ? pkg.packageManager : undefined
}
async function validateBun(base: string, upstream: string): Promise<void> {
const current = manager(await Bun.file("package.json").text())
const ours = manager(await $`git show ${base}:package.json`.text())
const theirs = manager(await $`git show ${upstream}:package.json`.text())
assertBunPackageManager(current, ours, theirs)
logger.success(`Validated Bun packageManager: ${current ?? "missing"}`)
}
async function createBackupBranch(baseBranch: string): Promise<string> {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19)
const backupName = `backup/${baseBranch}-${timestamp}`
@@ -807,6 +822,7 @@ async function main() {
// Exit early - don't continue to finalization steps
process.exit(1)
} else {
await validateBun(baseSha, targetVersion.commit)
await git.stageAll()
await git.commit(`merge: upstream ${targetVersion.tag}`)
logger.success("Merge completed - all conflicts auto-resolved!")
@@ -824,6 +840,7 @@ async function main() {
if (reconcileCount > 0) {
logger.success(`Reconciled ${reconcileCount} package.json file(s) post-merge`)
}
await validateBun(baseSha, targetVersion.commit)
await git.stageAll()
const hasChanges = await git.hasUncommittedChanges()
if (hasChanges) {
@@ -1,5 +1,13 @@
import { expect, test } from "bun:test"
import { fixCatalog, fixMetadata, fixScripts, mergeWithNewestVersions } from "./transform-package-json"
import {
assertBunPackageManager,
fixCatalog,
fixMetadata,
fixPackageManager,
fixScripts,
mergeWithNewestVersions,
selectBunPackageManager,
} from "./transform-package-json"
test("fixScripts preserves Kilo-only root scripts from base", () => {
const ours = {
@@ -125,3 +133,54 @@ test("mergeWithNewestVersions appends theirs-only keys at the end", () => {
const result = mergeWithNewestVersions(ours, theirs, changes, "dependencies")
expect(Object.keys(result)).toEqual(["a", "b", "c"])
})
test("selectBunPackageManager keeps the newer Bun version", () => {
expect(selectBunPackageManager("bun@1.3.14", "bun@1.3.13")).toBe("bun@1.3.14")
expect(selectBunPackageManager("bun@1.3.14", "bun@1.3.15")).toBe("bun@1.3.15")
expect(selectBunPackageManager("bun@1.3.14", "bun@1.3.14")).toBe("bun@1.3.14")
})
test("selectBunPackageManager preserves valid versions over malformed values", () => {
expect(selectBunPackageManager("bun@1.3.14", "bun@latest")).toBe("bun@1.3.14")
expect(selectBunPackageManager("bun@latest", "bun@1.3.15")).toBe("bun@1.3.15")
expect(selectBunPackageManager("bun@latest", "npm@11.0.0")).toBeUndefined()
})
test("fixPackageManager prevents root Bun downgrades", () => {
const pkg: Record<string, unknown> = { packageManager: "bun@1.3.13" }
const ours = { packageManager: "bun@1.3.14" }
const changes: string[] = []
fixPackageManager(pkg, "package.json", ours, changes)
expect(pkg.packageManager).toBe("bun@1.3.14")
expect(changes).toEqual(["packageManager: bun@1.3.13 -> bun@1.3.14 (Kilo newer)"])
})
test("fixPackageManager accepts upstream Bun upgrades", () => {
const pkg: Record<string, unknown> = { packageManager: "bun@1.3.15" }
const changes: string[] = []
fixPackageManager(pkg, "package.json", { packageManager: "bun@1.3.14" }, changes)
expect(pkg.packageManager).toBe("bun@1.3.15")
expect(changes).toEqual([])
})
test("fixPackageManager ignores nested package.json files", () => {
const pkg: Record<string, unknown> = { packageManager: "bun@1.3.13" }
const changes: string[] = []
fixPackageManager(pkg, "packages/opencode/package.json", { packageManager: "bun@1.3.14" }, changes)
expect(pkg.packageManager).toBe("bun@1.3.13")
expect(changes).toEqual([])
})
test("assertBunPackageManager rejects merged downgrades and invalid values", () => {
expect(() => assertBunPackageManager("bun@1.3.13", "bun@1.3.14", "bun@1.3.12")).toThrow(
"Bun packageManager downgrade detected",
)
expect(() => assertBunPackageManager("bun@latest", "bun@1.3.14", "bun@1.3.15")).toThrow(
"Bun packageManager validation failed",
)
})
test("assertBunPackageManager accepts the newest input or a newer result", () => {
expect(() => assertBunPackageManager("bun@1.3.15", "bun@1.3.14", "bun@1.3.15")).not.toThrow()
expect(() => assertBunPackageManager("bun@1.3.16", "bun@1.3.14", "bun@1.3.15")).not.toThrow()
})
@@ -105,6 +105,51 @@ function compareVersions(a: string, b: string): number | null {
return 0
}
function bun(value: unknown): { value: string; version: string } | null {
if (typeof value !== "string") return null
const match = value.match(/^bun@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$/)
if (!match) return null
if (compareVersions(match[1], match[1]) === null) return null
return { value, version: match[1] }
}
export function selectBunPackageManager(ours: unknown, theirs: unknown): string | undefined {
const left = bun(ours)
const right = bun(theirs)
if (left && right) return compareVersions(left.version, right.version)! > 0 ? left.value : right.value
if (left) return left.value
if (right) return right.value
return undefined
}
export function fixPackageManager(
pkg: Record<string, unknown>,
path: string,
ours: Record<string, unknown> | null,
changes: string[],
): void {
if (path !== "package.json") return
const next = selectBunPackageManager(ours?.packageManager, pkg.packageManager)
if (!next || pkg.packageManager === next) return
const prior = typeof pkg.packageManager === "string" ? pkg.packageManager : "missing or invalid"
changes.push(`packageManager: ${prior} -> ${next} (Kilo newer)`)
pkg.packageManager = next
}
export function assertBunPackageManager(current: unknown, base: unknown, upstream: unknown): void {
const inputs = [bun(base), bun(upstream)].filter((item): item is NonNullable<typeof item> => item !== null)
if (inputs.length === 0) return
const required = inputs.reduce((max, item) => (compareVersions(item.version, max.version)! > 0 ? item : max))
const actual = bun(current)
if (!actual) {
throw new Error(
`Bun packageManager validation failed: merged value is invalid; expected at least ${required.value}`,
)
}
if (compareVersions(actual.version, required.version)! >= 0) return
throw new Error(`Bun packageManager downgrade detected: merged ${actual.value}, expected at least ${required.value}`)
}
/**
* Merge two dependency objects using "newest wins" strategy
* For non-comparable versions (URLs, catalog:, workspace:*), upstream (theirs) wins
@@ -392,6 +437,8 @@ export async function transformPackageJson(file: string, options: PackageJsonOpt
pkg.name = newName
}
fixPackageManager(pkg, relativePath, ourPkg, changes)
// 2. Preserve Kilo version if requested
if (options.preserveVersion !== false) {
const kiloVersion = await getCurrentVersion()
@@ -612,6 +659,8 @@ export async function transformAllPackageJson(options: PackageJsonOptions = {}):
pkg.name = newName
}
fixPackageManager(pkg, path, kiloPkg, changes)
// 2. Preserve Kilo version if requested
if (options.preserveVersion !== false) {
const kiloVersion = await getCurrentVersion()
@@ -824,6 +873,8 @@ export async function reconcilePackageJsonFromRefs(
pkg.name = newName
}
fixPackageManager(pkg, relativePath, ourPkg, changes)
if (options.preserveVersion !== false) {
const kiloVersion = await getCurrentVersion()
if (pkg.version !== kiloVersion) {