From 73b8b80687744a6eda38b46ee22aa99960f65eb5 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Sun, 22 Feb 2026 14:50:14 -0800 Subject: [PATCH 1/4] feat: add --dry-run flag to launchpad_copy.py Add a global --dry-run flag that applies to all subcommands (copy-to-series, promote, wait-for-builds). When set, all read operations execute normally while write operations (copyPackage, syncSources) are replaced with log messages describing the action that would be taken, including package name, version, and source/destination. Co-Authored-By: Claude Opus 4.6 --- scripts/launchpad_copy.py | 61 +++++++---- tests/test_launchpad_copy.py | 203 +++++++++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 22 deletions(-) diff --git a/scripts/launchpad_copy.py b/scripts/launchpad_copy.py index a191dfa..e8058e4 100644 --- a/scripts/launchpad_copy.py +++ b/scripts/launchpad_copy.py @@ -123,6 +123,7 @@ class LaunchpadWrapper: def __init__(self): self.queue = defaultdict(set) + self.dry_run = False @functools.cached_property def lp(self): @@ -246,14 +247,17 @@ def perform_queued_copies(self, ppa): if first: log.info("") first = False - log.info("Copying %s to %s", ", ".join(sorted(names)), target_series) - ppa.syncSources( - from_archive=ppa, - to_series=target_series, - to_pocket=pocket, - include_binaries=True, - source_names=sorted(names), - ) + if self.dry_run: + log.info("DRY-RUN: would copy %s from %s to %s", ", ".join(sorted(names)), source_series, target_series) + else: + log.info("Copying %s to %s", ", ".join(sorted(names)), target_series) + ppa.syncSources( + from_archive=ppa, + to_series=target_series, + to_pocket=pocket, + include_binaries=True, + source_names=sorted(names), + ) def copy_to_series(self): """Copy packages from source series to all other supported Ubuntu series.""" @@ -394,20 +398,29 @@ def promote(self): if pkg.source_package_name not in PACKAGE_WHITELIST: continue 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( - from_archive=source_ppa, - include_binaries=True, - to_pocket=pkg.pocket, - source_name=pkg.source_package_name, - version=pkg.source_package_version, - ) + if self.dry_run: + log.info( + "DRY-RUN: would copy %s %s (%s) to %s", + pkg.source_package_name, + pkg.source_package_version, + pkg.distro_series_link, + RELEASE_PPA_NAME, + ) + else: + 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( + from_archive=source_ppa, + include_binaries=True, + to_pocket=pkg.pocket, + source_name=pkg.source_package_name, + version=pkg.source_package_version, + ) copied_any = True except lre.BadRequest as e: if "is obsolete and will not accept new uploads" in str(e): @@ -441,6 +454,7 @@ def build_parser(): ) parser.add_argument("-q", "--quiet", action="store_true", help="Suppress info output.") parser.add_argument("--debug", action="store_true", help="Enable HTTP debug output.") + parser.add_argument("--dry-run", action="store_true", default=False, help="Log actions without making changes.") subparsers = parser.add_subparsers(dest="command", required=True) @@ -486,12 +500,14 @@ def configure_logging(args): def cmd_copy_to_series(args): """Copy packages from source series to all other supported Ubuntu series.""" lp = LaunchpadWrapper() + lp.dry_run = args.dry_run return lp.copy_to_series() def cmd_wait_for_builds(args): """Wait for Launchpad builds to complete.""" lp = LaunchpadWrapper() + lp.dry_run = args.dry_run return lp.wait_for_builds( package=args.package, version=args.version, @@ -504,6 +520,7 @@ def cmd_wait_for_builds(args): def cmd_promote(args): """Promote published packages from kolibri-proposed to kolibri PPA.""" lp = LaunchpadWrapper() + lp.dry_run = args.dry_run return lp.promote() diff --git a/tests/test_launchpad_copy.py b/tests/test_launchpad_copy.py index 742ef2e..2dd48a5 100644 --- a/tests/test_launchpad_copy.py +++ b/tests/test_launchpad_copy.py @@ -14,6 +14,9 @@ from launchpad_copy import LaunchpadWrapper from launchpad_copy import build_parser +from launchpad_copy import cmd_copy_to_series +from launchpad_copy import cmd_promote +from launchpad_copy import cmd_wait_for_builds from launchpad_copy import configure_logging from launchpad_copy import get_current_series from launchpad_copy import get_supported_series @@ -124,6 +127,26 @@ def test_debug_flag(self): args = parser.parse_args(["--debug", "promote"]) assert args.debug is True + def test_dry_run_flag_defaults_to_false(self): + parser = build_parser() + args = parser.parse_args(["copy-to-series"]) + assert args.dry_run is False + + def test_dry_run_flag_accepted(self): + parser = build_parser() + args = parser.parse_args(["--dry-run", "copy-to-series"]) + assert args.dry_run is True + + def test_dry_run_flag_with_promote(self): + parser = build_parser() + args = parser.parse_args(["--dry-run", "promote"]) + assert args.dry_run is True + + def test_dry_run_flag_with_wait_for_builds(self): + parser = build_parser() + args = parser.parse_args(["--dry-run", "wait-for-builds", "--package", "kolibri-server", "--version", "1.0"]) + assert args.dry_run is True + # --- Series discovery tests --- @@ -250,6 +273,133 @@ def test_get_usable_sources_skips_superseded(self): assert len(result) == 0 +# --- dry-run tests --- + + +class TestDryRunPromote: + """Test that --dry-run prevents mutating calls in promote.""" + + def test_dry_run_skips_copy_package(self): + wrapper = LaunchpadWrapper() + wrapper.dry_run = True + mock_source_ppa = MagicMock() + mock_dest_ppa = MagicMock() + + 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.pocket = "Release" + + mock_source_ppa.getPublishedSources.return_value = [mock_pkg] + + with ( + patch.object( + type(wrapper), + "proposed_ppa", + new_callable=lambda: property(lambda self: mock_source_ppa), + ), + patch.object( + type(wrapper), + "release_ppa", + new_callable=lambda: property(lambda self: mock_dest_ppa), + ), + ): + result = wrapper.promote() + + mock_dest_ppa.copyPackage.assert_not_called() + assert result == 0 + + def test_dry_run_logs_what_would_be_promoted(self, caplog): + wrapper = LaunchpadWrapper() + wrapper.dry_run = True + mock_source_ppa = MagicMock() + mock_dest_ppa = MagicMock() + + 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.pocket = "Release" + + mock_source_ppa.getPublishedSources.return_value = [mock_pkg] + + with ( + patch.object( + type(wrapper), + "proposed_ppa", + new_callable=lambda: property(lambda self: mock_source_ppa), + ), + patch.object( + type(wrapper), + "release_ppa", + new_callable=lambda: property(lambda self: mock_dest_ppa), + ), + caplog.at_level(logging.INFO, logger=log.name), + ): + wrapper.promote() + + assert any( + "DRY-RUN" in r.message and "kolibri-server" in r.message and "0.9.0" in r.message for r in caplog.records + ) + + +class TestDryRunWaitForBuilds: + """Test that wait-for-builds works normally in dry-run mode (it's already read-only).""" + + def test_dry_run_wait_for_builds_still_works(self): + wrapper = LaunchpadWrapper() + wrapper.dry_run = True + mock_ppa = MagicMock() + source = MagicMock() + source.source_package_name = "kolibri-server" + source.source_package_version = "1.0" + source.status = "Published" + build = MagicMock() + build.buildstate = "Successfully built" + build.arch_tag = "amd64" + build.web_link = "https://launchpad.net/build/amd64" + source.getBuilds.return_value = [build] + mock_ppa.getPublishedSources.return_value = [source] + + with ( + patch.object(wrapper, "get_ppa", return_value=mock_ppa), + patch("launchpad_copy.time") as mock_time, + ): + mock_time.time.side_effect = [0, 0, 0] + mock_time.sleep = MagicMock() + result = wrapper.wait_for_builds("kolibri-server", "1.0") + + assert result == 0 + + +class TestDryRunCopyToSeries: + """Test that --dry-run prevents mutating calls in copy-to-series.""" + + def test_dry_run_skips_sync_sources(self): + wrapper = LaunchpadWrapper() + wrapper.dry_run = True + wrapper.queue_copy("kolibri-server", "jammy", "noble", "Release") + + mock_ppa = MagicMock() + wrapper.perform_queued_copies(mock_ppa) + + mock_ppa.syncSources.assert_not_called() + + def test_dry_run_logs_what_would_be_copied(self, caplog): + wrapper = LaunchpadWrapper() + wrapper.dry_run = True + wrapper.queue_copy("kolibri-server", "jammy", "noble", "Release") + + mock_ppa = MagicMock() + with caplog.at_level(logging.INFO, logger=log.name): + wrapper.perform_queued_copies(mock_ppa) + + assert any( + "DRY-RUN" in r.message and "kolibri-server" in r.message and "noble" in r.message for r in caplog.records + ) + + # --- configure_logging tests --- @@ -326,6 +476,59 @@ def test_dispatches_to_promote(self): mock_cmd.assert_called_once() assert result == 0 + def test_dry_run_flag_passed_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", "--dry-run", "copy-to-series"]), + ): + main() + + mock_cmd.assert_called_once() + args = mock_cmd.call_args[0][0] + assert args.dry_run is True + + def test_dry_run_flag_passed_to_promote(self): + with ( + patch("launchpad_copy.cmd_promote", return_value=0) as mock_cmd, + patch("sys.argv", ["launchpad_copy.py", "--dry-run", "promote"]), + ): + main() + + mock_cmd.assert_called_once() + args = mock_cmd.call_args[0][0] + assert args.dry_run is True + + +class TestDryRunIntegration: + """Test that --dry-run is threaded from CLI to LaunchpadWrapper.""" + + def test_cmd_copy_to_series_sets_dry_run_on_wrapper(self): + parser = build_parser() + args = parser.parse_args(["--dry-run", "copy-to-series"]) + with patch("launchpad_copy.LaunchpadWrapper") as MockWrapper: + instance = MockWrapper.return_value + instance.copy_to_series.return_value = 0 + cmd_copy_to_series(args) + assert instance.dry_run is True + + def test_cmd_promote_sets_dry_run_on_wrapper(self): + parser = build_parser() + args = parser.parse_args(["--dry-run", "promote"]) + with patch("launchpad_copy.LaunchpadWrapper") as MockWrapper: + instance = MockWrapper.return_value + instance.promote.return_value = 0 + cmd_promote(args) + assert instance.dry_run is True + + def test_cmd_wait_for_builds_sets_dry_run_on_wrapper(self): + parser = build_parser() + args = parser.parse_args(["--dry-run", "wait-for-builds", "--package", "kolibri-server", "--version", "1.0"]) + with patch("launchpad_copy.LaunchpadWrapper") as MockWrapper: + instance = MockWrapper.return_value + instance.wait_for_builds.return_value = 0 + cmd_wait_for_builds(args) + assert instance.dry_run is True + # --- copy-to-series subcommand tests --- From 42ed6cb8390b5734aedcf89cdc0ac755a28fa2ef Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Sun, 22 Feb 2026 14:50:20 -0800 Subject: [PATCH 2/4] feat: add workflow_dispatch trigger with dry_run input to build_debian Add a workflow_dispatch trigger with a dry_run boolean input (default true) to enable manual end-to-end testing of the publishing workflow. When dry_run is true: build_package and wait_for_source_builds jobs are skipped, block_release_step approval gate is skipped, and --dry-run is passed to all launchpad_copy.py invocations. The check_version job falls back to the changelog version when no release tag is present. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build_debian.yml | 51 +++++++++++++++++++++++------- 1 file changed, 39 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build_debian.yml b/.github/workflows/build_debian.yml index f172a6b..07dac30 100644 --- a/.github/workflows/build_debian.yml +++ b/.github/workflows/build_debian.yml @@ -2,6 +2,12 @@ name: Build Debian source package on: release: types: [published] + workflow_dispatch: + inputs: + dry_run: + description: Run in dry-run mode (no publishing) + type: boolean + default: true jobs: check_version: runs-on: ubuntu-latest @@ -10,17 +16,22 @@ jobs: steps: - name: Checkout codebase uses: actions/checkout@v4 - - name: Extract version from release tag - id: version - run: | - VERSION=${GITHUB_REF#refs/tags/v} - echo "VERSION=${VERSION}" >> "$GITHUB_OUTPUT" - name: Extract version from changelog id: changelog_version run: | CHANGELOG_VERSION=$(dpkg-parsechangelog -S Version) echo "CHANGELOG_VERSION=${CHANGELOG_VERSION}" >> "$GITHUB_OUTPUT" + - name: Extract version from release tag + id: version + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "VERSION=${{ steps.changelog_version.outputs.CHANGELOG_VERSION }}" >> "$GITHUB_OUTPUT" + else + TAG_VERSION=${GITHUB_REF#refs/tags/v} + echo "VERSION=${TAG_VERSION}" >> "$GITHUB_OUTPUT" + fi - name: Validate Version consistency + if: ${{ github.event_name != 'workflow_dispatch' }} id: version_check env: VERSION: ${{ steps.version.outputs.VERSION }} @@ -32,6 +43,7 @@ jobs: fi echo "Version check passed: ${VERSION}" build_package: + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }} runs-on: ubuntu-latest needs: check_version steps: @@ -53,6 +65,7 @@ jobs: make sign-and-upload echo "upload completed successfully!" wait_for_source_builds: + if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }} needs: - check_version - build_package @@ -80,7 +93,13 @@ jobs: if: always() run: rm -f /tmp/lp-creds.txt copy_to_other_distributions: - needs: wait_for_source_builds + 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') runs-on: ubuntu-latest steps: - name: Checkout codebase @@ -98,7 +117,7 @@ jobs: env: LP_CREDENTIALS_FILE: /tmp/lp-creds.txt run: | - python3 scripts/launchpad_copy.py copy-to-series + python3 scripts/launchpad_copy.py ${{ inputs.dry_run && '--dry-run' || '' }} copy-to-series - name: Cleanup Launchpad credentials if: always() run: rm -f /tmp/lp-creds.txt @@ -123,14 +142,14 @@ jobs: env: LP_CREDENTIALS_FILE: /tmp/lp-creds.txt run: | - python3 scripts/launchpad_copy.py wait-for-builds \ + python3 scripts/launchpad_copy.py ${{ inputs.dry_run && '--dry-run' || '' }} wait-for-builds \ --package kolibri-server \ --version "${{ needs.check_version.outputs.version }}" - name: Cleanup Launchpad credentials if: always() run: rm -f /tmp/lp-creds.txt block_release_step: - if: ${{ !github.event.release.prerelease }} + if: ${{ !github.event.release.prerelease && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} name: Job to block publish of a release until it has been manually approved needs: wait_for_copy_builds runs-on: ubuntu-latest @@ -138,9 +157,17 @@ jobs: steps: - run: echo "Release approved — proceeding to promote to kolibri PPA." copy_package_from_proposed_to_ppa: - if: ${{ !github.event.release.prerelease }} + if: | + always() && + needs.wait_for_copy_builds.result == 'success' && + ( + (!github.event.release.prerelease && needs.block_release_step.result == 'success') || + (github.event_name == 'workflow_dispatch' && inputs.dry_run) + ) name: Promote packages from kolibri-proposed to kolibri - needs: block_release_step + needs: + - wait_for_copy_builds + - block_release_step runs-on: ubuntu-latest steps: - name: Checkout codebase @@ -158,7 +185,7 @@ jobs: env: LP_CREDENTIALS_FILE: /tmp/lp-creds.txt run: | - python3 scripts/launchpad_copy.py promote + python3 scripts/launchpad_copy.py ${{ inputs.dry_run && '--dry-run' || '' }} promote - name: Cleanup Launchpad credentials if: always() run: rm -f /tmp/lp-creds.txt From 70796c93beb8b3acc0b41af5b4850d424e40c29f Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Sun, 22 Feb 2026 14:50:25 -0800 Subject: [PATCH 3/4] docs: update README with Launchpad credentials setup and dry-run usage Replace the outdated Releasing section with comprehensive documentation covering: automated release workflow steps, Launchpad credentials generation via create_lp_creds.py, manual dry-run testing via workflow_dispatch, and launchpad_copy.py CLI usage for all subcommands. Co-Authored-By: Claude Opus 4.6 --- README.rst | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 82 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 011aa2d..e8b347a 100644 --- a/README.rst +++ b/README.rst @@ -21,7 +21,7 @@ To fetch and build a new version of this package, the following workflow is sugg You can optimize this workflow according to your own needs. -Changes can be built and released in ``kolibri-proposed`` by the `Learning Equality Launchpad team `__. +Changes can be built and released in ``kolibri-proposed`` by the `Learning Equality Launchpad team `__. Working in the repo ------------------- @@ -53,13 +53,89 @@ After this, pre-commit hooks will run automatically on ``git commit``. To run al Releasing --------- -Push new changes to ``kolibri-proposed`` and test them there. +Automated release workflow +~~~~~~~~~~~~~~~~~~~~~~~~~~ -To build packages for all current Ubuntu release series: +Publishing a GitHub release triggers the ``build_debian.yml`` workflow, which: -#. Install Launchpadlib: ``sudo apt install python-launchpadlib`` -#. 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. -#. 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``. +#. Validates the release tag version against ``debian/changelog`` +#. Builds, signs, and uploads the source package to the ``kolibri-proposed`` PPA via ``dput`` +#. Waits for Launchpad to build the source package +#. Copies the built package to all supported Ubuntu series +#. Waits for all copy builds to complete +#. (Non-prerelease only) Requires manual approval via the ``release`` environment +#. Promotes packages from ``kolibri-proposed`` to ``kolibri`` PPA + +Launchpad credentials setup +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The workflow requires Launchpad API credentials stored as a GitHub Actions secret. + +To generate credentials: + +#. Install launchpadlib: ``pip install launchpadlib`` +#. Run the credentials helper script:: + + python3 scripts/create_lp_creds.py + +#. Approve the authorization request in your browser. This writes a credentials file (default: ``launchpad.credentials``). +#. Copy the full content of the credentials file. +#. In GitHub, go to the repository **Settings > Secrets and variables > Actions > New repository secret**. +#. Create a secret named ``LP_CREDENTIALS`` and paste the credentials file content. + +The workflow writes this secret to a temporary file at runtime and cleans it up after each job. + +Manual dry-run testing +~~~~~~~~~~~~~~~~~~~~~~ + +The workflow supports a ``workflow_dispatch`` trigger for manual dry-run testing. This allows validating the full workflow (auth, API queries, version checks, copy logic) without any mutating API calls. + +To trigger from the GitHub UI: + +#. Go to **Actions > Build Debian source package > Run workflow** +#. The ``dry_run`` input defaults to ``true`` +#. Click **Run workflow** + +To trigger from the command line:: + + gh workflow run build_debian.yml --field dry_run=true + +When ``dry_run`` is ``true``: + +- The ``build_package`` job is skipped (no ``dput`` upload) +- The ``wait_for_source_builds`` job is skipped +- The ``block_release_step`` manual approval gate is skipped +- All script invocations receive the ``--dry-run`` flag +- Read-only Launchpad API calls (auth, PPA lookup, source listing) execute normally +- Write operations (``copyPackage``, ``syncSources``) are replaced with log messages + +Launchpad copy script +~~~~~~~~~~~~~~~~~~~~~ + +The ``scripts/launchpad_copy.py`` script manages Launchpad PPA operations with three subcommands: + +``copy-to-series`` + Copies packages from the source Ubuntu series to all other supported series within the ``kolibri-proposed`` PPA:: + + python3 scripts/launchpad_copy.py copy-to-series + +``promote`` + Promotes all published packages from ``kolibri-proposed`` to the ``kolibri`` PPA:: + + python3 scripts/launchpad_copy.py promote + +``wait-for-builds`` + Polls Launchpad until all builds for a source package reach a terminal state:: + + python3 scripts/launchpad_copy.py wait-for-builds --package kolibri-server --version 1.0.0 + +All subcommands accept the ``--dry-run`` flag, which logs what would happen without making any mutating API calls:: + + python3 scripts/launchpad_copy.py --dry-run copy-to-series + python3 scripts/launchpad_copy.py --dry-run promote + python3 scripts/launchpad_copy.py --dry-run wait-for-builds --package kolibri-server --version 1.0.0 + +Additional flags: ``-v`` / ``-vv`` for verbosity, ``-q`` for quiet mode, ``--debug`` for HTTP-level debugging. Overview -------- From 21021afdf8dc9ceccf9df572650b5e5cb42b86b3 Mon Sep 17 00:00:00 2001 From: rtibblesbot Date: Sun, 22 Feb 2026 21:59:59 -0800 Subject: [PATCH 4/4] refactor: replace --dry-run with idempotent steps for rerunnable workflow Replace the --dry-run flag approach with idempotent operations so the workflow can be safely rerun after partial failure. Instead of skipping write operations, copyPackage and syncSources now handle "already published" errors gracefully, logging and continuing. - Remove --dry-run flag from CLI and LaunchpadWrapper - Add error handling for "same version already published" in promote() and perform_queued_copies() - Simplify workflow_dispatch: remove dry_run input, use event_name checks - Update README to document rerunnable workflow dispatch behavior - Replace dry-run tests with idempotency tests Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build_debian.yml | 19 +- README.rst | 25 +-- scripts/launchpad_copy.py | 61 +++--- tests/test_launchpad_copy.py | 323 +++++++++++------------------ 4 files changed, 164 insertions(+), 264 deletions(-) diff --git a/.github/workflows/build_debian.yml b/.github/workflows/build_debian.yml index 07dac30..a34648c 100644 --- a/.github/workflows/build_debian.yml +++ b/.github/workflows/build_debian.yml @@ -3,11 +3,6 @@ on: release: types: [published] workflow_dispatch: - inputs: - dry_run: - description: Run in dry-run mode (no publishing) - type: boolean - default: true jobs: check_version: runs-on: ubuntu-latest @@ -43,7 +38,7 @@ jobs: fi echo "Version check passed: ${VERSION}" build_package: - if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }} + if: ${{ github.event_name != 'workflow_dispatch' }} runs-on: ubuntu-latest needs: check_version steps: @@ -65,7 +60,7 @@ jobs: make sign-and-upload echo "upload completed successfully!" wait_for_source_builds: - if: ${{ github.event_name != 'workflow_dispatch' || !inputs.dry_run }} + if: ${{ github.event_name != 'workflow_dispatch' }} needs: - check_version - build_package @@ -117,7 +112,7 @@ jobs: env: LP_CREDENTIALS_FILE: /tmp/lp-creds.txt run: | - python3 scripts/launchpad_copy.py ${{ inputs.dry_run && '--dry-run' || '' }} copy-to-series + python3 scripts/launchpad_copy.py copy-to-series - name: Cleanup Launchpad credentials if: always() run: rm -f /tmp/lp-creds.txt @@ -142,14 +137,14 @@ jobs: env: LP_CREDENTIALS_FILE: /tmp/lp-creds.txt run: | - python3 scripts/launchpad_copy.py ${{ inputs.dry_run && '--dry-run' || '' }} wait-for-builds \ + python3 scripts/launchpad_copy.py wait-for-builds \ --package kolibri-server \ --version "${{ needs.check_version.outputs.version }}" - name: Cleanup Launchpad credentials if: always() run: rm -f /tmp/lp-creds.txt block_release_step: - if: ${{ !github.event.release.prerelease && !(github.event_name == 'workflow_dispatch' && inputs.dry_run) }} + 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 @@ -162,7 +157,7 @@ jobs: needs.wait_for_copy_builds.result == 'success' && ( (!github.event.release.prerelease && needs.block_release_step.result == 'success') || - (github.event_name == 'workflow_dispatch' && inputs.dry_run) + (github.event_name == 'workflow_dispatch') ) name: Promote packages from kolibri-proposed to kolibri needs: @@ -185,7 +180,7 @@ jobs: env: LP_CREDENTIALS_FILE: /tmp/lp-creds.txt run: | - python3 scripts/launchpad_copy.py ${{ inputs.dry_run && '--dry-run' || '' }} promote + python3 scripts/launchpad_copy.py promote - name: Cleanup Launchpad credentials if: always() run: rm -f /tmp/lp-creds.txt diff --git a/README.rst b/README.rst index e8b347a..c693866 100644 --- a/README.rst +++ b/README.rst @@ -85,29 +85,26 @@ To generate credentials: The workflow writes this secret to a temporary file at runtime and cleans it up after each job. -Manual dry-run testing -~~~~~~~~~~~~~~~~~~~~~~ +Manual workflow dispatch +~~~~~~~~~~~~~~~~~~~~~~~~ -The workflow supports a ``workflow_dispatch`` trigger for manual dry-run testing. This allows validating the full workflow (auth, API queries, version checks, copy logic) without any mutating API calls. +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. To trigger from the GitHub UI: #. Go to **Actions > Build Debian source package > Run workflow** -#. The ``dry_run`` input defaults to ``true`` #. Click **Run workflow** To trigger from the command line:: - gh workflow run build_debian.yml --field dry_run=true + gh workflow run build_debian.yml -When ``dry_run`` is ``true``: +When triggered via ``workflow_dispatch``: -- The ``build_package`` job is skipped (no ``dput`` upload) -- The ``wait_for_source_builds`` job is skipped +- The ``build_package`` and ``wait_for_source_builds`` jobs are skipped (no release artifact to upload) +- The version is read from ``debian/changelog`` instead of the release tag - The ``block_release_step`` manual approval gate is skipped -- All script invocations receive the ``--dry-run`` flag -- Read-only Launchpad API calls (auth, PPA lookup, source listing) execute normally -- Write operations (``copyPackage``, ``syncSources``) are replaced with log messages +- All copy and promote steps run normally — they are idempotent and safely handle packages that were already copied in a previous run Launchpad copy script ~~~~~~~~~~~~~~~~~~~~~ @@ -129,11 +126,7 @@ The ``scripts/launchpad_copy.py`` script manages Launchpad PPA operations with t python3 scripts/launchpad_copy.py wait-for-builds --package kolibri-server --version 1.0.0 -All subcommands accept the ``--dry-run`` flag, which logs what would happen without making any mutating API calls:: - - python3 scripts/launchpad_copy.py --dry-run copy-to-series - python3 scripts/launchpad_copy.py --dry-run promote - python3 scripts/launchpad_copy.py --dry-run wait-for-builds --package kolibri-server --version 1.0.0 +All subcommands are idempotent — rerunning them after a partial success safely skips packages that were already copied or promoted. Additional flags: ``-v`` / ``-vv`` for verbosity, ``-q`` for quiet mode, ``--debug`` for HTTP-level debugging. diff --git a/scripts/launchpad_copy.py b/scripts/launchpad_copy.py index e8058e4..50673d7 100644 --- a/scripts/launchpad_copy.py +++ b/scripts/launchpad_copy.py @@ -123,7 +123,6 @@ class LaunchpadWrapper: def __init__(self): self.queue = defaultdict(set) - self.dry_run = False @functools.cached_property def lp(self): @@ -247,10 +246,8 @@ def perform_queued_copies(self, ppa): if first: log.info("") first = False - if self.dry_run: - log.info("DRY-RUN: would copy %s from %s to %s", ", ".join(sorted(names)), source_series, target_series) - else: - log.info("Copying %s to %s", ", ".join(sorted(names)), target_series) + log.info("Copying %s to %s", ", ".join(sorted(names)), target_series) + try: ppa.syncSources( from_archive=ppa, to_series=target_series, @@ -258,6 +255,11 @@ def perform_queued_copies(self, ppa): include_binaries=True, source_names=sorted(names), ) + except lre.BadRequest as e: + if "same version already published" in str(e): + log.info("Already copied to %s — skipping", target_series) + else: + raise def copy_to_series(self): """Copy packages from source series to all other supported Ubuntu series.""" @@ -398,34 +400,32 @@ def promote(self): if pkg.source_package_name not in PACKAGE_WHITELIST: continue try: - if self.dry_run: - log.info( - "DRY-RUN: would copy %s %s (%s) to %s", - pkg.source_package_name, - pkg.source_package_version, - pkg.distro_series_link, - RELEASE_PPA_NAME, - ) - else: + 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( + from_archive=source_ppa, + include_binaries=True, + to_pocket=pkg.pocket, + source_name=pkg.source_package_name, + version=pkg.source_package_version, + ) + copied_any = True + except lre.BadRequest as e: + msg = str(e) + if "is obsolete and will not accept new uploads" in msg: log.info( - "Copying %s %s (%s) to %s", + "Skip obsolete series for %s %s", pkg.source_package_name, pkg.source_package_version, - pkg.distro_series_link, - RELEASE_PPA_NAME, ) - dest_ppa.copyPackage( - from_archive=source_ppa, - include_binaries=True, - to_pocket=pkg.pocket, - source_name=pkg.source_package_name, - version=pkg.source_package_version, - ) - copied_any = True - except lre.BadRequest as e: - if "is obsolete and will not accept new uploads" in str(e): + elif "same version already published" in msg: log.info( - "Skip obsolete series for %s %s", + "Already published %s %s — skipping", pkg.source_package_name, pkg.source_package_version, ) @@ -454,8 +454,6 @@ def build_parser(): ) parser.add_argument("-q", "--quiet", action="store_true", help="Suppress info output.") parser.add_argument("--debug", action="store_true", help="Enable HTTP debug output.") - parser.add_argument("--dry-run", action="store_true", default=False, help="Log actions without making changes.") - subparsers = parser.add_subparsers(dest="command", required=True) subparsers.add_parser( @@ -500,14 +498,12 @@ def configure_logging(args): def cmd_copy_to_series(args): """Copy packages from source series to all other supported Ubuntu series.""" lp = LaunchpadWrapper() - lp.dry_run = args.dry_run return lp.copy_to_series() def cmd_wait_for_builds(args): """Wait for Launchpad builds to complete.""" lp = LaunchpadWrapper() - lp.dry_run = args.dry_run return lp.wait_for_builds( package=args.package, version=args.version, @@ -520,7 +516,6 @@ def cmd_wait_for_builds(args): def cmd_promote(args): """Promote published packages from kolibri-proposed to kolibri PPA.""" lp = LaunchpadWrapper() - lp.dry_run = args.dry_run return lp.promote() diff --git a/tests/test_launchpad_copy.py b/tests/test_launchpad_copy.py index 2dd48a5..cfa0c1e 100644 --- a/tests/test_launchpad_copy.py +++ b/tests/test_launchpad_copy.py @@ -14,9 +14,6 @@ from launchpad_copy import LaunchpadWrapper from launchpad_copy import build_parser -from launchpad_copy import cmd_copy_to_series -from launchpad_copy import cmd_promote -from launchpad_copy import cmd_wait_for_builds from launchpad_copy import configure_logging from launchpad_copy import get_current_series from launchpad_copy import get_supported_series @@ -127,26 +124,6 @@ def test_debug_flag(self): args = parser.parse_args(["--debug", "promote"]) assert args.debug is True - def test_dry_run_flag_defaults_to_false(self): - parser = build_parser() - args = parser.parse_args(["copy-to-series"]) - assert args.dry_run is False - - def test_dry_run_flag_accepted(self): - parser = build_parser() - args = parser.parse_args(["--dry-run", "copy-to-series"]) - assert args.dry_run is True - - def test_dry_run_flag_with_promote(self): - parser = build_parser() - args = parser.parse_args(["--dry-run", "promote"]) - assert args.dry_run is True - - def test_dry_run_flag_with_wait_for_builds(self): - parser = build_parser() - args = parser.parse_args(["--dry-run", "wait-for-builds", "--package", "kolibri-server", "--version", "1.0"]) - assert args.dry_run is True - # --- Series discovery tests --- @@ -238,6 +215,47 @@ def test_perform_queued_copies_skips_empty_queues(self): mock_ppa.syncSources.assert_not_called() + def test_perform_queued_copies_handles_already_synced(self): + """Idempotency: syncSources errors for already-copied packages are handled gracefully.""" + wrapper = LaunchpadWrapper() + wrapper.queue_copy("kolibri-server", "jammy", "noble", "Release") + + class MockBadRequest(Exception): + pass + + mock_ppa = MagicMock() + mock_ppa.syncSources.side_effect = MockBadRequest( + "kolibri-server 0.9.0 in noble (same version already published)" + ) + + with patch("launchpad_copy.lre") as mock_lre: + mock_lre.BadRequest = MockBadRequest + wrapper.perform_queued_copies(mock_ppa) + + # Should not raise — the error is handled gracefully + + def test_perform_queued_copies_logs_already_synced(self, caplog): + """Idempotency: logs a message when syncSources finds package already exists.""" + wrapper = LaunchpadWrapper() + wrapper.queue_copy("kolibri-server", "jammy", "noble", "Release") + + class MockBadRequest(Exception): + pass + + mock_ppa = MagicMock() + mock_ppa.syncSources.side_effect = MockBadRequest( + "kolibri-server 0.9.0 in noble (same version already published)" + ) + + with ( + patch("launchpad_copy.lre") as mock_lre, + caplog.at_level(logging.INFO, logger=log.name), + ): + mock_lre.BadRequest = MockBadRequest + wrapper.perform_queued_copies(mock_ppa) + + assert any("already" in r.message.lower() for r in caplog.records) + def test_get_usable_sources_filters_by_whitelist(self): wrapper = LaunchpadWrapper() mock_ppa = MagicMock() @@ -273,133 +291,6 @@ def test_get_usable_sources_skips_superseded(self): assert len(result) == 0 -# --- dry-run tests --- - - -class TestDryRunPromote: - """Test that --dry-run prevents mutating calls in promote.""" - - def test_dry_run_skips_copy_package(self): - wrapper = LaunchpadWrapper() - wrapper.dry_run = True - mock_source_ppa = MagicMock() - mock_dest_ppa = MagicMock() - - 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.pocket = "Release" - - mock_source_ppa.getPublishedSources.return_value = [mock_pkg] - - with ( - patch.object( - type(wrapper), - "proposed_ppa", - new_callable=lambda: property(lambda self: mock_source_ppa), - ), - patch.object( - type(wrapper), - "release_ppa", - new_callable=lambda: property(lambda self: mock_dest_ppa), - ), - ): - result = wrapper.promote() - - mock_dest_ppa.copyPackage.assert_not_called() - assert result == 0 - - def test_dry_run_logs_what_would_be_promoted(self, caplog): - wrapper = LaunchpadWrapper() - wrapper.dry_run = True - mock_source_ppa = MagicMock() - mock_dest_ppa = MagicMock() - - 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.pocket = "Release" - - mock_source_ppa.getPublishedSources.return_value = [mock_pkg] - - with ( - patch.object( - type(wrapper), - "proposed_ppa", - new_callable=lambda: property(lambda self: mock_source_ppa), - ), - patch.object( - type(wrapper), - "release_ppa", - new_callable=lambda: property(lambda self: mock_dest_ppa), - ), - caplog.at_level(logging.INFO, logger=log.name), - ): - wrapper.promote() - - assert any( - "DRY-RUN" in r.message and "kolibri-server" in r.message and "0.9.0" in r.message for r in caplog.records - ) - - -class TestDryRunWaitForBuilds: - """Test that wait-for-builds works normally in dry-run mode (it's already read-only).""" - - def test_dry_run_wait_for_builds_still_works(self): - wrapper = LaunchpadWrapper() - wrapper.dry_run = True - mock_ppa = MagicMock() - source = MagicMock() - source.source_package_name = "kolibri-server" - source.source_package_version = "1.0" - source.status = "Published" - build = MagicMock() - build.buildstate = "Successfully built" - build.arch_tag = "amd64" - build.web_link = "https://launchpad.net/build/amd64" - source.getBuilds.return_value = [build] - mock_ppa.getPublishedSources.return_value = [source] - - with ( - patch.object(wrapper, "get_ppa", return_value=mock_ppa), - patch("launchpad_copy.time") as mock_time, - ): - mock_time.time.side_effect = [0, 0, 0] - mock_time.sleep = MagicMock() - result = wrapper.wait_for_builds("kolibri-server", "1.0") - - assert result == 0 - - -class TestDryRunCopyToSeries: - """Test that --dry-run prevents mutating calls in copy-to-series.""" - - def test_dry_run_skips_sync_sources(self): - wrapper = LaunchpadWrapper() - wrapper.dry_run = True - wrapper.queue_copy("kolibri-server", "jammy", "noble", "Release") - - mock_ppa = MagicMock() - wrapper.perform_queued_copies(mock_ppa) - - mock_ppa.syncSources.assert_not_called() - - def test_dry_run_logs_what_would_be_copied(self, caplog): - wrapper = LaunchpadWrapper() - wrapper.dry_run = True - wrapper.queue_copy("kolibri-server", "jammy", "noble", "Release") - - mock_ppa = MagicMock() - with caplog.at_level(logging.INFO, logger=log.name): - wrapper.perform_queued_copies(mock_ppa) - - assert any( - "DRY-RUN" in r.message and "kolibri-server" in r.message and "noble" in r.message for r in caplog.records - ) - - # --- configure_logging tests --- @@ -476,59 +367,6 @@ def test_dispatches_to_promote(self): mock_cmd.assert_called_once() assert result == 0 - def test_dry_run_flag_passed_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", "--dry-run", "copy-to-series"]), - ): - main() - - mock_cmd.assert_called_once() - args = mock_cmd.call_args[0][0] - assert args.dry_run is True - - def test_dry_run_flag_passed_to_promote(self): - with ( - patch("launchpad_copy.cmd_promote", return_value=0) as mock_cmd, - patch("sys.argv", ["launchpad_copy.py", "--dry-run", "promote"]), - ): - main() - - mock_cmd.assert_called_once() - args = mock_cmd.call_args[0][0] - assert args.dry_run is True - - -class TestDryRunIntegration: - """Test that --dry-run is threaded from CLI to LaunchpadWrapper.""" - - def test_cmd_copy_to_series_sets_dry_run_on_wrapper(self): - parser = build_parser() - args = parser.parse_args(["--dry-run", "copy-to-series"]) - with patch("launchpad_copy.LaunchpadWrapper") as MockWrapper: - instance = MockWrapper.return_value - instance.copy_to_series.return_value = 0 - cmd_copy_to_series(args) - assert instance.dry_run is True - - def test_cmd_promote_sets_dry_run_on_wrapper(self): - parser = build_parser() - args = parser.parse_args(["--dry-run", "promote"]) - with patch("launchpad_copy.LaunchpadWrapper") as MockWrapper: - instance = MockWrapper.return_value - instance.promote.return_value = 0 - cmd_promote(args) - assert instance.dry_run is True - - def test_cmd_wait_for_builds_sets_dry_run_on_wrapper(self): - parser = build_parser() - args = parser.parse_args(["--dry-run", "wait-for-builds", "--package", "kolibri-server", "--version", "1.0"]) - with patch("launchpad_copy.LaunchpadWrapper") as MockWrapper: - instance = MockWrapper.return_value - instance.wait_for_builds.return_value = 0 - cmd_wait_for_builds(args) - assert instance.dry_run is True - # --- copy-to-series subcommand tests --- @@ -678,6 +516,85 @@ def test_skips_non_whitelisted_package(self): mock_dest_ppa.copyPackage.assert_not_called() assert result == 0 + def test_handles_already_published_package_gracefully(self): + """Idempotency: promote skips packages already copied to dest PPA.""" + wrapper = LaunchpadWrapper() + mock_source_ppa = MagicMock() + mock_dest_ppa = MagicMock() + + 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.pocket = "Release" + + mock_source_ppa.getPublishedSources.return_value = [mock_pkg] + + class MockBadRequest(Exception): + pass + + mock_dest_ppa.copyPackage.side_effect = MockBadRequest( + "kolibri-server 0.9.0 in jammy (same version already published in the target archive)" + ) + + with ( + patch.object( + type(wrapper), + "proposed_ppa", + new_callable=lambda: property(lambda self: mock_source_ppa), + ), + patch.object( + type(wrapper), + "release_ppa", + new_callable=lambda: property(lambda self: mock_dest_ppa), + ), + patch("launchpad_copy.lre") as mock_lre, + ): + mock_lre.BadRequest = MockBadRequest + result = wrapper.promote() + + assert result == 0 + + def test_already_published_logs_skip_message(self, caplog): + """Idempotency: promote logs that a package was already promoted.""" + wrapper = LaunchpadWrapper() + mock_source_ppa = MagicMock() + mock_dest_ppa = MagicMock() + + 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.pocket = "Release" + + mock_source_ppa.getPublishedSources.return_value = [mock_pkg] + + class MockBadRequest(Exception): + pass + + mock_dest_ppa.copyPackage.side_effect = MockBadRequest( + "kolibri-server 0.9.0 in jammy (same version already published in the target archive)" + ) + + with ( + patch.object( + type(wrapper), + "proposed_ppa", + new_callable=lambda: property(lambda self: mock_source_ppa), + ), + patch.object( + type(wrapper), + "release_ppa", + new_callable=lambda: property(lambda self: mock_dest_ppa), + ), + patch("launchpad_copy.lre") as mock_lre, + caplog.at_level(logging.INFO, logger=log.name), + ): + mock_lre.BadRequest = MockBadRequest + wrapper.promote() + + assert any("already published" in r.message.lower() and "kolibri-server" in r.message for r in caplog.records) + # --- wait-for-builds tests ---