commit d9233c157dcf6e761a7181a099b4aed50ab39828
parent efbad6d7f38ba8649e60573b8b12bb391fcc5a7e
Author: Christian Grothoff <christian@grothoff.org>
Date: Wed, 28 Oct 2026 01:57:25 +0100
bump pepsi
Diffstat:
2 files changed, 657 insertions(+), 309 deletions(-)
diff --git a/packaging/ng/packages.toml b/packaging/ng/packages.toml
@@ -77,6 +77,10 @@ builder = "generic"
url = "git://git.taler.net/taler-rust.git"
builder = "generic"
+[repositories."pepsi"]
+url = "git://git.taler.net/pepsi.git"
+builder = "generic"
+
[repositories."typescript-core"]
url = "git://git.taler.net/taler-typescript-core.git"
builder = "pnpm-workspace"
@@ -166,6 +170,13 @@ debian_path = ""
dependencies = []
enabled = true
+[packages."pepsi"]
+repository = "pepsi"
+tag = "v0.1.1"
+debian_path = ""
+dependencies = []
+enabled = true
+
[packages."sync"]
repository = "sync"
tag = "v1.6.0-dev.1"
diff --git a/packaging/ng/taler-pkg b/packaging/ng/taler-pkg
@@ -5,11 +5,14 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import argparse
-import subprocess
-import platform
+import datetime
import os
+import platform
import re
+import shlex
+import subprocess
import sys
+from dataclasses import dataclass, replace
from pathlib import Path
# Make local util package available
@@ -17,63 +20,95 @@ file = Path(__file__).resolve()
parent, root = file.parent, file.parents[1]
sys.path.append(str(root))
-from util import vercomp
+from util import vercomp # noqa: E402
mydir = os.path.dirname(os.path.realpath(__file__))
+sys.path.append(os.path.join(mydir, "buildscripts"))
+
+from package_config import ConfigError, load_config, write_config # noqa: E402
archs = ["arm64", "amd64"]
host = "taler.net"
+remote_user = "taler-packaging"
+signing_key = "0084993C2C6CDF471A7D7EFD26E546A5FE7E0266"
native_arch = "amd64" if platform.machine().lower() in ("x86_64", "amd64") else "arm64"
+config_path = Path(mydir) / "packages.toml"
+config = load_config(config_path)
+components = config.enabled_packages()
+
+
+@dataclass(frozen=True)
+class PublishingConfig:
+ distro: str
+ vendor: str
+ codename: str
+
+ @property
+ def prefix(self):
+ return f"apt/{self.vendor}"
+
+ @property
+ def testing_repo(self):
+ return f"taler-{self.vendor}-{self.codename}-testing"
+
+ @property
+ def testing_distribution(self):
+ return f"{self.codename}-testing"
+
+ @property
+ def initial_snapshot(self):
+ return f"taler-{self.vendor}-{self.codename}-stable-initial"
+
+
+publishing_configs = {
+ distro: PublishingConfig(distro, vendor, codename)
+ for distro, vendor, codename in (
+ ("debian-trixie", "debian", "trixie"),
+ ("ubuntu-noble", "ubuntu", "noble"),
+ )
+}
+
+
+def publishing_config(distro):
+ try:
+ return publishing_configs[distro]
+ except KeyError:
+ raise ValueError(f"unsupported publishing distro: {distro}") from None
+
+
+def remote_command(command, *, capture_output=False, tty=False):
+ ssh_command = ["ssh"]
+ if tty:
+ ssh_command.append("-t")
+ ssh_command.extend(
+ [f"{remote_user}@{host}", shlex.join(str(arg) for arg in command)]
+ )
+ return subprocess.run(
+ ssh_command,
+ check=True,
+ capture_output=capture_output,
+ text=capture_output,
+ )
+
+
+def remote_aptly(*args, capture_output=False, tty=False):
+ return remote_command(["aptly", *args], capture_output=capture_output, tty=tty)
-components = [
- "taler-wallet-cli",
- "taler-merchant-webui",
- "taler-exchange-kyc-webui",
- "taler-exchange-aml-webui",
- "taler-auditor-webui",
- "taler-challenger-helpers",
- "challenger-webui",
- "gnunet",
- "libeufin",
- "donau",
- "paivana-httpd",
- "challenger",
- "taler-exchange",
- "taler-harness",
- "taler-merchant",
- "taler-rust",
- "robocop",
- "pepsi",
- #"depolymerization",
- # These two packages don't have good debs yet,
- # Debian complains "No section given for ..., skipping.
- # "taler-directory",
- # "taler-mailbox",
- # We don't publish packages for these yet
- # "taler-mdb",
- # "taler-merchant-demos",
- "anastasis",
- "anastasis-gtk",
- # Currently not used anywhere
- # "sync",
-]
deps = {
- "taler-exchange": ["gnunet"],
- "anastasis": ["gnunet", "taler-merchant"],
- "anastasis-gtk": ["anastasis"],
- "taler-merchant": ["gnunet", "taler-exchange", "donau"],
- "donau": ["gnunet", "taler-exchange"],
- "challenger": ["taler-exchange"],
- # "taler-mdb": ["gnunet", "taler-exchange", "taler-merchant"],
- "sync": ["taler-merchant", "taler-exchange", "gnunet"],
- "paivana-httpd": ["taler-merchant", "taler-exchange", "gnunet"],
+ name: list(package.dependencies)
+ for name, package in config.packages.items()
+ if package.dependencies
}
# Compute reverse dependencies
rdeps = {}
for n1, d in deps.items():
+ if n1 not in components:
+ continue
for n2 in d:
+ if n2 not in components:
+ continue
rd = rdeps.setdefault(n2, [])
if n1 not in rd:
rd.append(n1)
@@ -125,7 +160,7 @@ def find_outdated(pkgdir, arch, roots):
"""Find outdated components based on tag files"""
outdated = set()
for component in roots:
- ver_requested = open(f"buildconfig/{component}.tag").read().strip()
+ ver_requested = config.packages[component].tag
built_tag_file = pkgdir / f"{component}@{arch}.built.tag"
ver_built = None
if built_tag_file.exists():
@@ -136,10 +171,58 @@ def find_outdated(pkgdir, arch, roots):
return outdated
+def component_group_key(component):
+ package = config.packages[component]
+ repository = config.repository_for(package)
+ builder = config.builder_for(package)
+ if builder != "pnpm-workspace":
+ # Single-package builders are deliberately isolated even when repositories match.
+ return repository.url, package.tag, builder, component
+ return repository.url, package.tag, builder, ""
+
+
+def group_components(selected, buildorder):
+ """Group selected components and topologically order the resulting groups."""
+ selected = set(selected)
+ groups = {}
+ component_to_group = {}
+ for component in buildorder:
+ if component not in selected:
+ continue
+ key = component_group_key(component)
+ groups.setdefault(key, []).append(component)
+ component_to_group[component] = key
+
+ group_dependencies = {key: set() for key in groups}
+ for component, key in component_to_group.items():
+ for dependency in deps.get(component, []):
+ dependency_key = component_to_group.get(dependency)
+ if dependency_key is not None and dependency_key != key:
+ group_dependencies[key].add(dependency_key)
+
+ ordered = []
+ permanent = set()
+ temporary = set()
+
+ def visit(key):
+ if key in permanent:
+ return
+ if key in temporary:
+ raise ConfigError("build groups contain a dependency cycle")
+ temporary.add(key)
+ for dependency in sorted(group_dependencies[key]):
+ visit(dependency)
+ temporary.remove(key)
+ permanent.add(key)
+ ordered.append(groups[key])
+
+ for key in groups:
+ visit(key)
+ return ordered
+
+
def build(cfg):
- transitive = False
- if cfg.transitive:
- transitive = True
+ transitive = cfg.transitive
distro = cfg.distro
vendor, codename = distro.split("-", 1)
print("building", distro)
@@ -193,14 +276,10 @@ def build(cfg):
print("outdated closure", closure)
- for component in buildorder:
- if transitive:
- if component not in closure:
- continue
- else:
- if component not in outdated:
- continue
- print("building", component)
+ selected = closure if transitive else outdated
+ for group in group_components(selected, buildorder):
+ builder = config.builder_for(config.packages[group[0]])
+ print("building", " ".join(group), f"with {builder}")
pkgdir.mkdir(parents=True, exist_ok=True)
cmd = [
"podman",
@@ -224,20 +303,20 @@ def build(cfg):
"--env",
"CARGO_BUILD_BUILD_DIR=/root/.cargo-build",
"--mount",
- f"type=bind,source={cachedir}/{distro}/apt-archives,target=/var/cache/apt/archives,relabel=shared",
+ f"type=bind,source={cachedir}/{distro}/apt-archives,target=/var/cache/apt/archives,relabel=shared,U=true",
"--mount",
- f"type=bind,source={cachedir}/{distro}/apt-lists,target=/var/lib/apt/lists,relabel=shared",
+ f"type=bind,source={cachedir}/{distro}/apt-lists,target=/var/lib/apt/lists,relabel=shared,U=true",
"--mount",
f"type=bind,source={mydir}/buildscripts,target=/buildscripts,readonly",
"--mount",
- f"type=bind,source={mydir}/buildconfig,target=/buildconfig,readonly",
+ f"type=bind,source={config_path},target=/packages.toml,readonly",
"--mount",
f"type=bind,source={pkgdir},target=/pkgdir",
image_tag,
- "/buildscripts/generic",
- component,
+ f"/buildscripts/{builder}",
codename,
arch,
+ *group,
]
if not cfg.dry:
subprocess.run(
@@ -251,57 +330,324 @@ def show_order(cfg):
print("build order:", buildorder)
+def aptly_list(*args):
+ result = remote_aptly(*args, "-raw", capture_output=True)
+ return set(result.stdout.split())
-def promote(cfg):
- dry = cfg.dry
- distro = cfg.distro
- vendor, codename = distro.split("-", 1)
- listfmt = "${package}_${version}_${architecture}.${$type}\n"
- if dry:
- subprocess.run(
- [
- "ssh",
- f"taler-packaging@{host}",
- f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ checkpull {codename}",
- ],
- check=True,
- )
+def published_repositories():
+ result = remote_aptly("publish", "list", "-raw", capture_output=True)
+ repositories = set()
+ for line in result.stdout.splitlines():
+ fields = line.split()
+ if len(fields) != 2:
+ raise ValueError(f"unexpected aptly publish list line: {line}")
+ repositories.add(tuple(fields))
+ return repositories
+
+
+def parse_publication(output):
+ publication = {"sources": {}}
+ in_sources = False
+ for line in output.splitlines():
+ if line == "Sources:":
+ in_sources = True
+ continue
+ if in_sources and line.startswith(" "):
+ match = re.fullmatch(r"\s+([^:]+):\s+(.+)\s+\[([^]]+)]", line)
+ if match is None:
+ raise ValueError(f"unexpected aptly publication source: {line}")
+ component, name, kind = match.groups()
+ publication["sources"][component] = (name, kind)
+ continue
+ in_sources = False
+ if line.startswith("Prefix: "):
+ publication["prefix"] = line.removeprefix("Prefix: ")
+ elif line.startswith("Distribution: "):
+ publication["distribution"] = line.removeprefix("Distribution: ")
+ elif line.startswith("Architectures: "):
+ publication["architectures"] = set(
+ line.removeprefix("Architectures: ").replace(",", " ").split()
+ )
+ return publication
+
+
+def get_publication(distribution, prefix):
+ result = remote_aptly("publish", "show", distribution, prefix, capture_output=True)
+ return parse_publication(result.stdout)
+
+
+def snapshot_origin(snapshot):
+ result = remote_aptly("snapshot", "show", snapshot, capture_output=True)
+ match = re.search(r"Snapshot from local repo \[([^]]+)]", result.stdout)
+ if match is not None:
+ return match.group(1)
+ in_sources = False
+ for line in result.stdout.splitlines():
+ if line == "Sources:":
+ in_sources = True
+ continue
+ if in_sources and line.startswith(" "):
+ match = re.fullmatch(r"\s+(.+)\s+\[(?:local|repo)]", line)
+ if match is not None:
+ return match.group(1)
+ elif in_sources:
+ break
+ raise ValueError(f"cannot determine source repo for aptly snapshot {snapshot}")
+
+
+def validate_publication(pubcfg, publication, *, stable):
+ distribution = pubcfg.codename if stable else pubcfg.testing_distribution
+ if publication.get("prefix") != pubcfg.prefix:
+ raise ValueError(f"unexpected prefix for {distribution} publication")
+ if publication.get("distribution") != distribution:
+ raise ValueError(f"unexpected distribution for {distribution} publication")
+ if publication.get("architectures") != set(archs):
+ raise ValueError(f"unexpected architectures for {distribution} publication")
+ snapshot = publication_snapshot(publication)
+ validate_snapshot(pubcfg, snapshot, stable=stable)
+
+
+def validate_snapshot(pubcfg, snapshot, *, stable):
+ if stable and snapshot == pubcfg.initial_snapshot:
+ if snapshot_packages(snapshot):
+ raise ValueError(f"initial snapshot {snapshot} is not empty")
else:
- subprocess.run(
- [
- "ssh",
- "-t",
- f"taler-packaging@{host}",
- f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ pull {codename}",
- ],
- check=True,
- )
- # Always export!
- # Reprepro is weird, listed packages might actually not show
- # up in the index yet.
- subprocess.run(
- [
- "ssh",
- "-t",
- f"taler-packaging@{host}",
- f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ export {codename}",
- ],
- check=True,
+ origin = snapshot_origin(snapshot)
+ if origin != pubcfg.testing_repo:
+ distribution = pubcfg.codename if stable else pubcfg.testing_distribution
+ raise ValueError(
+ f"publication {distribution} has unexpected source {origin}"
+ )
+
+
+def publish_options(distribution):
+ return [
+ f"-architectures={','.join(sorted(archs))}",
+ "-component=main",
+ f"-distribution={distribution}",
+ f"-gpg-key={signing_key}",
+ "-origin=GNU Taler",
+ "-label=Taler",
+ ]
+
+
+def initialize(cfg):
+ del cfg
+ repos = aptly_list("repo", "list")
+ snapshots = aptly_list("snapshot", "list")
+ publications = published_repositories()
+ changed = False
+
+ for pubcfg in publishing_configs.values():
+ if pubcfg.testing_repo not in repos:
+ remote_aptly(
+ "repo",
+ "create",
+ f"-distribution={pubcfg.testing_distribution}",
+ "-component=main",
+ pubcfg.testing_repo,
+ )
+ repos.add(pubcfg.testing_repo)
+ changed = True
+
+ testing_key = (pubcfg.prefix, pubcfg.testing_distribution)
+ if testing_key not in publications:
+ snapshot = create_testing_snapshot(pubcfg)
+ remote_aptly(
+ "publish",
+ "snapshot",
+ *publish_options(pubcfg.testing_distribution),
+ snapshot,
+ pubcfg.prefix,
+ tty=True,
+ )
+ publications.add(testing_key)
+ changed = True
+ else:
+ validate_publication(
+ pubcfg,
+ get_publication(pubcfg.testing_distribution, pubcfg.prefix),
+ stable=False,
+ )
+
+ stable_key = (pubcfg.prefix, pubcfg.codename)
+ if stable_key not in publications:
+ if pubcfg.initial_snapshot not in snapshots:
+ remote_aptly(
+ "snapshot",
+ "create",
+ pubcfg.initial_snapshot,
+ "empty",
+ )
+ snapshots.add(pubcfg.initial_snapshot)
+ changed = True
+ else:
+ validate_snapshot(pubcfg, pubcfg.initial_snapshot, stable=True)
+ remote_aptly(
+ "publish",
+ "snapshot",
+ *publish_options(pubcfg.codename),
+ pubcfg.initial_snapshot,
+ pubcfg.prefix,
+ tty=True,
+ )
+ publications.add(stable_key)
+ changed = True
+ else:
+ validate_publication(
+ pubcfg,
+ get_publication(pubcfg.codename, pubcfg.prefix),
+ stable=True,
+ )
+
+ if changed:
+ print("aptly repositories and publications initialized")
+ else:
+ print("aptly repositories and publications already initialized")
+
+
+def parse_package_list(output):
+ packages = []
+ in_packages = False
+ for line in output.splitlines():
+ if line == "Packages:":
+ in_packages = True
+ continue
+ if in_packages and line.startswith(" "):
+ packages.append(line.strip())
+ elif in_packages:
+ break
+ return packages
+
+
+def repo_packages(repo):
+ result = remote_aptly("repo", "show", "-with-packages", repo, capture_output=True)
+ return parse_package_list(result.stdout)
+
+
+def snapshot_packages(snapshot):
+ result = remote_aptly(
+ "snapshot", "show", "-with-packages", snapshot, capture_output=True
+ )
+ return parse_package_list(result.stdout)
+
+
+def publishing_packages(pubcfg):
+ """Include pending testing imports and publications sharing its package pool."""
+ sources = {(pubcfg.testing_repo, "local")}
+ for prefix, distribution in sorted(published_repositories()):
+ if prefix != pubcfg.prefix:
+ continue
+ publication = get_publication(distribution, prefix)
+ if (
+ publication.get("prefix") != prefix
+ or publication.get("distribution") != distribution
+ or not publication.get("sources")
+ ):
+ raise ValueError(f"invalid publication details for {prefix}/{distribution}")
+ source = publication["sources"].get("main")
+ if source is not None:
+ sources.add(source)
+
+ readers = {"local": repo_packages, "snapshot": snapshot_packages}
+ packages = set()
+ for name, kind in sorted(sources):
+ if kind not in readers:
+ raise ValueError(f"unsupported publication source {name} [{kind}]")
+ packages.update(readers[kind](name))
+ return sorted(packages)
+
+
+def publication_snapshot(publication):
+ distribution = publication.get("distribution", "unknown")
+ sources = publication.get("sources", {})
+ if set(sources) != {"main"}:
+ raise ValueError(f"unexpected components for {distribution} publication")
+ snapshot, kind = sources["main"]
+ if kind != "snapshot":
+ raise ValueError(
+ f"publication {distribution} uses a {kind} source; "
+ "convert it to a snapshot publication before proceeding"
)
+ return snapshot
-def show_published(cfg):
- distro = cfg.distro
- vendor, codename = distro.split("-", 1)
- listfmt = "${package}_${version}_${architecture}.${$type}\n"
- subprocess.run(
- [
- "ssh",
- f"taler-packaging@{host}",
- f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ --list-format '{listfmt}' list {codename}",
- ],
- check=True,
+
+def published_snapshot(pubcfg, *, stable):
+ """Return the published snapshot name and its enabled architectures."""
+ distribution = pubcfg.codename if stable else pubcfg.testing_distribution
+ publication = get_publication(distribution, pubcfg.prefix)
+ if (
+ publication.get("prefix") != pubcfg.prefix
+ or publication.get("distribution") != distribution
+ or not publication.get("architectures")
+ ):
+ raise ValueError(f"invalid publication details for {pubcfg.prefix}/{distribution}")
+ return publication_snapshot(publication), publication["architectures"]
+
+
+def testing_snapshot_name(pubcfg, now=None):
+ if now is None:
+ now = datetime.datetime.now(datetime.timezone.utc)
+ timestamp = now.strftime("%Y%m%dT%H%M%S%fZ")
+ return f"{pubcfg.testing_repo}-{timestamp}"
+
+
+def create_testing_snapshot(pubcfg):
+ snapshot = testing_snapshot_name(pubcfg)
+ remote_aptly("snapshot", "create", snapshot, "from", "repo", pubcfg.testing_repo)
+ return snapshot
+
+
+def promote(cfg):
+ pubcfg = publishing_config(cfg.distro)
+ snapshot, _ = published_snapshot(pubcfg, stable=False)
+ if cfg.dry:
+ stable_name, _ = published_snapshot(pubcfg, stable=True)
+ stable = set(snapshot_packages(stable_name))
+ testing = set(snapshot_packages(snapshot))
+ for package in sorted(stable - testing):
+ print(f"- {package}")
+ for package in sorted(testing - stable):
+ print(f"+ {package}")
+ if stable == testing:
+ print("testing and stable contain the same packages")
+ return
+
+ remote_aptly(
+ "publish",
+ "switch",
+ pubcfg.codename,
+ pubcfg.prefix,
+ snapshot,
+ tty=True,
)
+ print(f"promoted published testing snapshot {snapshot}")
+
+
+def show_published(cfg):
+ distro, _, channel = cfg.distro.rpartition("-")
+ if channel not in ("stable", "testing"):
+ distro, channel = cfg.distro, "stable"
+ pubcfg = publishing_config(distro)
+ snapshot, architectures = published_snapshot(pubcfg, stable=channel == "stable")
+ packages = {
+ package
+ for package in snapshot_packages(snapshot)
+ if package_identity(package)[2] in architectures | {"all"}
+ }
+ if cfg.latest:
+ latest = {}
+ for package in sorted(packages):
+ name, version, architecture = package_identity(package)
+ key = (name, architecture)
+ if key not in latest or vercomp.compare_versions(
+ version, latest[key][0]
+ ) > 0:
+ latest[key] = (version, package)
+ packages = {package for _, package in latest.values()}
+ for package in sorted(packages):
+ print(package)
def test(cfg):
@@ -310,7 +656,7 @@ def test(cfg):
distro = f"{vendor}-{codename}"
image_tag = f"localhost/taler-packaging-{distro}:latest"
dockerfile = f"distros/{distro}.Dockerfile"
- cachedir = Path(f"cache").absolute()
+ cachedir = Path("cache").absolute()
print("building base image")
subprocess.run(
[
@@ -345,183 +691,136 @@ def test(cfg):
check=True,
)
-def publish(cfg):
- distro = cfg.distro
- if distro.endswith("-testing"):
- print("Files are automatically published to testing", file=sys.stderr)
- sys.exit(1)
- vendor, codename = distro.split("-", 1)
- # List of .deb and .ddeb files.
- debs = []
- listfmt = "${package}_${version}_${architecture}.${$type}\n"
- server_debs_str = subprocess.check_output(
- [
- "ssh",
- f"taler-packaging@{host}",
- f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ --list-format '{listfmt}' list {codename}",
- ],
- encoding="utf-8",
- )
- server_debs = server_debs_str.split()
- for component in components:
- current = []
- for arch in archs + ["all"]:
- cf = Path(f"./packages/{distro}/{component}@{arch}.built.current")
- if not cf.exists():
- print(f"component {component}@{arch} has no current packages")
- continue
- with open(cf) as f:
- current = current + f.read().split()
- print("current", current)
- for deb in current:
- if deb.endswith(".deb"):
- pkg1, ver1, arch1 = deb.removesuffix(".deb").split("_")
- elif deb.endswith(".ddeb"):
- pkg1, ver1, arch1 = deb.removesuffix(".ddeb").split("_")
- else:
- raise Error(f"invalid deb filename: {deb}")
- fresh = True
- server_deb = None
- # If the server has the same or a later version,
- # the local version isn't fresh.
- for srvdeb in server_debs:
- pkg2, ver2, arch2 = srvdeb.removesuffix(".deb").split("_")
- if pkg1 != pkg2 or arch1 != arch2:
- continue
- if vercomp.compare_versions(ver1, ver2) <= 0:
- fresh = False
- server_deb = srvdeb
- break
- if fresh:
- debs.append(deb)
- else:
- print("package", deb, "not fresh, server has", server_deb)
- if len(debs) == 0:
- print("nothing to upload")
- else:
- print("uploading debs", debs)
- if cfg.dry:
- return
- debs = [Path(f"./packages/{distro}/") / x for x in debs]
- subprocess.run(
- [
- "ssh",
- f"taler-packaging@{host}",
- f"rm -f '/home/taler-packaging/{distro}/'*.deb '/home/taler-packaging/{distro}/'*.ddeb",
- ],
- check=True,
- )
- subprocess.run(
- ["rsync", "-a", "--info=progress2", "--", *debs, f"taler-packaging@{host}:{distro}/"], check=True
- )
- ret = subprocess.run(
- [
- "ssh",
- "-t",
- f"taler-packaging@{host}",
- f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ includedeb {codename}-testing ~/{vendor}-{codename}/*.deb",
- ],
- )
- if ret.returncode != 0:
- # Usually not critical if it fails.
- print(
- "Including ddebs failed. This can happen when including packages that have been included previously"
- )
- # Almost the same, but with ddebs.
- # We explicitly need to tell reprepro
- # to ignore the extension, because it does not
- # deal well with ddebs out of the box.
- ret = subprocess.run(
- [
- "ssh",
- "-t",
- f"taler-packaging@{host}",
- f"reprepro --ignore=extension -b /home/taler-packaging/www/apt/{vendor}/ includedeb {codename}-testing ~/{vendor}-{codename}/*.ddeb",
- ],
- )
- if ret.returncode != 0:
- # Usually not critical if it fails.
- print(
- "Including ddebs failed. This can happen when including packages that have been included previously"
- )
- # Always export!
- # Reprepro is weird, listed packages might actually not show
- # up in the index yet.
- subprocess.run(
- [
- "ssh",
- "-t",
- f"taler-packaging@{host}",
- f"reprepro -b /home/taler-packaging/www/apt/{vendor}/ export {codename}-testing",
- ],
- check=True,
- )
+def package_identity(filename):
+ stem = filename
+ for suffix in (".ddeb", ".deb"):
+ if filename.endswith(suffix):
+ stem = filename.removesuffix(suffix)
+ break
+ fields = stem.rsplit("_", 2)
+ if len(fields) != 3:
+ raise ValueError(f"invalid package reference: {filename}")
+ return fields
-def get_remote_version(url):
- """Get the latest stable tag from the git repo"""
- # Construct the git command
- # We use -c versionsort.suffix=- to ensure correct semantic version sorting
- cmd = [
- "git",
- "-c",
- "versionsort.suffix=-",
- "ls-remote",
- "--exit-code",
- "--refs",
- "--sort=version:refname",
- "--tags",
- url,
- "*.*.*",
- ]
- result = subprocess.run(cmd, capture_output=True, text=True, check=True)
+def package_file_identity(filename):
+ if Path(filename).name != filename or not filename.endswith((".deb", ".ddeb")):
+ raise ValueError(f"invalid package filename: {filename}")
+ return package_identity(filename)
- # Parse the output
- # Output format is usually: <hash>\trefs/tags/<tagname>
- lines = result.stdout.strip().split("\n")
- valid_tags = []
-
- for line in lines:
- parts = line.split()
- if len(parts) < 2:
+def newest_server_package(package, architecture, server_packages):
+ newest = None
+ newest_ref = None
+ for server_package in server_packages:
+ server_name, server_version, server_arch = package_identity(server_package)
+ if package != server_name or architecture != server_arch:
continue
+ if newest is None or vercomp.compare_versions(server_version, newest) > 0:
+ newest = server_version
+ newest_ref = server_package
+ return newest, newest_ref
- # refs/tags/v1.0.0 -> v1.0.0
- ref_path = parts[1]
- tag = ref_path.split("/")[-1]
-
- # Exclude pre-release semver versions
- if tag.startswith("v") and "-" in tag:
- continue
- valid_tags.append(tag)
-
- if valid_tags:
- return valid_tags[-1]
- return "(none)"
+def current_package_files(distro):
+ current = []
+ seen = set()
+ for component in components:
+ component_current = []
+ for arch in archs + ["all"]:
+ current_file = Path(f"./packages/{distro}/{component}@{arch}.built.current")
+ if not current_file.exists():
+ print(f"component {component}@{arch} has no current packages")
+ continue
+ component_current.extend(current_file.read_text().split())
+ print("current", component_current)
+ for package in component_current:
+ if package not in seen:
+ current.append(package)
+ seen.add(package)
+ return current
-def check_version(name, url):
- """
- Compares local buildconfig version with remote git version.
- """
- ver = get_remote_version(url)
- config_path = os.path.join("buildconfig", f"{name}.tag")
- with open(config_path, "r") as f:
- curr = f.read().strip()
- prefix = "[!] " if curr != ver else ""
- print(f"{prefix}{name} curr: {curr} latest: {ver}")
+def cleanup_uploads(staging_dir):
+ remote_command(
+ [
+ "sh",
+ "-c",
+ (
+ 'if [ ! -e "$1" ]; then exit 0; fi\n'
+ 'find "$1" -maxdepth 1 -type f '
+ r"\( -name '*.deb' -o -name '*.ddeb' \) -delete"
+ ),
+ "taler-pkg-cleanup",
+ staging_dir,
+ ]
+ )
-def print_latest(cfg):
- """Print latest upstream tag for each component"""
- for name in components:
- config_path = os.path.join("buildconfig", f"{name}.giturl")
- with open(config_path, "r") as f:
- giturl = f.read().strip()
- check_version(name, giturl)
+def publish(cfg):
+ distro = cfg.distro
+ if distro.endswith("-testing"):
+ print("Publish the base distro; packages always go to testing", file=sys.stderr)
+ sys.exit(1)
+ pubcfg = publishing_config(distro)
+ published_snapshot(pubcfg, stable=False)
+ server_packages = publishing_packages(pubcfg)
+ uploads = []
+ for package_file in current_package_files(distro):
+ package, version, architecture = package_file_identity(package_file)
+ server_version, server_ref = newest_server_package(
+ package, architecture, server_packages
+ )
+ if (
+ server_version is None
+ or vercomp.compare_versions(version, server_version) > 0
+ ):
+ uploads.append(package_file)
+ else:
+ print("package", package_file, "not fresh, server has", server_ref)
+
+ if uploads:
+ print("uploading debs", uploads)
+ else:
+ print("nothing to upload")
+ if cfg.dry:
+ return
+
+ staging_dir = f"/home/{remote_user}/{distro}"
+ if uploads:
+ remote_command(["mkdir", "-p", staging_dir])
+ local_files = [Path(f"./packages/{distro}") / name for name in uploads]
+ remote_files = [f"{staging_dir}/{name}" for name in uploads]
+ subprocess.run(
+ [
+ "rsync",
+ "-a",
+ "--info=progress2",
+ "--",
+ *local_files,
+ f"{remote_user}@{host}:{staging_dir}/",
+ ],
+ check=True,
+ )
+ remote_aptly("repo", "add", pubcfg.testing_repo, *remote_files)
+
+ snapshot = create_testing_snapshot(pubcfg)
+ remote_aptly(
+ "publish",
+ "switch",
+ pubcfg.testing_distribution,
+ pubcfg.prefix,
+ snapshot,
+ tty=True,
+ )
+ try:
+ cleanup_uploads(staging_dir)
+ except (subprocess.CalledProcessError, OSError) as exc:
+ raise RuntimeError(
+ f"Publishing {pubcfg.testing_distribution} succeeded, "
+ f"but cleanup of {staging_dir} failed"
+ ) from exc
# Tag syntax variants supported by buildscripts/generic:
@@ -585,55 +884,73 @@ def latest_tag(tags, dev):
return best
+def print_latest(cfg):
+ """Print the latest stable upstream tag for each enabled component."""
+ remote_tags = {}
+ for name in components:
+ package = config.packages[name]
+ url = config.repository_for(package).url
+ if url not in remote_tags:
+ remote_tags[url] = list_remote_tags(url)
+ latest = latest_tag(remote_tags[url], False) or "(none)"
+ prefix = "[!] " if package.tag != latest else ""
+ print(f"{prefix}{name} curr: {package.tag} latest: {latest}")
+
+
def upgrade(cfg):
- """Upgrade tag files in buildconfig to the latest upstream tag"""
+ """Upgrade package tags to the latest tag from their repositories."""
names = cfg.components
+ explicitly_selected = bool(names)
if not names:
- names = sorted(p.stem for p in Path("buildconfig").glob("*.tag"))
+ names = sorted(config.packages)
+ unknown = sorted(set(names) - set(config.packages))
+ if unknown:
+ raise ConfigError(f"unknown package(s): {', '.join(unknown)}")
# Multiple components can share a repo, only ask each remote once.
remote_tags = {}
upgraded = []
+ updated_packages = dict(config.packages)
for name in names:
- tag_file = Path("buildconfig") / f"{name}.tag"
- url_file = Path("buildconfig") / f"{name}.giturl"
- if not url_file.exists():
- print(f"[?] {name} has no giturl, skipping", file=sys.stderr)
+ package = config.packages[name]
+ if not package.auto_upgrade and not explicitly_selected:
+ print(f" {name} {package.tag} (automatic upgrades disabled)")
continue
- giturl = url_file.read_text().strip()
+ giturl = config.repository_for(package).url
if giturl not in remote_tags:
remote_tags[giturl] = list_remote_tags(giturl)
latest = latest_tag(remote_tags[giturl], cfg.dev)
if latest is None:
- print(f"[?] {name} has no usable tag in {giturl}, skipping", file=sys.stderr)
+ print(
+ f"[?] {name} has no usable tag in {giturl}, skipping", file=sys.stderr
+ )
continue
- curr = None
- if tag_file.exists():
- curr = tag_file.read_text().strip()
- currkey = tag_sortkey(curr)
- if currkey is None:
- print(
- f"[?] {name} tag {curr} has unsupported syntax, skipping",
- file=sys.stderr,
- )
- continue
- latestkey = tag_sortkey(latest)
- if currkey > latestkey:
- # Happens when the tag file pins a dev version but only
- # production tags are considered.
- print(f" {name} {curr} (newer than latest {latest})")
- continue
- if currkey == latestkey:
- print(f" {name} {curr} (up to date)")
- continue
- print(f"[!] {name} {curr or '(none)'} -> {latest}")
+ curr = package.tag
+ currkey = tag_sortkey(curr)
+ if currkey is None:
+ print(
+ f"[?] {name} tag {curr} has unsupported syntax, skipping",
+ file=sys.stderr,
+ )
+ continue
+ latestkey = tag_sortkey(latest)
+ if currkey > latestkey:
+ # Happens when a package pins a dev version but only production
+ # tags are considered.
+ print(f" {name} {curr} (newer than latest {latest})")
+ continue
+ if currkey == latestkey:
+ print(f" {name} {curr} (up to date)")
+ continue
+ print(f"[!] {name} {curr} -> {latest}")
upgraded.append(name)
if not cfg.dry:
- tag_file.write_text(latest + "\n")
+ updated_packages[name] = replace(package, tag=latest)
if not upgraded:
print("nothing to upgrade")
elif cfg.dry:
print("would upgrade:", " ".join(upgraded))
else:
+ write_config(config_path, replace(config, packages=updated_packages))
print("upgraded:", " ".join(upgraded))
@@ -644,6 +961,11 @@ def main():
subparsers = parser.add_subparsers(help="Run a subcommand", metavar="SUBCOMMAND")
+ parser_init = subparsers.add_parser(
+ "init", help="Initialize aptly repositories and publications."
+ )
+ parser_init.set_defaults(func=initialize)
+
# subcommand build
parser_build = subparsers.add_parser("build", help="Build packages for distro.")
@@ -653,16 +975,15 @@ def main():
parser_build.add_argument(
"--no-transitive",
help="Do not build transitive deps of changed components (default)",
- action="store_true",
+ action="store_false",
dest="transitive",
- default=None,
+ default=False,
)
parser_build.add_argument(
"--transitive",
help="Build transitive deps of changed components",
- action="store_false",
+ action="store_true",
dest="transitive",
- default=None,
)
parser_build.add_argument(
"--arch",
@@ -702,7 +1023,7 @@ def main():
parser_upgrade.add_argument(
"components",
nargs="*",
- help="Components to upgrade (default: all components in buildconfig)",
+ help="Components to upgrade (default: all packages in packages.toml)",
)
parser_upgrade.add_argument(
"--dev",
@@ -722,9 +1043,22 @@ def main():
# subcommand show-published
parser_show_published = subparsers.add_parser(
- "show-published", help="Show published packages on deb.taler.net"
+ "show-published", help="Show packages in a published stable or testing snapshot"
+ )
+ parser_show_published.add_argument(
+ "--latest",
+ action="store_true",
+ help="Show only the newest version of each package per architecture",
+ )
+ parser_show_published.add_argument(
+ "distro",
+ choices=[
+ f"{distro}{suffix}"
+ for distro in publishing_configs
+ for suffix in ("", "-stable", "-testing")
+ ],
+ help="Base distro (stable by default), or distro-stable/distro-testing",
)
- parser_show_published.add_argument("distro")
parser_show_published.set_defaults(func=show_published)
# subcommand publish
@@ -738,7 +1072,10 @@ def main():
parser_promote = subparsers.add_parser("promote", help="Promote testing to stable")
parser_promote.add_argument(
- "--dry", help="Dry run (show pulls)", action="store_true", default=False
+ "--dry",
+ help="Dry run (show testing changes)",
+ action="store_true",
+ default=False,
)
parser_promote.add_argument("distro")
parser_promote.set_defaults(func=promote)