From 267e5b62ffa74aa4a34a968de919c3822ca9cb30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 04:08:08 +0000 Subject: [PATCH] bump-version: also update the member's version in a Bun workspace's bun.lock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In a Bun workspace bun.lock lives at the workspace root and records each member's version under "workspaces" → "". The bump changed apps/web/package.json but left bun.lock at the old version: --frozen-lockfile still accepts it, but the next bun install rewrites the line (found by the TDD review of firsttris/snapraid-ui#82). For a package.json version file the bump now finds bun.lock in its folder or the nearest one above it up to the repository root and replaces the version in exactly that member's entry, in place, so the rest of the file stays as Bun wrote it. Also when the entry is still at an older version from an earlier bump. The root package has no version in bun.lock; there the lock is left alone. The package-lock.json and Cargo.lock handling is unchanged. tests/bump-version/test.sh runs the step's Python on fixtures (member, stale lock, root package, package-lock.json, Cargo.lock) and checks that bun install --frozen-lockfile and bun install leave the bumped lock unchanged; the Lint workflow runs it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Y3rdfbWb1xDUdPRng7TX9Z --- .github/workflows/bump-version.yml | 57 ++++++++++++++ .github/workflows/lint.yml | 16 +++- README.md | 8 +- tests/bump-version/test.sh | 119 +++++++++++++++++++++++++++++ 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100755 tests/bump-version/test.sh diff --git a/.github/workflows/bump-version.yml b/.github/workflows/bump-version.yml index 40ef7d4..8ad9dea 100644 --- a/.github/workflows/bump-version.yml +++ b/.github/workflows/bump-version.yml @@ -130,6 +130,59 @@ jobs: blocks[i] = re.sub(rf'^version = "{re.escape(old)}"$', f'version = "{new}"', block, count=1, flags=re.M) path.write_text("\n[[package]]\n".join(blocks), encoding="utf-8") + # bun.lock is JSON with trailing commas: parsed only for the positions of its values, so the + # version is replaced in place and the rest of the file stays as Bun wrote it. + # Objects become {key: (value, start, end)}, other values their text span. + def jsonc(text, i=0): + ws = re.compile(r"\s*") + i = ws.match(text, i).end() + if text[i] == '"': + end = re.compile(r'"(?:[^"\\]|\\.)*"').match(text, i).end() + return json.loads(text[i:end]), end + if text[i] in "{[": + close, members = "}" if text[i] == "{" else "]", {} + i += 1 + while True: + i = ws.match(text, i).end() + if text[i] == ",": + i += 1 + continue + if text[i] == close: + return members, i + 1 + if close == "}": + key, i = jsonc(text, i) + i = ws.match(text, i).end() + 1 # the colon + start = ws.match(text, i).end() + value, i = jsonc(text, start) + if close == "}": + members[key] = (value, start, i) + end = re.compile(r"[^,}\]\s]+").match(text, i).end() + return text[i:end], end + + # A Bun workspace keeps each member's version in the bun.lock at the workspace root, under + # "workspaces" → the member's path relative to it ("" for the root package, which gets none) + def bun_lock(path): + repo = Path.cwd().resolve() + directory = path.resolve().parent + for d in [directory, *directory.parents]: + if (d / "bun.lock").is_file(): + lock = d / "bun.lock" + member = directory.relative_to(d).as_posix() + return (lock.relative_to(repo) if lock.is_relative_to(repo) else lock), ("" if member == "." else member) + if d == repo: + return None, None + return None, None + + def replace_bun_lock(path, member, new): + text = path.read_text(encoding="utf-8") + entry = jsonc(text)[0].get("workspaces", ({},))[0].get(member) + version = entry and isinstance(entry[0], dict) and entry[0].get("version") + if not version: + return False + _, start, end = version + path.write_text(text[:start] + json.dumps(new) + text[end:], encoding="utf-8") + return True + prefix = os.environ["TAG_PREFIX"] main = Path(os.environ["VERSION_FILE"]) if os.environ["VERSION_FILE"] else None extras = [Path(line.strip()) for line in os.environ["EXTRA_FILES"].splitlines() if line.strip()] @@ -194,6 +247,10 @@ jobs: # The root entry and packages[""] replace(lock, old, new, count=2) changed.append(lock) + lock, member = bun_lock(path) if path.name == "package.json" else (None, None) + # Also when the lock is still at an older version (an earlier bump without it) + if lock and replace_bun_lock(lock, member, new) and lock not in changed: + changed.append(lock) lock = path.with_name("Cargo.lock") if path.name == "Cargo.toml" else None if lock and lock.is_file(): replace_cargo_lock(lock, old, new) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 523f186..7722ba3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,15 +1,18 @@ name: Lint -# Only the workflows are linted (and the apt-install action tried), so only a change to them runs it. +# Only the workflows are linted (and the apt-install action and the bump script tried), so only a +# change to them runs it. on: push: paths: - ".github/workflows/**" - "actions/**" + - "tests/**" pull_request: paths: - ".github/workflows/**" - "actions/**" + - "tests/**" jobs: actionlint: @@ -23,6 +26,17 @@ jobs: bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/v${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" ./actionlint -color + # The Python of bump-version.yml on fixtures: which version files and lock files it changes + bump-version: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: 1.4.4 + - run: tests/bump-version/test.sh + # The apt-install action itself: a first install fills the cache, a second job takes it from there apt-install: runs-on: ubuntu-latest diff --git a/README.md b/README.md index a241fcf..202fcd0 100644 --- a/README.md +++ b/README.md @@ -415,7 +415,13 @@ jobs: | `release-workflow` | `release.yml` | started on the new tag; `""` starts nothing | A `package-lock.json` next to a `package.json` and a `Cargo.lock` next to a `Cargo.toml` (the -workspace's own crates) are updated along with it. Outputs: `version`, `tag`. +workspace's own crates) are updated along with it. In a Bun workspace the `bun.lock` in the +`package.json`'s folder or the nearest one above it (up to the repository root) carries the version +of a member, e.g. `apps/web/package.json` under `"workspaces"` → `"apps/web"`: that entry is updated +too, so the next `bun install` changes nothing. The root package gets no version in `bun.lock`, so +there the lock stays as it is. Outputs: `version`, `tag`. + +`tests/bump-version/test.sh` runs the step's Python on fixtures (in the Lint workflow). **Release candidates** on the default branch, no extra branch: `preminor` tags `v1.3.0-rc.1` and the [Docker release](#docker-release) pushes `:next`, while `:latest` stays on `1.2.x`; fixes go diff --git a/tests/bump-version/test.sh b/tests/bump-version/test.sh new file mode 100755 index 0000000..7d46458 --- /dev/null +++ b/tests/bump-version/test.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Runs the Python of bump-version.yml's "Raise the version" step on small fixtures and checks which +# files it changes: the version file, a package-lock.json or Cargo.lock beside it, and the member's +# entry in a Bun workspace's bun.lock. Needs python3 with PyYAML and bun. +# Usage: tests/bump-version/test.sh +set -euo pipefail +here=$(cd "$(dirname "$0")" && pwd) +workflow=$here/../../.github/workflows/bump-version.yml +work=$(mktemp -d); trap 'rm -rf "$work"' EXIT + +# The step's script, exactly as the workflow runs it (shell: python) +python3 - "$workflow" "$work/bump.py" <<'PY' +import sys, yaml +steps = yaml.safe_load(open(sys.argv[1]))["jobs"]["bump"]["steps"] +open(sys.argv[2], "w").write(next(s for s in steps if s.get("id") == "bump")["run"]) +PY + +failures=0 +fail() { echo " FAIL: $*"; failures=$((failures + 1)); } +check() { if eval "$2"; then echo " ok: $1"; else fail "$1"; fi; } +version_of() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$1"; } +# The version in bun.lock's workspaces entry $2 (the file is JSON with trailing commas) +lock_version() { + python3 -c ' +import json, re, sys +text = re.sub(r",(\s*[}\]])", r"\1", open(sys.argv[1]).read()) +print(json.loads(text)["workspaces"][sys.argv[2]].get("version", "-"))' "$1" "$2" +} +# bump [bump]: runs the step in ; the output "files" goes to $dir/.files +bump() { + (cd "$1" && : > .out && GITHUB_OUTPUT=.out BUMP=${3:-minor} PREID=rc VERSION_FILE=$2 EXTRA_FILES="" \ + TAG_PREFIX=v python3 "$work/bump.py" && sed -n 's/^files=//p' .out > .files) +} +files_has() { grep -qw -- "$2" "$1/.files"; } +# A Bun workspace: root package $1 (JSON), members apps/web and apps/api at 1.3.3 +bun_workspace() { + mkdir -p "$2/apps/web" "$2/apps/api" + echo "$1" > "$2/package.json" + echo '{"name": "web", "version": "1.3.3", "dependencies": {"api": "workspace:*"}}' > "$2/apps/web/package.json" + echo '{"name": "api", "version": "1.3.3"}' > "$2/apps/api/package.json" + (cd "$2" && bun install >/dev/null 2>&1) +} + +echo "Bun workspace, version file is the member apps/web/package.json" +d=$work/member +bun_workspace '{"name": "root", "private": true, "workspaces": ["apps/*"]}' "$d" +bump "$d" apps/web/package.json +check "apps/web/package.json at 1.4.0" '[[ $(version_of "$d/apps/web/package.json") == 1.4.0 ]]' +check "bun.lock: apps/web at 1.4.0" '[[ $(lock_version "$d/bun.lock" apps/web) == 1.4.0 ]]' +check "bun.lock: apps/api stays at 1.3.3" '[[ $(lock_version "$d/bun.lock" apps/api) == 1.3.3 ]]' +check "bun.lock is in the release commit" 'files_has "$d" bun.lock' +cp "$d/bun.lock" "$work/bumped.lock" +check "bun install --frozen-lockfile accepts it" '(cd "$d" && bun install --frozen-lockfile >/dev/null 2>&1)' +check "bun install --frozen-lockfile leaves it as it is" 'cmp -s "$work/bumped.lock" "$d/bun.lock"' +check "bun install leaves it as it is" '(cd "$d" && bun install >/dev/null 2>&1) && cmp -s "$work/bumped.lock" "$d/bun.lock"' + +echo "Bun workspace, bun.lock already stale (an earlier bump without it)" +d=$work/stale +bun_workspace '{"name": "root", "private": true, "workspaces": ["apps/*"]}' "$d" +sed -i 's/"version": "1.3.3"/"version": "1.3.4"/' "$d/apps/web/package.json" +bump "$d" apps/web/package.json patch +check "bun.lock: apps/web at 1.3.5" '[[ $(lock_version "$d/bun.lock" apps/web) == 1.3.5 ]]' +cp "$d/bun.lock" "$work/bumped.lock" +check "bun install leaves it as it is" '(cd "$d" && bun install >/dev/null 2>&1) && cmp -s "$work/bumped.lock" "$d/bun.lock"' + +echo "Bun workspace, version file is the root package.json" +d=$work/root +bun_workspace '{"name": "root", "version": "2.0.0", "private": true, "workspaces": ["apps/*"]}' "$d" +cp "$d/bun.lock" "$work/before.lock" +bump "$d" package.json +check "package.json at 2.1.0" '[[ $(version_of "$d/package.json") == 2.1.0 ]]' +check "bun.lock untouched" 'cmp -s "$work/before.lock" "$d/bun.lock"' +check "bun.lock not in the release commit" '! files_has "$d" bun.lock' + +echo "package.json with a package-lock.json beside it" +d=$work/npm; mkdir -p "$d" +echo '{"name": "app", "version": "1.2.3", "dependencies": {"dep": "^1.2.3"}}' > "$d/package.json" +cat > "$d/package-lock.json" <<'JSON' +{ + "name": "app", + "version": "1.2.3", + "lockfileVersion": 3, + "packages": { + "": {"name": "app", "version": "1.2.3", "dependencies": {"dep": "^1.2.3"}}, + "node_modules/dep": {"version": "1.2.3"} + } +} +JSON +bump "$d" package.json +check "package-lock.json: root and packages[\"\"] at 1.3.0, the dependency at 1.2.3" \ + '[[ $(python3 -c "import json; l = json.load(open(\"$d/package-lock.json\")); p = l[\"packages\"]; print(l[\"version\"], p[\"\"][\"version\"], p[\"node_modules/dep\"][\"version\"])") == "1.3.0 1.3.0 1.2.3" ]]' +check "package-lock.json in the release commit" 'files_has "$d" package-lock.json' + +echo "Cargo.toml with a Cargo.lock beside it" +d=$work/cargo; mkdir -p "$d" +printf '[package]\nname = "app"\nversion = "1.2.3"\n' > "$d/Cargo.toml" +cat > "$d/Cargo.lock" <<'LOCK' +version = 4 + +[[package]] +name = "app" +version = "1.2.3" +dependencies = [ + "dep", +] + +[[package]] +name = "dep" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +LOCK +bump "$d" Cargo.toml +check "Cargo.toml at 1.3.0" 'grep -qx "version = \"1.3.0\"" "$d/Cargo.toml"' +check "Cargo.lock: app at 1.3.0, the registry crate at 1.2.3" \ + '[[ $(grep -c "^version = \"1.3.0\"" "$d/Cargo.lock") == 1 && $(grep -c "^version = \"1.2.3\"" "$d/Cargo.lock") == 1 ]] && grep -A1 "name = \"app\"" "$d/Cargo.lock" | grep -q 1.3.0' +check "Cargo.lock in the release commit" 'files_has "$d" Cargo.lock' + +if ((failures)); then echo "$failures failed"; exit 1; fi +echo "all passed"