Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions .github/workflows/build_debian.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
66 changes: 33 additions & 33 deletions scripts/launchpad_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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


Expand All @@ -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",
Expand Down Expand Up @@ -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():
Expand Down
48 changes: 24 additions & 24 deletions tests/test_launchpad_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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]
Expand All @@ -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

Expand All @@ -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):
Expand All @@ -397,15 +397,15 @@ 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]

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

Expand All @@ -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

Expand All @@ -436,15 +436,15 @@ 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]

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

Expand All @@ -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 ---
Expand Down Expand Up @@ -591,15 +591,15 @@ 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]

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(
Expand All @@ -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
Loading