Skip to content

Commit bd7feaf

Browse files
committed
Prevent stale-checkout changelog loss in version bump script
Add check_up_to_date_with_remote() to bump_version.py to detect when the local branch is behind its upstream before bumping. Running the bump from a stale checkout previously caused a released version's CHANGELOG.rst entry (v8.1.1) to be silently dropped when a later release (v8.1.2) was bumped from an out-of-date local copy. Also fixes pre-existing ruff findings in the script (S603/S607 partial-path subprocess calls, RUF005 list concat, DTZ011 naive date.today(), RUF013 implicit Optional, missing list[str] type arg).
1 parent 26af3e6 commit bd7feaf

1 file changed

Lines changed: 86 additions & 16 deletions

File tree

scripts/bump_version.py

Lines changed: 86 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,20 @@
2020
"""
2121

2222
import re
23+
import shutil
2324
import subprocess
2425
import sys
25-
from datetime import date
26+
from datetime import datetime
2627
from pathlib import Path
2728

29+
GIT = shutil.which("git") or "git"
2830

29-
def run_git_command(args: list) -> str:
31+
32+
def run_git_command(args: list[str]) -> str:
3033
"""Run a git command and return the output."""
3134
try:
32-
result = subprocess.run(
33-
["git"] + args,
35+
result = subprocess.run( # noqa: S603
36+
[GIT, *args],
3437
capture_output=True,
3538
text=True,
3639
check=True,
@@ -142,6 +145,73 @@ def check_working_directory_clean() -> None:
142145
sys.exit(1)
143146

144147

148+
def check_up_to_date_with_remote() -> None:
149+
"""Ensure the local branch is in sync with its remote before bumping.
150+
151+
Running the version bump from a stale local checkout can silently drop
152+
CHANGELOG.rst entries: the local file is missing changes (e.g. a prior
153+
release's changelog section) that already exist upstream, so the diff
154+
inserted by update_changelog() is based on outdated content and the
155+
upstream entry never makes it back in once pushed/merged.
156+
"""
157+
branch = run_git_command(["rev-parse", "--abbrev-ref", "HEAD"])
158+
upstream = subprocess.run( # noqa: S603
159+
[GIT, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
160+
capture_output=True,
161+
text=True,
162+
)
163+
if upstream.returncode != 0:
164+
print(
165+
f"Warning: Branch '{branch}' has no upstream tracking branch; "
166+
"skipping remote sync check.",
167+
file=sys.stderr,
168+
)
169+
return
170+
171+
upstream_ref = upstream.stdout.strip()
172+
173+
# Fetch quietly so the comparison below reflects the latest remote state.
174+
fetch = subprocess.run( # noqa: S603
175+
[GIT, "fetch", "--quiet"],
176+
capture_output=True,
177+
text=True,
178+
)
179+
if fetch.returncode != 0:
180+
print(
181+
f"Warning: 'git fetch' failed, remote sync check may be stale: "
182+
f"{fetch.stderr.strip()}",
183+
file=sys.stderr,
184+
)
185+
186+
local_sha = run_git_command(["rev-parse", "HEAD"])
187+
upstream_sha = run_git_command(["rev-parse", upstream_ref])
188+
189+
if local_sha == upstream_sha:
190+
return
191+
192+
behind = run_git_command(["rev-list", "--count", f"HEAD..{upstream_ref}"])
193+
ahead = run_git_command(["rev-list", "--count", f"{upstream_ref}..HEAD"])
194+
195+
if int(behind) > 0:
196+
print(
197+
f"Error: Local branch '{branch}' is {behind} commit(s) behind "
198+
f"'{upstream_ref}'.",
199+
file=sys.stderr,
200+
)
201+
print(
202+
"Pull the latest changes before bumping the version, otherwise "
203+
"CHANGELOG.rst entries from other releases may be lost.",
204+
file=sys.stderr,
205+
)
206+
sys.exit(1)
207+
208+
if int(ahead) > 0:
209+
print(
210+
f"Warning: Local branch '{branch}' is {ahead} commit(s) ahead of "
211+
f"'{upstream_ref}' (unpushed commits)."
212+
)
213+
214+
145215
def update_changelog(version: str) -> None:
146216
"""Insert a version heading into CHANGELOG.rst below the Unreleased section.
147217
@@ -169,15 +239,16 @@ def update_changelog(version: str) -> None:
169239

170240
content = changelog_path.read_text(encoding="utf-8")
171241

172-
heading = f"Version {version} ({date.today().isoformat()})"
242+
today = datetime.now().astimezone().date().isoformat()
243+
heading = f"Version {version} ({today})"
173244
underline = "=" * len(heading)
174245
version_block = f"{heading}\n{underline}\n"
175246

176247
# Match "Unreleased\n==========\n" (any number of = signs) followed by
177248
# one or more blank lines, then insert the version block after them.
178249
pattern = re.compile(
179250
r"(Unreleased\n=+\n)" # group 1: Unreleased heading
180-
r"(\n+)", # group 2: blank line(s) separator
251+
r"(\n+)", # group 2: blank line(s) separator
181252
re.MULTILINE,
182253
)
183254

@@ -192,10 +263,7 @@ def update_changelog(version: str) -> None:
192263

193264
# Insert the version block after the blank lines that follow "Unreleased"
194265
new_content = (
195-
content[: match.end()]
196-
+ version_block
197-
+ "\n"
198-
+ content[match.end() :]
266+
content[: match.end()] + version_block + "\n" + content[match.end() :]
199267
)
200268

201269
changelog_path.write_text(new_content, encoding="utf-8")
@@ -205,20 +273,18 @@ def update_changelog(version: str) -> None:
205273
def commit_changelog(version: str) -> None:
206274
"""Stage and commit the CHANGELOG.rst update."""
207275
run_git_command(["add", "CHANGELOG.rst"])
208-
run_git_command(
209-
["commit", "-m", f"Update changelog for v{version}"]
210-
)
276+
run_git_command(["commit", "-m", f"Update changelog for v{version}"])
211277
print("[OK] Committed changelog update")
212278

213279

214-
def create_tag(version: str, message: str = None) -> None:
280+
def create_tag(version: str, message: str | None = None) -> None:
215281
"""Create a git tag for the version."""
216282
tag_name = f"v{version}"
217283

218284
# Check if tag already exists
219285
try:
220-
subprocess.run(
221-
["git", "rev-parse", tag_name],
286+
subprocess.run( # noqa: S603
287+
[GIT, "rev-parse", tag_name],
222288
capture_output=True,
223289
check=True,
224290
)
@@ -283,6 +349,10 @@ def main() -> None:
283349
# Check working directory is clean
284350
check_working_directory_clean()
285351

352+
# Ensure we're not bumping from a stale checkout, which can silently
353+
# drop CHANGELOG.rst entries from releases made on the remote branch.
354+
check_up_to_date_with_remote()
355+
286356
# Get current version
287357
current_version = get_current_version()
288358
print(f"Current version: {current_version}")

0 commit comments

Comments
 (0)