taler-deployment

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

commit 981219adb2927d80c4f9181400291c5d304520b8
parent ddcd2db9a580dfbe9f1da76a8d683144ff76a1a5
Author: Florian Dold <dold@taler.net>
Date:   Thu,  3 Sep 2026 00:40:36 +0200

packaging/ng: replace reprepro publishing with aptly

Diffstat:
Mpackaging/ng/README.md | 40+++++++++++++++++++++++++++++++++++++++-
Apackaging/ng/server-side/README.md | 35+++++++++++++++++++++++++++++++++++
Dpackaging/ng/server-side/confs/debian/README | 2--
Dpackaging/ng/server-side/confs/debian/distributions | 38--------------------------------------
Dpackaging/ng/server-side/confs/debian/options | 3---
Dpackaging/ng/server-side/confs/debian/pulls | 14--------------
Dpackaging/ng/server-side/confs/ubuntu/README | 2--
Dpackaging/ng/server-side/confs/ubuntu/distributions | 19-------------------
Dpackaging/ng/server-side/confs/ubuntu/options | 3---
Dpackaging/ng/server-side/confs/ubuntu/pulls | 7-------
Mpackaging/ng/taler-pkg | 587+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mpackaging/ng/testing/test_build_grouping.py | 1+
Apackaging/ng/testing/test_publishing.py | 461+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
13 files changed, 975 insertions(+), 237 deletions(-)

diff --git a/packaging/ng/README.md b/packaging/ng/README.md @@ -56,7 +56,7 @@ so that this folder can be directly consumed as a trusted package source. * `packages.toml`: Repository, package, version, builder, and dependency configuration. * `buildscripts/*`: Build scripts used during the package build steps. * `distros/*`: Files for building for a specific distro. -* `server-side/*.sh`: Scripts that must be installed in taler-packaing@taler.net:$HOME +* `server-side/`: Server repository topology and initialization notes. ## Versions @@ -150,6 +150,44 @@ a package tag always produces a package that apt considers newer: ./taler-pkg build $DISTRO ``` +Supported publishing distributions are `debian-trixie` and `ubuntu-noble`. + +### Initializing the package repositories + +``` +./taler-pkg init +``` + +This idempotently initializes both supported distributions on +`taler-packaging@taler.net`. Testing is published directly from its mutable +aptly repo. Stable is published from an initial snapshot and is subsequently +changed only by promotion. Existing repositories and publications are +validated; incompatible existing state is reported rather than replaced. +The `apt/debian` and `apt/ubuntu` prefixes use aptly's default endpoint, so +they appear below `$rootDir/public`. + +Repository signing can prompt for the signing-key passphrase when a publication +is created or updated. + +### Publishing and promoting packages + +``` +# Preview and publish locally built packages to testing. +./taler-pkg publish --dry debian-trixie +./taler-pkg publish debian-trixie + +# List the packages in the current stable snapshot. +./taler-pkg show-published debian-trixie + +# Compare testing with stable, then promote testing as a new stable snapshot. +./taler-pkg promote --dry debian-trixie +./taler-pkg promote debian-trixie +``` + +Publishing imports only packages newer than those already in testing. The +testing repo retains all imported versions. Promotion snapshots are also kept +so that an older release can be selected manually if a rollback is needed. + ### Bumping component versions ``` diff --git a/packaging/ng/server-side/README.md b/packaging/ng/server-side/README.md @@ -0,0 +1,35 @@ +# Aptly repository layout + +The package repository on `taler-packaging@taler.net` has one mutable testing +repo and one bootstrap repo for each supported distribution: + +| Build distro | Testing repo | Stable bootstrap repo | Publish prefix | +| --- | --- | --- | --- | +| `debian-trixie` | `taler-debian-trixie-testing` | `taler-debian-trixie-stable` | `apt/debian` | +| `ubuntu-noble` | `taler-ubuntu-noble-testing` | `taler-ubuntu-noble-stable` | `apt/ubuntu` | + +These prefixes use aptly's default filesystem endpoint. Aptly therefore +publishes them below its default publish directory as +`$rootDir/public/apt/debian` and `$rootDir/public/apt/ubuntu`; no named +filesystem endpoint is involved. + +All repositories use the `main` component and publish `amd64` and `arm64`. +Testing distributions are `trixie-testing` and `noble-testing`; stable +distributions are `trixie` and `noble`. + +Run the initialization from `packaging/ng`: + +``` +./taler-pkg init +``` + +The command creates anything missing for both distributions. It directly +publishes each testing repo, creates an initial snapshot from each stable +bootstrap repo, and publishes that snapshot as stable. Rerunning the command +validates the existing topology and otherwise does nothing. It deliberately +refuses to replace incompatible publications. + +Normal uploads go only to the testing repos. `taler-pkg promote DISTRO` +snapshots the complete testing repo and atomically switches the stable +publication to that snapshot. Testing retains all imported versions, and +promotion snapshots are retained for manual rollback. diff --git a/packaging/ng/server-side/confs/debian/README b/packaging/ng/server-side/confs/debian/README @@ -1 +0,0 @@ -These files go into www/apt/debian/conf/ -\ No newline at end of file diff --git a/packaging/ng/server-side/confs/debian/distributions b/packaging/ng/server-side/confs/debian/distributions @@ -1,38 +0,0 @@ -Origin: GNU Taler -Label: Taler -Suite: stable -Codename: bookworm -Architectures: amd64 -Components: main -Description: Debian bookworm package repository for GNU Taler -SignWith: 0084993C2C6CDF471A7D7EFD26E546A5FE7E0266 -Pull: bookworm-testing - -Origin: GNU Taler -Label: Taler -Suite: testing -Codename: bookworm-testing -Architectures: amd64 -Components: main -Description: Debian bookworm package repository for GNU Taler -SignWith: 0084993C2C6CDF471A7D7EFD26E546A5FE7E0266 - -Origin: GNU Taler -Label: Taler -Suite: stable -Codename: trixie -Architectures: amd64 arm64 -Components: main -Description: Debian bookworm package repository for GNU Taler -SignWith: 0084993C2C6CDF471A7D7EFD26E546A5FE7E0266 -Pull: trixie-testing - -Origin: GNU Taler -Label: Taler -Suite: testing -Codename: trixie-testing -Architectures: amd64 arm64 -Components: main -Description: Debian bookworm package repository for GNU Taler -SignWith: 0084993C2C6CDF471A7D7EFD26E546A5FE7E0266 - diff --git a/packaging/ng/server-side/confs/debian/options b/packaging/ng/server-side/confs/debian/options @@ -1,3 +0,0 @@ -verbose -basedir /home/taler-packaging/www/apt/debian -ask-passphrase diff --git a/packaging/ng/server-side/confs/debian/pulls b/packaging/ng/server-side/confs/debian/pulls @@ -1,14 +0,0 @@ -Name: bookworm-testing -From: bookworm-testing -Components: main -Architectures: amd64 -UDebComponents: -FilterFormula: Package (% * ) - - -Name: trixie-testing -From: trixie-testing -Components: main -Architectures: amd64 -UDebComponents: -FilterFormula: Package (% * ) diff --git a/packaging/ng/server-side/confs/ubuntu/README b/packaging/ng/server-side/confs/ubuntu/README @@ -1 +0,0 @@ -These files go into www/apt/ubuntu/conf/ -\ No newline at end of file diff --git a/packaging/ng/server-side/confs/ubuntu/distributions b/packaging/ng/server-side/confs/ubuntu/distributions @@ -1,19 +0,0 @@ -Origin: GNU Taler -Label: Taler -Suite: testing -Codename: noble-testing -Architectures: amd64 arm64 -Components: main debug -Description: Ubuntu Noble repository for GNU Taler -SignWith: 0084993C2C6CDF471A7D7EFD26E546A5FE7E0266 - -Origin: GNU Taler -Label: Taler -Suite: stable -Codename: noble -Architectures: amd64 arm64 -Components: main debug -Description: Ubuntu Noble repository for GNU Taler -SignWith: 0084993C2C6CDF471A7D7EFD26E546A5FE7E0266 -Pull: noble-testing - diff --git a/packaging/ng/server-side/confs/ubuntu/options b/packaging/ng/server-side/confs/ubuntu/options @@ -1,3 +0,0 @@ -verbose -basedir /home/taler-packaging/www/apt/ubuntu -ask-passphrase diff --git a/packaging/ng/server-side/confs/ubuntu/pulls b/packaging/ng/server-side/confs/ubuntu/pulls @@ -1,7 +0,0 @@ -Name: noble-testing -From: noble-testing -Components: main -Architectures: amd64 -UDebComponents: -FilterFormula: Package (% * ) - diff --git a/packaging/ng/taler-pkg b/packaging/ng/taler-pkg @@ -5,12 +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 replace +from dataclasses import dataclass, replace from pathlib import Path # Make local util package available @@ -27,10 +29,76 @@ 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 stable_repo(self): + return f"taler-{self.vendor}-{self.codename}-stable" + + @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"{self.stable_repo}-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) + + deps = { name: list(package.dependencies) for name, package in config.packages.items() @@ -266,56 +334,262 @@ 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) - 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 + sources = publication.get("sources", {}) + 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") + if set(sources) != {"main"}: + raise ValueError(f"unexpected components for {distribution} publication") + source = sources["main"] + if stable: + if source[1] != "snapshot": + raise ValueError(f"stable publication {distribution} is not a snapshot") + origin = snapshot_origin(source[0]) + if origin not in {pubcfg.stable_repo, pubcfg.testing_repo}: + raise ValueError( + f"stable publication {distribution} has unexpected source {origin}" + ) + elif source != (pubcfg.testing_repo, "local"): + raise ValueError( + f"testing publication {distribution} has unexpected source {source[0]}" ) + + +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(): + for repo, distribution in ( + (pubcfg.stable_repo, pubcfg.codename), + (pubcfg.testing_repo, pubcfg.testing_distribution), + ): + if repo not in repos: + remote_aptly( + "repo", + "create", + f"-distribution={distribution}", + "-component=main", + repo, + ) + repos.add(repo) + changed = True + + testing_key = (pubcfg.prefix, pubcfg.testing_distribution) + if testing_key not in publications: + remote_aptly( + "publish", + "repo", + *publish_options(pubcfg.testing_distribution), + pubcfg.testing_repo, + 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, + "from", + "repo", + pubcfg.stable_repo, + ) + snapshots.add(pubcfg.initial_snapshot) + changed = True + elif snapshot_origin(pubcfg.initial_snapshot) != pubcfg.stable_repo: + raise ValueError( + f"snapshot {pubcfg.initial_snapshot} has an unexpected source" + ) + 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: - 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, - ) + print("aptly repositories and publications already initialized") -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 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 stable_snapshot(pubcfg): + publication = get_publication(pubcfg.codename, pubcfg.prefix) + sources = publication.get("sources", {}) + if set(sources) != {"main"} or sources["main"][1] != "snapshot": + raise ValueError(f"stable publication {pubcfg.codename} is not a snapshot") + return sources["main"][0] + + +def promotion_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 promote(cfg): + pubcfg = publishing_config(cfg.distro) + if cfg.dry: + stable = set(snapshot_packages(stable_snapshot(pubcfg))) + testing = set(repo_packages(pubcfg.testing_repo)) + 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 + + snapshot = promotion_snapshot_name(pubcfg) + remote_aptly("snapshot", "create", snapshot, "from", "repo", pubcfg.testing_repo) + remote_aptly( + "publish", + "switch", + pubcfg.codename, + pubcfg.prefix, + snapshot, + tty=True, + ) + print(f"promoted {pubcfg.testing_repo} as snapshot {snapshot}") + + +def show_published(cfg): + pubcfg = publishing_config(cfg.distro) + for package in snapshot_packages(stable_snapshot(pubcfg)): + print(package) def test(cfg): @@ -359,116 +633,123 @@ 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() + +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 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) + + +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 + + +def current_package_files(distro): + current = [] + seen = set() for component in components: - current = [] + component_current = [] for arch in archs + ["all"]: - cf = Path(f"./packages/{distro}/{component}@{arch}.built.current") - if not cf.exists(): + 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 - 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 ValueError(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") + 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 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) + server_packages = repo_packages(pubcfg.testing_repo) + 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("uploading debs", debs) - if cfg.dry: - return - debs = [Path(f"./packages/{distro}/") / x for x in debs] - subprocess.run( + print("nothing to upload") + if cfg.dry: + return + + if uploads: + staging_dir = f"/home/{remote_user}/{distro}" + remote_command(["mkdir", "-p", staging_dir]) + remote_command( [ - "ssh", - f"taler-packaging@{host}", - f"rm -f '/home/taler-packaging/{distro}/'*.deb '/home/taler-packaging/{distro}/'*.ddeb", - ], - check=True, + "find", + staging_dir, + "-maxdepth", + "1", + "-type", + "f", + "(", + "-name", + "*.deb", + "-o", + "-name", + "*.ddeb", + ")", + "-delete", + ] ) + 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", "--", *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", + "rsync", + "-a", + "--info=progress2", + "--", + *local_files, + f"{remote_user}@{host}:{staging_dir}/", ], + check=True, ) - 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, + remote_aptly("repo", "add", "-remove-files", pubcfg.testing_repo, *remote_files) + + remote_aptly( + "publish", "update", pubcfg.testing_distribution, pubcfg.prefix, tty=True ) @@ -565,7 +846,9 @@ def upgrade(cfg): 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 = package.tag currkey = tag_sortkey(curr) @@ -604,6 +887,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.") @@ -697,7 +985,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) diff --git a/packaging/ng/testing/test_build_grouping.py b/packaging/ng/testing/test_build_grouping.py @@ -13,6 +13,7 @@ from types import SimpleNamespace from unittest.mock import patch ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "buildscripts")) LOADER = importlib.machinery.SourceFileLoader("taler_pkg", str(ROOT / "taler-pkg")) SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) diff --git a/packaging/ng/testing/test_publishing.py b/packaging/ng/testing/test_publishing.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 + +# This file is in the public domain. + +import datetime +import importlib.machinery +import importlib.util +import subprocess +import sys +import unittest +from io import StringIO +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import call, patch + +ROOT = Path(__file__).parents[1] +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(ROOT / "buildscripts")) +LOADER = importlib.machinery.SourceFileLoader( + "taler_pkg_publishing", str(ROOT / "taler-pkg") +) +SPEC = importlib.util.spec_from_loader(LOADER.name, LOADER) +TALER_PKG = importlib.util.module_from_spec(SPEC) +LOADER.exec_module(TALER_PKG) + + +def result(stdout=""): + return subprocess.CompletedProcess([], 0, stdout=stdout) + + +def publication(prefix, distribution, source, kind): + return ( + f"Prefix: {prefix}\n" + f"Distribution: {distribution}\n" + "Architectures: amd64 arm64\n" + "Sources:\n" + f" main: {source} [{kind}]\n" + ) + + +class PublishingConfigTests(unittest.TestCase): + def test_distribution_mapping(self): + debian = TALER_PKG.publishing_config("debian-trixie") + ubuntu = TALER_PKG.publishing_config("ubuntu-noble") + + self.assertEqual("apt/debian", debian.prefix) + self.assertEqual("taler-debian-trixie-testing", debian.testing_repo) + self.assertEqual("trixie-testing", debian.testing_distribution) + self.assertEqual("apt/ubuntu", ubuntu.prefix) + self.assertEqual("taler-ubuntu-noble-stable", ubuntu.stable_repo) + with self.assertRaisesRegex(ValueError, "unsupported publishing distro"): + TALER_PKG.publishing_config("debian-bookworm") + + def test_remote_command_quotes_every_remote_argument(self): + with patch.object(TALER_PKG.subprocess, "run", return_value=result()) as run: + TALER_PKG.remote_command( + ["command", "argument with spaces", "*.deb"], + capture_output=True, + tty=True, + ) + + run.assert_called_once_with( + [ + "ssh", + "-t", + "taler-packaging@taler.net", + "command 'argument with spaces' '*.deb'", + ], + check=True, + capture_output=True, + text=True, + ) + + def test_parses_publication_and_package_list(self): + parsed = TALER_PKG.parse_publication( + publication( + "apt/debian", + "trixie", + "taler-debian-trixie-testing-20260903T120000000000Z", + "snapshot", + ) + ) + packages = TALER_PKG.parse_package_list( + "Name: repo\nPackages:\n one_1_amd64\n two_2_all\n" + ) + + self.assertEqual("apt/debian", parsed["prefix"]) + self.assertEqual( + ( + "taler-debian-trixie-testing-20260903T120000000000Z", + "snapshot", + ), + parsed["sources"]["main"], + ) + self.assertEqual(["one_1_amd64", "two_2_all"], packages) + + def test_snapshot_names_are_utc_and_unique_to_microseconds(self): + pubcfg = TALER_PKG.publishing_config("debian-trixie") + now = datetime.datetime( + 2026, 9, 3, 12, 34, 56, 123456, tzinfo=datetime.timezone.utc + ) + + name = TALER_PKG.promotion_snapshot_name(pubcfg, now) + + self.assertEqual("taler-debian-trixie-testing-20260903T123456123456Z", name) + + +class InitializeTests(unittest.TestCase): + def test_initializes_all_missing_repositories_and_publications(self): + outputs = iter([result(), result(), result()]) + + def aptly(*args, **kwargs): + if kwargs.get("capture_output"): + return next(outputs) + return result() + + with ( + patch.object(TALER_PKG, "remote_aptly", side_effect=aptly) as remote, + patch("sys.stdout", new_callable=StringIO) as stdout, + ): + TALER_PKG.initialize(SimpleNamespace()) + + calls = remote.call_args_list + self.assertIn( + call( + "repo", + "create", + "-distribution=trixie-testing", + "-component=main", + "taler-debian-trixie-testing", + ), + calls, + ) + self.assertIn( + call( + "publish", + "repo", + *TALER_PKG.publish_options("trixie-testing"), + "taler-debian-trixie-testing", + "apt/debian", + tty=True, + ), + calls, + ) + self.assertIn( + call( + "snapshot", + "create", + "taler-ubuntu-noble-stable-initial", + "from", + "repo", + "taler-ubuntu-noble-stable", + ), + calls, + ) + self.assertIn( + call( + "publish", + "snapshot", + *TALER_PKG.publish_options("noble"), + "taler-ubuntu-noble-stable-initial", + "apt/ubuntu", + tty=True, + ), + calls, + ) + self.assertIn("initialized", stdout.getvalue()) + + def test_complete_initialization_is_a_noop(self): + repos = "\n".join( + repo + for pubcfg in TALER_PKG.publishing_configs.values() + for repo in (pubcfg.stable_repo, pubcfg.testing_repo) + ) + snapshots = "debian-snapshot\nubuntu-snapshot\n" + publications = ( + "apt/debian trixie-testing\n" + "apt/debian trixie\n" + "apt/ubuntu noble-testing\n" + "apt/ubuntu noble\n" + ) + + def aptly(*args, **kwargs): + if args == ("repo", "list", "-raw"): + return result(repos) + if args == ("snapshot", "list", "-raw"): + return result(snapshots) + if args == ("publish", "list", "-raw"): + return result(publications) + if args[:2] == ("publish", "show"): + distribution, prefix = args[2:] + pubcfg = next( + item + for item in TALER_PKG.publishing_configs.values() + if item.prefix == prefix + ) + if distribution.endswith("-testing"): + return result( + publication(prefix, distribution, pubcfg.testing_repo, "local") + ) + snapshot = ( + "debian-snapshot" + if pubcfg.vendor == "debian" + else "ubuntu-snapshot" + ) + return result(publication(prefix, distribution, snapshot, "snapshot")) + if args[:2] == ("snapshot", "show"): + pubcfg = ( + TALER_PKG.publishing_config("debian-trixie") + if args[2] == "debian-snapshot" + else TALER_PKG.publishing_config("ubuntu-noble") + ) + return result( + f"Description: Snapshot from local repo [{pubcfg.testing_repo}]\n" + ) + self.fail(f"unexpected aptly call: {args} {kwargs}") + + with ( + patch.object(TALER_PKG, "remote_aptly", side_effect=aptly) as remote, + patch("sys.stdout", new_callable=StringIO) as stdout, + ): + TALER_PKG.initialize(SimpleNamespace()) + + self.assertFalse( + any(not item.kwargs.get("capture_output") for item in remote.mock_calls) + ) + self.assertIn("already initialized", stdout.getvalue()) + + def test_reuses_initial_snapshots_when_only_stable_publish_is_missing(self): + repos = "\n".join( + repo + for pubcfg in TALER_PKG.publishing_configs.values() + for repo in (pubcfg.stable_repo, pubcfg.testing_repo) + ) + snapshots = "\n".join( + pubcfg.initial_snapshot for pubcfg in TALER_PKG.publishing_configs.values() + ) + publications = "apt/debian trixie-testing\napt/ubuntu noble-testing\n" + + def aptly(*args, **kwargs): + if args == ("repo", "list", "-raw"): + return result(repos) + if args == ("snapshot", "list", "-raw"): + return result(snapshots) + if args == ("publish", "list", "-raw"): + return result(publications) + if args[:2] == ("publish", "show"): + distribution, prefix = args[2:] + pubcfg = next( + item + for item in TALER_PKG.publishing_configs.values() + if item.prefix == prefix + ) + return result( + publication(prefix, distribution, pubcfg.testing_repo, "local") + ) + if args[:2] == ("snapshot", "show"): + pubcfg = next( + item + for item in TALER_PKG.publishing_configs.values() + if item.initial_snapshot == args[2] + ) + return result( + f"Description: Snapshot from local repo [{pubcfg.stable_repo}]\n" + ) + return result() + + with patch.object(TALER_PKG, "remote_aptly", side_effect=aptly) as remote: + TALER_PKG.initialize(SimpleNamespace()) + + mutations = [ + item for item in remote.mock_calls if not item.kwargs.get("capture_output") + ] + self.assertEqual(2, len(mutations)) + self.assertTrue( + all(item.args[:2] == ("publish", "snapshot") for item in mutations) + ) + + def test_rejects_existing_testing_publication_with_wrong_source(self): + pubcfg = TALER_PKG.publishing_config("debian-trixie") + wrong = publication( + pubcfg.prefix, + pubcfg.testing_distribution, + pubcfg.stable_repo, + "local", + ) + with self.assertRaisesRegex(ValueError, "unexpected source"): + TALER_PKG.validate_publication( + pubcfg, TALER_PKG.parse_publication(wrong), stable=False + ) + + +class PublishTests(unittest.TestCase): + def test_uploads_only_newer_packages_and_updates_testing(self): + local_packages = [ + "merchant_2.0_amd64.deb", + "merchant-dbgsym_2.0_amd64.ddeb", + "exchange_1.0_amd64.deb", + ] + server_packages = [ + "merchant_1.0_amd64", + "merchant_3.0_arm64", + "merchant-dbgsym_1.0_amd64", + "exchange_2.0_amd64", + ] + with ( + patch.object( + TALER_PKG, "current_package_files", return_value=local_packages + ), + patch.object(TALER_PKG, "repo_packages", return_value=server_packages), + patch.object(TALER_PKG, "remote_command") as remote_command, + patch.object(TALER_PKG, "remote_aptly") as remote_aptly, + patch.object(TALER_PKG.subprocess, "run") as run, + ): + TALER_PKG.publish(SimpleNamespace(distro="debian-trixie", dry=False)) + + rsync = run.call_args.args[0] + self.assertIn(Path("packages/debian-trixie/merchant_2.0_amd64.deb"), rsync) + self.assertIn( + Path("packages/debian-trixie/merchant-dbgsym_2.0_amd64.ddeb"), rsync + ) + self.assertNotIn(Path("packages/debian-trixie/exchange_1.0_amd64.deb"), rsync) + self.assertEqual(2, remote_command.call_count) + remote_aptly.assert_has_calls( + [ + call( + "repo", + "add", + "-remove-files", + "taler-debian-trixie-testing", + "/home/taler-packaging/debian-trixie/merchant_2.0_amd64.deb", + "/home/taler-packaging/debian-trixie/merchant-dbgsym_2.0_amd64.ddeb", + ), + call( + "publish", + "update", + "trixie-testing", + "apt/debian", + tty=True, + ), + ] + ) + self.assertFalse( + any( + call.args[0:2] == ("repo", "remove") for call in remote_aptly.mock_calls + ) + ) + + def test_rejects_non_package_or_path_artifacts(self): + for filename in ( + "merchant.changes", + "merchant.deb", + "../merchant_2.0_amd64.deb", + ): + with self.subTest(filename=filename): + with self.assertRaisesRegex(ValueError, "invalid package"): + TALER_PKG.package_file_identity(filename) + + def test_dry_publish_never_mutates(self): + with ( + patch.object( + TALER_PKG, + "current_package_files", + return_value=["merchant_2.0_amd64.deb"], + ), + patch.object(TALER_PKG, "repo_packages", return_value=[]), + patch.object(TALER_PKG, "remote_command") as remote_command, + patch.object(TALER_PKG, "remote_aptly") as remote_aptly, + patch.object(TALER_PKG.subprocess, "run") as run, + ): + TALER_PKG.publish(SimpleNamespace(distro="debian-trixie", dry=True)) + + remote_command.assert_not_called() + remote_aptly.assert_not_called() + run.assert_not_called() + + def test_empty_publish_still_refreshes_testing_metadata(self): + with ( + patch.object(TALER_PKG, "current_package_files", return_value=[]), + patch.object(TALER_PKG, "repo_packages", return_value=[]), + patch.object(TALER_PKG, "remote_command") as remote_command, + patch.object(TALER_PKG, "remote_aptly") as remote_aptly, + patch.object(TALER_PKG.subprocess, "run") as run, + ): + TALER_PKG.publish(SimpleNamespace(distro="ubuntu-noble", dry=False)) + + remote_command.assert_not_called() + run.assert_not_called() + remote_aptly.assert_called_once_with( + "publish", "update", "noble-testing", "apt/ubuntu", tty=True + ) + + +class PromoteTests(unittest.TestCase): + def test_dry_promote_shows_stable_to_testing_diff(self): + with ( + patch.object(TALER_PKG, "stable_snapshot", return_value="stable-snapshot"), + patch.object( + TALER_PKG, + "snapshot_packages", + return_value=["common_1_all", "removed_1_amd64"], + ), + patch.object( + TALER_PKG, + "repo_packages", + return_value=["common_1_all", "new_2_amd64"], + ), + patch.object(TALER_PKG, "remote_aptly") as remote_aptly, + patch("sys.stdout", new_callable=StringIO) as stdout, + ): + TALER_PKG.promote(SimpleNamespace(distro="debian-trixie", dry=True)) + + self.assertEqual("- removed_1_amd64\n+ new_2_amd64\n", stdout.getvalue()) + remote_aptly.assert_not_called() + + def test_promote_snapshots_testing_and_switches_stable(self): + snapshot = "taler-ubuntu-noble-testing-20260903T123456123456Z" + with ( + patch.object(TALER_PKG, "promotion_snapshot_name", return_value=snapshot), + patch.object(TALER_PKG, "remote_aptly") as remote, + ): + TALER_PKG.promote(SimpleNamespace(distro="ubuntu-noble", dry=False)) + + remote.assert_has_calls( + [ + call( + "snapshot", + "create", + snapshot, + "from", + "repo", + "taler-ubuntu-noble-testing", + ), + call( + "publish", + "switch", + "noble", + "apt/ubuntu", + snapshot, + tty=True, + ), + ] + ) + + def test_show_published_lists_current_stable_snapshot(self): + with ( + patch.object(TALER_PKG, "stable_snapshot", return_value="stable-snapshot"), + patch.object( + TALER_PKG, + "snapshot_packages", + return_value=["one_1_amd64", "two_2_all"], + ), + patch("sys.stdout", new_callable=StringIO) as stdout, + ): + TALER_PKG.show_published(SimpleNamespace(distro="debian-trixie")) + + self.assertEqual("one_1_amd64\ntwo_2_all\n", stdout.getvalue()) + + +if __name__ == "__main__": + unittest.main()