blob: 2bbdc95e497cbf8901aa4e8613f8c88f8bd6839d [file]
#!/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, paths=None):
"""Return True if there are staged changes in the index, optionally scoped to paths."""
cmd = ["git", "diff", "--cached", "--name-only"]
if paths:
cmd += ["--"] + list(paths)
r = subprocess.run(cmd, cwd=git_root, capture_output=True, text=True)
return bool(r.stdout.strip())
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
rc_suffix = f" RC{args.rc_number}" if args.rc_number > 1 else ""
version_label = f"v{version}{rc_suffix}"
version_dir = git_root / "changelog" / f"v{version}"
unreleased_dir = git_root / "changelog" / "unreleased"
version_summary = f"changelog/v{version}/version-summary.md"
print(f"\n=== Changelog prepare for {version_label} 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()
if do_commit:
# Commit A: changelog/ YAML files only (version-summary.md is stale until generate runs)
print(f"\n[3] Staging changelog/ entries for {version_label} (commit A)")
ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
git(["add", "changelog"], cwd=git_root, dry_run=dry_run)
# Unstage version-summary.md — it is stale until logchangeGenerate runs
git(["restore", "--staged", version_summary], cwd=git_root, dry_run=dry_run, check=False)
if not dry_run and not has_staged_changes(git_root, ["changelog/"]):
print("\nNothing staged — changelog entries are already up to date.")
else:
msg_a = f"Changelog entries for {version_label}"
print(f"\n[4] Committing: {msg_a!r}")
git(["commit", "-m", msg_a], cwd=git_root, dry_run=dry_run)
# Commit B: regenerate, then stage CHANGELOG.md + version-summary.md
print(f"\n[5] Running logchangeGenerate and stripping [unreleased] block")
run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
print(f"\n[6] Staging CHANGELOG.md and version-summary.md (commit B)")
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
if not dry_run and not has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
print(" Nothing staged — CHANGELOG.md already up to date.")
else:
msg_b = f"Regenerate CHANGELOG.md for {version_label}"
print(f"\n[7] Committing: {msg_b!r}")
git(["commit", "-m", msg_b], cwd=git_root, dry_run=dry_run)
if not dry_run:
print(f"\nDone. Last commit {short_sha(git_root)} on {release_branch}.")
print(f"Push with: git push {args.git_remote} {release_branch}")
else:
print(f"\n[3] Running logchangeGenerate and stripping [unreleased] block")
run_logchange_generate(gradle_cmd, git_root, dry_run=dry_run)
print(f"\nReview the changes above, then run with --commit to stage and commit.")
if not dry_run:
print(f" git diff changelog/ CHANGELOG.md")
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
latest_lts_stable_branch = args.latest_lts_stable_branch
release_date_str = args.release_date or date.today().isoformat()
version_dir = git_root / "changelog" / f"v{version}"
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 latest_lts_stable_branch:
print(f" lts also targets: {latest_lts_stable_branch}")
if dry_run:
print(" (dry-run — no changes will be made)")
elif not do_push:
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")
version_summary = f"changelog/v{version}/version-summary.md"
# Step 2: commit A — release-date.txt only (before generate)
print(f"\n[2] Committing release-date.txt to {release_branch} (commit A)")
git(["add", f"changelog/v{version}/release-date.txt"], cwd=git_root, dry_run=dry_run)
if not dry_run and not has_staged_changes(git_root, [f"changelog/v{version}/release-date.txt"]):
print(" Nothing staged — release-date.txt was already up to date.")
else:
msg_a = f"Set release date {release_date_str} for v{version}"
print(f" Committing: {msg_a!r}")
git(["commit", "-m", msg_a], cwd=git_root, dry_run=dry_run)
# Step 3: commit B — regenerate, then stage CHANGELOG.md + version-summary.md
print(f"\n[3] Running logchangeGenerate (with release date {release_date_str})")
run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
print(f"\n[3b] Staging CHANGELOG.md and version-summary.md to {release_branch} (commit B)")
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
if not dry_run and not has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
print(" Nothing staged — CHANGELOG.md already up to date.")
else:
msg_b = f"Regenerate CHANGELOG.md for v{version}"
print(f" Committing: {msg_b!r}")
git(["commit", "-m", msg_b], cwd=git_root, dry_run=dry_run)
# Steps 4+5: for each target branch, find commits on release_branch not yet on
# that target, cherry-pick them, then regenerate CHANGELOG.md fresh.
# CHANGELOG.md is never cherry-picked — it is always regenerated so
# each branch gets a correct full-history version (avoids cross-major
# conflicts). version-summary.md is also excluded from cherry-picks
# for the same reason; it is regenerated alongside CHANGELOG.md.
# --cherry-pick with the symmetric-difference range (three dots) omits
# commits whose patch is already present on the target, making
# forward-port idempotent when re-run after an initial no-push run.
targets = [stable_branch, "main"]
if latest_lts_stable_branch:
targets.append(latest_lts_stable_branch)
for target in targets:
print(f"\n[4] Finding changelog/ commits on {release_branch} not yet on {target}")
if dry_run:
print(f" (dry-run) would run: git log --cherry-pick --right-only {target}...{release_branch} -- changelog/ :(exclude)changelog/*/version-summary.md")
commits = []
else:
result = subprocess.run(
["git", "log", "--oneline", "--reverse",
"--cherry-pick", "--right-only",
f"{target}...{release_branch}",
"--", "changelog/", ":(exclude)changelog/*/version-summary.md"],
cwd=git_root, capture_output=True, text=True, check=True,
)
commits = [line.split()[0] for line in result.stdout.strip().splitlines() if line]
if not commits:
print(f" No changelog commits to forward-port — {target} is already up to date.")
else:
print(f" Found {len(commits)} commit(s) to cherry-pick: {', '.join(commits)}")
print(f"\n[5] Cherry-picking {len(commits)} commit(s) to {target}")
git(["checkout", target], cwd=git_root, dry_run=dry_run)
for sha in commits:
# Commits that touch changelog/unreleased/ are deletions of files
# that exist under different names on stable/main — use -X ours so
# git keeps the target branch's own unreleased entries rather than
# trying to delete them. Commits that only add to the version
# folder are clean additions and cherry-pick without strategy options.
if commit_touches_unreleased(sha, git_root):
cp_args = ["cherry-pick", "-X", "ours", "-X", "no-renames", sha]
else:
cp_args = ["cherry-pick", sha]
try:
git(cp_args, cwd=git_root, dry_run=dry_run)
except subprocess.CalledProcessError:
print(f"\nError: cherry-pick of {sha} failed on {target}.",
file=sys.stderr)
print(" Resolve the conflict, then run: git cherry-pick --continue",
file=sys.stderr)
print(" Or abort with: git cherry-pick --abort",
file=sys.stderr)
sys.exit(1)
# Ensure unreleased/ folder exists for contributors after cherry-picks
ensure_unreleased_gitkeep(git_root, dry_run=dry_run)
git(["add", "changelog/unreleased/.gitkeep"], cwd=git_root, dry_run=dry_run)
if not dry_run and has_staged_changes(git_root, ["changelog/unreleased/.gitkeep"]):
git(["commit", "-m", f"Ensure changelog/unreleased/ folder exists on {target}"],
cwd=git_root, dry_run=dry_run)
# Remove any v{version} YAML files that still linger in unreleased/ on this
# target branch. Cherry-pick uses -X ours, so a "we modified / they deleted"
# conflict silently keeps the local copy; this explicit pass fixes that.
version_dir = git_root / f"changelog/v{version}"
stale = (
[
git_root / "changelog/unreleased" / f.name
for f in version_dir.glob("*.yml")
if (git_root / "changelog/unreleased" / f.name).exists()
]
if not dry_run
else []
)
if stale:
rel = [str(p.relative_to(git_root)) for p in stale]
print(f"\n Removing {len(stale)} stale unreleased file(s) from {target}: "
f"{', '.join(p.name for p in stale)}")
git(["rm", "--force"] + rel, cwd=git_root, dry_run=dry_run)
if not dry_run and has_staged_changes(git_root, ["changelog/unreleased/"]):
git(["commit", "-m", f"Remove v{version} entries from unreleased/ on {target}"],
cwd=git_root, dry_run=dry_run)
# Regenerate CHANGELOG.md and version-summary.md fresh on this branch.
print(f"\n Regenerating CHANGELOG.md on {target}")
run_logchange_generate(args.gradle_cmd, git_root, dry_run=dry_run)
git(["add", "CHANGELOG.md", version_summary], cwd=git_root, dry_run=dry_run)
if not dry_run and has_staged_changes(git_root, ["CHANGELOG.md", version_summary]):
git(["commit", "-m", f"Regenerate CHANGELOG.md with v{version} entries on {target}"],
cwd=git_root, dry_run=dry_run)
# Step 6: push (optional)
if do_push:
remote = args.git_remote
branches_to_push = [release_branch, stable_branch, "main"]
if latest_lts_stable_branch:
branches_to_push.append(latest_lts_stable_branch)
print(f"\n[6] Pushing {', '.join(branches_to_push)} to {remote}")
for branch in branches_to_push:
git_push(branch, remote, cwd=git_root, dry_run=dry_run)
else:
print(f"\nCherry-picks done. Review, then re-run with --push to push all branches.")
if not dry_run:
print(f" git log --oneline {stable_branch} -- changelog/")
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("--rc-number", type=int, default=1,
help="RC number (default: 1). RC2+ appends ' RC<n>' to commit messages.")
p.add_argument("--skip-validation", action="store_true",
help="Skip pre-flight YAML validation of changelog/unreleased/ files")
p.set_defaults(func=cmd_prepare)
# 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("--latest-lts-stable-branch",
help="Also forward-port to this LTS stable branch, e.g. branch_9x (LTS releases only)")
fp.add_argument("--release-date",
help="Release date YYYY-MM-DD (default: today)")
fp.add_argument("--push", action="store_true",
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()