← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~cjwatson/launchpad:generic-artifactory-publishing into launchpad:master

 

Colin Watson has proposed merging ~cjwatson/launchpad:generic-artifactory-publishing into launchpad:master.

Commit message:
Support generic Artifactory repositories

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

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

This follows the plan in https://docs.google.com/document/d/1fqfQhX2qR7oyIB3OBCXWw16pp_2xTSbKl6FU_jERblE: CI builds can produce artifacts that have the `name` and `version` (and optionally `source`) properties set, which are then used to construct publishing paths.  Generic repositories have no indexing support.
-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/launchpad:generic-artifactory-publishing into launchpad:master.
diff --git a/lib/lp/archivepublisher/artifactory.py b/lib/lp/archivepublisher/artifactory.py
index 4bd7f0a..f56799e 100644
--- a/lib/lp/archivepublisher/artifactory.py
+++ b/lib/lp/archivepublisher/artifactory.py
@@ -75,6 +75,11 @@ def _path_for(
             raise ValueError("Cannot publish a Go module with no module-path")
         # Base path required by https://go.dev/ref/mod#module-proxy.
         path = rootpath / module_path / "@v"
+    elif repository_format == ArchiveRepositoryFormat.GENERIC:
+        package_name = release.getUserDefinedField("name")
+        if package_name is None:
+            package_name = source_name
+        path = rootpath / package_name / source_version
     else:
         raise AssertionError(
             "Unsupported repository format: %r" % repository_format
@@ -550,6 +555,8 @@ class ArtifactoryPool:
                 "*.mod",
                 "*.zip",
             ]
+        elif repository_format == ArchiveRepositoryFormat.GENERIC:
+            return ["*"]
         else:
             raise AssertionError(
                 "Unknown repository format %r" % repository_format
diff --git a/lib/lp/archivepublisher/tests/test_artifactory.py b/lib/lp/archivepublisher/tests/test_artifactory.py
index 9aea55c..8cea8dd 100644
--- a/lib/lp/archivepublisher/tests/test_artifactory.py
+++ b/lib/lp/archivepublisher/tests/test_artifactory.py
@@ -142,6 +142,26 @@ class TestArtifactoryPool(TestCase):
             pool.pathFor(None, "go-module", "v1.0", pub_file),
         )
 
+    def test_pathFor_generic_with_file(self):
+        pool = self.makePool(ArchiveRepositoryFormat.GENERIC)
+        pub_file = FakePackageReleaseFile(
+            b"source artifact",
+            "foo-1.0.tar.gz",
+            release_type=FakeReleaseType.SOURCE,
+            user_defined_fields=[
+                ("name", "foo"),
+                ("version", "1.0"),
+                ("source", True),
+            ],
+        )
+        self.assertEqual(
+            ArtifactoryPath(
+                "https://foo.example.com/artifactory/repository/";
+                "foo/1.0/foo-1.0.tar.gz"
+            ),
+            pool.pathFor(None, "foo-generic", "1.0", pub_file),
+        )
+
     def test_addFile(self):
         pool = self.makePool()
         foo = ArtifactoryPoolTestingFile(
@@ -251,6 +271,12 @@ class TestArtifactoryPool(TestCase):
             pool.getArtifactPatterns(ArchiveRepositoryFormat.GO_PROXY),
         )
 
+    def test_getArtifactPatterns_generic(self):
+        pool = self.makePool()
+        self.assertEqual(
+            ["*"], pool.getArtifactPatterns(ArchiveRepositoryFormat.GENERIC)
+        )
+
     def test_getAllArtifacts_debian(self):
         # getAllArtifacts mostly relies on constructing a correct AQL query,
         # which we can't meaningfully test without a real Artifactory
@@ -363,6 +389,34 @@ class TestArtifactoryPool(TestCase):
             ),
         )
 
+    def test_getAllArtifacts_generic(self):
+        pool = self.makePool(ArchiveRepositoryFormat.GENERIC)
+        ArtifactoryPoolTestingFile(
+            pool=pool,
+            source_name="bar",
+            source_version="1.0",
+            filename="bar-1.0.tar.gz",
+            release_type=FakeReleaseType.SOURCE,
+            release_id=1,
+            user_defined_fields=[
+                ("name", "bar"),
+                ("version", "1.0"),
+                ("source", True),
+            ],
+        ).addToPool()
+        self.assertEqual(
+            {
+                PurePath("bar/1.0/bar-1.0.tar.gz"): {
+                    "launchpad.release-id": ["source:1"],
+                    "launchpad.source-name": ["bar"],
+                    "launchpad.source-version": ["1.0"],
+                },
+            },
+            pool.getAllArtifacts(
+                self.repository_name, ArchiveRepositoryFormat.GENERIC
+            ),
+        )
+
     def test_getAllArtifacts_handles_empty_properties(self):
         # AQL queries seem to return empty properties as something like
         # `{"key": "pypi.requires.python"}` rather than `{"key":
@@ -1102,6 +1156,168 @@ class TestArtifactoryPoolFromLibrarian(TestCaseWithFactory):
             path.properties,
         )
 
+    def test_updateProperties_generic_source(self):
+        pool = self.makePool(ArchiveRepositoryFormat.GENERIC)
+        dses = [
+            self.factory.makeDistroSeries(
+                distribution=pool.archive.distribution
+            )
+            for _ in range(2)
+        ]
+        das = self.factory.makeDistroArchSeries(distroseries=dses[0])
+        ci_build = self.factory.makeCIBuild(distro_arch_series=das)
+        spr = self.factory.makeSourcePackageRelease(
+            archive=pool.archive,
+            sourcepackagename="foo-package",
+            version="1.0",
+            format=SourcePackageType.CI_BUILD,
+            ci_build=ci_build,
+            user_defined_fields=[
+                ("name", "foo"),
+                ("version", "1.0"),
+                ("source", True),
+            ],
+        )
+        spph = self.factory.makeSourcePackagePublishingHistory(
+            archive=pool.archive,
+            sourcepackagerelease=spr,
+            distroseries=dses[0],
+            pocket=PackagePublishingPocket.RELEASE,
+            component="main",
+            sourcepackagename="foo-package",
+            version="1.0",
+            channel="edge",
+            format=SourcePackageType.CI_BUILD,
+        )
+        spr = spph.sourcepackagerelease
+        sprf = self.factory.makeSourcePackageReleaseFile(
+            sourcepackagerelease=spr,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="foo-1.0.tar.gz"
+            ),
+            filetype=SourcePackageFileType.GENERIC,
+        )
+        spphs = [spph]
+        spphs.append(
+            spph.copyTo(dses[1], PackagePublishingPocket.RELEASE, pool.archive)
+        )
+        transaction.commit()
+        pool.addFile(None, spr.name, spr.version, sprf)
+        path = pool.rootpath / "foo" / "1.0" / "foo-1.0.tar.gz"
+        self.assertTrue(path.exists())
+        self.assertFalse(path.is_symlink())
+        self.assertEqual(
+            {
+                "launchpad.release-id": ["source:%d" % spr.id],
+                "launchpad.source-name": ["foo-package"],
+                "launchpad.source-version": ["1.0"],
+                "soss.source_url": [
+                    ci_build.git_repository.getCodebrowseUrl()
+                ],
+                "soss.commit_id": [ci_build.commit_sha1],
+            },
+            path.properties,
+        )
+        pool.updateProperties(spr.name, spr.version, [sprf], spphs)
+        self.assertEqual(
+            {
+                "launchpad.release-id": ["source:%d" % spr.id],
+                "launchpad.source-name": ["foo-package"],
+                "launchpad.source-version": ["1.0"],
+                "launchpad.channel": list(
+                    sorted("%s:edge" % ds.name for ds in dses)
+                ),
+                "soss.source_url": [
+                    ci_build.git_repository.getCodebrowseUrl()
+                ],
+                "soss.commit_id": [ci_build.commit_sha1],
+            },
+            path.properties,
+        )
+
+    def test_updateProperties_generic_binary(self):
+        pool = self.makePool(ArchiveRepositoryFormat.GENERIC)
+        dses = [
+            self.factory.makeDistroSeries(
+                distribution=pool.archive.distribution
+            )
+            for _ in range(2)
+        ]
+        processor = self.factory.makeProcessor()
+        dases = [
+            self.factory.makeDistroArchSeries(
+                distroseries=ds, architecturetag=processor.name
+            )
+            for ds in dses
+        ]
+        ci_build = self.factory.makeCIBuild(distro_arch_series=dases[0])
+        bpn = self.factory.makeBinaryPackageName(name="foo")
+        bpr = self.factory.makeBinaryPackageRelease(
+            binarypackagename=bpn,
+            version="1.0",
+            ci_build=ci_build,
+            binpackageformat=BinaryPackageFormat.GENERIC,
+            user_defined_fields=[("name", "foo"), ("version", "1.0")],
+        )
+        bpf = self.factory.makeBinaryPackageFile(
+            binarypackagerelease=bpr,
+            library_file=self.factory.makeLibraryFileAlias(
+                filename="test-binary"
+            ),
+            filetype=BinaryPackageFileType.GENERIC,
+        )
+        bpph = self.factory.makeBinaryPackagePublishingHistory(
+            binarypackagerelease=bpr,
+            archive=pool.archive,
+            distroarchseries=dases[0],
+            pocket=PackagePublishingPocket.RELEASE,
+            architecturespecific=True,
+            channel="edge",
+        )
+        bpphs = [bpph]
+        bpphs.append(
+            getUtility(IPublishingSet).copyBinaries(
+                pool.archive,
+                dses[1],
+                PackagePublishingPocket.RELEASE,
+                [bpph],
+                channel="edge",
+            )[0]
+        )
+        transaction.commit()
+        pool.addFile(None, bpph.pool_name, bpph.pool_version, bpf)
+        path = pool.rootpath / "foo" / "1.0" / "test-binary"
+        self.assertTrue(path.exists())
+        self.assertFalse(path.is_symlink())
+        self.assertEqual(
+            {
+                "launchpad.release-id": ["binary:%d" % bpr.id],
+                "launchpad.source-name": ["foo"],
+                "launchpad.source-version": ["1.0"],
+                "soss.source_url": [
+                    ci_build.git_repository.getCodebrowseUrl()
+                ],
+                "soss.commit_id": [ci_build.commit_sha1],
+            },
+            path.properties,
+        )
+        pool.updateProperties(bpph.pool_name, bpph.pool_version, [bpf], bpphs)
+        self.assertEqual(
+            {
+                "launchpad.release-id": ["binary:%d" % bpr.id],
+                "launchpad.source-name": ["foo"],
+                "launchpad.source-version": ["1.0"],
+                "launchpad.channel": list(
+                    sorted("%s:edge" % ds.name for ds in dses)
+                ),
+                "soss.source_url": [
+                    ci_build.git_repository.getCodebrowseUrl()
+                ],
+                "soss.commit_id": [ci_build.commit_sha1],
+            },
+            path.properties,
+        )
+
     def test_updateProperties_preserves_externally_set_properties(self):
         # Artifactory sets some properties by itself as part of scanning
         # packages.  We leave those untouched.
diff --git a/lib/lp/registry/interfaces/sourcepackage.py b/lib/lp/registry/interfaces/sourcepackage.py
index d2a4ad2..6baff50 100644
--- a/lib/lp/registry/interfaces/sourcepackage.py
+++ b/lib/lp/registry/interfaces/sourcepackage.py
@@ -531,6 +531,16 @@ class SourcePackageFileType(DBEnumeratedType):
         """,
     )
 
+    GENERIC = DBItem(
+        15,
+        """
+        Generic source file
+
+        This file is a generic source file without any particular known
+        metadata of its own, produced by a CI build.
+        """,
+    )
+
 
 class SourcePackageType(DBEnumeratedType):
     """Source Package Format
diff --git a/lib/lp/soyuz/enums.py b/lib/lp/soyuz/enums.py
index 920975c..b3b63c9 100644
--- a/lib/lp/soyuz/enums.py
+++ b/lib/lp/soyuz/enums.py
@@ -297,6 +297,16 @@ class BinaryPackageFileType(DBEnumeratedType):
         """,
     )
 
+    GENERIC = DBItem(
+        9,
+        """
+        Generic binary file
+
+        This file is a generic binary file without any particular known
+        metadata of its own, produced by a CI build.
+        """,
+    )
+
 
 class BinaryPackageFormat(DBEnumeratedType):
     """Binary Package Format
@@ -384,6 +394,16 @@ class BinaryPackageFormat(DBEnumeratedType):
         """,
     )
 
+    GENERIC = DBItem(
+        9,
+        """
+        Generic binary
+
+        This is a generic binary format without any particular known
+        metadata of its own, produced by a CI build.
+        """,
+    )
+
 
 class PackageCopyPolicy(DBEnumeratedType):
     """Package copying policy.
@@ -947,3 +967,12 @@ class ArchiveRepositoryFormat(DBEnumeratedType):
         (https://go.dev/ref/mod#module-proxy).
         """,
     )
+
+    GENERIC = DBItem(
+        4,
+        """
+        Generic
+
+        A generic repository with a basic name/version layout and no indexing.
+        """,
+    )
diff --git a/lib/lp/soyuz/model/archivejob.py b/lib/lp/soyuz/model/archivejob.py
index 2bf29f5..f73cec1 100644
--- a/lib/lp/soyuz/model/archivejob.py
+++ b/lib/lp/soyuz/model/archivejob.py
@@ -26,6 +26,7 @@ from lp.code.interfaces.cibuild import ICIBuildSet
 from lp.code.interfaces.revisionstatus import (
     IRevisionStatusArtifact,
     IRevisionStatusArtifactSet,
+    IRevisionStatusReport,
 )
 from lp.registry.interfaces.distributionsourcepackage import (
     IDistributionSourcePackage,
@@ -305,6 +306,7 @@ class CIBuildUploadJob(ArchiveJobDerived):
         BinaryPackageFormat.WHL: BinaryPackageFileType.WHL,
         BinaryPackageFormat.CONDA_V1: BinaryPackageFileType.CONDA_V1,
         BinaryPackageFormat.CONDA_V2: BinaryPackageFileType.CONDA_V2,
+        BinaryPackageFormat.GENERIC: BinaryPackageFileType.GENERIC,
     }
 
     # We're only interested in uploading certain kinds of packages to
@@ -328,6 +330,10 @@ class CIBuildUploadJob(ArchiveJobDerived):
             SourcePackageFileType.GO_MODULE_MOD,
             SourcePackageFileType.GO_MODULE_ZIP,
         },
+        ArchiveRepositoryFormat.GENERIC: {
+            SourcePackageFileType.GENERIC,
+            BinaryPackageFormat.GENERIC,
+        },
     }
 
     @classmethod
@@ -411,7 +417,9 @@ class CIBuildUploadJob(ArchiveJobDerived):
     def target_channel(self):
         return self.metadata["target_channel"]
 
-    def _scanWheel(self, paths: Iterable[Path]) -> Dict[str, ArtifactMetadata]:
+    def _scanWheel(
+        self, report: IRevisionStatusReport, paths: Iterable[Path]
+    ) -> Dict[str, ArtifactMetadata]:
         all_metadata = {}
         for path in paths:
             if not path.name.endswith(".whl"):
@@ -436,7 +444,9 @@ class CIBuildUploadJob(ArchiveJobDerived):
             )
         return all_metadata
 
-    def _scanSDist(self, paths: Iterable[Path]) -> Dict[str, ArtifactMetadata]:
+    def _scanSDist(
+        self, report: IRevisionStatusReport, paths: Iterable[Path]
+    ) -> Dict[str, ArtifactMetadata]:
         all_metadata = {}
         for path in paths:
             if not path.name.endswith((".tar.gz", ".zip")):
@@ -484,7 +494,7 @@ class CIBuildUploadJob(ArchiveJobDerived):
         )
 
     def _scanCondaV1(
-        self, paths: Iterable[Path]
+        self, report: IRevisionStatusReport, paths: Iterable[Path]
     ) -> Dict[str, ArtifactMetadata]:
         all_metadata = {}
         for path in paths:
@@ -510,7 +520,7 @@ class CIBuildUploadJob(ArchiveJobDerived):
         return all_metadata
 
     def _scanCondaV2(
-        self, paths: Iterable[Path]
+        self, report: IRevisionStatusReport, paths: Iterable[Path]
     ) -> Dict[str, ArtifactMetadata]:
         all_metadata = {}
         for path in paths:
@@ -542,7 +552,9 @@ class CIBuildUploadJob(ArchiveJobDerived):
             )
         return all_metadata
 
-    def _scanGoMod(self, paths: Iterable[Path]) -> Dict[str, ArtifactMetadata]:
+    def _scanGoMod(
+        self, report: IRevisionStatusReport, paths: Iterable[Path]
+    ) -> Dict[str, ArtifactMetadata]:
         all_metadata = {}
         for path in paths:
             if not path.name.endswith(".mod"):
@@ -601,18 +613,54 @@ class CIBuildUploadJob(ArchiveJobDerived):
             )
         return all_metadata
 
-    def _scanFiles(self, directory: Path) -> Dict[str, ArtifactMetadata]:
+    def _scanGeneric(
+        self, report: IRevisionStatusReport, paths: Iterable[Path]
+    ) -> Dict[str, ArtifactMetadata]:
+        properties = report.properties
+        if (
+            properties is None
+            or "name" not in properties
+            or "version" not in properties
+        ):
+            return {}
+
+        all_metadata = {}
+        for path in paths:
+            if properties.get("source", False):
+                logger.info("%s is a generic source artifact", path.name)
+                all_metadata[path.name] = SourceArtifactMetadata(
+                    format=SourcePackageFileType.GENERIC,
+                    name=properties["name"],
+                    version=properties["version"],
+                )
+            else:
+                logger.info("%s is a generic binary artifact", path.name)
+                all_metadata[path.name] = BinaryArtifactMetadata(
+                    format=BinaryPackageFormat.GENERIC,
+                    name=properties["name"],
+                    version=properties["version"],
+                    summary="",
+                    description="",
+                    architecturespecific=True,
+                    homepage="",
+                )
+        return all_metadata
+
+    def _scanFiles(
+        self, report: IRevisionStatusReport, directory: Path
+    ) -> Dict[str, ArtifactMetadata]:
         scanners = (
             self._scanWheel,
             self._scanSDist,
             self._scanCondaV1,
             self._scanCondaV2,
             self._scanGoMod,
+            self._scanGeneric,
         )
         paths = [directory / child for child in directory.iterdir()]
         all_metadata = OrderedDict()
         for scanner in scanners:
-            for name, metadata in scanner(paths).items():
+            for name, metadata in scanner(report, paths).items():
                 all_metadata[name] = metadata
             paths = [path for path in paths if path.name not in all_metadata]
         return all_metadata
@@ -635,15 +683,24 @@ class CIBuildUploadJob(ArchiveJobDerived):
         with tempfile.TemporaryDirectory(prefix="ci-build-copy-job") as tmpdir:
             tmpdirpath = Path(tmpdir)
             artifact_by_name = {}
+            report_by_id = {}
             for artifact in artifacts:
                 if artifact.artifact_type == RevisionStatusArtifactType.LOG:
                     continue
                 name = artifact.library_file.filename
-                contents = str(tmpdirpath / name)
+                contents = tmpdirpath / str(artifact.report.id) / name
+                if not contents.parent.exists():
+                    contents.parent.mkdir()
                 artifact.library_file.open()
-                copy_and_close(artifact.library_file, open(contents, "wb"))
+                copy_and_close(
+                    artifact.library_file, open(str(contents), "wb")
+                )
                 artifact_by_name[name] = artifact
-            all_metadata = self._scanFiles(tmpdirpath)
+                report_by_id[str(artifact.report.id)] = artifact.report
+            all_metadata = {}
+            for report_path in sorted(tmpdirpath.iterdir()):
+                report = report_by_id[report_path.name]
+                all_metadata.update(self._scanFiles(report, report_path))
             for name, metadata in all_metadata.items():
                 if metadata.format not in allowed_formats:
                     logger.info(
diff --git a/lib/lp/soyuz/tests/test_archivejob.py b/lib/lp/soyuz/tests/test_archivejob.py
index 8d36944..623c7cb 100644
--- a/lib/lp/soyuz/tests/test_archivejob.py
+++ b/lib/lp/soyuz/tests/test_archivejob.py
@@ -273,6 +273,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             distribution=archive.distribution
         )
         build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
         job = CIBuildUploadJob.create(
             build,
             build.git_repository.owner,
@@ -284,7 +285,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         path = Path("wheel-indep/dist/wheel_indep-0.0.1-py3-none-any.whl")
         tmpdir = Path(self.useFixture(TempDir()).path)
         shutil.copy2(datadir(str(path)), str(tmpdir))
-        all_metadata = job._scanFiles(tmpdir)
+        all_metadata = job._scanFiles(report, tmpdir)
         self.assertThat(
             all_metadata,
             MatchesDict(
@@ -308,6 +309,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             distribution=archive.distribution
         )
         build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
         job = CIBuildUploadJob.create(
             build,
             build.git_repository.owner,
@@ -321,7 +323,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         )
         tmpdir = Path(self.useFixture(TempDir()).path)
         shutil.copy2(datadir(str(path)), str(tmpdir))
-        all_metadata = job._scanFiles(tmpdir)
+        all_metadata = job._scanFiles(report, tmpdir)
         self.assertThat(
             all_metadata,
             MatchesDict(
@@ -345,6 +347,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             distribution=archive.distribution
         )
         build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
         job = CIBuildUploadJob.create(
             build,
             build.git_repository.owner,
@@ -356,7 +359,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         path = Path("wheel-arch/dist/wheel-arch-0.0.1.tar.gz")
         tmpdir = Path(self.useFixture(TempDir()).path)
         shutil.copy2(datadir(str(path)), str(tmpdir))
-        all_metadata = job._scanFiles(tmpdir)
+        all_metadata = job._scanFiles(report, tmpdir)
         self.assertThat(
             all_metadata,
             MatchesDict(
@@ -377,6 +380,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             distribution=archive.distribution
         )
         build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
         job = CIBuildUploadJob.create(
             build,
             build.git_repository.owner,
@@ -388,7 +392,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         path = Path("conda-indep/dist/noarch/conda-indep-0.1-0.tar.bz2")
         tmpdir = Path(self.useFixture(TempDir()).path)
         shutil.copy2(datadir(str(path)), str(tmpdir))
-        all_metadata = job._scanFiles(tmpdir)
+        all_metadata = job._scanFiles(report, tmpdir)
         self.assertThat(
             all_metadata,
             MatchesDict(
@@ -413,6 +417,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             distribution=archive.distribution
         )
         build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
         job = CIBuildUploadJob.create(
             build,
             build.git_repository.owner,
@@ -424,7 +429,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         path = Path("conda-arch/dist/linux-64/conda-arch-0.1-0.tar.bz2")
         tmpdir = Path(self.useFixture(TempDir()).path)
         shutil.copy2(datadir(str(path)), str(tmpdir))
-        all_metadata = job._scanFiles(tmpdir)
+        all_metadata = job._scanFiles(report, tmpdir)
         self.assertThat(
             all_metadata,
             MatchesDict(
@@ -449,6 +454,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             distribution=archive.distribution
         )
         build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
         job = CIBuildUploadJob.create(
             build,
             build.git_repository.owner,
@@ -460,7 +466,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         path = Path("conda-v2-indep/dist/noarch/conda-v2-indep-0.1-0.conda")
         tmpdir = Path(self.useFixture(TempDir()).path)
         shutil.copy2(datadir(str(path)), str(tmpdir))
-        all_metadata = job._scanFiles(tmpdir)
+        all_metadata = job._scanFiles(report, tmpdir)
         self.assertThat(
             all_metadata,
             MatchesDict(
@@ -485,6 +491,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             distribution=archive.distribution
         )
         build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
         job = CIBuildUploadJob.create(
             build,
             build.git_repository.owner,
@@ -496,7 +503,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         path = Path("conda-v2-arch/dist/linux-64/conda-v2-arch-0.1-0.conda")
         tmpdir = Path(self.useFixture(TempDir()).path)
         shutil.copy2(datadir(str(path)), str(tmpdir))
-        all_metadata = job._scanFiles(tmpdir)
+        all_metadata = job._scanFiles(report, tmpdir)
         self.assertThat(
             all_metadata,
             MatchesDict(
@@ -522,6 +529,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             distribution=archive.distribution
         )
         build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(ci_build=build)
         job = CIBuildUploadJob.create(
             build,
             build.git_repository.owner,
@@ -537,7 +545,7 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         shutil.copy2(datadir(str(info_path)), str(tmpdir))
         shutil.copy2(datadir(str(mod_path)), str(tmpdir))
         shutil.copy2(datadir(str(zip_path)), str(tmpdir))
-        all_metadata = job._scanFiles(tmpdir)
+        all_metadata = job._scanFiles(report, tmpdir)
         self.assertThat(
             all_metadata,
             MatchesDict(
@@ -564,6 +572,85 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
             ),
         )
 
+    def test__scanFiles_generic_source(self):
+        self.useFixture(FakeLogger())
+        archive = self.factory.makeArchive()
+        distroseries = self.factory.makeDistroSeries(
+            distribution=archive.distribution
+        )
+        build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(
+            title="build-source", ci_build=build
+        )
+        report.update(
+            properties={
+                "name": "foo",
+                "version": "1.0",
+                "source": True,
+            }
+        )
+        job = CIBuildUploadJob.create(
+            build,
+            build.git_repository.owner,
+            archive,
+            distroseries,
+            PackagePublishingPocket.RELEASE,
+            target_channel="edge",
+        )
+        tmpdir = Path(self.useFixture(TempDir()).path)
+        (tmpdir / "foo-1.0.tar.gz").write_bytes(b"source artifact")
+        all_metadata = job._scanFiles(report, tmpdir)
+        self.assertThat(
+            all_metadata,
+            MatchesDict(
+                {
+                    "foo-1.0.tar.gz": MatchesStructure.byEquality(
+                        format=SourcePackageFileType.GENERIC,
+                        name="foo",
+                        version="1.0",
+                    )
+                }
+            ),
+        )
+
+    def test__scanFiles_generic_binary(self):
+        archive = self.factory.makeArchive()
+        distroseries = self.factory.makeDistroSeries(
+            distribution=archive.distribution
+        )
+        build = self.makeCIBuild(archive.distribution)
+        report = self.factory.makeRevisionStatusReport(
+            title="build-binary", ci_build=build
+        )
+        report.update(properties={"name": "foo", "version": "1.0"})
+        job = CIBuildUploadJob.create(
+            build,
+            build.git_repository.owner,
+            archive,
+            distroseries,
+            PackagePublishingPocket.RELEASE,
+            target_channel="edge",
+        )
+        tmpdir = Path(self.useFixture(TempDir()).path)
+        (tmpdir / "test-binary").write_bytes(b"binary artifact")
+        all_metadata = job._scanFiles(report, tmpdir)
+        self.assertThat(
+            all_metadata,
+            MatchesDict(
+                {
+                    "test-binary": MatchesStructure.byEquality(
+                        format=BinaryPackageFormat.GENERIC,
+                        name="foo",
+                        version="1.0",
+                        summary="",
+                        description="",
+                        architecturespecific=True,
+                        homepage="",
+                    )
+                }
+            ),
+        )
+
     def test_run_indep(self):
         archive = self.factory.makeArchive(
             repository_format=ArchiveRepositoryFormat.PYTHON
@@ -1046,6 +1133,126 @@ class TestCIBuildUploadJob(TestCaseWithFactory):
         )
         self.assertContentEqual([], archive.getAllPublishedBinaries())
 
+    def test_run_generic(self):
+        self.useFixture(FakeLogger())
+        archive = self.factory.makeArchive(
+            repository_format=ArchiveRepositoryFormat.GENERIC
+        )
+        distroseries = self.factory.makeDistroSeries(
+            distribution=archive.distribution
+        )
+        dases = [
+            self.factory.makeDistroArchSeries(distroseries=distroseries)
+            for _ in range(2)
+        ]
+        build = self.makeCIBuild(
+            archive.distribution, distro_arch_series=dases[0]
+        )
+        source_report = build.getOrCreateRevisionStatusReport("build-source:0")
+        source_report.setLog(b"log data")
+        source_report.update(
+            properties={
+                "name": "foo",
+                "version": "1.0",
+                "source": True,
+            }
+        )
+        source_report.attach(name="foo-1.0.tar.gz", data=b"source artifact")
+        binary_report = build.getOrCreateRevisionStatusReport("build-binary:0")
+        binary_report.setLog(b"log data")
+        binary_report.update(properties={"name": "foo", "version": "1.0"})
+        binary_report.attach(name="test-binary", data=b"binary artifact")
+        artifacts = (
+            IStore(RevisionStatusArtifact)
+            .find(
+                RevisionStatusArtifact,
+                RevisionStatusArtifact.report_id.is_in(
+                    {source_report.id, binary_report.id}
+                ),
+                RevisionStatusArtifact.artifact_type
+                == RevisionStatusArtifactType.BINARY,
+            )
+            .order_by("id")
+        )
+        job = CIBuildUploadJob.create(
+            build,
+            build.git_repository.owner,
+            archive,
+            distroseries,
+            PackagePublishingPocket.RELEASE,
+            target_channel="edge",
+        )
+        transaction.commit()
+
+        with dbuser(job.config.dbuser):
+            JobRunner([job]).runAll()
+
+        self.assertThat(
+            archive.getPublishedSources(),
+            MatchesSetwise(
+                MatchesStructure(
+                    sourcepackagename=MatchesStructure.byEquality(
+                        name=build.git_repository.target.name
+                    ),
+                    sourcepackagerelease=MatchesStructure(
+                        ci_build=Equals(build),
+                        sourcepackagename=MatchesStructure.byEquality(
+                            name=build.git_repository.target.name
+                        ),
+                        version=Equals("1.0"),
+                        format=Equals(SourcePackageType.CI_BUILD),
+                        architecturehintlist=Equals(""),
+                        creator=Equals(build.git_repository.owner),
+                        files=MatchesSetwise(
+                            MatchesStructure.byEquality(
+                                libraryfile=artifacts[0].library_file,
+                                filetype=SourcePackageFileType.GENERIC,
+                            )
+                        ),
+                        user_defined_fields=MatchesSetwise(
+                            Equals(["name", "foo"]),
+                            Equals(["version", "1.0"]),
+                            Equals(["source", True]),
+                        ),
+                    ),
+                    format=Equals(SourcePackageType.CI_BUILD),
+                    distroseries=Equals(distroseries),
+                )
+            ),
+        )
+        self.assertThat(
+            archive.getAllPublishedBinaries(),
+            MatchesSetwise(
+                MatchesStructure(
+                    binarypackagename=MatchesStructure.byEquality(name="foo"),
+                    binarypackagerelease=MatchesStructure(
+                        ci_build=Equals(build),
+                        binarypackagename=MatchesStructure.byEquality(
+                            name="foo"
+                        ),
+                        version=Equals("1.0"),
+                        summary=Equals(""),
+                        description=Equals(""),
+                        binpackageformat=Equals(BinaryPackageFormat.GENERIC),
+                        architecturespecific=Is(True),
+                        homepage=Equals(""),
+                        files=MatchesSetwise(
+                            MatchesStructure.byEquality(
+                                libraryfile=artifacts[1].library_file,
+                                filetype=BinaryPackageFileType.GENERIC,
+                            )
+                        ),
+                        user_defined_fields=MatchesSetwise(
+                            Equals(["name", "foo"]),
+                            Equals(["version", "1.0"]),
+                        ),
+                    ),
+                    binarypackageformat=Equals(BinaryPackageFormat.GENERIC),
+                    distroarchseries=Equals(dases[0]),
+                )
+            ),
+        )
+
     def test_run_attaches_properties(self):
         # The upload process attaches properties from the report as
         # `SourcePackageRelease.user_defined_fields` or