jrl-cmakemodules-scripts 2.1.0__tar.gz → 2.3.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jrl-cmakemodules-scripts
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Release scripting tools for JRL CMake modules
5
5
  License: BSD-3-Clause
6
6
  Project-URL: homepage, https://github.com/jrl-umi3218/jrl-cmakemodules
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jrl-cmakemodules-scripts
3
- Version: 2.1.0
3
+ Version: 2.3.0
4
4
  Summary: Release scripting tools for JRL CMake modules
5
5
  License: BSD-3-Clause
6
6
  Project-URL: homepage, https://github.com/jrl-umi3218/jrl-cmakemodules
@@ -46,17 +46,21 @@ uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
46
46
  | Option | Description |
47
47
  | :--- | :--- |
48
48
  | `--root <PATH>` | Project root (default: cwd). |
49
- | `--bump <major|minor|patch>` | Bump version component. |
49
+ | `--bump {major,minor,patch}` | Bump version component. |
50
50
  | `--update-version <X.Y.Z>` | Set a specific version. |
51
51
  | `--dry-run` | Show changes without writing files. |
52
52
  | `--short` | Print only the version string. |
53
- | `--output-format <text|json>` | Output format (default: text). |
53
+ | `--output-format {text,json}` | Output format (default: text). |
54
54
  | `--confirm` | Skip interactive prompts. |
55
55
  | `--list-files` | List tracked files. |
56
56
  | `--git-commit [MSG]` | Commit changes. Optional message (`{version}` placeholder). |
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,21 +68,37 @@ 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
101
+ import importlib.metadata
82
102
  import json
83
103
  import subprocess
84
104
  import shutil
@@ -122,6 +142,8 @@ STYLE_NEW_VALUE = "green"
122
142
  STYLE_UNCHANGED_VALUE = "dim"
123
143
  STYLE_HIGHLIGHT = "cyan"
124
144
 
145
+ GITHUB_URL = "https://github.com/"
146
+
125
147
 
126
148
  class VersionNotPresent(Exception):
127
149
  """Raised when a file exists but has no version field configured."""
@@ -132,6 +154,8 @@ class VersionNotPresent(Exception):
132
154
  class VersionExtractor(ABC):
133
155
  def __init__(self, file_path: Path):
134
156
  self.file_path = file_path
157
+ # Display label; discovery overrides it with a root-relative path.
158
+ self.label = file_path.name
135
159
 
136
160
  @abstractmethod
137
161
  def get_version(self) -> str:
@@ -152,6 +176,17 @@ class VersionExtractor(ABC):
152
176
  def path(self) -> str:
153
177
  return str(self.file_path)
154
178
 
179
+ def get_url(self) -> str | None:
180
+ return None
181
+
182
+ def validate_url(self, url: str) -> str | None:
183
+ if match := re.match(GITHUB_URL + r"[^/]+/[^/]+", url.strip("/").lower()):
184
+ valid_url = match.group()
185
+ console.print(
186
+ f"[{STYLE_INFO}]Found url {valid_url} in '{self.file_path}'[/{STYLE_INFO}]"
187
+ )
188
+ return valid_url
189
+
155
190
 
156
191
  class XmlVersionExtractor(VersionExtractor):
157
192
  def get_version(self) -> str:
@@ -178,6 +213,16 @@ class XmlVersionExtractor(VersionExtractor):
178
213
  with open(self.file_path, "w", encoding="utf-8") as f:
179
214
  f.write(new_content)
180
215
 
216
+ def get_url(self) -> str | None:
217
+ if not self.file_path.exists():
218
+ return None
219
+ with open(self.file_path, "r", encoding="utf-8") as f:
220
+ content = f.read()
221
+ for url in re.findall(r"<url[^>]*>(.*?)</url>", content, re.I):
222
+ if valid_url := self.validate_url(url):
223
+ return valid_url
224
+ return None
225
+
181
226
 
182
227
  class TomlVersionExtractor(VersionExtractor):
183
228
  def __init__(self, file_path: Path, keys: List[str]):
@@ -216,6 +261,17 @@ class TomlVersionExtractor(VersionExtractor):
216
261
  with open(self.file_path, "w", encoding="utf-8") as f:
217
262
  tomlkit.dump(data, f)
218
263
 
264
+ def get_url(self) -> str | None:
265
+ if not self.file_path.exists():
266
+ return None
267
+ with open(self.file_path, "r", encoding="utf-8") as f:
268
+ data = tomlkit.load(f)
269
+ if "project" in data and "urls" in data["project"]:
270
+ for url in data["project"]["urls"].values():
271
+ if valid_url := self.validate_url(url):
272
+ return valid_url
273
+ return None
274
+
219
275
 
220
276
  class YamlVersionExtractor(VersionExtractor):
221
277
  def __init__(self, file_path: Path, keys: List[str]):
@@ -253,6 +309,24 @@ class YamlVersionExtractor(VersionExtractor):
253
309
  self.yaml.dump(data, f)
254
310
 
255
311
 
312
+ class ConanfileVersionExtractor(VersionExtractor):
313
+ def get_version(self) -> str:
314
+ content = self.file_path.read_text()
315
+ if match := re.search(r'\s*version\s*=\s*["\']([^"\']+)["\']', content):
316
+ return match.group(1)
317
+ raise VersionNotPresent(f"Version not found in {self.name}")
318
+
319
+ def update_version(self, new_version: str) -> None:
320
+ pattern = re.compile(r'(\s*version\s*=\s*["\'])([^"\']+)(["\'])')
321
+ content = self.file_path.read_text()
322
+ if not pattern.search(content):
323
+ raise VersionNotPresent(f"Version not found in {self.name}")
324
+ new_content, count = pattern.subn(
325
+ lambda m: f"{m.group(1)}{new_version}{m.group(3)}", content, count=1
326
+ )
327
+ self.file_path.write_text(new_content)
328
+
329
+
256
330
  class CMakeListsVersionExtractor(VersionExtractor):
257
331
  """Specialized extractor for CMakeLists.txt that uses cmake-parser
258
332
  and handles both direct VERSION and variables (e.g., from package.xml)."""
@@ -288,7 +362,7 @@ class CMakeListsVersionExtractor(VersionExtractor):
288
362
  ver = args[version_idx + 1]
289
363
  # Check if it's a variable reference
290
364
  if not ver.startswith("${"):
291
- project_version = ver
365
+ project_version = ver.strip('"')
292
366
  except ValueError:
293
367
  pass
294
368
 
@@ -305,6 +379,15 @@ class CMakeListsVersionExtractor(VersionExtractor):
305
379
 
306
380
  return self._get_version_regex(content)
307
381
 
382
+ def get_url(self) -> str | None:
383
+ if not self.file_path.exists():
384
+ return None
385
+ with open(self.file_path, "r", encoding="utf-8") as f:
386
+ content = f.read()
387
+ if match := re.search(r'HOMEPAGE_URL\s+"([^"]+)"', content):
388
+ return self.validate_url(match.group(1))
389
+ return None
390
+
308
391
  def _get_command_args(self, node) -> List[str]:
309
392
  """Extract arguments from a cmake command node."""
310
393
  args = []
@@ -316,16 +399,16 @@ class CMakeListsVersionExtractor(VersionExtractor):
316
399
 
317
400
  def _get_version_regex(self, content: str) -> str:
318
401
  """Fallback regex-based version extraction."""
319
- # First try to find set(PROJECT_VERSION "X.Y.Z")
402
+ # Finds set(PROJECT_VERSION "X.Y.Z") or set(PROJECT_VERSION X.Y.Z)
320
403
  fallback_pattern = re.compile(
321
- r'set\s*\(\s*PROJECT_VERSION\s+"([0-9]+\.[0-9]+\.[0-9]+)"',
404
+ r'set\s*\(\s*PROJECT_VERSION\s+"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*\)',
322
405
  re.MULTILINE,
323
406
  )
324
407
  fallback_match = fallback_pattern.search(content)
325
408
 
326
409
  # Also check if project() uses a literal version or variable
327
410
  project_pattern = re.compile(
328
- r"project\s*\([^)]*VERSION\s+([\d.]+|\$\{[^}]+\})", re.MULTILINE
411
+ r'project\s*\([^)]*VERSION\s+"?([\d.]+|\$\{[^}]+\})"?', re.MULTILINE
329
412
  )
330
413
  project_match = project_pattern.search(content)
331
414
 
@@ -347,20 +430,22 @@ class CMakeListsVersionExtractor(VersionExtractor):
347
430
  with open(self.file_path, "r", encoding="utf-8") as f:
348
431
  content = f.read()
349
432
 
350
- # Update the fallback version in set(PROJECT_VERSION "...")
433
+ # 1. Update fallback version: matches set(PROJECT_VERSION 1.2.3) or set(PROJECT_VERSION "1.2.3")
434
+ # Always replaces it without quotes: set(PROJECT_VERSION 1.2.3)
351
435
  fallback_pattern = re.compile(
352
- r'(set\s*\(\s*PROJECT_VERSION\s+)"([0-9]+\.[0-9]+\.[0-9]+)"',
436
+ r'(set\s*\(\s*PROJECT_VERSION\s+)"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*\)',
353
437
  re.MULTILINE,
354
438
  )
355
439
 
356
440
  def repl_fallback(match):
357
- return f'{match.group(1)}"{new_version}"'
441
+ return f"{match.group(1)}{new_version})"
358
442
 
359
443
  content = fallback_pattern.sub(repl_fallback, content, count=1)
360
444
 
361
- # Also update literal version in project() if present
445
+ # 2. Update literal version in project(): matches VERSION 1.2.3 or VERSION "1.2.3"
446
+ # Always replaces it without quotes: VERSION 1.2.3
362
447
  project_pattern = re.compile(
363
- r"(project\s*\([^)]*VERSION\s+)([\d.]+)", re.MULTILINE
448
+ r'(project\s*\([^)]*VERSION\s+)"?([\d.]+)"?', re.MULTILINE
364
449
  )
365
450
 
366
451
  def repl_project(match):
@@ -372,6 +457,114 @@ class CMakeListsVersionExtractor(VersionExtractor):
372
457
  f.write(content)
373
458
 
374
459
 
460
+ class DebianChangelogVersionExtractor(VersionExtractor):
461
+ """Specialized extractor for debian/changelog files.
462
+
463
+ Expects standard format: package (version) distribution; urgency=...
464
+ """
465
+
466
+ @property
467
+ def name(self) -> str:
468
+ return "debian/changelog"
469
+
470
+ def get_version(self) -> str:
471
+ """Extracts and returns the clean upstream version string (omitting the Debian suffix)."""
472
+ with open(self.file_path, "r", encoding="utf-8") as f:
473
+ first_line = f.readline()
474
+
475
+ if not first_line:
476
+ raise VersionNotPresent(f"Changelog file {self.name} is empty")
477
+
478
+ match = re.match(r"^[a-zA-Z0-9.+_-]+\s+\(([^)]+)\)", first_line)
479
+ if match:
480
+ full_version = match.group(1)
481
+ # Splits on '-' or '~' to strip the packaging revision (e.g., '1.3.3-1debian1' -> '1.3.3')
482
+ return re.split(r"[-~]", full_version)[0]
483
+
484
+ raise VersionNotPresent(
485
+ f"Could not parse Debian version from first line of {self.name}"
486
+ )
487
+
488
+ def _get_raw_full_version(self) -> str:
489
+ """Internal helper to get the un-stripped full version string with its suffix."""
490
+ with open(self.file_path, "r", encoding="utf-8") as f:
491
+ first_line = f.readline()
492
+ match = re.match(r"^[a-zA-Z0-9.+_-]+\s+\(([^)]+)\)", first_line)
493
+ return match.group(1) if match else ""
494
+
495
+ def update_version(self, new_version: str) -> None:
496
+ # Compare core upstream versions safely to avoid duplicate entries
497
+ try:
498
+ if self.get_version() == re.split(r"[-~]", new_version)[0]:
499
+ return # Skip adding a duplicate entry
500
+ except VersionNotPresent:
501
+ pass
502
+
503
+ # Grab the raw full version string to parse its suffix before reading full file
504
+ try:
505
+ current_full_version = self._get_raw_full_version()
506
+ except Exception:
507
+ current_full_version = ""
508
+
509
+ with open(self.file_path, "r", encoding="utf-8") as f:
510
+ content = f.read()
511
+
512
+ # Parse the first line to reuse metadata
513
+ first_line = content.splitlines()[0] if content.strip() else ""
514
+ match = re.match(
515
+ r"^([a-zA-Z0-9.+_-]+)\s+\([^)]+\)\s+([^;]+);\s*(urgency=\w+)",
516
+ first_line,
517
+ )
518
+
519
+ if match:
520
+ package_name = match.group(1)
521
+ distribution = match.group(2).strip()
522
+ urgency = match.group(3)
523
+ else:
524
+ package_name = "package"
525
+ distribution = "unstable"
526
+ urgency = "urgency=medium"
527
+
528
+ # Determine the final version string to write, preserving the suffix
529
+ new_upstream = re.split(r"[-~]", new_version)[0]
530
+ if "-" in new_version or "~" in new_version:
531
+ formatted_version = new_version
532
+ elif current_full_version and (
533
+ "-" in current_full_version or "~" in current_full_version
534
+ ):
535
+ suffix_match = re.search(r"([-~].*)$", current_full_version)
536
+ suffix = suffix_match.group(1) if suffix_match else "-1"
537
+ formatted_version = f"{new_upstream}{suffix}"
538
+ else:
539
+ formatted_version = f"{new_upstream}-1"
540
+
541
+ # Fetch maintainer details
542
+ repo_dir = self.file_path.parent
543
+ name_ok, git_name = run_git_command(["config", "user.name"], cwd=repo_dir)
544
+ email_ok, git_email = run_git_command(["config", "user.email"], cwd=repo_dir)
545
+
546
+ if name_ok and email_ok:
547
+ maintainer = f"{git_name} <{git_email}>"
548
+ else:
549
+ maintainer = "Maintainer <maintainer@example.com>"
550
+
551
+ # Format RFC 5322 timestamp
552
+ now = datetime.datetime.now(datetime.timezone.utc).astimezone()
553
+ debian_date = now.strftime("%a, %d %b %Y %H:%M:%S %z")
554
+
555
+ # Write out using the compliant version with the preserved suffix
556
+ new_entry = (
557
+ f"{package_name} ({formatted_version}) {distribution}; {urgency}\n\n"
558
+ f" * New release: version {new_upstream}\n\n"
559
+ f" -- {maintainer} {debian_date}\n\n"
560
+ )
561
+
562
+ updated_content = new_entry + content
563
+
564
+ with open(self.file_path, "w", encoding="utf-8") as f:
565
+ f.write(updated_content)
566
+
567
+
375
568
  class ChangelogVersionExtractor(VersionExtractor):
376
569
  def __init__(self, file_path: Path, pattern: str = ""):
377
570
  super().__init__(file_path)
@@ -393,6 +586,8 @@ class ChangelogVersionExtractor(VersionExtractor):
393
586
 
394
587
  today = datetime.date.today().isoformat()
395
588
 
589
+ # Insert new release section
590
+
396
591
  pattern = r"^## \[Unreleased\]"
397
592
  if not re.search(pattern, content, re.MULTILINE):
398
593
  console.print(
@@ -404,12 +599,221 @@ class ChangelogVersionExtractor(VersionExtractor):
404
599
 
405
600
  new_content = re.sub(pattern, replacement, content, count=1, flags=re.MULTILINE)
406
601
 
602
+ # Add new release link and update unreleased link
603
+
604
+ pattern = r"^\[Unreleased\]: (https://.+)/(v.+)\.\.\.HEAD$"
605
+
606
+ if match := re.search(pattern, new_content, re.MULTILINE):
607
+ url, old_version = match.groups()
608
+ replacement = f"[Unreleased]: {url}/v{new_version}...HEAD\n[{new_version}]: {url}/{old_version}...v{new_version}"
609
+ new_content = re.sub(pattern, replacement, new_content, flags=re.MULTILINE)
610
+ else:
611
+ console.print(
612
+ f"[{STYLE_WARNING}]Warning: Link definitions in CHANGELOG.md can't be updated automatically.[/{STYLE_WARNING}]"
613
+ )
614
+
407
615
  with open(self.file_path, "w", encoding="utf-8") as f:
408
616
  f.write(new_content)
409
617
 
410
- console.print(
411
- f"[{STYLE_INFO}]Updated CHANGELOG.md header. Note: Link definitions at the bottom were not updated automatically.[/{STYLE_INFO}]"
618
+ console.print(f"[{STYLE_INFO}]Updated CHANGELOG.md.[/{STYLE_INFO}]")
619
+
620
+
621
+ def git_ignored_abs_dirs(repo_dir: Path) -> set:
622
+ """Absolute paths of git-ignored directories for the repo containing ``repo_dir``.
623
+
624
+ Runs ``git ls-files --directory`` from ``repo_dir`` so git resolves the
625
+ enclosing repository itself; paths are reported relative to ``repo_dir`` and
626
+ resolved to absolute Paths. Empty when ``repo_dir`` is not inside a repo.
627
+
628
+ Used to prune ignored dirs (e.g. build/, output/) during discovery instead
629
+ of walking into them.
630
+ """
631
+ try:
632
+ result = subprocess.run(
633
+ [
634
+ "git",
635
+ "ls-files",
636
+ "--others",
637
+ "--ignored",
638
+ "--exclude-standard",
639
+ "--directory",
640
+ "-z",
641
+ ],
642
+ cwd=repo_dir,
643
+ capture_output=True,
644
+ text=True,
412
645
  )
646
+ except (FileNotFoundError, OSError):
647
+ return set()
648
+
649
+ if result.returncode != 0:
650
+ return set()
651
+
652
+ return {
653
+ (repo_dir / entry.rstrip("/")).resolve()
654
+ for entry in result.stdout.split("\0")
655
+ if entry
656
+ }
657
+
658
+
659
+ def drop_git_ignored(root_dir: Path, paths: List[Path]) -> List[Path]:
660
+ """Drop paths that git ignores, honoring each path's own enclosing repo.
661
+
662
+ Candidates may live in independent nested git repositories even when
663
+ ``root_dir`` is not itself a repo (e.g. a plain folder holding several
664
+ checkouts). Anchoring a single ``git check-ignore`` at ``root_dir`` would
665
+ then bail out entirely and leak ignored build trees like ``output/`` into
666
+ the results. Instead, resolve the repository that actually contains each
667
+ candidate and run ``git check-ignore`` there. Paths outside any repo are
668
+ always kept.
669
+ """
670
+ if not paths:
671
+ return paths
672
+
673
+ # Group candidates by the git repo top-level that contains them.
674
+ groups: Dict[Path, List[Path]] = {}
675
+ for p in paths:
676
+ ok, top = run_git_command(["rev-parse", "--show-toplevel"], p.parent)
677
+ if not ok or not top:
678
+ continue # not inside any repo -> always kept
679
+ groups.setdefault(Path(top).resolve(), []).append(p)
680
+
681
+ ignored: set = set()
682
+ for top_path, repo_paths in groups.items():
683
+ rel = [str(p.resolve().relative_to(top_path)) for p in repo_paths]
684
+ try:
685
+ result = subprocess.run(
686
+ ["git", "check-ignore", "-z", "--stdin"],
687
+ cwd=top_path,
688
+ input="\0".join(rel) + "\0",
689
+ capture_output=True,
690
+ text=True,
691
+ )
692
+ except (FileNotFoundError, OSError):
693
+ continue # keep this repo's candidates on git failure
694
+
695
+ # 0 = some ignored, 1 = none ignored; anything else is an error, keep all.
696
+ if result.returncode not in (0, 1):
697
+ continue
698
+
699
+ ignored_rel = {chunk for chunk in result.stdout.split("\0") if chunk}
700
+ ignored.update(p for p, r in zip(repo_paths, rel) if r in ignored_rel)
701
+
702
+ return [p for p in paths if p not in ignored]
703
+
704
+
705
+ def git_submodule_dirs(root_dir: Path) -> set:
706
+ """Absolute paths of git submodule working trees (empty outside a git repo)."""
707
+ ok, top = run_git_command(["rev-parse", "--show-toplevel"], root_dir)
708
+ if not ok or not top:
709
+ return set()
710
+ top = Path(top)
711
+
712
+ ok, out = run_git_command(
713
+ ["config", "--file", ".gitmodules", "-z", "--get-regexp", "path"], top
714
+ )
715
+ if not ok or not out:
716
+ return set()
717
+
718
+ dirs = set()
719
+ for record in out.split("\0"):
720
+ key, _, value = record.partition("\n")
721
+ if key.endswith(".path") and value:
722
+ dirs.add((top / value).resolve())
723
+ return dirs
724
+
725
+
726
+ def drop_submodules(root_dir: Path, paths: List[Path]) -> List[Path]:
727
+ """Drop paths inside git submodules (they are versioned independently)."""
728
+ if not paths:
729
+ return paths
730
+ subdirs = git_submodule_dirs(root_dir)
731
+ if not subdirs:
732
+ return paths
733
+ kept = []
734
+ for p in paths:
735
+ resolved = p.resolve()
736
+ if any(resolved == s or s in resolved.parents for s in subdirs):
737
+ continue
738
+ kept.append(p)
739
+ return kept
740
+
741
+
742
+ def discover_package_roots(root_dir: Path) -> List[Path]:
743
+ """Discover nested ROS package roots (dirs with a package.xml, root excluded).
744
+
745
+ Skips hidden directories (.git, .pixi, ...), git submodules, and — inside a
746
+ git repo — anything git ignores (.gitignore, global excludes), like `fd`.
747
+
748
+ Hidden and ignored directories are pruned during the walk so we never
749
+ descend into large trees like `.venv` or `build/`. Nested repositories are
750
+ handled too: each one's ignore rules are learned when its top-level is
751
+ reached, so an ignored `output/` build tree is pruned even when `root_dir`
752
+ itself is not a git repo.
753
+ """
754
+ # Seed from root_dir's own repo (covers root being, or living inside, a repo).
755
+ ignored_abs = git_ignored_abs_dirs(root_dir)
756
+ candidates = []
757
+ for dirpath, dirnames, filenames in os.walk(root_dir):
758
+ current = Path(dirpath)
759
+ # A nested repo brings its own .gitignore; learn what it ignores before
760
+ # descending, so its build trees are pruned regardless of root_dir.
761
+ if current != root_dir and (current / ".git").exists():
762
+ ignored_abs |= git_ignored_abs_dirs(current)
763
+ kept = []
764
+ for d in dirnames:
765
+ if d.startswith("."):
766
+ continue
767
+ if ignored_abs and (current / d).resolve() in ignored_abs:
768
+ continue
769
+ kept.append(d)
770
+ dirnames[:] = kept
771
+ if current != root_dir and "package.xml" in filenames:
772
+ candidates.append(current / "package.xml")
773
+ candidates = drop_git_ignored(root_dir, candidates)
774
+ candidates = drop_submodules(root_dir, candidates)
775
+ return sorted({package_xml.parent for package_xml in candidates})
776
+
777
+
778
+ def build_root_checks(root_dir: Path) -> List[VersionExtractor]:
779
+ """Build the version extractors tracked at the repository root."""
780
+ return [
781
+ XmlVersionExtractor(root_dir / "package.xml"),
782
+ TomlVersionExtractor(root_dir / "pyproject.toml", ["project", "version"]),
783
+ ChangelogVersionExtractor(root_dir / "CHANGELOG.md", r""),
784
+ TomlVersionExtractor(root_dir / "pixi.toml", ["workspace", "version"]),
785
+ YamlVersionExtractor(root_dir / "CITATION.cff", ["version"]),
786
+ CMakeListsVersionExtractor(root_dir / "CMakeLists.txt"),
787
+ DebianChangelogVersionExtractor(root_dir / "debian/changelog"),
788
+ ConanfileVersionExtractor(root_dir / "conanfile.py"),
789
+ ]
790
+
791
+
792
+ def collect_version_checks(root_dir: Path) -> List[VersionExtractor]:
793
+ """Root checks plus the same set of checks for each nested package.
794
+
795
+ Each nested package is treated exactly like the repository root: the same
796
+ file/key combinations are checked, and any that don't exist in the nested
797
+ package directory are simply skipped (VersionExtractor.check_file_exists()).
798
+ """
799
+ checks = list(build_root_checks(root_dir))
800
+ seen_paths = {check.file_path for check in checks}
801
+
802
+ for package_root in discover_package_roots(root_dir):
803
+ for check in build_root_checks(package_root):
804
+ if check.file_path in seen_paths:
805
+ continue
806
+ checks.append(check)
807
+ seen_paths.add(check.file_path)
808
+
809
+ # Label nested files by their relative path so identical basenames stay distinct.
810
+ for check in checks:
811
+ try:
812
+ check.label = str(check.file_path.relative_to(root_dir))
813
+ except ValueError:
814
+ check.label = check.file_path.name
815
+
816
+ return checks
413
817
 
414
818
 
415
819
  def validate_semver(version: str) -> str:
@@ -444,22 +848,25 @@ def bump_version(version: str, bump_type: str) -> str:
444
848
  raise ValueError(f"Invalid bump type: {bump_type}")
445
849
 
446
850
 
447
- def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
448
- """Get the current consensus version from all files."""
449
- versions_found = set()
450
- errors = []
451
-
851
+ def collect_versions(
852
+ checks: List[VersionExtractor],
853
+ ) -> Tuple[set, List[str]]:
854
+ """Collect distinct versions and parse errors across all files. No console output."""
855
+ versions: set = set()
856
+ errors: List[str] = []
452
857
  for check in checks:
453
858
  if check.check_file_exists():
454
859
  try:
455
- version = check.get_version()
456
- versions_found.add(version)
860
+ versions.add(check.get_version())
457
861
  except VersionNotPresent:
458
862
  pass # file exists but has no version configured; skip
459
863
  except Exception as e:
460
- errors.append(f"{check.name}: {e}")
864
+ errors.append(f"{check.label}: {e}")
865
+ return versions, errors
461
866
 
462
- # Report parsing errors
867
+
868
+ def report_version_problems(versions: set, errors: List[str]) -> None:
869
+ """Print parse warnings and version-mismatch errors from collect_versions()."""
463
870
  if errors:
464
871
  console.print(
465
872
  f"[{STYLE_WARNING}]Warning: Failed to parse version from some files:[/{STYLE_WARNING}]"
@@ -467,21 +874,25 @@ def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
467
874
  for error in errors:
468
875
  console.print(f" [{STYLE_MUTED}]• {error}[/{STYLE_MUTED}]")
469
876
 
470
- if len(versions_found) == 1:
471
- return list(versions_found)[0]
472
- elif len(versions_found) > 1:
877
+ if len(versions) > 1:
473
878
  console.print(
474
- f"[{STYLE_ERROR}]Error: Multiple versions found: {', '.join(sorted(versions_found))}[/{STYLE_ERROR}]"
879
+ f"[{STYLE_INFO}]Tip:[/{STYLE_INFO}] use --update-version X.Y.Z to set a single version across all files."
475
880
  )
476
881
  console.print(
477
- f"[{STYLE_WARNING}]Please run --check-version first to resolve conflicts.[/{STYLE_WARNING}]"
882
+ f"[{STYLE_ERROR}]Error: version mismatch — multiple versions found: "
883
+ f"{', '.join(sorted(versions))}[/{STYLE_ERROR}]"
478
884
  )
479
- return None
480
- else:
885
+ elif not versions:
481
886
  console.print(
482
887
  f"[{STYLE_ERROR}]Error: No version found in any files.[/{STYLE_ERROR}]"
483
888
  )
484
- return None
889
+
890
+
891
+ def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
892
+ """Consensus version across all files, reporting any problems."""
893
+ versions, errors = collect_versions(checks)
894
+ report_version_problems(versions, errors)
895
+ return next(iter(versions)) if len(versions) == 1 else None
485
896
 
486
897
 
487
898
  def infer_change_type(
@@ -677,7 +1088,7 @@ def git_tag_version(
677
1088
  custom_tag_name: Optional[str] = None,
678
1089
  custom_tag_message: Optional[str] = None,
679
1090
  sign: bool = False,
680
- ) -> bool:
1091
+ ) -> Tuple[bool, str]:
681
1092
  """Create a git tag for the version.
682
1093
 
683
1094
  When ``sign`` is True the tag is GPG-signed (``git tag -s``); otherwise an
@@ -688,7 +1099,7 @@ def git_tag_version(
688
1099
  console.print(
689
1100
  f"[{STYLE_WARNING}]Not a git repository, skipping git tag.[/{STYLE_WARNING}]"
690
1101
  )
691
- return False
1102
+ return False, ""
692
1103
 
693
1104
  tag_name = (
694
1105
  custom_tag_name.format(version=version) if custom_tag_name else f"v{version}"
@@ -704,7 +1115,7 @@ def git_tag_version(
704
1115
  console.print(
705
1116
  f"[{STYLE_WARNING}]Tag {tag_name} already exists.[/{STYLE_WARNING}]"
706
1117
  )
707
- return False
1118
+ return False, ""
708
1119
 
709
1120
  if not auto_confirm:
710
1121
  confirmed = Confirm.ask(
@@ -723,14 +1134,113 @@ def git_tag_version(
723
1134
  )
724
1135
  if success:
725
1136
  console.print(f"[{STYLE_SUCCESS}]✓ Created tag: {tag_name}[/{STYLE_SUCCESS}]")
726
- console.print(
727
- f"[{STYLE_MUTED}] To push: git push origin {tag_name}[/{STYLE_MUTED}]"
728
- )
729
- return True
1137
+ return True, tag_name
730
1138
  else:
731
1139
  console.print(f"[{STYLE_ERROR}]Failed to create tag: {output}[/{STYLE_ERROR}]")
1140
+ return False, ""
1141
+
1142
+
1143
+ def push_tag(root_dir: Path, tag_name: str, project_url: str) -> bool:
1144
+ url = project_url.replace(GITHUB_URL, "git@github.com:")
1145
+ git_args = ["push", url, tag_name]
1146
+ console.print(f"[{STYLE_MUTED}]$ git {' '.join(git_args)}")
1147
+ success, output = run_git_command(git_args, cwd=root_dir)
1148
+ if success:
1149
+ console.print(f"[{STYLE_SUCCESS}]✓ Pushed tag: {tag_name}[/{STYLE_SUCCESS}]")
1150
+ else:
1151
+ console.print(f"[{STYLE_ERROR}]Failed to push tag: {output}[/{STYLE_ERROR}]")
1152
+ return success
1153
+
1154
+
1155
+ def git_archive(
1156
+ root_dir: Path, archive_name: str, tag_name: str, sign: bool = False
1157
+ ) -> bool:
1158
+ """Create a .tar.gz archive from git for the version.
1159
+
1160
+ When ``sign`` is True, a detached GPG .tar.gz.sig file is added.
1161
+ """
1162
+ git_args = ["archive", "--format", "tgz", "--output", archive_name, tag_name]
1163
+ console.print(f"[{STYLE_MUTED}]$ git {' '.join(git_args)}")
1164
+ success, output = run_git_command(git_args, cwd=root_dir)
1165
+ if not success:
1166
+ console.print(
1167
+ f"[{STYLE_ERROR}]Failed to create archive: {output}[/{STYLE_ERROR}]"
1168
+ )
732
1169
  return False
733
1170
 
1171
+ if sign:
1172
+ call = [
1173
+ "gpg",
1174
+ "--detach-sign",
1175
+ "--armor",
1176
+ "-o",
1177
+ f"{archive_name}.sig",
1178
+ archive_name,
1179
+ ]
1180
+ console.print(f"[{STYLE_MUTED}]$ {' '.join(call)}")
1181
+ result = subprocess.run(call, cwd=root_dir, capture_output=True, text=True)
1182
+ if result.returncode != 0:
1183
+ console.print(
1184
+ f"[{STYLE_ERROR}]Failed to sign archive: {result.stderr}[/{STYLE_ERROR}]"
1185
+ )
1186
+ return False
1187
+
1188
+ return True
1189
+
1190
+
1191
+ def gh_release(
1192
+ root_dir: Path,
1193
+ target_version: str,
1194
+ project_url: str,
1195
+ archive_name: str | None = None,
1196
+ sign: bool = False,
1197
+ dry_run: bool = False,
1198
+ ) -> Tuple[bool, str]:
1199
+ """Create a github release and return (success, output)."""
1200
+
1201
+ title = f"Release v{target_version}"
1202
+ repo = project_url.removeprefix(GITHUB_URL)
1203
+ call = ["gh", "release", "create", "--title", title, "--repo", repo]
1204
+
1205
+ # include latest release notes
1206
+ changelog = root_dir / "CHANGELOG.md"
1207
+ if changelog.is_file():
1208
+ notes = changelog.read_text().split("\n## ")
1209
+ if len(notes) >= 3: # 0: header, 1: unreleased, 2: latest
1210
+ today = datetime.date.today().isoformat()
1211
+ call = [
1212
+ *call,
1213
+ "--notes",
1214
+ notes[1].replace("[Unreleased]", f"[{target_version}] - {today}")
1215
+ if dry_run
1216
+ else notes[2],
1217
+ ]
1218
+ else:
1219
+ console.print(
1220
+ f"[{STYLE_WARNING}]Warning: No release notes available in CHANGELOG.md yet.[/{STYLE_WARNING}]"
1221
+ )
1222
+
1223
+ call = [*call, f"v{target_version}"]
1224
+
1225
+ # include .tar.gz archive and .tar.gz.sig signature
1226
+ if archive_name is not None:
1227
+ call = [*call, archive_name]
1228
+ if sign:
1229
+ call = [*call, f"{archive_name}.sig"]
1230
+
1231
+ print_call = "$ '" + "' '".join(c.replace("'", "\\'") for c in call) + "'"
1232
+ if dry_run:
1233
+ return True, print_call
1234
+
1235
+ console.print(f"[{STYLE_MUTED}]$ {print_call}")
1236
+ try:
1237
+ result = subprocess.run(call, cwd=root_dir, capture_output=True, text=True)
1238
+ if result.returncode != 0:
1239
+ return False, result.stderr.strip()
1240
+ return True, result.stdout.strip()
1241
+ except subprocess.CalledProcessError as e:
1242
+ return False, e.stderr or ""
1243
+
734
1244
 
735
1245
  def update_pixi_lock(root_dir: Path, dry_run: bool = False) -> Optional[str]:
736
1246
  """Update pixi.lock file by running 'pixi list'.
@@ -799,9 +1309,10 @@ def create_backups(file_paths: List[Path]) -> Dict[Path, Path]:
799
1309
  backups = {}
800
1310
  temp_dir = Path(tempfile.mkdtemp(prefix="release_backup_"))
801
1311
 
802
- for file_path in file_paths:
1312
+ # Index-prefix the name so files sharing a basename don't collide.
1313
+ for index, file_path in enumerate(file_paths):
803
1314
  if file_path.exists():
804
- backup_path = temp_dir / file_path.name
1315
+ backup_path = temp_dir / f"{index:04d}_{file_path.name}"
805
1316
  shutil.copy2(file_path, backup_path)
806
1317
  backups[file_path] = backup_path
807
1318
 
@@ -856,16 +1367,16 @@ def list_version_files(checks: List[VersionExtractor]) -> None:
856
1367
  else f"[{STYLE_ERROR}]✗[/{STYLE_ERROR}]"
857
1368
  )
858
1369
  file_type = check.__class__.__name__.replace("VersionExtractor", "")
859
- table.add_row(check.name, str(check.file_path), exists, file_type)
1370
+ table.add_row(check.label, str(check.file_path), exists, file_type)
860
1371
 
861
1372
  console.print(table)
862
1373
  sys.exit(0)
863
1374
 
864
1375
 
865
- def handle_check_version(checks: List[VersionExtractor], args) -> int:
1376
+ def handle_check_version(checks: List[VersionExtractor], args) -> bool:
866
1377
  """Handle the --check-version command.
867
1378
 
868
- Returns the exit code.
1379
+ Returns True if all files agree on one version, False otherwise.
869
1380
  """
870
1381
  results = []
871
1382
  versions_found = set()
@@ -878,7 +1389,7 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
878
1389
 
879
1390
  for check in checks:
880
1391
  result = {
881
- "file": check.name,
1392
+ "file": check.label,
882
1393
  "version": None,
883
1394
  "status": "Unknown",
884
1395
  "message": "",
@@ -911,13 +1422,14 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
911
1422
  consensus_version = "MISMATCH"
912
1423
 
913
1424
  if args.output_format == "json":
1425
+ consistent = not errors and len(versions_found) == 1
914
1426
  out_payload = {
915
1427
  "consensus_version": consensus_version,
916
1428
  "files": results,
917
- "consistent": not errors and len(versions_found) == 1,
1429
+ "consistent": consistent,
918
1430
  }
919
1431
  print(json.dumps(out_payload, indent=2))
920
- return 1 if errors else 0
1432
+ return consistent
921
1433
 
922
1434
  # Standard Rich table output
923
1435
  table = Table(title="Version Check Summary", box=box.ROUNDED)
@@ -946,7 +1458,7 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
946
1458
  ):
947
1459
  version_display = f"[{STYLE_SUCCESS}]{res['version']}[/{STYLE_SUCCESS}]"
948
1460
  elif consensus_version == "MISMATCH":
949
- version_display = f"[{STYLE_WARNING}]{res['version']}[/{STYLE_WARNING}]"
1461
+ version_display = f"[{STYLE_ERROR}]{res['version']}[/{STYLE_ERROR}]"
950
1462
 
951
1463
  table.add_row(res["file"], version_display, status_style, res["message"])
952
1464
 
@@ -956,27 +1468,53 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
956
1468
  if args.short and consensus_version and consensus_version != "MISMATCH":
957
1469
  print(consensus_version)
958
1470
 
1471
+ tag_check_failed = False
1472
+ tag_format_is_valid = False
1473
+ if hasattr(args, "check_tag") and args.check_tag:
1474
+ if not args.check_tag.startswith("v"):
1475
+ tag_check_failed = True
1476
+ errors = True
1477
+ else:
1478
+ tag_version = args.check_tag.lstrip("v")
1479
+ if consensus_version and consensus_version != "MISMATCH":
1480
+ if consensus_version != tag_version:
1481
+ tag_check_failed = True
1482
+ tag_format_is_valid = True
1483
+ errors = True
1484
+
959
1485
  if errors:
960
1486
  if len(versions_found) > 1:
961
1487
  console.print(
962
- f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Found conflicting versions: {', '.join(sorted(versions_found))}"
1488
+ f"\n[{STYLE_INFO}]Tip:[/{STYLE_INFO}] use --update-version X.Y.Z to set a single version across all files."
963
1489
  )
1490
+ console.print(
1491
+ f"[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] version mismatch — conflicting versions: {', '.join(sorted(versions_found))}"
1492
+ )
1493
+ elif tag_check_failed:
1494
+ if not tag_format_is_valid:
1495
+ console.print(
1496
+ 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."
1497
+ )
1498
+ else:
1499
+ console.print(
1500
+ f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Tag version ({args.check_tag.lstrip('v')}) does not match consensus version ({consensus_version})."
1501
+ )
964
1502
  else:
965
1503
  console.print(
966
- f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Errors encountered (parsing errors)."
1504
+ f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] parsing errors encountered."
967
1505
  )
968
- return 1
1506
+ return False
969
1507
  elif not versions_found:
970
1508
  console.print(
971
1509
  f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] No version files found in {args.root}."
972
1510
  )
973
- return 1
1511
+ return False
974
1512
  else:
975
1513
  if not args.short:
976
1514
  console.print(
977
1515
  f"\n[{STYLE_SUCCESS_STRONG}]SUCCESS:[/{STYLE_SUCCESS_STRONG}] All files match version [{STYLE_SUCCESS}]{consensus_version}[/{STYLE_SUCCESS}]."
978
1516
  )
979
- return 0
1517
+ return True
980
1518
 
981
1519
 
982
1520
  def perform_version_updates(
@@ -999,7 +1537,7 @@ def perform_version_updates(
999
1537
  try:
1000
1538
  if dry_run:
1001
1539
  curr = check.get_version()
1002
- dry_run_rows.append((check.name, curr, target_version))
1540
+ dry_run_rows.append((check.label, curr, target_version))
1003
1541
  else:
1004
1542
  try:
1005
1543
  old_version = check.get_version()
@@ -1008,20 +1546,20 @@ def perform_version_updates(
1008
1546
  except Exception:
1009
1547
  old_version = "?"
1010
1548
  check.update_version(target_version)
1011
- dry_run_rows.append((check.name, old_version, target_version))
1549
+ dry_run_rows.append((check.label, old_version, target_version))
1012
1550
  line = Text()
1013
- line.append(f" {check.name:<22}", style="cyan")
1551
+ line.append(f" {check.label:<28}", style="cyan")
1014
1552
  line.append(old_version, style=STYLE_OLD_VALUE)
1015
1553
  line.append(" → ", style="dim")
1016
1554
  line.append(target_version, style=STYLE_NEW_VALUE)
1017
1555
  console.print(line)
1018
- updated_files.append(check.name)
1556
+ updated_files.append(check.label)
1019
1557
  updated_file_paths.append(str(check.file_path))
1020
1558
  except VersionNotPresent:
1021
1559
  pass # file exists but has no version configured; skip
1022
1560
  except Exception as e:
1023
1561
  console.print(
1024
- f"[{STYLE_ERROR}]Failed to update {check.name}: {e}[/{STYLE_ERROR}]"
1562
+ f"[{STYLE_ERROR}]Failed to update {check.label}: {e}[/{STYLE_ERROR}]"
1025
1563
  )
1026
1564
  if not dry_run:
1027
1565
  failed = True
@@ -1044,14 +1582,14 @@ def show_dry_run_panel(
1044
1582
  console.print(f" [dim]{'─' * 44}[/dim]")
1045
1583
  for name, old, new in dry_run_rows:
1046
1584
  line = Text()
1047
- line.append(f" {name:<22}", style="cyan")
1585
+ line.append(f" {name:<28}", style="cyan")
1048
1586
  line.append(old, style=STYLE_OLD_VALUE)
1049
1587
  line.append(" → ", style="dim")
1050
1588
  line.append(new, style=STYLE_NEW_VALUE)
1051
1589
  console.print(line)
1052
1590
  if pixi_lock_would_update:
1053
1591
  line = Text()
1054
- line.append(f" {'pixi.lock':<22}", style="cyan")
1592
+ line.append(f" {'pixi.lock':<28}", style="cyan")
1055
1593
  line.append("regenerated via pixi list", style="dim")
1056
1594
  console.print(line)
1057
1595
 
@@ -1070,7 +1608,7 @@ def show_result_panel(
1070
1608
  """Display a polished summary of completed version updates."""
1071
1609
  if pixi_lock_updated:
1072
1610
  line = Text()
1073
- line.append(f" {'pixi.lock':<22}", style="cyan")
1611
+ line.append(f" {'pixi.lock':<28}", style="cyan")
1074
1612
  line.append("regenerated via pixi list", style="dim")
1075
1613
  console.print(line)
1076
1614
  console.print()
@@ -1164,6 +1702,28 @@ def main():
1164
1702
  action="store_true",
1165
1703
  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
1704
  )
1705
+ parser.add_argument(
1706
+ "--push-tag",
1707
+ action="store_true",
1708
+ help="Push the tag to the main repository. Only works with github for now.",
1709
+ )
1710
+
1711
+ parser.add_argument(
1712
+ "--git-archive",
1713
+ action="store_true",
1714
+ help="Create a .tar.gz archive of the project with git",
1715
+ )
1716
+ parser.add_argument(
1717
+ "--sign-archive",
1718
+ action="store_true",
1719
+ help="GPG-sign the git archive. Requires a configured signing key. Only meaningful with --git-archive.",
1720
+ )
1721
+ parser.add_argument(
1722
+ "--gh-release",
1723
+ action="store_true",
1724
+ help="Create Github release. Requires a configured gh cli.",
1725
+ )
1726
+
1167
1727
  parser.add_argument(
1168
1728
  "--short",
1169
1729
  action="store_true",
@@ -1177,6 +1737,11 @@ def main():
1177
1737
  )
1178
1738
 
1179
1739
  group = parser.add_mutually_exclusive_group(required=True)
1740
+ group.add_argument(
1741
+ "--version",
1742
+ action="store_true",
1743
+ help="Show jrl-cmakemodules-scripts version and exit",
1744
+ )
1180
1745
  group.add_argument(
1181
1746
  "--check-version", action="store_true", help="Check versions across files."
1182
1747
  )
@@ -1196,7 +1761,18 @@ def main():
1196
1761
  help="Bump the project version.",
1197
1762
  )
1198
1763
 
1764
+ parser.add_argument(
1765
+ "--check-tag",
1766
+ type=str,
1767
+ help="Check that the consensus version matches the given tag (e.g., v2.15.0). Only meaningful with --check-version.",
1768
+ )
1769
+
1199
1770
  args = parser.parse_args()
1771
+
1772
+ if args.version:
1773
+ print(importlib.metadata.version("jrl_cmakemodules_scripts"))
1774
+ sys.exit(0)
1775
+
1200
1776
  root_dir = args.root
1201
1777
 
1202
1778
  # Redirect console output to stderr for clean stdout with json/short
@@ -1206,6 +1782,9 @@ def main():
1206
1782
  else:
1207
1783
  console = Console()
1208
1784
 
1785
+ if args.check_tag and not args.check_version:
1786
+ parser.error("--check-tag requires --check-version")
1787
+
1209
1788
  if args.update_version:
1210
1789
  try:
1211
1790
  if not re.match(r"^\d+\.\d+\.\d+$", args.update_version):
@@ -1219,14 +1798,7 @@ def main():
1219
1798
  )
1220
1799
  sys.exit(1)
1221
1800
 
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
- ]
1801
+ checks: List[VersionExtractor] = collect_version_checks(root_dir)
1230
1802
 
1231
1803
  if args.list_files:
1232
1804
  if args.output_format == "json":
@@ -1234,7 +1806,7 @@ def main():
1234
1806
  for check in checks:
1235
1807
  files_list.append(
1236
1808
  {
1237
- "name": check.name,
1809
+ "name": check.label,
1238
1810
  "path": str(check.file_path),
1239
1811
  "exists": check.check_file_exists(),
1240
1812
  "type": check.__class__.__name__.replace(
@@ -1248,14 +1820,15 @@ def main():
1248
1820
  sys.exit(0)
1249
1821
 
1250
1822
  if args.check_version:
1251
- sys.exit(handle_check_version(checks, args))
1823
+ sys.exit(0 if handle_check_version(checks, args) else 1)
1252
1824
 
1253
1825
  current_version = None
1254
1826
  new_version_str = None
1255
1827
 
1256
1828
  if args.update_version:
1257
1829
  new_version_str = args.update_version
1258
- current_version = get_current_version(checks)
1830
+ versions, _ = collect_versions(checks)
1831
+ current_version = next(iter(versions)) if len(versions) == 1 else None
1259
1832
  if not args.dry_run:
1260
1833
  console.print(
1261
1834
  f"[{STYLE_INFO}]Updating versions to {new_version_str} in {root_dir}...[/{STYLE_INFO}]"
@@ -1370,6 +1943,22 @@ def main():
1370
1943
  elif args.short:
1371
1944
  print(target_version)
1372
1945
 
1946
+ project_url = ""
1947
+ archive_name = ""
1948
+ if args.push_tag:
1949
+ for check in checks:
1950
+ if url := check.get_url():
1951
+ project_url = url
1952
+ break
1953
+ else:
1954
+ where = "CMakeLists.txt, package.xml, or pyproject.toml"
1955
+ console.print(
1956
+ f"[{STYLE_ERROR_STRONG}]Error: can't push tag without a github homepage in {where}.[/{STYLE_ERROR_STRONG}]"
1957
+ )
1958
+ sys.exit(1)
1959
+ if args.git_archive:
1960
+ archive_name = f"{project_url.split('/')[-1]}-{target_version}.tar.gz"
1961
+
1373
1962
  if args.dry_run:
1374
1963
  pixi_lock_would_update = (root_dir / "pixi.lock").exists()
1375
1964
 
@@ -1388,6 +1977,7 @@ def main():
1388
1977
  )
1389
1978
  git_lines.append(f"$ git add {' '.join(rel_paths) if rel_paths else '-u'}")
1390
1979
  git_lines.append(f"$ git commit -m '{commit_message}'")
1980
+
1391
1981
  if args.git_tag is not None:
1392
1982
  custom_tag_name = None if args.git_tag is True else args.git_tag
1393
1983
  tag_name = (
@@ -1402,6 +1992,30 @@ def main():
1402
1992
  )
1403
1993
  tag_flag = "-s" if args.sign_tag else "-a"
1404
1994
  git_lines.append(f"$ git tag {tag_flag} {tag_name} -m '{tag_message}'")
1995
+ if args.push_tag:
1996
+ url = project_url.replace(GITHUB_URL, "git@github.com:")
1997
+ git_lines.append(f"$ git push {url} {tag_name}")
1998
+
1999
+ if args.git_archive:
2000
+ git_lines.append(
2001
+ f"$ git archive --format tgz --output {archive_name} {tag_name}"
2002
+ )
2003
+
2004
+ if args.sign_archive:
2005
+ git_lines.append(
2006
+ f"$ gpg --detach-sign --armor -o {archive_name}.sig {archive_name}"
2007
+ )
2008
+
2009
+ if args.gh_release:
2010
+ _, call = gh_release(
2011
+ root_dir,
2012
+ target_version,
2013
+ project_url,
2014
+ archive_name,
2015
+ args.sign_archive,
2016
+ dry_run=True,
2017
+ )
2018
+ git_lines.append(call)
1405
2019
 
1406
2020
  show_dry_run_panel(dry_run_rows, pixi_lock_would_update, git_lines)
1407
2021
  sys.exit(0)
@@ -1420,6 +2034,31 @@ def main():
1420
2034
  f"[{STYLE_WARNING}]Warning: --sign-tag has no effect without --git-tag.[/{STYLE_WARNING}]"
1421
2035
  )
1422
2036
 
2037
+ if args.gh_release and not args.push_tag:
2038
+ console.print(
2039
+ f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --push-tag.[/{STYLE_WARNING}]"
2040
+ )
2041
+
2042
+ if args.sign_archive and args.git_archive is None:
2043
+ console.print(
2044
+ f"[{STYLE_WARNING}]Warning: --sign-archive has no effect without --git-archive.[/{STYLE_WARNING}]"
2045
+ )
2046
+
2047
+ if args.git_archive and (root_dir / archive_name).is_file():
2048
+ console.print(
2049
+ f"[{STYLE_WARNING_STRONG}]Warning: {archive_name} already exists ![/{STYLE_WARNING_STRONG}]"
2050
+ )
2051
+ if Confirm.ask(
2052
+ "[bold]Remove this file (and the .sig if it is also there) and continue ?[/bold]",
2053
+ default=True,
2054
+ ):
2055
+ (root_dir / archive_name).unlink()
2056
+ sig = root_dir / f"{archive_name}.sig"
2057
+ if sig.is_file():
2058
+ sig.unlink()
2059
+ else:
2060
+ sys.exit(1)
2061
+
1423
2062
  if args.git_commit is not None:
1424
2063
  custom_message = None if args.git_commit is True else args.git_commit
1425
2064
  git_commit_version(
@@ -1432,7 +2071,7 @@ def main():
1432
2071
 
1433
2072
  if args.git_tag is not None:
1434
2073
  custom_tag_name = None if args.git_tag is True else args.git_tag
1435
- git_tag_version(
2074
+ _success, tag_name = git_tag_version(
1436
2075
  root_dir,
1437
2076
  target_version,
1438
2077
  args.confirm,
@@ -1441,6 +2080,36 @@ def main():
1441
2080
  args.sign_tag,
1442
2081
  )
1443
2082
 
2083
+ if args.push_tag:
2084
+ success = push_tag(root_dir, tag_name, project_url)
2085
+ if not success:
2086
+ sys.exit(1)
2087
+
2088
+ if args.git_archive and archive_name:
2089
+ success = git_archive(root_dir, archive_name, tag_name, args.sign_archive)
2090
+ if not success:
2091
+ sys.exit(1)
2092
+
2093
+ if args.gh_release:
2094
+ if args.git_tag is None:
2095
+ console.print(
2096
+ f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --git-tag.[/{STYLE_WARNING}]"
2097
+ )
2098
+ elif not args.push_tag:
2099
+ console.print(
2100
+ f"[{STYLE_WARNING}]Warning: --gh-release has no effect without --push-tag.[/{STYLE_WARNING}]"
2101
+ )
2102
+ else:
2103
+ success, _ = gh_release(
2104
+ root_dir,
2105
+ target_version,
2106
+ project_url,
2107
+ archive_name,
2108
+ args.sign_archive,
2109
+ )
2110
+ if not success:
2111
+ sys.exit(1)
2112
+
1444
2113
 
1445
2114
  if __name__ == "__main__":
1446
2115
  try:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jrl-cmakemodules-scripts"
7
- version = "2.1.0"
7
+ version = "2.3.0"
8
8
  description = "Release scripting tools for JRL CMake modules"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"