jrl-cmakemodules-scripts 2.0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: jrl-cmakemodules-scripts
3
- Version: 2.0.0
3
+ Version: 2.2.4
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.0.0
3
+ Version: 2.2.4
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
@@ -56,6 +56,11 @@ uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
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
+ | `--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. |
59
64
 
60
65
  **Git defaults**: commit `chore: bump version to {version}`, tag `v{version}`, tag message `Release version {version}`.
61
66
 
@@ -63,19 +68,34 @@ uv run --no-project jrl_release.py --bump patch --git-commit --git-tag
63
68
 
64
69
  | File | Key |
65
70
  | :--- | :--- |
66
- | `package.xml` | `<version>` tag |
67
- | `pyproject.toml` | `project.version` |
71
+ | `package.xml` | `<version> and <url>` tag |
72
+ | `pyproject.toml` | `project.version` and `project.urls` |
68
73
  | `CHANGELOG.md` | First `## [X.Y.Z]` section (not Unreleased) |
69
74
  | `pixi.toml` | `[workspace] version` |
70
75
  | `pixi.lock` | Regenerated via `pixi list` |
71
76
  | `CITATION.cff` | `version` key |
72
- | `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 = ...` |
73
80
 
74
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.
75
94
  """
76
95
 
77
96
  import sys
78
97
  import re
98
+ import os
79
99
  import argparse
80
100
  import datetime
81
101
  import json
@@ -121,6 +141,8 @@ STYLE_NEW_VALUE = "green"
121
141
  STYLE_UNCHANGED_VALUE = "dim"
122
142
  STYLE_HIGHLIGHT = "cyan"
123
143
 
144
+ GITHUB_URL = "https://github.com/"
145
+
124
146
 
125
147
  class VersionNotPresent(Exception):
126
148
  """Raised when a file exists but has no version field configured."""
@@ -131,6 +153,8 @@ class VersionNotPresent(Exception):
131
153
  class VersionExtractor(ABC):
132
154
  def __init__(self, file_path: Path):
133
155
  self.file_path = file_path
156
+ # Display label; discovery overrides it with a root-relative path.
157
+ self.label = file_path.name
134
158
 
135
159
  @abstractmethod
136
160
  def get_version(self) -> str:
@@ -151,6 +175,17 @@ class VersionExtractor(ABC):
151
175
  def path(self) -> str:
152
176
  return str(self.file_path)
153
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
+
154
189
 
155
190
  class XmlVersionExtractor(VersionExtractor):
156
191
  def get_version(self) -> str:
@@ -177,6 +212,14 @@ class XmlVersionExtractor(VersionExtractor):
177
212
  with open(self.file_path, "w", encoding="utf-8") as f:
178
213
  f.write(new_content)
179
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
+
180
223
 
181
224
  class TomlVersionExtractor(VersionExtractor):
182
225
  def __init__(self, file_path: Path, keys: List[str]):
@@ -215,6 +258,15 @@ class TomlVersionExtractor(VersionExtractor):
215
258
  with open(self.file_path, "w", encoding="utf-8") as f:
216
259
  tomlkit.dump(data, f)
217
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
+
218
270
 
219
271
  class YamlVersionExtractor(VersionExtractor):
220
272
  def __init__(self, file_path: Path, keys: List[str]):
@@ -252,6 +304,24 @@ class YamlVersionExtractor(VersionExtractor):
252
304
  self.yaml.dump(data, f)
253
305
 
254
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
+
255
325
  class CMakeListsVersionExtractor(VersionExtractor):
256
326
  """Specialized extractor for CMakeLists.txt that uses cmake-parser
257
327
  and handles both direct VERSION and variables (e.g., from package.xml)."""
@@ -287,7 +357,7 @@ class CMakeListsVersionExtractor(VersionExtractor):
287
357
  ver = args[version_idx + 1]
288
358
  # Check if it's a variable reference
289
359
  if not ver.startswith("${"):
290
- project_version = ver
360
+ project_version = ver.strip('"')
291
361
  except ValueError:
292
362
  pass
293
363
 
@@ -304,6 +374,13 @@ class CMakeListsVersionExtractor(VersionExtractor):
304
374
 
305
375
  return self._get_version_regex(content)
306
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
+
307
384
  def _get_command_args(self, node) -> List[str]:
308
385
  """Extract arguments from a cmake command node."""
309
386
  args = []
@@ -315,16 +392,16 @@ class CMakeListsVersionExtractor(VersionExtractor):
315
392
 
316
393
  def _get_version_regex(self, content: str) -> str:
317
394
  """Fallback regex-based version extraction."""
318
- # First try to find set(PROJECT_VERSION "X.Y.Z")
395
+ # Finds set(PROJECT_VERSION "X.Y.Z") or set(PROJECT_VERSION X.Y.Z)
319
396
  fallback_pattern = re.compile(
320
- 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*\)',
321
398
  re.MULTILINE,
322
399
  )
323
400
  fallback_match = fallback_pattern.search(content)
324
401
 
325
402
  # Also check if project() uses a literal version or variable
326
403
  project_pattern = re.compile(
327
- r"project\s*\([^)]*VERSION\s+([\d.]+|\$\{[^}]+\})", re.MULTILINE
404
+ r'project\s*\([^)]*VERSION\s+"?([\d.]+|\$\{[^}]+\})"?', re.MULTILINE
328
405
  )
329
406
  project_match = project_pattern.search(content)
330
407
 
@@ -346,20 +423,22 @@ class CMakeListsVersionExtractor(VersionExtractor):
346
423
  with open(self.file_path, "r", encoding="utf-8") as f:
347
424
  content = f.read()
348
425
 
349
- # Update the fallback version in set(PROJECT_VERSION "...")
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)
350
428
  fallback_pattern = re.compile(
351
- 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*\)',
352
430
  re.MULTILINE,
353
431
  )
354
432
 
355
433
  def repl_fallback(match):
356
- return f'{match.group(1)}"{new_version}"'
434
+ return f"{match.group(1)}{new_version})"
357
435
 
358
436
  content = fallback_pattern.sub(repl_fallback, content, count=1)
359
437
 
360
- # Also update literal version in project() if present
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
361
440
  project_pattern = re.compile(
362
- r"(project\s*\([^)]*VERSION\s+)([\d.]+)", re.MULTILINE
441
+ r'(project\s*\([^)]*VERSION\s+)"?([\d.]+)"?', re.MULTILINE
363
442
  )
364
443
 
365
444
  def repl_project(match):
@@ -371,6 +450,114 @@ class CMakeListsVersionExtractor(VersionExtractor):
371
450
  f.write(content)
372
451
 
373
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
+
374
561
  class ChangelogVersionExtractor(VersionExtractor):
375
562
  def __init__(self, file_path: Path, pattern: str = ""):
376
563
  super().__init__(file_path)
@@ -392,6 +579,8 @@ class ChangelogVersionExtractor(VersionExtractor):
392
579
 
393
580
  today = datetime.date.today().isoformat()
394
581
 
582
+ # Insert new release section
583
+
395
584
  pattern = r"^## \[Unreleased\]"
396
585
  if not re.search(pattern, content, re.MULTILINE):
397
586
  console.print(
@@ -403,12 +592,221 @@ class ChangelogVersionExtractor(VersionExtractor):
403
592
 
404
593
  new_content = re.sub(pattern, replacement, content, count=1, flags=re.MULTILINE)
405
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
+
406
608
  with open(self.file_path, "w", encoding="utf-8") as f:
407
609
  f.write(new_content)
408
610
 
409
- console.print(
410
- f"[{STYLE_INFO}]Updated CHANGELOG.md header. Note: Link definitions at the bottom were not updated automatically.[/{STYLE_INFO}]"
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,
411
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
412
810
 
413
811
 
414
812
  def validate_semver(version: str) -> str:
@@ -443,22 +841,25 @@ def bump_version(version: str, bump_type: str) -> str:
443
841
  raise ValueError(f"Invalid bump type: {bump_type}")
444
842
 
445
843
 
446
- def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
447
- """Get the current consensus version from all files."""
448
- versions_found = set()
449
- errors = []
450
-
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] = []
451
850
  for check in checks:
452
851
  if check.check_file_exists():
453
852
  try:
454
- version = check.get_version()
455
- versions_found.add(version)
853
+ versions.add(check.get_version())
456
854
  except VersionNotPresent:
457
855
  pass # file exists but has no version configured; skip
458
856
  except Exception as e:
459
- errors.append(f"{check.name}: {e}")
857
+ errors.append(f"{check.label}: {e}")
858
+ return versions, errors
460
859
 
461
- # Report parsing errors
860
+
861
+ def report_version_problems(versions: set, errors: List[str]) -> None:
862
+ """Print parse warnings and version-mismatch errors from collect_versions()."""
462
863
  if errors:
463
864
  console.print(
464
865
  f"[{STYLE_WARNING}]Warning: Failed to parse version from some files:[/{STYLE_WARNING}]"
@@ -466,21 +867,25 @@ def get_current_version(checks: List[VersionExtractor]) -> Optional[str]:
466
867
  for error in errors:
467
868
  console.print(f" [{STYLE_MUTED}]• {error}[/{STYLE_MUTED}]")
468
869
 
469
- if len(versions_found) == 1:
470
- return list(versions_found)[0]
471
- elif len(versions_found) > 1:
870
+ if len(versions) > 1:
472
871
  console.print(
473
- f"[{STYLE_ERROR}]Error: Multiple versions found: {', '.join(sorted(versions_found))}[/{STYLE_ERROR}]"
872
+ f"[{STYLE_INFO}]Tip:[/{STYLE_INFO}] use --update-version X.Y.Z to set a single version across all files."
474
873
  )
475
874
  console.print(
476
- f"[{STYLE_WARNING}]Please run --check-version first to resolve conflicts.[/{STYLE_WARNING}]"
875
+ f"[{STYLE_ERROR}]Error: version mismatch — multiple versions found: "
876
+ f"{', '.join(sorted(versions))}[/{STYLE_ERROR}]"
477
877
  )
478
- return None
479
- else:
878
+ elif not versions:
480
879
  console.print(
481
880
  f"[{STYLE_ERROR}]Error: No version found in any files.[/{STYLE_ERROR}]"
482
881
  )
483
- return None
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
484
889
 
485
890
 
486
891
  def infer_change_type(
@@ -675,14 +1080,19 @@ def git_tag_version(
675
1080
  auto_confirm: bool,
676
1081
  custom_tag_name: Optional[str] = None,
677
1082
  custom_tag_message: Optional[str] = None,
678
- ) -> bool:
679
- """Create a git tag for the version."""
1083
+ sign: bool = False,
1084
+ ) -> Tuple[bool, str]:
1085
+ """Create a git tag for the version.
1086
+
1087
+ When ``sign`` is True the tag is GPG-signed (``git tag -s``); otherwise an
1088
+ annotated tag is created (``git tag -a``).
1089
+ """
680
1090
  success, _ = run_git_command(["rev-parse", "--git-dir"], root_dir)
681
1091
  if not success:
682
1092
  console.print(
683
1093
  f"[{STYLE_WARNING}]Not a git repository, skipping git tag.[/{STYLE_WARNING}]"
684
1094
  )
685
- return False
1095
+ return False, ""
686
1096
 
687
1097
  tag_name = (
688
1098
  custom_tag_name.format(version=version) if custom_tag_name else f"v{version}"
@@ -698,7 +1108,7 @@ def git_tag_version(
698
1108
  console.print(
699
1109
  f"[{STYLE_WARNING}]Tag {tag_name} already exists.[/{STYLE_WARNING}]"
700
1110
  )
701
- return False
1111
+ return False, ""
702
1112
 
703
1113
  if not auto_confirm:
704
1114
  confirmed = Confirm.ask(
@@ -708,22 +1118,122 @@ def git_tag_version(
708
1118
  console.print(f"[{STYLE_WARNING}]Git tag skipped.[/{STYLE_WARNING}]")
709
1119
  return False
710
1120
 
1121
+ tag_flag = "-s" if sign else "-a"
711
1122
  console.print(
712
- f"[{STYLE_MUTED}]$ git tag -a {tag_name} -m '{tag_message}'[/{STYLE_MUTED}]"
1123
+ f"[{STYLE_MUTED}]$ git tag {tag_flag} {tag_name} -m '{tag_message}'[/{STYLE_MUTED}]"
713
1124
  )
714
1125
  success, output = run_git_command(
715
- ["tag", "-a", tag_name, "-m", tag_message], root_dir
1126
+ ["tag", tag_flag, tag_name, "-m", tag_message], root_dir
716
1127
  )
717
1128
  if success:
718
1129
  console.print(f"[{STYLE_SUCCESS}]✓ Created tag: {tag_name}[/{STYLE_SUCCESS}]")
719
- console.print(
720
- f"[{STYLE_MUTED}] To push: git push origin {tag_name}[/{STYLE_MUTED}]"
721
- )
722
- return True
1130
+ return True, tag_name
723
1131
  else:
724
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
+ )
725
1162
  return False
726
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
+
727
1237
 
728
1238
  def update_pixi_lock(root_dir: Path, dry_run: bool = False) -> Optional[str]:
729
1239
  """Update pixi.lock file by running 'pixi list'.
@@ -792,9 +1302,10 @@ def create_backups(file_paths: List[Path]) -> Dict[Path, Path]:
792
1302
  backups = {}
793
1303
  temp_dir = Path(tempfile.mkdtemp(prefix="release_backup_"))
794
1304
 
795
- for file_path in file_paths:
1305
+ # Index-prefix the name so files sharing a basename don't collide.
1306
+ for index, file_path in enumerate(file_paths):
796
1307
  if file_path.exists():
797
- backup_path = temp_dir / file_path.name
1308
+ backup_path = temp_dir / f"{index:04d}_{file_path.name}"
798
1309
  shutil.copy2(file_path, backup_path)
799
1310
  backups[file_path] = backup_path
800
1311
 
@@ -849,16 +1360,16 @@ def list_version_files(checks: List[VersionExtractor]) -> None:
849
1360
  else f"[{STYLE_ERROR}]✗[/{STYLE_ERROR}]"
850
1361
  )
851
1362
  file_type = check.__class__.__name__.replace("VersionExtractor", "")
852
- table.add_row(check.name, str(check.file_path), exists, file_type)
1363
+ table.add_row(check.label, str(check.file_path), exists, file_type)
853
1364
 
854
1365
  console.print(table)
855
1366
  sys.exit(0)
856
1367
 
857
1368
 
858
- def handle_check_version(checks: List[VersionExtractor], args) -> int:
1369
+ def handle_check_version(checks: List[VersionExtractor], args) -> bool:
859
1370
  """Handle the --check-version command.
860
1371
 
861
- Returns the exit code.
1372
+ Returns True if all files agree on one version, False otherwise.
862
1373
  """
863
1374
  results = []
864
1375
  versions_found = set()
@@ -871,7 +1382,7 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
871
1382
 
872
1383
  for check in checks:
873
1384
  result = {
874
- "file": check.name,
1385
+ "file": check.label,
875
1386
  "version": None,
876
1387
  "status": "Unknown",
877
1388
  "message": "",
@@ -904,13 +1415,14 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
904
1415
  consensus_version = "MISMATCH"
905
1416
 
906
1417
  if args.output_format == "json":
1418
+ consistent = not errors and len(versions_found) == 1
907
1419
  out_payload = {
908
1420
  "consensus_version": consensus_version,
909
1421
  "files": results,
910
- "consistent": not errors and len(versions_found) == 1,
1422
+ "consistent": consistent,
911
1423
  }
912
1424
  print(json.dumps(out_payload, indent=2))
913
- return 1 if errors else 0
1425
+ return consistent
914
1426
 
915
1427
  # Standard Rich table output
916
1428
  table = Table(title="Version Check Summary", box=box.ROUNDED)
@@ -939,7 +1451,7 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
939
1451
  ):
940
1452
  version_display = f"[{STYLE_SUCCESS}]{res['version']}[/{STYLE_SUCCESS}]"
941
1453
  elif consensus_version == "MISMATCH":
942
- version_display = f"[{STYLE_WARNING}]{res['version']}[/{STYLE_WARNING}]"
1454
+ version_display = f"[{STYLE_ERROR}]{res['version']}[/{STYLE_ERROR}]"
943
1455
 
944
1456
  table.add_row(res["file"], version_display, status_style, res["message"])
945
1457
 
@@ -949,27 +1461,53 @@ def handle_check_version(checks: List[VersionExtractor], args) -> int:
949
1461
  if args.short and consensus_version and consensus_version != "MISMATCH":
950
1462
  print(consensus_version)
951
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
+
952
1478
  if errors:
953
1479
  if len(versions_found) > 1:
954
1480
  console.print(
955
- f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Found conflicting versions: {', '.join(sorted(versions_found))}"
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))}"
956
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
+ )
957
1495
  else:
958
1496
  console.print(
959
- f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] Errors encountered (parsing errors)."
1497
+ f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] parsing errors encountered."
960
1498
  )
961
- return 1
1499
+ return False
962
1500
  elif not versions_found:
963
1501
  console.print(
964
1502
  f"\n[{STYLE_ERROR_STRONG}]FAILURE:[/{STYLE_ERROR_STRONG}] No version files found in {args.root}."
965
1503
  )
966
- return 1
1504
+ return False
967
1505
  else:
968
1506
  if not args.short:
969
1507
  console.print(
970
1508
  f"\n[{STYLE_SUCCESS_STRONG}]SUCCESS:[/{STYLE_SUCCESS_STRONG}] All files match version [{STYLE_SUCCESS}]{consensus_version}[/{STYLE_SUCCESS}]."
971
1509
  )
972
- return 0
1510
+ return True
973
1511
 
974
1512
 
975
1513
  def perform_version_updates(
@@ -992,7 +1530,7 @@ def perform_version_updates(
992
1530
  try:
993
1531
  if dry_run:
994
1532
  curr = check.get_version()
995
- dry_run_rows.append((check.name, curr, target_version))
1533
+ dry_run_rows.append((check.label, curr, target_version))
996
1534
  else:
997
1535
  try:
998
1536
  old_version = check.get_version()
@@ -1001,20 +1539,20 @@ def perform_version_updates(
1001
1539
  except Exception:
1002
1540
  old_version = "?"
1003
1541
  check.update_version(target_version)
1004
- dry_run_rows.append((check.name, old_version, target_version))
1542
+ dry_run_rows.append((check.label, old_version, target_version))
1005
1543
  line = Text()
1006
- line.append(f" {check.name:<22}", style="cyan")
1544
+ line.append(f" {check.label:<28}", style="cyan")
1007
1545
  line.append(old_version, style=STYLE_OLD_VALUE)
1008
1546
  line.append(" → ", style="dim")
1009
1547
  line.append(target_version, style=STYLE_NEW_VALUE)
1010
1548
  console.print(line)
1011
- updated_files.append(check.name)
1549
+ updated_files.append(check.label)
1012
1550
  updated_file_paths.append(str(check.file_path))
1013
1551
  except VersionNotPresent:
1014
1552
  pass # file exists but has no version configured; skip
1015
1553
  except Exception as e:
1016
1554
  console.print(
1017
- f"[{STYLE_ERROR}]Failed to update {check.name}: {e}[/{STYLE_ERROR}]"
1555
+ f"[{STYLE_ERROR}]Failed to update {check.label}: {e}[/{STYLE_ERROR}]"
1018
1556
  )
1019
1557
  if not dry_run:
1020
1558
  failed = True
@@ -1037,14 +1575,14 @@ def show_dry_run_panel(
1037
1575
  console.print(f" [dim]{'─' * 44}[/dim]")
1038
1576
  for name, old, new in dry_run_rows:
1039
1577
  line = Text()
1040
- line.append(f" {name:<22}", style="cyan")
1578
+ line.append(f" {name:<28}", style="cyan")
1041
1579
  line.append(old, style=STYLE_OLD_VALUE)
1042
1580
  line.append(" → ", style="dim")
1043
1581
  line.append(new, style=STYLE_NEW_VALUE)
1044
1582
  console.print(line)
1045
1583
  if pixi_lock_would_update:
1046
1584
  line = Text()
1047
- line.append(f" {'pixi.lock':<22}", style="cyan")
1585
+ line.append(f" {'pixi.lock':<28}", style="cyan")
1048
1586
  line.append("regenerated via pixi list", style="dim")
1049
1587
  console.print(line)
1050
1588
 
@@ -1063,7 +1601,7 @@ def show_result_panel(
1063
1601
  """Display a polished summary of completed version updates."""
1064
1602
  if pixi_lock_updated:
1065
1603
  line = Text()
1066
- line.append(f" {'pixi.lock':<22}", style="cyan")
1604
+ line.append(f" {'pixi.lock':<28}", style="cyan")
1067
1605
  line.append("regenerated via pixi list", style="dim")
1068
1606
  console.print(line)
1069
1607
  console.print()
@@ -1152,6 +1690,33 @@ def main():
1152
1690
  metavar="MESSAGE",
1153
1691
  help="Custom git tag message. Use {version} as placeholder for version number. Default: 'Release version {version}'",
1154
1692
  )
1693
+ parser.add_argument(
1694
+ "--sign-tag",
1695
+ action="store_true",
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.",
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
+
1155
1720
  parser.add_argument(
1156
1721
  "--short",
1157
1722
  action="store_true",
@@ -1184,6 +1749,12 @@ def main():
1184
1749
  help="Bump the project version.",
1185
1750
  )
1186
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
+
1187
1758
  args = parser.parse_args()
1188
1759
  root_dir = args.root
1189
1760
 
@@ -1194,6 +1765,9 @@ def main():
1194
1765
  else:
1195
1766
  console = Console()
1196
1767
 
1768
+ if args.check_tag and not args.check_version:
1769
+ parser.error("--check-tag requires --check-version")
1770
+
1197
1771
  if args.update_version:
1198
1772
  try:
1199
1773
  if not re.match(r"^\d+\.\d+\.\d+$", args.update_version):
@@ -1207,14 +1781,7 @@ def main():
1207
1781
  )
1208
1782
  sys.exit(1)
1209
1783
 
1210
- checks: List[VersionExtractor] = [
1211
- XmlVersionExtractor(root_dir / "package.xml"),
1212
- TomlVersionExtractor(root_dir / "pyproject.toml", ["project", "version"]),
1213
- ChangelogVersionExtractor(root_dir / "CHANGELOG.md", r""),
1214
- TomlVersionExtractor(root_dir / "pixi.toml", ["workspace", "version"]),
1215
- YamlVersionExtractor(root_dir / "CITATION.cff", ["version"]),
1216
- CMakeListsVersionExtractor(root_dir / "CMakeLists.txt"),
1217
- ]
1784
+ checks: List[VersionExtractor] = collect_version_checks(root_dir)
1218
1785
 
1219
1786
  if args.list_files:
1220
1787
  if args.output_format == "json":
@@ -1222,7 +1789,7 @@ def main():
1222
1789
  for check in checks:
1223
1790
  files_list.append(
1224
1791
  {
1225
- "name": check.name,
1792
+ "name": check.label,
1226
1793
  "path": str(check.file_path),
1227
1794
  "exists": check.check_file_exists(),
1228
1795
  "type": check.__class__.__name__.replace(
@@ -1236,14 +1803,15 @@ def main():
1236
1803
  sys.exit(0)
1237
1804
 
1238
1805
  if args.check_version:
1239
- sys.exit(handle_check_version(checks, args))
1806
+ sys.exit(0 if handle_check_version(checks, args) else 1)
1240
1807
 
1241
1808
  current_version = None
1242
1809
  new_version_str = None
1243
1810
 
1244
1811
  if args.update_version:
1245
1812
  new_version_str = args.update_version
1246
- current_version = get_current_version(checks)
1813
+ versions, _ = collect_versions(checks)
1814
+ current_version = next(iter(versions)) if len(versions) == 1 else None
1247
1815
  if not args.dry_run:
1248
1816
  console.print(
1249
1817
  f"[{STYLE_INFO}]Updating versions to {new_version_str} in {root_dir}...[/{STYLE_INFO}]"
@@ -1358,6 +1926,22 @@ def main():
1358
1926
  elif args.short:
1359
1927
  print(target_version)
1360
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
+
1361
1945
  if args.dry_run:
1362
1946
  pixi_lock_would_update = (root_dir / "pixi.lock").exists()
1363
1947
 
@@ -1376,6 +1960,7 @@ def main():
1376
1960
  )
1377
1961
  git_lines.append(f"$ git add {' '.join(rel_paths) if rel_paths else '-u'}")
1378
1962
  git_lines.append(f"$ git commit -m '{commit_message}'")
1963
+
1379
1964
  if args.git_tag is not None:
1380
1965
  custom_tag_name = None if args.git_tag is True else args.git_tag
1381
1966
  tag_name = (
@@ -1388,7 +1973,32 @@ def main():
1388
1973
  if args.git_tag_message
1389
1974
  else f"Release version {target_version}"
1390
1975
  )
1391
- git_lines.append(f"$ git tag -a {tag_name} -m '{tag_message}'")
1976
+ tag_flag = "-s" if args.sign_tag else "-a"
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)
1392
2002
 
1393
2003
  show_dry_run_panel(dry_run_rows, pixi_lock_would_update, git_lines)
1394
2004
  sys.exit(0)
@@ -1402,6 +2012,36 @@ def main():
1402
2012
  f"[{STYLE_WARNING}]Warning: --git-tag used without --git-commit. The tag will point to the current HEAD, not the version bump commit.[/{STYLE_WARNING}]"
1403
2013
  )
1404
2014
 
2015
+ if args.sign_tag and args.git_tag is None:
2016
+ console.print(
2017
+ f"[{STYLE_WARNING}]Warning: --sign-tag has no effect without --git-tag.[/{STYLE_WARNING}]"
2018
+ )
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
+
1405
2045
  if args.git_commit is not None:
1406
2046
  custom_message = None if args.git_commit is True else args.git_commit
1407
2047
  git_commit_version(
@@ -1414,14 +2054,45 @@ def main():
1414
2054
 
1415
2055
  if args.git_tag is not None:
1416
2056
  custom_tag_name = None if args.git_tag is True else args.git_tag
1417
- git_tag_version(
2057
+ _success, tag_name = git_tag_version(
1418
2058
  root_dir,
1419
2059
  target_version,
1420
2060
  args.confirm,
1421
2061
  custom_tag_name,
1422
2062
  args.git_tag_message,
2063
+ args.sign_tag,
1423
2064
  )
1424
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
+
1425
2096
 
1426
2097
  if __name__ == "__main__":
1427
2098
  try:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "jrl-cmakemodules-scripts"
7
- version = "2.0.0"
7
+ version = "2.2.4"
8
8
  description = "Release scripting tools for JRL CMake modules"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"