taler-deployment

Deployment scripts and configuration files
Log | Files | Refs | README

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())