Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .github/workflows/check-same-version.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: ">=3.12" # required by same-version
cache: 'pip' # optional
python-version: ">=3.10" # required by same-version
cache: 'pip' # optional and only works for Python projects

- name: Run same-version
uses: willynilly/same-version@v4.0.0
uses: willynilly/same-version@v5.0.0
with:
fail_for_missing_file: false
check_github_event: true
Expand All @@ -46,6 +46,7 @@ jobs:
check_r_description: false
check_composer_json: false
check_pom_xml: false
check_nuspec: false
check_cargo_toml: false
check_ro_crate_metadata_json: false
check_py_version_assignment: false
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ keywords:
- metadata
- harmonization
license: Apache-2.0
version: "4.0.0"
version: "5.0.0"
date-released: "2025-06-10"
references:
- title: Citation File Format
Expand Down
31 changes: 18 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,11 +64,12 @@ This workflow runs after the tag or release exists and can report problems, but
- `composer.json` (PHP)
- `Cargo.toml` (Rust)
- `pom.xml` (Java)
- `.nuspec` (.NET/C#/NuGet)
- `DESCRIPTION` (R)
- `ro-crate-metadata.json` (RO-Crate)


✅ Cross-language support (e.g., Python, R, JS/TypeScript, Java, Rust, PHP)
✅ Cross-language support (e.g., Python, R, JS/TypeScript, Java, Rust, PHP, C#)

✅ Cross-standard support for FAIR and Open Science metadata (e.g., CFF, CodeMeta, RO-Crate, Zenodo)

Expand All @@ -92,22 +93,24 @@ This workflow runs after the tag or release exists and can report problems, but

These files are currently supported out-of-the-box:

| File | Parser used |
|------------------|-------------|
| File | Initial Version Format (translates to PEP 440 for comparison) |
|------------------|-------------------|
| `CITATION.cff` | PEP 440 |
| `pyproject.toml` | PEP 440 |
| `setup.py` | PEP 440 |
| `package.json` | Strict SemVer (converted from canonical PEP 440 tag) |
| `package.json` | Strict SemVer |
| `codemeta.json` | PEP 440 |
| `.zenodo.json` | PEP 440 |
| `composer.json` | PEP 440 |
| `Cargo.toml` | PEP 440 |
| `pom.xml` | PEP 440 |
| `.nuspec` | Strict SemVer |
| R `DESCRIPTION` file | PEP 440 |
| Python file with `__version__` assignment | PEP 440 |
| `ro-crate-metadata.json` | PEP 440 |


Note SemVer allows arbitrary pre-releases while PEP 440 only allows 3 kinds (a, b, rc).
During conversion,

---

Expand Down Expand Up @@ -146,6 +149,8 @@ These files are currently supported out-of-the-box:
| `--r-description-path` | `r_description_path` | Path to R `DESCRIPTION` file | No | `DESCRIPTION` |
| `--check-pom-xml` | `check_pom_xml` | Check `pom.xml`? (`true/false`) | No | `true` |
| `--pom-xml-path` | `pom_xml_path` | Path to `pom.xml` | No | `pom.xml` |
| `--check-nuspec` | `check_nu_spec` | Check `.nuspec`? (`true/false`) | No | `true` |
| `--nuspec-path` | `nuspec_path` | Path to `.nuspec` | No | `.nuspec` |



Expand Down Expand Up @@ -200,11 +205,10 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: ">=3.12"
cache: 'pip'
python-version: ">=3.10"

- name: Run same-version
uses: willynilly/same-version@v4.0.0
uses: willynilly/same-version@v5.0.0
with:
fail_for_missing_file: false
check_github_event: true
Expand All @@ -220,6 +224,7 @@ jobs:
check_cargo_toml: false
check_py_version_assignment: false
check_pom_xml: false
check_nuspec: false
check_composer_json: false
check_ro_crate_metadata_json: false
```
Expand Down Expand Up @@ -249,11 +254,10 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: ">=3.12"
cache: 'pip'
python-version: ">=3.10"

- name: Run same-version
uses: willynilly/same-version@v4.0.0
uses: willynilly/same-version@v5.0.0
with:
fail_for_missing_file: false
check_github_event: true
Expand All @@ -269,6 +273,7 @@ jobs:
check_cargo_toml: false
check_py_version_assignment: false
check_pom_xml: false
check_nuspec: false
check_composer_json: false
check_ro_crate_metadata_json: false
```
Expand All @@ -289,7 +294,7 @@ Add to your `.pre-commit-config.yaml`:
```yaml
repos:
- repo: https://github.com/willynilly/same-version
rev: v4.0.0 # Use latest tag
rev: v5.0.0 # Use latest tag
hooks:
- id: same-version
stages: [pre-commit, pre-push]
Expand Down Expand Up @@ -357,7 +362,7 @@ To set up your development environment:
```bash
git clone https://github.com/willynilly/same-version.git
cd same-version
pip install -e .
pip install -e .[testing,dev]
pre-commit install -t pre-commit -t pre-push
pre-commit run --all-files
```
Expand Down
2 changes: 1 addition & 1 deletion example.pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
repos:
- repo: https://github.com/willynilly/same-version
rev: v4.0.0 # Use latest tag
rev: v5.0.0 # Use latest tag
hooks:
- id: same-version
stages: [pre-commit, pre-push]
9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ build-backend = "hatchling.build"

[project]
name = "same-version"
version = "4.0.0"
version = "5.0.0"
description = "Automatically ensures your software version metadata is consistent across key project files."
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.10"
license = "Apache-2.0"
license-files = ["LICEN[CS]E*"]
keywords = ["open science", "FAIR", "version", "software quality", "DevOps", "CI/CD", "GitHub Action", "pyproject.toml", "package.json", "setup.py", "codemeta.json", ".zenodo.json", "Zenodo", "CodeMeta", "CITATION.cff", "CFF", "citation", "metadata", "harmonization"]
authors = [
{ name = "Will Riley", email = "wanderingwill@gmail.com" },
Expand All @@ -31,7 +30,7 @@ classifiers = [
"Topic :: Software Development",
"Topic :: Utilities"
]
dependencies = ["pyyaml>=6.0.2", "tomli>=2.2.1", "packaging>=25.0", "semver>=3.0.4"]
dependencies = ["pyyaml>=6.0.2", "tomli>=2.2.1", "verple>=1.0.0"]

[project.urls]
Homepage = "https://github.com/willynilly/same-version"
Expand All @@ -45,6 +44,7 @@ testing = [
]
dev = [
"ruff>=0.11.12",
"pre-commit>=4.2.0"
]

[tool.pytest.ini_options]
Expand All @@ -54,6 +54,7 @@ pythonpath = [

[tool.hatch.build]
include = ["src/same_version/**", "CITATION.cff"]
license-files = ["LICEN[CS]E*"]

[tool.hatch.build.targets.wheel]
packages = ["src/same_version"]
Expand Down
27 changes: 15 additions & 12 deletions src/same_version/checkers/checker.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import logging
from argparse import Namespace

import packaging.version
from verple.verple import Verple

from same_version.extractors.extractor import Extractor
from same_version.utils import parse_version_pep440

logger = logging.getLogger(__name__)

Expand All @@ -14,18 +13,23 @@ def __init__(self, extractor: Extractor, cli_args: Namespace):
self.extractor = extractor
self.cli_args = cli_args
self._extracted_version: str | None = self.extractor.extract_version()

def create_pep440_version(self, version_str: str | None) -> packaging.version.Version | None:
version_pep440 : packaging.version.Version | None = parse_version_pep440(version_str)
return version_pep440
self._verple_version: Verple | None = self.create_verple_version(self._extracted_version)

def create_verple_version(self, version_str: str | None) -> Verple | None:
if version_str is None:
return None
try:
return Verple.parse(version_str)
except ValueError:
return None

@property
def target_version_str(self) -> str | None:
return self._extracted_version

@property
def target_version_pep440(self) -> packaging.version.Version | None:
return self.create_pep440_version(version_str=self.target_version_str)
def target_version(self) -> Verple | None:
return self._verple_version

@property
def target_exists(self) -> bool:
Expand All @@ -40,12 +44,11 @@ def target_cli_parameter_name(self) -> str | None:
return self.extractor.target_cli_parameter_name

def check(self, base_version_str: str | None) -> bool:
if not base_version_str:
if base_version_str is None:
return True
base_version_pep440 = self.create_pep440_version(version_str=base_version_str)
base_version: Verple | None = self.create_verple_version(version_str=base_version_str)
if self.target_exists:

if base_version_pep440 != self.target_version_pep440:
if base_version != self.target_version:
self._log_version_mismatch(base_version_str=base_version_str)
return False
else:
Expand Down
13 changes: 13 additions & 0 deletions src/same_version/checkers/nuspec_checker.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import logging
from argparse import Namespace

from same_version.checkers.file_checker import FileChecker
from same_version.extractors.nuspec_extractor import NuspecExtractor

logger = logging.getLogger(__name__)


class NuspecChecker(FileChecker):

def __init__(self, extractor: NuspecExtractor, cli_args: Namespace):
super().__init__(extractor=extractor, cli_args=cli_args)
38 changes: 5 additions & 33 deletions src/same_version/checkers/package_json_checker.py
Original file line number Diff line number Diff line change
@@ -1,40 +1,12 @@
import logging
from argparse import Namespace

from same_version.checkers.file_checker import FileChecker
from same_version.utils import parse_version_semver
from same_version.extractors.package_json_extractor import PackageJsonExtractor

logger = logging.getLogger(__name__)

class PackageJsonChecker(FileChecker):

def check(self, base_version_str: str | None) -> bool:
if base_version_str is None:
return True
base_version_pep440 = self.create_pep440_version(version_str=base_version_str)
if base_version_pep440 is None:
return True

if self.target_exists:
target_version_str = self.target_version_str
target_name = self.target_name

try:
base_version_semver_str: str = f"{base_version_pep440.major}.{base_version_pep440.minor}.{base_version_pep440.micro}"
if base_version_pep440.is_prerelease and base_version_pep440.pre is not None:
base_version_semver_str += f"-{base_version_pep440.pre[0]}.{base_version_pep440.pre[1]}"

base_version_semver = parse_version_semver(base_version_semver_str)
target_version_semver = parse_version_semver(target_version_str)

if base_version_semver != target_version_semver:
logger.error(f"❌ Version mismatch: {target_name} {target_version_str} != base version {base_version_str}")
return False
else:
return True

except Exception as e:
logger.error(f"❌ Parse error: Could not parse tag version as SemVer for {self.target_name}: {e}")
return False
else:
self._log_missing_target()
return False

def __init__(self, extractor: PackageJsonExtractor, cli_args: Namespace):
super().__init__(extractor=extractor, cli_args=cli_args)
15 changes: 12 additions & 3 deletions src/same_version/extractors/extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ class Extractor:
def __init__(self):
pass

def _should_have_version_in_data(self, data: dict) -> bool:
return True

def _get_data(self) -> dict:
raise NotImplementedError()

Expand All @@ -15,6 +18,9 @@ def _get_version_from_data(self, data: dict) -> str | None:

def _log_missing_version_error(self, data: dict):
logger.error(f"❌ {self.target_name} missing version metadata")

def _log_should_not_have_version_error(self, version: str, data: dict):
logger.error(f"❌ {self.target_name} should not have version metadata, but has version: {version}")

def _log_found_version_info(self, version: str, data: dict):
logger.info(f"📖 {self.target_name} version: {version}")
Expand All @@ -23,12 +29,15 @@ def extract_version(self) -> str | None:
data: dict = self._get_data()
version = self._get_version_from_data(data=data)
if version is None:
if self.target_exists:
if self.target_exists and self._should_have_version_in_data(data=data):
# only print this error if the target exists
self._log_missing_version_error(data=data)
return None
self._log_found_version_info(version=version, data=data)
return version
elif not self._should_have_version_in_data(data=data):
self._log_should_not_have_version_error(version=version, data=data)
else:
self._log_found_version_info(version=version, data=data)
return version

@property
def target_exists(self) -> bool:
Expand Down
9 changes: 8 additions & 1 deletion src/same_version/extractors/github_event_extractor.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ def target_name(self) -> str | None:
def target_cli_parameter_name(self) -> str | None:
return '--github-event-name'

def _should_have_version_in_data(self, data: dict):
# only the push and release have version metadata
# the pull request does not and should not.
event_name: str | None = data.get('github_event_name', None)
return event_name == 'push' or event_name == 'release'


def _get_data(self) -> dict[str, str | None]:
return self._data

Expand Down Expand Up @@ -55,7 +62,7 @@ def _get_version_from_data(self, data: dict) -> str | None:
logger.info(f"📦 Detected GitHub release tag version: {version}")
return version
elif event_name == 'pull_request':
logger.info("📦 Detected GitHub pull request")
logger.info("📦 Detected GitHub pull request. No version expected.")
return None
else:
logger.error(f"❌ Unsupported GitHub event: {event_name}")
Expand Down
Loading
Loading