← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ruinedyourlife/launchpad:native-tools-publishing into launchpad:master

 

Quentin Debhi has proposed merging ~ruinedyourlife/launchpad:native-tools-publishing into launchpad:master.

Commit message:
Publish maven and cargo artifacts with native tooling for craft builds

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ruinedyourlife/launchpad/+git/launchpad/+merge/483691
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ruinedyourlife/launchpad:native-tools-publishing into launchpad:master.
diff --git a/lib/lp/crafts/configure.zcml b/lib/lp/crafts/configure.zcml
index a13bd3f..db94bff 100644
--- a/lib/lp/crafts/configure.zcml
+++ b/lib/lp/crafts/configure.zcml
@@ -63,6 +63,11 @@
             interface="lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuildAdmin" />
     </class>
 
+    <subscriber
+        for="lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuild
+            lp.crafts.interfaces.craftrecipebuild.ICraftRecipeBuildStatusChangedEvent"
+        handler="lp.crafts.subscribers.craftrecipebuild.craft_build_status_changed" />
+
     <!-- CraftRecipeBuildSet -->
     <lp:securedutility
         class="lp.crafts.model.craftrecipebuild.CraftRecipeBuildSet"
diff --git a/lib/lp/crafts/interfaces/craftrecipejob.py b/lib/lp/crafts/interfaces/craftrecipejob.py
index addc6ee..a5689f2 100644
--- a/lib/lp/crafts/interfaces/craftrecipejob.py
+++ b/lib/lp/crafts/interfaces/craftrecipejob.py
@@ -136,3 +136,45 @@ class ICraftRecipeRequestBuildsJobSource(IJobSource):
             recipe and ID, or its `job_type` is not
             `CraftRecipeJobType.REQUEST_BUILDS`.
         """
+
+
+class IRustCrateUploadJob(Interface):
+    """A job that uploads a Rust crate to a registry."""
+
+    build_id = Attribute("The ID of the build to upload.")
+    build = Attribute("The build to upload.")
+    error_message = Attribute("The error message if the upload failed.")
+
+    def create(build):
+        """Create a new RustCrateUploadJob."""
+
+
+class IRustCrateUploadJobSource(IJobSource):
+    """A source for creating and finding RustCrateUploadJobs."""
+
+    def create(build):
+        """Upload a Rust crate build to a registry.
+
+        :param build: The build to upload.
+        """
+
+
+class IMavenArtifactUploadJob(Interface):
+    """A job that uploads a Maven artifact to a repository."""
+
+    build_id = Attribute("The ID of the build to upload.")
+    build = Attribute("The build to upload.")
+    error_message = Attribute("The error message if the upload failed.")
+
+    def create(build):
+        """Create a new MavenArtifactUploadJob."""
+
+
+class IMavenArtifactUploadJobSource(IJobSource):
+    """A source for creating and finding MavenArtifactUploadJobs."""
+
+    def create(build):
+        """Upload a Maven artifact build to a repository.
+
+        :param build: The build to upload.
+        """
diff --git a/lib/lp/crafts/model/craftrecipejob.py b/lib/lp/crafts/model/craftrecipejob.py
index d573665..41be4d8 100644
--- a/lib/lp/crafts/model/craftrecipejob.py
+++ b/lib/lp/crafts/model/craftrecipejob.py
@@ -7,8 +7,18 @@ __all__ = [
     "CraftRecipeJob",
     "CraftRecipeJobType",
     "CraftRecipeRequestBuildsJob",
+    "RustCrateUploadJob",
+    "MavenArtifactUploadJob",
 ]
 
+import json
+import lzma
+import os
+import subprocess
+import tempfile
+from configparser import NoSectionError
+from tarfile import TarFile
+
 import transaction
 from lazr.delegates import delegate_to
 from lazr.enum import DBEnumeratedType, DBItem
@@ -28,8 +38,15 @@ from lp.crafts.interfaces.craftrecipejob import (
     ICraftRecipeJob,
     ICraftRecipeRequestBuildsJob,
     ICraftRecipeRequestBuildsJobSource,
+    IMavenArtifactUploadJob,
+    IMavenArtifactUploadJobSource,
+    IRustCrateUploadJob,
+    IRustCrateUploadJobSource,
 )
 from lp.crafts.model.craftrecipebuild import CraftRecipeBuild
+from lp.registry.interfaces.distributionsourcepackage import (
+    IDistributionSourcePackage,
+)
 from lp.registry.interfaces.person import IPersonSet
 from lp.services.config import config
 from lp.services.database.bulk import load_related
@@ -56,6 +73,24 @@ class CraftRecipeJobType(DBEnumeratedType):
         """,
     )
 
+    RUST_CRATE_UPLOAD = DBItem(
+        1,
+        """
+        Rust crate upload
+
+        This job uploads a Rust crate to a registry.
+        """,
+    )
+
+    MAVEN_ARTIFACT_UPLOAD = DBItem(
+        2,
+        """
+        Maven artifact upload
+
+        This job uploads a Maven artifact to a repository.
+        """,
+    )
+
 
 @implementer(ICraftRecipeJob)
 class CraftRecipeJob(StormBase):
@@ -337,3 +372,419 @@ class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
             # are to this job's metadata and should be preserved.
             transaction.commit()
             raise
+
+
+@implementer(IRustCrateUploadJob)
+@provider(IRustCrateUploadJobSource)
+class RustCrateUploadJob(CraftRecipeJobDerived):
+    """A Job that uploads a Rust crate to a registry."""
+
+    class_job_type = CraftRecipeJobType.RUST_CRATE_UPLOAD
+
+    user_error_types = ()
+    retry_error_types = ()
+    max_retries = 5
+
+    config = config.IRustCrateUploadJobSource
+
+    @classmethod
+    def create(cls, build):
+        """See `IRustCrateUploadJobSource`."""
+        cls.metadata = {
+            "build_id": build.id,
+        }
+        recipe_job = CraftRecipeJob(
+            build.recipe, cls.class_job_type, cls.metadata
+        )
+        job = cls(recipe_job)
+        job.celeryRunOnCommit()
+        IStore(CraftRecipeJob).flush()
+        return job
+
+    @property
+    def build_id(self):
+        """See `IRustCrateUploadJob`."""
+        return self.metadata["build_id"]
+
+    @cachedproperty
+    def build(self):
+        """See `IRustCrateUploadJob`."""
+        return IStore(CraftRecipeBuild).get(CraftRecipeBuild, self.build_id)
+
+    @property
+    def error_message(self):
+        """See `IRustCrateUploadJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `IRustCrateUploadJob`."""
+        self.metadata["error_message"] = message
+
+    def run(self):
+        """See `IRunnableJob`."""
+        try:
+            # Find the archive file in the build
+            archive_file = None
+            for _, lfa, _ in self.build.getFiles():
+                if lfa.filename.endswith(".tar.xz"):
+                    archive_file = lfa
+                    break
+
+            if archive_file is None:
+                # Nothing to do
+                self.error_message = "No archive file found in build"
+                return
+
+            # Get the distribution name to access the correct configuration
+            distribution_name = None
+            if (
+                self.build.recipe.git_repository is not None
+                and IDistributionSourcePackage.providedBy(
+                    self.build.recipe.git_repository.target
+                )
+            ):
+                distribution_name = (
+                    self.build.recipe.git_repository.target.distribution.name
+                )
+
+            if not distribution_name:
+                self.error_message = (
+                    "Could not determine distribution for build"
+                )
+                return
+
+            # Get environment variables from configuration
+            try:
+                env_vars_json = config["craftbuild." + distribution_name][
+                    "environment_variables"
+                ]
+                if env_vars_json and env_vars_json.lower() != "none":
+                    env_vars = json.loads(env_vars_json)
+                    # Replace auth placeholders
+                    for key, value in env_vars.items():
+                        if (
+                            isinstance(value, str)
+                            and "%(write_auth)s" in value
+                        ):
+                            env_vars[key] = value.replace(
+                                "%(write_auth)s",
+                                config.artifactory.write_credentials,
+                            )
+                else:
+                    env_vars = {}
+            except (NoSectionError, KeyError):
+                self.error_message = (
+                    f"No configuration found for {distribution_name}"
+                )
+                return
+
+            # Look for specific Cargo publishing repository configuration
+            cargo_publish_url = env_vars.get("CARGO_PUBLISH_URL")
+            cargo_publish_auth = env_vars.get("CARGO_PUBLISH_AUTH")
+
+            if not cargo_publish_url or not cargo_publish_auth:
+                self.error_message = (
+                    "Missing Cargo publishing repository configuration"
+                )
+                return
+
+            # Download and extract the archive to a temporary location
+            with tempfile.TemporaryDirectory() as tmpdir:
+                archive_path = os.path.join(tmpdir, archive_file.filename)
+                with open(archive_path, "wb") as f:
+                    with archive_file.open() as lfa_file:
+                        f.write(lfa_file.read())
+
+                # Extract the archive
+                extract_dir = os.path.join(tmpdir, "extract")
+                os.makedirs(extract_dir, exist_ok=True)
+                with lzma.open(archive_path) as xz:
+                    with TarFile.open(fileobj=xz) as tar:
+                        tar.extractall(path=extract_dir)
+
+                # Find the crate file in the extracted archive
+                crate_file = None
+                for root, _, files in os.walk(extract_dir):
+                    for filename in files:
+                        if filename.endswith(".crate"):
+                            crate_file = os.path.join(root, filename)
+                            break
+                    if crate_file:
+                        break
+
+                if crate_file is None:
+                    self.error_message = "No .crate file found in archive"
+                    return
+
+                # Set up cargo config
+                cargo_dir = os.path.join(tmpdir, ".cargo")
+                os.makedirs(cargo_dir, exist_ok=True)
+
+                # Create config.toml
+                with open(os.path.join(cargo_dir, "config.toml"), "w") as f:
+                    f.write(
+                        """
+    [registry]
+    global-credential-providers = ["cargo:token"]
+
+    [registries.launchpad]
+    index = "{}"
+    """.format(
+                            cargo_publish_url
+                        )
+                    )
+
+                # Create credentials.toml
+                with open(
+                    os.path.join(cargo_dir, "credentials.toml"), "w"
+                ) as f:
+                    f.write(
+                        """
+    [registries.launchpad]
+    token = "Bearer {}"
+    """.format(
+                            cargo_publish_auth
+                        )
+                    )
+
+                # Run cargo publish
+                result = subprocess.run(
+                    [
+                        "cargo",
+                        "publish",
+                        "--no-verify",
+                        "--allow-dirty",
+                        "--registry",
+                        "launchpad",
+                    ],
+                    cwd=os.path.dirname(crate_file),
+                    env={"CARGO_HOME": cargo_dir},
+                    capture_output=True,
+                    text=True,
+                )
+
+                if result.returncode != 0:
+                    self.error_message = (
+                        f"Failed to publish crate: {result.stderr}"
+                    )
+                    raise Exception(self.error_message)
+
+                # Update metadata to indicate successful upload
+                self.error_message = None
+
+        except Exception as e:
+            self.error_message = str(e)
+            # The normal job infrastructure will abort the transaction, but
+            # we want to commit instead: the only database changes we make
+            # are to this job's metadata and should be preserved.
+            transaction.commit()
+            raise
+
+
+@implementer(IMavenArtifactUploadJob)
+@provider(IMavenArtifactUploadJobSource)
+class MavenArtifactUploadJob(CraftRecipeJobDerived):
+    """A Job that uploads a Maven artifact to a repository."""
+
+    class_job_type = CraftRecipeJobType.MAVEN_ARTIFACT_UPLOAD
+
+    user_error_types = ()
+    retry_error_types = ()
+    max_retries = 5
+
+    config = config.IMavenArtifactUploadJobSource
+
+    @classmethod
+    def create(cls, build):
+        """See `IMavenArtifactUploadJobSource`."""
+        cls.metadata = {
+            "build_id": build.id,
+        }
+        recipe_job = CraftRecipeJob(
+            build.recipe, cls.class_job_type, cls.metadata
+        )
+        job = cls(recipe_job)
+        job.celeryRunOnCommit()
+        IStore(CraftRecipeJob).flush()
+        return job
+
+    @property
+    def build_id(self):
+        """See `IMavenArtifactUploadJob`."""
+        return self.metadata["build_id"]
+
+    @cachedproperty
+    def build(self):
+        """See `IMavenArtifactUploadJob`."""
+        return IStore(CraftRecipeBuild).get(CraftRecipeBuild, self.build_id)
+
+    @property
+    def error_message(self):
+        """See `IMavenArtifactUploadJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `IMavenArtifactUploadJob`."""
+        self.metadata["error_message"] = message
+
+    def run(self):
+        """See `IRunnableJob`."""
+        try:
+            # Find the archive file in the build
+            archive_file = None
+            for _, lfa, _ in self.build.getFiles():
+                if lfa.filename.endswith(".tar.xz"):
+                    archive_file = lfa
+                    break
+
+            if archive_file is None:
+                # Nothing to do
+                self.error_message = "No archive file found in build"
+                return
+
+            # Get the distribution name to access the correct configuration
+            distribution_name = None
+            if (
+                self.build.recipe.git_repository is not None
+                and IDistributionSourcePackage.providedBy(
+                    self.build.recipe.git_repository.target
+                )
+            ):
+                distribution_name = (
+                    self.build.recipe.git_repository.target.distribution.name
+                )
+
+            if not distribution_name:
+                self.error_message = (
+                    "Could not determine distribution for build"
+                )
+                return
+
+            # Get environment variables from configuration
+            try:
+                env_vars_json = config["craftbuild." + distribution_name][
+                    "environment_variables"
+                ]
+                if env_vars_json and env_vars_json.lower() != "none":
+                    env_vars = json.loads(env_vars_json)
+                    # Replace auth placeholders
+                    for key, value in env_vars.items():
+                        if (
+                            isinstance(value, str)
+                            and "%(write_auth)s" in value
+                        ):
+                            env_vars[key] = value.replace(
+                                "%(write_auth)s",
+                                config.artifactory.write_credentials,
+                            )
+                else:
+                    env_vars = {}
+            except (NoSectionError, KeyError):
+                self.error_message = (
+                    f"No configuration found for {distribution_name}"
+                )
+                return
+
+            # Look for specific Maven publishing repository configuration
+            maven_publish_url = env_vars.get("MAVEN_PUBLISH_URL")
+            maven_publish_auth = env_vars.get("MAVEN_PUBLISH_AUTH")
+
+            if not maven_publish_url or not maven_publish_auth:
+                self.error_message = (
+                    "Missing Maven publishing repository configuration"
+                )
+                return
+
+            # Download and extract the archive to a temporary location
+            with tempfile.TemporaryDirectory() as tmpdir:
+                archive_path = os.path.join(tmpdir, archive_file.filename)
+                with open(archive_path, "wb") as f:
+                    with archive_file.open() as lfa_file:
+                        f.write(lfa_file.read())
+
+                # Extract the archive
+                extract_dir = os.path.join(tmpdir, "extract")
+                os.makedirs(extract_dir, exist_ok=True)
+                with lzma.open(archive_path) as xz:
+                    with TarFile.open(fileobj=xz) as tar:
+                        tar.extractall(path=extract_dir)
+
+                # Find the JAR file and pom.xml in the extracted archive
+                jar_file = None
+                pom_file = None
+
+                for root, _, files in os.walk(extract_dir):
+                    for filename in files:
+                        if filename.endswith(".jar"):
+                            jar_file = os.path.join(root, filename)
+                        elif filename == "pom.xml":
+                            pom_file = os.path.join(root, filename)
+                if jar_file is None:
+                    self.error_message = "No .jar file found in archive"
+                    return
+
+                if pom_file is None:
+                    self.error_message = "No pom.xml file found in archive"
+                    return
+
+                # Set up Maven settings
+                maven_dir = os.path.join(tmpdir, ".m2")
+                os.makedirs(maven_dir, exist_ok=True)
+
+                # Extract username and password from auth string
+                if ":" in maven_publish_auth:
+                    username, password = maven_publish_auth.split(":", 1)
+                else:
+                    username = "token"
+                    password = maven_publish_auth
+
+                # Create settings.xml with server configuration for the
+                # publishing repository
+                settings_xml = f"""<settings>
+    <servers>
+        <server>
+            <id>launchpad-publish</id>
+            <username>{username}</username>
+            <password>{password}</password>
+        </server>
+    </servers>
+</settings>"""
+
+                with open(os.path.join(maven_dir, "settings.xml"), "w") as f:
+                    f.write(settings_xml)
+
+                # Run mvn deploy using the pom file
+                result = subprocess.run(
+                    [
+                        "mvn",
+                        "deploy:deploy-file",
+                        f"-DpomFile={pom_file}",
+                        f"-Dfile={jar_file}",
+                        "-DrepositoryId=launchpad-publish",
+                        f"-Durl={maven_publish_url}",
+                        "--settings={}".format(
+                            os.path.join(maven_dir, "settings.xml")
+                        ),
+                    ],
+                    capture_output=True,
+                    text=True,
+                )
+
+                if result.returncode != 0:
+                    self.error_message = (
+                        f"Failed to publish Maven artifact: {result.stderr}"
+                    )
+                    raise Exception(self.error_message)
+
+                # Update metadata to indicate successful upload
+                self.error_message = None
+
+        except Exception as e:
+            self.error_message = str(e)
+            # The normal job infrastructure will abort the transaction, but
+            # we want to commit instead: the only database changes we make
+            # are to this job's metadata and should be preserved.
+            transaction.commit()
+            raise
diff --git a/lib/lp/crafts/subscribers/__init__.py b/lib/lp/crafts/subscribers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/lib/lp/crafts/subscribers/__init__.py
diff --git a/lib/lp/crafts/subscribers/craftrecipebuild.py b/lib/lp/crafts/subscribers/craftrecipebuild.py
new file mode 100644
index 0000000..039d4e4
--- /dev/null
+++ b/lib/lp/crafts/subscribers/craftrecipebuild.py
@@ -0,0 +1,108 @@
+# lib/lp/crafts/subscribers/craftrecipebuild.py
+
+"""Event subscribers for craft recipe builds."""
+
+import lzma
+from configparser import NoSectionError
+from tarfile import TarFile
+
+from zope.component import getUtility
+
+from lp.buildmaster.enums import BuildStatus
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuild
+from lp.crafts.interfaces.craftrecipejob import (
+    IMavenArtifactUploadJobSource,
+    IRustCrateUploadJobSource,
+)
+from lp.registry.interfaces.distributionsourcepackage import (
+    IDistributionSourcePackage,
+)
+from lp.services.config import config
+from lp.services.scripts import log
+from lp.services.webapp.publisher import canonical_url
+from lp.services.webhooks.interfaces import IWebhookSet
+from lp.services.webhooks.payload import compose_webhook_payload
+
+
+def _trigger_craft_build_webhook(build, action):
+    """Trigger a webhook for a craft recipe build event."""
+    payload = {
+        "craft_build": canonical_url(build, force_local_path=True),
+        "action": action,
+    }
+    payload.update(
+        compose_webhook_payload(
+            ICraftRecipeBuild,
+            build,
+            ["recipe", "build_request", "status"],
+        )
+    )
+    getUtility(IWebhookSet).trigger(
+        build.recipe, "craft-recipe:build:0.1", payload
+    )
+
+
+def craft_build_status_changed(build, event):
+    """Trigger events when craft recipe build statuses change."""
+    _trigger_craft_build_webhook(build, "status-changed")
+
+    if build.status == BuildStatus.FULLYBUILT:
+        # Check if this build is from a configured distribution
+        should_publish = False
+        if (
+            build.recipe.git_repository is not None
+            and IDistributionSourcePackage.providedBy(
+                build.recipe.git_repository.target
+            )
+        ):
+            distribution_name = (
+                build.recipe.git_repository.target.distribution.name
+            )
+            try:
+                # Check if there are any config variables for this distribution
+                config["craftbuild." + distribution_name]
+                should_publish = True
+            except NoSectionError:
+                pass
+
+        # Only schedule uploads for configured distribution builds
+        if should_publish and build.recipe.store_upload:
+            # Get the archive file and check its contents
+            for _, lfa, _ in build.getFiles():
+                if lfa.filename.endswith(".tar.xz"):
+                    has_crate, has_jar = check_archive_contents(lfa)
+
+                    if has_crate:
+                        log.info(
+                            "Scheduling upload of Rust crate from %r" % build
+                        )
+                        getUtility(IRustCrateUploadJobSource).create(build)
+
+                    if has_jar:
+                        log.info(
+                            "Scheduling upload of Maven artifact from %r"
+                            % build
+                        )
+                        getUtility(IMavenArtifactUploadJobSource).create(build)
+
+                    break
+
+
+def check_archive_contents(lfa):
+    """Check archive for crates and jars.
+
+    Returns a tuple of (has_crate, has_jar)
+    """
+    has_crate = False
+    has_jar = False
+
+    with lzma.open(lfa.open()) as xz:
+        with TarFile.open(fileobj=xz) as tar:
+            for member in tar.getmembers():
+                if member.name.endswith(".crate"):
+                    has_crate = True
+                    break
+                elif member.name.endswith(".jar"):
+                    has_jar = True
+                    break
+    return has_crate, has_jar
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 1f0a62f..bfcc5d7 100644
--- a/lib/lp/services/config/schema-lazr.conf
+++ b/lib/lp/services/config/schema-lazr.conf
@@ -303,13 +303,33 @@ scan_malware: False
 
 [craftbuild.soss]
 # value is a JSON Object
+# needed for publishing to artifactory
+# For Maven publishing, the following variables are required:
+# - MAVEN_PUBLISH_URL: URL of the Maven repository to publish to
+# - MAVEN_PUBLISH_AUTH: Authentication credentials for the Maven repository
+# 
+# For Cargo publishing, the following variables are required:
+# - CARGO_PUBLISH_URL: URL of the Cargo registry to publish to
+# - CARGO_PUBLISH_AUTH: Authentication token for the Cargo registry
+# - CARGO_REGISTRY_GLOBAL_CREDENTIAL_PROVIDERS: "cargo:token"
+#
+# The following variables can be adjusted to use different Artifactory repositories:
+# - CARGO_ARTIFACTORY1_URL: URL of the Cargo registry to publish to
+# - CARGO_ARTIFACTORY1_READ_AUTH: Authentication credentials for the Cargo registry
+# - MAVEN_ARTIFACTORY1_URL: URL of the Maven repository to publish to
+# - MAVEN_ARTIFACTORY1_READ_AUTH: Authentication credentials for the Maven repository
+#
 # example:
 # environment_variables: {
 #    "CARGO_ARTIFACTORY1_URL": "https://canonical.example.com/artifactory/api/cargo/cargo-upstream/index/";,
 #    "CARGO_ARTIFACTORY1_READ_AUTH": "%(read_auth)s",
+#    "CARGO_PUBLISH_URL": "https://canonical.example.com/artifactory/api/cargo/lws-cargo-testing-local/";,
+#    "CARGO_PUBLISH_AUTH": "%(write_auth)s",
 #    "CARGO_REGISTRY_GLOBAL_CREDENTIAL_PROVIDERS": "cargo:token",
 #    "MAVEN_ARTIFACTORY1_URL": "https://canonical.example.com/artifactory/api/maven/maven-upstream/index";,
 #    "MAVEN_ARTIFACTORY1_READ_AUTH": "%(read_auth)s",
+#    "MAVEN_PUBLISH_URL": "https://canonical.example.com/artifactory/api/maven/lws-maven-testing-local/";,
+#    "MAVEN_PUBLISH_AUTH": "%(write_auth)s"
 # }
 # 
 environment_variables: none 
diff --git a/lib/lp/services/webhooks/interfaces.py b/lib/lp/services/webhooks/interfaces.py
index f281e3d..166c503 100644
--- a/lib/lp/services/webhooks/interfaces.py
+++ b/lib/lp/services/webhooks/interfaces.py
@@ -58,6 +58,7 @@ WEBHOOK_EVENT_TYPES = {
     "merge-proposal:0.1": "Merge proposal",
     "oci-recipe:build:0.1": "OCI recipe build",
     "snap:build:0.1": "Snap build",
+    "craft-recipe:build:0.1": "Craft recipe build",
 }
 
 

Follow ups