This is an automated email from the ASF dual-hosted git repository.
dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/branch_10x by this push:
new fe3d7cf9f40 releaseWizard: fix changelog forward-port for 9.x -> 10.x
situation (#5021)
fe3d7cf9f40 is described below
commit fe3d7cf9f4075263dce14b393a1c0e84cf78189f
Author: David Smiley <[email protected]>
AuthorDate: Thu Oct 8 19:50:35 2026 -0400
releaseWizard: fix changelog forward-port for 9.x -> 10.x situation (#5021)
releaseWizard: fix changelog forward-port for a release on an older major
branch
logchange.py forward-port failed releasing 9.11.0: it tried to cherry-pick
9.x feature commits onto branch_10x and main.
* Only cherry-pick commits touching changelog/v{version}/. Selecting
everything under changelog/ picked up every feature commit with a changelog
entry since the release branch diverged from the target.
* Drop --cherry-pick from commit selection. Git computed patch-ids for the
whole symmetric difference before path limiting, taking minutes per target
across majors. Re-runs stay idempotent: an already-applied commit cherry-picks
to an empty change, which is skipped.
* Require a clean working tree, since the command switches branches and
discards generated files.
* Fast-forward the release branch and each target branch from the remote
before committing, so the final push isn't rejected.
* Resolve modify/delete conflicts confined to changelog/unreleased/ by
removing the entry, but only when it has a counterpart in
changelog/v{version}/; -X ours doesn't settle those. Other conflicts are left
for the release manager.
* Discard other versions' regenerated version-summary.md files (tracked or
untracked), which otherwise block the next checkout.
* Read git path listings NUL-separated, so changelog entries with non-ASCII
names aren't quoted.
* Note that git 2.23+ is required (git restore), and reword the
releaseWizard step accordingly.
Co-authored-by: Jan Høydahl <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 4b58db1a42b5b0ebc4b2fcebfd1f2aef68ce69dc)
---
dev-tools/scripts/logchange.py | 131 +++++++++++++++++++++++++++++------
dev-tools/scripts/releaseWizard.yaml | 10 +--
2 files changed, 116 insertions(+), 25 deletions(-)
diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py
index 2bbdc95e497..646f04306a8 100755
--- a/dev-tools/scripts/logchange.py
+++ b/dev-tools/scripts/logchange.py
@@ -19,6 +19,8 @@
Changelog release helper for Apache Solr release managers.
Normally invoked by the Release Wizard; see dev-docs/changelog.adoc for
details.
+
+Requires git 2.23 or later (git restore).
"""
import argparse
@@ -78,13 +80,83 @@ def has_staged_changes(git_root, paths=None):
return bool(r.stdout.strip())
+def git_paths(args, git_root):
+ """Run a git command that lists paths, NUL-separated so non-ASCII names
are not quoted."""
+ r = subprocess.run(
+ ["git"] + args + ["-z"],
+ cwd=git_root, capture_output=True, text=True, check=True,
+ )
+ return [p for p in r.stdout.split("\0") if p]
+
+
def commit_touches_unreleased(sha, git_root):
"""Return True if the given commit modifies any file under
changelog/unreleased/."""
+ files = git_paths(["diff-tree", "--no-commit-id", "-r", "--name-only",
sha], git_root)
+ return any(f.startswith("changelog/unreleased/") for f in files)
+
+
+def unmerged_paths(git_root):
+ return git_paths(["diff", "--name-only", "--diff-filter=U"], git_root)
+
+
+def recover_cherry_pick(git_root, version, dry_run=False):
+ """Try to finish a cherry-pick that stopped. Return True if it was
resolved.
+
+ Handles two cases: conflicts confined to changelog/unreleased/ entries that
+ are being released (a counterpart exists in changelog/v{version}/, so the
+ unreleased entry is removed), and a pick that is empty because its changes
+ are already on this branch. Any other conflict is left for the user.
+ """
+ if dry_run:
+ return False
+ in_progress = subprocess.run(
+ ["git", "rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"],
+ cwd=git_root, capture_output=True,
+ ).returncode == 0
+ if not in_progress:
+ return False
+ conflicts = unmerged_paths(git_root)
+ version_dir = git_root / "changelog" / f"v{version}"
+ for p in conflicts:
+ if not p.startswith("changelog/unreleased/"):
+ return False
+ if not (version_dir / Path(p).name).exists():
+ print(f" {p} conflicts but has no counterpart in
{version_dir.relative_to(git_root)}/ — "
+ f"not removing it.")
+ return False
+ if conflicts:
+ print(f" Removing {len(conflicts)} conflicting unreleased file(s): "
+ f"{', '.join(Path(p).name for p in conflicts)}")
+ git(["rm", "--quiet", "--"] + conflicts, cwd=git_root)
+ if has_staged_changes(git_root):
+ git(["-c", "core.editor=true", "cherry-pick", "--continue"],
cwd=git_root)
+ else:
+ print(" Nothing left to apply — skipping.")
+ git(["cherry-pick", "--skip"], cwd=git_root)
+ return True
+
+
+def require_clean_tree(git_root):
+ """Exit unless the working tree has no uncommitted changes to tracked
files."""
r = subprocess.run(
- ["git", "diff-tree", "--no-commit-id", "-r", "--name-only", sha],
+ ["git", "status", "--porcelain", "--untracked-files=no"],
cwd=git_root, capture_output=True, text=True, check=True,
)
- return any(f.startswith("changelog/unreleased/") for f in
r.stdout.splitlines())
+ if r.stdout.strip():
+ print("Error: the working tree has uncommitted changes. Commit or
stash them first:",
+ file=sys.stderr)
+ print(r.stdout, file=sys.stderr, end="")
+ sys.exit(1)
+
+
+def discard_generated_changelog_files(git_root, dry_run=False):
+ """Drop what logchangeGenerate left under changelog/ once the wanted files
are committed.
+
+ Generation rewrites every version's version-summary.md; a modified or
+ untracked copy for another version would block the next checkout.
+ """
+ git(["restore", "changelog/"], cwd=git_root, dry_run=dry_run)
+ git(["clean", "--force", "--", "changelog/*/version-summary.md"],
cwd=git_root, dry_run=dry_run)
def strip_unreleased_block(changelog_path: Path, dry_run=False):
@@ -322,9 +394,14 @@ def cmd_forward_port(args, git_root):
print(" (push disabled — run with --push when ready)")
print()
- # Step 1: checkout release branch and write release-date.txt
- print(f"[1] Checking out {release_branch} and writing release-date.txt")
+ # The script checks out several branches and discards generated files with
+ # git restore, so refuse to run on top of uncommitted work.
+ require_clean_tree(git_root)
+
+ # Step 1: checkout and update release branch, write release-date.txt
+ print(f"[1] Checking out and updating {release_branch}, writing
release-date.txt")
git(["checkout", release_branch], cwd=git_root, dry_run=dry_run)
+ git(["pull", "--ff-only", args.git_remote, release_branch], cwd=git_root,
dry_run=dry_run)
if not dry_run and not version_dir.exists():
print(f"Error: version folder {version_dir} does not exist. "
@@ -362,31 +439,41 @@ def cmd_forward_port(args, git_root):
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)
+ discard_generated_changelog_files(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.
+ # Only commits touching the version folder are considered: the
+ # release branch may have diverged from the target long ago
(e.g.
+ # a 9.x release vs main), so other changelog/ commits are
feature
+ # commits that don't belong on the target.
# 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.
+ # --cherry-pick is deliberately not used: git would compute
patch-ids
+ # for the whole symmetric difference (thousands of commits
across
+ # majors) before path limiting, taking minutes per target. A
commit
+ # already applied to the target instead cherry-picks to an empty
+ # change, which recover_cherry_pick skips, so re-running after
an
+ # initial no-push run remains idempotent.
targets = [stable_branch, "main"]
if latest_lts_stable_branch:
targets.append(latest_lts_stable_branch)
+ version_pathspec = f"changelog/v{version}/"
for target in targets:
- print(f"\n[4] Finding changelog/ commits on {release_branch} not yet
on {target}")
+ print(f"\n[4] Updating {target} and finding {version_pathspec} commits
on {release_branch} not yet on it")
+ git(["checkout", target], cwd=git_root, dry_run=dry_run)
+ git(["pull", "--ff-only", args.git_remote, target], cwd=git_root,
dry_run=dry_run)
if dry_run:
- print(f" (dry-run) would run: git log --cherry-pick --right-only
{target}...{release_branch} -- changelog/
:(exclude)changelog/*/version-summary.md")
+ print(f" (dry-run) would run: git log --right-only
{target}...{release_branch} -- {version_pathspec}
:(exclude)changelog/*/version-summary.md")
commits = []
else:
result = subprocess.run(
- ["git", "log", "--oneline", "--reverse",
- "--cherry-pick", "--right-only",
+ ["git", "log", "--oneline", "--reverse", "--right-only",
f"{target}...{release_branch}",
- "--", "changelog/",
":(exclude)changelog/*/version-summary.md"],
+ "--", version_pathspec,
":(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]
@@ -397,13 +484,12 @@ def cmd_forward_port(args, git_root):
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.
+ # Commits that touch changelog/unreleased/ move entries into the
+ # version folder. -X no-renames makes those plain adds + deletes;
+ # -X ours settles content conflicts in favour of the target. A
+ # deletion of an entry that differs on the target still conflicts
+ # (modify/delete), which recover_cherry_pick resolves.
if commit_touches_unreleased(sha, git_root):
cp_args = ["cherry-pick", "-X", "ours", "-X", "no-renames",
sha]
else:
@@ -411,6 +497,8 @@ def cmd_forward_port(args, git_root):
try:
git(cp_args, cwd=git_root, dry_run=dry_run)
except subprocess.CalledProcessError:
+ if recover_cherry_pick(git_root, version, dry_run=dry_run):
+ continue
print(f"\nError: cherry-pick of {sha} failed on {target}.",
file=sys.stderr)
print(" Resolve the conflict, then run: git cherry-pick
--continue",
@@ -427,8 +515,7 @@ def cmd_forward_port(args, git_root):
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.
+ # target branch, e.g. entries the release branch never had in
unreleased/.
version_dir = git_root / f"changelog/v{version}"
stale = (
[
@@ -455,6 +542,7 @@ def cmd_forward_port(args, git_root):
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)
+ discard_generated_changelog_files(git_root, dry_run=dry_run)
# Step 6: push (optional)
if do_push:
@@ -522,7 +610,8 @@ def build_parser():
help="Set release date and forward-port changelog
post-vote",
description="Run once after a successful vote.\n"
"Writes the release date to the version
folder and regenerates CHANGELOG.md.\n"
- "Then cherry-picks all changelog commits
from the release branch to the stable branch and main.\n"
+ "Then cherry-picks the commits touching
changelog/v<version>/ from the release branch to the stable branch and main.\n"
+ "Requires a clean working tree; each local
branch is fast-forwarded from the remote before committing.\n"
"By default nothing is pushed; review
first and pass --push when satisfied.",
formatter_class=fmt)
fp.add_argument("--stable-branch", required=True,
diff --git a/dev-tools/scripts/releaseWizard.yaml
b/dev-tools/scripts/releaseWizard.yaml
index 09a9be8e512..ec83c020ce0 100644
--- a/dev-tools/scripts/releaseWizard.yaml
+++ b/dev-tools/scripts/releaseWizard.yaml
@@ -1295,12 +1295,14 @@ groups:
2. Regenerates `CHANGELOG.md` — now with the correct release date in the
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
+ 3. For each of `{{ stable_branch }}`, `main`{% if is_lts_release %}, and
`{{ latest_lts_stable_branch }}`{% endif %}:
+ cherry-picks the commits touching `changelog/v{{ release_version }}/`
that are on
+ `{{ release_branch }}` but not yet on that branch, then regenerates
`CHANGELOG.md` fresh
4. Pushes `{{ release_branch }}`, `{{ stable_branch }}`, `main`{% if
is_lts_release %}, and `{{ latest_lts_stable_branch }}`{% endif %}
+ Note that the script requires a clean working tree and fast-forwards
each of
+ these local branches from the git remote (`origin` by default) before
committing to it.
+
Can be run standalone, see `dev-tools/scripts/logchange.py --help` for
details:
commands: !Commands
root_folder: '{{ git_checkout_folder }}'