← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/lpci:release-multi-arch into lpci:main

 

Colin Watson has proposed merging ~cjwatson/lpci:release-multi-arch into lpci:main.

Commit message:
Release the latest build of each architecture

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~cjwatson/lpci/+git/lpci/+merge/452398

`lpci release` incorrectly released the latest build regardless of architecture; this approach was OK when builds were typically only dispatched for a single architecture, but now that we need to handle releasing for (e.g.) both amd64 and arm64, it doesn't work so well.  Release the latest build for each architecture for which builds exist instead, and add a new `--architecture` option for the case where people want to override this behaviour and only release the latest build for a single architecture.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpci:release-multi-arch into lpci:main.
diff --git a/.mypy.ini b/.mypy.ini
index 2d0ab77..d749745 100644
--- a/.mypy.ini
+++ b/.mypy.ini
@@ -6,7 +6,7 @@ disallow_subclassing_any = false
 disallow_untyped_calls = false
 disallow_untyped_defs = false
 
-[mypy-fixtures.*,launchpadlib.*,systemfixtures.*,testtools.*,pluggy.*,wadllib.*]
+[mypy-fixtures.*,launchpadlib.*,lazr.restfulclient.*,systemfixtures.*,testtools.*,pluggy.*,wadllib.*]
 ignore_missing_imports = true
 
 [mypy-craft_cli.*]
diff --git a/NEWS.rst b/NEWS.rst
index 8c9132a..2748244 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -2,6 +2,13 @@
 Version history
 ===============
 
+0.2.4 (unreleased)
+==================
+
+- Fix ``lpci release`` to release the latest build of each architecture (or
+  a single architecture selected by the new ``--architecture`` option),
+  rather than only releasing the latest build regardless of architecture.
+
 0.2.3 (2023-07-20)
 ==================
 
diff --git a/docs/cli-interface.rst b/docs/cli-interface.rst
index bab45d1..eb0fafc 100644
--- a/docs/cli-interface.rst
+++ b/docs/cli-interface.rst
@@ -135,3 +135,6 @@ lpci release optional arguments
 - ``--commit ID`` to specify the source Git branch name, tag name, or commit
   ID (defaults to the tip commit found for the current branch in the
   upstream repository).
+
+- ``--architecture NAME`` to only release the builds for this architecture
+  (defaults to the latest build for each built architecture).
diff --git a/lpci/commands/release.py b/lpci/commands/release.py
index d51e239..fd523eb 100644
--- a/lpci/commands/release.py
+++ b/lpci/commands/release.py
@@ -3,11 +3,14 @@
 
 import re
 from argparse import ArgumentParser, Namespace
+from collections import defaultdict
 from operator import attrgetter
+from typing import Dict, List
 from urllib.parse import urlparse
 
 from craft_cli import BaseCommand, emit
 from launchpadlib.launchpad import Launchpad
+from lazr.restfulclient.resource import Entry
 
 from lpci.errors import CommandError
 from lpci.git import get_current_branch, get_current_remote_url
@@ -52,13 +55,21 @@ class ReleaseCommand(BaseCommand):
             ),
         )
         parser.add_argument(
+            "-a",
+            "--architecture",
+            help=(
+                "Only release the latest build for this architecture "
+                "(defaults to the latest build for each built architecture)"
+            ),
+        )
+        parser.add_argument(
             "archive", help="Target archive, e.g. ppa:OWNER/DISTRIBUTION/NAME"
         )
         parser.add_argument("suite", help="Target suite, e.g. focal")
         parser.add_argument("channel", help="Target channel, e.g. edge")
 
-    def run(self, args: Namespace) -> int:
-        """Run the command."""
+    def _check_args(self, args: Namespace) -> None:
+        """Check and process arguments."""
         if args.repository is None:
             current_remote_url = get_current_remote_url()
             if current_remote_url is None:
@@ -86,9 +97,9 @@ class ReleaseCommand(BaseCommand):
                     "branch."
                 )
 
-        launchpad = Launchpad.login_with(
-            "lpci", args.launchpad_instance, version="devel"
-        )
+    def _find_builds(
+        self, launchpad: Launchpad, args: Namespace
+    ) -> Dict[str, List[Entry]]:
         repository = launchpad.git_repositories.getByPath(path=args.repository)
         if repository is None:
             raise CommandError(
@@ -107,6 +118,10 @@ class ReleaseCommand(BaseCommand):
             report.ci_build
             for report in reports
             if report.ci_build is not None
+            and (
+                args.architecture is None
+                or report.ci_build.arch_tag == args.architecture
+            )
             and report.ci_build.buildstate == "Successfully built"
             and report.getArtifactURLs(artifact_type="Binary")
         ]
@@ -115,20 +130,44 @@ class ReleaseCommand(BaseCommand):
                 f"{args.repository}:{args.commit} has no completed CI "
                 f"builds with attached files."
             )
-        latest_build = sorted(builds, key=attrgetter("datebuilt"))[-1]
+        builds_by_arch = defaultdict(list)
+        for build in builds:
+            builds_by_arch[build.arch_tag].append(build)
+        return builds_by_arch
+
+    def _release_build(
+        self, launchpad: Launchpad, build: Entry, args: Namespace
+    ) -> None:
         archive = launchpad.archives.getByReference(reference=args.archive)
         description = (
-            f"build of {args.repository}:{args.commit} to "
+            f"{build.arch_tag} build of {args.repository}:{args.commit} to "
             f"{args.archive} {args.suite} {args.channel}"
         )
         if args.dry_run:
             emit.message(f"Would release {description}.")
         else:
             archive.uploadCIBuild(
-                ci_build=latest_build,
+                ci_build=build,
                 to_series=args.suite,
                 to_pocket="Release",
                 to_channel=args.channel,
             )
             emit.message(f"Released {description}.")
+
+    def run(self, args: Namespace) -> int:
+        """Run the command."""
+        self._check_args(args)
+        launchpad = Launchpad.login_with(
+            "lpci", args.launchpad_instance, version="devel"
+        )
+        builds_by_arch = self._find_builds(launchpad, args)
+        if args.architecture is not None:
+            arch_tags = [args.architecture]
+        else:
+            arch_tags = sorted(builds_by_arch)
+        for arch_tag in arch_tags:
+            latest_build = sorted(
+                builds_by_arch[arch_tag], key=attrgetter("datebuilt")
+            )[-1]
+            self._release_build(launchpad, latest_build, args)
         return 0
diff --git a/lpci/commands/tests/filter-wadl.py b/lpci/commands/tests/filter-wadl.py
index 91ddfbc..89705f4 100755
--- a/lpci/commands/tests/filter-wadl.py
+++ b/lpci/commands/tests/filter-wadl.py
@@ -23,7 +23,7 @@ keep_collections = {
 
 keep_entries = {
     "archive": ["uploadCIBuild"],
-    "ci_build": ["buildstate", "datebuilt", "get"],
+    "ci_build": ["arch_tag", "buildstate", "datebuilt", "get"],
     "git_ref": ["commit_sha1", "get"],
     "git_repository": ["getRefByPath", "getStatusReports"],
     "revision_status_report": [
diff --git a/lpci/commands/tests/launchpad-wadl.xml b/lpci/commands/tests/launchpad-wadl.xml
index 836fdd6..d61b793 100644
--- a/lpci/commands/tests/launchpad-wadl.xml
+++ b/lpci/commands/tests/launchpad-wadl.xml
@@ -263,6 +263,12 @@ A build record for a pipeline of CI jobs.
           The value of the HTTP ETag for this resource.
         </wadl:doc>
       </wadl:param>
+      <wadl:param style="plain" required="true" name="arch_tag" path="$['arch_tag']">
+        <wadl:doc>
+Architecture tag
+</wadl:doc>
+        
+      </wadl:param>
       <wadl:param style="plain" required="true" name="buildstate" path="$['buildstate']">
         <wadl:doc>
 <html:p>Status</html:p>
@@ -281,6 +287,7 @@ A build record for a pipeline of CI jobs.
         <wadl:option value="Uploading build" />
         <wadl:option value="Cancelling build" />
         <wadl:option value="Cancelled build" />
+        <wadl:option value="Gathering build output" />
       </wadl:param>
       <wadl:param style="plain" required="true" name="datebuilt" path="$['datebuilt']" type="xsd:dateTime">
         <wadl:doc>
@@ -425,29 +432,27 @@ A reference in a Git repository.
       <wadl:doc>
 A Git repository.
 </wadl:doc>
-      <wadl:method id="git_repository-getStatusReports" name="GET">
+      <wadl:method id="git_repository-getRefByPath" name="GET">
         <wadl:doc>
-<html:p>Retrieves the list of reports that exist for a commit.</html:p>
-<html:blockquote>
+<html:p>Look up a single reference in this repository by path.</html:p>
 <html:table class="rst-docutils field-list" frame="void" rules="none">
 <html:col class="field-name" />
 <html:col class="field-body" />
 <html:tbody valign="top">
-<html:tr class="rst-field"><html:th class="rst-field-name" colspan="2">param commit_sha1:</html:th></html:tr>
-<html:tr class="rst-field"><html:td>\&#160;</html:td><html:td class="rst-field-body">The commit sha1 for the report.</html:td>
+<html:tr class="rst-field"><html:th class="rst-field-name">param path:</html:th><html:td class="rst-field-body">A string to look up as a path.</html:td>
+</html:tr>
+<html:tr class="rst-field"><html:th class="rst-field-name">return:</html:th><html:td class="rst-field-body">An IGitRef, or None.</html:td>
 </html:tr>
 </html:tbody>
 </html:table>
-</html:blockquote>
-<html:p>Scopes: <html:tt class="rst-docutils literal">repository:build_status</html:tt></html:p>
 
 </wadl:doc>
         <wadl:request>
           
-            <wadl:param style="query" name="ws.op" required="true" fixed="getStatusReports" />
-            <wadl:param style="query" name="commit_sha1" required="true">
+            <wadl:param style="query" name="ws.op" required="true" fixed="getRefByPath" />
+            <wadl:param style="query" name="path" required="true">
               <wadl:doc>
-The Git commit for which this report is built.
+A string to look up as a path.
 </wadl:doc>
               
             </wadl:param>
@@ -455,30 +460,32 @@ The Git commit for which this report is built.
         </wadl:request>
         <wadl:response>
           
-          <wadl:representation href="https://api.launchpad.net/devel/#revision_status_report-page"; />
+          <wadl:representation href="https://api.launchpad.net/devel/#git_ref-full"; />
         </wadl:response>
       </wadl:method>
-      <wadl:method id="git_repository-getRefByPath" name="GET">
+      <wadl:method id="git_repository-getStatusReports" name="GET">
         <wadl:doc>
-<html:p>Look up a single reference in this repository by path.</html:p>
+<html:p>Retrieves the list of reports that exist for a commit.</html:p>
+<html:blockquote>
 <html:table class="rst-docutils field-list" frame="void" rules="none">
 <html:col class="field-name" />
 <html:col class="field-body" />
 <html:tbody valign="top">
-<html:tr class="rst-field"><html:th class="rst-field-name">param path:</html:th><html:td class="rst-field-body">A string to look up as a path.</html:td>
-</html:tr>
-<html:tr class="rst-field"><html:th class="rst-field-name">return:</html:th><html:td class="rst-field-body">An IGitRef, or None.</html:td>
+<html:tr class="rst-field"><html:th class="rst-field-name" colspan="2">param commit_sha1:</html:th></html:tr>
+<html:tr class="rst-field"><html:td>\&#160;</html:td><html:td class="rst-field-body">The commit sha1 for the report.</html:td>
 </html:tr>
 </html:tbody>
 </html:table>
+</html:blockquote>
+<html:p>Scopes: <html:tt class="rst-docutils literal">repository:build_status</html:tt></html:p>
 
 </wadl:doc>
         <wadl:request>
           
-            <wadl:param style="query" name="ws.op" required="true" fixed="getRefByPath" />
-            <wadl:param style="query" name="path" required="true">
+            <wadl:param style="query" name="ws.op" required="true" fixed="getStatusReports" />
+            <wadl:param style="query" name="commit_sha1" required="true">
               <wadl:doc>
-A string to look up as a path.
+The Git commit for which this report is built.
 </wadl:doc>
               
             </wadl:param>
@@ -486,7 +493,7 @@ A string to look up as a path.
         </wadl:request>
         <wadl:response>
           
-          <wadl:representation href="https://api.launchpad.net/devel/#git_ref-full"; />
+          <wadl:representation href="https://api.launchpad.net/devel/#revision_status_report-page"; />
         </wadl:response>
       </wadl:method>
       </wadl:resource_type>
diff --git a/lpci/commands/tests/test_release.py b/lpci/commands/tests/test_release.py
index 96649f4..46b5883 100644
--- a/lpci/commands/tests/test_release.py
+++ b/lpci/commands/tests/test_release.py
@@ -269,6 +269,7 @@ class TestRelease(CommandBaseTestCase):
                     "entries": [
                         {
                             "ci_build": {
+                                "arch_tag": "amd64",
                                 "buildstate": "Successfully built",
                                 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
                             },
@@ -301,7 +302,7 @@ class TestRelease(CommandBaseTestCase):
             MatchesStructure.byEquality(
                 exit_code=0,
                 messages=[
-                    f"Would release build of example:{commit_sha1} to "
+                    f"Would release amd64 build of example:{commit_sha1} to "
                     f"ppa:owner/ubuntu/name focal edge."
                 ],
             ),
@@ -318,6 +319,7 @@ class TestRelease(CommandBaseTestCase):
                     "entries": [
                         {
                             "ci_build": {
+                                "arch_tag": "amd64",
                                 "buildstate": "Successfully built",
                                 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
                             },
@@ -325,6 +327,7 @@ class TestRelease(CommandBaseTestCase):
                         },
                         {
                             "ci_build": {
+                                "arch_tag": "amd64",
                                 "buildstate": "Successfully built",
                                 "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
                             },
@@ -356,7 +359,7 @@ class TestRelease(CommandBaseTestCase):
             MatchesStructure.byEquality(
                 exit_code=0,
                 messages=[
-                    f"Released build of example:{commit_sha1} to "
+                    f"Released amd64 build of example:{commit_sha1} to "
                     f"ppa:owner/ubuntu/name focal edge."
                 ],
             ),
@@ -366,8 +369,9 @@ class TestRelease(CommandBaseTestCase):
             upload,
             MatchesListwise(
                 [
-                    MatchesStructure(
-                        datebuilt=Equals(datetime(2022, 1, 1, 12, 0, 0))
+                    MatchesStructure.byEquality(
+                        arch_tag="amd64",
+                        datebuilt=datetime(2022, 1, 1, 12, 0, 0),
                     ),
                     Equals("focal"),
                     Equals("Release"),
@@ -385,6 +389,7 @@ class TestRelease(CommandBaseTestCase):
                     "entries": [
                         {
                             "ci_build": {
+                                "arch_tag": "amd64",
                                 "buildstate": "Successfully built",
                                 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
                             },
@@ -392,6 +397,7 @@ class TestRelease(CommandBaseTestCase):
                         },
                         {
                             "ci_build": {
+                                "arch_tag": "amd64",
                                 "buildstate": "Successfully built",
                                 "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
                             },
@@ -423,7 +429,7 @@ class TestRelease(CommandBaseTestCase):
             MatchesStructure.byEquality(
                 exit_code=0,
                 messages=[
-                    f"Released build of example:{commit_sha1} to "
+                    f"Released amd64 build of example:{commit_sha1} to "
                     f"ppa:owner/ubuntu/name focal edge."
                 ],
             ),
@@ -442,6 +448,7 @@ class TestRelease(CommandBaseTestCase):
                     "entries": [
                         {
                             "ci_build": {
+                                "arch_tag": "amd64",
                                 "buildstate": "Successfully built",
                                 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
                             },
@@ -469,7 +476,7 @@ class TestRelease(CommandBaseTestCase):
             MatchesStructure.byEquality(
                 exit_code=0,
                 messages=[
-                    f"Released build of example:{commit_sha1} to "
+                    f"Released amd64 build of example:{commit_sha1} to "
                     f"ppa:owner/ubuntu/name focal edge."
                 ],
             ),
@@ -488,6 +495,7 @@ class TestRelease(CommandBaseTestCase):
                     "entries": [
                         {
                             "ci_build": {
+                                "arch_tag": "amd64",
                                 "buildstate": "Successfully built",
                                 "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
                             },
@@ -515,8 +523,200 @@ class TestRelease(CommandBaseTestCase):
             MatchesStructure.byEquality(
                 exit_code=0,
                 messages=[
-                    f"Released build of example:{commit_sha1} to "
+                    f"Released amd64 build of example:{commit_sha1} to "
                     f"ppa:owner/ubuntu/name focal edge."
                 ],
             ),
         )
+
+    def test_release_multiple_architectures(self):
+        lp = self.make_fake_launchpad()
+        commit_sha1 = "1" * 40
+        lp.git_repositories = {
+            "getByPath": lambda path: {
+                "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
+                "getStatusReports": lambda commit_sha1: {
+                    "entries": [
+                        {
+                            "ci_build": {
+                                "arch_tag": "amd64",
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                        {
+                            "ci_build": {
+                                "arch_tag": "arm64",
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 1, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                        {
+                            "ci_build": {
+                                "arch_tag": "amd64",
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                        {
+                            "ci_build": {
+                                "arch_tag": "arm64",
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 13, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                    ]
+                },
+            }
+        }
+        lp.archives = {
+            "getByReference": lambda reference: {
+                "uploadCIBuild": self.fake_upload
+            }
+        }
+
+        result = self.run_command(
+            "release",
+            "--repository",
+            "example",
+            "--commit",
+            "branch",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=0,
+                messages=[
+                    f"Released amd64 build of example:{commit_sha1} to "
+                    f"ppa:owner/ubuntu/name focal edge.",
+                    f"Released arm64 build of example:{commit_sha1} to "
+                    f"ppa:owner/ubuntu/name focal edge.",
+                ],
+            ),
+        )
+        self.assertThat(
+            self.uploads,
+            MatchesListwise(
+                [
+                    MatchesListwise(
+                        [
+                            MatchesStructure.byEquality(
+                                arch_tag="amd64",
+                                datebuilt=datetime(2022, 1, 1, 12, 0, 0),
+                            ),
+                            Equals("focal"),
+                            Equals("Release"),
+                            Equals("edge"),
+                        ]
+                    ),
+                    MatchesListwise(
+                        [
+                            MatchesStructure.byEquality(
+                                arch_tag="arm64",
+                                datebuilt=datetime(2022, 1, 1, 13, 0, 0),
+                            ),
+                            Equals("focal"),
+                            Equals("Release"),
+                            Equals("edge"),
+                        ],
+                    ),
+                ]
+            ),
+        )
+
+    def test_release_select_single_architecture(self):
+        lp = self.make_fake_launchpad()
+        commit_sha1 = "1" * 40
+        lp.git_repositories = {
+            "getByPath": lambda path: {
+                "getRefByPath": lambda path: {"commit_sha1": commit_sha1},
+                "getStatusReports": lambda commit_sha1: {
+                    "entries": [
+                        {
+                            "ci_build": {
+                                "arch_tag": "amd64",
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                        {
+                            "ci_build": {
+                                "arch_tag": "arm64",
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 1, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                        {
+                            "ci_build": {
+                                "arch_tag": "amd64",
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                        {
+                            "ci_build": {
+                                "arch_tag": "arm64",
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 13, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                    ]
+                },
+            }
+        }
+        lp.archives = {
+            "getByReference": lambda reference: {
+                "uploadCIBuild": self.fake_upload
+            }
+        }
+
+        result = self.run_command(
+            "release",
+            "--repository",
+            "example",
+            "--commit",
+            "branch",
+            "--architecture",
+            "amd64",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=0,
+                messages=[
+                    f"Released amd64 build of example:{commit_sha1} to "
+                    f"ppa:owner/ubuntu/name focal edge.",
+                ],
+            ),
+        )
+        [upload] = self.uploads
+        self.assertThat(
+            upload,
+            MatchesListwise(
+                [
+                    MatchesStructure.byEquality(
+                        arch_tag="amd64",
+                        datebuilt=datetime(2022, 1, 1, 12, 0, 0),
+                    ),
+                    Equals("focal"),
+                    Equals("Release"),
+                    Equals("edge"),
+                ]
+            ),
+        )
diff --git a/setup.cfg b/setup.cfg
index 803ab01..1fe623d 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -28,6 +28,7 @@ install_requires =
     craft-providers
     jinja2
     launchpadlib[keyring]
+    lazr.restfulclient
     pluggy
     pydantic
     python-dotenv