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()