File indexing completed on 2026-07-16 08:07:24
0001
0002
0003
0004
0005
0006
0007
0008
0009 """
0010 Utility to bump and make consistent all references to base Docker images and dependency versions.
0011
0012 This script can:
0013 1. Bump Docker image tags (e.g., ubuntu2404:83 -> ubuntu2404:84)
0014 2. Update spack-container versions in .devcontainer/Dockerfile
0015 3. Make all references consistent across the codebase
0016 """
0017
0018 import re
0019 import sys
0020 import difflib
0021 from pathlib import Path
0022 from typing import Annotated
0023
0024 import typer
0025 from rich.console import Console
0026 from rich.table import Table
0027 from rich.panel import Panel
0028 from rich.syntax import Syntax
0029 from rich import box
0030
0031 app = typer.Typer(
0032 help="Bump and make consistent Docker image tags and dependency versions",
0033 add_completion=False,
0034 )
0035 console = Console()
0036
0037
0038 class VersionBumper:
0039 """Handles version bumping for Docker images and dependencies."""
0040
0041
0042 PATTERNS = {
0043
0044 "docker_tag": re.compile(
0045 r"((?:registry\.cern\.ch/)?ghcr\.io/acts-project/[a-z0-9_]+):(\d+)"
0046 ),
0047
0048 "spack_container": re.compile(
0049 r"(ghcr\.io/acts-project/spack-container):(\d+\.\d+\.\d+)(_[a-z0-9._-]+)"
0050 ),
0051
0052
0053
0054 "dependency_tag": re.compile(
0055 r"(DEPENDENCY_TAG:(?:\s*['\"]?|(?:[^\n]|\n(?!\s{0,2}\w))*?default:\s*['\"]))(v\d+\.\d+\.\d+)(['\"]?)",
0056 re.MULTILINE,
0057 ),
0058 }
0059
0060
0061 FILE_PATTERNS = [
0062 ".gitlab-ci.yml",
0063 ".github/workflows/*.yml",
0064 ".github/workflows/*.yaml",
0065 ".github/actions/**/action.yml",
0066 ".devcontainer/Dockerfile",
0067 "CI/**/*.sh",
0068 "docs/**/*.md",
0069 ]
0070
0071 def __init__(self, repo_root: Path):
0072 self.repo_root = repo_root
0073
0074 def find_files(self) -> list[Path]:
0075 """Find all files that might contain version references."""
0076 files = set()
0077 for pattern in self.FILE_PATTERNS:
0078 files.update(self.repo_root.glob(pattern))
0079
0080
0081 return [
0082 f
0083 for f in sorted(files)
0084 if f.is_file() and "build" not in f.parts and "_deps" not in f.parts
0085 ]
0086
0087 def find_versions(self, content: str, pattern_name: str) -> set[str]:
0088 """Find all versions matching the given pattern."""
0089 pattern = self.PATTERNS[pattern_name]
0090 matches = pattern.findall(content)
0091
0092 if pattern_name == "docker_tag":
0093
0094 return {match[1] for match in matches}
0095 elif pattern_name == "spack_container":
0096
0097 return {match[1] for match in matches}
0098 elif pattern_name == "dependency_tag":
0099
0100 return {match[1] for match in matches}
0101
0102 return set()
0103
0104 def scan_versions(self) -> dict:
0105 """Scan the repository for all current versions."""
0106 versions = {
0107 "docker_tags": set(),
0108 "spack_container_versions": set(),
0109 "dependency_tags": set(),
0110 "files_with_docker_tags": [],
0111 "files_with_spack_container": [],
0112 "files_with_dependency_tags": [],
0113 }
0114
0115 files = self.find_files()
0116 console.print(f"[dim]Scanning {len(files)} files...[/dim]")
0117
0118 for file_path in files:
0119 try:
0120 content = file_path.read_text()
0121
0122
0123 docker_tags = self.find_versions(content, "docker_tag")
0124 if docker_tags:
0125 versions["docker_tags"].update(docker_tags)
0126 versions["files_with_docker_tags"].append(file_path)
0127
0128
0129 spack_versions = self.find_versions(content, "spack_container")
0130 if spack_versions:
0131 versions["spack_container_versions"].update(spack_versions)
0132 versions["files_with_spack_container"].append(file_path)
0133
0134
0135 dependency_tags = self.find_versions(content, "dependency_tag")
0136 if dependency_tags:
0137 versions["dependency_tags"].update(dependency_tags)
0138 versions["files_with_dependency_tags"].append(file_path)
0139
0140 except Exception as e:
0141 console.print(
0142 f"[yellow]Warning: Error reading {file_path}: {e}[/yellow]"
0143 )
0144
0145 return versions
0146
0147 def bump_docker_tag(
0148 self,
0149 file_path: Path,
0150 old_tags: set[str],
0151 new_tag: str,
0152 dry_run: bool = False,
0153 show_diff: bool = False,
0154 ) -> tuple[int, str, str]:
0155 """Bump Docker image tags in a file. Returns (replacements, old_content, new_content)."""
0156 content = file_path.read_text()
0157 pattern = self.PATTERNS["docker_tag"]
0158
0159 replacements = 0
0160
0161 def replace_tag(match):
0162 nonlocal replacements
0163 old_tag = match.group(2)
0164 if old_tag in old_tags and old_tag != new_tag:
0165 replacements += 1
0166 return f"{match.group(1)}:{new_tag}"
0167 return match.group(0)
0168
0169 new_content = pattern.sub(replace_tag, content)
0170
0171 if replacements > 0:
0172 if not dry_run:
0173 file_path.write_text(new_content)
0174
0175 prefix = "[dim][DRY RUN][/dim] " if dry_run else ""
0176 rel_path = file_path.relative_to(self.repo_root)
0177 console.print(
0178 f"{prefix}[green]✓[/green] Updated {replacements} occurrence(s) in [cyan]{rel_path}[/cyan]"
0179 )
0180
0181 if show_diff:
0182 self._show_diff(file_path, content, new_content)
0183
0184 return replacements, content, new_content
0185
0186 def bump_spack_container(
0187 self,
0188 file_path: Path,
0189 old_versions: set[str],
0190 new_version: str,
0191 dry_run: bool = False,
0192 show_diff: bool = False,
0193 ) -> tuple[int, str, str]:
0194 """Bump spack-container version in a file. Returns (replacements, old_content, new_content)."""
0195 content = file_path.read_text()
0196 pattern = self.PATTERNS["spack_container"]
0197
0198 replacements = 0
0199
0200 def replace_version(match):
0201 nonlocal replacements
0202 old_version = match.group(2)
0203 if old_version in old_versions and old_version != new_version:
0204 replacements += 1
0205 return f"{match.group(1)}:{new_version}{match.group(3)}"
0206 return match.group(0)
0207
0208 new_content = pattern.sub(replace_version, content)
0209
0210 if replacements > 0:
0211 if not dry_run:
0212 file_path.write_text(new_content)
0213
0214 prefix = "[dim][DRY RUN][/dim] " if dry_run else ""
0215 rel_path = file_path.relative_to(self.repo_root)
0216 console.print(
0217 f"{prefix}[green]✓[/green] Updated {replacements} occurrence(s) in [cyan]{rel_path}[/cyan]"
0218 )
0219
0220 if show_diff:
0221 self._show_diff(file_path, content, new_content)
0222
0223 return replacements, content, new_content
0224
0225 def bump_dependency_tag(
0226 self,
0227 file_path: Path,
0228 old_versions: set[str],
0229 new_version: str,
0230 dry_run: bool = False,
0231 show_diff: bool = False,
0232 ) -> tuple[int, str, str]:
0233 """Bump dependency tag in a file. Returns (replacements, old_content, new_content)."""
0234 content = file_path.read_text()
0235 pattern = self.PATTERNS["dependency_tag"]
0236
0237 replacements = 0
0238
0239 def replace_version(match):
0240 nonlocal replacements
0241 old_version = match.group(2)
0242 if old_version in old_versions and old_version != new_version:
0243 replacements += 1
0244 return f"{match.group(1)}{new_version}{match.group(3)}"
0245 return match.group(0)
0246
0247 new_content = pattern.sub(replace_version, content)
0248
0249 if replacements > 0:
0250 if not dry_run:
0251 file_path.write_text(new_content)
0252
0253 prefix = "[dim][DRY RUN][/dim] " if dry_run else ""
0254 rel_path = file_path.relative_to(self.repo_root)
0255 console.print(
0256 f"{prefix}[green]✓[/green] Updated {replacements} occurrence(s) in [cyan]{rel_path}[/cyan]"
0257 )
0258
0259 if show_diff:
0260 self._show_diff(file_path, content, new_content)
0261
0262 return replacements, content, new_content
0263
0264 def _show_diff(self, file_path: Path, old_content: str, new_content: str):
0265 """Display a unified diff of the changes."""
0266 rel_path = file_path.relative_to(self.repo_root)
0267 diff = difflib.unified_diff(
0268 old_content.splitlines(),
0269 new_content.splitlines(),
0270 fromfile=str(rel_path),
0271 tofile=str(rel_path),
0272 lineterm="",
0273 )
0274
0275 diff_lines = list(diff)
0276 if diff_lines:
0277 console.print()
0278 diff_text = "\n".join(diff_lines)
0279 syntax = Syntax(diff_text, "diff", theme="monokai", line_numbers=False)
0280 console.print(syntax)
0281
0282 def bump_all_docker_tags(
0283 self,
0284 old_tags: set[str],
0285 new_tag: str,
0286 dry_run: bool = False,
0287 show_diff: bool = False,
0288 ) -> int:
0289 """Bump all Docker tags across the repository."""
0290 files = self.find_files()
0291 total_replacements = 0
0292
0293 for file_path in files:
0294 try:
0295 replacements, _, _ = self.bump_docker_tag(
0296 file_path, old_tags, new_tag, dry_run, show_diff
0297 )
0298 total_replacements += replacements
0299 except Exception as e:
0300 console.print(f"[red]Error processing {file_path}: {e}[/red]")
0301
0302 return total_replacements
0303
0304 def bump_all_spack_containers(
0305 self,
0306 old_versions: set[str],
0307 new_version: str,
0308 dry_run: bool = False,
0309 show_diff: bool = False,
0310 ) -> int:
0311 """Bump all spack-container versions across the repository."""
0312 files = self.find_files()
0313 total_replacements = 0
0314
0315 for file_path in files:
0316 try:
0317 replacements, _, _ = self.bump_spack_container(
0318 file_path, old_versions, new_version, dry_run, show_diff
0319 )
0320 total_replacements += replacements
0321 except Exception as e:
0322 console.print(f"[red]Error processing {file_path}: {e}[/red]")
0323
0324 return total_replacements
0325
0326 def bump_all_dependency_tags(
0327 self,
0328 old_versions: set[str],
0329 new_version: str,
0330 dry_run: bool = False,
0331 show_diff: bool = False,
0332 ) -> int:
0333 """Bump all dependency tags across the repository."""
0334 files = self.find_files()
0335 total_replacements = 0
0336
0337 for file_path in files:
0338 try:
0339 replacements, _, _ = self.bump_dependency_tag(
0340 file_path, old_versions, new_version, dry_run, show_diff
0341 )
0342 total_replacements += replacements
0343 except Exception as e:
0344 console.print(f"[red]Error processing {file_path}: {e}[/red]")
0345
0346 return total_replacements
0347
0348
0349 @app.command()
0350 def scan(
0351 repo_root: Annotated[
0352 Path, typer.Option(help="Root directory of the repository")
0353 ] = Path.cwd(),
0354 ):
0355 """
0356 Scan repository for current versions.
0357
0358 This command scans the codebase and displays all Docker image tags
0359 and spack-container versions currently in use.
0360 """
0361 bumper = VersionBumper(repo_root)
0362 versions = bumper.scan_versions()
0363
0364 console.print()
0365
0366
0367 if versions["docker_tags"]:
0368 console.print("[bold]Docker image tags:[/bold]")
0369 for tag in sorted(versions["docker_tags"]):
0370 console.print(f" [cyan]{tag}[/cyan]")
0371 console.print(
0372 f"[dim] Found in {len(versions['files_with_docker_tags'])} file(s)[/dim]"
0373 )
0374 else:
0375 console.print("[bold]Docker image tags:[/bold] [yellow]none found[/yellow]")
0376
0377 console.print()
0378
0379
0380 if versions["spack_container_versions"]:
0381 console.print("[bold]Spack-container versions:[/bold]")
0382 for version in sorted(versions["spack_container_versions"]):
0383 console.print(f" [cyan]{version}[/cyan]")
0384 console.print(
0385 f"[dim] Found in {len(versions['files_with_spack_container'])} file(s)[/dim]"
0386 )
0387 else:
0388 console.print(
0389 "[bold]Spack-container versions:[/bold] [yellow]none found[/yellow]"
0390 )
0391
0392 console.print()
0393
0394
0395 if versions["dependency_tags"]:
0396 console.print("[bold]Dependency tags:[/bold]")
0397 for version in sorted(versions["dependency_tags"]):
0398 console.print(f" [cyan]{version}[/cyan]")
0399 console.print(
0400 f"[dim] Found in {len(versions['files_with_dependency_tags'])} file(s)[/dim]"
0401 )
0402 else:
0403 console.print("[bold]Dependency tags:[/bold] [yellow]none found[/yellow]")
0404
0405
0406 @app.command()
0407 def bump_docker_tag(
0408 new_tag: Annotated[str, typer.Argument(help="New Docker tag to use (e.g., 84)")],
0409 repo_root: Annotated[
0410 Path, typer.Option(help="Root directory of the repository")
0411 ] = Path.cwd(),
0412 dry_run: Annotated[
0413 bool, typer.Option("--dry-run", help="Preview changes without modifying files")
0414 ] = False,
0415 show_diff: Annotated[
0416 bool, typer.Option("--diff", help="Show diff of changes")
0417 ] = True,
0418 ):
0419 """
0420 Bump Docker image tags across the repository.
0421
0422 This command finds all Docker images like 'ubuntu2404:83' and updates
0423 all tag numbers to the new value.
0424
0425 Example:
0426 bump_versions.py bump-docker-tag 84
0427 bump_versions.py bump-docker-tag 84 --dry-run --diff
0428 """
0429 bumper = VersionBumper(repo_root)
0430
0431
0432 versions = bumper.scan_versions()
0433 if not versions["docker_tags"]:
0434 console.print("[red]Error: No Docker tags found in repository[/red]")
0435 raise typer.Exit(1)
0436
0437
0438 old_tags = versions["docker_tags"]
0439 console.print(f"[dim]Found Docker tags: {', '.join(sorted(old_tags))}[/dim]")
0440
0441
0442 if len(old_tags) == 1 and new_tag in old_tags:
0443 console.print(f"[yellow]Tag is already {new_tag}, no changes needed[/yellow]")
0444 raise typer.Exit(0)
0445
0446 console.print(f"[dim]Will replace ALL tags with: {new_tag}[/dim]")
0447
0448 console.print()
0449 if dry_run:
0450 console.print(
0451 Panel(
0452 f"[bold]DRY RUN:[/bold] Bumping Docker tags [cyan]{', '.join(sorted(old_tags))}[/cyan] → [cyan]{new_tag}[/cyan]",
0453 border_style="yellow",
0454 )
0455 )
0456 else:
0457 console.print(
0458 Panel(
0459 f"Bumping Docker tags [cyan]{', '.join(sorted(old_tags))}[/cyan] → [cyan]{new_tag}[/cyan]",
0460 border_style="green",
0461 )
0462 )
0463
0464 console.print()
0465 total = bumper.bump_all_docker_tags(old_tags, new_tag, dry_run, show_diff)
0466
0467 console.print()
0468 if total > 0:
0469 if dry_run:
0470 console.print(
0471 f"[green]✓[/green] Would update [bold]{total}[/bold] occurrence(s)"
0472 )
0473 console.print("[dim]Run without --dry-run to apply changes[/dim]")
0474 else:
0475 console.print(
0476 f"[green]✓[/green] Updated [bold]{total}[/bold] occurrence(s)"
0477 )
0478 else:
0479 console.print(f"[yellow]No occurrences found[/yellow]")
0480
0481
0482 @app.command()
0483 def bump_spack(
0484 new_version: Annotated[
0485 str, typer.Argument(help="New spack-container version (e.g., 19.0.0)")
0486 ],
0487 repo_root: Annotated[
0488 Path, typer.Option(help="Root directory of the repository")
0489 ] = Path.cwd(),
0490 dry_run: Annotated[
0491 bool, typer.Option("--dry-run", help="Preview changes without modifying files")
0492 ] = False,
0493 show_diff: Annotated[
0494 bool, typer.Option("--diff/--no-diff", help="Show diff of changes")
0495 ] = True,
0496 ):
0497 """
0498 Bump spack-container version and DEPENDENCY_TAG across the repository.
0499
0500 This command updates the spack-container image version used in
0501 .devcontainer/Dockerfile and other configuration files, as well as
0502 the DEPENDENCY_TAG in GitHub Actions and GitLab CI. It replaces
0503 all found versions with the new version.
0504
0505 Example:
0506 bump_versions.py bump-spack 19.0.0
0507 bump_versions.py bump-spack 19.0.0 --dry-run --diff
0508 """
0509 bumper = VersionBumper(repo_root)
0510
0511
0512 versions = bumper.scan_versions()
0513 if not versions["spack_container_versions"]:
0514 console.print(
0515 "[red]Error: No spack-container versions found in repository[/red]"
0516 )
0517 raise typer.Exit(1)
0518
0519
0520 old_spack_versions = versions["spack_container_versions"]
0521 console.print(
0522 f"[dim]Found spack-container versions: {', '.join(sorted(old_spack_versions))}[/dim]"
0523 )
0524
0525
0526 old_dependency_versions = versions.get("dependency_tags", set())
0527 if old_dependency_versions:
0528 console.print(
0529 f"[dim]Found dependency tags: {', '.join(sorted(old_dependency_versions))}[/dim]"
0530 )
0531
0532
0533 new_dependency_version = f"v{new_version}"
0534 spack_is_current = (
0535 len(old_spack_versions) == 1 and new_version in old_spack_versions
0536 )
0537 dependency_is_current = len(old_dependency_versions) <= 1 and (
0538 not old_dependency_versions or new_dependency_version in old_dependency_versions
0539 )
0540
0541 if spack_is_current and dependency_is_current:
0542 console.print(
0543 f"[yellow]All versions are already {new_version} (DEPENDENCY_TAG: {new_dependency_version}), no changes needed[/yellow]"
0544 )
0545 raise typer.Exit(0)
0546
0547 console.print(f"[dim]Will replace ALL versions with: {new_version}[/dim]")
0548
0549 console.print()
0550 if dry_run:
0551 console.print(
0552 Panel(
0553 f"[bold]DRY RUN:[/bold] Bumping spack-container and DEPENDENCY_TAG [cyan]{', '.join(sorted(old_spack_versions))}[/cyan] → [cyan]{new_version}[/cyan]",
0554 border_style="yellow",
0555 )
0556 )
0557 else:
0558 console.print(
0559 Panel(
0560 f"Bumping spack-container and DEPENDENCY_TAG [cyan]{', '.join(sorted(old_spack_versions))}[/cyan] → [cyan]{new_version}[/cyan]",
0561 border_style="green",
0562 )
0563 )
0564
0565 console.print()
0566
0567
0568 console.print("[bold]Updating spack-container versions:[/bold]")
0569 total_spack = bumper.bump_all_spack_containers(
0570 old_spack_versions, new_version, dry_run, show_diff
0571 )
0572
0573
0574 if old_dependency_versions:
0575 console.print()
0576 console.print("[bold]Updating DEPENDENCY_TAG:[/bold]")
0577 new_dependency_version = f"v{new_version}"
0578 total_dependency = bumper.bump_all_dependency_tags(
0579 old_dependency_versions, new_dependency_version, dry_run, show_diff
0580 )
0581 else:
0582 total_dependency = 0
0583
0584 console.print()
0585 total = total_spack + total_dependency
0586 if total > 0:
0587 if dry_run:
0588 console.print(
0589 f"[green]✓[/green] Would update [bold]{total}[/bold] occurrence(s) ([bold]{total_spack}[/bold] spack-container, [bold]{total_dependency}[/bold] DEPENDENCY_TAG)"
0590 )
0591 console.print("[dim]Run without --dry-run to apply changes[/dim]")
0592 else:
0593 console.print(
0594 f"[green]✓[/green] Updated [bold]{total}[/bold] occurrence(s) ([bold]{total_spack}[/bold] spack-container, [bold]{total_dependency}[/bold] DEPENDENCY_TAG)"
0595 )
0596 else:
0597 console.print(f"[yellow]No occurrences found[/yellow]")
0598
0599
0600 if __name__ == "__main__":
0601 app()