diff --git a/.github/workflows/build_debian.yml b/.github/workflows/build_debian.yml index 3ee3315..ab03c5d 100644 --- a/.github/workflows/build_debian.yml +++ b/.github/workflows/build_debian.yml @@ -175,7 +175,9 @@ jobs: - run: echo "Release approved — proceeding to promote to kolibri PPA." copy_package_from_proposed_to_ppa: name: Promote packages from kolibri-proposed to kolibri - needs: block_release_step + needs: + - check_version + - block_release_step runs-on: ubuntu-latest steps: - name: Checkout codebase @@ -193,7 +195,16 @@ jobs: env: LP_CREDENTIALS_FILE: /tmp/lp-creds.txt run: | - python3 scripts/launchpad_copy.py promote + python3 scripts/launchpad_copy.py promote \ + --version "${{ needs.check_version.outputs.version }}" + - name: Wait for promoted packages to be published + env: + LP_CREDENTIALS_FILE: /tmp/lp-creds.txt + run: | + python3 scripts/launchpad_copy.py wait-for-published \ + --package kolibri-server \ + --version "${{ needs.check_version.outputs.version }}" \ + --ppa kolibri - name: Cleanup Launchpad credentials if: always() run: rm -f /tmp/lp-creds.txt diff --git a/scripts/launchpad_copy.py b/scripts/launchpad_copy.py index 705ae09..2114b4d 100644 --- a/scripts/launchpad_copy.py +++ b/scripts/launchpad_copy.py @@ -391,7 +391,7 @@ def wait_for_published(self, package, version, ppa_name=None, series=None, timeo log.error("Timeout: %s %s not published within %ds", package, version, timeout) return 1 - def promote(self): + def promote(self, version): """Promote published packages from kolibri-proposed to kolibri PPA.""" log.info("Promoting packages from %s to %s", PROPOSED_PPA_NAME, RELEASE_PPA_NAME) @@ -400,48 +400,47 @@ def promote(self): packages = source_ppa.getPublishedSources(status="Published", order_by_date=True) - copied_any = False + # Group packages by series for syncSources calls + by_series = defaultdict(list) for pkg in packages: if pkg.source_package_name not in PACKAGE_WHITELIST: continue + if pkg.source_package_version != version: + continue + series_name = pkg.distro_series_link.rstrip("/").split("/")[-1] + by_series[series_name].append(pkg) + + if not by_series: + log.info("No eligible packages to promote.") + return 0 + + failures = [] + for series_name, pkgs in by_series.items(): + names = sorted(set(p.source_package_name for p in pkgs)) + log.info("Promoting %s from %s to %s", ", ".join(names), series_name, RELEASE_PPA_NAME) try: - log.info( - "Copying %s %s (%s) to %s", - pkg.source_package_name, - pkg.source_package_version, - pkg.distro_series_link, - RELEASE_PPA_NAME, - ) - dest_ppa.copyPackage( + dest_ppa.syncSources( from_archive=source_ppa, + to_series=series_name, + to_pocket=POCKET, include_binaries=True, - to_pocket=pkg.pocket, - source_name=pkg.source_package_name, - version=pkg.source_package_version, + source_names=names, ) - copied_any = True except lre.BadRequest as e: msg = str(e) - if "is obsolete and will not accept new uploads" in msg: - log.info( - "Skip obsolete series for %s %s", - pkg.source_package_name, - pkg.source_package_version, - ) - elif "same version already published" in msg: - log.info( - "Already published %s %s — skipping", - pkg.source_package_name, - pkg.source_package_version, - ) + if "same version already published" in msg: + log.info("Already published in %s — skipping", series_name) + elif "is obsolete and will not accept new uploads" in msg: + log.info("Skip obsolete series %s", series_name) else: - raise + log.error("Failed to promote to %s: %s", series_name, msg) + failures.append(series_name) - if not copied_any: - log.info("No eligible packages to promote.") - else: - log.info("Promotion requests submitted.") + if failures: + log.error("Promotion failed for series: %s", ", ".join(failures)) + return 1 + log.info("Promotion requests submitted.") return 0 @@ -467,10 +466,11 @@ def build_parser(): ) copy_parser.add_argument("--series", default=None, help="Source series override (default: auto-detect from OS).") - subparsers.add_parser( + promote_parser = subparsers.add_parser( "promote", help="Promote published packages from kolibri-proposed to kolibri PPA.", ) + promote_parser.add_argument("--version", required=True, help="Version to promote.") wait_parser = subparsers.add_parser( "wait-for-published", @@ -542,7 +542,7 @@ def cmd_check_source(args): def cmd_promote(args): """Promote published packages from kolibri-proposed to kolibri PPA.""" lp = LaunchpadWrapper() - return lp.promote() + return lp.promote(version=args.version) def main(): diff --git a/tests/test_launchpad_copy.py b/tests/test_launchpad_copy.py index 102c3e0..22d4889 100644 --- a/tests/test_launchpad_copy.py +++ b/tests/test_launchpad_copy.py @@ -41,7 +41,7 @@ def test_copy_to_series_subcommand_parsed(self): def test_promote_subcommand_parsed(self): parser = build_parser() - args = parser.parse_args(["promote"]) + args = parser.parse_args(["promote", "--version", "1.0"]) assert args.command == "promote" def test_subcommand_required(self): @@ -118,12 +118,12 @@ def test_wait_for_published_custom_ppa(self): def test_quiet_flag(self): parser = build_parser() - args = parser.parse_args(["-q", "promote"]) + args = parser.parse_args(["-q", "promote", "--version", "1.0"]) assert args.quiet is True def test_debug_flag(self): parser = build_parser() - args = parser.parse_args(["--debug", "promote"]) + args = parser.parse_args(["--debug", "promote", "--version", "1.0"]) assert args.debug is True @@ -299,21 +299,21 @@ class TestConfigureLogging: def test_default_sets_info_level(self): parser = build_parser() - args = parser.parse_args(["promote"]) + args = parser.parse_args(["promote", "--version", "1.0"]) log.handlers.clear() configure_logging(args) assert log.level == logging.INFO def test_quiet_sets_warning_level(self): parser = build_parser() - args = parser.parse_args(["-q", "promote"]) + args = parser.parse_args(["-q", "promote", "--version", "1.0"]) log.handlers.clear() configure_logging(args) assert log.level == logging.WARNING def test_vv_sets_debug_level(self): parser = build_parser() - args = parser.parse_args(["-vv", "promote"]) + args = parser.parse_args(["-vv", "promote", "--version", "1.0"]) log.handlers.clear() configure_logging(args) assert log.level == logging.DEBUG @@ -333,7 +333,7 @@ def test_copies_whitelisted_published_package(self): mock_pkg = MagicMock() mock_pkg.source_package_name = "kolibri-server" mock_pkg.source_package_version = "0.9.0" - mock_pkg.distro_series_link = "https://lp/ubuntu/jammy" + mock_pkg.distro_series_link = "https://api.launchpad.net/1.0/ubuntu/jammy" mock_pkg.pocket = "Release" mock_source_ppa.getPublishedSources.return_value = [mock_pkg] @@ -350,14 +350,14 @@ def test_copies_whitelisted_published_package(self): new_callable=lambda: property(lambda self: mock_dest_ppa), ), ): - result = wrapper.promote() + result = wrapper.promote(version="0.9.0") - mock_dest_ppa.copyPackage.assert_called_once_with( + mock_dest_ppa.syncSources.assert_called_once_with( from_archive=mock_source_ppa, - include_binaries=True, + to_series="jammy", to_pocket="Release", - source_name="kolibri-server", - version="0.9.0", + include_binaries=True, + source_names=["kolibri-server"], ) assert result == 0 @@ -383,9 +383,9 @@ def test_skips_non_whitelisted_package(self): new_callable=lambda: property(lambda self: mock_dest_ppa), ), ): - result = wrapper.promote() + result = wrapper.promote(version="0.9.0") - mock_dest_ppa.copyPackage.assert_not_called() + mock_dest_ppa.syncSources.assert_not_called() assert result == 0 def test_handles_already_published_package_gracefully(self): @@ -397,7 +397,7 @@ def test_handles_already_published_package_gracefully(self): mock_pkg = MagicMock() mock_pkg.source_package_name = "kolibri-server" mock_pkg.source_package_version = "0.9.0" - mock_pkg.distro_series_link = "https://lp/ubuntu/jammy" + mock_pkg.distro_series_link = "https://api.launchpad.net/1.0/ubuntu/jammy" mock_pkg.pocket = "Release" mock_source_ppa.getPublishedSources.return_value = [mock_pkg] @@ -405,7 +405,7 @@ def test_handles_already_published_package_gracefully(self): class MockBadRequest(Exception): pass - mock_dest_ppa.copyPackage.side_effect = MockBadRequest( + mock_dest_ppa.syncSources.side_effect = MockBadRequest( "kolibri-server 0.9.0 in jammy (same version already published in the target archive)" ) @@ -423,7 +423,7 @@ class MockBadRequest(Exception): patch("launchpad_copy.lre") as mock_lre, ): mock_lre.BadRequest = MockBadRequest - result = wrapper.promote() + result = wrapper.promote(version="0.9.0") assert result == 0 @@ -436,7 +436,7 @@ def test_already_published_logs_skip_message(self, caplog): mock_pkg = MagicMock() mock_pkg.source_package_name = "kolibri-server" mock_pkg.source_package_version = "0.9.0" - mock_pkg.distro_series_link = "https://lp/ubuntu/jammy" + mock_pkg.distro_series_link = "https://api.launchpad.net/1.0/ubuntu/jammy" mock_pkg.pocket = "Release" mock_source_ppa.getPublishedSources.return_value = [mock_pkg] @@ -444,7 +444,7 @@ def test_already_published_logs_skip_message(self, caplog): class MockBadRequest(Exception): pass - mock_dest_ppa.copyPackage.side_effect = MockBadRequest( + mock_dest_ppa.syncSources.side_effect = MockBadRequest( "kolibri-server 0.9.0 in jammy (same version already published in the target archive)" ) @@ -463,9 +463,9 @@ class MockBadRequest(Exception): caplog.at_level(logging.INFO, logger=log.name), ): mock_lre.BadRequest = MockBadRequest - wrapper.promote() + wrapper.promote(version="0.9.0") - assert any("already published" in r.message.lower() and "kolibri-server" in r.message for r in caplog.records) + assert any("already published" in r.message.lower() for r in caplog.records) # --- wait-for-published tests --- @@ -591,7 +591,7 @@ def test_handles_obsolete_series_in_promote(self): mock_pkg = MagicMock() mock_pkg.source_package_name = "kolibri-server" mock_pkg.source_package_version = "0.9.0" - mock_pkg.distro_series_link = "https://lp/ubuntu/xenial" + mock_pkg.distro_series_link = "https://api.launchpad.net/1.0/ubuntu/xenial" mock_pkg.pocket = "Release" mock_source_ppa.getPublishedSources.return_value = [mock_pkg] @@ -599,7 +599,7 @@ def test_handles_obsolete_series_in_promote(self): class MockBadRequest(Exception): pass - mock_dest_ppa.copyPackage.side_effect = MockBadRequest("xenial is obsolete and will not accept new uploads") + mock_dest_ppa.syncSources.side_effect = MockBadRequest("xenial is obsolete and will not accept new uploads") with ( patch.object( @@ -615,6 +615,6 @@ class MockBadRequest(Exception): patch("launchpad_copy.lre") as mock_lre, ): mock_lre.BadRequest = MockBadRequest - result = wrapper.promote() + result = wrapper.promote(version="0.9.0") assert result == 0