taler-deployment

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

commit 650728d0570e42834e39f272710d45c5303d4572
parent 1d5a56424b6fdb056cb0ff5f3f697f0218290aa7
Author: Florian Dold <dold@taler.net>
Date:   Sun,  6 Sep 2026 19:24:12 +0200

packaging/ng: publish testing snapshots and select displayed channels

Publish testing from snapshots and promote the snapshot currently served
in testing. Keep pending imports out of promotion and published listings.

Allow show-published to select stable or testing through distro suffixes,
with unsuffixed distros continuing to select stable.

Diffstat:
Mpackaging/ng/README.md | 36++++++++++++++++++++++++++----------
Mpackaging/ng/server-side/README.md | 26++++++++++++++++----------
Mpackaging/ng/taler-pkg | 109++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------
Mpackaging/ng/testing/test_publishing.py | 451++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------
4 files changed, 493 insertions(+), 129 deletions(-)

diff --git a/packaging/ng/README.md b/packaging/ng/README.md @@ -163,10 +163,10 @@ Supported publishing distributions are `debian-trixie` and `ubuntu-noble`. ``` 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. +`taler-packaging@taler.net`. Testing is initially published from a snapshot of +its mutable aptly repo. Stable starts with an empty 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`. @@ -180,10 +180,12 @@ is created or updated. ./taler-pkg publish --dry debian-trixie ./taler-pkg publish debian-trixie -# List the packages in the current stable snapshot. +# List the packages in the current stable or testing publication. ./taler-pkg show-published debian-trixie +./taler-pkg show-published debian-trixie-stable +./taler-pkg show-published debian-trixie-testing -# Compare testing with stable, then promote testing as a new stable snapshot. +# Compare published testing with stable, then promote that exact snapshot. ./taler-pkg promote --dry debian-trixie ./taler-pkg promote debian-trixie ``` @@ -196,13 +198,27 @@ different contents; publishing a changed build requires a newer version or Debian revision. `--dry` performs the same checks without changing the server. An incomplete server inventory aborts publishing before any files are uploaded. +After importing uploads, publishing snapshots the complete testing repo and +switches the testing publication to that snapshot. This also happens when no +new files need uploading, so pending imports can be published. Promotion +switches stable to the snapshot currently published in testing; imports that +have not been successfully published cannot reach stable through promotion. + +`show-published` queries the selected publication's snapshot through aptly, +lists its enabled architectures plus architecture-independent packages, and +prints sorted, unique `name_version_architecture` lines. A base distro selects +stable; the `-stable` and `-testing` suffixes select either channel explicitly. +The same suffixes work for `ubuntu-noble`. Pending testing imports are not +included. These suffixes are specific to `show-published`; building, publishing, +and promoting continue to use the base distro. + Packages found only in stable are skipped without copying them into testing. Since promotion replaces stable with the complete testing snapshot, those packages can disappear from stable on the next promotion. 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. Conflicts -already imported into testing require a separate repair; skipping local -uploads does not remove them. +retains all imported versions. Testing snapshots are also kept, including +unpublished snapshots from failed attempts, so that an older release can be +selected manually if a rollback is needed. Conflicts already imported into +testing require a separate repair; skipping local uploads does not remove them. After a successful publish, including one with nothing new to upload, all top-level regular `.deb` and `.ddeb` files in the remote upload directory diff --git a/packaging/ng/server-side/README.md b/packaging/ng/server-side/README.md @@ -23,13 +23,19 @@ 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 empty initial snapshot, 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. +The command creates anything missing for both distributions. It publishes a +snapshot of each testing repo, creates an empty initial snapshot, and publishes +that empty 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 publish DISTRO` +snapshots the complete testing repo and switches the testing publication to +that snapshot. `taler-pkg promote DISTRO` switches stable to the snapshot +currently published in testing. Pending imports are excluded from promotion +and from `show-published DISTRO-testing` until successfully published. + +Testing repos retain all imported versions. Snapshots, including those from +failed publish attempts, are retained for manual rollback. Both stable and +testing publications must use snapshots from their distribution's testing +repo, except that stable may still use its empty initial snapshot. diff --git a/packaging/ng/taler-pkg b/packaging/ng/taler-pkg @@ -398,35 +398,26 @@ def snapshot_origin(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") - validate_stable_snapshot(pubcfg, source[0]) - elif source != (pubcfg.testing_repo, "local"): - raise ValueError( - f"testing publication {distribution} has unexpected source {source[0]}" - ) + snapshot = publication_snapshot(publication) + validate_snapshot(pubcfg, snapshot, stable=stable) -def validate_stable_snapshot(pubcfg, snapshot): - if snapshot == pubcfg.initial_snapshot: +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: origin = snapshot_origin(snapshot) if origin != pubcfg.testing_repo: + distribution = pubcfg.codename if stable else pubcfg.testing_distribution raise ValueError( - f"stable publication {pubcfg.codename} has unexpected source {origin}" + f"publication {distribution} has unexpected source {origin}" ) @@ -462,11 +453,12 @@ def initialize(cfg): testing_key = (pubcfg.prefix, pubcfg.testing_distribution) if testing_key not in publications: + snapshot = create_testing_snapshot(pubcfg) remote_aptly( "publish", - "repo", + "snapshot", *publish_options(pubcfg.testing_distribution), - pubcfg.testing_repo, + snapshot, pubcfg.prefix, tty=True, ) @@ -491,7 +483,7 @@ def initialize(cfg): snapshots.add(pubcfg.initial_snapshot) changed = True else: - validate_stable_snapshot(pubcfg, pubcfg.initial_snapshot) + validate_snapshot(pubcfg, pubcfg.initial_snapshot, stable=True) remote_aptly( "publish", "snapshot", @@ -567,26 +559,53 @@ def publishing_packages(pubcfg): return sorted(packages) -def stable_snapshot(pubcfg): - publication = get_publication(pubcfg.codename, pubcfg.prefix) +def publication_snapshot(publication): + distribution = publication.get("distribution", "unknown") 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] + 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 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 promotion_snapshot_name(pubcfg, now=None): +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 = set(snapshot_packages(stable_snapshot(pubcfg))) - testing = set(repo_packages(pubcfg.testing_repo)) + 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): @@ -595,8 +614,6 @@ def promote(cfg): 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", @@ -605,12 +622,21 @@ def promote(cfg): snapshot, tty=True, ) - print(f"promoted {pubcfg.testing_repo} as snapshot {snapshot}") + print(f"promoted published testing snapshot {snapshot}") def show_published(cfg): - pubcfg = publishing_config(cfg.distro) - for package in snapshot_packages(stable_snapshot(pubcfg)): + 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"} + } + for package in sorted(packages): print(package) @@ -728,6 +754,7 @@ def publish(cfg): 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): @@ -768,8 +795,14 @@ def publish(cfg): ) remote_aptly("repo", "add", pubcfg.testing_repo, *remote_files) + snapshot = create_testing_snapshot(pubcfg) remote_aptly( - "publish", "update", pubcfg.testing_distribution, pubcfg.prefix, tty=True + "publish", + "switch", + pubcfg.testing_distribution, + pubcfg.prefix, + snapshot, + tty=True, ) try: cleanup_uploads(staging_dir) @@ -1000,9 +1033,17 @@ 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( + "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 diff --git a/packaging/ng/testing/test_publishing.py b/packaging/ng/testing/test_publishing.py @@ -48,9 +48,7 @@ class PublishingConfigTests(unittest.TestCase): 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-initial", ubuntu.initial_snapshot - ) + self.assertEqual("taler-ubuntu-noble-stable-initial", ubuntu.initial_snapshot) with self.assertRaisesRegex(ValueError, "unsupported publishing distro"): TALER_PKG.publishing_config("debian-bookworm") @@ -103,7 +101,7 @@ class PublishingConfigTests(unittest.TestCase): 2026, 9, 3, 12, 34, 56, 123456, tzinfo=datetime.timezone.utc ) - name = TALER_PKG.promotion_snapshot_name(pubcfg, now) + name = TALER_PKG.testing_snapshot_name(pubcfg, now) self.assertEqual("taler-debian-trixie-testing-20260903T123456123456Z", name) @@ -119,6 +117,11 @@ class InitializeTests(unittest.TestCase): with ( patch.object(TALER_PKG, "remote_aptly", side_effect=aptly) as remote, + patch.object( + TALER_PKG, + "testing_snapshot_name", + side_effect=lambda pubcfg: f"{pubcfg.testing_repo}-initial-test", + ), patch("sys.stdout", new_callable=StringIO) as stdout, ): TALER_PKG.initialize(SimpleNamespace()) @@ -137,9 +140,9 @@ class InitializeTests(unittest.TestCase): self.assertIn( call( "publish", - "repo", + "snapshot", *TALER_PKG.publish_options("trixie-testing"), - "taler-debian-trixie-testing", + "taler-debian-trixie-testing-initial-test", "apt/debian", tty=True, ), @@ -173,11 +176,22 @@ class InitializeTests(unittest.TestCase): ) ) self.assertIn("initialized", stdout.getvalue()) + for pubcfg in TALER_PKG.publishing_configs.values(): + self.assertIn( + call( + "snapshot", + "create", + f"{pubcfg.testing_repo}-initial-test", + "from", + "repo", + pubcfg.testing_repo, + ), + calls, + ) def test_complete_initialization_is_a_noop(self): repos = "\n".join( - pubcfg.testing_repo - for pubcfg in TALER_PKG.publishing_configs.values() + pubcfg.testing_repo for pubcfg in TALER_PKG.publishing_configs.values() ) snapshots = "debian-snapshot\nubuntu-snapshot\n" publications = ( @@ -201,10 +215,6 @@ class InitializeTests(unittest.TestCase): 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" @@ -235,8 +245,7 @@ class InitializeTests(unittest.TestCase): def test_reuses_initial_snapshots_when_only_stable_publish_is_missing(self): repos = "\n".join( - pubcfg.testing_repo - for pubcfg in TALER_PKG.publishing_configs.values() + pubcfg.testing_repo for pubcfg in TALER_PKG.publishing_configs.values() ) snapshots = "\n".join( pubcfg.initial_snapshot for pubcfg in TALER_PKG.publishing_configs.values() @@ -258,9 +267,17 @@ class InitializeTests(unittest.TestCase): if item.prefix == prefix ) return result( - publication(prefix, distribution, pubcfg.testing_repo, "local") + publication( + prefix, + distribution, + pubcfg.testing_repo + "-snapshot", + "snapshot", + ) ) if args[:2] == ("snapshot", "show"): + if args[2].endswith("-snapshot"): + repo = args[2].removesuffix("-snapshot") + return result(f"Description: Snapshot from local repo [{repo}]\n") return result( "Description: Snapshot from local repo [legacy-stable-repo]\n" "Packages:\n" @@ -278,15 +295,15 @@ class InitializeTests(unittest.TestCase): all(item.args[:2] == ("publish", "snapshot") for item in mutations) ) - def test_rejects_existing_testing_publication_with_wrong_source(self): + def test_rejects_legacy_testing_publication(self): pubcfg = TALER_PKG.publishing_config("debian-trixie") wrong = publication( pubcfg.prefix, pubcfg.testing_distribution, - "unexpected-repo", + pubcfg.testing_repo, "local", ) - with self.assertRaisesRegex(ValueError, "unexpected source"): + with self.assertRaisesRegex(ValueError, "convert it to a snapshot publication"): TALER_PKG.validate_publication( pubcfg, TALER_PKG.parse_publication(wrong), stable=False ) @@ -329,22 +346,52 @@ class InitializeTests(unittest.TestCase): pubcfg, TALER_PKG.parse_publication(initial), stable=True ) - def test_rejects_stable_snapshot_with_wrong_origin(self): + def test_rejects_snapshot_with_wrong_origin_in_either_channel(self): pubcfg = TALER_PKG.publishing_config("debian-trixie") - stable = publication( - pubcfg.prefix, - pubcfg.codename, - "unexpected-snapshot", - "snapshot", - ) + for stable in (False, True): + with self.subTest(stable=stable): + details = publication( + pubcfg.prefix, + pubcfg.codename if stable else pubcfg.testing_distribution, + "unexpected-snapshot", + "snapshot", + ) + with ( + patch.object( + TALER_PKG, "snapshot_origin", return_value="unexpected-repo" + ), + self.assertRaisesRegex(ValueError, "unexpected source"), + ): + TALER_PKG.validate_publication( + pubcfg, TALER_PKG.parse_publication(details), stable=stable + ) + def test_initialization_rejects_legacy_testing_without_replacing_it(self): + pubcfg = TALER_PKG.publishing_config("debian-trixie") with ( - patch.object(TALER_PKG, "snapshot_origin", return_value="unexpected-repo"), - self.assertRaisesRegex(ValueError, "unexpected source"), + patch.object(TALER_PKG, "aptly_list", return_value={pubcfg.testing_repo}), + patch.object( + TALER_PKG, + "published_repositories", + return_value={(pubcfg.prefix, pubcfg.testing_distribution)}, + ), + patch.object( + TALER_PKG, + "get_publication", + return_value=TALER_PKG.parse_publication( + publication( + pubcfg.prefix, + pubcfg.testing_distribution, + pubcfg.testing_repo, + "local", + ) + ), + ), + patch.object(TALER_PKG, "remote_aptly") as aptly, + self.assertRaisesRegex(ValueError, "convert it to a snapshot publication"), ): - TALER_PKG.validate_publication( - pubcfg, TALER_PKG.parse_publication(stable), stable=True - ) + TALER_PKG.initialize(SimpleNamespace()) + aptly.assert_not_called() class PublishingInventoryTests(unittest.TestCase): @@ -420,6 +467,7 @@ class PublishingInventoryTests(unittest.TestCase): elif failure == "details": details = {"sources": {}} with ( + patch.object(TALER_PKG, "published_snapshot"), patch.object( TALER_PKG, "published_repositories", @@ -463,6 +511,17 @@ class PublishTests(unittest.TestCase): self.publications = self.enterContext( patch.object(TALER_PKG, "published_repositories", return_value=set()) ) + self.published_snapshot = self.enterContext( + patch.object( + TALER_PKG, + "published_snapshot", + return_value=("previous", {"amd64", "arm64"}), + ) + ) + self.snapshot = "taler-testing-20260906T120000000000Z" + self.enterContext( + patch.object(TALER_PKG, "testing_snapshot_name", return_value=self.snapshot) + ) def test_uploads_only_newer_packages_and_updates_testing(self): local_packages = [ @@ -506,7 +565,7 @@ class PublishTests(unittest.TestCase): ["mkdir", "-p", "/home/taler-packaging/debian-trixie"] ) self.assertEqual( - ["command", "upload", "aptly", "aptly", "cleanup"], + ["command", "upload", "aptly", "aptly", "aptly", "cleanup"], [item[0] for item in operations.mock_calls], ) cleanup.assert_called_once_with("/home/taler-packaging/debian-trixie") @@ -520,10 +579,19 @@ class PublishTests(unittest.TestCase): "/home/taler-packaging/debian-trixie/merchant-dbgsym_2.0_amd64.ddeb", ), call( + "snapshot", + "create", + self.snapshot, + "from", + "repo", + "taler-debian-trixie-testing", + ), + call( "publish", - "update", + "switch", "trixie-testing", "apt/debian", + self.snapshot, tty=True, ), ] @@ -594,12 +662,26 @@ class PublishTests(unittest.TestCase): aptly.assert_not_called() cleanup.assert_not_called() else: - aptly.assert_called_once_with( - "publish", - "update", - "trixie-testing", - "apt/debian", - tty=True, + self.assertEqual( + [ + call( + "snapshot", + "create", + self.snapshot, + "from", + "repo", + "taler-debian-trixie-testing", + ), + call( + "publish", + "switch", + "trixie-testing", + "apt/debian", + self.snapshot, + tty=True, + ), + ], + aptly.call_args_list, ) cleanup.assert_called_once_with( "/home/taler-packaging/debian-trixie" @@ -695,15 +777,36 @@ class PublishTests(unittest.TestCase): remote_command.assert_not_called() run.assert_not_called() - remote_aptly.assert_called_once_with( - "publish", "update", "noble-testing", "apt/ubuntu", tty=True + self.assertEqual( + [ + call( + "snapshot", + "create", + self.snapshot, + "from", + "repo", + "taler-ubuntu-noble-testing", + ), + call( + "publish", + "switch", + "noble-testing", + "apt/ubuntu", + self.snapshot, + tty=True, + ), + ], + remote_aptly.call_args_list, ) cleanup.assert_called_once_with("/home/taler-packaging/ubuntu-noble") - def test_failed_upload_import_or_publication_does_not_clean_staged_files(self): - for phase in ("upload", "import", "publish"): + def test_failed_upload_import_snapshot_or_switch_keeps_staged_files(self): + for phase in ("upload", "import", "snapshot", "switch"): with self.subTest(phase=phase): error = subprocess.CalledProcessError(1, [phase]) + results = [result()] * max( + 0, ("upload", "import", "snapshot", "switch").index(phase) - 1 + ) + [error] with ( patch.object(TALER_PKG, "repo_packages", return_value=[]), @@ -716,7 +819,7 @@ class PublishTests(unittest.TestCase): patch.object( TALER_PKG, "remote_aptly", - side_effect=[error] if phase == "import" else [result(), error], + side_effect=results, ), patch.object( TALER_PKG.subprocess, @@ -749,8 +852,26 @@ class PublishTests(unittest.TestCase): ): TALER_PKG.publish(SimpleNamespace(distro="debian-trixie", dry=False)) - aptly.assert_called_once_with( - "publish", "update", "trixie-testing", "apt/debian", tty=True + self.assertEqual( + [ + call( + "snapshot", + "create", + self.snapshot, + "from", + "repo", + "taler-debian-trixie-testing", + ), + call( + "publish", + "switch", + "trixie-testing", + "apt/debian", + self.snapshot, + tty=True, + ), + ], + aptly.call_args_list, ) @@ -800,18 +921,29 @@ class UploadCleanupTests(unittest.TestCase): class PromoteTests(unittest.TestCase): def test_dry_promote_shows_stable_to_testing_diff(self): + snapshots = { + "stable-snapshot": ["common_1_all", "removed_1_amd64"], + "testing-snapshot": ["common_1_all", "new_2_amd64"], + } with ( - patch.object(TALER_PKG, "stable_snapshot", return_value="stable-snapshot"), + patch.object( + TALER_PKG, + "published_snapshot", + side_effect=lambda pubcfg, stable: ( + "stable-snapshot" if stable else "testing-snapshot", + {"amd64", "arm64"}, + ), + ), patch.object( TALER_PKG, "snapshot_packages", - return_value=["common_1_all", "removed_1_amd64"], + side_effect=snapshots.__getitem__, ), patch.object( TALER_PKG, "repo_packages", - return_value=["common_1_all", "new_2_amd64"], - ), + return_value=["common_1_all", "new_3_amd64", "pending_1_all"], + ) as repo, patch.object(TALER_PKG, "remote_aptly") as remote_aptly, patch("sys.stdout", new_callable=StringIO) as stdout, ): @@ -819,49 +951,218 @@ class PromoteTests(unittest.TestCase): self.assertEqual("- removed_1_amd64\n+ new_2_amd64\n", stdout.getvalue()) remote_aptly.assert_not_called() + repo.assert_not_called() - def test_promote_snapshots_testing_and_switches_stable(self): + def test_promote_reuses_the_published_testing_snapshot(self): snapshot = "taler-ubuntu-noble-testing-20260903T123456123456Z" with ( - patch.object(TALER_PKG, "promotion_snapshot_name", return_value=snapshot), + patch.object( + TALER_PKG, + "published_snapshot", + return_value=(snapshot, {"amd64", "arm64"}), + ) as published, + patch.object( + TALER_PKG, "repo_packages", return_value=["pending_2_all"] + ) as repo, 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, - ), - ] + published.assert_called_once_with( + TALER_PKG.publishing_config("ubuntu-noble"), stable=False + ) + remote.assert_called_once_with( + "publish", "switch", "noble", "apt/ubuntu", snapshot, tty=True ) + repo.assert_not_called() + + +class ShowPublishedTests(unittest.TestCase): + def test_all_target_forms_list_only_the_selected_published_snapshot(self): + for pubcfg in TALER_PKG.publishing_configs.values(): + for suffix in ("", "-stable", "-testing"): + with self.subTest(distro=pubcfg.distro, suffix=suffix): + distribution = ( + pubcfg.testing_distribution + if suffix == "-testing" + else pubcfg.codename + ) + snapshot = f"published-{distribution}" + + def aptly( + *args, + distribution=distribution, + pubcfg=pubcfg, + snapshot=snapshot, + **kwargs, + ): + self.assertEqual({"capture_output": True}, kwargs) + if args == ("publish", "show", distribution, pubcfg.prefix): + return result( + publication( + pubcfg.prefix, distribution, snapshot, "snapshot" + ) + ) + if args == ("snapshot", "show", "-with-packages", snapshot): + return result( + "Packages:\n two_2_all\n one_1_arm64\n one_1_amd64\n" + " two_2_all\n excluded_1_i386\n source_1_source\n" + ) + self.fail(f"unexpected aptly call: {args}") + + with ( + patch.object( + TALER_PKG, "remote_aptly", side_effect=aptly + ) as remote, + patch.object( + TALER_PKG, "repo_packages", return_value=["pending_3_all"] + ) as repo, + patch.object( + sys, + "argv", + ["taler-pkg", "show-published", pubcfg.distro + suffix], + ), + patch("sys.stdout", new_callable=StringIO) as stdout, + ): + TALER_PKG.main() + + self.assertEqual( + "one_1_amd64\none_1_arm64\ntwo_2_all\n", stdout.getvalue() + ) + self.assertEqual(2, remote.call_count) + repo.assert_not_called() + + def test_unsupported_targets_fail_before_contacting_server(self): + for target in ( + "debian-bookworm", + "debian-trixie-stable-testing", + "debian-trixie-experimental", + ): + with ( + self.subTest(target=target), + patch.object(sys, "argv", ["taler-pkg", "show-published", target]), + patch.object(TALER_PKG, "remote_aptly") as remote, + patch("sys.stderr", new_callable=StringIO), + self.assertRaises(SystemExit) as error, + ): + TALER_PKG.main() + self.assertEqual(2, error.exception.code) + remote.assert_not_called() + + def test_empty_published_snapshot_prints_nothing(self): + with ( + patch.object( + TALER_PKG, "published_snapshot", return_value=("empty", {"amd64"}) + ), + patch.object(TALER_PKG, "snapshot_packages", return_value=[]), + patch("sys.stdout", new_callable=StringIO) as stdout, + ): + TALER_PKG.show_published(SimpleNamespace(distro="ubuntu-noble-testing")) + self.assertEqual("", stdout.getvalue()) - def test_show_published_lists_current_stable_snapshot(self): + def test_unpublished_architectures_are_excluded(self): with ( - patch.object(TALER_PKG, "stable_snapshot", return_value="stable-snapshot"), + patch.object( + TALER_PKG, "published_snapshot", return_value=("selected", {"arm64"}) + ), patch.object( TALER_PKG, "snapshot_packages", - return_value=["one_1_amd64", "two_2_all"], + return_value=["one_1_amd64", "one_1_arm64", "two_2_all"], ), patch("sys.stdout", new_callable=StringIO) as stdout, ): - TALER_PKG.show_published(SimpleNamespace(distro="debian-trixie")) + TALER_PKG.show_published(SimpleNamespace(distro="debian-trixie-testing")) + self.assertEqual("one_1_arm64\ntwo_2_all\n", stdout.getvalue()) + + def test_failed_lookups_do_not_print_a_partial_listing(self): + for phase in ("publication", "snapshot"): + with self.subTest(phase=phase): + error = subprocess.CalledProcessError(1, ["aptly"]) + details = result( + publication("apt/debian", "trixie-testing", "published", "snapshot") + ) + with ( + patch.object( + TALER_PKG, + "remote_aptly", + side_effect=[error] + if phase == "publication" + else [details, error], + ), + patch("sys.stdout", new_callable=StringIO) as stdout, + self.assertRaises(subprocess.CalledProcessError), + ): + TALER_PKG.show_published( + SimpleNamespace(distro="debian-trixie-testing") + ) + self.assertEqual("", stdout.getvalue()) + - self.assertEqual("one_1_amd64\ntwo_2_all\n", stdout.getvalue()) +class PublishedSnapshotTests(unittest.TestCase): + def test_legacy_testing_is_rejected_before_listing_uploading_or_promoting(self): + for operation in ( + TALER_PKG.show_published, + TALER_PKG.publish, + TALER_PKG.promote, + ): + with self.subTest(operation=operation.__name__): + target = ( + "debian-trixie-testing" + if operation == TALER_PKG.show_published + else "debian-trixie" + ) + with ( + patch.object( + TALER_PKG, + "remote_aptly", + return_value=result( + publication( + "apt/debian", + "trixie-testing", + "taler-debian-trixie-testing", + "local", + ) + ), + ) as remote, + patch.object(TALER_PKG, "remote_command") as command, + patch.object(TALER_PKG, "publishing_packages") as inventory, + patch.object(TALER_PKG, "cleanup_uploads") as cleanup, + self.assertRaisesRegex( + ValueError, "convert it to a snapshot publication" + ), + ): + operation(SimpleNamespace(distro=target, dry=False)) + remote.assert_called_once_with( + "publish", + "show", + "trixie-testing", + "apt/debian", + capture_output=True, + ) + command.assert_not_called() + inventory.assert_not_called() + cleanup.assert_not_called() + + def test_invalid_publication_details_are_rejected(self): + for field, value in ( + ("prefix", "apt/ubuntu"), + ("distribution", "trixie"), + ("architectures", set()), + ("sources", {}), + ): + with self.subTest(field=field): + details = TALER_PKG.parse_publication( + publication("apt/debian", "trixie-testing", "selected", "snapshot") + ) + details[field] = value + with ( + patch.object(TALER_PKG, "get_publication", return_value=details), + self.assertRaises(ValueError), + ): + TALER_PKG.published_snapshot( + TALER_PKG.publishing_config("debian-trixie"), stable=False + ) if __name__ == "__main__":