From 82ffaa3bd7e8e72a3bfa8d136109419797d5700f Mon Sep 17 00:00:00 2001 From: David Smiley Date: Sun, 4 Oct 2026 14:29:06 -0400 Subject: [PATCH 1/9] 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. * Pull each target branch (fast-forward only) before cherry-picking, so the final push isn't rejected. * Resolve modify/delete conflicts confined to changelog/unreleased/ by removing the entry; -X ours doesn't settle those. Skip a pick that is already applied. * Discard other versions' regenerated version-summary.md files, which otherwise block the next checkout. Co-Authored-By: Claude Opus 5.5 --- dev-tools/scripts/logchange.py | 69 +++++++++++++++++++++++----- dev-tools/scripts/releaseWizard.yaml | 4 +- 2 files changed, 60 insertions(+), 13 deletions(-) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index 2bbdc95e497c..1ef1acb45a02 100755 --- a/dev-tools/scripts/logchange.py +++ b/dev-tools/scripts/logchange.py @@ -87,6 +87,42 @@ def commit_touches_unreleased(sha, git_root): return any(f.startswith("changelog/unreleased/") for f in r.stdout.splitlines()) +def unmerged_paths(git_root): + r = subprocess.run( + ["git", "diff", "--name-only", "--diff-filter=U"], + cwd=git_root, capture_output=True, text=True, check=True, + ) + return r.stdout.splitlines() + + +def recover_cherry_pick(git_root): + """Try to finish a cherry-pick that stopped. Return True if it was resolved. + + Handles two cases: conflicts confined to changelog/unreleased/ (the entry + is being released, so it is removed), and a pick that is empty because its + changes are already on this branch. + """ + 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) + if any(not p.startswith("changelog/unreleased/") for p in conflicts): + 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 strip_unreleased_block(changelog_path: Path, dry_run=False): """Remove the [unreleased] block that logchangeGenerate emits. @@ -362,9 +398,14 @@ 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) + git(["restore", "changelog/"], 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. + # 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 @@ -376,17 +417,20 @@ def cmd_forward_port(args, git_root): 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 --cherry-pick --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", 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 +441,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 +454,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): + 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 +472,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 +499,9 @@ 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) + # Generation may also rewrite other versions' summaries; leaving them + # modified would block the next checkout. + git(["restore", "changelog/"], cwd=git_root, dry_run=dry_run) # Step 6: push (optional) if do_push: diff --git a/dev-tools/scripts/releaseWizard.yaml b/dev-tools/scripts/releaseWizard.yaml index 0a3b0201b528..24980f6cf885 100644 --- a/dev-tools/scripts/releaseWizard.yaml +++ b/dev-tools/scripts/releaseWizard.yaml @@ -1299,8 +1299,8 @@ 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 + 3. Cherry-picks the commits touching `changelog/v{{ release_version }}/` that are on + `{{ release_branch }}` but not yet on the target 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 %} From 9d5942be23af1f417ee14387438799a1b2517f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 7 Oct 2026 20:18:19 +0200 Subject: [PATCH 2/9] logchange.py forward-port: drop --cherry-pick from commit selection With the symmetric-difference range git computes patch-ids for every commit in the difference before path limiting is applied. For a release on an older major branch that is thousands of commits per target (roughly 3.5 minutes each against main and branch_10x for 9.11.0), spent silently in a wizard step that runs unattended with --push. Without the flag the same selection takes a fraction of a second. Re-run idempotency is unaffected: a commit that is already applied cherry-picks to an empty change, which recover_cherry_pick skips. --- dev-tools/scripts/logchange.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index 1ef1acb45a02..23d0469e7cf6 100755 --- a/dev-tools/scripts/logchange.py +++ b/dev-tools/scripts/logchange.py @@ -410,9 +410,12 @@ def cmd_forward_port(args, git_root): # 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) @@ -423,12 +426,11 @@ def cmd_forward_port(args, git_root): 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} -- {version_pathspec} :(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}", "--", version_pathspec, ":(exclude)changelog/*/version-summary.md"], cwd=git_root, capture_output=True, text=True, check=True, From 51abc148e7154bc0a8ab6e1706938a193ef5d81c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 7 Oct 2026 20:19:06 +0200 Subject: [PATCH 3/9] logchange.py forward-port: only auto-remove unreleased entries being released recover_cherry_pick removed any conflicting path under changelog/unreleased/. An identically named entry on the target that belongs to a different version would be deleted silently and then pushed. Apply the same counterpart check as the stale-entry pass: the file must also exist in changelog/v{version}/. Otherwise fall through to the existing error path and leave the conflict for the release manager. --- dev-tools/scripts/logchange.py | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index 23d0469e7cf6..bedc4f5559a0 100755 --- a/dev-tools/scripts/logchange.py +++ b/dev-tools/scripts/logchange.py @@ -95,12 +95,13 @@ def unmerged_paths(git_root): return r.stdout.splitlines() -def recover_cherry_pick(git_root): +def recover_cherry_pick(git_root, version): """Try to finish a cherry-pick that stopped. Return True if it was resolved. - Handles two cases: conflicts confined to changelog/unreleased/ (the entry - is being released, so it is removed), and a pick that is empty because its - changes are already on this branch. + 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. """ in_progress = subprocess.run( ["git", "rev-parse", "-q", "--verify", "CHERRY_PICK_HEAD"], @@ -109,8 +110,14 @@ def recover_cherry_pick(git_root): if not in_progress: return False conflicts = unmerged_paths(git_root) - if any(not p.startswith("changelog/unreleased/") for p in conflicts): - return False + 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)}") @@ -456,7 +463,7 @@ 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): + if recover_cherry_pick(git_root, version): continue print(f"\nError: cherry-pick of {sha} failed on {target}.", file=sys.stderr) From 56ce3566f98aa26707b7c20a6915a6c8094b0d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 7 Oct 2026 20:20:36 +0200 Subject: [PATCH 4/9] logchange.py forward-port: refuse a dirty tree; clean untracked summaries The command checks out several branches and discards generated files with git restore, so uncommitted edits to tracked files that checkout carried along were silently lost. Require a clean tree up front. git restore only resets tracked files. A version folder whose version-summary.md is not tracked on one branch but is on the next gets an untracked copy from generation that blocks the checkout. Remove those with git clean limited to that filename pattern. --- dev-tools/scripts/logchange.py | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index bedc4f5559a0..7705c09397f0 100755 --- a/dev-tools/scripts/logchange.py +++ b/dev-tools/scripts/logchange.py @@ -130,6 +130,29 @@ def recover_cherry_pick(git_root, version): return True +def require_clean_tree(git_root): + """Exit unless the working tree has no uncommitted changes to tracked files.""" + r = subprocess.run( + ["git", "status", "--porcelain", "--untracked-files=no"], + cwd=git_root, capture_output=True, text=True, check=True, + ) + 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): """Remove the [unreleased] block that logchangeGenerate emits. @@ -365,6 +388,10 @@ def cmd_forward_port(args, git_root): print(" (push disabled — run with --push when ready)") print() + # 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 release branch and write release-date.txt print(f"[1] Checking out {release_branch} and writing release-date.txt") git(["checkout", release_branch], cwd=git_root, dry_run=dry_run) @@ -405,7 +432,7 @@ 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) - git(["restore", "changelog/"], 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. @@ -508,9 +535,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) - # Generation may also rewrite other versions' summaries; leaving them - # modified would block the next checkout. - git(["restore", "changelog/"], cwd=git_root, dry_run=dry_run) + discard_generated_changelog_files(git_root, dry_run=dry_run) # Step 6: push (optional) if do_push: From 137db780c36c7e92b2a1c3406ca5ad8b82d7847b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 7 Oct 2026 20:21:17 +0200 Subject: [PATCH 5/9] logchange.py forward-port: fast-forward the release branch before committing Only the target branches were pulled. If the local release branch was behind the remote, the final --push of it was rejected after all the target work had been done. Pull it the same way in step 1. --- dev-tools/scripts/logchange.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index 7705c09397f0..b9cf85f441e2 100755 --- a/dev-tools/scripts/logchange.py +++ b/dev-tools/scripts/logchange.py @@ -392,9 +392,10 @@ def cmd_forward_port(args, git_root): # git restore, so refuse to run on top of uncommitted work. require_clean_tree(git_root) - # Step 1: checkout release branch and write release-date.txt - print(f"[1] Checking out {release_branch} and writing release-date.txt") + # 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. " From 102e5e696abad024d542612d9c14383226d244f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 7 Oct 2026 20:21:41 +0200 Subject: [PATCH 6/9] logchange.py: read git path listings NUL-separated With core.quotepath at its default, git quotes and escapes non-ASCII path names in --name-only output. A changelog entry named from a JIRA summary with such characters then failed the unreleased/ prefix check in recover_cherry_pick and the subsequent git rm. Use -z for both path listings. --- dev-tools/scripts/logchange.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index b9cf85f441e2..aa3078dafb20 100755 --- a/dev-tools/scripts/logchange.py +++ b/dev-tools/scripts/logchange.py @@ -78,21 +78,23 @@ def has_staged_changes(git_root, paths=None): return bool(r.stdout.strip()) -def commit_touches_unreleased(sha, git_root): - """Return True if the given commit modifies any file under changelog/unreleased/.""" +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", "diff-tree", "--no-commit-id", "-r", "--name-only", sha], + ["git"] + args + ["-z"], cwd=git_root, capture_output=True, text=True, check=True, ) - return any(f.startswith("changelog/unreleased/") for f in r.stdout.splitlines()) + 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): - r = subprocess.run( - ["git", "diff", "--name-only", "--diff-filter=U"], - cwd=git_root, capture_output=True, text=True, check=True, - ) - return r.stdout.splitlines() + return git_paths(["diff", "--name-only", "--diff-filter=U"], git_root) def recover_cherry_pick(git_root, version): From e7aaf19f9ed0fef261ae020f827680613d5a633f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 7 Oct 2026 20:22:17 +0200 Subject: [PATCH 7/9] logchange.py forward-port: honour --dry-run in recover_cherry_pick Unreachable in dry-run today since a dry-run git() never raises, but the function ran git rm and cherry-pick --continue for real if it were ever called. Return early instead. --- dev-tools/scripts/logchange.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index aa3078dafb20..0c5886ec388c 100755 --- a/dev-tools/scripts/logchange.py +++ b/dev-tools/scripts/logchange.py @@ -97,7 +97,7 @@ def unmerged_paths(git_root): return git_paths(["diff", "--name-only", "--diff-filter=U"], git_root) -def recover_cherry_pick(git_root, version): +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 @@ -105,6 +105,8 @@ def recover_cherry_pick(git_root, version): 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, @@ -493,7 +495,7 @@ 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): + 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) From 4d2ac05f9faffeb75b96301624217c36f8b9f424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 7 Oct 2026 20:22:53 +0200 Subject: [PATCH 8/9] releaseWizard: reword forward-port step; mention clean tree and pulls The step-3 sentence read awkwardly after the pathspec change. Also tell the release manager that the script refuses a dirty tree and fast-forwards their local branches, and bring the argparse description in line. --- dev-tools/scripts/logchange.py | 3 ++- dev-tools/scripts/releaseWizard.yaml | 10 ++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index 0c5886ec388c..f41ea03f34b1 100755 --- a/dev-tools/scripts/logchange.py +++ b/dev-tools/scripts/logchange.py @@ -608,7 +608,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/ 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 24980f6cf885..832b73d78584 100644 --- a/dev-tools/scripts/releaseWizard.yaml +++ b/dev-tools/scripts/releaseWizard.yaml @@ -1299,12 +1299,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 the commits touching `changelog/v{{ release_version }}/` that are on - `{{ release_branch }}` but not yet on the target 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 }}' From 2bde1d2d57f7a75a60efb88b80830eb029347b80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20H=C3=B8ydahl?= Date: Wed, 7 Oct 2026 20:22:53 +0200 Subject: [PATCH 9/9] logchange.py: note the minimum git version --- dev-tools/scripts/logchange.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py index f41ea03f34b1..646f04306a8e 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