taler-repos (11656B)
1 #!/usr/bin/env python3 2 3 # This file is in the public domain. 4 5 """Manage the local Taler stack and run commands in its repositories.""" 6 7 from __future__ import annotations 8 9 import argparse 10 import os 11 import re 12 import shlex 13 import subprocess 14 import sys 15 from pathlib import Path 16 17 ROOT = Path.cwd() 18 REPOSITORIES = ( 19 "gnunet", 20 "exchange", 21 "merchant", 22 "anastasis", 23 "challenger", 24 "donau", 25 "paivana", 26 "taler-mdb", 27 "libeufin", 28 "taler-merchant-demos", 29 "taler-typescript-core", 30 "taler-rust", 31 ) 32 # Local build dependencies from taler-deployment/packaging/ng/packages.toml. 33 # Keep this map embedded so no deployment checkout is needed at runtime. 34 # Repositories omitted here have no dependencies within REPOSITORIES; 35 # external dependencies such as libmicrohttpd must already be installed. 36 BUILD_DEPENDENCIES = { 37 "exchange": ("gnunet",), 38 "merchant": ("gnunet", "exchange", "donau"), 39 "anastasis": ("gnunet", "merchant"), 40 "challenger": ("exchange",), 41 "donau": ("gnunet", "exchange"), 42 "paivana": ("merchant", "exchange", "gnunet"), 43 "taler-mdb": ("gnunet", "exchange", "merchant"), 44 "libeufin": ("taler-typescript-core",), 45 } 46 # The packaging tools support releases and numbered dev prereleases. 47 VERSION_TAG = re.compile( 48 r"v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)" 49 r"(?:-dev\.(0|[1-9][0-9]*))?" 50 ) 51 52 53 class BuildError(RuntimeError): 54 """A repository command or preflight check failed.""" 55 56 57 def capture(repo: Path, *command: str) -> str: 58 try: 59 result = subprocess.run( 60 command, 61 cwd=repo, 62 check=False, 63 capture_output=True, 64 text=True, 65 ) 66 except OSError as exc: 67 rendered = shlex.join(command) 68 raise BuildError(f"{repo.name}: could not run '{rendered}': {exc}") from exc 69 if result.returncode != 0: 70 detail = result.stderr.strip() or result.stdout.strip() 71 rendered = shlex.join(command) 72 raise BuildError(f"{repo.name}: '{rendered}' failed: {detail}") 73 return result.stdout.strip() 74 75 76 def run(repo: Path, *command: str) -> None: 77 print(f"\n[{repo.name}] $ {shlex.join(command)}", flush=True) 78 try: 79 subprocess.run(command, cwd=repo, check=True) 80 except subprocess.CalledProcessError as exc: 81 rendered = shlex.join(command) 82 raise BuildError( 83 f"{repo.name}: '{rendered}' exited with status {exc.returncode}" 84 ) from exc 85 except OSError as exc: 86 rendered = shlex.join(command) 87 raise BuildError(f"{repo.name}: could not run '{rendered}': {exc}") from exc 88 89 90 def repository(name: str) -> Path: 91 repo = ROOT / name 92 if not repo.is_dir(): 93 raise BuildError(f"{name}: repository directory not found at {repo}") 94 top = Path(capture(repo, "git", "rev-parse", "--show-toplevel")).resolve() 95 if top != repo.resolve(): 96 raise BuildError(f"{name}: {repo} is not a top-level Git repository") 97 return repo 98 99 100 def preflight(repos: list[Path]) -> None: 101 problems: list[str] = [] 102 for repo in repos: 103 try: 104 capture(repo, "git", "rev-parse", "--verify", "@{upstream}") 105 status = capture( 106 repo, "git", "status", "--porcelain", "--untracked-files=all" 107 ) 108 if status: 109 problems.append(f"{repo.name}: worktree is not clean") 110 except BuildError as exc: 111 problems.append(str(exc)) 112 if problems: 113 joined = "\n - ".join(problems) 114 raise BuildError(f"preflight failed:\n - {joined}") 115 116 117 def build_order() -> list[str]: 118 """Order the local repositories using the embedded dependency graph.""" 119 for name, dependencies in BUILD_DEPENDENCIES.items(): 120 if name not in REPOSITORIES: 121 raise BuildError(f"unknown repository in build dependencies: {name}") 122 for dependency in dependencies: 123 if dependency not in REPOSITORIES: 124 raise BuildError(f"{name}: unknown build dependency {dependency}") 125 126 ordered: list[str] = [] 127 visiting: list[str] = [] 128 129 def visit(name: str) -> None: 130 if name in ordered: 131 return 132 if name in visiting: 133 cycle = visiting[visiting.index(name) :] + [name] 134 raise BuildError(f"repository dependency cycle: {' -> '.join(cycle)}") 135 visiting.append(name) 136 for dependency in REPOSITORIES: 137 if dependency in BUILD_DEPENDENCIES.get(name, ()): 138 visit(dependency) 139 visiting.pop() 140 ordered.append(name) 141 142 for name in REPOSITORIES: 143 visit(name) 144 return ordered 145 146 147 def build() -> None: 148 repos = [repository(name) for name in build_order()] 149 preflight(repos) 150 151 print(f"Build order: {', '.join(repo.name for repo in repos)}", flush=True) 152 print("Preflight passed; updating all repositories.", flush=True) 153 for repo in repos: 154 run(repo, "git", "pull", "--ff-only") 155 156 home = Path.home() 157 configure = ( 158 "./configure", 159 f"--prefix={home / 'local'}", 160 f"CFLAGS=-I{home / 'local' / 'include'}", 161 f"LDFLAGS=-L{home / 'local' / 'lib64'}", 162 ) 163 jobs = str(os.cpu_count() or 1) 164 165 for repo in repos: 166 run(repo, "./bootstrap") 167 run(repo, *configure) 168 run(repo, "make", f"-j{jobs}") 169 run(repo, "make", "install") 170 171 172 def foreach(command: list[str]) -> None: 173 repos = [repository(name) for name in REPOSITORIES] 174 for repo in repos: 175 run(repo, *command) 176 177 178 def versions() -> None: 179 for name in REPOSITORIES: 180 repo = repository(name) 181 tags = capture(repo, "git", "tag", "--points-at", "HEAD").splitlines() 182 version = ", ".join(sorted(tags)) 183 if not version: 184 version = capture(repo, "git", "rev-parse", "--short=12", "HEAD") 185 dirty = capture(repo, "git", "status", "--porcelain", "--untracked-files=all") 186 suffix = " (dirty)" if dirty else "" 187 print(f"{name:<24} {version}{suffix}") 188 189 190 def next_tag(tags: list[str], dev: bool) -> str: 191 """Advance the highest supported local version using SemVer precedence.""" 192 versions: list[tuple[int, int, int, bool, int]] = [] 193 for tag in tags: 194 match = VERSION_TAG.fullmatch(tag) 195 if match is None: 196 continue 197 major, minor, patch = (int(match[i]) for i in (1, 2, 3)) 198 prerelease = match[4] 199 versions.append((major, minor, patch, prerelease is None, int(prerelease or 0))) 200 if not versions: 201 raise BuildError("no supported local version tag (vX.Y.Z or vX.Y.Z-dev.N)") 202 203 major, minor, patch, release, number = max(versions) 204 if release: 205 patch += 1 206 number = 0 207 suffix = f"-dev.{number + 1}" if dev else "" 208 return f"v{major}.{minor}.{patch}{suffix}" 209 210 211 def bump(dev: bool = False, dry_run: bool = False) -> None: 212 """Validate the whole stack before creating lightweight local tags.""" 213 changes: list[tuple[Path, str, str]] = [] 214 problems: list[str] = [] 215 for name in REPOSITORIES: 216 try: 217 repo = repository(name) 218 head = capture(repo, "git", "rev-parse", "--verify", "HEAD^{commit}") 219 head_tags = capture( 220 repo, "git", "tag", "--list", "v*", "--points-at", head 221 ).splitlines() 222 if head_tags: 223 print(f"{name:<24} skip: HEAD tagged {', '.join(sorted(head_tags))}") 224 continue 225 status = capture( 226 repo, "git", "status", "--porcelain", "--untracked-files=all" 227 ) 228 if status: 229 raise BuildError("worktree is not clean") 230 tags = capture(repo, "git", "tag", "--list").splitlines() 231 tag = next_tag(tags, dev) 232 if tag in tags: 233 raise BuildError(f"tag {tag} already exists") 234 changes.append((repo, tag, head)) 235 print(f"{name:<24} would tag {head[:12]} as {tag}") 236 except BuildError as exc: 237 detail = str(exc) 238 problems.append( 239 detail if detail.startswith(f"{name}:") else f"{name}: {detail}" 240 ) 241 242 if problems: 243 joined = "\n - ".join(problems) 244 raise BuildError(f"bump preflight failed; no tags created:\n - {joined}") 245 if dry_run: 246 print("Dry run complete; no tags created.") 247 return 248 249 created: list[str] = [] 250 for repo, tag, head in changes: 251 try: 252 # Override automatic signing so tags remain lightweight even when 253 # the user's Git configuration enables tag.gpgSign. 254 run(repo, "git", "-c", "tag.gpgSign=false", "tag", tag, head) 255 except BuildError as exc: 256 retained = ", ".join(created) or "none" 257 raise BuildError( 258 f"{exc}; tags already created and retained: {retained}" 259 ) from exc 260 created.append(f"{repo.name}:{tag}") 261 print(f"Created {len(created)} local tag(s).") 262 263 264 def parse_args() -> argparse.Namespace: 265 parser = argparse.ArgumentParser(description=__doc__) 266 parser.add_argument( 267 "--root", 268 type=Path, 269 default=ROOT, 270 help="directory containing the component repositories (default: current directory)", 271 ) 272 subparsers = parser.add_subparsers(dest="command", required=True) 273 subparsers.add_parser("build", help="pull, build, and install the Taler stack") 274 subparsers.add_parser("versions", help="show each repository's tag or commit") 275 foreach_parser = subparsers.add_parser( 276 "foreach", 277 help="run a command in each repository", 278 description=( 279 "Run PROGRAM with each configured repository as its working directory, " 280 "in repository list order. Validate all repository paths first and " 281 "stop at the first command failure. Use sh -c for shell expressions." 282 ), 283 ) 284 foreach_parser.add_argument( 285 "argv", 286 nargs=argparse.REMAINDER, 287 metavar="PROGRAM [ARG ...]", 288 help="program and arguments to execute; an optional -- may precede PROGRAM", 289 ) 290 bump_parser = subparsers.add_parser( 291 "bump", 292 help="tag the next version of each untagged component HEAD", 293 description=( 294 "Create local lightweight tags from the highest local vX.Y.Z or " 295 "vX.Y.Z-dev.N version, across all branches. Skip HEADs with any v* tag. " 296 "By default, advance the patch release or promote a dev version to " 297 "its release. Does not fetch, pull, push, or edit version files." 298 ), 299 ) 300 bump_parser.add_argument( 301 "--dev", 302 action="store_true", 303 help="increment dev.N, or start the next patch at dev.1; still skip tagged HEADs", 304 ) 305 bump_parser.add_argument( 306 "--dry-run", 307 action="store_true", 308 help="validate and show changes without tagging", 309 ) 310 args = parser.parse_args() 311 if args.command == "foreach": 312 if args.argv[:1] == ["--"]: 313 args.argv = args.argv[1:] 314 if not args.argv: 315 foreach_parser.error("a program is required") 316 return args 317 318 319 def main() -> int: 320 global ROOT 321 args = parse_args() 322 ROOT = args.root.resolve() 323 try: 324 if args.command == "build": 325 build() 326 elif args.command == "bump": 327 bump(dev=args.dev, dry_run=args.dry_run) 328 elif args.command == "foreach": 329 foreach(args.argv) 330 else: 331 versions() 332 except BuildError as exc: 333 print(f"taler-repos: {exc}", file=sys.stderr) 334 return 1 335 return 0 336 337 338 if __name__ == "__main__": 339 sys.exit(main())