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

dsmiley pushed a commit to branch branch_10x
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/branch_10x by this push:
     new 1e2a949d4b8 releaseWizard: summarize dependency changes from 
solr/licenses (#4966)
1e2a949d4b8 is described below

commit 1e2a949d4b8b43e58987fc64330484282a29f9ca
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):
    
    Backport deltas: validate-changelog.yml unchanged (branch_10x lacks the 
solrbot skip / renovate-changelog workflows, and the comment-only change there 
does not apply); changelog.adoc section numbered 6.5 to follow 6.4 on this 
branch.
    
    Co-authored-by: Claude Opus 5.5 <[email protected]>
    (cherry picked from commit 28aeacffa57e143b273ba091f21628950e244f01)
---
 dev-docs/changelog.adoc                |   7 +
 dev-tools/scripts/addDepsToChanges.py  | 257 ---------------------------------
 dev-tools/scripts/dependencyChanges.py | 202 ++++++++++++++++++++++++++
 dev-tools/scripts/releaseWizard.yaml   |  10 +-
 4 files changed, 216 insertions(+), 260 deletions(-)

diff --git a/dev-docs/changelog.adoc b/dev-docs/changelog.adoc
index d8b767c81d7..bd8d3e5cd4a 100644
--- a/dev-docs/changelog.adoc
+++ b/dev-docs/changelog.adoc
@@ -272,6 +272,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.
 
+=== 6.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).
+
 == 7. 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 ec83c020ce0..1e13c326851 100644
--- a/dev-tools/scripts/releaseWizard.yaml
+++ b/dev-tools/scripts/releaseWizard.yaml
@@ -674,18 +674,22 @@ groups:
   - !Todo
     id: dependency_updates_changes
     title: Add dependency updates to changelog
-    description: Bulk add all 'solrbot' dependency updates since last release
+    description: |
+      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