jrl-cmakemodules-scripts 2.1.0__tar.gz → 2.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/PKG-INFO +1 -1
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/PKG-INFO +1 -1
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_release.py +724 -72
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/pyproject.toml +1 -1
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/README.md +0 -0
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/SOURCES.txt +0 -0
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/dependency_links.txt +0 -0
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/entry_points.txt +0 -0
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/requires.txt +0 -0
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/jrl_cmakemodules_scripts.egg-info/top_level.txt +0 -0
- {jrl_cmakemodules_scripts-2.1.0 → jrl_cmakemodules_scripts-2.2.4}/setup.cfg +0 -0
|
@@ -57,6 +57,10 @@ uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
|
|
|
57
57
|
| `--git-tag [NAME]` | Create a tag. Optional name (`{version}` placeholder). |
|
|
58
58
|
| `--git-tag-message <MSG>` | Tag annotation (`{version}` placeholder). |
|
|
59
59
|
| `--sign-tag` | GPG-sign the tag (`git tag -s`). Needs a configured signing key. |
|
|
60
|
+
| `--push-tag` | Push the tag to the main repository. Only works with github for now. |
|
|
61
|
+
| `--git-archive` | Create a .tar.gz archive. |
|
|
62
|
+
| `--sign-archive` | GPG-sign the archive. Needs a configured signing key. |
|
|
63
|
+
| `--gh-release` | Create Github release. Needs a configured gh cli. |
|
|
60
64
|
|
|
61
65
|
**Git defaults**: commit `chore: bump version to {version}`, tag `v{version}`, tag message `Release version {version}`.
|
|
62
66
|
|
|
@@ -64,19 +68,34 @@ uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
|
|
|
64
68
|
|
|
65
69
|
| File | Key |
|
|
66
70
|
| :--- | :--- |
|
|
67
|
-
| `package.xml` | `<version>` tag |
|
|
68
|
-
| `pyproject.toml` | `project.version` |
|
|
71
|
+
| `package.xml` | `<version> and <url>` tag |
|
|
72
|
+
| `pyproject.toml` | `project.version` and `project.urls` |
|
|
69
73
|
| `CHANGELOG.md` | First `## [X.Y.Z]` section (not Unreleased) |
|
|
70
74
|
| `pixi.toml` | `[workspace] version` |
|
|
71
75
|
| `pixi.lock` | Regenerated via `pixi list` |
|
|
72
76
|
| `CITATION.cff` | `version` key |
|
|
73
|
-
| `CMakeLists.txt` | `project(... VERSION X.Y.Z ...)` |
|
|
77
|
+
| `CMakeLists.txt` | `project(... VERSION X.Y.Z ... HOMEPAGE_URL ...)` |
|
|
78
|
+
| `debian/changelog` | `version = ...` |
|
|
79
|
+
| `conanfile.py` | `version = ...` |
|
|
74
80
|
|
|
75
81
|
> Requires `pixi` CLI if `pixi.lock` exists in the project root.
|
|
82
|
+
|
|
83
|
+
## Meta-packages
|
|
84
|
+
|
|
85
|
+
ROS meta-packages are handled automatically: every nested directory with a
|
|
86
|
+
`package.xml` is treated like the repository root — the full set of
|
|
87
|
+
supported files (`package.xml`, `pyproject.toml`, `CHANGELOG.md`, `pixi.toml`,
|
|
88
|
+
`CITATION.cff`, `CMakeLists.txt`, `debian/changelog`) is checked there too,
|
|
89
|
+
skipping any that don't exist in that nested directory.
|
|
90
|
+
Discovery skips hidden directories (`.git`, `.pixi`, `.venv`, …), git submodules,
|
|
91
|
+
and — honoring your `.gitignore` via `git check-ignore` — ignored dirs like
|
|
92
|
+
`build/` or `dist/`. All discovered files must agree on a single
|
|
93
|
+
version to bump version.
|
|
76
94
|
"""
|
|
77
95
|
|
|
78
96
|
import sys
|
|
79
97
|
import re
|
|
98
|
+
import os
|
|
80
99
|
import argparse
|
|
81
100
|
import datetime
|
|
82
101
|
import json
|
|
@@ -122,6 +141,8 @@ STYLE_NEW_VALUE = "green"
|
|
|
122
141
|
STYLE_UNCHANGED_VALUE = "dim"
|
|
123
142
|
STYLE_HIGHLIGHT = "cyan"
|
|
124
143
|
|
|
144
|
+
GITHUB_URL = "https://github.com/"
|
|
145
|
+
|
|
125
146
|
|
|
126
147
|
class VersionNotPresent(Exception):
|
|
127
148
|
"""Raised when a file exists but has no version field configured."""
|
|
@@ -132,6 +153,8 @@ class VersionNotPresent(Exception):
|
|
|
132
153
|
class VersionExtractor(ABC):
|
|
133
154
|
def __init__(self, file_path: Path):
|
|
134
155
|
self.file_path = file_path
|
|
156
|
+
# Display label; discovery overrides it with a root-relative path.
|
|
157
|
+
self.label = file_path.name
|
|
135
158
|
|
|
136
159
|
@abstractmethod
|
|
137
160
|
def get_version(self) -> str:
|
|
@@ -152,6 +175,17 @@ class VersionExtractor(ABC):
|
|
|
152
175
|
def path(self) -> str:
|
|
153
176
|
return str(self.file_path)
|
|
154
177
|
|
|
178
|
+
def get_url(self) -> str | None:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
def validate_url(self, url: str) -> str | None:
|
|
182
|
+
if match := re.match(GITHUB_URL + r"[^/]+/[^/]+", url.strip("/").lower()):
|
|
183
|
+
valid_url = match.group()
|
|
184
|
+
console.print(
|
|
185
|
+
f"[{STYLE_INFO}]Found url {valid_url} in '{self.file_path}'[/{STYLE_INFO}]"
|
|
186
|
+
)
|
|
187
|
+
return valid_url
|
|
188
|
+
|
|
155
189
|
|
|
156
190
|
class XmlVersionExtractor(VersionExtractor):
|
|
157
191
|
def get_version(self) -> str:
|
|
@@ -178,6 +212,14 @@ class XmlVersionExtractor(VersionExtractor):
|
|
|
178
212
|
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
179
213
|
f.write(new_content)
|
|
180
214
|
|
|
215
|
+
def get_url(self) -> str | None:
|
|
216
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
217
|
+
content = f.read()
|
|
218
|
+
for url in re.findall(r"<url[^>]*>(.*?)</url>", content, re.I):
|
|
219
|
+
if valid_url := self.validate_url(url):
|
|
220
|
+
return valid_url
|
|
221
|
+
return None
|
|
222
|
+
|
|
181
223
|
|
|
182
224
|
class TomlVersionExtractor(VersionExtractor):
|
|
183
225
|
def __init__(self, file_path: Path, keys: List[str]):
|
|
@@ -216,6 +258,15 @@ class TomlVersionExtractor(VersionExtractor):
|
|
|
216
258
|
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
217
259
|
tomlkit.dump(data, f)
|
|
218
260
|
|
|
261
|
+
def get_url(self) -> str | None:
|
|
262
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
263
|
+
data = tomlkit.load(f)
|
|
264
|
+
if "project" in data and "urls" in data["project"]:
|
|
265
|
+
for url in data["project"]["urls"].values():
|
|
266
|
+
if valid_url := self.validate_url(url):
|
|
267
|
+
return valid_url
|
|
268
|
+
return None
|
|
269
|
+
|
|
219
270
|
|
|
220
271
|
class YamlVersionExtractor(VersionExtractor):
|
|
221
272
|
def __init__(self, file_path: Path, keys: List[str]):
|
|
@@ -253,6 +304,24 @@ class YamlVersionExtractor(VersionExtractor):
|
|
|
253
304
|
self.yaml.dump(data, f)
|
|
254
305
|
|
|
255
306
|
|
|
307
|
+
class ConanfileVersionExtractor(VersionExtractor):
|
|
308
|
+
def get_version(self) -> str:
|
|
309
|
+
content = self.file_path.read_text()
|
|
310
|
+
if match := re.search(r'\s*version\s*=\s*["\']([^"\']+)["\']', content):
|
|
311
|
+
return match.group(1)
|
|
312
|
+
raise VersionNotPresent(f"Version not found in {self.name}")
|
|
313
|
+
|
|
314
|
+
def update_version(self, new_version: str) -> None:
|
|
315
|
+
pattern = re.compile(r'(\s*version\s*=\s*["\'])([^"\']+)(["\'])')
|
|
316
|
+
content = self.file_path.read_text()
|
|
317
|
+
if not pattern.search(content):
|
|
318
|
+
raise VersionNotPresent(f"Version not found in {self.name}")
|
|
319
|
+
new_content, count = pattern.subn(
|
|
320
|
+
lambda m: f"{m.group(1)}{new_version}{m.group(3)}", content, count=1
|
|
321
|
+
)
|
|
322
|
+
self.file_path.write_text(new_content)
|
|
323
|
+
|
|
324
|
+
|
|
256
325
|
class CMakeListsVersionExtractor(VersionExtractor):
|
|
257
326
|
"""Specialized extractor for CMakeLists.txt that uses cmake-parser
|
|
258
327
|
and handles both direct VERSION and variables (e.g., from package.xml)."""
|
|
@@ -288,7 +357,7 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
288
357
|
ver = args[version_idx + 1]
|
|
289
358
|
# Check if it's a variable reference
|
|
290
359
|
if not ver.startswith("${"):
|
|
291
|
-
project_version = ver
|
|
360
|
+
project_version = ver.strip('"')
|
|
292
361
|
except ValueError:
|
|
293
362
|
pass
|
|
294
363
|
|
|
@@ -305,6 +374,13 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
305
374
|
|
|
306
375
|
return self._get_version_regex(content)
|
|
307
376
|
|
|
377
|
+
def get_url(self) -> str | None:
|
|
378
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
379
|
+
content = f.read()
|
|
380
|
+
if match := re.search(r'HOMEPAGE_URL\s+"([^"]+)"', content):
|
|
381
|
+
return self.validate_url(match.group(1))
|
|
382
|
+
return None
|
|
383
|
+
|
|
308
384
|
def _get_command_args(self, node) -> List[str]:
|
|
309
385
|
"""Extract arguments from a cmake command node."""
|
|
310
386
|
args = []
|
|
@@ -316,16 +392,16 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
316
392
|
|
|
317
393
|
def _get_version_regex(self, content: str) -> str:
|
|
318
394
|
"""Fallback regex-based version extraction."""
|
|
319
|
-
#
|
|
395
|
+
# Finds set(PROJECT_VERSION "X.Y.Z") or set(PROJECT_VERSION X.Y.Z)
|
|
320
396
|
fallback_pattern = re.compile(
|
|
321
|
-
r'set\s*\(\s*PROJECT_VERSION\s+"([0-9]+\.[0-9]+\.[0-9]+)"',
|
|
397
|
+
r'set\s*\(\s*PROJECT_VERSION\s+"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*\)',
|
|
322
398
|
re.MULTILINE,
|
|
323
399
|
)
|
|
324
400
|
fallback_match = fallback_pattern.search(content)
|
|
325
401
|
|
|
326
402
|
# Also check if project() uses a literal version or variable
|
|
327
403
|
project_pattern = re.compile(
|
|
328
|
-
r
|
|
404
|
+
r'project\s*\([^)]*VERSION\s+"?([\d.]+|\$\{[^}]+\})"?', re.MULTILINE
|
|
329
405
|
)
|
|
330
406
|
project_match = project_pattern.search(content)
|
|
331
407
|
|
|
@@ -347,20 +423,22 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
347
423
|
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
348
424
|
content = f.read()
|
|
349
425
|
|
|
350
|
-
# Update
|
|
426
|
+
# 1. Update fallback version: matches set(PROJECT_VERSION 1.2.3) or set(PROJECT_VERSION "1.2.3")
|
|
427
|
+
# Always replaces it without quotes: set(PROJECT_VERSION 1.2.3)
|
|
351
428
|
fallback_pattern = re.compile(
|
|
352
|
-
r'(set\s*\(\s*PROJECT_VERSION\s+)"([0-9]+\.[0-9]+\.[0-9]+)"',
|
|
429
|
+
r'(set\s*\(\s*PROJECT_VERSION\s+)"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*\)',
|
|
353
430
|
re.MULTILINE,
|
|
354
431
|
)
|
|
355
432
|
|
|
356
433
|
def repl_fallback(match):
|
|
357
|
-
return f
|
|
434
|
+
return f"{match.group(1)}{new_version})"
|
|
358
435
|
|
|
359
436
|
content = fallback_pattern.sub(repl_fallback, content, count=1)
|
|
360
437
|
|
|
361
|
-
#
|
|
438
|
+
# 2. Update literal version in project(): matches VERSION 1.2.3 or VERSION "1.2.3"
|
|
439
|
+
# Always replaces it without quotes: VERSION 1.2.3
|
|
362
440
|
project_pattern = re.compile(
|
|
363
|
-
r
|
|
441
|
+
r'(project\s*\([^)]*VERSION\s+)"?([\d.]+)"?', re.MULTILINE
|
|
364
442
|
)
|
|
365
443
|
|
|
366
444
|
def repl_project(match):
|
|
@@ -372,6 +450,114 @@ class CMakeListsVersionExtractor(VersionExtractor):
|
|
|
372
450
|
f.write(content)
|
|
373
451
|
|
|
374
452
|
|
|
453
|
+
class DebianChangelogVersionExtractor(VersionExtractor):
|
|
454
|
+
"""Specialized extractor for debian/changelog files.
|
|
455
|
+
|
|
456
|
+
Expects standard format: package (version) distribution; urgency=...
|
|
457
|
+
"""
|
|
458
|
+
|
|
459
|
+
@property
|
|
460
|
+
def name(self) -> str:
|
|
461
|
+
return "debian/changelog"
|
|
462
|
+
|
|
463
|
+
def get_version(self) -> str:
|
|
464
|
+
"""Extracts and returns the clean upstream version string (omitting the Debian suffix)."""
|
|
465
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
466
|
+
first_line = f.readline()
|
|
467
|
+
|
|
468
|
+
if not first_line:
|
|
469
|
+
raise VersionNotPresent(f"Changelog file {self.name} is empty")
|
|
470
|
+
|
|
471
|
+
match = re.match(r"^[a-zA-Z0-9.+_-]+\s+\(([^)]+)\)", first_line)
|
|
472
|
+
if match:
|
|
473
|
+
full_version = match.group(1)
|
|
474
|
+
# Splits on '-' or '~' to strip the packaging revision (e.g., '1.3.3-1debian1' -> '1.3.3')
|
|
475
|
+
return re.split(r"[-~]", full_version)[0]
|
|
476
|
+
|
|
477
|
+
raise VersionNotPresent(
|
|
478
|
+
f"Could not parse Debian version from first line of {self.name}"
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
def _get_raw_full_version(self) -> str:
|
|
482
|
+
"""Internal helper to get the un-stripped full version string with its suffix."""
|
|
483
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
484
|
+
first_line = f.readline()
|
|
485
|
+
match = re.match(r"^[a-zA-Z0-9.+_-]+\s+\(([^)]+)\)", first_line)
|
|
486
|
+
return match.group(1) if match else ""
|
|
487
|
+
|
|
488
|
+
def update_version(self, new_version: str) -> None:
|
|
489
|
+
# Compare core upstream versions safely to avoid duplicate entries
|
|
490
|
+
try:
|
|
491
|
+
if self.get_version() == re.split(r"[-~]", new_version)[0]:
|
|
492
|
+
return # Skip adding a duplicate entry
|
|
493
|
+
except VersionNotPresent:
|
|
494
|
+
pass
|
|
495
|
+
|
|
496
|
+
# Grab the raw full version string to parse its suffix before reading full file
|
|
497
|
+
try:
|
|
498
|
+
current_full_version = self._get_raw_full_version()
|
|
499
|
+
except Exception:
|
|
500
|
+
current_full_version = ""
|
|
501
|
+
|
|
502
|
+
with open(self.file_path, "r", encoding="utf-8") as f:
|
|
503
|
+
content = f.read()
|
|
504
|
+
|
|
505
|
+
# Parse the first line to reuse metadata
|
|
506
|
+
first_line = content.splitlines()[0] if content.strip() else ""
|
|
507
|
+
match = re.match(
|
|
508
|
+
r"^([a-zA-Z0-9.+_-]+)\s+\([^)]+\)\s+([^;]+);\s*(urgency=\w+)",
|
|
509
|
+
first_line,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
if match:
|
|
513
|
+
package_name = match.group(1)
|
|
514
|
+
distribution = match.group(2).strip()
|
|
515
|
+
urgency = match.group(3)
|
|
516
|
+
else:
|
|
517
|
+
package_name = "package"
|
|
518
|
+
distribution = "unstable"
|
|
519
|
+
urgency = "urgency=medium"
|
|
520
|
+
|
|
521
|
+
# Determine the final version string to write, preserving the suffix
|
|
522
|
+
new_upstream = re.split(r"[-~]", new_version)[0]
|
|
523
|
+
if "-" in new_version or "~" in new_version:
|
|
524
|
+
formatted_version = new_version
|
|
525
|
+
elif current_full_version and (
|
|
526
|
+
"-" in current_full_version or "~" in current_full_version
|
|
527
|
+
):
|
|
528
|
+
suffix_match = re.search(r"([-~].*)$", current_full_version)
|
|
529
|
+
suffix = suffix_match.group(1) if suffix_match else "-1"
|
|
530
|
+
formatted_version = f"{new_upstream}{suffix}"
|
|
531
|
+
else:
|
|
532
|
+
formatted_version = f"{new_upstream}-1"
|
|
533
|
+
|
|
534
|
+
# Fetch maintainer details
|
|
535
|
+
repo_dir = self.file_path.parent
|
|
536
|
+
name_ok, git_name = run_git_command(["config", "user.name"], cwd=repo_dir)
|
|
537
|
+
email_ok, git_email = run_git_command(["config", "user.email"], cwd=repo_dir)
|
|
538
|
+
|
|
539
|
+
if name_ok and email_ok:
|
|
540
|
+
maintainer = f"{git_name} <{git_email}>"
|
|
541
|
+
else:
|
|
542
|
+
maintainer = "Maintainer <maintainer@example.com>"
|
|
543
|
+
|
|
544
|
+
# Format RFC 5322 timestamp
|
|
545
|
+
now = datetime.datetime.now(datetime.timezone.utc).astimezone()
|
|
546
|
+
debian_date = now.strftime("%a, %d %b %Y %H:%M:%S %z")
|
|
547
|
+
|
|
548
|
+
# Write out using the compliant version with the preserved suffix
|
|
549
|
+
new_entry = (
|
|
550
|
+
f"{package_name} ({formatted_version}) {distribution}; {urgency}\n\n"
|
|
551
|
+
f" * New release: version {new_upstream}\n\n"
|
|
552
|
+
f" -- {maintainer} {debian_date}\n\n"
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
updated_content = new_entry + content
|
|
556
|
+
|
|
557
|
+
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
558
|
+
f.write(updated_content)
|
|
559
|
+
|
|
560
|
+
|
|
375
561
|
class ChangelogVersionExtractor(VersionExtractor):
|
|
376
562
|
def __init__(self, file_path: Path, pattern: str = ""):
|
|
377
563
|
super().__init__(file_path)
|
|
@@ -393,6 +579,8 @@ class ChangelogVersionExtractor(VersionExtractor):
|
|
|
393
579
|
|
|
394
580
|
today = datetime.date.today().isoformat()
|
|
395
581
|
|
|
582
|
+
# Insert new release section
|
|
583
|
+
|
|
396
584
|
pattern = r"^## \[Unreleased\]"
|
|
397
585
|
if not re.search(pattern, content, re.MULTILINE):
|
|
398
586
|
console.print(
|
|
@@ -404,12 +592,221 @@ class ChangelogVersionExtractor(VersionExtractor):
|
|
|
404
592
|
|
|
405
593
|
new_content = re.sub(pattern, replacement, content, count=1, flags=re.MULTILINE)
|
|
406
594
|
|
|
595
|
+
# Add new release link and update unreleased link
|
|
596
|
+
|
|
597
|
+
pattern = r"^\[Unreleased\]: (https://.+)/(v.+)\.\.\.HEAD$"
|
|
598
|
+
|
|
599
|
+
if match := re.search(pattern, new_content, re.MULTILINE):
|
|
600
|
+
url, old_version = match.groups()
|
|
601
|
+
replacement = f"[Unreleased]: {url}/v{new_version}...HEAD\n[{new_version}]: {url}/{old_version}...v{new_version}"
|
|
602
|
+
new_content = re.sub(pattern, replacement, new_content, flags=re.MULTILINE)
|
|
603
|
+
else:
|
|
604
|
+
console.print(
|
|
605
|
+
f"[{STYLE_WARNING}]Warning: Link definitions in CHANGELOG.md can't be updated automatically.[/{STYLE_WARNING}]"
|
|
606
|
+
)
|
|
607
|
+
|
|
407
608
|
with open(self.file_path, "w", encoding="utf-8") as f:
|
|
408
609
|
f.write(new_content)
|
|
409
610
|
|
|
410
|
-
console.print(
|
|
411
|
-
|
|
611
|
+
console.print(f"[{STYLE_INFO}]Updated CHANGELOG.md.[/{STYLE_INFO}]")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def git_ignored_abs_dirs(repo_dir: Path) -> set:
|
|
615
|
+
"""Absolute paths of git-ignored directories for the repo containing ``repo_dir``.
|
|
616
|
+
|
|
617
|
+
Runs ``git ls-files --directory`` from ``repo_dir`` so git resolves the
|
|
618
|
+
enclosing repository itself; paths are reported relative to ``repo_dir`` and
|
|
619
|
+
resolved to absolute Paths. Empty when ``repo_dir`` is not inside a repo.
|
|
620
|
+
|
|
621
|
+
Used to prune ignored dirs (e.g. build/, output/) during discovery instead
|
|
622
|
+
of walking into them.
|
|
623
|
+
"""
|
|
624
|
+
try:
|
|
625
|
+
result = subprocess.run(
|
|
626
|
+
[
|
|
627
|
+
"git",
|
|
628
|
+
"ls-files",
|
|
629
|
+
"--others",
|
|
630
|
+
"--ignored",
|
|
631
|
+
"--exclude-standard",
|
|
632
|
+
"--directory",
|
|
633
|
+
"-z",
|
|
634
|
+
],
|
|
635
|
+
cwd=repo_dir,
|
|
636
|
+
capture_output=True,
|
|
637
|
+
text=True,
|
|
412
638
|
)
|
|
639
|
+
except (FileNotFoundError, OSError):
|
|
640
|
+
return set()
|
|
641
|
+
|
|
642
|
+
if result.returncode != 0:
|
|
643
|
+
return set()
|
|
644
|
+
|
|
645
|
+
return {
|
|
646
|
+
(repo_dir / entry.rstrip("/")).resolve()
|
|
647
|
+
for entry in result.stdout.split("\0")
|
|
648
|
+
if entry
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def drop_git_ignored(root_dir: Path, paths: List[Path]) -> List[Path]:
|
|
653
|
+
"""Drop paths that git ignores, honoring each path's own enclosing repo.
|
|
654
|
+
|
|
655
|
+
Candidates may live in independent nested git repositories even when
|
|
656
|
+
``root_dir`` is not itself a repo (e.g. a plain folder holding several
|
|
657
|
+
checkouts). Anchoring a single ``git check-ignore`` at ``root_dir`` would
|
|
658
|
+
then bail out entirely and leak ignored build trees like ``output/`` into
|
|
659
|
+
the results. Instead, resolve the repository that actually contains each
|
|
660
|
+
candidate and run ``git check-ignore`` there. Paths outside any repo are
|
|
661
|
+
always kept.
|
|
662
|
+
"""
|
|
663
|
+
if not paths:
|
|
664
|
+
return paths
|
|
665
|
+
|
|
666
|
+
# Group candidates by the git repo top-level that contains them.
|
|
667
|
+
groups: Dict[Path, List[Path]] = {}
|
|
668
|
+
for p in paths:
|
|
669
|
+
ok, top = run_git_command(["rev-parse", "--show-toplevel"], p.parent)
|
|
670
|
+
if not ok or not top:
|
|
671
|
+
continue # not inside any repo -> always kept
|
|
672
|
+
groups.setdefault(Path(top).resolve(), []).append(p)
|
|
673
|
+
|
|
674
|
+
ignored: set = set()
|
|
675
|
+
for top_path, repo_paths in groups.items():
|
|
676
|
+
rel = [str(p.resolve().relative_to(top_path)) for p in repo_paths]
|
|
677
|
+
try:
|
|
678
|
+
result = subprocess.run(
|
|
679
|
+
["git", "check-ignore", "-z", "--stdin"],
|
|
680
|
+
cwd=top_path,
|
|
681
|
+
input="\0".join(rel) + "\0",
|
|
682
|
+
capture_output=True,
|
|
683
|
+
text=True,
|
|
684
|
+
)
|
|
685
|
+
except (FileNotFoundError, OSError):
|
|
686
|
+
continue # keep this repo's candidates on git failure
|
|
687
|
+
|
|
688
|
+
# 0 = some ignored, 1 = none ignored; anything else is an error, keep all.
|
|
689
|
+
if result.returncode not in (0, 1):
|
|
690
|
+
continue
|
|
691
|
+
|
|
692
|
+
ignored_rel = {chunk for chunk in result.stdout.split("\0") if chunk}
|
|
693
|
+
ignored.update(p for p, r in zip(repo_paths, rel) if r in ignored_rel)
|
|
694
|
+
|
|
695
|
+
return [p for p in paths if p not in ignored]
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def git_submodule_dirs(root_dir: Path) -> set:
|
|
699
|
+
"""Absolute paths of git submodule working trees (empty outside a git repo)."""
|
|
700
|
+
ok, top = run_git_command(["rev-parse", "--show-toplevel"], root_dir)
|
|
701
|
+
if not ok or not top:
|
|
702
|
+
return set()
|
|
703
|
+
top = Path(top)
|
|
704
|
+
|
|
705
|
+
ok, out = run_git_command(
|
|
706
|
+
["config", "--file", ".gitmodules", "-z", "--get-regexp", "path"], top
|
|
707
|
+
)
|
|
708
|
+
if not ok or not out:
|
|
709
|
+
return set()
|
|
710
|
+
|
|
711
|
+
dirs = set()
|
|
712
|
+
for record in out.split("\0"):
|
|
713
|
+
key, _, value = record.partition("\n")
|
|
714
|
+
if key.endswith(".path") and value:
|
|
715
|
+
dirs.add((top / value).resolve())
|
|
716
|
+
return dirs
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def drop_submodules(root_dir: Path, paths: List[Path]) -> List[Path]:
|
|
720
|
+
"""Drop paths inside git submodules (they are versioned independently)."""
|
|
721
|
+
if not paths:
|
|
722
|
+
return paths
|
|
723
|
+
subdirs = git_submodule_dirs(root_dir)
|
|
724
|
+
if not subdirs:
|
|
725
|
+
return paths
|
|
726
|
+
kept = []
|
|
727
|
+
for p in paths:
|
|
728
|
+
resolved = p.resolve()
|
|
729
|
+
if any(resolved == s or s in resolved.parents for s in subdirs):
|
|
730
|
+
continue
|
|
731
|
+
kept.append(p)
|
|
732
|
+
return kept
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def discover_package_roots(root_dir: Path) -> List[Path]:
|
|
736
|
+
"""Discover nested ROS package roots (dirs with a package.xml, root excluded).
|
|
737
|
+
|
|
738
|
+
Skips hidden directories (.git, .pixi, ...), git submodules, and — inside a
|
|
739
|
+
git repo — anything git ignores (.gitignore, global excludes), like `fd`.
|
|
740
|
+
|
|
741
|
+
Hidden and ignored directories are pruned during the walk so we never
|
|
742
|
+
descend into large trees like `.venv` or `build/`. Nested repositories are
|
|
743
|
+
handled too: each one's ignore rules are learned when its top-level is
|
|
744
|
+
reached, so an ignored `output/` build tree is pruned even when `root_dir`
|
|
745
|
+
itself is not a git repo.
|
|
746
|
+
"""
|
|
747
|
+
# Seed from root_dir's own repo (covers root being, or living inside, a repo).
|
|
748
|
+
ignored_abs = git_ignored_abs_dirs(root_dir)
|
|
749
|
+
candidates = []
|
|
750
|
+
for dirpath, dirnames, filenames in os.walk(root_dir):
|
|
751
|
+
current = Path(dirpath)
|
|
752
|
+
# A nested repo brings its own .gitignore; learn what it ignores before
|
|
753
|
+
# descending, so its build trees are pruned regardless of root_dir.
|
|
754
|
+
if current != root_dir and (current / ".git").exists():
|
|
755
|
+
ignored_abs |= git_ignored_abs_dirs(current)
|
|
756
|
+
kept = []
|
|
757
|
+
for d in dirnames:
|
|
758
|
+
if d.startswith("."):
|
|
759
|
+
continue
|
|
760
|
+
if ignored_abs and (current / d).resolve() in ignored_abs:
|
|
761
|
+
continue
|
|
762
|
+
kept.append(d)
|
|
763
|
+
dirnames[:] = kept
|
|
764
|
+
if current != root_dir and "package.xml" in filenames:
|
|
765
|
+
candidates.append(current / "package.xml")
|
|
766
|
+
candidates = drop_git_ignored(root_dir, candidates)
|
|
767
|
+
candidates = drop_submodules(root_dir, candidates)
|
|
768
|
+
return sorted({package_xml.parent for package_xml in candidates})
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def build_root_checks(root_dir: Path) -> List[VersionExtractor]:
|
|
772
|
+
"""Build the version extractors tracked at the repository root."""
|
|
773
|
+
return [
|
|
774
|
+
XmlVersionExtractor(root_dir / "package.xml"),
|
|
775
|
+
TomlVersionExtractor(root_dir / "pyproject.toml", ["project", "version"]),
|
|
776
|
+
ChangelogVersionExtractor(root_dir / "CHANGELOG.md", r""),
|
|
777
|
+
TomlVersionExtractor(root_dir / "pixi.toml", ["workspace", "version"]),
|
|
778
|
+
YamlVersionExtractor(root_dir / "CITATION.cff", ["version"]),
|
|
779
|
+
CMakeListsVersionExtractor(root_dir / "CMakeLists.txt"),
|
|
780
|
+
DebianChangelogVersionExtractor(root_dir / "debian/changelog"),
|
|
781
|
+
ConanfileVersionExtractor(root_dir / "conanfile.py"),
|
|
782
|
+
]
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def collect_version_checks(root_dir: Path) -> List[VersionExtractor]:
|
|
786
|
+
"""Root checks plus the same set of checks for each nested package.
|
|
787
|
+
|
|
788
|
+
Each nested package is treated exactly like the repository root: the same
|
|
789
|
+
file/key combinations are checked, and any that don't exist in the nested
|
|
790
|
+
package directory are simply skipped (VersionExtractor.check_file_exists()).
|
|
791
|
+
"""
|
|
792
|
+
checks = list(build_root_checks(root_dir))
|
|
793
|
+
seen_paths = {check.file_path for check in checks}
|
|
794
|
+
|
|
795
|
+
for package_root in discover_package_roots(root_dir):
|
|
796
|
+
for check in build_root_checks(package_root):
|
|
797
|
+
if check.file_path in seen_paths:
|
|
798
|
+
continue
|
|
799
|
+
checks.append(check)
|
|
800
|
+
seen_paths.add(check.file_path)
|
|
801
|
+
|
|
802
|
+
# Label nested files by their relative path so identical basenames stay distinct.
|
|
803
|
+
for check in checks:
|
|
804
|
+
try:
|
|
805
|
+
check.label = str(check.file_path.relative_to(root_dir))
|
|
806
|
+
except ValueError:
|
|
807
|
+
check.label = check.file_path.name
|
|
808
|
+
|
|
809
|
+
return checks
|
|
413
810
|
|
|
414
811
|
|
|
415
812
|
def validate_semver(version: str) -> str:
|
|
@@ -444,22 +841,25 @@ def bump_version(version: str, bump_type: str) -> str:
|
|
|
444
841
|
raise ValueError(f"Invalid bump type: {bump_type}")
|
|
445
842
|
|
|
446
843
|
|
|
447
|
-
def
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
errors
|
|
451
|
-
|
|
844
|
+
def collect_versions(
|
|
845
|
+
checks: List[VersionExtractor],
|
|
846
|
+
) -> Tuple[set, List[str]]:
|
|
847
|
+
"""Collect distinct versions and parse errors across all files. No console output."""
|
|
848
|
+
versions: set = set()
|
|
849
|
+
errors: List[str] = []
|
|
452
850
|
for check in checks:
|
|
453
851
|
if check.check_file_exists():
|
|
454
852
|
try:
|
|
455
|
-
|
|
456
|
-
versions_found.add(version)
|
|
853
|
+
versions.add(check.get_version())
|
|
457
854
|
except VersionNotPresent:
|
|
458
855
|
pass # file exists but has no version configured; skip
|
|
459
856
|
except Exception as e:
|
|
460
|
-
errors.append(f"{check.
|
|
857
|
+
errors.append(f"{check.label}: {e}")
|
|
858
|
+
return versions, errors
|
|
461
859
|
|
|
462
|
-
|
|
860
|
+
|
|
861
|
+
def report_version_problems(versions: set, errors: List[str]) -> None:
|
|
862
|
+
"""Print parse warnings and version-mismatch errors from collect_versions()."""
|
|
463
863
|
if errors:
|
|
464
864
|
console.print(
|
|
465
865
|
f"[{STYLE_WARNING}]Warning: Failed to parse version from some files:[/{STYLE_WARNING}]"
|
|
@@ -467,21 +867,25 @@ def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
|
|
|
467
867
|
for error in errors:
|
|
468
868
|
console.print(f" [{STYLE_MUTED}]• {error}[/{STYLE_MUTED}]")
|
|
469
869
|
|
|
470
|
-
if len(
|
|
471
|
-
return list(versions_found)[0]
|
|
472
|
-
elif len(versions_found) > 1:
|
|
870
|
+
if len(versions) > 1:
|
|
473
871
|
console.print(
|
|
474
|
-
f"[{
|
|
872
|
+
f"[{STYLE_INFO}]Tip:[/{STYLE_INFO}] use --update-version X.Y.Z to set a single version across all files."
|
|
475
873
|
)
|
|
476
874
|
console.print(
|
|
477
|
-
f"[{
|
|
875
|
+
f"[{STYLE_ERROR}]Error: version mismatch — multiple versions found: "
|
|
876
|
+
f"{', '.join(sorted(versions))}[/{STYLE_ERROR}]"
|
|
478
877
|
)
|
|
479
|
-
|
|
480
|
-
else:
|
|
878
|
+
elif not versions:
|
|
481
879
|
console.print(
|
|
482
880
|
f"[{STYLE_ERROR}]Error: No version found in any files.[/{STYLE_ERROR}]"
|
|
483
881
|
)
|
|
484
|
-
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
|
|
885
|
+
"""Consensus version across all files, reporting any problems."""
|
|
886
|
+
versions, errors = collect_versions(checks)
|
|
887
|
+
report_version_problems(versions, errors)
|
|
888
|
+
return next(iter(versions)) if len(versions) == 1 else None
|
|
485
889
|
|
|
486
890
|
|
|
487
891
|
def infer_change_type(
|
|
@@ -677,7 +1081,7 @@ def git_tag_version(
|
|
|
677
1081
|
custom_tag_name: Optional[str] = None,
|
|
678
1082
|
custom_tag_message: Optional[str] = None,
|
|
679
1083
|
sign: bool = False,
|
|
680
|
-
) -> bool:
|
|
1084
|
+
) -> Tuple[bool, str]:
|
|
681
1085
|
"""Create a git tag for the version.
|
|
682
1086
|
|
|
683
1087
|
When ``sign`` is True the tag is GPG-signed (``git tag -s``); otherwise an
|
|
@@ -688,7 +1092,7 @@ def git_tag_version(
|
|
|
688
1092
|
console.print(
|
|
689
1093
|
f"[{STYLE_WARNING}]Not a git repository, skipping git tag.[/{STYLE_WARNING}]"
|
|
690
1094
|
)
|
|
691
|
-
return False
|
|
1095
|
+
return False, ""
|
|
692
1096
|
|
|
693
1097
|
tag_name = (
|
|
694
1098
|
custom_tag_name.format(version=version) if custom_tag_name else f"v{version}"
|
|
@@ -704,7 +1108,7 @@ def git_tag_version(
|
|
|
704
1108
|
console.print(
|
|
705
1109
|
f"[{STYLE_WARNING}]Tag {tag_name} already exists.[/{STYLE_WARNING}]"
|
|
706
1110
|
)
|
|
707
|
-
return False
|
|
1111
|
+
return False, ""
|
|
708
1112
|
|
|
709
1113
|
if not auto_confirm:
|
|
710
1114
|
confirmed = Confirm.ask(
|
|
@@ -723,14 +1127,113 @@ def git_tag_version(
|
|
|
723
1127
|
)
|
|
724
1128
|
if success:
|
|
725
1129
|
console.print(f"[{STYLE_SUCCESS}]✓ Created tag: {tag_name}[/{STYLE_SUCCESS}]")
|
|
726
|
-
|
|
727
|
-
f"[{STYLE_MUTED}] To push: git push origin {tag_name}[/{STYLE_MUTED}]"
|
|
728
|
-
)
|
|
729
|
-
return True
|
|
1130
|
+
return True, tag_name
|
|
730
1131
|
else:
|
|
731
1132
|
console.print(f"[{STYLE_ERROR}]Failed to create tag: {output}[/{STYLE_ERROR}]")
|
|
1133
|
+
return False, ""
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def push_tag(root_dir: Path, tag_name: str, project_url: str) -> bool:
|
|
1137
|
+
url = project_url.replace(GITHUB_URL, "git@github.com:")
|
|
1138
|
+
git_args = ["push", url, tag_name]
|
|
1139
|
+
console.print(f"[{STYLE_MUTED}]$ git {' '.join(git_args)}")
|
|
1140
|
+
success, output = run_git_command(git_args, cwd=root_dir)
|
|
1141
|
+
if success:
|
|
1142
|
+
console.print(f"[{STYLE_SUCCESS}]✓ Pushed tag: {tag_name}[/{STYLE_SUCCESS}]")
|
|
1143
|
+
else:
|
|
1144
|
+
console.print(f"[{STYLE_ERROR}]Failed to push tag: {output}[/{STYLE_ERROR}]")
|
|
1145
|
+
return success
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def git_archive(
|
|
1149
|
+
root_dir: Path, archive_name: str, tag_name: str, sign: bool = False
|
|
1150
|
+
) -> bool:
|
|
1151
|
+
"""Create a .tar.gz archive from git for the version.
|
|
1152
|
+
|
|
1153
|
+
When ``sign`` is True, a detached GPG .tar.gz.sig file is added.
|
|
1154
|
+
"""
|
|
1155
|
+
git_args = ["archive", "--format", "tgz", "--output", archive_name, tag_name]
|
|
1156
|
+
console.print(f"[{STYLE_MUTED}]$ git {' '.join(git_args)}")
|
|
1157
|
+
success, output = run_git_command(git_args, cwd=root_dir)
|
|
1158
|
+
if not success:
|
|
1159
|
+
console.print(
|
|
1160
|
+
f"[{STYLE_ERROR}]Failed to create archive: {output}[/{STYLE_ERROR}]"
|
|
1161
|
+
)
|
|
732
1162
|
return False
|
|
733
1163
|
|
|
1164
|
+
if sign:
|
|
1165
|
+
call = [
|
|
1166
|
+
"gpg",
|
|
1167
|
+
"--detach-sign",
|
|
1168
|
+
"--armor",
|
|
1169
|
+
"-o",
|
|
1170
|
+
f"{archive_name}.sig",
|
|
1171
|
+
archive_name,
|
|
1172
|
+
]
|
|
1173
|
+
console.print(f"[{STYLE_MUTED}]$ {' '.join(call)}")
|
|
1174
|
+
result = subprocess.run(call, cwd=root_dir, capture_output=True, text=True)
|
|
1175
|
+
if result.returncode != 0:
|
|
1176
|
+
console.print(
|
|
1177
|
+
f"[{STYLE_ERROR}]Failed to sign archive: {result.stderr}[/{STYLE_ERROR}]"
|
|
1178
|
+
)
|
|
1179
|
+
return False
|
|
1180
|
+
|
|
1181
|
+
return True
|
|
1182
|
+
|
|
1183
|
+
|
|
1184
|
+
def gh_release(
|
|
1185
|
+
root_dir: Path,
|
|
1186
|
+
target_version: str,
|
|
1187
|
+
project_url: str,
|
|
1188
|
+
archive_name: str | None = None,
|
|
1189
|
+
sign: bool = False,
|
|
1190
|
+
dry_run: bool = False,
|
|
1191
|
+
) -> Tuple[bool, str]:
|
|
1192
|
+
"""Create a github release and return (success, output)."""
|
|
1193
|
+
|
|
1194
|
+
title = f"Release v{target_version}"
|
|
1195
|
+
repo = project_url.removeprefix(GITHUB_URL)
|
|
1196
|
+
call = ["gh", "release", "create", "--title", title, "--repo", repo]
|
|
1197
|
+
|
|
1198
|
+
# include latest release notes
|
|
1199
|
+
changelog = root_dir / "CHANGELOG.md"
|
|
1200
|
+
if changelog.is_file():
|
|
1201
|
+
notes = changelog.read_text().split("\n## ")
|
|
1202
|
+
if len(notes) >= 3: # 0: header, 1: unreleased, 2: latest
|
|
1203
|
+
today = datetime.date.today().isoformat()
|
|
1204
|
+
call = [
|
|
1205
|
+
*call,
|
|
1206
|
+
"--notes",
|
|
1207
|
+
notes[1].replace("[Unreleased]", f"[{target_version}] - {today}")
|
|
1208
|
+
if dry_run
|
|
1209
|
+
else notes[2],
|
|
1210
|
+
]
|
|
1211
|
+
else:
|
|
1212
|
+
console.print(
|
|
1213
|
+
f"[{STYLE_WARNING}]Warning: No release notes available in CHANGELOG.md yet.[/{STYLE_WARNING}]"
|
|
1214
|
+
)
|
|
1215
|
+
|
|
1216
|
+
call = [*call, f"v{target_version}"]
|
|
1217
|
+
|
|
1218
|
+
# include .tar.gz archive and .tar.gz.sig signature
|
|
1219
|
+
if archive_name is not None:
|
|
1220
|
+
call = [*call, archive_name]
|
|
1221
|
+
if sign:
|
|
1222
|
+
call = [*call, f"{archive_name}.sig"]
|
|
1223
|
+
|
|
1224
|
+
print_call = "$ '" + "' '".join(c.replace("'", "\\'") for c in call) + "'"
|
|
1225
|
+
if dry_run:
|
|
1226
|
+
return True, print_call
|
|
1227
|
+
|
|
1228
|
+
console.print(f"[{STYLE_MUTED}]$ {print_call}")
|
|
1229
|
+
try:
|
|
1230
|
+
result = subprocess.run(call, cwd=root_dir, capture_output=True, text=True)
|
|
1231
|
+
if result.returncode != 0:
|
|
1232
|
+
return False, result.stderr.strip()
|
|
1233
|
+
return True, result.stdout.strip()
|
|
1234
|
+
except subprocess.CalledProcessError as e:
|
|
1235
|
+
return False, e.stderr or ""
|
|
1236
|
+
|
|
734
1237
|
|
|
735
1238
|
def update_pixi_lock(root_dir: Path, dry_run: bool = False) -> Optional[str]:
|
|
736
1239
|
"""Update pixi.lock file by running 'pixi list'.
|
|
@@ -799,9 +1302,10 @@ def create_backups(file_paths: List[Path]) -> Dict[Path, Path]:
|
|
|
799
1302
|
backups = {}
|
|
800
1303
|
temp_dir = Path(tempfile.mkdtemp(prefix="release_backup_"))
|
|
801
1304
|
|
|
802
|
-
|
|
1305
|
+
# Index-prefix the name so files sharing a basename don't collide.
|
|
1306
|
+
for index, file_path in enumerate(file_paths):
|
|
803
1307
|
if file_path.exists():
|
|
804
|
-
backup_path = temp_dir / file_path.name
|
|
1308
|
+
backup_path = temp_dir / f"{index:04d}_{file_path.name}"
|
|
805
1309
|
shutil.copy2(file_path, backup_path)
|
|
806
1310
|
backups[file_path] = backup_path
|
|
807
1311
|
|
|
@@ -856,16 +1360,16 @@ def list_version_files(checks: List[VersionExtractor]) -> None:
|
|
|
856
1360
|
else f"[{STYLE_ERROR}]✗[/{STYLE_ERROR}]"
|
|
857
1361
|
)
|
|
858
1362
|
file_type = check.__class__.__name__.replace("VersionExtractor", "")
|
|
859
|
-
table.add_row(check.
|
|
1363
|
+
table.add_row(check.label, str(check.file_path), exists, file_type)
|
|
860
1364
|
|
|
861
1365
|
console.print(table)
|
|
862
1366
|
sys.exit(0)
|
|
863
1367
|
|
|
864
1368
|
|
|
865
|
-
def handle_check_version(checks: List[VersionExtractor], args) ->
|
|
1369
|
+
def handle_check_version(checks: List[VersionExtractor], args) -> bool:
|
|
866
1370
|
"""Handle the --check-version command.
|
|
867
1371
|
|
|
868
|
-
Returns
|
|
1372
|
+
Returns True if all files agree on one version, False otherwise.
|
|
869
1373
|
"""
|
|
870
1374
|
results = []
|
|
871
1375
|
versions_found = set()
|
|
@@ -878,7 +1382,7 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
|
878
1382
|
|
|
879
1383
|
for check in checks:
|
|
880
1384
|
result = {
|
|
881
|
-
"file": check.
|
|
1385
|
+
"file": check.label,
|
|
882
1386
|
"version": None,
|
|
883
1387
|
"status": "Unknown",
|
|
884
1388
|
"message": "",
|
|
@@ -911,13 +1415,14 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
|
911
1415
|
consensus_version = "MISMATCH"
|
|
912
1416
|
|
|
913
1417
|
if args.output_format == "json":
|
|
1418
|
+
consistent = not errors and len(versions_found) == 1
|
|
914
1419
|
out_payload = {
|
|
915
1420
|
"consensus_version": consensus_version,
|
|
916
1421
|
"files": results,
|
|
917
|
-
"consistent":
|
|
1422
|
+
"consistent": consistent,
|
|
918
1423
|
}
|
|
919
1424
|
print(json.dumps(out_payload, indent=2))
|
|
920
|
-
return
|
|
1425
|
+
return consistent
|
|
921
1426
|
|
|
922
1427
|
# Standard Rich table output
|
|
923
1428
|
table = Table(title="Version Check Summary", box=box.ROUNDED)
|
|
@@ -946,7 +1451,7 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
|
946
1451
|
):
|
|
947
1452
|
version_display = f"[{STYLE_SUCCESS}]{res['version']}[/{STYLE_SUCCESS}]"
|
|
948
1453
|
elif consensus_version == "MISMATCH":
|
|
949
|
-
version_display = f"[{
|
|
1454
|
+
version_display = f"[{STYLE_ERROR}]{res['version']}[/{STYLE_ERROR}]"
|
|
950
1455
|
|
|
951
1456
|
table.add_row(res["file"], version_display, status_style, res["message"])
|
|
952
1457
|
|
|
@@ -956,27 +1461,53 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
|
|
|
956
1461
|
if args.short and consensus_version and consensus_version != "MISMATCH":
|
|
957
1462
|
print(consensus_version)
|
|
958
1463
|
|
|
1464
|
+
tag_check_failed = False
|
|
1465
|
+
tag_format_is_valid = False
|
|
1466
|
+
if hasattr(args, "check_tag") and args.check_tag:
|
|
1467
|
+
if not args.check_tag.startswith("v"):
|
|
1468
|
+
tag_check_failed = True
|
|
1469
|
+
errors = True
|
|
1470
|
+
else:
|
|
1471
|
+
tag_version = args.check_tag.lstrip("v")
|
|
1472
|
+
if consensus_version and consensus_version != "MISMATCH":
|
|
1473
|
+
if consensus_version != tag_version:
|
|
1474
|
+
tag_check_failed = True
|
|
1475
|
+
tag_format_is_valid = True
|
|
1476
|
+
errors = True
|
|
1477
|
+
|
|
959
1478
|
if errors:
|
|
960
1479
|
if len(versions_found) > 1:
|
|
961
1480
|
console.print(
|
|
962
|
-
f"\n[{
|
|
1481
|
+
f"\n[{STYLE_INFO}]Tip:[/{STYLE_INFO}] use --update-version X.Y.Z to set a single version across all files."
|
|
1482
|
+
)
|
|
1483
|
+
console.print(
|
|
1484
|
+
f"[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] version mismatch — conflicting versions: {', '.join(sorted(versions_found))}"
|
|
963
1485
|
)
|
|
1486
|
+
elif tag_check_failed:
|
|
1487
|
+
if not tag_format_is_valid:
|
|
1488
|
+
console.print(
|
|
1489
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Tag version ({args.check_tag}) format is invalid. It should start with 'v' and be a semantic version."
|
|
1490
|
+
)
|
|
1491
|
+
else:
|
|
1492
|
+
console.print(
|
|
1493
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Tag version ({args.check_tag.lstrip('v')}) does not match consensus version ({consensus_version})."
|
|
1494
|
+
)
|
|
964
1495
|
else:
|
|
965
1496
|
console.print(
|
|
966
|
-
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}]
|
|
1497
|
+
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] parsing errors encountered."
|
|
967
1498
|
)
|
|
968
|
-
return
|
|
1499
|
+
return False
|
|
969
1500
|
elif not versions_found:
|
|
970
1501
|
console.print(
|
|
971
1502
|
f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] No version files found in {args.root}."
|
|
972
1503
|
)
|
|
973
|
-
return
|
|
1504
|
+
return False
|
|
974
1505
|
else:
|
|
975
1506
|
if not args.short:
|
|
976
1507
|
console.print(
|
|
977
1508
|
f"\n[{STYLE_SUCCESS_STRONG}]SUCCESS:[/{STYLE_SUCCESS_STRONG}] All files match version [{STYLE_SUCCESS}]{consensus_version}[/{STYLE_SUCCESS}]."
|
|
978
1509
|
)
|
|
979
|
-
return
|
|
1510
|
+
return True
|
|
980
1511
|
|
|
981
1512
|
|
|
982
1513
|
def perform_version_updates(
|
|
@@ -999,7 +1530,7 @@ def perform_version_updates(
|
|
|
999
1530
|
try:
|
|
1000
1531
|
if dry_run:
|
|
1001
1532
|
curr = check.get_version()
|
|
1002
|
-
dry_run_rows.append((check.
|
|
1533
|
+
dry_run_rows.append((check.label, curr, target_version))
|
|
1003
1534
|
else:
|
|
1004
1535
|
try:
|
|
1005
1536
|
old_version = check.get_version()
|
|
@@ -1008,20 +1539,20 @@ def perform_version_updates(
|
|
|
1008
1539
|
except Exception:
|
|
1009
1540
|
old_version = "?"
|
|
1010
1541
|
check.update_version(target_version)
|
|
1011
|
-
dry_run_rows.append((check.
|
|
1542
|
+
dry_run_rows.append((check.label, old_version, target_version))
|
|
1012
1543
|
line = Text()
|
|
1013
|
-
line.append(f" {check.
|
|
1544
|
+
line.append(f" {check.label:<28}", style="cyan")
|
|
1014
1545
|
line.append(old_version, style=STYLE_OLD_VALUE)
|
|
1015
1546
|
line.append(" → ", style="dim")
|
|
1016
1547
|
line.append(target_version, style=STYLE_NEW_VALUE)
|
|
1017
1548
|
console.print(line)
|
|
1018
|
-
updated_files.append(check.
|
|
1549
|
+
updated_files.append(check.label)
|
|
1019
1550
|
updated_file_paths.append(str(check.file_path))
|
|
1020
1551
|
except VersionNotPresent:
|
|
1021
1552
|
pass # file exists but has no version configured; skip
|
|
1022
1553
|
except Exception as e:
|
|
1023
1554
|
console.print(
|
|
1024
|
-
f"[{STYLE_ERROR}]Failed to update {check.
|
|
1555
|
+
f"[{STYLE_ERROR}]Failed to update {check.label}: {e}[/{STYLE_ERROR}]"
|
|
1025
1556
|
)
|
|
1026
1557
|
if not dry_run:
|
|
1027
1558
|
failed = True
|
|
@@ -1044,14 +1575,14 @@ def show_dry_run_panel(
|
|
|
1044
1575
|
console.print(f" [dim]{'─' * 44}[/dim]")
|
|
1045
1576
|
for name, old, new in dry_run_rows:
|
|
1046
1577
|
line = Text()
|
|
1047
|
-
line.append(f" {name:<
|
|
1578
|
+
line.append(f" {name:<28}", style="cyan")
|
|
1048
1579
|
line.append(old, style=STYLE_OLD_VALUE)
|
|
1049
1580
|
line.append(" → ", style="dim")
|
|
1050
1581
|
line.append(new, style=STYLE_NEW_VALUE)
|
|
1051
1582
|
console.print(line)
|
|
1052
1583
|
if pixi_lock_would_update:
|
|
1053
1584
|
line = Text()
|
|
1054
|
-
line.append(f" {'pixi.lock':<
|
|
1585
|
+
line.append(f" {'pixi.lock':<28}", style="cyan")
|
|
1055
1586
|
line.append("regenerated via pixi list", style="dim")
|
|
1056
1587
|
console.print(line)
|
|
1057
1588
|
|
|
@@ -1070,7 +1601,7 @@ def show_result_panel(
|
|
|
1070
1601
|
"""Display a polished summary of completed version updates."""
|
|
1071
1602
|
if pixi_lock_updated:
|
|
1072
1603
|
line = Text()
|
|
1073
|
-
line.append(f" {'pixi.lock':<
|
|
1604
|
+
line.append(f" {'pixi.lock':<28}", style="cyan")
|
|
1074
1605
|
line.append("regenerated via pixi list", style="dim")
|
|
1075
1606
|
console.print(line)
|
|
1076
1607
|
console.print()
|
|
@@ -1164,6 +1695,28 @@ def main():
|
|
|
1164
1695
|
action="store_true",
|
|
1165
1696
|
help="GPG-sign the git tag (uses 'git tag -s' instead of '-a'). Requires a configured signing key (git config user.signingkey). Only meaningful with --git-tag.",
|
|
1166
1697
|
)
|
|
1698
|
+
parser.add_argument(
|
|
1699
|
+
"--push-tag",
|
|
1700
|
+
action="store_true",
|
|
1701
|
+
help="Push the tag to the main repository. Only works with github for now.",
|
|
1702
|
+
)
|
|
1703
|
+
|
|
1704
|
+
parser.add_argument(
|
|
1705
|
+
"--git-archive",
|
|
1706
|
+
action="store_true",
|
|
1707
|
+
help="Create a .tar.gz archive of the project with git",
|
|
1708
|
+
)
|
|
1709
|
+
parser.add_argument(
|
|
1710
|
+
"--sign-archive",
|
|
1711
|
+
action="store_true",
|
|
1712
|
+
help="GPG-sign the git archive. Requires a configured signing key. Only meaningful with --git-archive.",
|
|
1713
|
+
)
|
|
1714
|
+
parser.add_argument(
|
|
1715
|
+
"--gh-release",
|
|
1716
|
+
action="store_true",
|
|
1717
|
+
help="Create Github release. Requires a configured gh cli.",
|
|
1718
|
+
)
|
|
1719
|
+
|
|
1167
1720
|
parser.add_argument(
|
|
1168
1721
|
"--short",
|
|
1169
1722
|
action="store_true",
|
|
@@ -1196,6 +1749,12 @@ def main():
|
|
|
1196
1749
|
help="Bump the project version.",
|
|
1197
1750
|
)
|
|
1198
1751
|
|
|
1752
|
+
parser.add_argument(
|
|
1753
|
+
"--check-tag",
|
|
1754
|
+
type=str,
|
|
1755
|
+
help="Check that the consensus version matches the given tag (e.g., v2.15.0). Only meaningful with --check-version.",
|
|
1756
|
+
)
|
|
1757
|
+
|
|
1199
1758
|
args = parser.parse_args()
|
|
1200
1759
|
root_dir = args.root
|
|
1201
1760
|
|
|
@@ -1206,6 +1765,9 @@ def main():
|
|
|
1206
1765
|
else:
|
|
1207
1766
|
console = Console()
|
|
1208
1767
|
|
|
1768
|
+
if args.check_tag and not args.check_version:
|
|
1769
|
+
parser.error("--check-tag requires --check-version")
|
|
1770
|
+
|
|
1209
1771
|
if args.update_version:
|
|
1210
1772
|
try:
|
|
1211
1773
|
if not re.match(r"^\d+\.\d+\.\d+$", args.update_version):
|
|
@@ -1219,14 +1781,7 @@ def main():
|
|
|
1219
1781
|
)
|
|
1220
1782
|
sys.exit(1)
|
|
1221
1783
|
|
|
1222
|
-
checks: List[VersionExtractor] =
|
|
1223
|
-
XmlVersionExtractor(root_dir / "package.xml"),
|
|
1224
|
-
TomlVersionExtractor(root_dir / "pyproject.toml", ["project", "version"]),
|
|
1225
|
-
ChangelogVersionExtractor(root_dir / "CHANGELOG.md", r""),
|
|
1226
|
-
TomlVersionExtractor(root_dir / "pixi.toml", ["workspace", "version"]),
|
|
1227
|
-
YamlVersionExtractor(root_dir / "CITATION.cff", ["version"]),
|
|
1228
|
-
CMakeListsVersionExtractor(root_dir / "CMakeLists.txt"),
|
|
1229
|
-
]
|
|
1784
|
+
checks: List[VersionExtractor] = collect_version_checks(root_dir)
|
|
1230
1785
|
|
|
1231
1786
|
if args.list_files:
|
|
1232
1787
|
if args.output_format == "json":
|
|
@@ -1234,7 +1789,7 @@ def main():
|
|
|
1234
1789
|
for check in checks:
|
|
1235
1790
|
files_list.append(
|
|
1236
1791
|
{
|
|
1237
|
-
"name": check.
|
|
1792
|
+
"name": check.label,
|
|
1238
1793
|
"path": str(check.file_path),
|
|
1239
1794
|
"exists": check.check_file_exists(),
|
|
1240
1795
|
"type": check.__class__.__name__.replace(
|
|
@@ -1248,14 +1803,15 @@ def main():
|
|
|
1248
1803
|
sys.exit(0)
|
|
1249
1804
|
|
|
1250
1805
|
if args.check_version:
|
|
1251
|
-
sys.exit(handle_check_version(checks, args))
|
|
1806
|
+
sys.exit(0 if handle_check_version(checks, args) else 1)
|
|
1252
1807
|
|
|
1253
1808
|
current_version = None
|
|
1254
1809
|
new_version_str = None
|
|
1255
1810
|
|
|
1256
1811
|
if args.update_version:
|
|
1257
1812
|
new_version_str = args.update_version
|
|
1258
|
-
|
|
1813
|
+
versions, _ = collect_versions(checks)
|
|
1814
|
+
current_version = next(iter(versions)) if len(versions) == 1 else None
|
|
1259
1815
|
if not args.dry_run:
|
|
1260
1816
|
console.print(
|
|
1261
1817
|
f"[{STYLE_INFO}]Updating versions to {new_version_str} in {root_dir}...[/{STYLE_INFO}]"
|
|
@@ -1370,6 +1926,22 @@ def main():
|
|
|
1370
1926
|
elif args.short:
|
|
1371
1927
|
print(target_version)
|
|
1372
1928
|
|
|
1929
|
+
project_url = ""
|
|
1930
|
+
archive_name = ""
|
|
1931
|
+
if args.push_tag:
|
|
1932
|
+
for check in checks:
|
|
1933
|
+
if url := check.get_url():
|
|
1934
|
+
project_url = url
|
|
1935
|
+
break
|
|
1936
|
+
else:
|
|
1937
|
+
where = "CMakeLists.txt, package.xml, or pyproject.toml"
|
|
1938
|
+
console.print(
|
|
1939
|
+
f"[{STYLE_ERROR_STRONG}]Error: can't push tag without a github homepage in {where}.[/{STYLE_ERROR_STRONG}]"
|
|
1940
|
+
)
|
|
1941
|
+
sys.exit(1)
|
|
1942
|
+
if args.git_archive:
|
|
1943
|
+
archive_name = f"{project_url.split('/')[-1]}-{target_version}.tar.gz"
|
|
1944
|
+
|
|
1373
1945
|
if args.dry_run:
|
|
1374
1946
|
pixi_lock_would_update = (root_dir / "pixi.lock").exists()
|
|
1375
1947
|
|
|
@@ -1388,6 +1960,7 @@ def main():
|
|
|
1388
1960
|
)
|
|
1389
1961
|
git_lines.append(f"$ git add {' '.join(rel_paths) if rel_paths else '-u'}")
|
|
1390
1962
|
git_lines.append(f"$ git commit -m '{commit_message}'")
|
|
1963
|
+
|
|
1391
1964
|
if args.git_tag is not None:
|
|
1392
1965
|
custom_tag_name = None if args.git_tag is True else args.git_tag
|
|
1393
1966
|
tag_name = (
|
|
@@ -1402,6 +1975,30 @@ def main():
|
|
|
1402
1975
|
)
|
|
1403
1976
|
tag_flag = "-s" if args.sign_tag else "-a"
|
|
1404
1977
|
git_lines.append(f"$ git tag {tag_flag} {tag_name} -m '{tag_message}'")
|
|
1978
|
+
if args.push_tag:
|
|
1979
|
+
url = project_url.replace(GITHUB_URL, "git@github.com:")
|
|
1980
|
+
git_lines.append(f"$ git push {url} {tag_name}")
|
|
1981
|
+
|
|
1982
|
+
if args.git_archive:
|
|
1983
|
+
git_lines.append(
|
|
1984
|
+
f"$ git archive --format tgz --output {archive_name} {tag_name}"
|
|
1985
|
+
)
|
|
1986
|
+
|
|
1987
|
+
if args.sign_archive:
|
|
1988
|
+
git_lines.append(
|
|
1989
|
+
f"$ gpg --detach-sign --armor -o {archive_name}.sig {archive_name}"
|
|
1990
|
+
)
|
|
1991
|
+
|
|
1992
|
+
if args.gh_release:
|
|
1993
|
+
_, call = gh_release(
|
|
1994
|
+
root_dir,
|
|
1995
|
+
target_version,
|
|
1996
|
+
project_url,
|
|
1997
|
+
archive_name,
|
|
1998
|
+
args.sign_archive,
|
|
1999
|
+
dry_run=True,
|
|
2000
|
+
)
|
|
2001
|
+
git_lines.append(call)
|
|
1405
2002
|
|
|
1406
2003
|
show_dry_run_panel(dry_run_rows, pixi_lock_would_update, git_lines)
|
|
1407
2004
|
sys.exit(0)
|
|
@@ -1420,6 +2017,31 @@ def main():
|
|
|
1420
2017
|
f"[{STYLE_WARNING}]Warning: --sign-tag has no effect without --git-tag.[/{STYLE_WARNING}]"
|
|
1421
2018
|
)
|
|
1422
2019
|
|
|
2020
|
+
if args.gh_release and not args.push_tag:
|
|
2021
|
+
console.print(
|
|
2022
|
+
f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --push-tag.[/{STYLE_WARNING}]"
|
|
2023
|
+
)
|
|
2024
|
+
|
|
2025
|
+
if args.sign_archive and args.git_archive is None:
|
|
2026
|
+
console.print(
|
|
2027
|
+
f"[{STYLE_WARNING}]Warning: --sign-archive has no effect without --git-archive.[/{STYLE_WARNING}]"
|
|
2028
|
+
)
|
|
2029
|
+
|
|
2030
|
+
if args.git_archive and (root_dir / archive_name).is_file():
|
|
2031
|
+
console.print(
|
|
2032
|
+
f"[{STYLE_WARNING_STRONG}]Warning: {archive_name} already exists ![/{STYLE_WARNING_STRONG}]"
|
|
2033
|
+
)
|
|
2034
|
+
if Confirm.ask(
|
|
2035
|
+
"[bold]Remove this file (and the .sig if it is also there) and continue ?[/bold]",
|
|
2036
|
+
default=True,
|
|
2037
|
+
):
|
|
2038
|
+
(root_dir / archive_name).unlink()
|
|
2039
|
+
sig = root_dir / f"{archive_name}.sig"
|
|
2040
|
+
if sig.is_file():
|
|
2041
|
+
sig.unlink()
|
|
2042
|
+
else:
|
|
2043
|
+
sys.exit(1)
|
|
2044
|
+
|
|
1423
2045
|
if args.git_commit is not None:
|
|
1424
2046
|
custom_message = None if args.git_commit is True else args.git_commit
|
|
1425
2047
|
git_commit_version(
|
|
@@ -1432,7 +2054,7 @@ def main():
|
|
|
1432
2054
|
|
|
1433
2055
|
if args.git_tag is not None:
|
|
1434
2056
|
custom_tag_name = None if args.git_tag is True else args.git_tag
|
|
1435
|
-
git_tag_version(
|
|
2057
|
+
_success, tag_name = git_tag_version(
|
|
1436
2058
|
root_dir,
|
|
1437
2059
|
target_version,
|
|
1438
2060
|
args.confirm,
|
|
@@ -1441,6 +2063,36 @@ def main():
|
|
|
1441
2063
|
args.sign_tag,
|
|
1442
2064
|
)
|
|
1443
2065
|
|
|
2066
|
+
if args.push_tag:
|
|
2067
|
+
success = push_tag(root_dir, tag_name, project_url)
|
|
2068
|
+
if not success:
|
|
2069
|
+
sys.exit(1)
|
|
2070
|
+
|
|
2071
|
+
if args.git_archive and archive_name:
|
|
2072
|
+
success = git_archive(root_dir, archive_name, tag_name, args.sign_archive)
|
|
2073
|
+
if not success:
|
|
2074
|
+
sys.exit(1)
|
|
2075
|
+
|
|
2076
|
+
if args.gh_release:
|
|
2077
|
+
if args.git_tag is None:
|
|
2078
|
+
console.print(
|
|
2079
|
+
f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --git-tag.[/{STYLE_WARNING}]"
|
|
2080
|
+
)
|
|
2081
|
+
elif not args.push_tag:
|
|
2082
|
+
console.print(
|
|
2083
|
+
f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --push-tag.[/{STYLE_WARNING}]"
|
|
2084
|
+
)
|
|
2085
|
+
else:
|
|
2086
|
+
success, _ = gh_release(
|
|
2087
|
+
root_dir,
|
|
2088
|
+
target_version,
|
|
2089
|
+
project_url,
|
|
2090
|
+
archive_name,
|
|
2091
|
+
args.sign_archive,
|
|
2092
|
+
)
|
|
2093
|
+
if not success:
|
|
2094
|
+
sys.exit(1)
|
|
2095
|
+
|
|
1444
2096
|
|
|
1445
2097
|
if __name__ == "__main__":
|
|
1446
2098
|
try:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|