← 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..59d8621 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"
@@ -110,4 +115,15 @@
         name="craft-recipe-build">
         <allow interface="lp.services.macaroons.interfaces.IMacaroonIssuerPublic"/>
     </lp:securedutility>
+
+    <!-- CraftPublishingJob -->
+    <lp:securedutility
+        component="lp.crafts.model.craftrecipejob.CraftPublishingJob"
+        provides="lp.crafts.interfaces.craftrecipejob.ICraftPublishingJobSource">
+        <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftPublishingJobSource" />
+    </lp:securedutility>
+    <class class="lp.crafts.model.craftrecipejob.CraftPublishingJob">
+        <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftRecipeJob" />
+        <allow interface="lp.crafts.interfaces.craftrecipejob.ICraftPublishingJob" />
+    </class>
 </configure>
diff --git a/lib/lp/crafts/interfaces/craftrecipebuild.py b/lib/lp/crafts/interfaces/craftrecipebuild.py
index 7b57a27..60e2a54 100644
--- a/lib/lp/crafts/interfaces/craftrecipebuild.py
+++ b/lib/lp/crafts/interfaces/craftrecipebuild.py
@@ -7,6 +7,7 @@ __all__ = [
     "ICraftFile",
     "ICraftRecipeBuild",
     "ICraftRecipeBuildSet",
+    "ICraftRecipeBuildStatusChangedEvent",
 ]
 
 from lazr.restful.declarations import (
@@ -17,6 +18,7 @@ from lazr.restful.declarations import (
 )
 from lazr.restful.fields import Reference
 from zope.interface import Attribute, Interface
+from zope.interface.interfaces import IObjectEvent
 from zope.schema import Bool, Datetime, Dict, Int, TextLine
 
 from lp import _
@@ -38,6 +40,10 @@ from lp.services.librarian.interfaces import ILibraryFileAlias
 from lp.soyuz.interfaces.distroarchseries import IDistroArchSeries
 
 
+class ICraftRecipeBuildStatusChangedEvent(IObjectEvent):
+    """The status of a craft recipe build changed."""
+
+
 class ICraftRecipeBuildView(IPackageBuildView):
     """ICraftRecipeBuild attributes that require launchpad.View."""
 
diff --git a/lib/lp/crafts/interfaces/craftrecipejob.py b/lib/lp/crafts/interfaces/craftrecipejob.py
index addc6ee..81ea511 100644
--- a/lib/lp/crafts/interfaces/craftrecipejob.py
+++ b/lib/lp/crafts/interfaces/craftrecipejob.py
@@ -7,6 +7,8 @@ __all__ = [
     "ICraftRecipeJob",
     "ICraftRecipeRequestBuildsJob",
     "ICraftRecipeRequestBuildsJobSource",
+    "ICraftPublishingJob",
+    "ICraftPublishingJobSource",
 ]
 
 from lazr.restful.fields import Reference
@@ -136,3 +138,34 @@ class ICraftRecipeRequestBuildsJobSource(IJobSource):
             recipe and ID, or its `job_type` is not
             `CraftRecipeJobType.REQUEST_BUILDS`.
         """
+
+
+class ICraftPublishingJob(IRunnableJob):
+    """
+    A job that publishes craft recipe build artifacts to external repositories.
+    """
+
+    build_id = Attribute("The ID of the build to publish.")
+    build = Attribute("The build to publish.")
+    error_message = Attribute("The error message if the publishing failed.")
+
+    def create(build):
+        """Create a new CraftPublishingJob."""
+
+    def getPublishingType():
+        """Return the publishing type that this job will handle.
+
+        Each type corresponds to a specific artifact type and publishing
+        destination.
+        """
+
+
+class ICraftPublishingJobSource(IJobSource):
+    """A source for creating and finding CraftPublishingJobs."""
+
+    def create(build):
+        """
+        Publish artifacts from a craft recipe build to external repositories.
+
+        :param build: The build to publish.
+        """
diff --git a/lib/lp/crafts/model/craftrecipebuild.py b/lib/lp/crafts/model/craftrecipebuild.py
index 8fa4baa..1ecd39c 100644
--- a/lib/lp/crafts/model/craftrecipebuild.py
+++ b/lib/lp/crafts/model/craftrecipebuild.py
@@ -6,6 +6,7 @@
 __all__ = [
     "CraftFile",
     "CraftRecipeBuild",
+    "ICraftRecipeBuildStatusChangedEvent",
 ]
 
 from datetime import timedelta, timezone
@@ -16,6 +17,7 @@ from storm.locals import Bool, DateTime, Desc, Int, Reference, Store, Unicode
 from storm.store import EmptyResultSet
 from zope.component import getUtility
 from zope.interface import implementer
+from zope.interface.interfaces import ObjectEvent
 from zope.security.proxy import removeSecurityProxy
 
 from lp.app.errors import NotFoundError
@@ -33,6 +35,7 @@ from lp.crafts.interfaces.craftrecipebuild import (
     ICraftFile,
     ICraftRecipeBuild,
     ICraftRecipeBuildSet,
+    ICraftRecipeBuildStatusChangedEvent,
 )
 from lp.crafts.mail.craftrecipebuild import CraftRecipeBuildMailer
 from lp.registry.interfaces.pocket import PackagePublishingPocket
@@ -60,6 +63,11 @@ from lp.services.webapp.snapshot import notify_modified
 from lp.soyuz.model.distroarchseries import DistroArchSeries
 
 
+@implementer(ICraftRecipeBuildStatusChangedEvent)
+class CraftRecipeBuildStatusChangedEvent(ObjectEvent):
+    """See `ICraftRecipeBuildStatusChangedEvent`."""
+
+
 @implementer(ICraftRecipeBuild)
 class CraftRecipeBuild(PackageBuildMixin, StormBase):
     """See `ICraftRecipeBuild`."""
diff --git a/lib/lp/crafts/model/craftrecipejob.py b/lib/lp/crafts/model/craftrecipejob.py
index d573665..1419fe9 100644
--- a/lib/lp/crafts/model/craftrecipejob.py
+++ b/lib/lp/crafts/model/craftrecipejob.py
@@ -7,8 +7,18 @@ __all__ = [
     "CraftRecipeJob",
     "CraftRecipeJobType",
     "CraftRecipeRequestBuildsJob",
+    "CraftPublishingJob",
 ]
 
+import json
+import lzma
+import os
+import subprocess
+import tarfile
+import tempfile
+from configparser import NoSectionError
+from pathlib import Path
+
 import transaction
 from lazr.delegates import delegate_to
 from lazr.enum import DBEnumeratedType, DBItem
@@ -25,11 +35,16 @@ from lp.crafts.interfaces.craftrecipe import (
     MissingSourcecraftYaml,
 )
 from lp.crafts.interfaces.craftrecipejob import (
+    ICraftPublishingJob,
+    ICraftPublishingJobSource,
     ICraftRecipeJob,
     ICraftRecipeRequestBuildsJob,
     ICraftRecipeRequestBuildsJobSource,
 )
 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 +71,16 @@ class CraftRecipeJobType(DBEnumeratedType):
         """,
     )
 
+    PUBLISH_ARTIFACTS = DBItem(
+        1,
+        """
+        Publish artifacts
+
+        This job publishes craft recipe build artifacts to external
+        repositories.
+        """,
+    )
+
 
 @implementer(ICraftRecipeJob)
 class CraftRecipeJob(StormBase):
@@ -337,3 +362,331 @@ class CraftRecipeRequestBuildsJob(CraftRecipeJobDerived):
             # are to this job's metadata and should be preserved.
             transaction.commit()
             raise
+
+
+@implementer(ICraftPublishingJob)
+@provider(ICraftPublishingJobSource)
+class CraftPublishingJob(CraftRecipeJobDerived):
+    """
+    A Job that publishes craft recipe build artifacts to external
+    repositories.
+    """
+
+    class_job_type = CraftRecipeJobType.PUBLISH_ARTIFACTS
+
+    user_error_types = ()
+    retry_error_types = ()
+    max_retries = 5
+
+    config = config.ICraftPublishingJobSource
+
+    @classmethod
+    def create(cls, build):
+        """See `ICraftPublishingJobSource`."""
+        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 `ICraftPublishingJob`."""
+        return self.metadata["build_id"]
+
+    @cachedproperty
+    def build(self):
+        """See `ICraftPublishingJob`."""
+        return IStore(CraftRecipeBuild).get(CraftRecipeBuild, self.build_id)
+
+    @property
+    def error_message(self):
+        """See `ICraftPublishingJob`."""
+        return self.metadata.get("error_message")
+
+    @error_message.setter
+    def error_message(self, message):
+        """See `ICraftPublishingJob`."""
+        self.metadata["error_message"] = message
+
+    def getPublishingType(self, archive_path):
+        """Determine the type of artifacts to publish.
+
+        :param archive_path: Optional path to an already downloaded archive
+        file
+        :return: "cargo", "maven", or None if no publishable artifacts found
+        """
+        try:
+            has_crate = False
+            has_jar = False
+            has_pom = False
+
+            with lzma.open(archive_path) as xz:
+                with tarfile.open(fileobj=xz) as tar:
+                    for member in tar.getmembers():
+                        if member.name.endswith(".crate"):
+                            has_crate = True
+                        elif member.name.endswith(".jar"):
+                            has_jar = True
+                        elif member.name == "pom.xml":
+                            has_pom = True
+
+                        if has_crate:
+                            return "cargo"
+                        if has_jar and has_pom:
+                            return "maven"
+        except Exception:
+            pass
+
+        return None
+
+    def run(self):
+        """See `IRunnableJob`."""
+        try:
+            # Get the distribution name to access the correct configuration
+            distribution_name = None
+            git_repo = self.build.recipe.git_repository
+            if git_repo is not None:
+                if IDistributionSourcePackage.providedBy(git_repo.target):
+                    distribution_name = git_repo.target.distribution.name
+
+            if not distribution_name:
+                self.error_message = (
+                    "Could not determine distribution for build"
+                )
+                raise Exception(self.error_message)
+
+            # 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}"
+                )
+                raise Exception(self.error_message)
+
+            # 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:
+                raise Exception("No archive file found in build")
+
+            # Process the archive file
+            with tempfile.TemporaryDirectory() as tmpdir:
+                # Download the archive file to a temporary location
+                archive_path = os.path.join(tmpdir, "archive.tar.xz")
+
+                # Use the correct pattern for LibraryFileAlias
+                archive_file.open()  # This sets _datafile but returns None
+                try:
+                    with open(archive_path, "wb") as f:
+                        f.write(archive_file.read())
+                finally:
+                    archive_file.close()
+
+                # Determine what to publish using the getPublishingType method
+                publishing_type = self.getPublishingType(archive_path)
+
+                if publishing_type is None:
+                    raise Exception(
+                        "No publishable artifacts found in archive"
+                    )
+
+                # Extract the archive
+                extract_dir = os.path.join(tmpdir, "extract")
+                os.makedirs(extract_dir, exist_ok=True)
+
+                try:
+                    with lzma.open(archive_path) as xz:
+                        with tarfile.open(fileobj=xz, mode="r") as tar:
+                            tar.extractall(path=extract_dir)
+                except Exception as e:
+                    raise Exception(f"Failed to extract archive: {str(e)}")
+
+                # Publish artifacts based on type
+                if publishing_type == "cargo":
+                    self._publish_rust_crate(extract_dir, env_vars)
+                elif publishing_type == "maven":
+                    self._publish_maven_artifact(extract_dir, env_vars)
+
+        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
+
+    def _publish_rust_crate(self, extract_dir, env_vars):
+        """Publish Rust crates found in the extracted archive.
+
+        :param extract_dir: Path to the extracted archive
+        :param env_vars: Environment variables from configuration
+        :raises: Exception if publishing fails
+        """
+        # 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:
+            raise Exception(
+                "Missing Cargo publishing repository configuration"
+            )
+
+        # Look for .crate files
+        crate_files = list(Path(extract_dir).rglob("*.crate"))
+        crate_file = crate_files[0]
+
+        # Set up cargo config
+        cargo_dir = os.path.join(extract_dir, ".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(str(crate_file)),
+            env={"CARGO_HOME": cargo_dir},
+            capture_output=True,
+            text=True,
+        )
+
+        if result.returncode != 0:
+            raise Exception(f"Failed to publish crate: {result.stderr}")
+
+    def _publish_maven_artifact(self, extract_dir, env_vars):
+        """Publish Maven artifacts found in the extracted archive.
+
+        :param extract_dir: Path to the extracted archive
+        :param env_vars: Environment variables from configuration
+        :raises: Exception if publishing fails
+        """
+        # 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:
+            raise Exception(
+                "Missing Maven publishing repository configuration"
+            )
+
+        # 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:
+            raise Exception("No .jar file found in archive")
+
+        if pom_file is None:
+            raise Exception("No pom.xml file found in archive")
+
+        # Set up Maven settings
+        maven_dir = os.path.join(extract_dir, ".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:
+            raise Exception(
+                f"Failed to publish Maven artifact: {result.stderr}"
+            )
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..5b29d9d
--- /dev/null
+++ b/lib/lp/crafts/subscribers/craftrecipebuild.py
@@ -0,0 +1,71 @@
+# lib/lp/crafts/subscribers/craftrecipebuild.py
+
+"""Event subscribers for craft recipe builds."""
+
+from configparser import NoSectionError
+
+from zope.component import getUtility
+
+from lp.buildmaster.enums import BuildStatus
+from lp.crafts.interfaces.craftrecipebuild import ICraftRecipeBuild
+from lp.crafts.interfaces.craftrecipejob import ICraftPublishingJobSource
+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:
+                # If no section is found, we shouldn't publish
+                should_publish = False
+                log.debug(
+                    "No configuration found for distribution %s, "
+                    "skipping upload" % distribution_name
+                )
+
+        # Only schedule uploads for configured distribution builds
+        if should_publish and build.recipe.store_upload:
+            log.info("Scheduling publishing of artifacts from %r" % build)
+            getUtility(ICraftPublishingJobSource).create(build)
diff --git a/lib/lp/crafts/tests/test_craftrecipejob.py b/lib/lp/crafts/tests/test_craftrecipejob.py
index 11f3b47..1809f3c 100644
--- a/lib/lp/crafts/tests/test_craftrecipejob.py
+++ b/lib/lp/crafts/tests/test_craftrecipejob.py
@@ -6,6 +6,7 @@
 from textwrap import dedent
 
 import six
+from fixtures import FakeLogger
 from testtools.matchers import (
     AfterPreprocessing,
     ContainsDict,
@@ -18,6 +19,7 @@ from testtools.matchers import (
     MatchesStructure,
 )
 from zope.component import getUtility
+from zope.security.proxy import removeSecurityProxy
 
 from lp.app.interfaces.launchpad import ILaunchpadCelebrities
 from lp.code.tests.helpers import GitHostingFixture
@@ -26,10 +28,13 @@ from lp.crafts.interfaces.craftrecipe import (
     CannotParseSourcecraftYaml,
 )
 from lp.crafts.interfaces.craftrecipejob import (
+    ICraftPublishingJob,
+    ICraftPublishingJobSource,
     ICraftRecipeJob,
     ICraftRecipeRequestBuildsJob,
 )
 from lp.crafts.model.craftrecipejob import (
+    CraftPublishingJob,
     CraftRecipeJob,
     CraftRecipeJobType,
     CraftRecipeRequestBuildsJob,
@@ -40,10 +45,11 @@ from lp.services.database.sqlbase import get_transaction_timestamp
 from lp.services.features.testing import FeatureFixture
 from lp.services.job.interfaces.job import JobStatus
 from lp.services.job.runner import JobRunner
+from lp.services.librarian.interfaces import ILibraryFileAliasSet
 from lp.services.mail.sendmail import format_address_for_person
 from lp.testing import TestCaseWithFactory
 from lp.testing.dbuser import dbuser
-from lp.testing.layers import ZopelessDatabaseLayer
+from lp.testing.layers import CeleryJobLayer, ZopelessDatabaseLayer
 
 
 class TestCraftRecipeJob(TestCaseWithFactory):
@@ -265,3 +271,293 @@ class TestCraftRecipeRequestBuildsJob(TestCaseWithFactory):
                 builds=AfterPreprocessing(set, MatchesSetwise()),
             ),
         )
+
+
+class TestCraftPublishingJob(TestCaseWithFactory):
+    """Test the CraftPublishingJob."""
+
+    layer = CeleryJobLayer
+
+    def setUp(self):
+        super().setUp()
+        self.useFixture(
+            FeatureFixture(
+                {"jobs.celery.enabled_classes": "CraftPublishingJob"}
+            )
+        )
+        self.useFixture(FakeLogger())
+        self.useFixture(FeatureFixture({CRAFT_RECIPE_ALLOW_CREATE: "on"}))
+        self.recipe = self.factory.makeCraftRecipe()
+        self.build = self.factory.makeCraftRecipeBuild(recipe=self.recipe)
+
+    def test_provides_interface(self):
+        # CraftPublishingJob provides ICraftPublishingJob.
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+
+        # Check that the instance provides the job interface
+        self.assertProvides(job, ICraftPublishingJob)
+
+        # Check that the class provides the source interface
+        self.assertProvides(CraftPublishingJob, ICraftPublishingJobSource)
+
+    def test_create(self):
+        # CraftPublishingJob.create creates a CraftPublishingJob with the
+        # correct attributes.
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+
+        job = removeSecurityProxy(job)
+
+        self.assertThat(
+            job,
+            MatchesStructure(
+                class_job_type=Equals(CraftRecipeJobType.PUBLISH_ARTIFACTS),
+                recipe=Equals(self.recipe),
+                build=Equals(self.build),
+                error_message=Is(None),
+            ),
+        )
+
+    def test_run_failure_cannot_determine_distribution(self):
+        """Test failure when distribution cannot be determined."""
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+
+        # Create a mock git repository with a non-distribution target
+        git_repo = self.factory.makeGitRepository()
+        self.patch(
+            removeSecurityProxy(self.recipe), "git_repository", git_repo
+        )
+
+        JobRunner([job]).runAll()
+
+        job = removeSecurityProxy(job)
+        self.assertEqual(JobStatus.FAILED, job.job.status)
+        self.assertEqual(
+            "Could not determine distribution for build",
+            job.error_message,
+        )
+
+    def test_run_failure_no_configuration(self):
+        """Test failure when no configuration is found for the distribution."""
+        # Create a distribution with a name that won't have a configuration
+        distribution = self.factory.makeDistribution(name="nonexistent")
+
+        # Create a distribution source package for our distribution
+        package = self.factory.makeDistributionSourcePackage(
+            distribution=distribution
+        )
+
+        # Create a git repository targeting that package
+        git_repository = self.factory.makeGitRepository(target=package)
+
+        # Update our recipe to use this git repository
+        removeSecurityProxy(self.recipe).git_repository = git_repository
+
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        JobRunner([job]).runAll()
+        job = removeSecurityProxy(job)
+
+        self.assertEqual(JobStatus.FAILED, job.job.status)
+        self.assertEqual(
+            "No configuration found for nonexistent",
+            job.error_message,
+        )
+
+    def test_run_failure_no_archive_file(self):
+        """Test failure when no archive file is found in the build."""
+        distribution = self.factory.makeDistribution(name="soss")
+
+        # Set up config with environment variables but no Cargo publishing info
+        # We just need a config section for the distribution name
+        self.pushConfig("craftbuild.soss")
+        package = self.factory.makeDistributionSourcePackage(
+            distribution=distribution
+        )
+        git_repository = self.factory.makeGitRepository(target=package)
+        removeSecurityProxy(self.recipe).git_repository = git_repository
+
+        # Create a job without adding any files to the build
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+
+        # Run the job - it should fail because there are no files
+        JobRunner([job]).runAll()
+
+        # Verify job failed with error message
+        job = removeSecurityProxy(job)
+
+        self.assertEqual(JobStatus.FAILED, job.job.status)
+        self.assertEqual(
+            "No archive file found in build",
+            job.error_message,
+        )
+
+    def test_run_no_publishable_artifacts(self):
+        """Test failure when no publishable artifacts are found."""
+        distribution = self.factory.makeDistribution(name="soss")
+
+        # Set up config with environment variables but no Cargo publishing info
+        # We just need a config section for the distribution name
+        self.pushConfig("craftbuild.soss")
+        package = self.factory.makeDistributionSourcePackage(
+            distribution=distribution
+        )
+        git_repository = self.factory.makeGitRepository(target=package)
+        removeSecurityProxy(self.recipe).git_repository = git_repository
+
+        # Create a tar.xz file with a dummy file (but no artifacts)
+        import lzma
+        import tarfile
+        from io import BytesIO
+
+        # Create a tar file in memory
+        tar_content = BytesIO()
+        with tarfile.open(fileobj=tar_content, mode="w") as tar:
+            # Add a dummy text file
+            info = tarfile.TarInfo("test.txt")
+            dummy_content = b"test content"
+            info.size = len(dummy_content)
+            tar.addfile(info, BytesIO(dummy_content))
+
+        # Compress the tar content with lzma
+        tar_content.seek(0)
+        xz_content = BytesIO()
+        with lzma.open(xz_content, "w") as xz:
+            xz.write(tar_content.read())
+
+        xz_content.seek(0)
+        tar_xz_content = xz_content.read()
+
+        # Create a LibraryFileAlias with the tar.xz content
+        librarian = getUtility(ILibraryFileAliasSet)
+        lfa = librarian.create(
+            "archive.tar.xz",
+            len(tar_xz_content),
+            BytesIO(tar_xz_content),
+            "application/x-xz",
+        )
+
+        # Add the file to the build
+        removeSecurityProxy(self.build).addFile(lfa)
+
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        JobRunner([job]).runAll()
+        job = removeSecurityProxy(job)
+
+        self.assertEqual(JobStatus.FAILED, job.job.status)
+        self.assertEqual(
+            "No publishable artifacts found in archive",
+            job.error_message,
+        )
+
+    def test_run_missing_cargo_config(self):
+        """Test failure when a crate is found but Cargo config is missing."""
+        distribution = self.factory.makeDistribution(name="soss")
+        self.pushConfig("craftbuild.soss")
+        package = self.factory.makeDistributionSourcePackage(
+            distribution=distribution
+        )
+
+        git_repository = self.factory.makeGitRepository(target=package)
+
+        removeSecurityProxy(self.recipe).git_repository = git_repository
+
+        # Create a tar.xz file with a .crate file
+        import lzma
+        import tarfile
+        from io import BytesIO
+
+        tar_content = BytesIO()
+        with tarfile.open(fileobj=tar_content, mode="w") as tar:
+            # Add a dummy crate file
+            info = tarfile.TarInfo("test-0.1.0.crate")
+            dummy_content = b"dummy crate content"
+            info.size = len(dummy_content)
+            tar.addfile(info, BytesIO(dummy_content))
+
+        tar_content.seek(0)
+        xz_content = BytesIO()
+        with lzma.open(xz_content, "w") as xz:
+            xz.write(tar_content.read())
+
+        xz_content.seek(0)
+        tar_xz_content = xz_content.read()
+
+        librarian = getUtility(ILibraryFileAliasSet)
+        lfa = librarian.create(
+            "archive.tar.xz",
+            len(tar_xz_content),
+            BytesIO(tar_xz_content),
+            "application/x-xz",
+        )
+
+        removeSecurityProxy(self.build).addFile(lfa)
+
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        JobRunner([job]).runAll()
+        job = removeSecurityProxy(job)
+
+        self.assertEqual(JobStatus.FAILED, job.job.status)
+        self.assertEqual(
+            "Missing Cargo publishing repository configuration",
+            job.error_message,
+        )
+
+    def test_run_missing_maven_config(self):
+        """
+        Test failure when Maven artifacts are found but Maven config is
+        missing.
+        """
+        distribution = self.factory.makeDistribution(name="soss")
+
+        self.pushConfig("craftbuild.soss")
+        package = self.factory.makeDistributionSourcePackage(
+            distribution=distribution
+        )
+        git_repository = self.factory.makeGitRepository(target=package)
+        removeSecurityProxy(self.recipe).git_repository = git_repository
+
+        # Create a tar.xz file with a .jar file and pom.xml
+        import lzma
+        import tarfile
+        from io import BytesIO
+
+        tar_content = BytesIO()
+        with tarfile.open(fileobj=tar_content, mode="w") as tar:
+            # Add a dummy jar file
+            info = tarfile.TarInfo("test-0.1.0.jar")
+            dummy_jar_content = b"dummy jar content"
+            info.size = len(dummy_jar_content)
+            tar.addfile(info, BytesIO(dummy_jar_content))
+
+            # Add a pom.xml file
+            info = tarfile.TarInfo("pom.xml")
+            dummy_pom_content = b"<project>...</project>"
+            info.size = len(dummy_pom_content)
+            tar.addfile(info, BytesIO(dummy_pom_content))
+
+        tar_content.seek(0)
+        xz_content = BytesIO()
+        with lzma.open(xz_content, "w") as xz:
+            xz.write(tar_content.read())
+
+        xz_content.seek(0)
+        tar_xz_content = xz_content.read()
+
+        librarian = getUtility(ILibraryFileAliasSet)
+        lfa = librarian.create(
+            "archive.tar.xz",
+            len(tar_xz_content),
+            BytesIO(tar_xz_content),
+            "application/x-xz",
+        )
+
+        removeSecurityProxy(self.build).addFile(lfa)
+
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        JobRunner([job]).runAll()
+        job = removeSecurityProxy(job)
+
+        self.assertEqual(JobStatus.FAILED, job.job.status)
+        self.assertEqual(
+            "Missing Maven publishing repository configuration",
+            job.error_message,
+        )
diff --git a/lib/lp/services/config/schema-lazr.conf b/lib/lp/services/config/schema-lazr.conf
index 1f0a62f..5dd3279 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/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/maven-testing-local/";,
+#    "MAVEN_PUBLISH_AUTH": "%(write_auth)s"
 # }
 # 
 environment_variables: none 
@@ -2022,6 +2042,11 @@ module: lp.crafts.interfaces.craftrecipejob
 dbuser: craft-build-job
 crontab_group: MAIN
 
+[ICraftPublishingJobSource]
+module: lp.crafts.interfaces.craftrecipejob
+dbuser: craft-publish-job
+crontab_group: MAIN
+
 [ICIBuildUploadJobSource]
 module: lp.soyuz.interfaces.archivejob
 dbuser: uploader
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