← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/lpcraft:release-command into lpcraft:main

 

Colin Watson has proposed merging ~cjwatson/lpcraft:release-command into lpcraft:main.

Commit message:
Add a release command

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This integrates with various API endpoints in Launchpad to take the results of a CI build and release them to a properly-configured PPA.

Tests won't pass until https://code.launchpad.net/~cjwatson/launchpadlib/+git/launchpadlib/+merge/435261 and https://code.launchpad.net/~cjwatson/launchpadlib/+git/launchpadlib/+merge/435267 have landed and we've made a new launchpadlib release, though I've tested with a local environment that has those patches applied.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lpcraft:release-command into lpcraft:main.
diff --git a/.mypy.ini b/.mypy.ini
index 60a899d..7a5124e 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.*,systemfixtures.*,testtools.*,pluggy.*]
+[mypy-fixtures.*,launchpadlib.*,systemfixtures.*,testtools.*,pluggy.*]
 ignore_missing_imports = true
 
 [mypy-craft_cli.*]
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 7b6a1bf..dccbc06 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -10,7 +10,9 @@ repos:
     -   id: check-yaml
     -   id: debug-statements
     -   id: end-of-file-fixer
+        exclude: lpcraft/commands/tests/launchpad-wadl\.xml
     -   id: trailing-whitespace
+        exclude: lpcraft/commands/tests/launchpad-wadl\.xml
 -   repo: https://github.com/PyCQA/flake8
     rev: 6.0.0
     hooks:
diff --git a/NEWS.rst b/NEWS.rst
index 2b01545..6cbc395 100644
--- a/NEWS.rst
+++ b/NEWS.rst
@@ -2,11 +2,13 @@
 Version history
 ===============
 
-0.0.40 (unreleased)
-===================
+0.1.0 (unreleased)
+==================
 
 - Fix the leakage of package repositories from a job to the next
 
+- Add a ``release`` command.
+
 0.0.39 (2023-01-06)
 ===================
 
diff --git a/docs/cli-interface.rst b/docs/cli-interface.rst
index afb6907..f30d37e 100644
--- a/docs/cli-interface.rst
+++ b/docs/cli-interface.rst
@@ -91,3 +91,32 @@ lpcraft run-one optional arguments
   ``lpcraft run-one --set-env="PIP_INDEX_URL=http://pypi.example.com/simple"; test 0``
 
   This option is repeatable.
+
+lpcraft release
+---------------
+
+This command releases a Launchpad build of a commit to a target archive
+(which must be configured with a repository format that accepts packages of
+the appropriate type).  It checks that the commit in question was
+successfully built and has some attached files.
+
+**Example:**
+
+``lpcraft release ppa:ubuntu-security/soss/soss-python-stable-local focal edge``
+
+lpcraft release optional arguments
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+- ``--launchpad INSTANCE`` to use a Launchpad instance other than
+  production.
+
+- ``--dry-run`` to just report what would be done rather than actually
+  performing a release.
+
+- ``--repository URL`` to specify the source Git repository URL (defaults to
+  the upstream repository for the current branch, if on
+  ``git.launchpad.net``).
+
+- ``--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).
diff --git a/lpcraft/commands/release.py b/lpcraft/commands/release.py
new file mode 100644
index 0000000..22651b5
--- /dev/null
+++ b/lpcraft/commands/release.py
@@ -0,0 +1,134 @@
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import re
+from argparse import ArgumentParser, Namespace
+from operator import attrgetter
+from urllib.parse import urlparse
+
+from craft_cli import BaseCommand, emit
+from launchpadlib.launchpad import Launchpad
+
+from lpcraft.errors import CommandError
+from lpcraft.git import get_current_branch, get_current_remote_url
+
+
+class ReleaseCommand(BaseCommand):
+    """Release a Launchpad build of a commit to a target archive."""
+
+    name = "release"
+    help_msg = __doc__.splitlines()[0]
+    overview = __doc__
+    common = True
+
+    def fill_parser(self, parser: ArgumentParser) -> None:
+        """Add arguments specific to this command."""
+        parser.add_argument(
+            "-l",
+            "--launchpad",
+            dest="launchpad_instance",
+            default="production",
+            help="Use this Launchpad instance.",
+        )
+        parser.add_argument(
+            "-n",
+            "--dry-run",
+            default=False,
+            action="store_true",
+            help="Just report what would be done.",
+        )
+        parser.add_argument(
+            "--repository",
+            help=(
+                "Git repository URL (defaults to the upstream repository for "
+                "the current branch, if on git.launchpad.net)"
+            ),
+        )
+        parser.add_argument(
+            "--commit",
+            help=(
+                "Git branch name, tag name, or commit ID (defaults to the "
+                "current branch)"
+            ),
+        )
+        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."""
+        if args.repository is None:
+            current_remote_url = get_current_remote_url()
+            if current_remote_url is None:
+                raise CommandError(
+                    "No --repository option was given, and the current branch "
+                    "does not track a remote branch."
+                )
+            parsed_url = urlparse(current_remote_url)
+            # XXX cjwatson 2023-01-04: Ideally this would check for the git
+            # service corresponding to the --launchpad argument rather than
+            # hardcoding git.launchpad.net.
+            if parsed_url.netloc == "git.launchpad.net":
+                args.repository = parsed_url.path
+            else:
+                raise CommandError(
+                    "No --repository option was given, and the current branch "
+                    "does not track a remote branch on git.launchpad.net."
+                )
+        args.repository = args.repository.lstrip("/")
+        if args.commit is None:
+            args.commit = get_current_branch()
+            if args.commit is None:
+                raise CommandError(
+                    "No --commit option was given, and there is no current "
+                    "branch."
+                )
+
+        launchpad = Launchpad.login_with(
+            "lpcraft", args.launchpad_instance, version="devel"
+        )
+        repository = launchpad.git_repositories.getByPath(path=args.repository)
+        if repository is None:
+            raise CommandError(
+                f"Repository {args.repository} does not exist on Launchpad."
+            )
+        if re.match(r"^[0-9a-f]{40}$", args.commit) is None:
+            ref = repository.getRefByPath(path=args.commit)
+            if ref is None:
+                raise CommandError(
+                    f"{args.repository} has no branch or tag named "
+                    f"{args.commit}."
+                )
+            args.commit = ref.commit_sha1
+        reports = repository.getStatusReports(commit_sha1=args.commit)
+        builds = [
+            report.ci_build
+            for report in reports
+            if report.ci_build is not None
+            and report.ci_build.buildstate == "Successfully built"
+            and report.getArtifactURLs(artifact_type="Binary")
+        ]
+        if not builds:
+            raise CommandError(
+                f"{args.repository}:{args.commit} has no completed CI "
+                f"builds with attached files."
+            )
+        latest_build = sorted(builds, key=attrgetter("datebuilt"))[-1]
+        archive = launchpad.archives.getByReference(reference=args.archive)
+        description = (
+            f"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,
+                to_series=args.suite,
+                to_pocket="Release",
+                to_channel=args.channel,
+            )
+            emit.message(f"Released {description}.")
+        return 0
diff --git a/lpcraft/commands/tests/filter-wadl.py b/lpcraft/commands/tests/filter-wadl.py
new file mode 100755
index 0000000..f1e1dae
--- /dev/null
+++ b/lpcraft/commands/tests/filter-wadl.py
@@ -0,0 +1,130 @@
+#! /usr/bin/python3
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+"""
+Make a filtered version of Launchpad's WADL description.
+
+Download Launchpad's WADL description and filter it to include only the
+parts we need.
+"""
+
+from pathlib import Path
+from xml.etree import ElementTree
+
+import requests
+
+wadl_namespace = "http://research.sun.com/wadl/2006/10";
+
+keep_collections = {
+    "archives": ["getByReference"],
+    "git_repositories": ["getByPath"],
+}
+
+keep_entries = {
+    "archive": ["uploadCIBuild"],
+    "ci_build": ["buildstate", "datebuilt", "get"],
+    "git_ref": ["commit_sha1", "get"],
+    "git_repository": ["getRefByPath", "getStatusReports"],
+    "revision_status_report": [
+        "ci_build_link",
+        "get",
+        "getArtifactURLs",
+        "page-resource-get",
+    ],
+}
+
+
+def download_wadl() -> str:
+    response = requests.get(
+        "https://api.launchpad.net/devel/";,
+        headers={"Accept": "application/vnd.sun.wadl+xml"},
+    )
+    response.raise_for_status()
+    return response.text
+
+
+def reduce_wadl(wadl: str) -> ElementTree.Element:
+    ElementTree.register_namespace("wadl", wadl_namespace)
+    parsed = ElementTree.fromstring(wadl)
+
+    for resource_type in parsed.findall(
+        "wadl:resource_type", namespaces={"wadl": wadl_namespace}
+    ):
+        resource_type_name = resource_type.get("id")
+        if resource_type_name == "service-root":
+            continue
+        elif resource_type_name in keep_collections:
+            keep_methods = [
+                f"{resource_type_name}-{method}"
+                for method in keep_collections[resource_type_name]
+            ]
+            for method in resource_type.findall(
+                "wadl:method", namespaces={"wadl": wadl_namespace}
+            ):
+                if method.get("id") not in keep_methods:
+                    resource_type.remove(method)
+        elif resource_type_name in keep_entries:
+            keep_methods = [
+                f"{resource_type_name}-{method}"
+                for method in keep_entries[resource_type_name]
+            ]
+            for method in resource_type.findall(
+                "wadl:method", namespaces={"wadl": wadl_namespace}
+            ):
+                if method.get("id") not in keep_methods:
+                    resource_type.remove(method)
+        elif (
+            resource_type_name.endswith("-page-resource")
+            and resource_type_name.removesuffix("-page-resource")
+            in keep_entries
+        ):
+            continue
+        else:
+            parsed.remove(resource_type)
+
+    for representation in parsed.findall(
+        "wadl:representation", namespaces={"wadl": wadl_namespace}
+    ):
+        representation_name = representation.get("id").removesuffix("-full")
+        if representation_name == "service-root-json":
+            for collection_link_param in list(representation):
+                collection_name = collection_link_param.get("name")
+                if (
+                    collection_name.removesuffix("_collection_link")
+                    not in keep_collections
+                ):
+                    representation.remove(collection_link_param)
+        elif representation_name in keep_entries:
+            for param in representation.findall(
+                "wadl:param", namespaces={"wadl": wadl_namespace}
+            ):
+                if param.get("name") in {
+                    "http_etag",
+                    "resource_type_link",
+                    "self_link",
+                    "web_link",
+                }:
+                    continue
+                elif (
+                    param.get("name") not in keep_entries[representation_name]
+                ):
+                    representation.remove(param)
+        elif (
+            representation_name.endswith("-page")
+            and representation_name.removesuffix("-page") in keep_entries
+        ):
+            continue
+        else:
+            parsed.remove(representation)
+
+    return parsed
+
+
+def write_wadl(parsed: ElementTree.Element, path: Path) -> None:
+    ElementTree.ElementTree(parsed).write(path, xml_declaration=True)
+
+
+write_wadl(
+    reduce_wadl(download_wadl()), Path(__file__).parent / "launchpad-wadl.xml"
+)
diff --git a/lpcraft/commands/tests/launchpad-wadl.xml b/lpcraft/commands/tests/launchpad-wadl.xml
new file mode 100644
index 0000000..145b807
--- /dev/null
+++ b/lpcraft/commands/tests/launchpad-wadl.xml
@@ -0,0 +1,680 @@
+<?xml version='1.0' encoding='us-ascii'?>
+<wadl:application xmlns:html="http://www.w3.org/1999/xhtml"; xmlns:wadl="http://research.sun.com/wadl/2006/10"; xmlns:xs="http://www.w3.org/2001/XMLSchema"; xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"; xsi:schemaLocation="http://research.sun.com/wadl/2006/10/wadl.xsd";>
+
+  <wadl:doc title="About this service">The Launchpad web service allows automated
+        clients to access most of the functionality available on the
+        Launchpad web site. For help getting started, see
+        <html:a href="https://help.launchpad.net/API/";>the help wiki.</html:a></wadl:doc>
+
+  <wadl:doc title="About version devel">This version of the web service reflects the most
+        recent changes made. It may abruptly change without
+        warning. Periodically, these changes are bundled up and given a
+        permanent version number.</wadl:doc>
+
+  
+  <wadl:resources base="https://api.launchpad.net/devel/";>
+    <wadl:resource path="" type="#service-root" />
+  </wadl:resources>
+
+  
+  <wadl:resource_type id="service-root">
+    <wadl:doc>The root of the web service.</wadl:doc>
+    <wadl:method name="GET" id="service-root-get">
+      <wadl:response>
+        <wadl:representation href="#service-root-json" />
+        <wadl:representation mediaType="application/vnd.sun.wadl+xml" id="service-root-wadl" />
+        </wadl:response>
+      </wadl:method>
+  </wadl:resource_type>
+
+  
+  <wadl:representation mediaType="application/json" id="service-root-json">
+    
+      <wadl:param style="plain" name="archives_collection_link" path="$['archives_collection_link']">
+        <wadl:link resource_type="https://api.launchpad.net/devel/#archives"; />
+      </wadl:param>
+    
+    
+      <wadl:param style="plain" name="git_repositories_collection_link" path="$['git_repositories_collection_link']">
+        <wadl:link resource_type="https://api.launchpad.net/devel/#git_repositories"; />
+      </wadl:param>
+    
+    
+      </wadl:representation>
+
+  
+
+  
+  
+    <wadl:resource_type id="archives">
+      <wadl:doc>
+Interface for ArchiveSet
+</wadl:doc>
+      <wadl:method id="archives-getByReference" name="GET">
+        <wadl:doc>
+Return the IArchive with the given archive reference.
+</wadl:doc>
+        <wadl:request>
+          
+            <wadl:param style="query" name="ws.op" required="true" fixed="getByReference">
+              <wadl:doc>The name of the operation being invoked.</wadl:doc>
+            </wadl:param>
+            <wadl:param style="query" name="reference" required="true">
+             <wadl:doc>
+Archive reference string
+</wadl:doc>
+              
+            </wadl:param>
+          
+        </wadl:request>
+        <wadl:response>
+          
+          <wadl:representation href="https://api.launchpad.net/devel/#archive-full"; />
+        </wadl:response>
+      </wadl:method>
+    </wadl:resource_type>
+
+  
+  
+    <wadl:resource_type id="git_repositories">
+      <wadl:doc>
+Interface representing the set of Git repositories.
+</wadl:doc>
+      <wadl:method id="git_repositories-getByPath" name="GET">
+        <wadl:doc>
+<html:p>Find a repository by its path.</html:p>
+<html:p>Any of these forms may be used:</html:p>
+<html:pre class="rst-literal-block">
+Unique names:
+    ~OWNER/PROJECT/+git/NAME
+    ~OWNER/DISTRO/+source/SOURCE/+git/NAME
+    ~OWNER/+git/NAME
+Owner-target default aliases:
+    ~OWNER/PROJECT
+    ~OWNER/DISTRO/+source/SOURCE
+Official aliases:
+    PROJECT
+    DISTRO/+source/SOURCE
+</html:pre>
+<html:p>Return None if no match was found.</html:p>
+
+</wadl:doc>
+        <wadl:request>
+          
+            <wadl:param style="query" name="ws.op" required="true" fixed="getByPath">
+              <wadl:doc>The name of the operation being invoked.</wadl:doc>
+            </wadl:param>
+            <wadl:param style="query" name="path" required="true">
+             <wadl:doc>
+Repository path
+</wadl:doc>
+              
+            </wadl:param>
+          
+        </wadl:request>
+        <wadl:response>
+          
+          <wadl:representation href="https://api.launchpad.net/devel/#git_repository-full"; />
+        </wadl:response>
+      </wadl:method>
+      </wadl:resource_type>
+
+  
+  
+    <wadl:resource_type id="archive">
+      <wadl:doc>
+Main Archive interface.
+</wadl:doc>
+      <wadl:method id="archive-uploadCIBuild" name="POST">
+        <wadl:doc>
+Upload the output of a CI build to this archive.
+</wadl:doc>
+        <wadl:request>
+          <wadl:representation mediaType="application/x-www-form-urlencoded">
+            <wadl:param style="query" name="ws.op" required="true" fixed="uploadCIBuild" />
+            <wadl:param style="query" name="to_pocket" required="true">
+              <wadl:doc>
+Target pocket name
+</wadl:doc>
+              
+            </wadl:param>
+            <wadl:param style="query" name="ci_build" required="true">
+              
+              <wadl:link resource_type="https://api.launchpad.net/devel/#ci_build"; />
+            </wadl:param>
+            <wadl:param style="query" name="to_channel" required="false">
+              <wadl:doc>
+Target channel
+</wadl:doc>
+              
+            </wadl:param>
+            <wadl:param style="query" name="to_series" required="true">
+              <wadl:doc>
+Target distroseries name
+</wadl:doc>
+              
+            </wadl:param>
+          </wadl:representation>
+        </wadl:request>
+        
+      </wadl:method>
+      </wadl:resource_type>
+
+
+    <wadl:representation mediaType="application/json" id="archive-full">
+      <wadl:param style="plain" name="self_link" path="$['self_link']">
+        <wadl:doc>The canonical link to this resource.</wadl:doc>
+        <wadl:link resource_type="https://api.launchpad.net/devel/#archive"; />
+      </wadl:param>
+      <wadl:param style="plain" name="web_link" path="$['web_link']">
+        <wadl:doc>
+          The canonical human-addressable web link to this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:doc>
+          The link to the WADL description of this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+        <wadl:doc>
+          The value of the HTTP ETag for this resource.
+        </wadl:doc>
+      </wadl:param>
+      </wadl:representation>
+
+    <wadl:resource_type id="archive-page-resource">
+      <wadl:method name="GET" id="archive-page-resource-get">
+        <wadl:response>
+          <wadl:representation href="#archive-page" />
+        </wadl:response>
+      </wadl:method>
+    </wadl:resource_type>
+
+    <wadl:representation mediaType="application/json" id="archive-page">
+
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:link />
+      </wadl:param>
+
+      
+
+      <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+      <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+        <wadl:link resource_type="#ScalarValue" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+      <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+        <wadl:link resource_type="#archive-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+        <wadl:link resource_type="#archive-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+      <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+        <wadl:link resource_type="https://api.launchpad.net/devel/#archive"; />
+      </wadl:param>
+  </wadl:representation>
+
+  
+  
+    <wadl:resource_type id="ci_build">
+      <wadl:doc>
+A build record for a pipeline of CI jobs.
+</wadl:doc>
+      <wadl:method name="GET" id="ci_build-get">
+        <wadl:response>
+          <wadl:representation href="https://api.launchpad.net/devel/#ci_build-full"; />
+          <wadl:representation mediaType="application/xhtml+xml" id="ci_build-xhtml" />
+          <wadl:representation mediaType="application/vnd.sun.wadl+xml" id="ci_build-wadl" />
+        </wadl:response>
+      </wadl:method>
+
+      </wadl:resource_type>
+
+
+    <wadl:representation mediaType="application/json" id="ci_build-full">
+      <wadl:param style="plain" name="self_link" path="$['self_link']">
+        <wadl:doc>The canonical link to this resource.</wadl:doc>
+        <wadl:link resource_type="https://api.launchpad.net/devel/#ci_build"; />
+      </wadl:param>
+      <wadl:param style="plain" name="web_link" path="$['web_link']">
+        <wadl:doc>
+          The canonical human-addressable web link to this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:doc>
+          The link to the WADL description of this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+        <wadl:doc>
+          The value of the HTTP ETag for this resource.
+        </wadl:doc>
+      </wadl:param>
+      <wadl:param style="plain" required="true" name="buildstate" path="$['buildstate']">
+        <wadl:doc>
+<html:p>Status</html:p>
+<html:p>The current status of the job.</html:p>
+
+</wadl:doc>
+        
+        <wadl:option value="Needs building" />
+        <wadl:option value="Successfully built" />
+        <wadl:option value="Failed to build" />
+        <wadl:option value="Dependency wait" />
+        <wadl:option value="Chroot problem" />
+        <wadl:option value="Build for superseded Source" />
+        <wadl:option value="Currently building" />
+        <wadl:option value="Failed to upload" />
+        <wadl:option value="Uploading build" />
+        <wadl:option value="Cancelling build" />
+        <wadl:option value="Cancelled build" />
+      </wadl:param>
+      <wadl:param style="plain" required="true" name="datebuilt" path="$['datebuilt']" type="xsd:dateTime">
+        <wadl:doc>
+<html:p>Date finished</html:p>
+<html:p>The timestamp when the build farm job was finished.</html:p>
+
+</wadl:doc>
+        
+      </wadl:param>
+      </wadl:representation>
+
+    <wadl:resource_type id="ci_build-page-resource">
+      <wadl:method name="GET" id="ci_build-page-resource-get">
+        <wadl:response>
+          <wadl:representation href="#ci_build-page" />
+        </wadl:response>
+      </wadl:method>
+    </wadl:resource_type>
+
+    <wadl:representation mediaType="application/json" id="ci_build-page">
+
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:link />
+      </wadl:param>
+
+      
+
+      <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+      <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+        <wadl:link resource_type="#ScalarValue" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+      <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+        <wadl:link resource_type="#ci_build-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+        <wadl:link resource_type="#ci_build-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+      <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+        <wadl:link resource_type="https://api.launchpad.net/devel/#ci_build"; />
+      </wadl:param>
+  </wadl:representation>
+
+  
+  
+    <wadl:resource_type id="git_ref">
+      <wadl:doc>
+A reference in a Git repository.
+</wadl:doc>
+      <wadl:method name="GET" id="git_ref-get">
+        <wadl:response>
+          <wadl:representation href="https://api.launchpad.net/devel/#git_ref-full"; />
+          <wadl:representation mediaType="application/xhtml+xml" id="git_ref-xhtml" />
+          <wadl:representation mediaType="application/vnd.sun.wadl+xml" id="git_ref-wadl" />
+        </wadl:response>
+      </wadl:method>
+
+      </wadl:resource_type>
+
+
+    <wadl:representation mediaType="application/json" id="git_ref-full">
+      <wadl:param style="plain" name="self_link" path="$['self_link']">
+        <wadl:doc>The canonical link to this resource.</wadl:doc>
+        <wadl:link resource_type="https://api.launchpad.net/devel/#git_ref"; />
+      </wadl:param>
+      <wadl:param style="plain" name="web_link" path="$['web_link']">
+        <wadl:doc>
+          The canonical human-addressable web link to this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:doc>
+          The link to the WADL description of this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+        <wadl:doc>
+          The value of the HTTP ETag for this resource.
+        </wadl:doc>
+      </wadl:param>
+      <wadl:param style="plain" required="true" name="commit_sha1" path="$['commit_sha1']">
+        <wadl:doc>
+<html:p>Commit SHA-1</html:p>
+<html:p>The full SHA-1 object name of the commit object referenced by this reference.</html:p>
+
+</wadl:doc>
+        
+      </wadl:param>
+      </wadl:representation>
+
+    <wadl:resource_type id="git_ref-page-resource">
+      <wadl:method name="GET" id="git_ref-page-resource-get">
+        <wadl:response>
+          <wadl:representation href="#git_ref-page" />
+        </wadl:response>
+      </wadl:method>
+    </wadl:resource_type>
+
+    <wadl:representation mediaType="application/json" id="git_ref-page">
+
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:link />
+      </wadl:param>
+
+      
+
+      <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+      <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+        <wadl:link resource_type="#ScalarValue" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+      <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+        <wadl:link resource_type="#git_ref-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+        <wadl:link resource_type="#git_ref-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+      <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+        <wadl:link resource_type="https://api.launchpad.net/devel/#git_ref"; />
+      </wadl:param>
+  </wadl:representation>
+
+  
+  
+    <wadl:resource_type id="git_repository">
+      <wadl:doc>
+A Git repository.
+</wadl:doc>
+      <wadl:method id="git_repository-getRefByPath" name="GET">
+        <wadl:doc>
+<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">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>
+
+</wadl:doc>
+        <wadl:request>
+          
+            <wadl:param style="query" name="ws.op" required="true" fixed="getRefByPath" />
+            <wadl:param style="query" name="path" required="true">
+              <wadl:doc>
+A string to look up as a path.
+</wadl:doc>
+              
+            </wadl:param>
+          
+        </wadl:request>
+        <wadl:response>
+          
+          <wadl:representation href="https://api.launchpad.net/devel/#git_ref-full"; />
+        </wadl:response>
+      </wadl:method>
+      <wadl:method id="git_repository-getStatusReports" name="GET">
+        <wadl:doc>
+<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" 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="getStatusReports" />
+            <wadl:param style="query" name="commit_sha1" required="true">
+              <wadl:doc>
+The Git commit for which this report is built.
+</wadl:doc>
+              
+            </wadl:param>
+          
+        </wadl:request>
+        <wadl:response>
+          
+          <wadl:representation href="https://api.launchpad.net/devel/#revision_status_report-page"; />
+        </wadl:response>
+      </wadl:method>
+      </wadl:resource_type>
+
+
+    <wadl:representation mediaType="application/json" id="git_repository-full">
+      <wadl:param style="plain" name="self_link" path="$['self_link']">
+        <wadl:doc>The canonical link to this resource.</wadl:doc>
+        <wadl:link resource_type="https://api.launchpad.net/devel/#git_repository"; />
+      </wadl:param>
+      <wadl:param style="plain" name="web_link" path="$['web_link']">
+        <wadl:doc>
+          The canonical human-addressable web link to this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:doc>
+          The link to the WADL description of this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+        <wadl:doc>
+          The value of the HTTP ETag for this resource.
+        </wadl:doc>
+      </wadl:param>
+      </wadl:representation>
+
+    <wadl:resource_type id="git_repository-page-resource">
+      <wadl:method name="GET" id="git_repository-page-resource-get">
+        <wadl:response>
+          <wadl:representation href="#git_repository-page" />
+        </wadl:response>
+      </wadl:method>
+    </wadl:resource_type>
+
+    <wadl:representation mediaType="application/json" id="git_repository-page">
+
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:link />
+      </wadl:param>
+
+      
+
+      <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+      <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+        <wadl:link resource_type="#ScalarValue" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+      <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+        <wadl:link resource_type="#git_repository-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+        <wadl:link resource_type="#git_repository-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+      <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+        <wadl:link resource_type="https://api.launchpad.net/devel/#git_repository"; />
+      </wadl:param>
+  </wadl:representation>
+
+  
+  
+    <wadl:resource_type id="revision_status_report">
+      <wadl:doc>
+An revision status report for a Git commit.
+</wadl:doc>
+      <wadl:method name="GET" id="revision_status_report-get">
+        <wadl:response>
+          <wadl:representation href="https://api.launchpad.net/devel/#revision_status_report-full"; />
+          <wadl:representation mediaType="application/xhtml+xml" id="revision_status_report-xhtml" />
+          <wadl:representation mediaType="application/vnd.sun.wadl+xml" id="revision_status_report-wadl" />
+        </wadl:response>
+      </wadl:method>
+
+      <wadl:method id="revision_status_report-getArtifactURLs" name="GET">
+        <wadl:doc>
+<html:p>Retrieves the list of URLs for artifacts that exist for this report.</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" colspan="2">param artifact_type:</html:th></html:tr>
+<html:tr class="rst-field"><html:td>\&#160;</html:td><html:td class="rst-field-body">The type of artifact 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="getArtifactURLs" />
+            <wadl:param style="query" name="artifact_type" required="false">
+              
+              
+              <wadl:option value="Log" />
+              <wadl:option value="Binary" />
+            </wadl:param>
+          
+        </wadl:request>
+        
+      </wadl:method>
+      </wadl:resource_type>
+
+
+    <wadl:representation mediaType="application/json" id="revision_status_report-full">
+      <wadl:param style="plain" name="self_link" path="$['self_link']">
+        <wadl:doc>The canonical link to this resource.</wadl:doc>
+        <wadl:link resource_type="https://api.launchpad.net/devel/#revision_status_report"; />
+      </wadl:param>
+      <wadl:param style="plain" name="web_link" path="$['web_link']">
+        <wadl:doc>
+          The canonical human-addressable web link to this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:doc>
+          The link to the WADL description of this resource.
+        </wadl:doc>
+        <wadl:link />
+      </wadl:param>
+      <wadl:param style="plain" name="http_etag" path="$['http_etag']">
+        <wadl:doc>
+          The value of the HTTP ETag for this resource.
+        </wadl:doc>
+      </wadl:param>
+      <wadl:param style="plain" required="true" name="ci_build_link" path="$['ci_build_link']">
+        <wadl:doc>
+The CI build that produced this report.
+</wadl:doc>
+        <wadl:link resource_type="https://api.launchpad.net/devel/#ci_build"; />
+      </wadl:param>
+      </wadl:representation>
+
+    <wadl:resource_type id="revision_status_report-page-resource">
+      <wadl:method name="GET" id="revision_status_report-page-resource-get">
+        <wadl:response>
+          <wadl:representation href="#revision_status_report-page" />
+        </wadl:response>
+      </wadl:method>
+    </wadl:resource_type>
+
+    <wadl:representation mediaType="application/json" id="revision_status_report-page">
+
+      <wadl:param style="plain" name="resource_type_link" path="$['resource_type_link']">
+        <wadl:link />
+      </wadl:param>
+
+      
+
+      <wadl:param style="plain" name="total_size" path="$['total_size']" required="false" />
+
+      <wadl:param style="plain" name="total_size_link" path="$['total_size_link']" required="false">
+        <wadl:link resource_type="#ScalarValue" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="start" path="$['start']" required="true" />
+
+      <wadl:param style="plain" name="next_collection_link" path="$['next_collection_link']">
+        <wadl:link resource_type="#revision_status_report-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="prev_collection_link" path="$['prev_collection_link']">
+        <wadl:link resource_type="#revision_status_report-page-resource" />
+      </wadl:param>
+
+      <wadl:param style="plain" name="entries" path="$['entries']" required="true" />
+
+      <wadl:param style="plain" name="entry_links" path="$['entries'][*]['self_link']">
+        <wadl:link resource_type="https://api.launchpad.net/devel/#revision_status_report"; />
+      </wadl:param>
+  </wadl:representation>
+
+  
+  
+    <xs:simpleType name="binary">
+   <xs:list itemType="byte" />
+  </xs:simpleType>
+
+</wadl:application>
\ No newline at end of file
diff --git a/lpcraft/commands/tests/test_release.py b/lpcraft/commands/tests/test_release.py
new file mode 100644
index 0000000..c854f02
--- /dev/null
+++ b/lpcraft/commands/tests/test_release.py
@@ -0,0 +1,477 @@
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import io
+from datetime import datetime
+from pathlib import Path
+
+from fixtures import MockPatchObject
+from launchpadlib.launchpad import Launchpad
+from launchpadlib.testing.launchpad import FakeLaunchpad
+from systemfixtures import FakeProcesses
+from testtools.matchers import Equals, MatchesListwise, MatchesStructure
+from wadllib.application import Application
+
+from lpcraft.commands.tests import CommandBaseTestCase
+from lpcraft.errors import CommandError
+
+
+class TestRelease(CommandBaseTestCase):
+    def setUp(self):
+        super().setUp()
+        self.uploads = []
+
+    def set_up_local_branch(
+        self, branch_name: str, remote_name: str, url: str
+    ):
+        def fake_git(args):
+            if args["args"][1] == "branch":
+                return {"stdout": io.StringIO(f"{branch_name}\n")}
+            elif args["args"][1] == "config":
+                return {"stdout": io.StringIO(f"{remote_name}\n")}
+            elif args["args"][1] == "remote":
+                return {"stdout": io.StringIO(f"{url}\n")}
+            else:  # pragma: no cover
+                return {"returncode": 1}
+
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(fake_git, name="git")
+        return processes_fixture
+
+    def make_fake_launchpad(self) -> FakeLaunchpad:
+        lp = FakeLaunchpad(
+            application=Application(
+                "https://api.launchpad.net/devel/";,
+                (Path(__file__).parent / "launchpad-wadl.xml").read_bytes(),
+            )
+        )
+        self.useFixture(
+            MockPatchObject(
+                Launchpad, "login_with", lambda *args, **kwargs: lp
+            )
+        )
+        return lp
+
+    def fake_upload(self, ci_build, to_series, to_pocket, to_channel):
+        self.uploads.append((ci_build, to_series, to_pocket, to_channel))
+
+    def test_no_repository_argument_and_no_remote_branch(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.StringIO("\n")}, name="git"
+        )
+
+        result = self.run_command(
+            "release", "ppa:owner/ubuntu/name", "focal", "edge"
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError(
+                        "No --repository option was given, and the current "
+                        "branch does not track a remote branch."
+                    )
+                ],
+            ),
+        )
+
+    def test_no_repository_argument_and_remote_branch_on_bad_host(self):
+        self.set_up_local_branch(
+            "feature", "origin", "git+ssh://git.example.com/"
+        )
+
+        result = self.run_command(
+            "release", "ppa:owner/ubuntu/name", "focal", "edge"
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError(
+                        "No --repository option was given, and the current "
+                        "branch does not track a remote branch on "
+                        "git.launchpad.net."
+                    )
+                ],
+            ),
+        )
+
+    def test_no_commit_argument_and_no_current_branch(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.StringIO("\n")}, name="git"
+        )
+
+        result = self.run_command(
+            "release",
+            "--repository",
+            "example",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError(
+                        "No --commit option was given, and there is no "
+                        "current branch."
+                    )
+                ],
+            ),
+        )
+
+    def test_repository_does_not_exist(self):
+        lp = self.make_fake_launchpad()
+        lp.git_repositories = {"getByPath": lambda path: None}
+
+        result = self.run_command(
+            "release",
+            "--repository",
+            "example",
+            "--commit",
+            "missing",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError(
+                        "Repository example does not exist on Launchpad."
+                    )
+                ],
+            ),
+        )
+
+    def test_no_branch_or_tag(self):
+        lp = self.make_fake_launchpad()
+        lp.git_repositories = {
+            "getByPath": lambda path: {"getRefByPath": lambda path: None}
+        }
+
+        result = self.run_command(
+            "release",
+            "--repository",
+            "example",
+            "--commit",
+            "missing",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError("example has no branch or tag named missing.")
+                ],
+            ),
+        )
+
+    def test_no_completed_ci_builds(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": {"buildstate": "Needs building"}}]
+                },
+            }
+        }
+
+        result = self.run_command(
+            "release",
+            "--repository",
+            "example",
+            "--commit",
+            "branch",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError(
+                        f"example:{commit_sha1} has no completed CI builds "
+                        f"with attached files."
+                    )
+                ],
+            ),
+        )
+
+    def test_no_ci_builds_with_artifacts(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": {"buildstate": "Successfully built"},
+                            "getArtifactURLs": lambda artifact_type: [],
+                        }
+                    ]
+                },
+            }
+        }
+
+        result = self.run_command(
+            "release",
+            "--repository",
+            "example",
+            "--commit",
+            "branch",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=1,
+                errors=[
+                    CommandError(
+                        f"example:{commit_sha1} has no completed CI builds "
+                        f"with attached files."
+                    )
+                ],
+            ),
+        )
+
+    def test_dry_run(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": {
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        }
+                    ]
+                },
+            }
+        }
+        lp.archives = {
+            "getByReference": lambda reference: {
+                "uploadCIBuild": self.fake_upload
+            }
+        }
+
+        result = self.run_command(
+            "release",
+            "--dry-run",
+            "--repository",
+            "example",
+            "--commit",
+            "branch",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=0,
+                messages=[
+                    f"Would release build of example:{commit_sha1} to "
+                    f"ppa:owner/ubuntu/name focal edge."
+                ],
+            ),
+        )
+        self.assertEqual([], self.uploads)
+
+    def test_release(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": {
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                        {
+                            "ci_build": {
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 12, 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 build of example:{commit_sha1} to "
+                    f"ppa:owner/ubuntu/name focal edge."
+                ],
+            ),
+        )
+        [upload] = self.uploads
+        self.assertThat(
+            upload,
+            MatchesListwise(
+                [
+                    MatchesStructure(
+                        datebuilt=Equals(datetime(2022, 1, 1, 12, 0, 0))
+                    ),
+                    Equals("focal"),
+                    Equals("Release"),
+                    Equals("edge"),
+                ]
+            ),
+        )
+
+    def test_release_with_commit_id(self):
+        lp = self.make_fake_launchpad()
+        commit_sha1 = "1" * 40
+        lp.git_repositories = {
+            "getByPath": lambda path: {
+                "getStatusReports": lambda commit_sha1: {
+                    "entries": [
+                        {
+                            "ci_build": {
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                        {
+                            "ci_build": {
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 12, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        },
+                    ]
+                },
+            }
+        }
+        lp.archives = {
+            "getByReference": lambda reference: {
+                "uploadCIBuild": self.fake_upload
+            }
+        }
+
+        result = self.run_command(
+            "release",
+            "--repository",
+            "example",
+            "--commit",
+            commit_sha1,
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=0,
+                messages=[
+                    f"Released build of example:{commit_sha1} to "
+                    f"ppa:owner/ubuntu/name focal edge."
+                ],
+            ),
+        )
+
+    def test_release_from_current_branch(self):
+        self.set_up_local_branch(
+            "feature", "origin", "git+ssh://git.launchpad.net/example"
+        )
+        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": {
+                                "buildstate": "Successfully built",
+                                "datebuilt": datetime(2022, 1, 1, 0, 0, 0),
+                            },
+                            "getArtifactURLs": lambda artifact_type: ["url"],
+                        }
+                    ]
+                },
+            }
+        }
+        lp.archives = {
+            "getByReference": lambda reference: {
+                "uploadCIBuild": self.fake_upload
+            }
+        }
+
+        result = self.run_command(
+            "release",
+            "ppa:owner/ubuntu/name",
+            "focal",
+            "edge",
+        )
+
+        self.assertThat(
+            result,
+            MatchesStructure.byEquality(
+                exit_code=0,
+                messages=[
+                    f"Released build of example:{commit_sha1} to "
+                    f"ppa:owner/ubuntu/name focal edge."
+                ],
+            ),
+        )
diff --git a/lpcraft/git.py b/lpcraft/git.py
new file mode 100644
index 0000000..21f1f07
--- /dev/null
+++ b/lpcraft/git.py
@@ -0,0 +1,50 @@
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+__all__ = [
+    "get_current_branch",
+    "get_current_remote_url",
+]
+
+import subprocess
+from typing import Optional
+
+
+def get_current_branch() -> Optional[str]:
+    """Return the current Git branch name.
+
+    If there is no current branch (e.g. after `git checkout --detach`), then
+    return None.
+    """
+    current_branch = subprocess.run(
+        ["git", "branch", "--show-current"],
+        capture_output=True,
+        check=True,
+        text=True,
+    ).stdout.rstrip("\n")
+    if current_branch:
+        return current_branch
+
+
+def get_current_remote_url() -> Optional[str]:
+    """Return the remote URL for the current Git branch.
+
+    If there is no current branch, or if the current branch is not a
+    remote-tracking branch, then return None.
+    """
+    current_branch = get_current_branch()
+    if current_branch is None:
+        return None
+    current_remote = subprocess.run(
+        ["git", "config", f"branch.{current_branch}.remote"],
+        capture_output=True,
+        check=True,
+        text=True,
+    ).stdout.rstrip("\n")
+    if current_remote:
+        return subprocess.run(
+            ["git", "remote", "get-url", current_remote],
+            capture_output=True,
+            check=True,
+            text=True,
+        ).stdout.rstrip("\n")
diff --git a/lpcraft/main.py b/lpcraft/main.py
index fd08566..6b27e37 100644
--- a/lpcraft/main.py
+++ b/lpcraft/main.py
@@ -20,6 +20,7 @@ from craft_cli import (
 
 from lpcraft._version import version_description as lpcraft_version
 from lpcraft.commands.clean import CleanCommand
+from lpcraft.commands.release import ReleaseCommand
 from lpcraft.commands.run import RunCommand, RunOneCommand
 from lpcraft.commands.version import VersionCommand
 
@@ -43,6 +44,9 @@ _basic_commands = [
     RunOneCommand,
     VersionCommand,
 ]
+_launchpad_commands = [
+    ReleaseCommand,
+]
 
 
 def main(argv: Optional[List[str]] = None) -> int:
@@ -51,7 +55,10 @@ def main(argv: Optional[List[str]] = None) -> int:
         argv = sys.argv[1:]
 
     emit.init(EmitterMode.BRIEF, "lpcraft", f"Starting {lpcraft_version}")
-    command_groups = [CommandGroup("Basic", _basic_commands)]
+    command_groups = [
+        CommandGroup("Basic", _basic_commands),
+        CommandGroup("Launchpad", _launchpad_commands),
+    ]
     summary = "Run Launchpad CI jobs."
     extra_global_args = [
         GlobalArgument(
diff --git a/lpcraft/tests/test_git.py b/lpcraft/tests/test_git.py
new file mode 100644
index 0000000..f2b47e7
--- /dev/null
+++ b/lpcraft/tests/test_git.py
@@ -0,0 +1,99 @@
+# Copyright 2023 Canonical Ltd.  This software is licensed under the
+# GNU General Public License version 3 (see the file LICENSE).
+
+import io
+
+from systemfixtures import FakeProcesses
+from testtools import TestCase
+
+from lpcraft.git import get_current_branch, get_current_remote_url
+
+
+class TestGetCurrentBranch(TestCase):
+    def test_has_no_current_branch(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.StringIO("\n")}, name="git"
+        )
+
+        self.assertIsNone(get_current_branch())
+
+        self.assertEqual(
+            [["git", "branch", "--show-current"]],
+            [proc._args["args"] for proc in processes_fixture.procs],
+        )
+
+    def test_has_current_branch(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.StringIO("feature\n")}, name="git"
+        )
+
+        self.assertEqual("feature", get_current_branch())
+
+        self.assertEqual(
+            [["git", "branch", "--show-current"]],
+            [proc._args["args"] for proc in processes_fixture.procs],
+        )
+
+
+class TestGetCurrentRemoteURL(TestCase):
+    def test_has_no_current_branch(self):
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(
+            lambda _: {"stdout": io.StringIO("\n")}, name="git"
+        )
+
+        self.assertIsNone(get_current_remote_url())
+
+        self.assertEqual(
+            [["git", "branch", "--show-current"]],
+            [proc._args["args"] for proc in processes_fixture.procs],
+        )
+
+    def test_has_no_current_remote(self):
+        def fake_git(args):
+            if args["args"][1] == "branch":
+                return {"stdout": io.StringIO("feature\n")}
+            elif args["args"][1] == "config":
+                return {"stdout": io.StringIO("\n")}
+            else:  # pragma: no cover
+                return {"returncode": 1}
+
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(fake_git, name="git")
+
+        self.assertIsNone(get_current_remote_url())
+        self.assertEqual(
+            [
+                ["git", "branch", "--show-current"],
+                ["git", "config", "branch.feature.remote"],
+            ],
+            [proc._args["args"] for proc in processes_fixture.procs],
+        )
+
+    def test_has_current_remote(self):
+        def fake_git(args):
+            if args["args"][1] == "branch":
+                return {"stdout": io.StringIO("feature\n")}
+            elif args["args"][1] == "config":
+                return {"stdout": io.StringIO("origin\n")}
+            elif args["args"][1] == "remote":
+                return {"stdout": io.StringIO("git+ssh://git.example.com/\n")}
+            else:  # pragma: no cover
+                return {"returncode": 1}
+
+        processes_fixture = self.useFixture(FakeProcesses())
+        processes_fixture.add(fake_git, name="git")
+
+        self.assertEqual(
+            "git+ssh://git.example.com/", get_current_remote_url()
+        )
+        self.assertEqual(
+            [
+                ["git", "branch", "--show-current"],
+                ["git", "config", "branch.feature.remote"],
+                ["git", "remote", "get-url", "origin"],
+            ],
+            [proc._args["args"] for proc in processes_fixture.procs],
+        )
diff --git a/requirements.in b/requirements.in
index 7a73bd5..216669a 100644
--- a/requirements.in
+++ b/requirements.in
@@ -1,5 +1,6 @@
 craft-cli
 craft-providers
+launchpadlib
 pydantic
 PyYAML
 python-dotenv
diff --git a/requirements.txt b/requirements.txt
index 32ab7f2..301ed96 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -6,23 +6,61 @@
 #
 certifi==2022.12.7
     # via requests
+cffi==1.15.1
+    # via cryptography
 charset-normalizer==2.1.1
     # via requests
 craft-cli==1.2.0
     # via -r requirements.in
 craft-providers==1.6.2
     # via -r requirements.in
+cryptography==39.0.0
+    # via secretstorage
+distro==1.8.0
+    # via lazr-restfulclient
+httplib2==0.21.0
+    # via
+    #   launchpadlib
+    #   lazr-restfulclient
 idna==3.4
     # via requests
-platformdirs==2.6.0
+importlib-metadata==6.0.0
+    # via keyring
+importlib-resources==5.10.2
+    # via keyring
+jaraco-classes==3.2.3
+    # via keyring
+jeepney==0.8.0
+    # via
+    #   keyring
+    #   secretstorage
+keyring==23.13.1
+    # via launchpadlib
+launchpadlib==1.10.18
+    # via -r requirements.in
+lazr-restfulclient==0.14.5
+    # via launchpadlib
+lazr-uri==1.0.6
+    # via
+    #   launchpadlib
+    #   wadllib
+more-itertools==9.0.0
+    # via jaraco-classes
+oauthlib==3.2.2
+    # via lazr-restfulclient
+platformdirs==2.6.2
     # via craft-cli
 pluggy==1.0.0
     # via -r requirements.in
-pydantic==1.10.2
+pycparser==2.21
+    # via cffi
+pydantic==1.10.4
     # via
     #   -r requirements.in
     #   craft-cli
     #   craft-providers
+pyparsing==3.0.9
+    # via httplib2
 python-dotenv==0.21.0
     # via -r requirements.in
 pyyaml==6.0
@@ -34,7 +72,22 @@ requests==2.28.1
     # via requests-unixsocket
 requests-unixsocket==0.3.0
     # via craft-providers
+secretstorage==3.3.3
+    # via keyring
+six==1.16.0
+    # via
+    #   launchpadlib
+    #   lazr-restfulclient
 typing-extensions==4.4.0
     # via pydantic
 urllib3==1.26.13
     # via requests
+wadllib==1.3.6
+    # via lazr-restfulclient
+zipp==3.11.0
+    # via
+    #   importlib-metadata
+    #   importlib-resources
+
+# The following packages are considered to be unsafe in a requirements file:
+# setuptools
diff --git a/setup.cfg b/setup.cfg
index d115049..ad08580 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,6 +1,6 @@
 [metadata]
 name = lpcraft
-version = 0.0.40.dev0
+version = 0.1.0.dev0
 description = Runner for Launchpad CI jobs
 long_description = file: README.rst
 long_description_content_type = text/x-rst
@@ -26,6 +26,7 @@ install_requires =
     craft-cli
     craft-providers
     jinja2
+    launchpadlib
     pluggy
     pydantic
     python-dotenv
@@ -41,11 +42,13 @@ docs =
 test =
     coverage
     fixtures
+    launchpadlib[testing]
     pdbpp
     pytest
     responses
     systemfixtures
     testtools
+    wadllib
 
 [isort]
 known_first_party = lpcraft
diff --git a/snap/snapcraft.yaml b/snap/snapcraft.yaml
index f1fa9af..7cf5af0 100644
--- a/snap/snapcraft.yaml
+++ b/snap/snapcraft.yaml
@@ -24,6 +24,9 @@ apps:
       # help git find its stuff
       GIT_TEMPLATE_DIR: $SNAP/git/templates
       GIT_EXEC_PATH: $SNAP/git/git-core
+    plugs:
+      # Allow launchpadlib access to the desktop keyring.
+      - desktop
 
 grade: stable
 confinement: classic

Follow ups