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"