This is an automated email from the ASF dual-hosted git repository.
janhoy pushed a commit to branch branch_9x
in repository https://gitbox.apache.org/repos/asf/solr.git
The following commit(s) were added to refs/heads/branch_9x by this push:
new 08989118374 New logchange.py script to simplify changelog handling in
release wizard (#4413)
08989118374 is described below
commit 089891183749d424059c9b139ae93d8f988c9cd3
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
(cherry picked from commit eeb457e20b01d7a4cf959990fd36d1dbdfe1c48a)
---
.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 6e0f2973230..b088e736c62 100644
--- a/dev-docs/changelog.adoc
+++ b/dev-docs/changelog.adoc
@@ -127,7 +127,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.
=== 6.2 Migration tool
@@ -245,6 +245,12 @@ Example report output (Json or Markdown):
}
----
+=== 6.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.
+
== 7. 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 a16fc9094b6..d01b4ae1645 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 de33ce4acb5..79144f85404 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 4f60085e7d9..c9ee81f0691 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 e458a2be2fb..7c053a36cf1 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']
@@ -87,16 +92,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)")
@@ -163,11 +161,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)