← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~ilkeremrekoc/launchpad:maven-cargo-metadata into launchpad:master

 

İlker Emre Koç has proposed merging ~ilkeremrekoc/launchpad:maven-cargo-metadata into launchpad:master.

Commit message:
Add metadata to the published craft packages

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~ilkeremrekoc/launchpad/+git/launchpad/+merge/486190

There isn't a reliable way to put the necessary metadata to Artifactory
during publishing of Cargo and Maven builds. As a result, we will
instead, add these metadata right after publishing instead.

-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~ilkeremrekoc/launchpad:maven-cargo-metadata into launchpad:master.
diff --git a/lib/lp/crafts/model/craftrecipebuildjob.py b/lib/lp/crafts/model/craftrecipebuildjob.py
index 8133aeb..a0af8ed 100644
--- a/lib/lp/crafts/model/craftrecipebuildjob.py
+++ b/lib/lp/crafts/model/craftrecipebuildjob.py
@@ -16,6 +16,8 @@ import tempfile
 from configparser import NoSectionError
 
 import transaction
+import yaml
+from artifactory import ArtifactoryPath
 from lazr.delegates import delegate_to
 from lazr.enum import DBEnumeratedType, DBItem
 from storm.databases.postgres import JSON
@@ -37,6 +39,7 @@ from lp.services.database.interfaces import IPrimaryStore, IStore
 from lp.services.database.stormbase import StormBase
 from lp.services.job.model.job import EnumeratedSubclass, Job
 from lp.services.job.runner import BaseRunnableJob
+from lp.services.scripts import log
 
 
 class CraftRecipeBuildJobType(DBEnumeratedType):
@@ -149,6 +152,7 @@ class CraftPublishingJob(CraftRecipeBuildJobDerived):
 
     task_queue = "native_publisher_job"
 
+    artifactory_base_url = config.artifactory.base_url
     config = config.ICraftPublishingJobSource
 
     @classmethod
@@ -273,7 +277,9 @@ class CraftPublishingJob(CraftRecipeBuildJobDerived):
                     )
 
                     # Publish the Rust crate
-                    self._publish_rust_crate(crate_dir, env_vars)
+                    self._publish_rust_crate(
+                        crate_dir, env_vars, crate_file.filename
+                    )
                 elif jar_file is not None and pom_file is not None:
                     # Download the jar file
                     jar_path = os.path.join(tmpdir, jar_file.filename)
@@ -297,6 +303,7 @@ class CraftPublishingJob(CraftRecipeBuildJobDerived):
                     self._publish_maven_artifact(
                         tmpdir,
                         env_vars,
+                        jar_file.filename,
                         jar_path,
                         pom_path,
                     )
@@ -312,7 +319,7 @@ class CraftPublishingJob(CraftRecipeBuildJobDerived):
             transaction.commit()
             raise
 
-    def _publish_rust_crate(self, extract_dir, env_vars):
+    def _publish_rust_crate(self, extract_dir, env_vars, artifact_name):
         """Publish Rust crates from the extracted crate directory.
 
         :param extract_dir: Path to the extracted crate directory
@@ -373,8 +380,10 @@ class CraftPublishingJob(CraftRecipeBuildJobDerived):
         if result.returncode != 0:
             raise Exception(f"Failed to publish crate: {result.stderr}")
 
+        self._publish_properties(cargo_publish_url, artifact_name)
+
     def _publish_maven_artifact(
-        self, work_dir, env_vars, jar_path=None, pom_path=None
+        self, work_dir, env_vars, artifact_name, jar_path=None, pom_path=None
     ):
         """Publish Maven artifacts.
 
@@ -435,6 +444,8 @@ class CraftPublishingJob(CraftRecipeBuildJobDerived):
                 f"Failed to publish Maven artifact: {result.stderr}"
             )
 
+        self._publish_properties(maven_publish_url, artifact_name)
+
     def _get_maven_settings_xml(self, username, password):
         """Generate Maven settings.xml content.
 
@@ -488,3 +499,97 @@ class CraftPublishingJob(CraftRecipeBuildJobDerived):
 
         # Combine all parts
         return header + schema + servers + profiles + active_profiles
+
+    def _publish_properties(
+        self, publish_url: str, artifact_name: str
+    ) -> None:
+        """Publish properties to the artifact in Artifactory."""
+
+        new_properties = {}
+
+        new_properties["soss.commit_id"] = (
+            [self.build.revision_id] if self.build.revision_id else ["unknown"]
+        )
+        new_properties["soss.source_url"] = [self._recipe_git_url()]
+        new_properties["soss.type"] = ["source"]
+        new_properties["soss.license"] = [self._get_license_metadata()]
+
+        # Repo name is derived from the URL
+        # We assume the URL ends with the repository name
+        repo_name = publish_url.rstrip("/").split("/")[-1]
+
+        # Search for the artifact in Artifactory using AQL
+        root_path = ArtifactoryPath(self.artifactory_base_url)
+        artifacts = root_path.aql(
+            "items.find",
+            {
+                "repo": repo_name,
+                "name": artifact_name,
+            },
+            ".include",
+            ["repo", "path", "name"],
+            ".limit(1)",
+        )
+
+        if not artifacts:
+            raise NotFoundError(
+                f"Artifact '{artifact_name}' not found in repository \
+                '{repo_name}'"
+            )
+
+        if len(artifacts) > 1:
+            log.info(
+                f"Multiple artifacts found for '{artifact_name}'"
+                + f"in repository '{repo_name}'. Using the first one."
+            )
+
+        # Get the first artifact that matches the name
+        artifact = artifacts[0]
+
+        artifact_path = ArtifactoryPath(
+            root_path, artifact["repo"], artifact["path"], artifact["name"]
+        )
+        artifact_path.set_properties(new_properties)
+
+    def _recipe_git_url(self):
+        """Get the recipe git URL."""
+
+        craft_recipe = self.build.recipe
+        if craft_recipe.git_repository is not None:
+            return craft_recipe.git_repository.git_https_url
+        elif craft_recipe.git_repository_url is not None:
+            return craft_recipe.git_repository_url
+        else:
+            log.info(
+                f"Recipe {craft_recipe.id} has no git repository URL defined."
+            )
+            return "unknown"
+
+    def _get_license_metadata(self) -> str:
+        """Get the license metadata from the build files."""
+        for _, lfa, _ in self.build.getFiles():
+            if lfa.filename == "metadata.yaml":
+                lfa.open()
+                try:
+                    content = lfa.read().decode("utf-8")
+                    metadata = yaml.safe_load(content)
+
+                    if "license" not in metadata:
+                        log.info(
+                            "No license found in metadata.yaml, returning \
+                            'unknown'."
+                        )
+                        return "unknown"
+
+                    return metadata.get("license")
+
+                except yaml.YAMLError as e:
+                    self.error_message = f"Failed to parse metadata.yaml: {e}"
+
+                    log.info(self.error_message)
+                    return "unknown"
+                finally:
+                    lfa.close()
+
+        log.info("No metadata.yaml file found in the build files.")
+        return "unknown"
diff --git a/lib/lp/crafts/tests/test_craftrecipebuildjob.py b/lib/lp/crafts/tests/test_craftrecipebuildjob.py
index e25ca1f..6fcd5da 100644
--- a/lib/lp/crafts/tests/test_craftrecipebuildjob.py
+++ b/lib/lp/crafts/tests/test_craftrecipebuildjob.py
@@ -3,13 +3,20 @@
 
 import io
 import json
+import os
 import tarfile
+import tempfile
+from pathlib import Path
 
-from fixtures import FakeLogger
+from artifactory import ArtifactoryPath
+from fixtures import FakeLogger, MonkeyPatch
 from testtools.matchers import Equals, Is, MatchesStructure
 from zope.component import getUtility
 from zope.security.proxy import removeSecurityProxy
 
+from lp.archivepublisher.tests.artifactory_fixture import (
+    FakeArtifactoryFixture,
+)
 from lp.crafts.interfaces.craftrecipe import CRAFT_RECIPE_ALLOW_CREATE
 from lp.crafts.interfaces.craftrecipebuildjob import (
     ICraftPublishingJob,
@@ -25,6 +32,7 @@ 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.librarian.utils import copy_and_close
 from lp.testing import TestCaseWithFactory
 from lp.testing.layers import CeleryJobLayer, ZopelessDatabaseLayer
 
@@ -50,6 +58,13 @@ class TestCraftRecipeBuildJob(TestCaseWithFactory):
         )
 
 
+class MockBytesIO(io.BytesIO):
+    """A mock BytesIO class to simulate an artifact file."""
+
+    def open(self):
+        pass
+
+
 class TestCraftPublishingJob(TestCaseWithFactory):
     """Test the CraftPublishingJob."""
 
@@ -67,6 +82,72 @@ class TestCraftPublishingJob(TestCaseWithFactory):
         self.recipe = self.factory.makeCraftRecipe()
         self.build = self.factory.makeCraftRecipeBuild(recipe=self.recipe)
 
+        # Set up the Artifactory fixture
+        self.base_url = "https://example.com/artifactory";
+        self.repository_name = "repository"
+
+        self.artifactory = self.useFixture(
+            FakeArtifactoryFixture(self.base_url, self.repository_name)
+        )
+
+        self.useFixture(
+            MonkeyPatch(
+                "lp.crafts.model.craftrecipebuildjob."
+                + "CraftPublishingJob.artifactory_base_url",
+                self.base_url,
+            )
+        )
+
+    def _artifactory_search(self, repo_name, artifact_name):
+        """Helper to search for a file in the Artifactory fixture."""
+
+        root_path = ArtifactoryPath(self.base_url)
+        artifacts = root_path.aql(
+            "items.find",
+            {
+                "repo": repo_name,
+                "name": artifact_name,
+            },
+            ".include",
+            # We don't use "repo", but the AQL documentation says that
+            # non-admin users must include all of "name", "repo",
+            # and "path" in the include directive.
+            ["repo", "path", "name", "property"],
+            ".limit(1)",
+        )
+
+        if not artifacts:
+            return
+
+        artifact = artifacts[0]
+
+        properties = {}
+        for prop in artifact.get("properties", {}):
+            properties[prop["key"]] = prop.get("value", "")
+
+        artifact["properties"] = properties
+
+        return artifact
+
+    def _artifactory_put(
+        self, base_url, middle_path, artifact_name, artifact_file
+    ):
+        """Helper to put a file into the Artifactory fixture."""
+
+        fd, name = tempfile.mkstemp(prefix="temp-download.")
+        f = os.fdopen(fd, "wb")
+
+        targetpath = ArtifactoryPath(base_url, middle_path, artifact_name)
+        targetpath.parent.mkdir(parents=True, exist_ok=True)
+
+        try:
+            artifact_file.open()
+            copy_and_close(artifact_file, f)
+            targetpath.deploy_file(name, parameters={"test": ["True"]})
+        finally:
+            f.close()
+            Path(name).unlink()
+
     def test_provides_interface(self):
         # CraftPublishingJob provides ICraftPublishingJob.
         job = getUtility(ICraftPublishingJobSource).create(self.build)
@@ -367,7 +448,7 @@ edition = "2018"
             "craftbuild.soss",
             environment_variables=json.dumps(
                 {
-                    "CARGO_PUBLISH_URL": "https://example.com/registry";,
+                    "CARGO_PUBLISH_URL": f"{self.base_url}/repository",
                     "CARGO_PUBLISH_AUTH": "lp:token123",
                 }
             ),
@@ -431,6 +512,21 @@ edition = "2018"
 
         removeSecurityProxy(self.build).addFile(lfa)
 
+        # Add a metadata file with license information
+        license_value = "Apache-2.0"
+        metadata_yaml = f"license: {license_value}\n"
+        librarian = getUtility(ILibraryFileAliasSet)
+        metadata_lfa = librarian.create(
+            "metadata.yaml",
+            len(metadata_yaml),
+            MockBytesIO(metadata_yaml.encode("utf-8")),
+            "text/x-yaml",
+        )
+        removeSecurityProxy(self.build).addFile(metadata_lfa)
+
+        # Set a revision ID for the build
+        removeSecurityProxy(self.build).revision_id = "random-revision-id"
+
         # Create a mock return value for subprocess.run
         mock_completed_process = type(
             "MockCompletedProcess",
@@ -454,6 +550,26 @@ edition = "2018"
 
         self.patch(crbj_subprocess, "run", mock_run)
 
+        original_publish_properties = CraftPublishingJob._publish_properties
+
+        def mock_cargo_publish_properties(*args, **kwargs):
+            """Mock _publish_properties to deploy the crate to Artifactory
+            Fixture before testing.
+
+            We need to do this in a nested function here because we need
+            to access the `lfa` variable which is created in the this test
+            setup above but the mocked function (and the lfa.open()) can
+            only be called inside the job's run method."""
+
+            self._artifactory_put(args[1], "crates/test", args[2], lfa)
+            return original_publish_properties(*args, **kwargs)
+
+        self.patch(
+            CraftPublishingJob,
+            "_publish_properties",
+            mock_cargo_publish_properties,
+        )
+
         # Create and run the job
         job = getUtility(ICraftPublishingJobSource).create(self.build)
         JobRunner([job]).runAll()
@@ -498,6 +614,24 @@ edition = "2018"
         env = kwargs["env"]
         self.assertIn("CARGO_HOME", env)
 
+        # Verify that the artifact's metadata were uploaded to Artifactory
+        artifact = self._artifactory_search("repository", lfa.filename)
+
+        self.assertIsNotNone(artifact, "Artifact not found in Artifactory")
+
+        self.assertEqual(artifact["repo"], "repository")
+        self.assertEqual(artifact["name"], lfa.filename)
+        self.assertEqual(artifact["path"], "crates/test")
+        self.assertEqual(
+            artifact["properties"]["soss.commit_id"], "random-revision-id"
+        )
+        self.assertEqual(
+            artifact["properties"]["soss.source_url"],
+            git_repository.git_https_url,
+        )
+        self.assertEqual(artifact["properties"]["soss.type"], "source")
+        self.assertEqual(artifact["properties"]["soss.license"], license_value)
+
     def test_run_missing_maven_config(self):
         """
         Test failure when Maven artifacts are found but Maven config is
@@ -561,7 +695,7 @@ edition = "2018"
             "craftbuild.soss",
             environment_variables=json.dumps(
                 {
-                    "MAVEN_PUBLISH_URL": "https://example.com/maven";,
+                    "MAVEN_PUBLISH_URL": f"{self.base_url}/repository",
                     "MAVEN_PUBLISH_AUTH": "maven_user:maven_password",
                 }
             ),
@@ -604,6 +738,21 @@ edition = "2018"
         removeSecurityProxy(self.build).addFile(jar_lfa)
         removeSecurityProxy(self.build).addFile(pom_lfa)
 
+        # Create a metadata file with license information
+        license_value = "Apache-2.0"
+        metadata_yaml = f"license: {license_value}\n"
+        librarian = getUtility(ILibraryFileAliasSet)
+        metadata_lfa = librarian.create(
+            "metadata.yaml",
+            len(metadata_yaml),
+            MockBytesIO(metadata_yaml.encode("utf-8")),
+            "text/x-yaml",
+        )
+        removeSecurityProxy(self.build).addFile(metadata_lfa)
+
+        # Set a revision ID for the build
+        removeSecurityProxy(self.build).revision_id = "random-revision-id"
+
         # Create a mock return value for subprocess.run
         mock_completed_process = type(
             "MockCompletedProcess",
@@ -627,6 +776,28 @@ edition = "2018"
 
         self.patch(crbj_subprocess, "run", mock_run)
 
+        original_publish_properties = CraftPublishingJob._publish_properties
+
+        def mock_maven_publish_properties(*args, **kwargs):
+            """Mock _publish_properties to deploy the crate to Artifactory
+            Fixture before testing.
+
+            We need to do this in a nested function here because we need
+            to access the `lfa` variable which is created in the this test
+            setup above but the mocked function (and the lfa.open()) can
+            only be called inside the job's run method."""
+
+            self._artifactory_put(
+                args[1], "com/example/test-artifact/0.1.0", args[2], jar_lfa
+            )
+            return original_publish_properties(*args, **kwargs)
+
+        self.patch(
+            CraftPublishingJob,
+            "_publish_properties",
+            mock_maven_publish_properties,
+        )
+
         # Create and run the job
         job = getUtility(ICraftPublishingJobSource).create(self.build)
         JobRunner([job]).runAll()
@@ -655,7 +826,9 @@ edition = "2018"
 
         # Check for required Maven arguments
         self.assertIn("-DrepositoryId=central", args_str)
-        self.assertIn("-Durl=https://example.com/maven";, args_str)
+        self.assertIn(
+            "-Durl=https://example.com/artifactory/repository";, args_str
+        )
 
         # Verify that the POM and JAR files are correctly referenced
         pom_found = False
@@ -686,3 +859,247 @@ edition = "2018"
         # Verify working directory was set correctly
         kwargs = mvn_call[1]
         self.assertIn("cwd", kwargs)
+
+        artifact = self._artifactory_search("repository", jar_lfa.filename)
+
+        # Verify that the artifact's metadata were uploaded to Artifactory
+        self.assertIsNotNone(artifact, "Artifact not found in Artifactory")
+
+        self.assertEqual(artifact["repo"], "repository")
+        self.assertEqual(artifact["name"], jar_lfa.filename)
+        self.assertEqual(artifact["path"], "com/example/test-artifact/0.1.0")
+        self.assertEqual(
+            artifact["properties"]["soss.commit_id"], "random-revision-id"
+        )
+        self.assertEqual(
+            artifact["properties"]["soss.source_url"],
+            git_repository.git_https_url,
+        )
+        self.assertEqual(artifact["properties"]["soss.type"], "source")
+        self.assertEqual(artifact["properties"]["soss.license"], license_value)
+
+    def test__publish_properties_sets_expected_properties(self):
+        """Test that _publish_properties sets the correct properties in
+        Artifactory."""
+        # Arrange
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        job = removeSecurityProxy(job)
+
+        # Patch _recipe_git_url and _get_license_metadata for deterministic
+        # output
+        self.patch(
+            CraftPublishingJob,
+            "_recipe_git_url",
+            lambda self: "https://example.com/repo.git";,
+        )
+        self.patch(
+            CraftPublishingJob, "_get_license_metadata", lambda self: "MIT"
+        )
+
+        removeSecurityProxy(self.build).revision_id = "random-revision-id"
+
+        self._artifactory_put(
+            f"{self.base_url}/repository",
+            "middle_folder",
+            "artifact.file",
+            MockBytesIO(b"dummy content"),
+        )
+        job._publish_properties(f"{self.base_url}/repository", "artifact.file")
+        artifact = self._artifactory_search("repository", "artifact.file")
+
+        self.assertIsNotNone(artifact, "Artifact not found in Artifactory")
+
+        props = artifact["properties"]
+        self.assertIn("soss.commit_id", props)
+        self.assertIn("soss.source_url", props)
+        self.assertIn("soss.type", props)
+        self.assertIn("soss.license", props)
+        self.assertEqual(props["soss.commit_id"], job.build.revision_id)
+        self.assertEqual(
+            props["soss.source_url"], "https://example.com/repo.git";
+        )
+        self.assertEqual(props["soss.type"], "source")
+        self.assertEqual(props["soss.license"], "MIT")
+
+    def test__publish_properties_artifact_not_found(self):
+        """Test that _publish_properties raises NotFoundError if artifact is
+        missing."""
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        job = removeSecurityProxy(job)
+
+        from lp.app.errors import NotFoundError
+
+        self.assertRaises(
+            NotFoundError,
+            job._publish_properties,
+            f"{self.base_url}/repository",
+            "missing-artifact.file",
+        )
+
+    def test__publish_properties_no_metadata_yaml(self):
+        """Test that _publish_properties sets license to 'unknown' if no
+        metadata.yaml is present."""
+
+        self._artifactory_put(
+            f"{self.base_url}/repository",
+            "some/path",
+            "artifact.file",
+            MockBytesIO(b"dummy content"),
+        )
+
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        job = removeSecurityProxy(job)
+        job.run = lambda: job._publish_properties(
+            f"{self.base_url}/repository", "artifact.file"
+        )
+
+        self.patch(
+            CraftPublishingJob,
+            "_recipe_git_url",
+            lambda self: "https://example.com/repo.git";,
+        )
+
+        JobRunner([job]).runAll()
+
+        artifact = self._artifactory_search("repository", "artifact.file")
+        self.assertEqual(artifact["properties"]["soss.license"], "unknown")
+
+    def test__publish_properties_no_license_in_metadata_yaml(self):
+        """Test that _publish_properties sets license to 'unknown' if no
+        license is specified in metadata.yaml."""
+
+        # Create a broken metadata.yaml file with a license
+        metadata_yaml = "no_license: True\n"
+        librarian = getUtility(ILibraryFileAliasSet)
+        metadata_lfa = librarian.create(
+            "metadata.yaml",
+            len(metadata_yaml),
+            MockBytesIO(metadata_yaml.encode("utf-8")),
+            "text/x-yaml",
+        )
+        removeSecurityProxy(self.build).addFile(metadata_lfa)
+
+        self._artifactory_put(
+            f"{self.base_url}/repository",
+            "some/path",
+            "artifact.file",
+            MockBytesIO(b"dummy content"),
+        )
+
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        job = removeSecurityProxy(job)
+        job.run = lambda: job._publish_properties(
+            f"{self.base_url}/repository", "artifact.file"
+        )
+
+        self.patch(
+            CraftPublishingJob,
+            "_recipe_git_url",
+            lambda self: "https://example.com/repo.git";,
+        )
+
+        JobRunner([job]).runAll()
+
+        artifact = self._artifactory_search("repository", "artifact.file")
+        self.assertEqual(artifact["properties"]["soss.license"], "unknown")
+
+    def test__publish_properties_license_from_metadata_yaml(self):
+        """Test that _publish_properties gets license from metadata.yaml
+        if present."""
+
+        # Create a metadata.yaml file with a license
+        license_value = "Apache-2.0"
+        metadata_yaml = f"license: {license_value}\n"
+        librarian = getUtility(ILibraryFileAliasSet)
+        metadata_lfa = librarian.create(
+            "metadata.yaml",
+            len(metadata_yaml),
+            MockBytesIO(metadata_yaml.encode("utf-8")),
+            "text/x-yaml",
+        )
+        removeSecurityProxy(self.build).addFile(metadata_lfa)
+
+        self._artifactory_put(
+            f"{self.base_url}/repository",
+            "some/path",
+            "artifact.file",
+            MockBytesIO(b"dummy content"),
+        )
+
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        job = removeSecurityProxy(job)
+        job.run = lambda: job._publish_properties(
+            f"{self.base_url}/repository", "artifact.file"
+        )
+
+        self.patch(
+            CraftPublishingJob,
+            "_recipe_git_url",
+            lambda self: "https://example.com/repo.git";,
+        )
+
+        JobRunner([job]).runAll()
+
+        artifact = self._artifactory_search("repository", "artifact.file")
+        self.assertEqual(artifact["properties"]["soss.license"], license_value)
+
+    def test__publish_properties_git_repository_source_url(self):
+        """Test that _publish_properties gets git_repository as source_url."""
+
+        self._artifactory_put(
+            f"{self.base_url}/repository",
+            "some/path",
+            "artifact.file",
+            MockBytesIO(b"dummy content"),
+        )
+
+        git_repository = self.factory.makeGitRepository()
+        removeSecurityProxy(self.recipe).git_repository = git_repository
+
+        job = getUtility(ICraftPublishingJobSource).create(self.build)
+        job = removeSecurityProxy(job)
+
+        self.patch(
+            CraftPublishingJob, "_get_license_metadata", lambda self: "MIT"
+        )
+
+        job._publish_properties(f"{self.base_url}/repository", "artifact.file")
+
+        artifact = self._artifactory_search("repository", "artifact.file")
+        self.assertEqual(
+            artifact["properties"]["soss.source_url"],
+            git_repository.git_https_url,
+        )
+
+    def test__publish_properties_git_repository_url_source_url(self):
+        """Test that _publish_properties gets git_repository_url as
+        source_url."""
+
+        self._artifactory_put(
+            f"{self.base_url}/repository",
+            "some/path",
+            "artifact.file",
+            MockBytesIO(b"dummy content"),
+        )
+
+        git_url_recipe = self.factory.makeCraftRecipe(
+            git_ref=self.factory.makeGitRefRemote()
+        )
+        git_url_build = self.factory.makeCraftRecipeBuild(
+            recipe=git_url_recipe
+        )
+
+        job = getUtility(ICraftPublishingJobSource).create(git_url_build)
+        job = removeSecurityProxy(job)
+
+        self.patch(
+            CraftPublishingJob, "_get_license_metadata", lambda self: "MIT"
+        )
+
+        job._publish_properties(f"{self.base_url}/repository", "artifact.file")
+
+        artifact = self._artifactory_search("repository", "artifact.file")
+        self.assertEqual(
+            artifact["properties"]["soss.source_url"],
+            git_url_recipe.git_repository_url,
+        )