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 eeb457e20b0 New logchange.py script to simplify changelog handling in 
release wizard (#4413)
eeb457e20b0 is described below

commit eeb457e20b01d7a4cf959990fd36d1dbdfe1c48a
Author: Jan Høydahl <[email protected]>
AuthorDate: Tue May 12 15:25:57 2026 +0200

    New logchange.py script to simplify changelog handling in release wizard 
(#4413)
    
    Refactor the wizard to generate CHANGELOG.md inside RC loop instead of 
before
    Move cherry-picking of the changelog commits to main and stable branch 
until after the vote
---
 .github/workflows/validate-changelog.yml           |   2 +-
 dev-docs/changelog.adoc                            |   8 +-
 dev-tools/scripts/README.md                        |  21 +
 dev-tools/scripts/logchange.py                     | 488 +++++++++++++++++++++
 dev-tools/scripts/releaseWizard.py                 |   5 +-
 dev-tools/scripts/releaseWizard.yaml               | 195 +++-----
 .../scripts/validate-changelog-yaml.py             |  53 ++-
 7 files changed, 615 insertions(+), 157 deletions(-)

diff --git a/.github/workflows/validate-changelog.yml 
b/.github/workflows/validate-changelog.yml
index 2d6c3a71d78..5fee5134aa4 100644
--- a/.github/workflows/validate-changelog.yml
+++ b/.github/workflows/validate-changelog.yml
@@ -97,7 +97,7 @@ jobs:
           echo "Validating: $file"
 
           # Validate using a Python script
-          python3 .github/scripts/validate-changelog-yaml.py "$file"
+          python3 dev-tools/scripts/validate-changelog-yaml.py "$file"
 
           if [ $? -ne 0 ]; then
             VALIDATION_FAILED=true
diff --git a/dev-docs/changelog.adoc b/dev-docs/changelog.adoc
index ee503457a9e..22b18b19635 100644
--- a/dev-docs/changelog.adoc
+++ b/dev-docs/changelog.adoc
@@ -113,7 +113,7 @@ The logchange gradle plugin offers some tasks, here are the 
two most important:
 
 The `logchangeRelease` and `logchangeGenerate` tasks are used by 
ReleaseWizard. The `logchangeArchive` task can be ran once for every major 
release or when the number of versioned changelog folders grow too large.
 
-These are integrated in the Release Wizard.
+These are integrated in the Release Wizard via 
`dev-tools/scripts/logchange.py`, which also handles RC2+ scenarios — see 
section 5.4.
 
 === 5.2 Migration tool
 
@@ -231,6 +231,12 @@ Example report output (Json or Markdown):
 }
 ----
 
+=== 5.4 Release candidate changelog script
+
+`dev-tools/scripts/logchange.py` handles changelog git operations for each RC
+and the final post-vote forward-porting.  It is integrated into the Release
+Wizard but can also be run standalone.  Run with `--help` for usage.
+
 == 6. Further Reading
 
 * https://github.com/logchange/logchange[Logchange web page]
diff --git a/dev-tools/scripts/README.md b/dev-tools/scripts/README.md
index e0c3071022c..0618a1aae14 100644
--- a/dev-tools/scripts/README.md
+++ b/dev-tools/scripts/README.md
@@ -182,6 +182,27 @@ Each YAML file complies with the schema outlined in 
`dev-docs/changelog.adoc`.
     # Default behavior
     python3 dev-tools/scripts/changes2logchange.py solr/CHANGES.txt
 
+### validate-changelog-yaml.py
+
+Validates one or more changelog YAML files, or all YAML files in a folder.
+Used by the GitHub Actions workflow to validate entries on pull requests,
+and called automatically by `logchange.py prepare` as a pre-flight check.
+
+    python3 dev-tools/scripts/validate-changelog-yaml.py 
changelog/unreleased/my-fix.yml
+    python3 dev-tools/scripts/validate-changelog-yaml.py changelog/unreleased/
+
+### logchange.py
+
+Handles changelog git operations during a release. Normally invoked by the
+Release Wizard, but can also be run standalone.
+
+    usage: logchange.py prepare [-h] --version VERSION --release-branch 
RELEASE_BRANCH [--gradle-cmd GRADLE_CMD]
+                            [--git-remote GIT_REMOTE] [--dry-run] [--commit]
+
+    usage: logchange.py forward-port [-h] --version VERSION --release-branch 
RELEASE_BRANCH [--gradle-cmd GRADLE_CMD]
+                                 [--git-remote GIT_REMOTE] [--dry-run] 
--stable-branch STABLE_BRANCH
+                                 [--release-date RELEASE_DATE] [--push]
+
 ### validateChangelogs.py
 
 Validates changelog folder structure and feature distribution across 
development branches (main, stable, release). See dev-docs for more.
diff --git a/dev-tools/scripts/logchange.py b/dev-tools/scripts/logchange.py
new file mode 100755
index 00000000000..921488da9b9
--- /dev/null
+++ b/dev-tools/scripts/logchange.py
@@ -0,0 +1,488 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""
+Changelog release helper for Apache Solr release managers.
+
+Normally invoked by the Release Wizard; see dev-docs/changelog.adoc for 
details.
+"""
+
+import argparse
+import re
+import shutil
+import subprocess
+import sys
+from datetime import date
+from pathlib import Path
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+def find_git_root() -> Path:
+    result = subprocess.run(
+        ["git", "rev-parse", "--show-toplevel"],
+        capture_output=True, text=True, check=True,
+    )
+    return Path(result.stdout.strip())
+
+
+def run(cmd, *, cwd, dry_run=False, check=True, capture=False):
+    """Print and optionally execute a shell command."""
+    print(f"  $ {' '.join(str(c) for c in cmd)}")
+    if dry_run:
+        return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="")
+    return subprocess.run(
+        cmd, cwd=cwd, capture_output=capture, text=True, check=check,
+    )
+
+
+def git(args, *, cwd, dry_run=False, check=True, capture=False):
+    return run(["git"] + args, cwd=cwd, dry_run=dry_run, check=check, 
capture=capture)
+
+
+def git_push(branch, remote, *, cwd, dry_run=False):
+    git(["checkout", branch], cwd=cwd, dry_run=dry_run)
+    git(["push", remote, branch], cwd=cwd, dry_run=dry_run)
+
+
+def short_sha(git_root):
+    r = subprocess.run(
+        ["git", "rev-parse", "--short", "HEAD"],
+        cwd=git_root, capture_output=True, text=True, check=True,
+    )
+    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,
+    )
+    return bool(r.stdout.strip())
+
+
+def commit_touches_unreleased(sha, git_root):
+    """Return True if the given commit modifies any file under 
changelog/unreleased/."""
+    r = subprocess.run(
+        ["git", "diff-tree", "--no-commit-id", "-r", "--name-only", sha],
+        cwd=git_root, capture_output=True, text=True, check=True,
+    )
+    return any(f.startswith("changelog/unreleased/") for f in 
r.stdout.splitlines())
+
+
+def strip_unreleased_block(changelog_path: Path, dry_run=False):
+    """Remove the [unreleased] block that logchangeGenerate emits.
+
+    CHANGELOG.md uses logchange's setext-style headings, e.g.:
+
+        [unreleased]
+        ------------
+
+        ### Added ...
+        ...content...
+
+        [10.1.0]
+        --------
+
+    We strip from the ``[unreleased]`` line up to (but not including) the next
+    versioned heading, identified by a ``[`` at the start of a line that is not
+    ``[unreleased]``.
+    """
+    if not changelog_path.exists():
+        print("  (CHANGELOG.md not found — skipping strip)")
+        return
+    text = changelog_path.read_text(encoding="utf-8")
+    cleaned = re.sub(
+        r'^\[unreleased\]\n(?:.*\n)*?(?=^\[(?!unreleased\]))',
+        '',
+        text,
+        flags=re.MULTILINE,
+    )
+    if cleaned == text:
+        print("  (no [unreleased] block found in CHANGELOG.md — nothing 
stripped)")
+        return
+    print("  Stripped [unreleased] block from CHANGELOG.md")
+    if not dry_run:
+        changelog_path.write_text(cleaned, encoding="utf-8")
+
+
+def validate_unreleased_yamls(git_root, dry_run=False):
+    """Run validate-changelog-yaml.py over changelog/unreleased/.
+
+    Passes the folder path to the validator, which handles file discovery 
itself.
+    Locates the validator relative to this script so it works regardless of 
cwd.
+    If the validator cannot be found, logs a warning and returns True 
(proceed).
+    Returns True if all files are valid (or nothing to validate), False 
otherwise.
+    """
+    unreleased_dir = git_root / "changelog" / "unreleased"
+    if not unreleased_dir.exists():
+        return True
+
+    validator = Path(__file__).parent / "validate-changelog-yaml.py"
+    if not validator.exists():
+        print(f"  Warning: validator not found at {validator} — skipping YAML 
validation")
+        return True
+
+    print(f"\n[0] Validating changelog/unreleased/ with {validator.name}")
+    if dry_run:
+        print(f"  (dry-run) would run: {validator.name} {unreleased_dir}")
+        return True
+
+    result = subprocess.run(
+        [sys.executable, str(validator), str(unreleased_dir)],
+        cwd=git_root,
+    )
+    if result.returncode != 0:
+        print("\nError: changelog YAML validation failed — please fix the 
issues and run again.",
+              file=sys.stderr)
+        return False
+    return True
+
+
+def ensure_unreleased_gitkeep(git_root, dry_run=False):
+    """Ensure changelog/unreleased/.gitkeep exists and is staged.
+
+    After a release the unreleased/ folder may be absent or untracked.
+    Committing a .gitkeep guarantees contributors always find the folder.
+    """
+    gitkeep = git_root / "changelog" / "unreleased" / ".gitkeep"
+    if not gitkeep.exists():
+        print(f"  Creating {gitkeep.relative_to(git_root)}")
+        if not dry_run:
+            gitkeep.parent.mkdir(parents=True, exist_ok=True)
+            gitkeep.touch()
+
+
+def run_logchange_generate(gradle_cmd, git_root, dry_run=False):
+    run([gradle_cmd, "logchangeGenerate"], cwd=git_root, dry_run=dry_run)
+    strip_unreleased_block(git_root / "CHANGELOG.md", dry_run=dry_run)
+    # logchangeGenerate may create/touch 
changelog/unreleased/version-summary.md;
+    # remove it so it doesn't clutter the working tree.
+    summary = git_root / "changelog" / "unreleased" / "version-summary.md"
+    if summary.exists():
+        print(f"  Removing {summary.relative_to(git_root)}")
+        if not dry_run:
+            summary.unlink()
+
+
+# ---------------------------------------------------------------------------
+# prepare
+# ---------------------------------------------------------------------------
+
+def cmd_prepare(args, git_root):
+    dry_run = args.dry_run
+    do_commit = args.commit
+    version = args.version
+    release_branch = args.release_branch
+    gradle_cmd = args.gradle_cmd
+
+    version_dir = git_root / "changelog" / f"v{version}"
+    unreleased_dir = git_root / "changelog" / "unreleased"
+
+    print(f"\n=== Changelog prepare for v{version} on {release_branch} ===")
+    if dry_run:
+        print("    (dry-run — no changes will be made)\n")
+    else:
+        print()
+
+    print(f"[1] Checking out {release_branch}")
+    git(["checkout", release_branch], cwd=git_root, dry_run=dry_run)
+
+    if not args.skip_validation:
+        if not validate_unreleased_yamls(git_root, dry_run=dry_run):
+            sys.exit(1)
+
+    # RC1 vs RC2+ detection is done on the filesystem after checkout.
+    # In dry-run mode the checkout is skipped, so warn the user the detection
+    # may reflect the current branch rather than the release branch.
+    if dry_run and not version_dir.exists():
+        print(f"  (dry-run: branch not actually checked out; RC path detection 
"
+              f"is based on current working tree and may be inaccurate)")
+
+    if not version_dir.exists():
+        # ------------------------------------------------------------------ #
+        # RC1 path: version folder does not yet exist                         #
+        # ------------------------------------------------------------------ #
+        print(f"\n[2] RC1 path — running logchangeRelease --releaseDate none 
--versionToRelease {version}")
+        run([gradle_cmd, "logchangeRelease", "--releaseDate", "none", 
"--versionToRelease", version],
+            cwd=git_root, dry_run=dry_run)
+    else:
+        # ------------------------------------------------------------------ #
+        # RC2+ path: version folder already exists, move only new entries     #
+        # ------------------------------------------------------------------ #
+        print(f"\n[2] RC2+ path — copying new unreleased entries to 
{version_dir.name}/")
+
+        if not unreleased_dir.exists():
+            print("  changelog/unreleased/ does not exist — nothing to move.")
+        else:
+            existing = {f.name for f in version_dir.iterdir() if f.is_file()}
+            new_files = [
+                f for f in unreleased_dir.iterdir()
+                if f.is_file() and f.name not in existing
+            ]
+
+            if not new_files:
+                print("  No new entries in changelog/unreleased/ — nothing to 
move.")
+            else:
+                for src in sorted(new_files):
+                    dst = version_dir / src.name
+                    print(f"  Moving {src.name} → {version_dir.name}/")
+                    if not dry_run:
+                        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")
+        ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
+        git(["add", "changelog", "CHANGELOG.md"], 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
+
+        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)
+
+        if not dry_run:
+            print(f"\nDone. Commit {short_sha(git_root)} on {release_branch}.")
+            print(f"Push with:  git push {args.git_remote} {release_branch}")
+    else:
+        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")
+
+    if dry_run:
+        print("\nDry-run complete — no changes made.")
+
+
+# ---------------------------------------------------------------------------
+# forward-port
+# ---------------------------------------------------------------------------
+
+def cmd_forward_port(args, git_root):
+    dry_run = args.dry_run
+    do_push = args.push
+    version = args.version
+    release_branch = args.release_branch
+    stable_branch = args.stable_branch
+    release_date_str = args.release_date or date.today().isoformat()
+
+    version_dir = git_root / "changelog" / f"v{version}"
+
+    print(f"\n=== Changelog forward-port for v{version} ===")
+    print(f"    Release date   : {release_date_str}")
+    print(f"    release_branch : {release_branch}")
+    print(f"    stable_branch  : {stable_branch}")
+    print(f"    also targets   : main")
+    if dry_run:
+        print("    (dry-run — no changes will be made)")
+    elif not do_push:
+        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")
+    git(["checkout", 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. "
+              f"Run 'prepare' first.", file=sys.stderr)
+        sys.exit(1)
+
+    date_file = version_dir / "release-date.txt"
+    print(f"  Writing {date_file.relative_to(git_root)}: {release_date_str}")
+    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)
+
+    # 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)
+
+    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.")
+    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]
+
+    if not commits:
+        print(f"  No changelog commits to forward-port — {stable_branch} is 
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}"],
+                    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"]:
+            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")
+
+    if dry_run:
+        print("\nDry-run complete — no changes made.")
+    elif do_push:
+        print("\nDone.")
+
+
+# ---------------------------------------------------------------------------
+# CLI
+# ---------------------------------------------------------------------------
+
+def build_parser():
+    fmt = lambda prog: argparse.RawDescriptionHelpFormatter(prog, width=120)
+    parser = argparse.ArgumentParser(
+        description=__doc__,
+        epilog="For options on each subcommand run: logchange.py <subcommand> 
--help",
+        formatter_class=fmt,
+    )
+    sub = parser.add_subparsers(dest="subcommand", required=True)
+
+    common = argparse.ArgumentParser(add_help=False)
+    common.add_argument("--version", required=True,
+                        help="Release version, e.g. 10.1.0")
+    common.add_argument("--release-branch", required=True,
+                        help="Release branch, e.g. branch_10_1")
+    common.add_argument("--gradle-cmd", default="./gradlew",
+                        help="Gradle wrapper command (default: ./gradlew)")
+    common.add_argument("--git-remote", default="origin",
+                        help="Git remote name to push to (default: origin)")
+    common.add_argument("--dry-run", action="store_true",
+                        help="Print actions without executing or modifying 
anything")
+
+    # prepare
+    p = sub.add_parser("prepare", parents=[common],
+                       help="Prepare changelog for an RC (RC1 or RC2+)",
+                       description="Prepare the changelog for a release 
candidate.\n"
+                                   "RC1: runs 'gradlew logchangeRelease' to 
move all unreleased entries to the version folder.\n"
+                                   "RC2+: copies only new unreleased entries 
into the existing version folder.\n"
+                                   "In both cases CHANGELOG.md is regenerated. 
By default changes are left uncommitted for review.",
+                       formatter_class=fmt)
+    p.add_argument("--commit", action="store_true",
+                   help="Stage and commit the result (default: leave 
uncommitted for review)")
+    p.add_argument("--skip-validation", action="store_true",
+                   help="Skip pre-flight YAML validation of 
changelog/unreleased/ files")
+    p.set_defaults(func=cmd_prepare)
+
+    # forward-port
+    fp = sub.add_parser("forward-port", parents=[common],
+                        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"
+                                    "By default nothing is pushed; review 
first and pass --push when satisfied.",
+                        formatter_class=fmt)
+    fp.add_argument("--stable-branch", required=True,
+                    help="Stable branch, e.g. branch_10x")
+    fp.add_argument("--release-date",
+                    help="Release date YYYY-MM-DD (default: today)")
+    fp.add_argument("--push", action="store_true",
+                    help="Push all branches after cherry-picking (default: 
leave for review)")
+    fp.set_defaults(func=cmd_forward_port)
+
+    return parser
+
+
+def main():
+    parser = build_parser()
+    args = parser.parse_args()
+
+    if hasattr(args, 'release_date') and args.release_date:
+        try:
+            date.fromisoformat(args.release_date)
+        except ValueError:
+            print(f"Error: --release-date must be YYYY-MM-DD, got: 
{args.release_date!r}",
+                  file=sys.stderr)
+            sys.exit(1)
+
+    try:
+        git_root = find_git_root()
+    except subprocess.CalledProcessError:
+        print("Error: must be run from within the Solr git repository.", 
file=sys.stderr)
+        sys.exit(1)
+
+    try:
+        args.func(args, git_root)
+    except subprocess.CalledProcessError as e:
+        print(f"\nError: command failed with exit code {e.returncode}", 
file=sys.stderr)
+        if e.stderr:
+            print(e.stderr, file=sys.stderr)
+        sys.exit(e.returncode)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/dev-tools/scripts/releaseWizard.py 
b/dev-tools/scripts/releaseWizard.py
index ffb2525607a..11467bbc714 100755
--- a/dev-tools/scripts/releaseWizard.py
+++ b/dev-tools/scripts/releaseWizard.py
@@ -100,6 +100,7 @@ def expand_jinja(text, vars=None):
         'release_version': state.release_version,
         'release_version_underscore': state.release_version.replace('.', '_'),
         'release_date': state.get_release_date(),
+        'release_date_iso': state.get_release_date_iso(),
         'ivy2_folder': os.path.expanduser("~/.ivy2/"),
         'config_path': state.config_path,
         'rc_number': state.rc_number,
@@ -1256,7 +1257,7 @@ def configure_pgp(gpg_todo):
         print("Your key has %s signatures, of which %s are by committers 
(@apache.org address)" % (sigs, apache_sigs))
         if apache_sigs < 1:
             print(textwrap.dedent("""\
-                Your key is not signed by any other committer. 
+                Your key is not signed by any other committer.
                 Please review https://infra.apache.org/openpgp.html#apache-wot
                 and make sure to get your key signed until next time.
                 You may want to run 'gpg --refresh-keys' to refresh your 
keychain."""))
@@ -1374,7 +1375,7 @@ def main():
                             subtitle=get_releasing_text,
                             prologue_text="Welcome to the release wizard. From 
here you can manage the process including creating new RCs. "
                                           "All changes are persisted, so you 
can exit any time and continue later. Make sure to read the Help section.",
-                            epilogue_text="® 2022 The Solr project. Licensed 
under the Apache License 2.0\nScript version v%s)" % getScriptVersion(),
+                            epilogue_text="(C) The Apache Solr project. 
Licensed under the Apache License 2.0\nScript version v%s)" % 
getScriptVersion(),
                             clear_screen=False)
 
     todo_menu = ConsoleMenu(title=get_releasing_text,
diff --git a/dev-tools/scripts/releaseWizard.yaml 
b/dev-tools/scripts/releaseWizard.yaml
index 06db6437074..6f8b002185b 100644
--- a/dev-tools/scripts/releaseWizard.yaml
+++ b/dev-tools/scripts/releaseWizard.yaml
@@ -694,144 +694,6 @@ groups:
       - !Command
         cmd: git add changelog  && git commit -m "Add dependency updates to 
changelog for {{ release_version }}"  && git push
         logfile: dependency-changes.log
-  - !Todo
-    id: logchange_release
-    title: Run logchange release to prepare changelog folder
-    description: |
-      This task will run `logchange release` to prepare the new `changelog/v{{ 
release_version }}` folder
-      and record it in two separate commits. These commits are then 
cherry-picked to the stable and unstable branches.
-      This task does not push anything upstream.
-
-      **IMPORTANT**: You must do each command in this TODO individually, as 
the cherry-pick command relies on there being two
-      new commits on the release-branch to cherry-pick from.
-    depends: dependency_updates_changes
-    commands: !Commands
-      root_folder: '{{ git_checkout_folder }}'
-      commands_text: Run `logchange release` to create the changelog folder
-      confirm_each_command: true
-      commands:
-      - !Command
-        cmd: |
-          git checkout {{ release_branch }} && \
-          echo "Running logchange release" && \
-          {{ gradle_cmd }} logchangeRelease && \
-          echo "Recording in two commits" && \
-          git rm -r changelog/unreleased && \
-          git commit -m "Logchange release on {{ release_branch }} - rm 
changelog/unreleased" && \
-          echo "--wizard-var changelog_rm_sha=$(git rev-parse --short HEAD)" 
&& \
-          git add changelog && \
-          git commit -m "Logchange release on {{ release_branch }} - add 
changelog/v{{ release_version }}" && \
-          echo "--wizard-var changelog_add_sha=$(git rev-parse --short HEAD)"
-        comment: Creates `changelog/v{{ release_version }}` folder and records 
it in two separate commits
-        logfile: logchange-release.log
-        shell: true
-        tee: true
-        persist_vars: true
-      - !Command
-        cmd: |
-          echo "--- Chery-picking to stable branch" && \
-          git checkout {{ stable_branch }} && \
-          git cherry-pick -X ours -X no-renames {{ changelog_rm_sha }} && \
-          git cherry-pick {{ changelog_add_sha }} && \
-          echo "--- Chery-picking to unstable branch" && \
-          git checkout main && \
-          git cherry-pick -X ours -X no-renames {{ changelog_rm_sha }} && \
-          git cherry-pick {{ changelog_add_sha }}
-        comment: Cherry-pick the release changelog commits to stable and 
unstable branches
-        logfile: cherry-pick-changelog-release.log
-        shell: true
-        tee: true
-  - !Todo
-    id: validate_changelog_unreleased
-    title: Validate changelog structure across branches
-    description: |
-      Validate that the changelog folder structure is correct and consistent 
across branches,
-      and verify that changelog entries are properly distributed (no released 
files in unreleased folder).
-      This ensures the CHANGELOG.md generation was successful and ready for 
the release.
-    depends: logchange_release
-    vars:
-      report_file: "{{ [rc_folder, 'changelog_validation_report.json'] | 
path_join }}"
-    commands: !Commands
-      root_folder: '{{ git_checkout_folder }}'
-      commands_text: Run the changelog validation script to help assess state 
of changelog folder
-      confirm_each_command: false
-      commands:
-        - !Command
-          cmd: python3 -u dev-tools/scripts/validateChangelogs.py 
--report-file {{ report_file }} --format json --skip-sync-check
-          logfile: validateChangelogs.log
-          tee: true
-    post_description: |
-      Review the validation report at {{ report_file }}. If there are any 
errors, you may need to 
-      go back and fix the changelog entries and commit the fix to relevant 
branch. If there are only 
-      warnings, you can proceed. The validation ensures:
-      - All changelog/vX.Y.Z folders are identical across branches
-      - No released JIRAs exist in the unreleased folder
-      - You see a list of `changelog/unreleased/` files that are *new* to each 
version
-  - !Todo
-    id: logchange_generate
-    title: Generate CHANGELOG.md using logchange generate
-    description: |
-      This task will run `logchange generate` to generate the `/CHANGELOG.md` 
file
-      and commit it to the release branch. The generated CHANGELOG will be 
cherry-picked
-      to the stable and unstable branches afterward.
-    depends: validate_changelog_unreleased
-    commands: !Commands
-      root_folder: '{{ git_checkout_folder }}'
-      commands_text: Generate `/CHANGELOG.md` using logchange
-      commands:
-      - !Command
-        cmd: |
-          git checkout {{ release_branch }} && \
-          echo "Running logchange generate" && \
-          {{ gradle_cmd }} logchangeGenerate && \
-          sed '/^\[unreleased\]$/,+3d' ./CHANGELOG.md >/tmp/CHANGELOG.md.tmp 
&& \
-          mv /tmp/CHANGELOG.md.tmp CHANGELOG.md && \
-          rm -f changelog/unreleased/version-summary.md && \
-          git add CHANGELOG.md changelog && \
-          git commit -m "CHANGELOG.md generated for release v{{ 
release_version }}" && \
-          echo "--wizard-var changelog_md_sha=$(git rev-parse --short HEAD)"
-        comment: Generate `/CHANGELOG.md` and commit to {{ release_branch }}
-        logfile: logchange-generate.log
-        shell: true
-        tee: true
-        persist_vars: true
-      - !Command
-        cmd: |
-          generate_sha=$(git rev-parse --short HEAD) && \
-          echo "--- Chery-picking to stable branch" && \
-          git checkout {{ stable_branch }} && \
-          git cherry-pick {{ changelog_md_sha }} && \
-          echo "--- Chery-picking to unstable branch" && \
-          git checkout main && \
-          git cherry-pick {{ changelog_md_sha }}
-        comment: Cherry-pick the CHANGELOG generation commit to stable and 
unstable branches
-        logfile: cherry-pick-changelog-generate.log
-        shell: true
-        tee: true
-  - !Todo
-    id: push_changelog_to_branches
-    title: Push changelog edits to all branches
-    description: |
-      This task will push all the changelog edits (release, generate, and 
other changes)
-      to each branch (release, stable, and unstable) upstream to the git 
repository.
-
-      Perform this step after all changelog validations have passed and you're 
satisfied
-      with the changelog state on all branches.
-    depends: logchange_generate
-    commands: !Commands
-      root_folder: '{{ git_checkout_folder }}'
-      commands_text: Push changelog commits to all branches
-      commands:
-      - !Command
-        cmd: |
-          for branch in {{ release_branch }} {{ stable_branch }} main; do  
-          git checkout $branch
-          git push origin $branch
-          done
-        comment: Push changelog commits to all branches
-        logfile: push-changelog-all-branches.log
-        shell: true
-        tee: true
   - !Todo
     id: draft_release_notes
     title: Get a draft of the release notes in place
@@ -887,6 +749,37 @@ groups:
   - prerequisites
   is_in_rc_loop: true
   todos:
+  - !Todo
+    id: changelog_update_rc
+    title: Prepare changelog for RC{{ rc_number }}
+    description: |
+      This step first validates all changelogs in `changelog/unreleased/`.
+      If there are any errors, they must be fixed before proceeding.
+      
+      It then moves changelog entries into `changelog/v{{ release_version }}/` 
and
+      regenerates `CHANGELOG.md` without a release date. The date is updated 
later,
+      in the `publish` phase.
+
+      The result is committed to `{{ release_branch }}` only. Cherry-picking to
+      `{{ stable_branch }}` and `main` is done in `publish` phase.
+
+      Can be run standalone, see `dev-tools/scripts/logchange.py prepare 
--help`.
+    depends: dependency_updates_changes
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Prepare changelog folder and regenerate CHANGELOG.md for 
RC{{ rc_number }}
+      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
+        logfile: changelog-update-rc.log
+        tee: true
+      - !Command
+        cmd: git push origin {{ release_branch }}
+        comment: Push prepared changelog to {{ release_branch }}
+        logfile: push-changelog-update-rc.log
+        tee: true
   - !Todo
     id: run_tests
     title: Run javadoc tests
@@ -1342,6 +1235,32 @@ groups:
       to validate Maven repo.
 
       If the check fails, please re-run the task, until it succeeds.
+  - !Todo
+    id: forward_port_changelog
+    title: Forward-port changelog to stable and main branches
+    description: |
+      Now that the vote has passed, this step:
+
+      1. Writes release date {{ release_date_iso }} to `changelog/v{{ 
release_version }}/release-date.txt`
+      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`
+
+      Can be run standalone, see `dev-tools/scripts/logchange.py --help` for 
details:
+    commands: !Commands
+      root_folder: '{{ git_checkout_folder }}'
+      commands_text: Set release date and cherry-pick changelog to stable and 
main
+      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
+          comment: Set release date, regenerate CHANGELOG.md, cherry-pick and 
push to all branches
+          logfile: forward-port-changelog.log
+          tee: true
 
 - !TodoGroup
   id: website
diff --git a/.github/scripts/validate-changelog-yaml.py 
b/dev-tools/scripts/validate-changelog-yaml.py
similarity index 85%
rename from .github/scripts/validate-changelog-yaml.py
rename to dev-tools/scripts/validate-changelog-yaml.py
index e3c30e974b4..0076a41184e 100644
--- a/.github/scripts/validate-changelog-yaml.py
+++ b/dev-tools/scripts/validate-changelog-yaml.py
@@ -18,7 +18,11 @@
 #
 
 """
-Validates changelog YAML files in changelog/unreleased/ folder.
+Validates changelog YAML files.
+
+Usage:
+  validate-changelog-yaml.py <file1> [<file2> ...]   Validate one or more files
+  validate-changelog-yaml.py <folder>                Validate all .yml/.yaml 
files in folder
 
 Checks:
 - File is valid YAML
@@ -35,11 +39,12 @@ Checks:
 """
 
 import sys
+from pathlib import Path
 import yaml
 
 
 def validate_changelog_yaml(file_path):
-    """Validate a changelog YAML file."""
+    """Validate a changelog YAML file. Returns True if valid."""
     valid_types = ['added', 'changed', 'fixed', 'deprecated', 'removed', 
'dependency_update', 'security', 'other']
     valid_keys = ['title', 'type', 'issues', 'links', 'important_notes', 
'modules', 'authors']
     deprecated_keys = ['merge_requests', 'configurations']
@@ -89,16 +94,9 @@ def validate_changelog_yaml(file_path):
         if 'authors' not in data or not data['authors']:
             print(f"::error file={file_path}::Missing or empty 'authors' 
field")
             return False
-
-        if not isinstance(data['authors'], list):
-            print(f"::error file={file_path}::Field 'authors' must be a list")
+        if not isinstance(data['authors'], list) or len(data['authors']) == 0:
+            print(f"::error file={file_path}::Field 'authors' must be a 
non-empty list")
             return False
-
-        if len(data['authors']) == 0:
-            print(f"::error file={file_path}::Field 'authors' must contain at 
least one author")
-            return False
-
-        # Validate each author
         for i, author in enumerate(data['authors']):
             if not isinstance(author, dict):
                 print(f"::error file={file_path}::Author {i} must be a mapping 
(key-value pairs)")
@@ -171,11 +169,36 @@ def validate_changelog_yaml(file_path):
         return False
 
 
+def collect_files(args):
+    """Expand a mix of file paths and folder paths into a list of YAML 
files."""
+    files = []
+    for arg in args:
+        p = Path(arg)
+        if p.is_dir():
+            found = sorted(p.glob("*.yml")) + sorted(p.glob("*.yaml"))
+            if not found:
+                print(f"Warning: no .yml/.yaml files found in {p}", 
file=sys.stderr)
+            files.extend(found)
+        elif p.is_file():
+            files.append(p)
+        else:
+            print(f"::error::Not a file or directory: {arg}")
+            sys.exit(1)
+    return files
+
+
 if __name__ == '__main__':
     if len(sys.argv) < 2:
-        print("Usage: validate-changelog-yaml.py <yaml-file>")
+        print("Usage: validate-changelog-yaml.py <file1> [<file2> ...] | 
<folder>")
         sys.exit(1)
 
-    file_path = sys.argv[1]
-    if not validate_changelog_yaml(file_path):
-        sys.exit(1)
+    files = collect_files(sys.argv[1:])
+    if not files:
+        sys.exit(0)
+
+    failed = False
+    for f in files:
+        if not validate_changelog_yaml(f):
+            failed = True
+
+    sys.exit(1 if failed else 0)


Reply via email to