Skip to content

Commit 19c2f56

Browse files
committed
Improve releaseWizard's logchange.py to handle LTS release (#4425)
(cherry picked from commit 7a0a004)
1 parent 2b6a340 commit 19c2f56

3 files changed

Lines changed: 184 additions & 91 deletions

File tree

dev-tools/scripts/logchange.py

Lines changed: 163 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -69,12 +69,12 @@ def short_sha(git_root):
6969
return r.stdout.strip()
7070

7171

72-
def has_staged_changes(git_root):
73-
"""Return True if there are staged changes in the index."""
74-
r = subprocess.run(
75-
["git", "diff", "--cached", "--name-only"],
76-
cwd=git_root, capture_output=True, text=True,
77-
)
72+
def has_staged_changes(git_root, paths=None):
73+
"""Return True if there are staged changes in the index, optionally scoped to paths."""
74+
cmd = ["git", "diff", "--cached", "--name-only"]
75+
if paths:
76+
cmd += ["--"] + list(paths)
77+
r = subprocess.run(cmd, cwd=git_root, capture_output=True, text=True)
7878
return bool(r.stdout.strip())
7979

8080

@@ -192,11 +192,14 @@ def cmd_prepare(args, git_root):
192192
version = args.version
193193
release_branch = args.release_branch
194194
gradle_cmd = args.gradle_cmd
195+
rc_suffix = f" RC{args.rc_number}" if args.rc_number > 1 else ""
196+
version_label = f"v{version}{rc_suffix}"
195197

196198
version_dir = git_root / "changelog" / f"v{version}"
197199
unreleased_dir = git_root / "changelog" / "unreleased"
200+
version_summary = f"changelog/v{version}/version-summary.md"
198201

199-
print(f"\n=== Changelog prepare for v{version} on {release_branch} ===")
202+
print(f"\n=== Changelog prepare for {version_label} on {release_branch} ===")
200203
if dry_run:
201204
print(" (dry-run — no changes will be made)\n")
202205
else:
@@ -248,26 +251,41 @@ def cmd_prepare(args, git_root):
248251
shutil.copy2(src, dst)
249252
src.unlink()
250253

251-
print(f"\n[3] Running logchangeGenerate and stripping [unreleased] block")
252-
run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
253-
254254
if do_commit:
255-
print(f"\n[4] Staging changelog/ and CHANGELOG.md")
255+
# Commit A: changelog/ YAML files only (version-summary.md is stale until generate runs)
256+
print(f"\n[3] Staging changelog/ entries for {version_label} (commit A)")
256257
ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
257-
git(["add", "changelog", "CHANGELOG.md"], cwd=git_root, dry_run=dry_run)
258+
git(["add", "changelog"], cwd=git_root, dry_run=dry_run)
259+
# Unstage version-summary.md — it is stale until logchangeGenerate runs
260+
git(["restore", "--staged", version_summary], cwd=git_root, dry_run=dry_run, check=False)
261+
262+
if not dry_run and not has_staged_changes(git_root, ["changelog/"]):
263+
print("\nNothing staged — changelog entries are already up to date.")
264+
else:
265+
msg_a = f"Changelog entries for {version_label}"
266+
print(f"\n[4] Committing: {msg_a!r}")
267+
git(["commit", "-m", msg_a], cwd=git_root, dry_run=dry_run)
258268

259-
if not dry_run and not has_staged_changes(git_root):
260-
print("\nNothing staged — changelog is already up to date.")
261-
return
269+
# Commit B: regenerate, then stage CHANGELOG.md + version-summary.md
270+
print(f"\n[5] Running logchangeGenerate and stripping [unreleased] block")
271+
run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
262272

263-
msg = f"Changelog prepare for v{version}"
264-
print(f"\n[5] Committing: {msg!r}")
265-
git(["commit", "-m", msg], cwd=git_root, dry_run=dry_run)
273+
print(f"\n[6] Staging CHANGELOG.md and version-summary.md (commit B)")
274+
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
275+
276+
if not dry_run and not has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
277+
print(" Nothing staged — CHANGELOG.md already up to date.")
278+
else:
279+
msg_b = f"Regenerate CHANGELOG.md for {version_label}"
280+
print(f"\n[7] Committing: {msg_b!r}")
281+
git(["commit", "-m", msg_b], cwd=git_root, dry_run=dry_run)
266282

267283
if not dry_run:
268-
print(f"\nDone. Commit {short_sha(git_root)} on {release_branch}.")
284+
print(f"\nDone. Last commit {short_sha(git_root)} on {release_branch}.")
269285
print(f"Push with: git push {args.git_remote} {release_branch}")
270286
else:
287+
print(f"\n[3] Running logchangeGenerate and stripping [unreleased] block")
288+
run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
271289
print(f"\nReview the changes above, then run with --commit to stage and commit.")
272290
if not dry_run:
273291
print(f" git diff changelog/ CHANGELOG.md")
@@ -286,6 +304,7 @@ def cmd_forward_port(args, git_root):
286304
version = args.version
287305
release_branch = args.release_branch
288306
stable_branch = args.stable_branch
307+
latest_lts_stable_branch = args.latest_lts_stable_branch
289308
release_date_str = args.release_date or date.today().isoformat()
290309

291310
version_dir = git_root / "changelog" / f"v{version}"
@@ -295,6 +314,8 @@ def cmd_forward_port(args, git_root):
295314
print(f" release_branch : {release_branch}")
296315
print(f" stable_branch : {stable_branch}")
297316
print(f" also targets : main")
317+
if latest_lts_stable_branch:
318+
print(f" lts also targets: {latest_lts_stable_branch}")
298319
if dry_run:
299320
print(" (dry-run — no changes will be made)")
300321
elif not do_push:
@@ -315,83 +336,139 @@ def cmd_forward_port(args, git_root):
315336
if not dry_run:
316337
date_file.write_text(release_date_str + "\n", encoding="utf-8")
317338

318-
# Step 2: regenerate CHANGELOG.md (now with the release date)
319-
print(f"\n[2] Running logchangeGenerate (with release date {release_date_str})")
320-
run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
339+
version_summary = f"changelog/v{version}/version-summary.md"
321340

322-
# Step 3: stage and commit
323-
print(f"\n[3] Staging and committing to {release_branch}")
324-
git(["add", "changelog", "CHANGELOG.md"], cwd=git_root, dry_run=dry_run)
341+
# Step 2: commit A — release-date.txt only (before generate)
342+
print(f"\n[2] Committing release-date.txt to {release_branch} (commit A)")
343+
git(["add", f"changelog/v{version}/release-date.txt"], cwd=git_root, dry_run=dry_run)
325344

326-
if not dry_run and not has_staged_changes(git_root):
327-
print(" Nothing staged — release-date.txt and CHANGELOG.md were already up to date.")
345+
if not dry_run and not has_staged_changes(git_root, [f"changelog/v{version}/release-date.txt"]):
346+
print(" Nothing staged — release-date.txt was already up to date.")
328347
else:
329-
msg = f"Set release date {release_date_str} and regenerate CHANGELOG.md for v{version}"
330-
print(f" Committing: {msg!r}")
331-
git(["commit", "-m", msg], cwd=git_root, dry_run=dry_run)
332-
333-
# Step 4: find commits on release_branch not yet on stable_branch that
334-
# touch changelog/ or CHANGELOG.md. --cherry-pick with the
335-
# symmetric-difference range (three dots) omits commits whose patch
336-
# is already present on the target, making forward-port idempotent
337-
# when re-run after an initial no-push review run.
338-
print(f"\n[4] Finding changelog commits on {release_branch} not yet on {stable_branch}")
339-
result = subprocess.run(
340-
["git", "log", "--oneline", "--reverse",
341-
"--cherry-pick", "--right-only",
342-
f"{stable_branch}...{release_branch}",
343-
"--", "changelog/", "CHANGELOG.md"],
344-
cwd=git_root, capture_output=True, text=True, check=True,
345-
)
346-
commits = [line.split()[0] for line in result.stdout.strip().splitlines() if line]
348+
msg_a = f"Set release date {release_date_str} for v{version}"
349+
print(f" Committing: {msg_a!r}")
350+
git(["commit", "-m", msg_a], cwd=git_root, dry_run=dry_run)
351+
352+
# Step 3: commit B — regenerate, then stage CHANGELOG.md + version-summary.md
353+
print(f"\n[3] Running logchangeGenerate (with release date {release_date_str})")
354+
run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
347355

348-
if not commits:
349-
print(f" No changelog commits to forward-port — {stable_branch} is already up to date.")
356+
print(f"\n[3b] Staging CHANGELOG.md and version-summary.md to {release_branch} (commit B)")
357+
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
358+
359+
if not dry_run and not has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
360+
print(" Nothing staged — CHANGELOG.md already up to date.")
350361
else:
351-
print(f" Found {len(commits)} commit(s) to cherry-pick: {', '.join(commits)}")
352-
353-
for target in [stable_branch, "main"]:
354-
print(f"\n[5] Cherry-picking {len(commits)} commit(s) to {target}")
355-
git(["checkout", target], cwd=git_root, dry_run=dry_run)
356-
for sha in commits:
357-
# Commits that touch changelog/unreleased/ are deletions of files
358-
# that exist under different names on stable/main — use -X ours so
359-
# git keeps the target branch's own unreleased entries rather than
360-
# trying to delete them. Commits that only add to the version
361-
# folder or update CHANGELOG.md are clean additions; cherry-pick
362-
# them plainly so real conflicts are not silently discarded.
363-
if commit_touches_unreleased(sha, git_root):
364-
cp_args = ["cherry-pick", "-X", "ours", "-X", "no-renames", sha]
365-
else:
366-
cp_args = ["cherry-pick", sha]
367-
try:
368-
git(cp_args, cwd=git_root, dry_run=dry_run)
369-
except subprocess.CalledProcessError:
370-
print(f"\nError: cherry-pick of {sha} failed on {target}.",
371-
file=sys.stderr)
372-
print(" Resolve the conflict, then run: git cherry-pick --continue",
373-
file=sys.stderr)
374-
print(" Or abort with: git cherry-pick --abort",
375-
file=sys.stderr)
376-
sys.exit(1)
377-
378-
# Ensure unreleased/ folder exists for contributors after cherry-picks
379-
ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
380-
git(["add", "changelog/unreleased/.gitkeep"], cwd=git_root, dry_run=dry_run)
381-
if not dry_run and has_staged_changes(git_root):
382-
git(["commit", "-m", f"Ensure changelog/unreleased/ folder exists on {target}"],
362+
msg_b = f"Regenerate CHANGELOG.md for v{version}"
363+
print(f" Committing: {msg_b!r}")
364+
git(["commit", "-m", msg_b], cwd=git_root, dry_run=dry_run)
365+
366+
# Steps 4+5: for each target branch, find commits on release_branch not yet on
367+
# that target, cherry-pick them, then regenerate CHANGELOG.md fresh.
368+
# CHANGELOG.md is never cherry-picked — it is always regenerated so
369+
# each branch gets a correct full-history version (avoids cross-major
370+
# conflicts). version-summary.md is also excluded from cherry-picks
371+
# for the same reason; it is regenerated alongside CHANGELOG.md.
372+
# --cherry-pick with the symmetric-difference range (three dots) omits
373+
# commits whose patch is already present on the target, making
374+
# forward-port idempotent when re-run after an initial no-push run.
375+
targets = [stable_branch, "main"]
376+
if latest_lts_stable_branch:
377+
targets.append(latest_lts_stable_branch)
378+
379+
for target in targets:
380+
print(f"\n[4] Finding changelog/ commits on {release_branch} not yet on {target}")
381+
if dry_run:
382+
print(f" (dry-run) would run: git log --cherry-pick --right-only {target}...{release_branch} -- changelog/ :(exclude)changelog/*/version-summary.md")
383+
commits = []
384+
else:
385+
result = subprocess.run(
386+
["git", "log", "--oneline", "--reverse",
387+
"--cherry-pick", "--right-only",
388+
f"{target}...{release_branch}",
389+
"--", "changelog/", ":(exclude)changelog/*/version-summary.md"],
390+
cwd=git_root, capture_output=True, text=True, check=True,
391+
)
392+
commits = [line.split()[0] for line in result.stdout.strip().splitlines() if line]
393+
394+
if not commits:
395+
print(f" No changelog commits to forward-port — {target} is already up to date.")
396+
else:
397+
print(f" Found {len(commits)} commit(s) to cherry-pick: {', '.join(commits)}")
398+
399+
print(f"\n[5] Cherry-picking {len(commits)} commit(s) to {target}")
400+
git(["checkout", target], cwd=git_root, dry_run=dry_run)
401+
for sha in commits:
402+
# Commits that touch changelog/unreleased/ are deletions of files
403+
# that exist under different names on stable/main — use -X ours so
404+
# git keeps the target branch's own unreleased entries rather than
405+
# trying to delete them. Commits that only add to the version
406+
# folder are clean additions and cherry-pick without strategy options.
407+
if commit_touches_unreleased(sha, git_root):
408+
cp_args = ["cherry-pick", "-X", "ours", "-X", "no-renames", sha]
409+
else:
410+
cp_args = ["cherry-pick", sha]
411+
try:
412+
git(cp_args, cwd=git_root, dry_run=dry_run)
413+
except subprocess.CalledProcessError:
414+
print(f"\nError: cherry-pick of {sha} failed on {target}.",
415+
file=sys.stderr)
416+
print(" Resolve the conflict, then run: git cherry-pick --continue",
417+
file=sys.stderr)
418+
print(" Or abort with: git cherry-pick --abort",
419+
file=sys.stderr)
420+
sys.exit(1)
421+
422+
# Ensure unreleased/ folder exists for contributors after cherry-picks
423+
ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
424+
git(["add", "changelog/unreleased/.gitkeep"], cwd=git_root, dry_run=dry_run)
425+
if not dry_run and has_staged_changes(git_root, ["changelog/unreleased/.gitkeep"]):
426+
git(["commit", "-m", f"Ensure changelog/unreleased/ folder exists on {target}"],
427+
cwd=git_root, dry_run=dry_run)
428+
429+
# Remove any v{version} YAML files that still linger in unreleased/ on this
430+
# target branch. Cherry-pick uses -X ours, so a "we modified / they deleted"
431+
# conflict silently keeps the local copy; this explicit pass fixes that.
432+
version_dir = git_root / f"changelog/v{version}"
433+
stale = (
434+
[
435+
git_root / "changelog/unreleased" / f.name
436+
for f in version_dir.glob("*.yml")
437+
if (git_root / "changelog/unreleased" / f.name).exists()
438+
]
439+
if not dry_run
440+
else []
441+
)
442+
if stale:
443+
rel = [str(p.relative_to(git_root)) for p in stale]
444+
print(f"\n Removing {len(stale)} stale unreleased file(s) from {target}: "
445+
f"{', '.join(p.name for p in stale)}")
446+
git(["rm", "--force"] + rel, cwd=git_root, dry_run=dry_run)
447+
if not dry_run and has_staged_changes(git_root, ["changelog/unreleased/"]):
448+
git(["commit", "-m", f"Remove v{version} entries from unreleased/ on {target}"],
383449
cwd=git_root, dry_run=dry_run)
384450

451+
# Regenerate CHANGELOG.md and version-summary.md fresh on this branch.
452+
print(f"\n Regenerating CHANGELOG.md on {target}")
453+
run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
454+
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
455+
if not dry_run and has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
456+
git(["commit", "-m", f"Regenerate CHANGELOG.md with v{version} entries on {target}"],
457+
cwd=git_root, dry_run=dry_run)
458+
385459
# Step 6: push (optional)
386460
if do_push:
387461
remote = args.git_remote
388-
print(f"\n[6] Pushing {release_branch}, {stable_branch}, main to {remote}")
389-
for branch in [release_branch, stable_branch, "main"]:
462+
branches_to_push = [release_branch, stable_branch, "main"]
463+
if latest_lts_stable_branch:
464+
branches_to_push.append(latest_lts_stable_branch)
465+
print(f"\n[6] Pushing {', '.join(branches_to_push)} to {remote}")
466+
for branch in branches_to_push:
390467
git_push(branch, remote, cwd=git_root, dry_run=dry_run)
391468
else:
392469
print(f"\nCherry-picks done. Review, then re-run with --push to push all branches.")
393470
if not dry_run:
394-
print(f" git log --oneline {stable_branch} -- changelog/ CHANGELOG.md")
471+
print(f" git log --oneline {stable_branch} -- changelog/")
395472

396473
if dry_run:
397474
print("\nDry-run complete — no changes made.")
@@ -434,6 +511,8 @@ def build_parser():
434511
formatter_class=fmt)
435512
p.add_argument("--commit", action="store_true",
436513
help="Stage and commit the result (default: leave uncommitted for review)")
514+
p.add_argument("--rc-number", type=int, default=1,
515+
help="RC number (default: 1). RC2+ appends ' RC<n>' to commit messages.")
437516
p.add_argument("--skip-validation", action="store_true",
438517
help="Skip pre-flight YAML validation of changelog/unreleased/ files")
439518
p.set_defaults(func=cmd_prepare)
@@ -448,6 +527,8 @@ def build_parser():
448527
formatter_class=fmt)
449528
fp.add_argument("--stable-branch", required=True,
450529
help="Stable branch, e.g. branch_10x")
530+
fp.add_argument("--latest-lts-stable-branch",
531+
help="Also forward-port to this LTS stable branch, e.g. branch_9x (LTS releases only)")
451532
fp.add_argument("--release-date",
452533
help="Release date YYYY-MM-DD (default: today)")
453534
fp.add_argument("--push", action="store_true",

dev-tools/scripts/releaseWizard.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ def expand_jinja(text, vars=None):
149149
'latest_lts_version_major': state.latest_lts_version_major,
150150
'latest_lts_version_minor': state.latest_lts_version_minor,
151151
'latest_lts_version_bugfix': state.latest_lts_version_bugfix,
152+
'latest_lts_stable_branch': state.get_lts_stable_branch_name(),
153+
'is_lts_release': state.is_lts_release(),
152154
'main_version': state.get_main_version(),
153155
'mirrored_versions': state.get_mirrored_versions(),
154156
'mirrored_versions_to_delete': state.get_mirrored_versions_to_delete(),
@@ -643,6 +645,12 @@ def get_stable_branch_name(self):
643645
v = Version.parse(self.latest_version)
644646
return "branch_%sx" % v.major
645647

648+
def get_lts_stable_branch_name(self):
649+
return "branch_%sx" % self.latest_lts_version_major
650+
651+
def is_lts_release(self):
652+
return self.release_version_major == self.latest_lts_version_major
653+
646654
def get_next_version(self):
647655
if self.release_type == 'major':
648656
return "%s.0.0" % (self.release_version_major + 1)

0 commit comments

Comments
 (0)