This is an automated email from the ASF dual-hosted git repository.
janhoy pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/main by this push:
new 7a0a00476d5 Improve releaseWizard's logchange.py to handle LTS release
(#4425)
7a0a00476d5 is described below
commit 7a0a00476d5dcbf001987981145f928fcba08b7e
Author: Jan Høydahl <[email protected]>
AuthorDate: Thu May 14 00:17:51 2026 +0200
Improve releaseWizard's logchange.py to handle LTS release (#4425)
---
dev-tools/scripts/logchange.py | 245 +++++++++++++++++++++++------------
dev-tools/scripts/releaseWizard.py | 8 ++
dev-tools/scripts/releaseWizard.yaml | 22 ++--
3 files changed, 184 insertions(+), 91 deletions(-)
diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py
index 921488da9b9..2bbdc95e497 100755
--- a/dev-tools/scripts/logchange.py
+++ b/dev-tools/scripts/logchange.py
@@ -69,12 +69,12 @@ def short_sha(git_root):
return r.stdout.strip()
-def has_staged_changes(git_root):
- """Return True if there are staged changes in the index."""
- r = subprocess.run(
- ["git", "diff", "--cached", "--name-only"],
- cwd=git_root, capture_output=True, text=True,
- )
+def has_staged_changes(git_root, paths=None):
+ """Return True if there are staged changes in the index, optionally scoped
to paths."""
+ cmd = ["git", "diff", "--cached", "--name-only"]
+ if paths:
+ cmd += ["--"] + list(paths)
+ r = subprocess.run(cmd, cwd=git_root, capture_output=True, text=True)
return bool(r.stdout.strip())
@@ -192,11 +192,14 @@ def cmd_prepare(args, git_root):
version = args.version
release_branch = args.release_branch
gradle_cmd = args.gradle_cmd
+ rc_suffix = f" RC{args.rc_number}" if args.rc_number > 1 else ""
+ version_label = f"v{version}{rc_suffix}"
version_dir = git_root / "changelog" / f"v{version}"
unreleased_dir = git_root / "changelog" / "unreleased"
+ version_summary = f"changelog/v{version}/version-summary.md"
- print(f"\n=== Changelog prepare for v{version} on {release_branch} ===")
+ print(f"\n=== Changelog prepare for {version_label} on {release_branch}
===")
if dry_run:
print(" (dry-run — no changes will be made)\n")
else:
@@ -248,26 +251,41 @@ def cmd_prepare(args, git_root):
shutil.copy2(src, dst)
src.unlink()
- print(f"\n[3] Running logchangeGenerate and stripping [unreleased] block")
- run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
-
if do_commit:
- print(f"\n[4] Staging changelog/ and CHANGELOG.md")
+ # Commit A: changelog/ YAML files only (version-summary.md is stale
until generate runs)
+ print(f"\n[3] Staging changelog/ entries for {version_label} (commit
A)")
ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
- git(["add", "changelog", "CHANGELOG.md"], cwd=git_root,
dry_run=dry_run)
+ git(["add", "changelog"], cwd=git_root, dry_run=dry_run)
+ # Unstage version-summary.md — it is stale until logchangeGenerate runs
+ git(["restore", "--staged", version_summary], cwd=git_root,
dry_run=dry_run, check=False)
+
+ if not dry_run and not has_staged_changes(git_root, ["changelog/"]):
+ print("\nNothing staged — changelog entries are already up to
date.")
+ else:
+ msg_a = f"Changelog entries for {version_label}"
+ print(f"\n[4] Committing: {msg_a!r}")
+ git(["commit", "-m", msg_a], cwd=git_root, dry_run=dry_run)
- if not dry_run and not has_staged_changes(git_root):
- print("\nNothing staged — changelog is already up to date.")
- return
+ # Commit B: regenerate, then stage CHANGELOG.md + version-summary.md
+ print(f"\n[5] Running logchangeGenerate and stripping [unreleased]
block")
+ run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
- msg = f"Changelog prepare for v{version}"
- print(f"\n[5] Committing: {msg!r}")
- git(["commit", "-m", msg], cwd=git_root, dry_run=dry_run)
+ print(f"\n[6] Staging CHANGELOG.md and version-summary.md (commit B)")
+ git(["add", "CHANGELOG.md", version_summary], cwd=git_root,
dry_run=dry_run)
+
+ if not dry_run and not has_staged_changes(git_root, ["CHANGELOG.md",
version_summary]):
+ print(" Nothing staged — CHANGELOG.md already up to date.")
+ else:
+ msg_b = f"Regenerate CHANGELOG.md for {version_label}"
+ print(f"\n[7] Committing: {msg_b!r}")
+ git(["commit", "-m", msg_b], cwd=git_root, dry_run=dry_run)
if not dry_run:
- print(f"\nDone. Commit {short_sha(git_root)} on {release_branch}.")
+ print(f"\nDone. Last commit {short_sha(git_root)} on
{release_branch}.")
print(f"Push with: git push {args.git_remote} {release_branch}")
else:
+ print(f"\n[3] Running logchangeGenerate and stripping [unreleased]
block")
+ run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
print(f"\nReview the changes above, then run with --commit to stage
and commit.")
if not dry_run:
print(f" git diff changelog/ CHANGELOG.md")
@@ -286,6 +304,7 @@ def cmd_forward_port(args, git_root):
version = args.version
release_branch = args.release_branch
stable_branch = args.stable_branch
+ latest_lts_stable_branch = args.latest_lts_stable_branch
release_date_str = args.release_date or date.today().isoformat()
version_dir = git_root / "changelog" / f"v{version}"
@@ -295,6 +314,8 @@ def cmd_forward_port(args, git_root):
print(f" release_branch : {release_branch}")
print(f" stable_branch : {stable_branch}")
print(f" also targets : main")
+ if latest_lts_stable_branch:
+ print(f" lts also targets: {latest_lts_stable_branch}")
if dry_run:
print(" (dry-run — no changes will be made)")
elif not do_push:
@@ -315,83 +336,139 @@ def cmd_forward_port(args, git_root):
if not dry_run:
date_file.write_text(release_date_str + "\n", encoding="utf-8")
- # Step 2: regenerate CHANGELOG.md (now with the release date)
- print(f"\n[2] Running logchangeGenerate (with release date
{release_date_str})")
- run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
+ version_summary = f"changelog/v{version}/version-summary.md"
- # Step 3: stage and commit
- print(f"\n[3] Staging and committing to {release_branch}")
- git(["add", "changelog", "CHANGELOG.md"], cwd=git_root, dry_run=dry_run)
+ # Step 2: commit A — release-date.txt only (before generate)
+ print(f"\n[2] Committing release-date.txt to {release_branch} (commit A)")
+ git(["add", f"changelog/v{version}/release-date.txt"], cwd=git_root,
dry_run=dry_run)
- if not dry_run and not has_staged_changes(git_root):
- print(" Nothing staged — release-date.txt and CHANGELOG.md were
already up to date.")
+ if not dry_run and not has_staged_changes(git_root,
[f"changelog/v{version}/release-date.txt"]):
+ print(" Nothing staged — release-date.txt was already up to date.")
else:
- msg = f"Set release date {release_date_str} and regenerate
CHANGELOG.md for v{version}"
- print(f" Committing: {msg!r}")
- git(["commit", "-m", msg], cwd=git_root, dry_run=dry_run)
-
- # Step 4: find commits on release_branch not yet on stable_branch that
- # touch changelog/ or CHANGELOG.md. --cherry-pick with the
- # symmetric-difference range (three dots) omits commits whose patch
- # is already present on the target, making forward-port idempotent
- # when re-run after an initial no-push review run.
- print(f"\n[4] Finding changelog commits on {release_branch} not yet on
{stable_branch}")
- result = subprocess.run(
- ["git", "log", "--oneline", "--reverse",
- "--cherry-pick", "--right-only",
- f"{stable_branch}...{release_branch}",
- "--", "changelog/", "CHANGELOG.md"],
- cwd=git_root, capture_output=True, text=True, check=True,
- )
- commits = [line.split()[0] for line in result.stdout.strip().splitlines()
if line]
+ msg_a = f"Set release date {release_date_str} for v{version}"
+ print(f" Committing: {msg_a!r}")
+ git(["commit", "-m", msg_a], cwd=git_root, dry_run=dry_run)
+
+ # Step 3: commit B — regenerate, then stage CHANGELOG.md +
version-summary.md
+ print(f"\n[3] Running logchangeGenerate (with release date
{release_date_str})")
+ run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
- if not commits:
- print(f" No changelog commits to forward-port — {stable_branch} is
already up to date.")
+ print(f"\n[3b] Staging CHANGELOG.md and version-summary.md to
{release_branch} (commit B)")
+ git(["add", "CHANGELOG.md", version_summary], cwd=git_root,
dry_run=dry_run)
+
+ if not dry_run and not has_staged_changes(git_root, ["CHANGELOG.md",
version_summary]):
+ print(" Nothing staged — CHANGELOG.md already up to date.")
else:
- print(f" Found {len(commits)} commit(s) to cherry-pick: {',
'.join(commits)}")
-
- for target in [stable_branch, "main"]:
- print(f"\n[5] Cherry-picking {len(commits)} commit(s) to {target}")
- git(["checkout", target], cwd=git_root, dry_run=dry_run)
- for sha in commits:
- # Commits that touch changelog/unreleased/ are deletions of
files
- # that exist under different names on stable/main — use -X
ours so
- # git keeps the target branch's own unreleased entries rather
than
- # trying to delete them. Commits that only add to the version
- # folder or update CHANGELOG.md are clean additions;
cherry-pick
- # them plainly so real conflicts are not silently discarded.
- if commit_touches_unreleased(sha, git_root):
- cp_args = ["cherry-pick", "-X", "ours", "-X",
"no-renames", sha]
- else:
- cp_args = ["cherry-pick", sha]
- try:
- git(cp_args, cwd=git_root, dry_run=dry_run)
- except subprocess.CalledProcessError:
- print(f"\nError: cherry-pick of {sha} failed on {target}.",
- file=sys.stderr)
- print(" Resolve the conflict, then run: git cherry-pick
--continue",
- file=sys.stderr)
- print(" Or abort with: git cherry-pick
--abort",
- file=sys.stderr)
- sys.exit(1)
-
- # Ensure unreleased/ folder exists for contributors after
cherry-picks
- ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
- git(["add", "changelog/unreleased/.gitkeep"], cwd=git_root,
dry_run=dry_run)
- if not dry_run and has_staged_changes(git_root):
- git(["commit", "-m", f"Ensure changelog/unreleased/ folder
exists on {target}"],
+ msg_b = f"Regenerate CHANGELOG.md for v{version}"
+ print(f" Committing: {msg_b!r}")
+ git(["commit", "-m", msg_b], cwd=git_root, dry_run=dry_run)
+
+ # Steps 4+5: for each target branch, find commits on release_branch not
yet on
+ # that target, cherry-pick them, then regenerate CHANGELOG.md
fresh.
+ # CHANGELOG.md is never cherry-picked — it is always
regenerated so
+ # each branch gets a correct full-history version (avoids
cross-major
+ # conflicts). version-summary.md is also excluded from
cherry-picks
+ # for the same reason; it is regenerated alongside CHANGELOG.md.
+ # --cherry-pick with the symmetric-difference range (three
dots) omits
+ # commits whose patch is already present on the target, making
+ # forward-port idempotent when re-run after an initial no-push
run.
+ targets = [stable_branch, "main"]
+ if latest_lts_stable_branch:
+ targets.append(latest_lts_stable_branch)
+
+ for target in targets:
+ print(f"\n[4] Finding changelog/ commits on {release_branch} not yet
on {target}")
+ if dry_run:
+ print(f" (dry-run) would run: git log --cherry-pick --right-only
{target}...{release_branch} -- changelog/
:(exclude)changelog/*/version-summary.md")
+ commits = []
+ else:
+ result = subprocess.run(
+ ["git", "log", "--oneline", "--reverse",
+ "--cherry-pick", "--right-only",
+ f"{target}...{release_branch}",
+ "--", "changelog/",
":(exclude)changelog/*/version-summary.md"],
+ cwd=git_root, capture_output=True, text=True, check=True,
+ )
+ commits = [line.split()[0] for line in
result.stdout.strip().splitlines() if line]
+
+ if not commits:
+ print(f" No changelog commits to forward-port — {target} is
already up to date.")
+ else:
+ print(f" Found {len(commits)} commit(s) to cherry-pick: {',
'.join(commits)}")
+
+ print(f"\n[5] Cherry-picking {len(commits)} commit(s) to {target}")
+ git(["checkout", target], cwd=git_root, dry_run=dry_run)
+ for sha in commits:
+ # Commits that touch changelog/unreleased/ are deletions of files
+ # that exist under different names on stable/main — use -X ours so
+ # git keeps the target branch's own unreleased entries rather than
+ # trying to delete them. Commits that only add to the version
+ # folder are clean additions and cherry-pick without strategy
options.
+ if commit_touches_unreleased(sha, git_root):
+ cp_args = ["cherry-pick", "-X", "ours", "-X", "no-renames",
sha]
+ else:
+ cp_args = ["cherry-pick", sha]
+ try:
+ git(cp_args, cwd=git_root, dry_run=dry_run)
+ except subprocess.CalledProcessError:
+ print(f"\nError: cherry-pick of {sha} failed on {target}.",
+ file=sys.stderr)
+ print(" Resolve the conflict, then run: git cherry-pick
--continue",
+ file=sys.stderr)
+ print(" Or abort with: git cherry-pick
--abort",
+ file=sys.stderr)
+ sys.exit(1)
+
+ # Ensure unreleased/ folder exists for contributors after cherry-picks
+ ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
+ git(["add", "changelog/unreleased/.gitkeep"], cwd=git_root,
dry_run=dry_run)
+ if not dry_run and has_staged_changes(git_root,
["changelog/unreleased/.gitkeep"]):
+ git(["commit", "-m", f"Ensure changelog/unreleased/ folder exists
on {target}"],
+ cwd=git_root, dry_run=dry_run)
+
+ # Remove any v{version} YAML files that still linger in unreleased/ on
this
+ # target branch. Cherry-pick uses -X ours, so a "we modified / they
deleted"
+ # conflict silently keeps the local copy; this explicit pass fixes
that.
+ version_dir = git_root / f"changelog/v{version}"
+ stale = (
+ [
+ git_root / "changelog/unreleased" / f.name
+ for f in version_dir.glob("*.yml")
+ if (git_root / "changelog/unreleased" / f.name).exists()
+ ]
+ if not dry_run
+ else []
+ )
+ if stale:
+ rel = [str(p.relative_to(git_root)) for p in stale]
+ print(f"\n Removing {len(stale)} stale unreleased file(s) from
{target}: "
+ f"{', '.join(p.name for p in stale)}")
+ git(["rm", "--force"] + rel, cwd=git_root, dry_run=dry_run)
+ if not dry_run and has_staged_changes(git_root,
["changelog/unreleased/"]):
+ git(["commit", "-m", f"Remove v{version} entries from
unreleased/ on {target}"],
cwd=git_root, dry_run=dry_run)
+ # Regenerate CHANGELOG.md and version-summary.md fresh on this branch.
+ print(f"\n Regenerating CHANGELOG.md on {target}")
+ run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
+ git(["add", "CHANGELOG.md", version_summary], cwd=git_root,
dry_run=dry_run)
+ if not dry_run and has_staged_changes(git_root, ["CHANGELOG.md",
version_summary]):
+ git(["commit", "-m", f"Regenerate CHANGELOG.md with v{version}
entries on {target}"],
+ cwd=git_root, dry_run=dry_run)
+
# Step 6: push (optional)
if do_push:
remote = args.git_remote
- print(f"\n[6] Pushing {release_branch}, {stable_branch}, main to
{remote}")
- for branch in [release_branch, stable_branch, "main"]:
+ branches_to_push = [release_branch, stable_branch, "main"]
+ if latest_lts_stable_branch:
+ branches_to_push.append(latest_lts_stable_branch)
+ print(f"\n[6] Pushing {', '.join(branches_to_push)} to {remote}")
+ for branch in branches_to_push:
git_push(branch, remote, cwd=git_root, dry_run=dry_run)
else:
print(f"\nCherry-picks done. Review, then re-run with --push to push
all branches.")
if not dry_run:
- print(f" git log --oneline {stable_branch} -- changelog/
CHANGELOG.md")
+ print(f" git log --oneline {stable_branch} -- changelog/")
if dry_run:
print("\nDry-run complete — no changes made.")
@@ -434,6 +511,8 @@ def build_parser():
formatter_class=fmt)
p.add_argument("--commit", action="store_true",
help="Stage and commit the result (default: leave
uncommitted for review)")
+ p.add_argument("--rc-number", type=int, default=1,
+ help="RC number (default: 1). RC2+ appends ' RC<n>' to
commit messages.")
p.add_argument("--skip-validation", action="store_true",
help="Skip pre-flight YAML validation of
changelog/unreleased/ files")
p.set_defaults(func=cmd_prepare)
@@ -448,6 +527,8 @@ def build_parser():
formatter_class=fmt)
fp.add_argument("--stable-branch", required=True,
help="Stable branch, e.g. branch_10x")
+ fp.add_argument("--latest-lts-stable-branch",
+ help="Also forward-port to this LTS stable branch, e.g.
branch_9x (LTS releases only)")
fp.add_argument("--release-date",
help="Release date YYYY-MM-DD (default: today)")
fp.add_argument("--push", action="store_true",
diff --git a/dev-tools/scripts/releaseWizard.py
b/dev-tools/scripts/releaseWizard.py
index 11467bbc714..6a4dd91c549 100755
--- a/dev-tools/scripts/releaseWizard.py
+++ b/dev-tools/scripts/releaseWizard.py
@@ -149,6 +149,8 @@ def expand_jinja(text, vars=None):
'latest_lts_version_major': state.latest_lts_version_major,
'latest_lts_version_minor': state.latest_lts_version_minor,
'latest_lts_version_bugfix': state.latest_lts_version_bugfix,
+ 'latest_lts_stable_branch': state.get_lts_stable_branch_name(),
+ 'is_lts_release': state.is_lts_release(),
'main_version': state.get_main_version(),
'mirrored_versions': state.get_mirrored_versions(),
'mirrored_versions_to_delete': state.get_mirrored_versions_to_delete(),
@@ -643,6 +645,12 @@ class ReleaseState:
v = Version.parse(self.latest_version)
return "branch_%sx" % v.major
+ def get_lts_stable_branch_name(self):
+ return "branch_%sx" % self.latest_lts_version_major
+
+ def is_lts_release(self):
+ return self.release_version_major == self.latest_lts_version_major
+
def get_next_version(self):
if self.release_type == 'major':
return "%s.0.0" % (self.release_version_major + 1)
diff --git a/dev-tools/scripts/releaseWizard.yaml
b/dev-tools/scripts/releaseWizard.yaml
index 6f8b002185b..a85e558775f 100644
--- a/dev-tools/scripts/releaseWizard.yaml
+++ b/dev-tools/scripts/releaseWizard.yaml
@@ -771,8 +771,8 @@ groups:
confirm_each_command: false
commands:
- !Command
- cmd: python3 -u dev-tools/scripts/logchange.py prepare --version {{
release_version }} --release-branch {{ release_branch }} --gradle-cmd {{
gradle_cmd }} --commit
- comment: Move unreleased entries to version folder, regenerate
CHANGELOG.md, and commit
+ cmd: python3 -u dev-tools/scripts/logchange.py prepare --version {{
release_version }} --release-branch {{ release_branch }} --gradle-cmd {{
gradle_cmd }} --rc-number {{ rc_number }} --commit
+ comment: Move unreleased entries to version folder and commit; then
regenerate CHANGELOG.md and commit (two commits total)
logfile: changelog-update-rc.log
tee: true
- !Command
@@ -1238,17 +1238,21 @@ groups:
- !Todo
id: forward_port_changelog
title: Forward-port changelog to stable and main branches
+ vars:
+ lts_branch_arg: "{% if is_lts_release %}--latest-lts-stable-branch {{
latest_lts_stable_branch }}{% endif %}"
description: |
Now that the vote has passed, this step:
1. Writes release date {{ release_date_iso }} to `changelog/v{{
release_version }}/release-date.txt`
+ and commits that to `{{ release_branch }}` (commit A)
2. Regenerates `CHANGELOG.md` — now with the correct release date in the
- version heading (e.g. `## [{{ release_version }}] - {{
release_date_iso }}`)
- 3. Commits that to `{{ release_branch }}`
- 4. Cherry-picks **all** changelog-touching commits that are on
- `{{ release_branch }}` but not yet on `{{ stable_branch }}` to both
- `{{ stable_branch }}` and `main`
- 5. Pushes `{{ release_branch }}`, `{{ stable_branch }}`, and `main`
+ version heading (e.g. `## [{{ release_version }}] - {{
release_date_iso }}`) —
+ and commits `CHANGELOG.md` + `version-summary.md` to `{{
release_branch }}` (commit B)
+ 3. Cherry-picks **all** changelog-touching commits that are on
+ `{{ release_branch }}` but not yet on `{{ stable_branch }}` to
+ `{{ stable_branch }}`, `main`{% if is_lts_release %}, and `{{
latest_lts_stable_branch }}`{% endif %};
+ regenerates `CHANGELOG.md` fresh on each target branch
+ 4. Pushes `{{ release_branch }}`, `{{ stable_branch }}`, `main`{% if
is_lts_release %}, and `{{ latest_lts_stable_branch }}`{% endif %}
Can be run standalone, see `dev-tools/scripts/logchange.py --help` for
details:
commands: !Commands
@@ -1257,7 +1261,7 @@ groups:
confirm_each_command: false
commands:
- !Command
- cmd: python3 -u dev-tools/scripts/logchange.py forward-port
--version {{ release_version }} --release-date {{ release_date_iso }}
--release-branch {{ release_branch }} --stable-branch {{ stable_branch }}
--gradle-cmd {{ gradle_cmd }} --push
+ cmd: python3 -u dev-tools/scripts/logchange.py forward-port
--version {{ release_version }} --release-date {{ release_date_iso }}
--release-branch {{ release_branch }} --stable-branch {{ stable_branch }}
--gradle-cmd {{ gradle_cmd }} {{ lts_branch_arg }} --push
comment: Set release date, regenerate CHANGELOG.md, cherry-pick and
push to all branches
logfile: forward-port-changelog.log
tee: true