jrl-cmakemodules-scripts 2.1.0__tar.gz → 2.2.4__tar.gz

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