You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tsfile.apache.org by co...@apache.org on 2026/09/17 07:36:29 UTC

(tsfile) 14/22: feat(ci): assemble native package artifact bundle

This is an automated email from the ASF dual-hosted git repository.

ColinLeeo pushed a commit to branch feat/native-package-artifacts
in repository https://gitbox.apache.org/repos/asf/tsfile.git

commit 3b1495cdfbde0369e1354974bce1bc6e8c4f52fa
Author: ColinLee <sh...@163.com>
AuthorDate: Thu Sep 10 20:51:37 2026 +0800

    feat(ci): assemble native package artifact bundle
---
 .github/workflows/native-packages.yml            |  96 ++++++++
 packaging/README.md                              |  17 ++
 packaging/scripts/assemble_native_packages.py    | 244 +++++++++++++++++++
 packaging/tests/test_assemble_native_packages.py | 298 +++++++++++++++++++++++
 4 files changed, 655 insertions(+)

diff --git a/.github/workflows/native-packages.yml b/.github/workflows/native-packages.yml
index 019918a70..dc01c872d 100644
--- a/.github/workflows/native-packages.yml
+++ b/.github/workflows/native-packages.yml
@@ -659,3 +659,99 @@ jobs:
           path: homebrew/
           if-no-files-found: error
           retention-days: 14
+
+  assemble:
+    name: Assemble checksummed native package bundle
+    needs: [prepare, test-deb, test-rpm, merge-homebrew, build-windows]
+    runs-on: ubuntu-24.04
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v7
+
+      - name: Download package versions
+        uses: actions/download-artifact@v8
+        with:
+          name: native-package-versions
+          path: versions
+
+      - name: Download DEB packages
+        uses: actions/download-artifact@v8
+        with:
+          name: native-deb-ubuntu22.04-amd64
+          path: input/deb
+
+      - name: Download RPM packages
+        uses: actions/download-artifact@v8
+        with:
+          name: native-rpm-almalinux9-x86_64
+          path: input/rpm
+
+      - name: Download Homebrew Formula and bottles
+        uses: actions/download-artifact@v8
+        with:
+          name: native-homebrew
+          path: input/homebrew
+
+      - name: Download Windows ZIP
+        uses: actions/download-artifact@v8
+        with:
+          name: native-windows-msvc-x86_64
+          path: input/windows
+
+      - name: Assemble the publishable bundle
+        env:
+          LOGICAL_VERSION: ${{ needs.prepare.outputs.logical_version }}
+        shell: bash
+        run: |
+          python3 packaging/scripts/assemble_native_packages.py \
+            --input-dir input \
+            --output-dir "tsfile-native-packages-$LOGICAL_VERSION" \
+            --versions-json versions/versions.json \
+            --source-repository "${{ github.repository }}" \
+            --source-commit "${{ github.sha }}"
+
+      - name: Verify assembled manifest checksums
+        env:
+          LOGICAL_VERSION: ${{ needs.prepare.outputs.logical_version }}
+        shell: bash
+        run: |
+          python3 - <<'PY'
+          import hashlib
+          import json
+          import os
+          import re
+          from pathlib import Path
+
+          output = Path(f"tsfile-native-packages-{os.environ['LOGICAL_VERSION']}")
+          manifest = json.loads((output / "manifest.json").read_text(encoding="utf-8"))
+          checksums = (output / "SHA256SUMS").read_text(encoding="utf-8").splitlines()
+          expected_checksums = []
+          paths = set()
+          for artifact in manifest["artifacts"]:
+              relative = Path(artifact["path"])
+              assert relative.as_posix() == artifact["path"]
+              assert relative not in paths, f"Duplicate manifest path: {relative}"
+              paths.add(relative)
+              path = output / relative
+              assert path.is_file() and not path.is_symlink(), f"Missing artifact: {relative}"
+              digest = hashlib.sha256(path.read_bytes()).hexdigest()
+              assert re.fullmatch(r"[0-9a-f]{64}", artifact["sha256"])
+              assert digest == artifact["sha256"], f"Hash mismatch: {relative}"
+              assert path.stat().st_size == artifact["size"], f"Size mismatch: {relative}"
+              expected_checksums.append(f"{digest}  {relative.as_posix()}")
+          assert checksums == sorted(expected_checksums)
+          actual_paths = {
+              path.relative_to(output)
+              for path in output.rglob("*")
+              if path.is_file() and path.name not in {"SHA256SUMS", "manifest.json"}
+          }
+          assert actual_paths == paths
+          PY
+
+      - name: Upload checksummed native package bundle
+        uses: actions/upload-artifact@v7
+        with:
+          name: tsfile-native-packages-${{ needs.prepare.outputs.archive_version }}
+          path: tsfile-native-packages-${{ needs.prepare.outputs.logical_version }}/
+          if-no-files-found: error
+          retention-days: 14
diff --git a/packaging/README.md b/packaging/README.md
index bf4afaee5..c693cd1f6 100644
--- a/packaging/README.md
+++ b/packaging/README.md
@@ -133,3 +133,20 @@ The workflow uploads that ZIP as the intermediate artifact
 `native-windows-msvc-x86_64` for 14 days. Like the Linux jobs, it validates the
 license files, CMake package config, staged executable, library, import library,
 and public headers without publishing the artifact.
+
+## Final native package bundle
+
+Only after the Ubuntu 22.04 and 24.04 DEB installation tests, AlmaLinux 9 RPM
+installation test, Homebrew bottle merge, and Windows SDK/CLI build all succeed,
+the workflow assembles `tsfile-native-packages-<archive-version>`. The final
+GitHub Actions artifact retains the DEBs, RPMs, merged Formula and bottles, and
+Windows ZIP in their package-family layouts for 14 days. It also contains a
+sorted `SHA256SUMS` and `manifest.json` with the source identity, generated
+versions, byte sizes, SHA-256 values, and the JFrog repository, immutable target
+path, and properties required for later manual publication.
+
+The final job has no publishing credentials and does not upload to JFrog. A
+maintainer can later use the manifest to upload DEBs to `tsfile-debian` with the
+recorded Debian coordinates, RPMs to `tsfile-rpm/dev/el9/x86_64`, and Homebrew
+and Windows files to their immutable `tsfile/homebrew/dev/versions/<version>`
+and `tsfile/windows/dev/versions/<version>` paths.
diff --git a/packaging/scripts/assemble_native_packages.py b/packaging/scripts/assemble_native_packages.py
new file mode 100644
index 000000000..d9c6a356c
--- /dev/null
+++ b/packaging/scripts/assemble_native_packages.py
@@ -0,0 +1,244 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Assemble native package artifacts into a checksummed publishable bundle."""
+
+from __future__ import annotations
+
+import argparse
+import hashlib
+import json
+import os
+import shutil
+from pathlib import Path
+from typing import Sequence
+
+DEB_PROPERTIES = {
+    "deb.distribution": ["jammy", "noble"],
+    "deb.component": ["dev"],
+    "deb.architecture": ["amd64"],
+}
+REQUIRED_VERSIONS = {"archive_version", "homebrew_version"}
+REQUIRED_SOURCE = {"commit", "repository"}
+
+
+def _validate_metadata(values: dict[str, str], required: set[str], name: str) -> None:
+    if not isinstance(values, dict) or not required.issubset(values):
+        raise ValueError(f"{name} must contain {sorted(required)}")
+    if any(
+        not isinstance(key, str) or not isinstance(value, str)
+        for key, value in values.items()
+    ):
+        raise ValueError(f"{name} must map strings to strings")
+
+
+def _input_files(input_dir: Path) -> list[Path]:
+    if not input_dir.is_dir() or input_dir.is_symlink():
+        raise ValueError(f"input directory is not a real directory: {input_dir}")
+    files: list[Path] = []
+    for directory, directories, names in os.walk(input_dir, followlinks=False):
+        directory_path = Path(directory)
+        for child in directories:
+            path = directory_path / child
+            if path.is_symlink():
+                raise ValueError(f"symbolic link input is not allowed: {path}")
+        for name in names:
+            path = directory_path / name
+            if path.is_symlink():
+                raise ValueError(f"symbolic link input is not allowed: {path}")
+            if not path.is_file():
+                raise ValueError(f"undeclared file type: {path}")
+            if path.stat().st_size == 0:
+                raise ValueError(f"empty file input is not allowed: {path}")
+            files.append(path)
+    return sorted(files, key=lambda path: path.relative_to(input_dir).as_posix())
+
+
+def _artifact_description(
+    input_dir: Path, source_path: Path, versions: dict[str, str]
+) -> tuple[Path, dict[str, object]]:
+    relative_path = source_path.relative_to(input_dir)
+    parts = relative_path.parts
+    filename = source_path.name
+    if len(parts) >= 2 and parts[0] == "deb" and filename.endswith(".deb"):
+        target = Path("deb/ubuntu22.04-amd64") / filename
+        return target, {
+            "family": "deb",
+            "platform": "ubuntu22.04-amd64",
+            "targetRepository": "tsfile-debian",
+            "targetPath": f"pool/dev/ubuntu22.04-amd64/{filename}",
+            "properties": DEB_PROPERTIES,
+        }
+    if len(parts) >= 2 and parts[0] == "rpm" and filename.endswith(".rpm"):
+        target = Path("rpm/almalinux9-x86_64") / filename
+        return target, {
+            "family": "rpm",
+            "platform": "almalinux9-x86_64",
+            "targetRepository": "tsfile-rpm",
+            "targetPath": f"dev/el9/x86_64/{filename}",
+            "properties": {},
+        }
+    if (
+        len(parts) == 3
+        and parts[:2] == ("homebrew", "Formula")
+        and filename == "tsfile-dev.rb"
+    ):
+        target = Path(*parts)
+        return target, {
+            "family": "homebrew",
+            "platform": "homebrew",
+            "targetRepository": "tsfile",
+            "targetPath": f"homebrew/dev/versions/{versions['homebrew_version']}/{target.as_posix()[len('homebrew/'):]}",
+            "properties": {},
+        }
+    if (
+        len(parts) == 3
+        and parts[:2] == ("homebrew", "bottles")
+        and (filename.endswith(".tar.gz") or filename.endswith(".bottle.json"))
+    ):
+        target = Path(*parts)
+        return target, {
+            "family": "homebrew",
+            "platform": "homebrew",
+            "targetRepository": "tsfile",
+            "targetPath": f"homebrew/dev/versions/{versions['homebrew_version']}/{target.as_posix()[len('homebrew/'):]}",
+            "properties": {},
+        }
+    if len(parts) >= 2 and parts[0] == "windows" and filename.endswith(".zip"):
+        target = Path("windows") / filename
+        return target, {
+            "family": "windows",
+            "platform": "windows-msvc-x86_64",
+            "targetRepository": "tsfile",
+            "targetPath": f"windows/dev/versions/{versions['archive_version']}/{filename}",
+            "properties": {},
+        }
+    raise ValueError(f"undeclared file type: {relative_path.as_posix()}")
+
+
+def _require_complete_families(
+    planned: list[tuple[Path, Path, dict[str, object]]],
+) -> None:
+    families = [metadata["family"] for _, _, metadata in planned]
+    if not any(family == "deb" for family in families):
+        raise ValueError("missing DEB package input")
+    if not any(family == "rpm" for family in families):
+        raise ValueError("missing RPM package input")
+    if not any(family == "windows" for family in families):
+        raise ValueError("missing Windows ZIP input")
+    homebrew_paths = {
+        target.as_posix()
+        for _, target, metadata in planned
+        if metadata["family"] == "homebrew"
+    }
+    if "homebrew/Formula/tsfile-dev.rb" not in homebrew_paths:
+        raise ValueError("missing Homebrew Formula input")
+    if not any(path.endswith(".tar.gz") for path in homebrew_paths):
+        raise ValueError("missing Homebrew Bottle input")
+    if not any(path.endswith(".bottle.json") for path in homebrew_paths):
+        raise ValueError("missing Homebrew Bottle metadata input")
+
+
+def _sha256(path: Path) -> str:
+    digest = hashlib.sha256()
+    with path.open("rb") as source:
+        for block in iter(lambda: source.read(1024 * 1024), b""):
+            digest.update(block)
+    return digest.hexdigest()
+
+
+def assemble(
+    input_dir: Path, output_dir: Path, versions: dict[str, str], source: dict[str, str]
+) -> dict[str, object]:
+    """Copy approved artifacts and return their deterministic publication manifest."""
+    _validate_metadata(versions, REQUIRED_VERSIONS, "versions")
+    _validate_metadata(source, REQUIRED_SOURCE, "source")
+    input_dir = Path(input_dir)
+    output_dir = Path(output_dir)
+    files = _input_files(input_dir)
+    planned: list[tuple[Path, Path, dict[str, object]]] = []
+    target_paths: set[Path] = set()
+    for source_path in files:
+        target, metadata = _artifact_description(input_dir, source_path, versions)
+        if target in target_paths:
+            raise ValueError(f"duplicate target path: {target.as_posix()}")
+        target_paths.add(target)
+        planned.append((source_path, target, metadata))
+    _require_complete_families(planned)
+    if output_dir.is_symlink():
+        raise ValueError(f"output directory must not be a symbolic link: {output_dir}")
+    if output_dir.exists() and any(output_dir.iterdir()):
+        raise ValueError(f"output directory must be empty: {output_dir}")
+    output_dir.mkdir(parents=True, exist_ok=True)
+
+    artifacts: list[dict[str, object]] = []
+    for source_path, target, metadata in sorted(
+        planned, key=lambda item: item[1].as_posix()
+    ):
+        destination = output_dir / target
+        destination.parent.mkdir(parents=True, exist_ok=True)
+        shutil.copyfile(source_path, destination)
+        artifact = {
+            **metadata,
+            "filename": destination.name,
+            "path": target.as_posix(),
+            "size": destination.stat().st_size,
+            "sha256": _sha256(destination),
+        }
+        artifacts.append(artifact)
+
+    checksums = "".join(
+        f"{artifact['sha256']}  {artifact['path']}\n" for artifact in artifacts
+    )
+    (output_dir / "SHA256SUMS").write_text(checksums, encoding="utf-8")
+    manifest: dict[str, object] = {
+        "artifacts": artifacts,
+        "source": source,
+        "versions": versions,
+    }
+    (output_dir / "manifest.json").write_text(
+        json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8"
+    )
+    return manifest
+
+
+def _argument_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument("--input-dir", type=Path, required=True)
+    parser.add_argument("--output-dir", type=Path, required=True)
+    parser.add_argument("--versions-json", type=Path, required=True)
+    parser.add_argument("--source-repository", required=True)
+    parser.add_argument("--source-commit", required=True)
+    return parser
+
+
+def main(argv: Sequence[str] | None = None) -> int:
+    """Assemble a bundle from an Actions artifact download directory."""
+    arguments = _argument_parser().parse_args(argv)
+    versions = json.loads(arguments.versions_json.read_text(encoding="utf-8"))
+    assemble(
+        arguments.input_dir,
+        arguments.output_dir,
+        versions,
+        {"repository": arguments.source_repository, "commit": arguments.source_commit},
+    )
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/packaging/tests/test_assemble_native_packages.py b/packaging/tests/test_assemble_native_packages.py
new file mode 100644
index 000000000..ab2342378
--- /dev/null
+++ b/packaging/tests/test_assemble_native_packages.py
@@ -0,0 +1,298 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import importlib.util
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+MODULE_PATH = REPOSITORY_ROOT / "packaging/scripts/assemble_native_packages.py"
+VERSIONS = {
+    "archive_version": "2.5.0-dev0.20260910.123.1.gabcdef1",
+    "homebrew_version": "2.5.0.dev0.20260910.123.1.gabcdef1",
+    "logical_version": "2.5.0.dev0+20260910.123.1.gabcdef1",
+}
+SOURCE = {
+    "commit": "abcdef1234567890abcdef1234567890abcdef12",
+    "repository": "ColinLeeo/tsfile",
+}
+
+
+def load_assembler_module():
+    if not MODULE_PATH.is_file():
+        return None
+    spec = importlib.util.spec_from_file_location(
+        "assemble_native_packages", MODULE_PATH
+    )
+    if spec is None or spec.loader is None:
+        raise ImportError(f"cannot load native package assembler from {MODULE_PATH}")
+    module = importlib.util.module_from_spec(spec)
+    spec.loader.exec_module(module)
+    return module
+
+
+class AssembleNativePackagesTest(unittest.TestCase):
+    @classmethod
+    def setUpClass(cls):
+        cls.module = load_assembler_module()
+
+    def require_module(self):
+        self.assertIsNotNone(
+            self.module, "native package assembler must exist for final bundle assembly"
+        )
+        return self.module
+
+    def write_fixture(self, directory):
+        files = {
+            "deb/tsfile_2.5.0-dev_amd64.deb": b"deb artifact\n",
+            "rpm/tsfile-2.5.0-dev.x86_64.rpm": b"rpm artifact\n",
+            "homebrew/Formula/tsfile-dev.rb": b"formula artifact\n",
+            "homebrew/bottles/tsfile-dev.bottle.tar.gz": b"bottle artifact\n",
+            "homebrew/bottles/tsfile-dev.bottle.json": b"bottle metadata\n",
+            "windows/tsfile-2.5.0-dev-windows-x86_64.zip": b"windows artifact\n",
+        }
+        for relative_path, contents in files.items():
+            path = directory / relative_path
+            path.parent.mkdir(parents=True, exist_ok=True)
+            path.write_bytes(contents)
+        return files
+
+    def test_assembles_publishable_bundle_with_literal_metadata(self):
+        module = self.require_module()
+
+        with tempfile.TemporaryDirectory() as temporary_directory:
+            input_directory = Path(temporary_directory) / "input"
+            output_directory = Path(temporary_directory) / "bundle"
+            self.write_fixture(input_directory)
+
+            manifest = module.assemble(
+                input_directory, output_directory, VERSIONS, SOURCE
+            )
+
+            expected_paths = [
+                "deb/ubuntu22.04-amd64/tsfile_2.5.0-dev_amd64.deb",
+                "homebrew/Formula/tsfile-dev.rb",
+                "homebrew/bottles/tsfile-dev.bottle.json",
+                "homebrew/bottles/tsfile-dev.bottle.tar.gz",
+                "rpm/almalinux9-x86_64/tsfile-2.5.0-dev.x86_64.rpm",
+                "windows/tsfile-2.5.0-dev-windows-x86_64.zip",
+            ]
+            self.assertEqual(
+                sorted(
+                    path.relative_to(output_directory).as_posix()
+                    for path in output_directory.rglob("*")
+                    if path.is_file()
+                    and path.name not in {"SHA256SUMS", "manifest.json"}
+                ),
+                expected_paths,
+            )
+            self.assertEqual(
+                (output_directory / "SHA256SUMS").read_text(encoding="utf-8"),
+                "ef5ca1431457b6dec117aff4aafcfd14edec3fe628d3fefe334867e502b8c137  deb/ubuntu22.04-amd64/tsfile_2.5.0-dev_amd64.deb\n"
+                "b045d33311f553503342e1b061df60161e414503d68dc14af90537f254dfc224  homebrew/Formula/tsfile-dev.rb\n"
+                "ae98b99fb84c92fb10b00d75e0c51035006067659043b9dc0cb08d9902633967  homebrew/bottles/tsfile-dev.bottle.json\n"
+                "95f3ddb71b1a0c5c95c5aa0352321075cc465d6a583dff8bf601e888abec4b82  homebrew/bottles/tsfile-dev.bottle.tar.gz\n"
+                "f557fbfdaf15a34e59adaf4fa487062419dd8fe39eb057f6b581b4469cd3a25f  rpm/almalinux9-x86_64/tsfile-2.5.0-dev.x86_64.rpm\n"
+                "f59fc30fd2aaa4a4594eb006fce33041e2702b621abf936af5619b3eabf23c8f  windows/tsfile-2.5.0-dev-windows-x86_64.zip\n",
+            )
+            self.assertEqual(
+                json.loads((output_directory / "manifest.json").read_text()), manifest
+            )
+            self.assertEqual(manifest["source"], SOURCE)
+            self.assertEqual(manifest["versions"], VERSIONS)
+            self.assertEqual(
+                manifest["artifacts"],
+                [
+                    {
+                        "family": "deb",
+                        "filename": "tsfile_2.5.0-dev_amd64.deb",
+                        "path": "deb/ubuntu22.04-amd64/tsfile_2.5.0-dev_amd64.deb",
+                        "platform": "ubuntu22.04-amd64",
+                        "properties": {
+                            "deb.architecture": ["amd64"],
+                            "deb.component": ["dev"],
+                            "deb.distribution": ["jammy", "noble"],
+                        },
+                        "sha256": "ef5ca1431457b6dec117aff4aafcfd14edec3fe628d3fefe334867e502b8c137",
+                        "size": 13,
+                        "targetPath": "pool/dev/ubuntu22.04-amd64/tsfile_2.5.0-dev_amd64.deb",
+                        "targetRepository": "tsfile-debian",
+                    },
+                    {
+                        "family": "homebrew",
+                        "filename": "tsfile-dev.rb",
+                        "path": "homebrew/Formula/tsfile-dev.rb",
+                        "platform": "homebrew",
+                        "properties": {},
+                        "sha256": "b045d33311f553503342e1b061df60161e414503d68dc14af90537f254dfc224",
+                        "size": 17,
+                        "targetPath": "homebrew/dev/versions/2.5.0.dev0.20260910.123.1.gabcdef1/Formula/tsfile-dev.rb",
+                        "targetRepository": "tsfile",
+                    },
+                    {
+                        "family": "homebrew",
+                        "filename": "tsfile-dev.bottle.json",
+                        "path": "homebrew/bottles/tsfile-dev.bottle.json",
+                        "platform": "homebrew",
+                        "properties": {},
+                        "sha256": "ae98b99fb84c92fb10b00d75e0c51035006067659043b9dc0cb08d9902633967",
+                        "size": 16,
+                        "targetPath": "homebrew/dev/versions/2.5.0.dev0.20260910.123.1.gabcdef1/bottles/tsfile-dev.bottle.json",
+                        "targetRepository": "tsfile",
+                    },
+                    {
+                        "family": "homebrew",
+                        "filename": "tsfile-dev.bottle.tar.gz",
+                        "path": "homebrew/bottles/tsfile-dev.bottle.tar.gz",
+                        "platform": "homebrew",
+                        "properties": {},
+                        "sha256": "95f3ddb71b1a0c5c95c5aa0352321075cc465d6a583dff8bf601e888abec4b82",
+                        "size": 16,
+                        "targetPath": "homebrew/dev/versions/2.5.0.dev0.20260910.123.1.gabcdef1/bottles/tsfile-dev.bottle.tar.gz",
+                        "targetRepository": "tsfile",
+                    },
+                    {
+                        "family": "rpm",
+                        "filename": "tsfile-2.5.0-dev.x86_64.rpm",
+                        "path": "rpm/almalinux9-x86_64/tsfile-2.5.0-dev.x86_64.rpm",
+                        "platform": "almalinux9-x86_64",
+                        "properties": {},
+                        "sha256": "f557fbfdaf15a34e59adaf4fa487062419dd8fe39eb057f6b581b4469cd3a25f",
+                        "size": 13,
+                        "targetPath": "dev/el9/x86_64/tsfile-2.5.0-dev.x86_64.rpm",
+                        "targetRepository": "tsfile-rpm",
+                    },
+                    {
+                        "family": "windows",
+                        "filename": "tsfile-2.5.0-dev-windows-x86_64.zip",
+                        "path": "windows/tsfile-2.5.0-dev-windows-x86_64.zip",
+                        "platform": "windows-msvc-x86_64",
+                        "properties": {},
+                        "sha256": "f59fc30fd2aaa4a4594eb006fce33041e2702b621abf936af5619b3eabf23c8f",
+                        "size": 17,
+                        "targetPath": "windows/dev/versions/2.5.0-dev0.20260910.123.1.gabcdef1/tsfile-2.5.0-dev-windows-x86_64.zip",
+                        "targetRepository": "tsfile",
+                    },
+                ],
+            )
+
+    def test_rejects_unknown_files(self):
+        module = self.require_module()
+
+        with tempfile.TemporaryDirectory() as temporary_directory:
+            input_directory = Path(temporary_directory) / "input"
+            self.write_fixture(input_directory)
+            (input_directory / "deb/README.txt").write_text(
+                "not a package\n", encoding="utf-8"
+            )
+
+            with self.assertRaisesRegex(ValueError, "undeclared file type"):
+                module.assemble(
+                    input_directory,
+                    Path(temporary_directory) / "bundle",
+                    VERSIONS,
+                    SOURCE,
+                )
+
+    def test_rejects_empty_and_symbolic_link_inputs(self):
+        module = self.require_module()
+
+        with tempfile.TemporaryDirectory() as temporary_directory:
+            input_directory = Path(temporary_directory) / "input"
+            self.write_fixture(input_directory)
+            (input_directory / "rpm/tsfile-2.5.0-dev.x86_64.rpm").write_bytes(b"")
+            with self.assertRaisesRegex(ValueError, "empty file"):
+                module.assemble(
+                    input_directory,
+                    Path(temporary_directory) / "empty",
+                    VERSIONS,
+                    SOURCE,
+                )
+
+            self.write_fixture(input_directory)
+            os.symlink(
+                input_directory / "deb/tsfile_2.5.0-dev_amd64.deb",
+                input_directory / "deb/linked.deb",
+            )
+            with self.assertRaisesRegex(ValueError, "symbolic link"):
+                module.assemble(
+                    input_directory,
+                    Path(temporary_directory) / "linked",
+                    VERSIONS,
+                    SOURCE,
+                )
+
+    def test_rejects_duplicate_destination_names(self):
+        module = self.require_module()
+
+        with tempfile.TemporaryDirectory() as temporary_directory:
+            input_directory = Path(temporary_directory) / "input"
+            self.write_fixture(input_directory)
+            duplicate = input_directory / "deb/duplicate/tsfile_2.5.0-dev_amd64.deb"
+            duplicate.parent.mkdir()
+            duplicate.write_bytes(b"another deb artifact\n")
+
+            with self.assertRaisesRegex(ValueError, "duplicate target path"):
+                module.assemble(
+                    input_directory,
+                    Path(temporary_directory) / "bundle",
+                    VERSIONS,
+                    SOURCE,
+                )
+
+    def test_cli_assembles_from_versions_json(self):
+        self.require_module()
+
+        with tempfile.TemporaryDirectory() as temporary_directory:
+            temporary_path = Path(temporary_directory)
+            input_directory = temporary_path / "input"
+            self.write_fixture(input_directory)
+            versions_path = temporary_path / "versions.json"
+            versions_path.write_text(json.dumps(VERSIONS), encoding="utf-8")
+            output_directory = temporary_path / "bundle"
+
+            subprocess.run(
+                [
+                    sys.executable,
+                    str(MODULE_PATH),
+                    "--input-dir",
+                    str(input_directory),
+                    "--output-dir",
+                    str(output_directory),
+                    "--versions-json",
+                    str(versions_path),
+                    "--source-repository",
+                    SOURCE["repository"],
+                    "--source-commit",
+                    SOURCE["commit"],
+                ],
+                check=True,
+            )
+            manifest_path = output_directory / "manifest.json"
+            self.assertTrue(manifest_path.is_file(), "CLI must write manifest.json")
+            self.assertEqual(
+                json.loads(manifest_path.read_text(encoding="utf-8"))["source"], SOURCE
+            )
+
+
+if __name__ == "__main__":
+    unittest.main()