Skip to content
This repository was archived by the owner on Jul 16, 2026. It is now read-only.

Commit 432aef4

Browse files
authored
Merge pull request #112 from rtibblesbot/issue-108-b5f1c0
Add --dry-run flag and workflow_dispatch trigger for Launchpad script
2 parents 4249221 + 21021af commit 432aef4

4 files changed

Lines changed: 247 additions & 24 deletions

File tree

.github/workflows/build_debian.yml

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ name: Build Debian source package
22
on:
33
release:
44
types: [published]
5+
workflow_dispatch:
56
jobs:
67
check_version:
78
runs-on: ubuntu-latest
@@ -10,17 +11,22 @@ jobs:
1011
steps:
1112
- name: Checkout codebase
1213
uses: actions/checkout@v4
13-
- name: Extract version from release tag
14-
id: version
15-
run: |
16-
VERSION=${GITHUB_REF#refs/tags/v}
17-
echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT"
1814
- name: Extract version from changelog
1915
id: changelog_version
2016
run: |
2117
CHANGELOG_VERSION=$(dpkg-parsechangelog -S Version)
2218
echo "CHANGELOG_VERSION=${CHANGELOG_VERSION}" >> "$GITHUB_OUTPUT"
19+
- name: Extract version from release tag
20+
id: version
21+
run: |
22+
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
23+
echo "VERSION=${{ steps.changelog_version.outputs.CHANGELOG_VERSION }}" >> "$GITHUB_OUTPUT"
24+
else
25+
TAG_VERSION=${GITHUB_REF#refs/tags/v}
26+
echo "VERSION=${TAG_VERSION}" >> "$GITHUB_OUTPUT"
27+
fi
2328
- name: Validate Version consistency
29+
if: ${{ github.event_name != 'workflow_dispatch' }}
2430
id: version_check
2531
env:
2632
VERSION: ${{ steps.version.outputs.VERSION }}
@@ -32,6 +38,7 @@ jobs:
3238
fi
3339
echo "Version check passed: ${VERSION}"
3440
build_package:
41+
if: ${{ github.event_name != 'workflow_dispatch' }}
3542
runs-on: ubuntu-latest
3643
needs: check_version
3744
steps:
@@ -53,6 +60,7 @@ jobs:
5360
make sign-and-upload
5461
echo "upload completed successfully!"
5562
wait_for_source_builds:
63+
if: ${{ github.event_name != 'workflow_dispatch' }}
5664
needs:
5765
- check_version
5866
- build_package
@@ -80,7 +88,13 @@ jobs:
8088
if: always()
8189
run: rm -f /tmp/lp-creds.txt
8290
copy_to_other_distributions:
83-
needs: wait_for_source_builds
91+
needs:
92+
- check_version
93+
- wait_for_source_builds
94+
if: |
95+
always() &&
96+
needs.check_version.result == 'success' &&
97+
(needs.wait_for_source_builds.result == 'success' || needs.wait_for_source_builds.result == 'skipped')
8498
runs-on: ubuntu-latest
8599
steps:
86100
- name: Checkout codebase
@@ -130,17 +144,25 @@ jobs:
130144
if: always()
131145
run: rm -f /tmp/lp-creds.txt
132146
block_release_step:
133-
if: ${{ !github.event.release.prerelease }}
147+
if: ${{ !github.event.release.prerelease && github.event_name != 'workflow_dispatch' }}
134148
name: Job to block publish of a release until it has been manually approved
135149
needs: wait_for_copy_builds
136150
runs-on: ubuntu-latest
137151
environment: release
138152
steps:
139153
- run: echo "Release approved — proceeding to promote to kolibri PPA."
140154
copy_package_from_proposed_to_ppa:
141-
if: ${{ !github.event.release.prerelease }}
155+
if: |
156+
always() &&
157+
needs.wait_for_copy_builds.result == 'success' &&
158+
(
159+
(!github.event.release.prerelease && needs.block_release_step.result == 'success') ||
160+
(github.event_name == 'workflow_dispatch')
161+
)
142162
name: Promote packages from kolibri-proposed to kolibri
143-
needs: block_release_step
163+
needs:
164+
- wait_for_copy_builds
165+
- block_release_step
144166
runs-on: ubuntu-latest
145167
steps:
146168
- name: Checkout codebase

README.rst

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ To fetch and build a new version of this package, the following workflow is sugg
2121

2222
You can optimize this workflow according to your own needs.
2323

24-
Changes can be built and released in ``kolibri-proposed`` by the `Learning Equality Launchpad team <https://launchpad.net/~learningequality/>`__.
24+
Changes can be built and released in ``kolibri-proposed`` by the `Learning Equality Launchpad team <https://launchpad.net/~learningequality>`__.
2525

2626
Working in the repo
2727
-------------------
@@ -53,13 +53,82 @@ After this, pre-commit hooks will run automatically on ``git commit``. To run al
5353
Releasing
5454
---------
5555

56-
Push new changes to ``kolibri-proposed`` and test them there.
56+
Automated release workflow
57+
~~~~~~~~~~~~~~~~~~~~~~~~~~
5758

58-
To build packages for all current Ubuntu release series:
59+
Publishing a GitHub release triggers the ``build_debian.yml`` workflow, which:
5960

60-
#. Install Launchpadlib: ``sudo apt install python-launchpadlib``
61-
#. Run ``ppa-copy-packages.py`` script to copy the builds for Xenial to all other currently active and supported Ubuntu releases on Launchpad. The script is run from command line with ``python2 ppa-copy-packages.py``. After this, you should be prompted to create an API key for your Launchpad account.
62-
#. When a release in ``kolibri-proposed`` should be released as a stable release, use the binary copy function on Launchpad to copy builds from ``kolibri-proposed``.
61+
#. Validates the release tag version against ``debian/changelog``
62+
#. Builds, signs, and uploads the source package to the ``kolibri-proposed`` PPA via ``dput``
63+
#. Waits for Launchpad to build the source package
64+
#. Copies the built package to all supported Ubuntu series
65+
#. Waits for all copy builds to complete
66+
#. (Non-prerelease only) Requires manual approval via the ``release`` environment
67+
#. Promotes packages from ``kolibri-proposed`` to ``kolibri`` PPA
68+
69+
Launchpad credentials setup
70+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
71+
72+
The workflow requires Launchpad API credentials stored as a GitHub Actions secret.
73+
74+
To generate credentials:
75+
76+
#. Install launchpadlib: ``pip install launchpadlib``
77+
#. Run the credentials helper script::
78+
79+
python3 scripts/create_lp_creds.py
80+
81+
#. Approve the authorization request in your browser. This writes a credentials file (default: ``launchpad.credentials``).
82+
#. Copy the full content of the credentials file.
83+
#. In GitHub, go to the repository **Settings > Secrets and variables > Actions > New repository secret**.
84+
#. Create a secret named ``LP_CREDENTIALS`` and paste the credentials file content.
85+
86+
The workflow writes this secret to a temporary file at runtime and cleans it up after each job.
87+
88+
Manual workflow dispatch
89+
~~~~~~~~~~~~~~~~~~~~~~~~
90+
91+
The workflow supports a ``workflow_dispatch`` trigger for manual reruns. This is useful when a release workflow fails partway through — you can fix the issue and rerun the workflow without it breaking because earlier steps already succeeded.
92+
93+
To trigger from the GitHub UI:
94+
95+
#. Go to **Actions > Build Debian source package > Run workflow**
96+
#. Click **Run workflow**
97+
98+
To trigger from the command line::
99+
100+
gh workflow run build_debian.yml
101+
102+
When triggered via ``workflow_dispatch``:
103+
104+
- The ``build_package`` and ``wait_for_source_builds`` jobs are skipped (no release artifact to upload)
105+
- The version is read from ``debian/changelog`` instead of the release tag
106+
- The ``block_release_step`` manual approval gate is skipped
107+
- All copy and promote steps run normally — they are idempotent and safely handle packages that were already copied in a previous run
108+
109+
Launchpad copy script
110+
~~~~~~~~~~~~~~~~~~~~~
111+
112+
The ``scripts/launchpad_copy.py`` script manages Launchpad PPA operations with three subcommands:
113+
114+
``copy-to-series``
115+
Copies packages from the source Ubuntu series to all other supported series within the ``kolibri-proposed`` PPA::
116+
117+
python3 scripts/launchpad_copy.py copy-to-series
118+
119+
``promote``
120+
Promotes all published packages from ``kolibri-proposed`` to the ``kolibri`` PPA::
121+
122+
python3 scripts/launchpad_copy.py promote
123+
124+
``wait-for-builds``
125+
Polls Launchpad until all builds for a source package reach a terminal state::
126+
127+
python3 scripts/launchpad_copy.py wait-for-builds --package kolibri-server --version 1.0.0
128+
129+
All subcommands are idempotent — rerunning them after a partial success safely skips packages that were already copied or promoted.
130+
131+
Additional flags: ``-v`` / ``-vv`` for verbosity, ``-q`` for quiet mode, ``--debug`` for HTTP-level debugging.
63132

64133
Overview
65134
--------

scripts/launchpad_copy.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -247,13 +247,19 @@ def perform_queued_copies(self, ppa):
247247
log.info("")
248248
first = False
249249
log.info("Copying %s to %s", ", ".join(sorted(names)), target_series)
250-
ppa.syncSources(
251-
from_archive=ppa,
252-
to_series=target_series,
253-
to_pocket=pocket,
254-
include_binaries=True,
255-
source_names=sorted(names),
256-
)
250+
try:
251+
ppa.syncSources(
252+
from_archive=ppa,
253+
to_series=target_series,
254+
to_pocket=pocket,
255+
include_binaries=True,
256+
source_names=sorted(names),
257+
)
258+
except lre.BadRequest as e:
259+
if "same version already published" in str(e):
260+
log.info("Already copied to %s — skipping", target_series)
261+
else:
262+
raise
257263

258264
def copy_to_series(self):
259265
"""Copy packages from source series to all other supported Ubuntu series."""
@@ -410,12 +416,19 @@ def promote(self):
410416
)
411417
copied_any = True
412418
except lre.BadRequest as e:
413-
if "is obsolete and will not accept new uploads" in str(e):
419+
msg = str(e)
420+
if "is obsolete and will not accept new uploads" in msg:
414421
log.info(
415422
"Skip obsolete series for %s %s",
416423
pkg.source_package_name,
417424
pkg.source_package_version,
418425
)
426+
elif "same version already published" in msg:
427+
log.info(
428+
"Already published %s %s — skipping",
429+
pkg.source_package_name,
430+
pkg.source_package_version,
431+
)
419432
else:
420433
raise
421434

@@ -441,7 +454,6 @@ def build_parser():
441454
)
442455
parser.add_argument("-q", "--quiet", action="store_true", help="Suppress info output.")
443456
parser.add_argument("--debug", action="store_true", help="Enable HTTP debug output.")
444-
445457
subparsers = parser.add_subparsers(dest="command", required=True)
446458

447459
subparsers.add_parser(

tests/test_launchpad_copy.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,47 @@ def test_perform_queued_copies_skips_empty_queues(self):
215215

216216
mock_ppa.syncSources.assert_not_called()
217217

218+
def test_perform_queued_copies_handles_already_synced(self):
219+
"""Idempotency: syncSources errors for already-copied packages are handled gracefully."""
220+
wrapper = LaunchpadWrapper()
221+
wrapper.queue_copy("kolibri-server", "jammy", "noble", "Release")
222+
223+
class MockBadRequest(Exception):
224+
pass
225+
226+
mock_ppa = MagicMock()
227+
mock_ppa.syncSources.side_effect = MockBadRequest(
228+
"kolibri-server 0.9.0 in noble (same version already published)"
229+
)
230+
231+
with patch("launchpad_copy.lre") as mock_lre:
232+
mock_lre.BadRequest = MockBadRequest
233+
wrapper.perform_queued_copies(mock_ppa)
234+
235+
# Should not raise — the error is handled gracefully
236+
237+
def test_perform_queued_copies_logs_already_synced(self, caplog):
238+
"""Idempotency: logs a message when syncSources finds package already exists."""
239+
wrapper = LaunchpadWrapper()
240+
wrapper.queue_copy("kolibri-server", "jammy", "noble", "Release")
241+
242+
class MockBadRequest(Exception):
243+
pass
244+
245+
mock_ppa = MagicMock()
246+
mock_ppa.syncSources.side_effect = MockBadRequest(
247+
"kolibri-server 0.9.0 in noble (same version already published)"
248+
)
249+
250+
with (
251+
patch("launchpad_copy.lre") as mock_lre,
252+
caplog.at_level(logging.INFO, logger=log.name),
253+
):
254+
mock_lre.BadRequest = MockBadRequest
255+
wrapper.perform_queued_copies(mock_ppa)
256+
257+
assert any("already" in r.message.lower() for r in caplog.records)
258+
218259
def test_get_usable_sources_filters_by_whitelist(self):
219260
wrapper = LaunchpadWrapper()
220261
mock_ppa = MagicMock()
@@ -475,6 +516,85 @@ def test_skips_non_whitelisted_package(self):
475516
mock_dest_ppa.copyPackage.assert_not_called()
476517
assert result == 0
477518

519+
def test_handles_already_published_package_gracefully(self):
520+
"""Idempotency: promote skips packages already copied to dest PPA."""
521+
wrapper = LaunchpadWrapper()
522+
mock_source_ppa = MagicMock()
523+
mock_dest_ppa = MagicMock()
524+
525+
mock_pkg = MagicMock()
526+
mock_pkg.source_package_name = "kolibri-server"
527+
mock_pkg.source_package_version = "0.9.0"
528+
mock_pkg.distro_series_link = "https://lp/ubuntu/jammy"
529+
mock_pkg.pocket = "Release"
530+
531+
mock_source_ppa.getPublishedSources.return_value = [mock_pkg]
532+
533+
class MockBadRequest(Exception):
534+
pass
535+
536+
mock_dest_ppa.copyPackage.side_effect = MockBadRequest(
537+
"kolibri-server 0.9.0 in jammy (same version already published in the target archive)"
538+
)
539+
540+
with (
541+
patch.object(
542+
type(wrapper),
543+
"proposed_ppa",
544+
new_callable=lambda: property(lambda self: mock_source_ppa),
545+
),
546+
patch.object(
547+
type(wrapper),
548+
"release_ppa",
549+
new_callable=lambda: property(lambda self: mock_dest_ppa),
550+
),
551+
patch("launchpad_copy.lre") as mock_lre,
552+
):
553+
mock_lre.BadRequest = MockBadRequest
554+
result = wrapper.promote()
555+
556+
assert result == 0
557+
558+
def test_already_published_logs_skip_message(self, caplog):
559+
"""Idempotency: promote logs that a package was already promoted."""
560+
wrapper = LaunchpadWrapper()
561+
mock_source_ppa = MagicMock()
562+
mock_dest_ppa = MagicMock()
563+
564+
mock_pkg = MagicMock()
565+
mock_pkg.source_package_name = "kolibri-server"
566+
mock_pkg.source_package_version = "0.9.0"
567+
mock_pkg.distro_series_link = "https://lp/ubuntu/jammy"
568+
mock_pkg.pocket = "Release"
569+
570+
mock_source_ppa.getPublishedSources.return_value = [mock_pkg]
571+
572+
class MockBadRequest(Exception):
573+
pass
574+
575+
mock_dest_ppa.copyPackage.side_effect = MockBadRequest(
576+
"kolibri-server 0.9.0 in jammy (same version already published in the target archive)"
577+
)
578+
579+
with (
580+
patch.object(
581+
type(wrapper),
582+
"proposed_ppa",
583+
new_callable=lambda: property(lambda self: mock_source_ppa),
584+
),
585+
patch.object(
586+
type(wrapper),
587+
"release_ppa",
588+
new_callable=lambda: property(lambda self: mock_dest_ppa),
589+
),
590+
patch("launchpad_copy.lre") as mock_lre,
591+
caplog.at_level(logging.INFO, logger=log.name),
592+
):
593+
mock_lre.BadRequest = MockBadRequest
594+
wrapper.promote()
595+
596+
assert any("already published" in r.message.lower() and "kolibri-server" in r.message for r in caplog.records)
597+
478598

479599
# --- wait-for-builds tests ---
480600

0 commit comments

Comments
 (0)