Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions .github/workflows/bump-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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()]
Expand Down Expand Up @@ -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)
Expand Down
16 changes: 15 additions & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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
Expand Down
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
119 changes: 119 additions & 0 deletions tests/bump-version/test.sh
Original file line number Diff line number Diff line change
@@ -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 <dir> <version-file> [bump]: runs the step in <dir>; 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"
Loading