Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.
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
46 changes: 25 additions & 21 deletions .github/workflows/build_debian.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ jobs:
fi
echo "Version check passed: ${VERSION}"
build_package:
if: ${{ github.event_name != 'workflow_dispatch' }}
runs-on: ubuntu-latest
needs: check_version
steps:
Expand All @@ -48,19 +47,40 @@ jobs:
run: |
sudo apt-get update
sudo apt-get install -y build-essential devscripts debhelper dpkg-dev dput python3-launchpadlib
- name: Write Launchpad credentials
env:
LP_CREDENTIALS: ${{ secrets.LP_CREDENTIALS }}
run: |
echo "$LP_CREDENTIALS" > /tmp/lp-creds.txt
- name: Check if source already uploaded
id: check_source
env:
LP_CREDENTIALS_FILE: /tmp/lp-creds.txt
run: |
if python3 scripts/launchpad_copy.py check-source \
--package kolibri-server \
--version "${{ needs.check_version.outputs.version }}"; then
echo "already_uploaded=true" >> "$GITHUB_OUTPUT"
else
echo "already_uploaded=false" >> "$GITHUB_OUTPUT"
fi
- name: Import GPG key
if: steps.check_source.outputs.already_uploaded != 'true'
run: |
echo -n "${{ secrets.GPG_SIGNING_KEY }}" | base64 --decode | gpg --import --no-tty --batch --yes
- name: sign and upload package
if: steps.check_source.outputs.already_uploaded != 'true'
env:
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
GPG_KEY_ID: ${{ secrets.GPG_KEY_ID }}
run: |
echo "Signing package..."
make sign-and-upload
echo "upload completed successfully!"
- name: Cleanup Launchpad credentials
if: always()
run: rm -f /tmp/lp-creds.txt
wait_for_source_builds:
if: ${{ github.event_name != 'workflow_dispatch' }}
needs:
- check_version
- build_package
Expand Down Expand Up @@ -88,21 +108,15 @@ jobs:
if: always()
run: rm -f /tmp/lp-creds.txt
copy_to_other_distributions:
needs:
- check_version
- wait_for_source_builds
if: |
always() &&
needs.check_version.result == 'success' &&
(needs.wait_for_source_builds.result == 'success' || needs.wait_for_source_builds.result == 'skipped')
needs: wait_for_source_builds
runs-on: ubuntu-latest
steps:
- name: Checkout codebase
uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y python3-launchpadlib ubuntu-distro-info
sudo apt-get install -y python3-launchpadlib
- name: Write Launchpad credentials
env:
LP_CREDENTIALS: ${{ secrets.LP_CREDENTIALS }}
Expand Down Expand Up @@ -144,25 +158,15 @@ jobs:
if: always()
run: rm -f /tmp/lp-creds.txt
block_release_step:
if: ${{ !github.event.release.prerelease && github.event_name != 'workflow_dispatch' }}
name: Job to block publish of a release until it has been manually approved
needs: wait_for_copy_builds
runs-on: ubuntu-latest
environment: release
steps:
- run: echo "Release approved — proceeding to promote to kolibri PPA."
copy_package_from_proposed_to_ppa:
if: |
always() &&
needs.wait_for_copy_builds.result == 'success' &&
(
(!github.event.release.prerelease && needs.block_release_step.result == 'success') ||
(github.event_name == 'workflow_dispatch')
)
name: Promote packages from kolibri-proposed to kolibri
needs:
- wait_for_copy_builds
- block_release_step
needs: block_release_step
runs-on: ubuntu-latest
steps:
- name: Checkout codebase
Expand Down
51 changes: 45 additions & 6 deletions scripts/launchpad_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,12 @@ def get_current_series():
return subprocess.check_output(["lsb_release", "-cs"], text=True).strip()


def get_supported_series(source_series):
"""Discover supported Ubuntu series dynamically, including ESM/ELTS."""
out = subprocess.check_output(["ubuntu-distro-info", "--supported-esm"], text=True).strip()
all_series = out.split()
series = [s for s in all_series if s and s != source_series]
def get_supported_series(distribution, source_series):
"""Discover supported Ubuntu series from Launchpad, excluding source_series."""
supported_statuses = ("Supported", "Current Stable Release")
series = [
s.name for s in distribution.series if s.active and s.status in supported_statuses and s.name != source_series
]
log.info("Dynamic series discovery:")
log.info(" Target series (will copy to): %s", ", ".join(series))
return series
Expand Down Expand Up @@ -275,7 +276,7 @@ def copy_to_series(self):
for name, version in self.get_usable_sources(ppa, tuple(PACKAGE_WHITELIST), source_series):
mentioned = False
notices = []
target_series_names = get_supported_series(source_series)
target_series_names = get_supported_series(ppa.distribution, source_series)
for target_series_name in target_series_names:
source = self.get_source_for(ppa, name, version, target_series_name)
if source is None:
Expand Down Expand Up @@ -313,6 +314,24 @@ def copy_to_series(self):
log.debug("All done")
return 0

def check_source(self, package, version, ppa_name=None):
"""Check if a source package version exists in a PPA.
Returns 0 if found (already uploaded), 1 if missing.
"""
ppa_name = ppa_name or PROPOSED_PPA_NAME
ppa = self.get_ppa(ppa_name)
published = ppa.getPublishedSources(
source_name=package,
version=version,
order_by_date=True,
)
active = [s for s in published if s.status not in ("Deleted", "Superseded", "Obsolete")]
if active:
log.info("%s %s already exists in %s (status: %s)", package, version, ppa_name, active[0].status)
return 0
log.info("%s %s not found in %s", package, version, ppa_name)
return 1

def wait_for_builds(self, package, version, ppa_name=None, timeout=1800, interval=60):
"""Wait for all builds of a source package to reach a terminal state.

Expand Down Expand Up @@ -478,6 +497,14 @@ def build_parser():
"--interval", type=int, default=60, help="Polling interval in seconds (default: %(default)s)."
)

check_parser = subparsers.add_parser(
"check-source",
help="Check if a source package version already exists in a PPA.",
)
check_parser.add_argument("--package", required=True, help="Source package name.")
check_parser.add_argument("--version", required=True, help="Expected version string.")
check_parser.add_argument("--ppa", default=PROPOSED_PPA_NAME, help="PPA name to check (default: %(default)s).")

return parser


Expand Down Expand Up @@ -513,6 +540,16 @@ def cmd_wait_for_builds(args):
)


def cmd_check_source(args):
"""Check if a source package version already exists in a PPA."""
lp = LaunchpadWrapper()
return lp.check_source(
package=args.package,
version=args.version,
ppa_name=args.ppa,
)


def cmd_promote(args):
"""Promote published packages from kolibri-proposed to kolibri PPA."""
lp = LaunchpadWrapper()
Expand All @@ -526,6 +563,8 @@ def main():

if args.command == "copy-to-series":
return cmd_copy_to_series(args)
elif args.command == "check-source":
return cmd_check_source(args)
elif args.command == "promote":
return cmd_promote(args)
elif args.command == "wait-for-builds":
Expand Down
159 changes: 0 additions & 159 deletions tests/test_launchpad_copy.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@
from launchpad_copy import build_parser
from launchpad_copy import configure_logging
from launchpad_copy import get_current_series
from launchpad_copy import get_supported_series
from launchpad_copy import log
from launchpad_copy import main

# --- Argparse tests ---

Expand Down Expand Up @@ -128,7 +126,6 @@ def test_debug_flag(self):
# --- Series discovery tests ---

has_lsb_release = shutil.which("lsb_release") is not None
has_ubuntu_distro_info = shutil.which("ubuntu-distro-info") is not None


@pytest.mark.skipif(not has_lsb_release, reason="lsb_release not available on this system")
Expand All @@ -146,34 +143,6 @@ def test_raises_on_missing_command(self):
get_current_series()


@pytest.mark.skipif(
not has_ubuntu_distro_info,
reason="ubuntu-distro-info not available on this system",
)
class TestGetSupportedSeries:
"""Test dynamic series discovery using real ubuntu-distro-info."""

def test_returns_list_of_series(self):
result = get_supported_series("jammy")
assert isinstance(result, list)
assert len(result) > 0

def test_excludes_source_series(self):
result = get_supported_series("jammy")
assert "jammy" not in result

def test_all_entries_are_non_empty_strings(self):
result = get_supported_series("jammy")
for s in result:
assert isinstance(s, str)
assert len(s) > 0

def test_raises_on_missing_command(self):
with patch("subprocess.check_output", side_effect=FileNotFoundError("no cmd")):
with pytest.raises(FileNotFoundError):
get_supported_series("jammy")


# --- LaunchpadWrapper tests ---


Expand Down Expand Up @@ -319,134 +288,6 @@ def test_vv_sets_debug_level(self):
assert log.level == logging.DEBUG


# --- main / subcommand dispatch tests ---


class TestMainDispatch:
"""Test that main dispatches to the correct subcommand."""

def test_dispatches_to_copy_to_series(self):

with (
patch("launchpad_copy.cmd_copy_to_series", return_value=0) as mock_cmd,
patch("sys.argv", ["launchpad_copy.py", "copy-to-series"]),
):
result = main()

mock_cmd.assert_called_once()
assert result == 0

def test_dispatches_to_wait_for_builds(self):
with (
patch("launchpad_copy.cmd_wait_for_builds", return_value=0) as mock_cmd,
patch(
"sys.argv",
[
"launchpad_copy.py",
"wait-for-builds",
"--package",
"kolibri-server",
"--version",
"1.0",
],
),
):
result = main()

mock_cmd.assert_called_once()
assert result == 0

def test_dispatches_to_promote(self):

with (
patch("launchpad_copy.cmd_promote", return_value=0) as mock_cmd,
patch("sys.argv", ["launchpad_copy.py", "promote"]),
):
result = main()

mock_cmd.assert_called_once()
assert result == 0


# --- copy-to-series subcommand tests ---


class TestCopyToSeries:
"""Test the copy-to-series logic on LaunchpadWrapper."""

def test_queues_copy_for_missing_package(self):
wrapper = LaunchpadWrapper()
mock_ppa = MagicMock()

with (
patch.object(
type(wrapper),
"proposed_ppa",
new_callable=lambda: property(lambda self: mock_ppa),
),
patch.object(
wrapper,
"get_usable_sources",
return_value=[("kolibri-server", "0.9.0")],
),
patch.object(wrapper, "get_source_for", return_value=None),
patch.object(wrapper, "has_published_binaries", return_value=True),
patch("launchpad_copy.get_current_series", return_value="jammy"),
patch("launchpad_copy.get_supported_series", return_value=["noble"]),
):
wrapper.copy_to_series()

assert ("jammy", "noble", "Release") in wrapper.queue
assert "kolibri-server" in wrapper.queue[("jammy", "noble", "Release")]

def test_skips_copy_when_not_built_yet(self):
wrapper = LaunchpadWrapper()
mock_ppa = MagicMock()

mock_build = MagicMock()
mock_build.buildstate = "Currently building"
mock_build.web_link = "https://example.com"

with (
patch.object(
type(wrapper),
"proposed_ppa",
new_callable=lambda: property(lambda self: mock_ppa),
),
patch.object(
wrapper,
"get_usable_sources",
return_value=[("kolibri-server", "0.9.0")],
),
patch.object(wrapper, "get_source_for", return_value=None),
patch.object(wrapper, "has_published_binaries", return_value=False),
patch.object(wrapper, "get_builds_for", return_value=[mock_build]),
patch("launchpad_copy.get_current_series", return_value="jammy"),
patch("launchpad_copy.get_supported_series", return_value=["noble"]),
):
wrapper.copy_to_series()

assert len(wrapper.queue) == 0

def test_returns_zero(self):
wrapper = LaunchpadWrapper()
mock_ppa = MagicMock()

with (
patch.object(
type(wrapper),
"proposed_ppa",
new_callable=lambda: property(lambda self: mock_ppa),
),
patch.object(wrapper, "get_usable_sources", return_value=[]),
patch("launchpad_copy.get_current_series", return_value="jammy"),
patch("launchpad_copy.get_supported_series", return_value=[]),
):
result = wrapper.copy_to_series()

assert result == 0


# --- promote subcommand tests ---


Expand Down
Loading