diff --git a/CHANGELOG.md b/CHANGELOG.md
index 997e44b..8df42ea 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,42 +2,121 @@
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
-and this project uses a **date-based versioning scheme** (`YYYY.MM.DD`).
+and this project uses a **date-based versioning scheme** (`YYYY.MM.DD`).
+
+Legend: 🆕 New • ❌ Removed • 🟢 Patch • 🔵 Minor • 🔴 Major
## [Unreleased]
+## [2025.10.24]
+
+| Component | Version | Status | Prev.
Version |
+|:------------------------------------------------|:-------:|:------:|:----------------:|
+| **Keypop Dependencies** | | | |
+| [keypop-reader-java-api] | `2.0.1` | | |
+| [keypop-calypso-card-java-api] | `2.1.2` | | |
+| [keypop-calypso-crypto-legacysam-java-api] | `0.7.0` | | |
+| [keypop-storagecard-java-api] | `0.3.0` | | |
+| | | | |
+| **Keyple Core** | | | |
+| [keyple-common-java-api] | `2.0.2` | | |
+| [keyple-plugin-storagecard-java-api] | `1.0.0` | | |
+| [keyple-service-java-lib] | `3.3.6` | 🟢 | `3.3.5` |
+| [keyple-service-resource-java-lib] | `3.1.0` | | |
+| [keyple-util-java-lib] | `2.4.0` | | |
+| | | | |
+| **Keyple Distributed** | | | |
+| [keyple-distributed-local-java-lib] | `2.5.2` | | |
+| [keyple-distributed-network-java-lib] | `2.5.1` | | |
+| [keyple-distributed-remote-java-lib] | `2.5.1` | | |
+| | | | |
+| **Keyple Interop** | | | |
+| [keyple-interop-jsonapi-client-kmp-lib] | `0.1.6` | | |
+| [keyple-interop-localreader-nfcmobile-kmp-lib] | `0.1.6` | | |
+| | | | |
+| **Keyple Card Extensions** | | | |
+| [keyple-card-calypso-java-lib] | `3.1.9` | | |
+| [keyple-card-calypso-crypto-legacysam-java-lib] | `0.9.0` | | |
+| [keyple-card-calypso-crypto-pki-java-lib] | `0.2.3` | | |
+| [keyple-card-generic-java-lib] | `3.1.2` | | |
+| | | | |
+| **Keyple Reader Plugins** | | | |
+| [keyple-plugin-android-nfc-java-lib] | `3.1.0` | | |
+| [keyple-plugin-android-omapi-java-lib] | `2.1.0` | | |
+| [keyple-plugin-cardresource-java-lib] | `2.0.1` | | |
+| [keyple-plugin-pcsc-java-lib] | `2.5.3` | 🟢 | `2.5.2` |
+| [keyple-plugin-stub-java-lib] | `2.2.1` | | |
+
## [2025.09.12]
First publication of the **Keyple Java BOM**.
This release defines a consistent set of versions for **Keyple** and **Keypop** artifacts.
-### Keypop Dependencies
-- `org.eclipse.keypop:keypop-reader-java-api:2.0.1`
-- `org.eclipse.keypop:keypop-calypso-card-java-api:2.1.2`
-- `org.eclipse.keypop:keypop-calypso-crypto-legacysam-java-api:0.7.0`
-- `org.eclipse.keypop:keypop-storagecard-java-api:0.3.0`
-### Keyple Core
-- `org.eclipse.keyple:keyple-common-java-api:2.0.2`
-- `org.eclipse.keyple:keyple-plugin-storagecard-java-api:1.0.0`
-- `org.eclipse.keyple:keyple-service-java-lib:3.3.5`
-- `org.eclipse.keyple:keyple-service-resource-java-lib:3.1.0`
-- `org.eclipse.keyple:keyple-util-java-lib:2.4.0`
-### Keyple Distributed
-- `org.eclipse.keyple:keyple-distributed-local-java-lib:2.5.2`
-- `org.eclipse.keyple:keyple-distributed-network-java-lib:2.5.1`
-- `org.eclipse.keyple:keyple-distributed-remote-java-lib:2.5.1`
-### Keyple Interop
-- `org.eclipse.keyple:keyple-interop-jsonapi-client-kmp-lib:0.1.6` (+ JVM, Android, iOS variants)
-- `org.eclipse.keyple:keyple-interop-localreader-nfcmobile-kmp-lib:0.1.6` (+ JVM, Android, iOS variants)
-### Keyple Card Extensions
-- `org.eclipse.keyple:keyple-card-calypso-java-lib:3.1.9`
-- `org.eclipse.keyple:keyple-card-calypso-crypto-legacysam-java-lib:0.9.0`
-- `org.eclipse.keyple:keyple-card-calypso-crypto-pki-java-lib:0.2.3`
-- `org.eclipse.keyple:keyple-card-generic-java-lib:3.1.2`
-### Keyple Reader Plugins
-- `org.eclipse.keyple:keyple-plugin-android-nfc-java-lib:3.1.0`
-- `org.eclipse.keyple:keyple-plugin-android-omapi-java-lib:2.1.0`
-- `org.eclipse.keyple:keyple-plugin-cardresource-java-lib:2.0.1`
-- `org.eclipse.keyple:keyple-plugin-pcsc-java-lib:2.5.2`
-- `org.eclipse.keyple:keyple-plugin-stub-java-lib:2.2.1`
-
-[Unreleased]: https://github.com/eclipse-keyple/keyple-java-bom/compare/2025.09.12...HEAD
+
+| Component | Version | Status |
+|:------------------------------------------------|:-------:|:------:|
+| **Keypop Dependencies** | | |
+| [keypop-reader-java-api] | `2.0.1` | 🆕 |
+| [keypop-calypso-card-java-api] | `2.1.2` | 🆕 |
+| [keypop-calypso-crypto-legacysam-java-api] | `0.7.0` | 🆕 |
+| [keypop-storagecard-java-api] | `0.3.0` | 🆕 |
+| | | |
+| **Keyple Core** | | |
+| [keyple-common-java-api] | `2.0.2` | 🆕 |
+| [keyple-plugin-storagecard-java-api] | `1.0.0` | 🆕 |
+| [keyple-service-java-lib] | `3.3.5` | 🆕 |
+| [keyple-service-resource-java-lib] | `3.1.0` | 🆕 |
+| [keyple-util-java-lib] | `2.4.0` | 🆕 |
+| | | |
+| **Keyple Distributed** | | |
+| [keyple-distributed-local-java-lib] | `2.5.2` | 🆕 |
+| [keyple-distributed-network-java-lib] | `2.5.1` | 🆕 |
+| [keyple-distributed-remote-java-lib] | `2.5.1` | 🆕 |
+| | | |
+| **Keyple Interop** | | |
+| [keyple-interop-jsonapi-client-kmp-lib] | `0.1.6` | 🆕 |
+| [keyple-interop-localreader-nfcmobile-kmp-lib] | `0.1.6` | 🆕 |
+| | | |
+| **Keyple Card Extensions** | | |
+| [keyple-card-calypso-java-lib] | `3.1.9` | 🆕 |
+| [keyple-card-calypso-crypto-legacysam-java-lib] | `0.9.0` | 🆕 |
+| [keyple-card-calypso-crypto-pki-java-lib] | `0.2.3` | 🆕 |
+| [keyple-card-generic-java-lib] | `3.1.2` | 🆕 |
+| | | |
+| **Keyple Reader Plugins** | | |
+| [keyple-plugin-android-nfc-java-lib] | `3.1.0` | 🆕 |
+| [keyple-plugin-android-omapi-java-lib] | `2.1.0` | 🆕 |
+| [keyple-plugin-cardresource-java-lib] | `2.0.1` | 🆕 |
+| [keyple-plugin-pcsc-java-lib] | `2.5.2` | 🆕 |
+| [keyple-plugin-stub-java-lib] | `2.2.1` | 🆕 |
+
+[Unreleased]: https://github.com/eclipse-keyple/keyple-java-bom/compare/2025.10.24...HEAD
+[2025.10.24]: https://github.com/eclipse-keyple/keyple-java-bom/compare/2025.09.12...2025.10.24
[2025.09.12]: https://github.com/eclipse-keyple/keyple-java-bom/releases/tag/2025.09.12
+
+[keypop-reader-java-api]: https://github.com/eclipse-keypop/keypop-reader-java-api/releases
+[keypop-calypso-card-java-api]: https://github.com/eclipse-keypop/keypop-calypso-card-java-api/releases
+[keypop-calypso-crypto-legacysam-java-api]: https://github.com/eclipse-keypop/keypop-calypso-crypto-legacysam-java-api/releases
+[keypop-storagecard-java-api]: https://github.com/eclipse-keypop/keypop-storagecard-java-api/releases
+
+[keyple-common-java-api]: https://github.com/eclipse-keyple/keyple-common-java-api/releases
+[keyple-plugin-storagecard-java-api]: https://github.com/eclipse-keyple/keyple-plugin-storagecard-java-api/releases
+[keyple-service-java-lib]: https://github.com/eclipse-keyple/keyple-service-java-lib/releases
+[keyple-service-resource-java-lib]: https://github.com/eclipse-keyple/keyple-service-resource-java-lib/releases
+[keyple-util-java-lib]: https://github.com/eclipse-keyple/keyple-util-java-lib/releases
+
+[keyple-distributed-local-java-lib]: https://github.com/eclipse-keyple/keyple-distributed-local-java-lib/releases
+[keyple-distributed-network-java-lib]: https://github.com/eclipse-keyple/keyple-distributed-network-java-lib/releases
+[keyple-distributed-remote-java-lib]: https://github.com/eclipse-keyple/keyple-distributed-remote-java-lib/releases
+
+[keyple-interop-jsonapi-client-kmp-lib]: https://github.com/eclipse-keyple/keyple-interop-jsonapi-client-kmp-lib/releases
+[keyple-interop-localreader-nfcmobile-kmp-lib]: https://github.com/eclipse-keyple/keyple-interop-localreader-nfcmobile-kmp-lib/releases
+
+[keyple-card-calypso-java-lib]: https://github.com/eclipse-keyple/keyple-card-calypso-java-lib/releases
+[keyple-card-calypso-crypto-legacysam-java-lib]: https://github.com/eclipse-keyple/keyple-card-calypso-crypto-legacysam-java-lib/releases
+[keyple-card-calypso-crypto-pki-java-lib]: https://github.com/eclipse-keyple/keyple-card-calypso-crypto-pki-java-lib/releases
+[keyple-card-generic-java-lib]: https://github.com/eclipse-keyple/keyple-card-generic-java-lib/releases
+
+[keyple-plugin-android-nfc-java-lib]: https://github.com/eclipse-keyple/keyple-plugin-android-nfc-java-lib/releases
+[keyple-plugin-android-omapi-java-lib]: https://github.com/eclipse-keyple/keyple-plugin-android-omapi-java-lib/releases
+[keyple-plugin-cardresource-java-lib]: https://github.com/eclipse-keyple/keyple-plugin-cardresource-java-lib/releases
+[keyple-plugin-pcsc-java-lib]: https://github.com/eclipse-keyple/keyple-plugin-pcsc-java-lib/releases
+[keyple-plugin-stub-java-lib]: https://github.com/eclipse-keyple/keyple-plugin-stub-java-lib/releases
diff --git a/build.gradle.kts b/build.gradle.kts
index 42d4fa9..141e615 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -32,7 +32,7 @@ dependencies {
// Keyple core
api("org.eclipse.keyple:keyple-common-java-api:2.0.2")
api("org.eclipse.keyple:keyple-plugin-storagecard-java-api:1.0.0")
- api("org.eclipse.keyple:keyple-service-java-lib:3.3.5")
+ api("org.eclipse.keyple:keyple-service-java-lib:3.3.6")
api("org.eclipse.keyple:keyple-service-resource-java-lib:3.1.0")
api("org.eclipse.keyple:keyple-util-java-lib:2.4.0")
// Keyple distributed
@@ -61,7 +61,7 @@ dependencies {
api("org.eclipse.keyple:keyple-plugin-android-nfc-java-lib:3.1.0")
api("org.eclipse.keyple:keyple-plugin-android-omapi-java-lib:2.1.0")
api("org.eclipse.keyple:keyple-plugin-cardresource-java-lib:2.0.1")
- api("org.eclipse.keyple:keyple-plugin-pcsc-java-lib:2.5.2")
+ api("org.eclipse.keyple:keyple-plugin-pcsc-java-lib:2.5.3")
api("org.eclipse.keyple:keyple-plugin-stub-java-lib:2.2.1")
}
}
diff --git a/tools/README.md b/tools/README.md
new file mode 100644
index 0000000..7a8eeec
--- /dev/null
+++ b/tools/README.md
@@ -0,0 +1,169 @@
+# Automatic CHANGELOG Update Script
+
+## Description
+
+`update_changelog.py` is a Python script that **automates the update** of the `CHANGELOG.md` file based on version changes detected in `build.gradle.kts`.
+
+## Features
+
+- **Automatic change detection** between `build.gradle.kts` and the latest version in `CHANGELOG.md`:
+ - 🆕 **New dependencies**: automatically detected
+ - ❌ **Removed dependencies**: displayed in their original category with their last version
+ - 🟢 **Patch**: x.y.z → x.y.(z+n)
+ - 🔵 **Minor**: x.y.z → x.(y+n).z
+ - 🔴 **Major**: x.y.z → (x+n).y.z
+- Grouping of KMP libraries (a single line for the base library)
+- Preservation of the dependencies' order as defined in `build.gradle.kts`
+- **Automatic update of reference links**:
+ - Version links `[Unreleased]` and `[version]`
+ - Automatic addition of links for new dependencies (from organization eclipse-keypop or eclipse-keyple)
+ - Automatic removal of links for dependencies that have been dropped
+- Compliance with the "Keep a Changelog" format
+
+-----
+
+## Prerequisites
+
+- Python 3.7 or higher
+- The `build.gradle.kts` and `CHANGELOG.md` files must be located at the project root
+
+-----
+
+## Usage
+
+**Important**: The script must be executed from the project root directory.
+
+### Basic Usage (Current Date)
+
+```bash
+# On Windows
+.\tools\update_changelog.bat
+
+# On Linux/Mac
+./tools/update_changelog.sh
+
+# With Python directly (all systems)
+python tools/update_changelog.py
+```
+
+This command uses the current date in the format `YYYY.MM.DD` as the version number.
+
+### Specifying a Custom Date
+
+```bash
+# On Windows
+.\tools\update_changelog.bat 2025.10.30
+
+# On Linux/Mac
+./tools/update_changelog.sh 2025.10.30
+
+# With Python directly (all systems)
+python tools/update_changelog.py 2025.10.30
+```
+
+The date format must be `YYYY.MM.DD`.
+
+-----
+
+## Behavior
+
+1. **Dependency Parsing**: The script analyzes `build.gradle.kts` and extracts all dependencies while preserving their order
+2. **Comparison**: It compares these versions with those in the latest section of `CHANGELOG.md`
+3. **Change Detection**:
+ - If no changes are detected: displays a message and makes no modification
+ - If changes are detected: creates a new section with the appropriate statuses
+4. **Update**: Adds the new section after `[Unreleased]` and updates the reference links
+
+-----
+
+## Examples
+
+### Example 1: No Changes
+
+```bash
+$ python tools/update_changelog.py
+Updating CHANGELOG.md for version 2025.10.23...
+Found 23 dependencies in build.gradle.kts
+Latest CHANGELOG version: 2025.10.23
+No changes detected between build.gradle.kts and the latest CHANGELOG version.
+```
+
+### Example 2: With Changes
+
+```bash
+$ python tools/update_changelog.py 2025.10.30
+Updating CHANGELOG.md for version 2025.10.30...
+Found 23 dependencies in build.gradle.kts
+Latest CHANGELOG version: 2025.10.23
+CHANGELOG.md updated successfully with version 2025.10.30
+```
+
+The `CHANGELOG.md` file will be updated with:
+
+- A new section `## [2025.10.30]`
+- The statuses 🟢/🔵/🔴 for each modified component
+- The "Prev. Version" column showing the old version
+- Updated links:
+ ```
+ [Unreleased]: https://github.com/eclipse-keyple/keyple-java-bom/compare/2025.10.30...HEAD
+ [2025.10.30]: https://github.com/eclipse-keyple/keyple-java-bom/compare/2025.10.23...2025.10.30
+ ```
+
+-----
+
+## Generated Structure
+
+The new section follows this format:
+
+```markdown
+## [2025.10.30]
+
+| Component | Version | Status | Prev.
Version |
+|:-----------------------------------|:-------:|:------:|:----------------:|
+| **Keypop Dependencies** | | | |
+| [keypop-reader-java-api] | `2.0.1` | | |
+| ... | | | |
+| | | | |
+| **Keyple Core** | | | |
+| [keyple-common-java-api] | `2.0.2` | | |
+| [keyple-service-java-lib] | `3.4.0` | 🔵 | `3.3.6` |
+| [keyple-service-resource-java-lib] | `4.0.0` | 🔴 | `3.1.0` |
+| [keyple-test-java-lib] | `1.0.0` | 🆕 | |
+| [keyple-util-java-lib] | `2.4.0` | ❌ | |
+| ... | | | |
+```
+
+In this example:
+
+- `keyple-service-java-lib`: Minor change (3.3.6 → 3.4.0) marked 🔵
+- `keyple-service-resource-java-lib`: Major change (3.1.0 → 4.0.0) marked 🔴
+- `keyple-test-java-lib`: New dependency marked 🆕
+- `keyple-util-java-lib`: Removed dependency marked ❌
+
+### Reference Links Update
+
+The script also automatically updates the links at the bottom of the file:
+
+```markdown
+[Unreleased]: https://github.com/eclipse-keyple/keyple-java-bom/compare/2025.10.30...HEAD
+[2025.10.30]: https://github.com/eclipse-keyple/keyple-java-bom/compare/2025.10.23...2025.10.30
+
+[keypop-reader-java-api]: https://github.com/eclipse-keypop/keypop-reader-java-api/releases
+...
+[keyple-test-java-lib]: https://github.com/eclipse-keyple/keyple-test-java-lib/releases
+...
+```
+
+- **New dependencies**: the link is automatically added in the appropriate section (eclipse-keypop or eclipse-keyple)
+- **Removed dependencies**: the link is automatically removed
+
+-----
+
+## Notes
+
+- The script respects the exact order of dependencies as defined in `build.gradle.kts`
+- KMP libraries with variants (-jvm, -android, -iosarm64, etc.) are grouped into a single line
+- Only components whose version has changed will have a status and a value in "Prev. Version"
+- New dependencies (🆕) and version changes (🟢🔵🔴) appear in the order of the `build.gradle.kts` file
+- Removed dependencies (❌) appear after the current dependencies of their original category
+- If an entire category is removed, it appears at the end of the table with its dependencies marked ❌
\ No newline at end of file
diff --git a/tools/update_changelog.bat b/tools/update_changelog.bat
new file mode 100644
index 0000000..56de830
--- /dev/null
+++ b/tools/update_changelog.bat
@@ -0,0 +1,15 @@
+@echo off
+REM Script to update CHANGELOG.md
+REM Usage: update_changelog.bat [YYYY.MM.DD]
+
+REM Save current directory
+set CURRENT_DIR=%CD%
+
+REM Move to project root directory (parent of tools folder)
+cd /d "%~dp0.."
+
+REM Execute Python script from tools folder
+python "%~dp0update_changelog.py" %*
+
+REM Restore current directory
+cd /d "%CURRENT_DIR%"
diff --git a/tools/update_changelog.py b/tools/update_changelog.py
new file mode 100644
index 0000000..66a696f
--- /dev/null
+++ b/tools/update_changelog.py
@@ -0,0 +1,587 @@
+#!/usr/bin/env python3
+"""
+Script to automatically update CHANGELOG.md from build.gradle.kts changes.
+
+Usage:
+ python update_changelog.py [YYYY.MM.DD]
+
+If no date is provided, uses today's date.
+"""
+
+import re
+import sys
+from datetime import date
+from typing import Dict, List, Tuple, Optional
+from dataclasses import dataclass
+
+
+@dataclass
+class Dependency:
+ """Represents a dependency with its metadata."""
+ artifact_id: str
+ version: str
+ category: str
+ group_id: str
+
+
+@dataclass
+class ChangelogEntry:
+ """Represents an entry in the changelog."""
+ name: str
+ version: str
+ status: str
+ prev_version: str
+ category: str = ""
+
+
+class BuildGradleParser:
+ """Parser for build.gradle.kts file."""
+
+ CATEGORY_MAPPING = {
+ "Keypop": "**Keypop Dependencies**",
+ "Keyple core": "**Keyple Core**",
+ "Keyple distributed": "**Keyple Distributed**",
+ "Keyple interop": "**Keyple Interop**",
+ "Keyple card extensions": "**Keyple Card Extensions**",
+ "Keyple reader plugins": "**Keyple Reader Plugins**",
+ }
+
+ # Patterns for KMP library variants to exclude from individual listing
+ KMP_VARIANT_SUFFIXES = [
+ "-jvm", "-android", "-iosarm64", "-iossimulatorarm64", "-iosx64"
+ ]
+
+ def __init__(self, filepath: str):
+ self.filepath = filepath
+ self.dependencies: List[Dependency] = []
+
+ def parse(self) -> List[Dependency]:
+ """Parse the build.gradle.kts file and extract dependencies."""
+ with open(self.filepath, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ current_category = None
+ dependencies_by_base = {} # Track base libraries for KMP grouping
+
+ # Extract the dependencies block
+ constraints_match = re.search(r'constraints\s*\{(.*?)\}', content, re.DOTALL)
+ if not constraints_match:
+ return []
+
+ constraints_block = constraints_match.group(1)
+ lines = constraints_block.split('\n')
+
+ for line in lines:
+ line = line.strip()
+
+ # Check for category comment
+ if line.startswith('//'):
+ category_name = line.lstrip('/').strip()
+ current_category = self.CATEGORY_MAPPING.get(category_name, category_name)
+ continue
+
+ # Parse api() lines
+ api_match = re.match(r'api\("([^:]+):([^:]+):([^"]+)"\)', line)
+ if api_match and current_category:
+ group_id = api_match.group(1)
+ artifact_id = api_match.group(2)
+ version = api_match.group(3)
+
+ # Check if this is a KMP variant
+ base_artifact = artifact_id
+ is_variant = False
+ for suffix in self.KMP_VARIANT_SUFFIXES:
+ if artifact_id.endswith(suffix):
+ base_artifact = artifact_id[:artifact_id.rfind(suffix)]
+ is_variant = True
+ break
+
+ # Only add the base library, not variants
+ if not is_variant:
+ dep = Dependency(
+ artifact_id=artifact_id,
+ version=version,
+ category=current_category,
+ group_id=group_id
+ )
+ dependencies_by_base[artifact_id] = dep
+ elif base_artifact not in dependencies_by_base:
+ # If we encounter a variant before the base, add the base
+ dep = Dependency(
+ artifact_id=base_artifact,
+ version=version,
+ category=current_category,
+ group_id=group_id
+ )
+ dependencies_by_base[base_artifact] = dep
+
+ # Preserve order by re-parsing and keeping only base libraries
+ result = []
+ current_category = None
+
+ for line in lines:
+ line = line.strip()
+
+ if line.startswith('//'):
+ category_name = line.lstrip('/').strip()
+ current_category = self.CATEGORY_MAPPING.get(category_name, category_name)
+ continue
+
+ api_match = re.match(r'api\("([^:]+):([^:]+):([^"]+)"\)', line)
+ if api_match and current_category:
+ artifact_id = api_match.group(2)
+
+ # Check if this is a base library (not a variant)
+ is_base = True
+ for suffix in self.KMP_VARIANT_SUFFIXES:
+ if artifact_id.endswith(suffix):
+ is_base = False
+ break
+
+ if is_base and artifact_id in dependencies_by_base:
+ result.append(dependencies_by_base[artifact_id])
+
+ return result
+
+
+class ChangelogParser:
+ """Parser for CHANGELOG.md file."""
+
+ def __init__(self, filepath: str):
+ self.filepath = filepath
+
+ def parse_latest_version(self) -> Tuple[Optional[str], Dict[str, ChangelogEntry]]:
+ """Parse the latest version section and return version number and entries."""
+ with open(self.filepath, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Find the [Unreleased] section first
+ unreleased_match = re.search(r'\n## \[Unreleased\]', content)
+ if not unreleased_match:
+ return None, {}
+
+ # Find the first version section after [Unreleased]
+ search_start = unreleased_match.end()
+ version_match = re.search(r'\n## \[(\d{4}\.\d{2}\.\d{2})\]', content[search_start:])
+ if not version_match:
+ return None, {}
+
+ version = version_match.group(1)
+
+ # Extract the section content until the next ## or end of file
+ section_start = search_start + version_match.end()
+ next_section = re.search(r'\n## \[', content[section_start:])
+ if next_section:
+ section_end = section_start + next_section.start()
+ else:
+ # Look for the reference links section
+ refs_match = re.search(r'\n\[Unreleased\]:', content[section_start:])
+ if refs_match:
+ section_end = section_start + refs_match.start()
+ else:
+ section_end = len(content)
+
+ section_content = content[section_start:section_end]
+
+ # Parse the table
+ entries = {}
+ lines = section_content.split('\n')
+ in_table = False
+ current_category = ""
+
+ for line in lines:
+ if line.startswith('|') and '---' in line:
+ in_table = True
+ continue
+
+ if in_table and line.startswith('|'):
+ # Parse table row
+ cells = [cell.strip() for cell in line.split('|')[1:-1]]
+ if len(cells) >= 2:
+ name = cells[0].strip()
+
+ # Check if this is a category header
+ if '**' in name:
+ current_category = name
+ continue
+
+ # Skip empty rows
+ if not name or not cells[1].strip():
+ continue
+
+ dep_version = cells[1].strip('`').strip()
+ status = cells[2].strip() if len(cells) > 2 else ''
+ prev_version = cells[3].strip('`').strip() if len(cells) > 3 else ''
+
+ # Extract artifact name from markdown link if present
+ link_match = re.match(r'\[([^\]]+)\]', name)
+ if link_match:
+ name = link_match.group(1)
+
+ if name and dep_version:
+ entries[name] = ChangelogEntry(name, dep_version, status, prev_version, current_category)
+
+ return version, entries
+
+
+class VersionComparator:
+ """Compare versions and determine change type."""
+
+ @staticmethod
+ def parse_version(version: str) -> Tuple[int, ...]:
+ """Parse a version string into a tuple of integers."""
+ try:
+ return tuple(int(x) for x in version.split('.'))
+ except (ValueError, AttributeError):
+ return (0,)
+
+ @staticmethod
+ def get_status(old_version: str, new_version: str) -> str:
+ """Determine status emoji based on semantic versioning."""
+ old = VersionComparator.parse_version(old_version)
+ new = VersionComparator.parse_version(new_version)
+
+ # Pad to same length
+ max_len = max(len(old), len(new))
+ old = old + (0,) * (max_len - len(old))
+ new = new + (0,) * (max_len - len(new))
+
+ if new == old:
+ return ""
+
+ # Check major version (first component)
+ if new[0] > old[0]:
+ return "🔴"
+
+ # Check minor version (second component)
+ if len(new) > 1 and len(old) > 1 and new[1] > old[1]:
+ return "🔵"
+
+ # Otherwise it's a patch
+ return "🟢"
+
+
+class ChangelogGenerator:
+ """Generate new changelog entries."""
+
+ def __init__(self, filepath: str):
+ self.filepath = filepath
+
+ def generate_new_section(
+ self,
+ new_version: str,
+ dependencies: List[Dependency],
+ old_entries: Dict[str, ChangelogEntry]
+ ) -> Tuple[str, bool]:
+ """Generate a new version section for the changelog."""
+ lines = [
+ f"## [{new_version}]",
+ "",
+ "| Component | Version | Status | Prev.
Version |",
+ "|:------------------------------------------------|:-------:|:------:|:----------------:|"
+ ]
+
+ has_changes = False
+ current_deps_names = set()
+
+ # Build a dict of current dependencies by category for easy lookup
+ deps_by_category = {}
+ for dep in dependencies:
+ if dep.category not in deps_by_category:
+ deps_by_category[dep.category] = []
+ deps_by_category[dep.category].append(dep)
+ current_deps_names.add(dep.artifact_id)
+
+ # Find removed dependencies organized by category
+ removed_by_category = {}
+ for name, old_entry in old_entries.items():
+ if name not in current_deps_names:
+ has_changes = True
+ if old_entry.category not in removed_by_category:
+ removed_by_category[old_entry.category] = []
+ removed_by_category[old_entry.category].append(old_entry)
+
+ # Process each category in order (from dependencies list)
+ current_category = None
+ for dep in dependencies:
+ # Add category header if changed
+ if dep.category != current_category:
+ if current_category is not None:
+ # Add removed dependencies from previous category
+ if current_category in removed_by_category:
+ for removed in removed_by_category[current_category]:
+ name = f"[{removed.name}]"
+ version = f"`{removed.version}`"
+ status = "❌"
+ prev_version = ""
+ line = f"| {name:<47} | {version:^7} | {status:^6} | {prev_version:^16} |"
+ lines.append(line)
+ lines.append("| | | | |")
+
+ current_category = dep.category
+ lines.append(f"| {current_category:<47} | | | |")
+
+ # Determine if there's a change
+ old_entry = old_entries.get(dep.artifact_id)
+ status = ""
+ prev_version = ""
+
+ if old_entry:
+ if old_entry.version != dep.version:
+ status = VersionComparator.get_status(old_entry.version, dep.version)
+ prev_version = f"`{old_entry.version}`"
+ has_changes = True
+ else:
+ # New dependency
+ status = "🆕"
+ has_changes = True
+
+ # Format the line
+ name = f"[{dep.artifact_id}]"
+ version = f"`{dep.version}`"
+
+ # Build the line with proper alignment
+ line = f"| {name:<47} | {version:^7} | {status:^6} | {prev_version:^16} |"
+ lines.append(line)
+
+ # Add removed dependencies from last category
+ if current_category and current_category in removed_by_category:
+ for removed in removed_by_category[current_category]:
+ name = f"[{removed.name}]"
+ version = f"`{removed.version}`"
+ status = "❌"
+ prev_version = ""
+ line = f"| {name:<47} | {version:^7} | {status:^6} | {prev_version:^16} |"
+ lines.append(line)
+
+ # Handle removed dependencies from categories that no longer exist
+ for category, removed_list in removed_by_category.items():
+ if category not in deps_by_category:
+ lines.append("| | | | |")
+ lines.append(f"| {category:<47} | | | |")
+ for removed in removed_list:
+ name = f"[{removed.name}]"
+ version = f"`{removed.version}`"
+ status = "❌"
+ prev_version = ""
+ line = f"| {name:<47} | {version:^7} | {status:^6} | {prev_version:^16} |"
+ lines.append(line)
+
+ return "\n".join(lines), has_changes
+
+ def update_changelog(
+ self,
+ new_version: str,
+ new_section: str,
+ has_changes: bool,
+ dependencies: List[Dependency],
+ old_entries: Dict[str, ChangelogEntry]
+ ) -> bool:
+ """Update the CHANGELOG.md file with the new section."""
+ if not has_changes:
+ print("No changes detected between build.gradle.kts and the latest CHANGELOG version.")
+ return False
+
+ with open(self.filepath, 'r', encoding='utf-8') as f:
+ content = f.read()
+
+ # Find the [Unreleased] section
+ unreleased_match = re.search(r'(## \[Unreleased\]\n)', content)
+ if not unreleased_match:
+ print("Error: Could not find [Unreleased] section in CHANGELOG.md")
+ return False
+
+ # Insert new section after [Unreleased]
+ insert_pos = unreleased_match.end()
+ new_content = (
+ content[:insert_pos] +
+ "\n" + new_section + "\n" +
+ content[insert_pos:]
+ )
+
+ # Update reference links
+ new_content = self.update_reference_links(new_content, new_version, dependencies, old_entries)
+
+ with open(self.filepath, 'w', encoding='utf-8') as f:
+ f.write(new_content)
+
+ print(f"CHANGELOG.md updated successfully with version {new_version}")
+ return True
+
+ def update_reference_links(
+ self,
+ content: str,
+ new_version: str,
+ dependencies: List[Dependency],
+ old_entries: Dict[str, ChangelogEntry]
+ ) -> str:
+ """Update the [Unreleased] and version reference links, and add/remove dependency links."""
+ # Find the reference links section
+ unreleased_link_match = re.search(
+ r'\[Unreleased\]: https://github\.com/([^/]+)/([^/]+)/compare/([\d.]+)\.\.\.HEAD',
+ content
+ )
+
+ if not unreleased_link_match:
+ return content
+
+ org = unreleased_link_match.group(1)
+ repo = unreleased_link_match.group(2)
+ old_version = unreleased_link_match.group(3)
+
+ # Update [Unreleased] link to point from new version to HEAD
+ new_unreleased_link = f"[Unreleased]: https://github.com/{org}/{repo}/compare/{new_version}...HEAD"
+ content = re.sub(
+ r'\[Unreleased\]: https://github\.com/[^/]+/[^/]+/compare/[\d.]+\.\.\.HEAD',
+ new_unreleased_link,
+ content
+ )
+
+ # Add new version link after [Unreleased] link
+ new_version_link = f"[{new_version}]: https://github.com/{org}/{repo}/compare/{old_version}...{new_version}"
+ content = re.sub(
+ r'(\[Unreleased\]: [^\n]+\n)',
+ f'\\1{new_version_link}\n',
+ content
+ )
+
+ # Find added and removed dependencies
+ current_deps = {dep.artifact_id for dep in dependencies}
+ old_deps = set(old_entries.keys())
+
+ added_deps = current_deps - old_deps
+ removed_deps = old_deps - current_deps
+
+ # First, remove links for removed dependencies
+ for dep_name in removed_deps:
+ content = re.sub(
+ rf'\n\[{re.escape(dep_name)}\]: https://github\.com/[^\n]+\n',
+ '\n',
+ content
+ )
+
+ # Extract all existing artifact links (keypop and keyple) into a dict
+ existing_links = {}
+ for match in re.finditer(r'\n\[((keypop|keyple)-[^\]]+)\]: ([^\n]+)\n', content):
+ artifact_id = match.group(1)
+ full_line = match.group(0)
+ existing_links[artifact_id] = full_line
+
+ # Build set of all dependencies we're managing
+ managed_deps = set(dep.artifact_id for dep in dependencies)
+
+ # Build the new ordered list of links based on dependencies order
+ # Group by category to add blank lines between categories
+ new_links_section = []
+ processed_deps = set()
+ previous_category = None
+
+ for dep in dependencies:
+ processed_deps.add(dep.artifact_id)
+
+ # Add blank line between categories
+ if previous_category and dep.category != previous_category:
+ new_links_section.append('')
+
+ if dep.artifact_id in existing_links:
+ # Use existing link (strip the leading \n if present)
+ link = existing_links[dep.artifact_id].lstrip('\n')
+ new_links_section.append(link.rstrip('\n'))
+ else:
+ # Create new link (either because it's a new dependency or the link is missing)
+ if dep.artifact_id.startswith('keypop-'):
+ org = 'eclipse-keypop'
+ elif dep.artifact_id.startswith('keyple-'):
+ org = 'eclipse-keyple'
+ else:
+ continue
+ new_link = f"[{dep.artifact_id}]: https://github.com/{org}/{dep.artifact_id}/releases"
+ new_links_section.append(new_link)
+
+ previous_category = dep.category
+
+ # Add any existing links that are not in current dependencies and not removed
+ # (these might be from older versions still referenced in the changelog)
+ orphan_links = []
+ for artifact_id, link_line in existing_links.items():
+ if artifact_id not in processed_deps and artifact_id not in removed_deps:
+ orphan_links.append(link_line.lstrip('\n').rstrip('\n'))
+
+ if orphan_links:
+ new_links_section.append('') # Blank line before orphan links
+ new_links_section.extend(orphan_links)
+
+ # Find the artifact links section boundaries
+ first_artifact_link = re.search(r'\n\[(keypop|keyple)-[^\]]+\]: ', content)
+ if first_artifact_link:
+ # Find where artifact links end (before the next section or end of file)
+ links_start = first_artifact_link.start()
+
+ # Find the end of all artifact links
+ last_artifact_link = None
+ for match in re.finditer(r'\n\[(keypop|keyple)-[^\]]+\]: [^\n]+\n', content):
+ last_artifact_link = match
+
+ if last_artifact_link:
+ links_end = last_artifact_link.end()
+
+ # Replace the entire artifact links section with the new ordered one
+ # Join with newlines and add a trailing newline
+ new_links_text = '\n' + '\n'.join(new_links_section) + '\n'
+ content = (
+ content[:links_start] +
+ new_links_text +
+ content[links_end:]
+ )
+
+ return content
+
+
+def main():
+ """Main entry point."""
+ # Determine version
+ if len(sys.argv) > 1:
+ new_version = sys.argv[1]
+ # Validate format
+ if not re.match(r'^\d{4}\.\d{2}\.\d{2}$', new_version):
+ print(f"Error: Invalid version format '{new_version}'. Expected YYYY.MM.DD")
+ sys.exit(1)
+ else:
+ today = date.today()
+ new_version = today.strftime("%Y.%m.%d")
+
+ print(f"Updating CHANGELOG.md for version {new_version}...")
+
+ # Parse build.gradle.kts
+ gradle_parser = BuildGradleParser("build.gradle.kts")
+ dependencies = gradle_parser.parse()
+ print(f"Found {len(dependencies)} dependencies in build.gradle.kts")
+
+ # Parse CHANGELOG.md
+ changelog_parser = ChangelogParser("CHANGELOG.md")
+ old_version, old_entries = changelog_parser.parse_latest_version()
+ if old_version:
+ print(f"Latest CHANGELOG version: {old_version}")
+ else:
+ print("No previous version found in CHANGELOG.md")
+ old_entries = {}
+
+ # Check if the target version already exists
+ if old_version == new_version:
+ print(f"Version {new_version} already exists in CHANGELOG.md. No changes needed.")
+ sys.exit(0)
+
+ # Generate new section
+ generator = ChangelogGenerator("CHANGELOG.md")
+ new_section, has_changes = generator.generate_new_section(
+ new_version, dependencies, old_entries
+ )
+
+ # Update changelog
+ success = generator.update_changelog(new_version, new_section, has_changes, dependencies, old_entries)
+
+ sys.exit(0 if success else 1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/update_changelog.sh b/tools/update_changelog.sh
new file mode 100644
index 0000000..3855b32
--- /dev/null
+++ b/tools/update_changelog.sh
@@ -0,0 +1,12 @@
+#!/bin/bash
+# Script to update CHANGELOG.md
+# Usage: update_changelog.sh [YYYY.MM.DD]
+
+# Get the directory where the script is located
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# Move to project root directory (parent of tools folder)
+cd "$SCRIPT_DIR/.."
+
+# Execute Python script
+python "$SCRIPT_DIR/update_changelog.py" "$@"