fix(release): sync-versions owns bun.lock and enforces bun pin parity; release v0.13.5 (#40)

v0.13.4 reached npm but its Docker and Fly builds failed on
`bun install --frozen-lockfile`. bun.lock still recorded workspace
versions 0.13.2 and FUSE optionalDependencies pins ^0.8.1. CI ran the
pinned bun 1.4.0, which tolerates that drift, while the image's floating
`oven/bun:1.4` tag resolved to 1.4.1, which refuses it. The tag content
cannot be fixed, so this ships as 0.13.5.

- scripts/sync-versions.ts: every bump now rewrites the bun.lock
  workspace versions and FUSE pins (version fields only, resolutions
  untouched). --check verifies them, and verifies that packageManager,
  both Dockerfile FROM oven/bun: tags, and every workflow bun-version:
  name the same exact bun. Floating tags fail the gate.
- scripts/release.sh: run bun install --frozen-lockfile before the
  check, the same gate the Docker build runs.
- Pin bun 1.4.1 in Dockerfile, package.json packageManager, and all
  workflows.
- Bump to 0.13.5.
- RELEASING.md + CLAUDE.md: document both invariants and the recovery
  rule for a broken tagged commit.
This commit is contained in:
2pac
2026-09-04 15:38:05 +02:00
committed by GitHub
parent 913b10339c
commit d5022bbdfd
21 changed files with 179 additions and 34 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "agent-fs",
"description": "Agent-first filesystem CLI — store, search, and version files backed by S3",
"version": "0.13.4",
"version": "0.13.5",
"author": {
"name": "desplega-ai"
},
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4.0"
bun-version: "1.4.1"
# Hard gate: a partial bump (root moved, sub-packages/Cargo.toml/plugin
# left behind) fails here instead of shipping a broken version set.
+2 -2
View File
@@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4.0"
bun-version: "1.4.1"
- run: bun install --frozen-lockfile
# Catches a partial version bump (root package.json moved but the
# sub-packages, Cargo.toml, or plugin.json were left behind) on the PR
@@ -48,7 +48,7 @@ jobs:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: "1.4.0"
bun-version: "1.4.1"
- run: bun install --frozen-lockfile
- name: Run local-filesystem E2E suite
run: bun run scripts/e2e.ts "bun run packages/cli/src/index.ts --" --local-only
+3 -3
View File
@@ -32,7 +32,7 @@ jobs:
- uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
with:
bun-version: "1.4.0"
bun-version: "1.4.1"
- name: Verify tag matches package.json version
id: pkg
@@ -209,7 +209,7 @@ jobs:
- uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
with:
bun-version: "1.4.0"
bun-version: "1.4.1"
- name: Download linux-x64 artifact
uses: actions/download-artifact@fa0a91b85d4f404e444e00e005971372dc801d16 # v4.1.8
@@ -258,7 +258,7 @@ jobs:
- uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
with:
bun-version: "1.4.0"
bun-version: "1.4.1"
- run: bun install --frozen-lockfile
- run: bun run build
+2
View File
@@ -27,6 +27,8 @@ Short version: releases are automatic. A version change landing on `main` trigge
Never bump a version by hand-editing `package.json``scripts/sync-versions.ts` owns every place the version appears, and CI fails a partial bump.
Two more invariants that `sync-versions.ts --check` enforces on every PR and before every tag (details in [RELEASING.md § Version targets](./RELEASING.md#version-targets)): `bun.lock` workspace versions move with the release version, and the bun version is pinned to the same exact number in `package.json` `packageManager`, both `Dockerfile` `FROM oven/bun:` tags, and every workflow `bun-version:`. Never use a floating `oven/bun:1.4` tag. Bump the three places together, and run `bun install --frozen-lockfile` after any dependency or version change so the lockfile is committed with it.
## Release Checklist (applies to plans, research, and Plan mode)
When making changes to core ops, CLI commands, or MCP tools, always check:
Generated
+1 -1
View File
@@ -4,7 +4,7 @@ version = 4
[[package]]
name = "agent-fs-fuse"
version = "0.13.4"
version = "0.13.5"
dependencies = [
"anyhow",
"bytes",
+2 -2
View File
@@ -1,4 +1,4 @@
FROM oven/bun:1.4 AS builder
FROM oven/bun:1.4.1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
@@ -18,7 +18,7 @@ RUN bun install --frozen-lockfile
COPY . .
RUN bun run build
FROM oven/bun:1.4-slim
FROM oven/bun:1.4.1-slim
WORKDIR /app
COPY --from=builder /app/package.json /app/bun.lock ./
+5
View File
@@ -70,6 +70,7 @@ Ordering inside `npm-publish.yml` is load-bearing: the FUSE sub-packages must re
- the FUSE `optionalDependencies` pins in `packages/cli/package.json` (as `^{version}`)
- `packages/fuse-helper/Cargo.toml` and the `agent-fs-fuse` entry in `Cargo.lock`
- `.claude-plugin/plugin.json`
- `bun.lock`: the `version` of every workspace entry and the FUSE `optionalDependencies` pins. Only those fields move; dependency resolutions are never touched.
```bash
bun run scripts/sync-versions.ts 0.13.0 # rewrite them all
@@ -82,6 +83,8 @@ bun run scripts/sync-versions.ts --check # verify, exit 1 on drift
- **`ci.yml`, on every PR** — a partial bump (root moved, sub-packages left behind) turns the PR red.
- **`auto-release.yml`, before tagging** — the real backstop.
`--check` also enforces **bun pin parity**: `packageManager` in the root `package.json`, both `FROM oven/bun:` tags in `Dockerfile`, and every `bun-version:` in `.github/workflows/` must name the same exact bun version. Floating tags such as `oven/bun:1.4` are rejected. v0.13.4 is the reason: CI ran the pinned 1.4.0 and passed, the image build resolved the floating tag to 1.4.1, which refuses `--frozen-lockfile` on a lagging `bun.lock`, and the Docker and Fly publishes failed after the tag existed. Bump all three places together.
`live/` and `landing/` are deliberately excluded — they're deployed by Vercel and carry their own versions.
---
@@ -119,6 +122,8 @@ gh workflow run docker-publish.yml -f tag=v0.13.0
Safe to re-run: every publish step checks the registry first and skips versions already there.
If the publish failed because the tagged commit itself is broken (for example `bun install --frozen-lockfile` rejects the committed `bun.lock`), re-dispatching cannot help: the tag's content is fixed. Fix it on `main` and cut the next patch version. Never move or delete a released tag.
### Publishing from a laptop
Last resort, when Actions itself is the problem. Requires `NPM_CONFIG_TOKEN` (Bun ignores `NODE_AUTH_TOKEN`):
+9 -9
View File
@@ -15,7 +15,7 @@
},
"packages/cli": {
"name": "@desplega.ai/agent-fs",
"version": "0.13.2",
"version": "0.13.5",
"bin": {
"agent-fs": "dist/cli.js",
},
@@ -36,8 +36,8 @@
"zod": "^3.24.0",
},
"optionalDependencies": {
"@desplega.ai/agent-fs-fuse-linux-arm64": "^0.8.1",
"@desplega.ai/agent-fs-fuse-linux-x64": "^0.8.1",
"@desplega.ai/agent-fs-fuse-linux-arm64": "^0.13.5",
"@desplega.ai/agent-fs-fuse-linux-x64": "^0.13.5",
"sqlite-vec-darwin-arm64": "^0.1.9",
"sqlite-vec-darwin-x64": "^0.1.9",
"sqlite-vec-linux-arm64": "^0.1.9",
@@ -47,7 +47,7 @@
},
"packages/core": {
"name": "@desplega.ai/agent-fs-core",
"version": "0.13.2",
"version": "0.13.5",
"dependencies": {
"@aws-sdk/client-s3": "^3.750.0",
"@aws-sdk/s3-request-presigner": "^3.750.0",
@@ -70,15 +70,15 @@
},
"packages/fuse-helper-linux-arm64": {
"name": "@desplega.ai/agent-fs-fuse-linux-arm64",
"version": "0.13.2",
"version": "0.13.5",
},
"packages/fuse-helper-linux-x64": {
"name": "@desplega.ai/agent-fs-fuse-linux-x64",
"version": "0.13.2",
"version": "0.13.5",
},
"packages/just-bash": {
"name": "@desplega.ai/agent-fs-just-bash",
"version": "0.13.2",
"version": "0.13.5",
"peerDependencies": {
"just-bash": ">=3.0.1",
},
@@ -88,7 +88,7 @@
},
"packages/mcp": {
"name": "@desplega.ai/agent-fs-mcp",
"version": "0.13.2",
"version": "0.13.5",
"dependencies": {
"@desplega.ai/agent-fs-core": "workspace:*",
"@modelcontextprotocol/sdk": "^1.27.1",
@@ -97,7 +97,7 @@
},
"packages/server": {
"name": "@desplega.ai/agent-fs-server",
"version": "0.13.2",
"version": "0.13.5",
"dependencies": {
"@desplega.ai/agent-fs-core": "workspace:*",
"@desplega.ai/agent-fs-mcp": "workspace:*",
+1 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.1.0",
"info": {
"title": "agent-fs API",
"version": "0.13.4",
"version": "0.13.5",
"description": "A persistent, searchable filesystem for AI agents. agent-fs is to files what agentmail is to email.",
"license": {
"name": "MIT",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "agent-fs",
"version": "0.13.4",
"packageManager": "bun@1.4.0",
"version": "0.13.5",
"packageManager": "bun@1.4.1",
"private": true,
"workspaces": [
"packages/*"
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@desplega.ai/agent-fs",
"version": "0.13.4",
"version": "0.13.5",
"type": "module",
"description": "Agent-first filesystem backed by S3",
"license": "MIT",
@@ -35,8 +35,8 @@
"sqlite-vec-linux-arm64": "^0.1.9",
"sqlite-vec-linux-x64": "^0.1.9",
"sqlite-vec-windows-x64": "^0.1.9",
"@desplega.ai/agent-fs-fuse-linux-x64": "^0.13.4",
"@desplega.ai/agent-fs-fuse-linux-arm64": "^0.13.4"
"@desplega.ai/agent-fs-fuse-linux-x64": "^0.13.5",
"@desplega.ai/agent-fs-fuse-linux-arm64": "^0.13.5"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.750.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@desplega.ai/agent-fs-core",
"version": "0.13.4",
"version": "0.13.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
@@ -1,6 +1,6 @@
{
"name": "@desplega.ai/agent-fs-fuse-linux-arm64",
"version": "0.13.4",
"version": "0.13.5",
"description": "Linux aarch64 FUSE helper binary for @desplega.ai/agent-fs. Do not install directly.",
"os": [
"linux"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@desplega.ai/agent-fs-fuse-linux-x64",
"version": "0.13.4",
"version": "0.13.5",
"description": "Linux x86_64 FUSE helper binary for @desplega.ai/agent-fs. Do not install directly.",
"os": [
"linux"
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "agent-fs-fuse"
version = "0.13.4"
version = "0.13.5"
edition = "2021"
description = "FUSE helper for agent-fs — mounts agent-fs drives as a Linux filesystem."
license = "MIT"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@desplega.ai/agent-fs-just-bash",
"version": "0.13.4",
"version": "0.13.5",
"type": "module",
"description": "just-bash-compatible filesystem adapter for agent-fs",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@desplega.ai/agent-fs-mcp",
"version": "0.13.4",
"version": "0.13.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@desplega.ai/agent-fs-server",
"version": "0.13.4",
"version": "0.13.5",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
+5
View File
@@ -27,6 +27,11 @@ fi
bun run scripts/sync-versions.ts "$VERSION"
bun run scripts/sync-openapi.ts
# The same gate the Docker build runs. sync-versions.ts rewrote the bun.lock
# workspace versions above; any other lockfile drift fails here instead of in
# docker-publish, after the tag already exists and cannot be fixed.
bun install --frozen-lockfile
# Belt and braces — the same gate CI enforces, run before anything is pushed.
bun run scripts/sync-versions.ts --check
+135 -2
View File
@@ -18,6 +18,10 @@
// @desplega.ai/agent-fs-fuse-linux-* — pinned to the new version
// - packages/fuse-helper/Cargo.toml — `version = "..."` on the [package] line
// - .claude-plugin/plugin.json
// - bun.lock: the "version" of every workspace entry plus the FUSE
// optionalDependencies pins. bun 1.4.1 refuses --frozen-lockfile when
// these lag package.json; only these fields are rewritten, resolutions
// are never touched.
//
// --dry-run prints the would-be changes without writing.
//
@@ -27,7 +31,7 @@
// before it reaches main, and the auto-release workflow runs it as a gate
// before tagging.
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
@@ -124,6 +128,89 @@ function readCargoLockVersion(relPath: string): string | null {
return mapCargoLockVersion(readFileSync(abs, "utf-8"), FUSE_CRATE, null).version;
}
// bun.lock records every workspace's version and the FUSE optionalDependencies
// pins. bun 1.4.0 tolerated a lagging lockfile under --frozen-lockfile; bun
// 1.4.1 refuses it. That is how v0.13.4 reached npm but failed the Docker and
// Fly builds: CI ran the pinned 1.4.0, the image ran the floating 1.4.1. Only
// the version fields are rewritten here, so a release never pulls a new
// dependency resolution on the side.
//
// Pass `replacement: null` to read the current values without rewriting.
const BUN_LOCK = "bun.lock";
function mapBunLockVersions(
raw: string,
replacement: string | null
): { versions: string[]; fusePins: string[]; text: string } {
const lines = raw.split("\n");
let inWorkspaces = false;
const versions: string[] = [];
const fusePins: string[] = [];
const out = lines.map((line) => {
if (line === ' "workspaces": {') {
inWorkspaces = true;
return line;
}
// The next two-space-indented section ("packages", "overrides", ...)
// closes the workspaces block.
if (inWorkspaces && /^ "[^"]*": \{$/.test(line)) {
inWorkspaces = false;
return line;
}
if (!inWorkspaces) return line;
const v = /^(\s{6}"version": ")([^"]*)(",?)$/.exec(line);
if (v) {
versions.push(v[2] as string);
return replacement === null ? line : `${v[1]}${replacement}${v[3]}`;
}
if (line.trimStart().startsWith(`"${FUSE_OPT_DEP_PREFIX}`)) {
const f = /^(\s{8}"[^"]*": ")([^"]*)(",?)$/.exec(line);
if (f) {
fusePins.push(f[2] as string);
return replacement === null ? line : `${f[1]}^${replacement}${f[3]}`;
}
}
return line;
});
return { versions, fusePins, text: out.join("\n") };
}
// The Docker build, CI, and the packageManager field must run the same exact
// bun. A floating `oven/bun:1.4` tag is what let CI (pinned 1.4.0) stay green
// while the image build (resolved to 1.4.1) failed --frozen-lockfile on the
// same commit. Checked only; nothing here is rewritten by a version bump.
function checkBunPinParity(): string[] {
const problems: string[] = [];
const rootPkg = readJson("package.json");
const pm = String(rootPkg?.packageManager ?? "");
const m = /^bun@(\d+\.\d+\.\d+)$/.exec(pm);
if (!m) {
return [`package.json packageManager: "${pm}" (expected bun@<exact version>)`];
}
const expected = m[1] as string;
const dockerfile = readFileSync(resolve(repoRoot, "Dockerfile"), "utf-8");
for (const match of dockerfile.matchAll(/^FROM oven\/bun:(\S+)/gm)) {
const tag = match[1] as string;
if (tag.replace(/-slim$/, "") !== expected) {
problems.push(`Dockerfile FROM oven/bun:${tag}: expected ${expected} (no floating tags)`);
}
}
const wfDir = resolve(repoRoot, ".github/workflows");
for (const name of readdirSync(wfDir).sort()) {
if (!name.endsWith(".yml") && !name.endsWith(".yaml")) continue;
const text = readFileSync(resolve(wfDir, name), "utf-8");
for (const match of text.matchAll(/bun-version:\s*"?([^"\s]+)"?/g)) {
const found = match[1] as string;
if (found !== expected) {
problems.push(`.github/workflows/${name} bun-version ${found}: expected ${expected}`);
}
}
}
return problems;
}
// --check ---------------------------------------------------------------
// Compares the version *fields* rather than whole-file bytes, so reformatting
// a package.json can never trip the gate — only a real version mismatch does.
@@ -173,6 +260,24 @@ if (check) {
problems.push(`${CARGO_LOCK}${lockVersion} (expected ${expected})`);
}
const bunLockAbs = resolve(repoRoot, BUN_LOCK);
if (!existsSync(bunLockAbs)) {
problems.push(`${BUN_LOCK}: file not found`);
} else {
const { versions, fusePins } = mapBunLockVersions(readFileSync(bunLockAbs, "utf-8"), null);
if (versions.length === 0) {
problems.push(`${BUN_LOCK}: no workspace version entries found`);
}
for (const found of new Set(versions.filter((v) => v !== expected))) {
problems.push(`${BUN_LOCK} workspace version ${found}: expected ${expected}`);
}
for (const found of new Set(fusePins.filter((p) => p !== `^${expected}`))) {
problems.push(`${BUN_LOCK} FUSE optionalDependencies pin ${found}: expected ^${expected}`);
}
}
const pinProblems = checkBunPinParity();
const plugin = readJson(".claude-plugin/plugin.json");
if (!plugin) {
problems.push(".claude-plugin/plugin.json — file not found");
@@ -188,9 +293,21 @@ if (check) {
console.error(
`\nFix with: bun run scripts/sync-versions.ts ${expected}\nthen commit the result.`
);
process.exit(1);
}
if (pinProblems.length > 0) {
if (problems.length > 0) console.error("");
console.error("bun pin drift (packageManager, Dockerfile, and workflows must agree):\n");
for (const p of pinProblems) console.error(`${p}`);
console.error(
"\nFix by hand: set the same exact bun version in package.json packageManager,\n" +
"both Dockerfile FROM oven/bun: tags, and every workflow bun-version:, then\n" +
"run bun install --frozen-lockfile and commit."
);
}
if (problems.length > 0 || pinProblems.length > 0) process.exit(1);
console.log(`✓ every version target is at ${expected}.`);
process.exit(0);
}
@@ -336,6 +453,22 @@ rewriteCargoToml("packages/fuse-helper/Cargo.toml");
}
}
// bun.lock --------------------------------------------------------------
{
const abs = resolve(repoRoot, BUN_LOCK);
if (!existsSync(abs)) {
console.warn(`[skip] ${BUN_LOCK}: not found`);
} else {
const raw = readFileSync(abs, "utf-8");
const { versions, text } = mapBunLockVersions(raw, newVersion);
if (versions.length === 0) {
console.warn(`[warn] ${BUN_LOCK}: no workspace version entries found`);
} else if (recordIfDifferent(BUN_LOCK, raw, text)) {
if (!dryRun) writeFileSync(abs, text);
}
}
}
// Plugin metadata -------------------------------------------------------
rewriteJson(".claude-plugin/plugin.json", (plugin) => {
plugin.version = newVersion;