This is an automated email from the ASF dual-hosted git repository.

dsmiley 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 28aeacffa57 releaseWizard: summarize dependency changes from 
solr/licenses (#4966)
28aeacffa57 is described below

commit 28aeacffa57e143b273ba091f21628950e244f01
Author: David Smiley <[email protected]>
AuthorDate: Fri Oct 9 00:44:53 2026 -0400

    releaseWizard: summarize dependency changes from solr/licenses (#4966)
    
    Replaces addDepsToChanges.py, which bulk-converted solrbot commits into 
changelog entries (duplicating the entries solrbot PRs already create), with 
dev-tools/scripts/dependencyChanges.py.
    
    The new script diffs the jar checksum files in solr/licenses/ between the 
previous release tag and HEAD (git only; no build needed):
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
---
 .github/scripts/generate-renovate-changelog.py   | 272 -----------------------
 .github/workflows/renovate-changelog-prepare.yml | 123 ----------
 .github/workflows/renovate-changelog-push.yml    | 177 ---------------
 .github/workflows/validate-changelog.yml         |   2 +-
 dev-docs/changelog.adoc                          |   7 +
 dev-tools/scripts/addDepsToChanges.py            | 257 ---------------------
 dev-tools/scripts/dependencyChanges.py           | 202 +++++++++++++++++
 dev-tools/scripts/releaseWizard.yaml             |  12 +-
 8 files changed, 216 insertions(+), 836 deletions(-)

diff --git a/.github/scripts/generate-renovate-changelog.py 
b/.github/scripts/generate-renovate-changelog.py
deleted file mode 100644
index ab37fd26288..00000000000
--- a/.github/scripts/generate-renovate-changelog.py
+++ /dev/null
@@ -1,272 +0,0 @@
-#!/usr/bin/env python3
-"""
-Generate changelog YAML entry for Renovate dependency update PRs.
-
-This script parses the PR title to extract dependency information,
-then generates a changelog YAML file in changelog/unreleased/ with the proper
-naming convention and content structure.
-
-Usage:
-    python3 generate-renovate-changelog.py --pr-number 1234 --pr-title "Update 
org.apache.httpcomponents to v1.2.3"
-"""
-
-import argparse
-import os
-import re
-import sys
-import yaml
-from pathlib import Path
-from typing import Optional, Tuple
-
-
-def sanitize_slug(text: str, max_length: int = 50) -> str:
-    """
-    Sanitize text to create a valid filename slug.
-
-    - Convert to lowercase
-    - Replace dots, colons, slashes with dashes
-    - Replace other special chars with dashes
-    - Preserve word boundaries
-    - Truncate to max_length while preserving word boundaries
-    """
-    # Convert to lowercase
-    text = text.lower()
-
-    # Replace colons, slashes, dots with dashes
-    text = re.sub(r'[:/.]+', '-', text)
-
-    # Replace other special characters with dashes
-    text = re.sub(r'[^a-z0-9\s-]', '-', text)
-
-    # Replace spaces with dashes
-    text = re.sub(r'\s+', '-', text)
-
-    # Replace multiple dashes with single dash
-    text = re.sub(r'-+', '-', text)
-
-    # Remove leading/trailing dashes
-    text = text.strip('-')
-
-    # Truncate to max_length at word boundary
-    if len(text) > max_length:
-        text = text[:max_length]
-        # Find last dash and truncate there
-        last_dash = text.rfind('-')
-        if last_dash > 0:
-            text = text[:last_dash]
-        text = text.rstrip('-')
-
-    return text
-
-
-def parse_pr_title(title: str) -> Tuple[str, Optional[str]]:
-    """
-    Parse Renovate PR title to extract dependency name and version.
-
-    Handles patterns like:
-    - "Update dependency org.junit.jupiter:junit-jupiter to v6"
-    - "Update dependency com.jayway.jsonpath:json-path to v2.10.0"
-    - "Update netty to v4.2.6.Final"
-    - "Update apache.kafka to v3.9.1"
-    - "Update actions/checkout action to v5"
-
-    Returns:
-        Tuple of (title_for_changelog, dependency_slug_for_filename)
-
-    Note: The slug excludes the version number so the filename remains stable
-          across version updates.
-    """
-
-    # Pattern 1: "Update dependency {group}:{artifact} to {version}"
-    match = re.match(r'Update dependency (.+?) to (.+?)(?:\s*$|\s*\(|$)', 
title)
-    if match:
-        dep_name = match.group(1)
-        version = match.group(2).strip()
-        changelog_title = f"Update {dep_name} to {version}"
-        # Slug contains only the dependency name, not the version
-        slug = sanitize_slug(f"Update {dep_name}")
-        return changelog_title, slug
-
-    # Pattern 2: "Update {owner}/{action} action to {version}"
-    match = re.match(r'Update ([a-z0-9-]+/[a-z0-9-]+) action to 
(.+?)(?:\s*$|\s*\(|$)', title)
-    if match:
-        action = match.group(1)
-        version = match.group(2).strip()
-        changelog_title = f"Update {action} action to {version}"
-        # Slug contains only the action name, not the version
-        slug = sanitize_slug(f"Update {action} action")
-        return changelog_title, slug
-
-    # Pattern 3: "Update {package} to {version}" (short form)
-    match = re.match(r'Update ([a-z0-9\-_.]+) to (.+?)(?:\s*$|\s*\(|$)', title)
-    if match:
-        package = match.group(1)
-        version = match.group(2).strip()
-        changelog_title = f"Update {package} to {version}"
-        # Slug contains only the package name, not the version
-        slug = sanitize_slug(f"Update {package}")
-        return changelog_title, slug
-
-    # Fallback: use title as-is if no pattern matches
-    return title, sanitize_slug(title)
-
-
-def generate_changelog_entry(
-    pr_number: int,
-    changelog_title: str,
-    pr_url: str = None
-) -> dict:
-    """Generate the changelog YAML entry dict."""
-
-    if pr_url is None:
-        pr_url = f"https://github.com/apache/solr/pull/{pr_number}";
-
-    return {
-        'title': changelog_title,
-        'type': 'dependency_update',
-        'authors': [
-            {'name': 'solrbot'}
-        ],
-        'links': [
-            {
-                'name': f'PR#{pr_number}',
-                'url': pr_url
-            }
-        ]
-    }
-
-
-def find_existing_changelog_file(pr_number: int, changelog_dir: str = 
'changelog/unreleased') -> Optional[str]:
-    """Find existing changelog file for this PR, returns path if exists."""
-    pattern = f"PR#{pr_number}-*.yml"
-    path = Path(changelog_dir)
-
-    if not path.exists():
-        return None
-
-    for file in path.glob(f"PR#{pr_number}-*.yml"):
-        return str(file)
-
-    return None
-
-
-def delete_old_changelog_files(pr_number: int, changelog_dir: str = 
'changelog/unreleased') -> int:
-    """
-    Delete all existing changelog files for this PR number.
-
-    This ensures we don't accumulate orphaned files when the PR title/slug 
changes.
-
-    Returns:
-        Number of files deleted
-    """
-    pattern = f"PR#{pr_number}-*.yml"
-    path = Path(changelog_dir)
-
-    if not path.exists():
-        return 0
-
-    deleted_count = 0
-    for file in path.glob(pattern):
-        try:
-            file.unlink()
-            print(f"Deleted old changelog file: {file}")
-            deleted_count += 1
-        except Exception as e:
-            print(f"Warning: Could not delete file {file}: {e}", 
file=sys.stderr)
-
-    if deleted_count > 0:
-        print(f"Deleted {deleted_count} old changelog file(s) for 
PR#{pr_number}")
-
-    return deleted_count
-
-
-def should_update_changelog(existing_file: str, new_title: str) -> bool:
-    """
-    Check if we need to update the changelog file.
-
-    Updates if the title has changed (version was bumped).
-    """
-    if not existing_file or not Path(existing_file).exists():
-        return False
-
-    try:
-        with open(existing_file, 'r') as f:
-            content = yaml.safe_load(f)
-
-        existing_title = content.get('title', '')
-        return existing_title != new_title
-    except Exception as e:
-        print(f"Warning: Could not read existing file {existing_file}: {e}", 
file=sys.stderr)
-        return False
-
-
-def write_changelog_file(filename: str, entry: dict, changelog_dir: str = 
'changelog/unreleased') -> None:
-    """Write the changelog YAML file."""
-    path = Path(changelog_dir)
-    path.mkdir(parents=True, exist_ok=True)
-
-    filepath = path / filename
-
-    # Use YAML dumper that preserves order and formatting
-    with open(filepath, 'w') as f:
-        yaml.dump(
-            entry,
-            f,
-            default_flow_style=False,
-            sort_keys=False,
-            allow_unicode=True
-        )
-
-    print(f"Created/updated changelog file: {filepath}")
-
-
-def main():
-    parser = argparse.ArgumentParser(
-        description='Generate changelog entry for Renovate PR',
-        formatter_class=argparse.RawDescriptionHelpFormatter,
-        epilog="""
-Examples:
-  python3 generate-renovate-changelog.py --pr-number 1234 --pr-title "Update 
org.apache.httpcomponents to v1.2.3"
-  python3 generate-renovate-changelog.py --pr-number 3751 --pr-title "Update 
dependency com.microsoft.onnxruntime:onnxruntime to v1.23.1"
-        """
-    )
-
-    parser.add_argument(
-        '--pr-number',
-        type=int,
-        required=True,
-        help='GitHub PR number'
-    )
-    parser.add_argument(
-        '--pr-title',
-        required=True,
-        help='GitHub PR title (from the Renovate bot)'
-    )
-    parser.add_argument(
-        '--changelog-dir',
-        default='changelog/unreleased',
-        help='Directory for changelog files (default: changelog/unreleased)'
-    )
-
-    args = parser.parse_args()
-
-    # Delete any existing changelog files for this PR to ensure a clean slate
-    # This prevents orphaned files when the PR title/slug changes
-    delete_old_changelog_files(args.pr_number, args.changelog_dir)
-
-    # Parse the PR title
-    changelog_title, slug = parse_pr_title(args.pr_title)
-
-    # Generate filename
-    filename = f"PR#{args.pr_number}-{slug}.yml"
-
-    # Generate the new entry
-    entry = generate_changelog_entry(args.pr_number, changelog_title)
-
-    # Write the changelog file
-    write_changelog_file(filename, entry, args.changelog_dir)
-    return 0
-
-
-if __name__ == '__main__':
-    sys.exit(main())
diff --git a/.github/workflows/renovate-changelog-prepare.yml 
b/.github/workflows/renovate-changelog-prepare.yml
deleted file mode 100644
index 5dab69c6867..00000000000
--- a/.github/workflows/renovate-changelog-prepare.yml
+++ /dev/null
@@ -1,123 +0,0 @@
-name: Generate Renovate Changelog (Stage 1 - Prepare)
-
-# Stage 1: runs with pull_request (no secrets, no write access).
-# Checks out BASE repo code only, runs the trusted Python script,
-# and saves the generated changelog file + PR metadata as an artifact
-# for Stage 2 to pick up and push to the fork branch.
-#
-# Generation is skipped (SKIP=true in the artifact metadata) when the PR
-# carries the 'no-changelog' label, or when a user other than solrbot has
-# manually edited changelog/unreleased/ in the PR — in that case the bot
-# must not overwrite the manual changes.
-on:
-  pull_request:
-    types:
-      - opened
-      - synchronize
-    branches:
-      - main
-      - 'branch_*'
-    paths-ignore:
-      - '.github/**'
-
-concurrency:
-  group: renovate-changelog-prepare-${{ github.event.pull_request.number }}
-  cancel-in-progress: true
-
-permissions:
-  contents: read
-  pull-requests: read # Needed to list PR commits for the manual-edit check
-
-jobs:
-  generate:
-    # Only run for Renovate bot PRs from the expected fork
-    if: |
-      github.event.pull_request.user.login == 'solrbot' &&
-      github.event.pull_request.head.repo.full_name == 'solrbot/apache-_-solr'
-    runs-on: ubuntu-latest
-
-    steps:
-      - name: Checkout BASE repository at base branch (NOT fork code)
-        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # 
v7.0.1
-        with:
-          ref: ${{ github.event.pull_request.base.ref }}
-          repository: ${{ github.repository }}
-
-      - name: Check whether changelog generation should be skipped
-        id: skip-check
-        env:
-          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-          PR_NUMBER: ${{ github.event.pull_request.number }}
-          REPO: ${{ github.repository }}
-          HAS_NO_CHANGELOG_LABEL: ${{ 
contains(github.event.pull_request.labels.*.name, 'no-changelog') }}
-        run: |
-          set -euo pipefail
-          skip=false
-
-          if [ "$HAS_NO_CHANGELOG_LABEL" = "true" ]; then
-            skip=true
-            echo "::notice::Skipping changelog generation for PR#${PR_NUMBER}: 
PR has the 'no-changelog' label"
-          else
-            # Skip if any PR commit from a user other than solrbot touched
-            # changelog/unreleased/ — a human has taken over the changelog
-            # entry and the bot must not overwrite it. Commits with an
-            # unresolvable login are treated as manual edits (fail safe).
-            while read -r sha login; do
-              if [ "$login" != "solrbot" ]; then
-                if gh api "repos/${REPO}/commits/${sha}" --jq 
'.files[].filename' | grep -q '^changelog/unreleased/'; then
-                  skip=true
-                  echo "::notice::Skipping changelog generation for 
PR#${PR_NUMBER}: changelog/unreleased/ was manually edited by '${login}' in 
commit ${sha}"
-                  break
-                fi
-              fi
-            done < <(gh api --paginate 
"repos/${REPO}/pulls/${PR_NUMBER}/commits" \
-                       --jq '.[] | "\(.sha) \(.author.login // "unknown")"')
-          fi
-
-          echo "skip=${skip}" >> "$GITHUB_OUTPUT"
-
-      - name: Set up Python
-        if: steps.skip-check.outputs.skip != 'true'
-        uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # 
v7.0.0
-        with:
-          python-version: '3.x'
-
-      - name: Install dependencies
-        if: steps.skip-check.outputs.skip != 'true'
-        run: python3 -m pip install --quiet pyyaml
-
-      - name: Generate changelog entry
-        if: steps.skip-check.outputs.skip != 'true'
-        env:
-          PR_NUMBER: ${{ github.event.pull_request.number }}
-          PR_TITLE: ${{ github.event.pull_request.title }}
-        run: |
-          python3 .github/scripts/generate-renovate-changelog.py \
-            --pr-number "$PR_NUMBER" \
-            --pr-title "$PR_TITLE"
-
-      - name: Assemble artifact
-        env:
-          PR_NUMBER: ${{ github.event.pull_request.number }}
-          HEAD_REF: ${{ github.event.pull_request.head.ref }}
-          HEAD_REPO: ${{ github.event.pull_request.head.repo.full_name }}
-          SKIP: ${{ steps.skip-check.outputs.skip }}
-        run: |
-          set -euo pipefail
-          mkdir -p artifact
-          if [ "$SKIP" != "true" ] && [ -d changelog/unreleased ]; then
-            cp -r changelog/unreleased artifact/changelog-unreleased
-          fi
-          # Use printf + env vars to avoid shell injection from PR metadata 
values
-          printf 'PR_NUMBER=%s\nHEAD_REF=%s\nHEAD_REPO=%s\nSKIP=%s\n' \
-            "$PR_NUMBER" "$HEAD_REF" "$HEAD_REPO" "$SKIP" \
-            > artifact/pr-metadata.env
-          echo "Artifact contents:"; find artifact/ -type f
-
-      - name: Upload artifact
-        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a 
# v7.0.1
-        with:
-          name: renovate-changelog-artifact
-          path: artifact/
-          retention-days: 1
-          if-no-files-found: error
diff --git a/.github/workflows/renovate-changelog-push.yml 
b/.github/workflows/renovate-changelog-push.yml
deleted file mode 100644
index 0e44c9f434e..00000000000
--- a/.github/workflows/renovate-changelog-push.yml
+++ /dev/null
@@ -1,177 +0,0 @@
-name: Generate Renovate Changelog (Stage 2 - Push)
-
-# Stage 2: runs after Stage 1 completes, in the base-repo context with access 
to
-# SOLRBOT_GITHUB_TOKEN. Downloads the pre-generated artifact (no fork code 
executed here),
-# validates metadata, and pushes the changelog file to the fork branch.
-#
-# If Stage 1 flagged SKIP=true in the artifact metadata (PR has the
-# 'no-changelog' label, or a user manually edited changelog/unreleased/),
-# this workflow leaves the changelog untouched.
-on:
-  workflow_run:
-    workflows:
-      - "Generate Renovate Changelog (Stage 1 - Prepare)"
-    types:
-      - completed
-
-# Each Stage 2 run corresponds to a unique Stage 1 run (unique 
workflow_run.id).
-# No cancel-in-progress: Stage 1's concurrency already serializes per-PR.
-concurrency:
-  group: renovate-changelog-push-${{ github.event.workflow_run.id }}
-
-permissions:
-  actions: read  # Required to download artifacts from another workflow run
-  contents: read # Minimal; actual write access to fork uses 
SOLRBOT_GITHUB_TOKEN PAT
-
-jobs:
-  push-changelog:
-    # Only proceed if Stage 1 succeeded for the expected fork.
-    # Checking head_repository here avoids a spurious artifact-not-found 
failure
-    # when Stage 1 ran but skipped its generate job (e.g. non-solrbot PR).
-    if: |
-      github.event.workflow_run.conclusion == 'success' &&
-      github.event.workflow_run.head_repository.full_name == 
'solrbot/apache-_-solr'
-    runs-on: ubuntu-latest
-
-    steps:
-      - name: Download artifact from Stage 1
-        uses: 
actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
-        with:
-          name: renovate-changelog-artifact
-          run-id: ${{ github.event.workflow_run.id }}
-          github-token: ${{ secrets.GITHUB_TOKEN }}
-          path: downloaded-artifact/
-
-      - name: Read and validate PR metadata
-        id: meta
-        run: |
-          set -euo pipefail
-          META_FILE="downloaded-artifact/pr-metadata.env"
-          if [ ! -f "$META_FILE" ]; then
-            echo "::error::Metadata file missing from artifact"; exit 1
-          fi
-
-          # Parse with grep/cut rather than source to avoid executing file 
content as shell.
-          # Use || true so a missing key doesn't abort under set -euo pipefail 
before the
-          # explicit emptiness check below can emit a meaningful error message.
-          PR_NUMBER=$(grep '^PR_NUMBER=' "$META_FILE" | cut -d= -f2- || true)
-          HEAD_REF=$(grep  '^HEAD_REF='  "$META_FILE" | cut -d= -f2- || true)
-          HEAD_REPO=$(grep '^HEAD_REPO=' "$META_FILE" | cut -d= -f2- || true)
-          SKIP=$(grep      '^SKIP='      "$META_FILE" | cut -d= -f2- || true)
-
-          if [ -z "$PR_NUMBER" ] || [ -z "$HEAD_REF" ] || [ -z "$HEAD_REPO" ]; 
then
-            echo "::error::Missing required metadata fields (PR_NUMBER, 
HEAD_REF, or HEAD_REPO)"; exit 1
-          fi
-
-          # Security: verify this is the expected fork before using 
SOLRBOT_GITHUB_TOKEN
-          if [ "$HEAD_REPO" != "solrbot/apache-_-solr" ]; then
-            echo "::error::Unexpected HEAD_REPO: '$HEAD_REPO'. Expected 
'solrbot/apache-_-solr'. Aborting."; exit 1
-          fi
-
-          # Validate PR_NUMBER is a plain positive integer (prevents injection)
-          if ! [[ "$PR_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
-            echo "::error::PR_NUMBER is not a valid positive integer: 
'$PR_NUMBER'"; exit 1
-          fi
-
-          # Validate HEAD_REF using Git's own branch-name rules. This rejects 
edge cases
-          # such as '..', '@{', trailing '.lock', and ':' (dangerous in push 
refspecs)
-          # while still accepting valid Renovate branch names like 
renovate/node@lts.
-          if ! git check-ref-format --branch "$HEAD_REF" > /dev/null 2>&1; then
-            echo "::error::HEAD_REF is not a valid Git branch name: 
'$HEAD_REF'"; exit 1
-          fi
-
-          # SKIP is optional (absent in artifacts from older Stage 1 runs); 
default to false
-          if [ "$SKIP" = "true" ]; then
-            echo "::notice::Stage 1 flagged skip for PR#${PR_NUMBER} — leaving 
changelog untouched"
-          else
-            SKIP=false
-          fi
-
-          echo "pr_number=$PR_NUMBER" >> "$GITHUB_OUTPUT"
-          echo "head_ref=$HEAD_REF"   >> "$GITHUB_OUTPUT"
-          echo "head_repo=$HEAD_REPO" >> "$GITHUB_OUTPUT"
-          echo "skip=$SKIP"           >> "$GITHUB_OUTPUT"
-          echo "Validated: PR#${PR_NUMBER} on ${HEAD_REPO}@${HEAD_REF} 
(skip=${SKIP})"
-
-      - name: Clone fork branch
-        if: steps.meta.outputs.skip != 'true'
-        env:
-          SOLRBOT_TOKEN: ${{ secrets.SOLRBOT_GITHUB_TOKEN }}
-          HEAD_REPO: ${{ steps.meta.outputs.head_repo }}
-          HEAD_REF: ${{ steps.meta.outputs.head_ref }}
-        run: |
-          set -euo pipefail
-          # Store credentials so the token never appears in the command line 
or process list
-          git config --global credential.helper store
-          printf 'https://x-access-token:%[email protected]\n' "$SOLRBOT_TOKEN" 
> ~/.git-credentials
-          chmod 600 ~/.git-credentials
-
-          git clone --depth=1 --branch "$HEAD_REF" \
-            "https://github.com/${HEAD_REPO}.git"; \
-            fork-checkout
-
-      - name: Apply changelog to fork checkout
-        if: steps.meta.outputs.skip != 'true'
-        id: apply
-        env:
-          PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
-        run: |
-          set -euo pipefail
-          CHANGELOG_DIR="fork-checkout/changelog/unreleased"
-          ARTIFACT_DIR="downloaded-artifact/changelog-unreleased"
-
-          if [ ! -d "$CHANGELOG_DIR" ]; then
-            echo "::error::changelog/unreleased not found in fork checkout"; 
exit 1
-          fi
-
-          if [ ! -d "$ARTIFACT_DIR" ]; then
-            echo "::warning::No changelog-unreleased directory in artifact for 
PR#${PR_NUMBER}"
-            echo "has_changes=false" >> "$GITHUB_OUTPUT"
-            exit 0
-          fi
-
-          # Remove stale PR#NNN-*.yml files (handles slug changes between 
synchronize events)
-          find "$CHANGELOG_DIR" -maxdepth 1 -name "PR#${PR_NUMBER}-*.yml" 
-delete -print
-
-          # Copy the new PR#NNN-*.yml file(s) from the artifact
-          COPIED=0
-          for f in "${ARTIFACT_DIR}/PR#${PR_NUMBER}-"*.yml; do
-            if [ -f "$f" ]; then
-              cp -v "$f" "$CHANGELOG_DIR/"
-              COPIED=$((COPIED + 1))
-            fi
-          done
-
-          if [ "$COPIED" -eq 0 ]; then
-            echo "::warning::No PR#${PR_NUMBER}-*.yml file found in artifact"
-            echo "has_changes=false" >> "$GITHUB_OUTPUT"
-          else
-            echo "Copied $COPIED changelog file(s)"
-            echo "has_changes=true" >> "$GITHUB_OUTPUT"
-          fi
-
-      - name: Commit and push to fork branch
-        if: steps.apply.outputs.has_changes == 'true'
-        env:
-          HEAD_REF: ${{ steps.meta.outputs.head_ref }}
-          PR_NUMBER: ${{ steps.meta.outputs.pr_number }}
-        run: |
-          set -euo pipefail
-          cd fork-checkout
-
-          if [ -z "$(git status --porcelain changelog/unreleased/)" ]; then
-            echo "No changelog changes (already up to date)"
-            exit 0
-          fi
-
-          git config user.name "SolrBot"
-          git config user.email "[email protected]"
-
-          git add changelog/unreleased/
-          git commit -m "Add changelog entry for PR#${PR_NUMBER}"
-
-          # Credential store (configured in Clone step) provides authentication
-          git push origin "HEAD:refs/heads/${HEAD_REF}"
-
-          # Remove credentials from disk now that the push is complete
-          rm -f ~/.git-credentials
diff --git a/.github/workflows/validate-changelog.yml 
b/.github/workflows/validate-changelog.yml
index 66fcc1c0481..af43d05b4d5 100644
--- a/.github/workflows/validate-changelog.yml
+++ b/.github/workflows/validate-changelog.yml
@@ -20,7 +20,7 @@ concurrency:
 jobs:
   validate-changelog:
     name: Check changelog entry
-    # Skip validation for Renovate PRs (solrbot) - they get changelog entries 
automatically.
+    # Skip validation for Renovate PRs (solrbot); dependency changes are 
summarized at release time.
     # On label events, only re-run for the 'no-changelog' label; other label 
churn
     # (e.g. from the labeler workflow) leaves the existing check result 
untouched.
     if: |
diff --git a/dev-docs/changelog.adoc b/dev-docs/changelog.adoc
index 70536cc0f04..ac6f0504ff5 100644
--- a/dev-docs/changelog.adoc
+++ b/dev-docs/changelog.adoc
@@ -292,6 +292,13 @@ Example report output (Json or Markdown):
 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.
 
+=== 5.5 Dependency changes summary
+
+`dev-tools/scripts/dependencyChanges.py` summarizes the third-party dependency 
changes
+since the previous release by diffing `solr/licenses/`.  By default it prints 
to stdout;
+with `--write`, the Release Wizard uses it to write `dependency_update` 
changelog entries
+(one each for added, upgraded, and removed).
+
 == 6. Further Reading
 
 * https://github.com/logchange/logchange[Logchange web page]
diff --git a/dev-tools/scripts/addDepsToChanges.py 
b/dev-tools/scripts/addDepsToChanges.py
deleted file mode 100755
index 3286d03cac4..00000000000
--- a/dev-tools/scripts/addDepsToChanges.py
+++ /dev/null
@@ -1,257 +0,0 @@
-#!/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.
-
-"""
-Script to create changelog YAML entries for solrbot dependency updates
-"""
-import os
-import sys
-
-sys.path.append(os.path.dirname(__file__))
-from scriptutil import *
-
-import argparse
-import re
-import yaml
-from pathlib import Path
-
-line_re = re.compile(r"(.*?) (\(branch_\d+x\) )?\(#(\d+)\)$")
-
-
-class ChangeEntry:
-    """
-    Represents one dependency change entry.
-    Fields:
-      - pr_num: string PR number (e.g., '3605')
-      - message: cleaned commit message text to be shown in CHANGES (noise 
removed)
-      - author: Git author (e.g., 'solrbot')
-    """
-
-    def __init__(self, pr_num: str, message: str, author: str):
-        self.pr_num = pr_num
-        self.message = message
-        self.author = author
-
-    def dep_key(self) -> str:
-        """
-        Extract a dependency key from the message after 'Update ' and before ' 
to', '(' or end.
-        This is used for de-duplication and sorting. Case-insensitive.
-        """
-        m = re.search(r"(?i)update\s+(.+?)(?:\s+to\b|\s*\(|$)", self.message)
-        if m:
-            return m.group(1).strip()
-        return self.message.strip()
-
-    def __str__(self) -> str:
-        # Keep trailing newline to preserve existing blank-line formatting by 
update_changes
-        return f"* PR#{self.pr_num}: {self.message} ({self.author})\n"
-
-    def to_yaml_dict(self) -> dict:
-        """
-        Convert to a dictionary suitable for YAML serialization.
-        Extracts JIRA IDs from the title and adds them to links.
-        """
-        # Extract JIRA IDs from the message
-        title, jira_links = extract_jira_issues_from_title(self.message)
-
-        # Build links: JIRA issues first, then PR
-        links = jira_links.copy()  # Start with JIRA links
-        links.append({
-            'name': f'PR#{self.pr_num}',
-            'url': f'https://github.com/apache/solr/pull/{self.pr_num}'
-        })
-
-        return {
-            'title': title,
-            'type': 'dependency_update',
-            'authors': [
-                {
-                    'name': self.author
-                }
-            ],
-            'links': links
-        }
-
-    def yaml_filename(self) -> str:
-        """
-        Generate a filesystem-safe filename for this entry.
-        Format: PR#####-slug.yaml
-        Truncates slug on whitespace boundaries, allowing up to 255 chars 
total.
-        """
-        # Clean message for slug
-        slug = self.message.lower()
-        # Replace whitespace with single space
-        slug = re.sub(r'\s+', ' ', slug)
-        # Remove non-alphanumeric except dashes
-        slug = re.sub(r'[^a-z0-9-._ ]', '', slug)
-
-        # Calculate available space for slug
-        # Format: "PR" + pr_num + "-" + slug + ".yaml"
-        # Typical PR#1234 = 8 chars + "-" = 9 chars, ".yaml" = 5 chars, total 
overhead = 14 chars
-        # Most filesystems limit filenames to 255 chars
-        max_filename_length = 255
-        overhead = len(f"PR{self.pr_num}-.yaml")
-        max_slug_length = max_filename_length - overhead
-
-        # Truncate to max length on word boundaries if necessary
-        if len(slug) > max_slug_length:
-            # Find the last space within the limit
-            truncated = slug[:max_slug_length]
-            last_dash = truncated.rfind(' ')
-            if last_dash > max_slug_length // 2:  # Keep at least half the 
available space
-                slug = truncated[:last_dash]
-            else:
-                # If no good word boundary, use hard limit and clean up 
trailing spaces
-                slug = truncated.rstrip(' ')
-        else:
-            # Remove trailing spaces
-            slug = slug.rstrip(' ')
-
-        return f"PR{self.pr_num}-{slug}.yaml"
-
-
-def get_prev_release_tag(ver):
-    """
-    Based on a given version, compute the git tag for the "previous" version 
to calculate changes since.
-    For a major version, we want all solrbot commits since last release, i.e. 
X-1.Y.Z
-    For a minor version, we want all solrbot commits since X.Y-1.0
-    For a patch version, we want all solrbot commits since X.Y.Z-1
-    """
-    releases_arr = run('git tag |grep "releases/solr" | cut -c 
15-').strip().split("\n")
-    releases = list(map(lambda x: Version.parse(x), releases_arr))
-    if ver.is_major_release():
-        last = releases.pop()
-        return "releases/solr/%s" % last.dot
-    if ver.is_minor_release():
-        return "releases/solr/%s.%s.0" % (ver.major, ver.minor - 1)
-    if ver.is_bugfix_release():
-        return "releases/solr/%s.%s.%s" % (ver.major, ver.minor, ver.bugfix - 
1)
-    return None
-
-
-def read_config():
-    parser = argparse.ArgumentParser(description='Adds changelog entries in 
changelog/ folder')
-    parser.add_argument('--version', type=Version.parse, help='Solr version to 
add changes to', required=True)
-    parser.add_argument('--user', default='solrbot', help='Git user to get 
changes for. Defaults to solrbot')
-    newconf = parser.parse_args()
-    return newconf
-
-
-def get_gitlog_lines(user: str, prev_tag: str):
-    """
-    Run git log and return a list of raw subject lines (%s), newest first.
-    """
-    output = run('git log --author=' + user + ' --oneline --no-merges 
--pretty=format:"%s" ' + prev_tag + '..')
-    return list(filter(None, output.split("\n")))
-
-
-def parse_gitlog_lines(lines, author: str):
-    """
-    Parse raw git log subject lines into ChangeEntry objects.
-    - Extract pr_num and message
-    - Strip '(branch_Nx)' noise
-    - Remove optional leading 'chore(deps): '
-    - Normalize 'update dependency X' to 'Update X' (case-insensitive)
-    """
-    entries = []
-    for line in lines:
-        match = line_re.search(line)
-        if not match:
-            print("Skipped un-parsable line: %s" % line)
-            continue
-        text = match.group(1)
-        pr_num = match.group(3)
-        # Clean message noise
-        msg = text
-        msg = re.sub(r"^chore\(deps\):\s*", "", msg, flags=re.IGNORECASE)
-        # Normalize 'update dependency' to 'Update '
-        msg = re.sub(r"(?i)^update\s+dependenc(y|ies)\s+", "Update ", msg)
-        entries.append(ChangeEntry(pr_num=pr_num, message=msg, author=author))
-    return entries
-
-
-def write_changelog_yaml(entries):
-    """
-    Write each ChangeEntry to a YAML file in changelog/unreleased/
-    """
-    changelog_dir = Path('changelog/unreleased')
-
-    # Create directory if it doesn't exist
-    changelog_dir.mkdir(parents=True, exist_ok=True)
-
-    count = 0
-    for entry in entries:
-        filename = changelog_dir / entry.yaml_filename()
-        yaml_data = entry.to_yaml_dict()
-
-        # Write YAML file with proper formatting
-        with open(filename, 'w') as f:
-            yaml.dump(yaml_data, f, default_flow_style=False, sort_keys=False, 
allow_unicode=True)
-
-        print(f"Created: {filename}")
-        count += 1
-
-    return count
-
-
-def dedupe_entries(entries):
-    """
-    De-duplicate dependency update entries (newest first input) by dep_key.
-    Keeps the first occurrence for each dependency key.
-    """
-    seen = set()
-    result = []
-    for e in entries:
-        key = e.dep_key().lower()
-        if key not in seen:
-            seen.add(key)
-            result.append(e)
-    return result
-
-
-def sort_entries(entries):
-    """Return a new list sorted alphabetically by dependency key 
(case-insensitive)."""
-    return sorted(entries, key=lambda e: e.dep_key().lower())
-
-
-def main():
-    if not os.path.exists('CHANGELOG.md'):
-        sys.exit("Tool must be run from the root of a source checkout.")
-    newconf = read_config()
-    prev_tag = get_prev_release_tag(newconf.version)
-    print("Creating changelog YAML entries for dependency updates since git 
tag %s" % prev_tag)
-    try:
-        gitlog_lines = get_gitlog_lines(newconf.user, prev_tag)
-        entries = parse_gitlog_lines(gitlog_lines, author=newconf.user)
-        if entries:
-            deduped = dedupe_entries(entries)
-            sorted_entries = sort_entries(deduped)
-            count = write_changelog_yaml(sorted_entries)
-            print(f"Successfully created {count} changelog YAML entries")
-        else:
-            print("No changes found for version %s" % newconf.version.dot)
-        print("Done")
-    except subprocess.CalledProcessError:
-        print("Error running git log - check your --version")
-        sys.exit(1)
-
-
-if __name__ == '__main__':
-    try:
-        main()
-    except KeyboardInterrupt:
-        print('\nReceived Ctrl-C, exiting early')
diff --git a/dev-tools/scripts/dependencyChanges.py 
b/dev-tools/scripts/dependencyChanges.py
new file mode 100644
index 00000000000..c5d71a381f4
--- /dev/null
+++ b/dev-tools/scripts/dependencyChanges.py
@@ -0,0 +1,202 @@
+#!/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.
+
+"""
+Summarizes changes to the third-party dependencies Solr ships, by diffing the
+jar checksum files in solr/licenses/ between two git refs (by default: the
+previous release tag and HEAD).  Jars that moved between the same two versions
+are listed together, since they are typically from the same project.
+
+Prints the summary to stdout, or with --write, writes changelog YAML entries.
+
+This is a stopgap until Solr publishes an SBOM per release, which would be the
+better source to diff.
+"""
+
+import argparse
+import os
+import re
+import subprocess
+import sys
+from collections import defaultdict
+
+sys.path.append(os.path.dirname(__file__))
+from scriptutil import Version, find_current_version
+
+LICENSES_DIR = 'solr/licenses'
+DEFAULT_OUTPUT_DIR = 'changelog/unreleased'
+
+# A version starts at the last '-'-delimited segment that looks like a dotted 
number.
+# e.g. log4j-1.2-api-2.25.3, zstd-jni-1.5.6-10, guava-33.4.8-jre
+VERSION_SEG_RE = re.compile(r'^\d+\.\d')
+# Classifiers that distinguish separate jars of the same artifact and version
+CLASSIFIER_RE = 
re.compile(r'-(tests?|linux-[\w-]+|osx-[\w-]+|windows-[\w-]+)$')
+# Test artifacts sharing a LICENSE file with shipped ones (e.g. 
lucene-test-framework)
+TEST_ARTIFACT_RE = re.compile(r'-(tests?|testing|test-framework)$')
+
+
+def git(*args):
+  return subprocess.run(['git'] + list(args), capture_output=True, text=True, 
check=True).stdout
+
+
+def parse_jar(jar_name):
+  """Returns (artifact, version) for a jar name without the '.jar' 
extension."""
+  classifier = ''
+  m = CLASSIFIER_RE.search(jar_name)
+  if m:
+    classifier = m.group(0)
+    jar_name = jar_name[:m.start()]
+  segs = jar_name.split('-')
+  idx = max((i for i, s in enumerate(segs) if i > 0 and 
VERSION_SEG_RE.match(s)), default=None)
+  if idx is None:
+    idx = max((i for i, s in enumerate(segs) if i > 0 and s[:1].isdigit()), 
default=len(segs))
+  return '-'.join(segs[:idx]) + classifier, '-'.join(segs[idx:])
+
+
+def read_licenses_dir(ref):
+  """Returns (artifact -> version, set of LICENSE file prefixes) at the given 
ref."""
+  jars = {}
+  license_prefixes = set()
+  for name in git('ls-tree', '--name-only', f'{ref}:{LICENSES_DIR}').split():
+    if name.endswith('.jar.sha1'):
+      artifact, version = parse_jar(name[:-len('.jar.sha1')])
+      jars[artifact] = version
+    else:
+      m = re.match(r'(.+)-LICENSE-.+\.txt$', name)
+      if m:
+        license_prefixes.add(m.group(1))
+  return jars, license_prefixes
+
+
+def has_license(artifact, license_prefixes):
+  """Whether some '-'-delimited prefix of the artifact has a LICENSE file 
(mirrors jar-checks.gradle)."""
+  name = CLASSIFIER_RE.sub('', artifact)
+  while True:
+    if name in license_prefixes:
+      return True
+    prefix = re.sub(r'-[^-]+$', '', name)
+    if prefix == name:
+      return False
+    name = prefix
+
+
+def find_previous_release_tag(current):
+  cur = Version.parse(current)
+  best = None
+  for tag in git('tag', '-l', 'releases/solr/*').split():
+    m = re.fullmatch(r'releases/solr/(\d+)\.(\d+)\.(\d+)', tag)
+    if not m:
+      continue
+    v = tuple(int(x) for x in m.groups())
+    if v < (cur.major, cur.minor, cur.bugfix) and (best is None or v > 
best[0]):
+      best = (v, tag)
+  if best is None:
+    sys.exit(f'No release tag found before {current}; pass --from')
+  return best[1]
+
+
+def describe_artifacts(artifacts):
+  """e.g. 'asm, asm-tree', or 'jetty-* (23 jars)' when all share the first 
name segment."""
+  if len(artifacts) > 3:
+    first = {a.split('-')[0] for a in artifacts}
+    if len(first) == 1:
+      return f'{first.pop()}-* ({len(artifacts)} jars)'
+  return ', '.join(artifacts)
+
+
+CATEGORIES = ('added', 'upgraded', 'removed')
+
+
+def compute_changes(from_ref, to_ref):
+  """Returns category ('added', 'upgraded', 'removed') -> sorted list of 
human-readable changes."""
+  old_jars, old_license_prefixes = read_licenses_dir(from_ref)
+  new_jars, new_license_prefixes = read_licenses_dir(to_ref)
+
+  # Only jars with a LICENSE file count: *.sha1 files also cover jars we don't 
ship (e.g. test
+  # dependencies), whereas LICENSE files are only required for shipped ones 
(since SOLR-15465; older
+  # releases have them for non-shipped jars too, making removals from such a 
release unreliable).
+  def shipped(artifact, license_prefixes):
+    return has_license(artifact, license_prefixes) and not 
TEST_ARTIFACT_RE.search(artifact)
+
+  by_transition = defaultdict(list)  # (old_version or None, new_version or 
None) -> artifacts
+  for artifact in old_jars.keys() | new_jars.keys():
+    old, new = old_jars.get(artifact), new_jars.get(artifact)
+    if old == new:
+      continue
+    if shipped(artifact, new_license_prefixes if new else 
old_license_prefixes):
+      by_transition[(old, new)].append(artifact)
+
+  changes = {c: [] for c in CATEGORIES}
+  for (old, new), artifacts in by_transition.items():
+    names = describe_artifacts(sorted(artifacts, key=str.lower))
+    if old is None:
+      changes['added'].append(f'{names} {new}')
+    elif new is None:
+      changes['removed'].append(f'{names} {old}')
+    else:
+      changes['upgraded'].append(f'{names} {old} → {new}')
+  return {c: sorted(lines, key=str.lower) for c, lines in changes.items()}
+
+
+def to_yaml(title, version):
+  def quote(s):
+    return "'" + s.replace("'", "''") + "'"
+  return (f'# Generated by dev-tools/scripts/dependencyChanges.py\n'
+          f'title: {quote(title)}\n'
+          f'type: dependency_update\n'
+          f'authors:\n'
+          f'  - name: various contributors\n'
+          f'links:\n'
+          f'  - name: solr/licenses\n'
+          f'    url: 
https://github.com/apache/solr/tree/releases/solr/{version}/solr/licenses\n')
+
+
+def main():
+  parser = argparse.ArgumentParser(description=__doc__, 
formatter_class=argparse.RawDescriptionHelpFormatter)
+  parser.add_argument('--from', dest='from_ref',
+                      help='Git ref to compare from (default: the release tag 
preceding the current version)')
+  parser.add_argument('--to', dest='to_ref', default='HEAD', help='Git ref to 
compare to (default: HEAD)')
+  parser.add_argument('--write', nargs='?', const=DEFAULT_OUTPUT_DIR, 
metavar='DIR',
+                      help='Write changelog YAML entries (one per category) 
instead of printing'
+                           f' (default dir: {DEFAULT_OUTPUT_DIR})')
+  args = parser.parse_args()
+
+  version = find_current_version()
+  from_ref = args.from_ref or find_previous_release_tag(version)
+  changes = compute_changes(from_ref, args.to_ref)
+  since = from_ref.rsplit('/', 1)[-1]
+
+  if not args.write:
+    print(f'Dependency changes from {from_ref} to {args.to_ref}:')
+    for category in CATEGORIES:
+      print(f'{category.capitalize()}: ' + ('; '.join(changes[category]) or 
'(none)'))
+    return
+
+  for i, category in enumerate(CATEGORIES, 1):  # numbered so the changelog 
lists them in this order
+    path = os.path.join(args.write, f'dependency-changes-{i}-{category}.yml')
+    if changes[category]:
+      title = f'Third-party dependencies {category} since {since}: ' + '; 
'.join(changes[category])
+      with open(path, 'w', encoding='utf-8') as f:
+        f.write(to_yaml(title, version))
+      print(f'Wrote {len(changes[category])} {category} dependency changes to 
{path}')
+    elif os.path.exists(path):
+      os.remove(path)
+      print(f'Removed {path}; no {category} dependency changes')
+
+
+if __name__ == '__main__':
+  main()
diff --git a/dev-tools/scripts/releaseWizard.yaml 
b/dev-tools/scripts/releaseWizard.yaml
index 832b73d7858..1e13c326851 100644
--- a/dev-tools/scripts/releaseWizard.yaml
+++ b/dev-tools/scripts/releaseWizard.yaml
@@ -675,21 +675,21 @@ groups:
     id: dependency_updates_changes
     title: Add dependency updates to changelog
     description: |
-      Bulk add all 'solrbot' dependency updates since last release.
-      NOTE: Work in progress to let each Solrbot PR add its own changes to 
changelog.
-      This step will be removed once that is done.
-      Until then, there may be a mix of PRs with and without changes to 
changelog.
+      Add changelog entries summarizing third-party dependencies added, 
upgraded, and removed since the
+      previous release, derived from the jar checksum files in 
`solr/licenses/`.
+      Re-running it overwrites the entries, e.g. if dependencies changed 
before a respin.
+      Run the script without `--write` to just print the summary.
     depends: clean_git_checkout
     commands: !Commands
       root_folder: '{{ git_checkout_folder }}'
-      commands_text: We call out to a helper script that compiles SolrBot 
changes into `changelog/unreleased`
+      commands_text: We call out to a helper script that writes 
`changelog/unreleased/dependency-changes-*.yml`
       confirm_each_command: true
       commands:
       - !Command
         cmd: git checkout {{ release_branch }}
         stdout: true
       - !Command
-        cmd: python3 -u dev-tools/scripts/addDepsToChanges.py --user solrbot 
--version {{ release_version }}
+        cmd: python3 -u dev-tools/scripts/dependencyChanges.py --write
         tee: true
       - !Command
         cmd: git add changelog  && git commit -m "Add dependency updates to 
changelog for {{ release_version }}"  && git push

Reply via email to