mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
Package the standalone lancedb CLI with every stable and beta GitHub release. Publish archives for Linux x86_64, Linux ARM64, and Apple Silicon macOS, along with SHA-256 checksums, licenses, and third-party notices. Synchronize the CLI version with the SDKs and validate that release tags match both manifests. Add PR and manual packaging dry runs, runtime compatibility checks, and smoke tests of extracted executables. GitHub release creation now waits for all three archives to pass validation; SDK registry publishing remains independent. Update installation instructions and the release checklist. Linux binaries target glibc 2.28, retaining existing x86_64 CPU requirements; macOS binaries target macOS 14 or newer.
229 lines
9.3 KiB
Python
229 lines
9.3 KiB
Python
import io
|
|
import os
|
|
import tarfile
|
|
import tempfile
|
|
import tomllib
|
|
import unittest
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from ci import package_cli as cli
|
|
|
|
VERSION = "1.2.3-beta.4"
|
|
|
|
|
|
class PackageCliTest(unittest.TestCase):
|
|
def setUp(self):
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(self.tmp.cleanup)
|
|
self.root = Path(self.tmp.name)
|
|
self.output = self.root / "dist"
|
|
self.write_versions(VERSION)
|
|
for name in cli.NOTICES:
|
|
(self.root / name).write_text(f"Test {name}\n")
|
|
self.binary = self.root / "lancedb"
|
|
self.binary.write_text(
|
|
"#!/usr/bin/env python3\n"
|
|
"import os, sys\n"
|
|
"assert not any(k.startswith('LANCEDB_') for k in os.environ)\n"
|
|
"assert os.environ['HOME'] != os.environ.get('ORIGINAL_HOME')\n"
|
|
"assert not os.listdir(os.environ['HOME'])\n"
|
|
f"print({{('--version',): 'lancedb {VERSION}', "
|
|
"('--help',): 'Usage: lancedb', "
|
|
"('profile', 'list'): 'Configured profiles:'}[tuple(sys.argv[1:])])\n"
|
|
)
|
|
# Packaging must restore executable permissions even if the input lacks them.
|
|
self.binary.chmod(0o644)
|
|
|
|
def write_versions(self, version):
|
|
(self.root / ".bumpversion.toml").write_text(
|
|
f'[tool.bumpversion]\ncurrent_version = "{version}"\n'
|
|
)
|
|
for manifest in ("rust/lancedb/Cargo.toml", "rust/lancedb/cli/Cargo.toml"):
|
|
path = self.root / manifest
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(f'[package]\nversion = "{version}"\n')
|
|
|
|
def make_package(self, target=cli.TARGETS[0]):
|
|
with patch.object(cli, "check_runtime"):
|
|
return cli.package(self.root, self.binary, target, self.output, VERSION)
|
|
|
|
def test_stable_and_beta_versions_and_tags(self):
|
|
for version in ("1.2.3", VERSION):
|
|
self.write_versions(version)
|
|
for ref in (
|
|
f"refs/tags/v{version}",
|
|
"refs/heads/main",
|
|
"refs/pull/1/merge",
|
|
):
|
|
with self.subTest(version=version, ref=ref):
|
|
self.assertEqual(cli.release_version(self.root, ref), version)
|
|
|
|
def test_mismatched_tag_and_manifests_fail(self):
|
|
with self.assertRaisesRegex(ValueError, "Tag"):
|
|
cli.release_version(self.root, "refs/tags/v1.2.3")
|
|
for manifest in ("rust/lancedb/Cargo.toml", "rust/lancedb/cli/Cargo.toml"):
|
|
self.write_versions(VERSION)
|
|
(self.root / manifest).write_text('[package]\nversion = "0.1.0"\n')
|
|
with (
|
|
self.subTest(manifest=manifest),
|
|
self.assertRaisesRegex(ValueError, "version does not match"),
|
|
):
|
|
cli.release_version(self.root, "refs/heads/main")
|
|
|
|
def test_cli_participates_in_repository_version_bumps(self):
|
|
config = tomllib.loads((cli.ROOT / ".bumpversion.toml").read_text())["tool"][
|
|
"bumpversion"
|
|
]
|
|
entry = next(
|
|
f
|
|
for f in config["files"]
|
|
if f.get("filename") == "rust/lancedb/cli/Cargo.toml"
|
|
)
|
|
manifest = (cli.ROOT / entry["filename"]).read_text()
|
|
version = cli.release_version(cli.ROOT, "")
|
|
self.assertIn(entry["search"].format(current_version=version), manifest)
|
|
lock = tomllib.loads((cli.ROOT / "Cargo.lock").read_text())
|
|
package = next(p for p in lock["package"] if p["name"] == "lancedb-cli")
|
|
self.assertEqual(package["version"], version)
|
|
|
|
def test_archive_layout_permissions_and_isolated_smoke_test(self):
|
|
with patch.dict(
|
|
os.environ,
|
|
{"LANCEDB_API_KEY": "unused", "ORIGINAL_HOME": os.environ["HOME"]},
|
|
):
|
|
archive = self.make_package()
|
|
self.assertEqual(archive.name, f"lancedb-v{VERSION}-{cli.TARGETS[0]}.tar.gz")
|
|
cli.verify_archive(archive, VERSION, cli.TARGETS[0])
|
|
with tarfile.open(archive) as tar:
|
|
for member in tar.getmembers():
|
|
self.assertEqual(member.uid, 0)
|
|
self.assertEqual(member.gid, 0)
|
|
|
|
def test_wrong_binary_version_fails_smoke_test(self):
|
|
self.binary.write_text(
|
|
self.binary.read_text().replace(f"lancedb {VERSION}", "lancedb 0.1.0")
|
|
)
|
|
with self.assertRaisesRegex(ValueError, "wrong version"):
|
|
self.make_package()
|
|
|
|
def test_bad_archive_members_are_rejected(self):
|
|
archive = self.make_package()
|
|
stem = cli.archive_stem(VERSION, cli.TARGETS[0])
|
|
for bad in (
|
|
"missing",
|
|
"extra",
|
|
"duplicate",
|
|
"symlink",
|
|
"permissions",
|
|
"empty",
|
|
"traversal",
|
|
):
|
|
with self.subTest(bad=bad):
|
|
with tarfile.open(archive, "w:gz") as tar:
|
|
for name in ("lancedb", *cli.NOTICES):
|
|
if bad == "missing" and name == "LICENSE":
|
|
continue
|
|
member = tarfile.TarInfo(f"{stem}/{name}")
|
|
member.mode = 0o755 if name == "lancedb" else 0o644
|
|
member.size = 1
|
|
if name == "lancedb":
|
|
if bad == "symlink":
|
|
member.type = tarfile.SYMTYPE
|
|
member.linkname = "/usr/bin/true"
|
|
member.size = 0
|
|
elif bad == "permissions":
|
|
member.mode = 0o644
|
|
elif bad == "empty":
|
|
member.size = 0
|
|
elif bad == "traversal":
|
|
member.name = "../lancedb"
|
|
tar.addfile(member, io.BytesIO(b"x"))
|
|
if bad in ("extra", "duplicate"):
|
|
member = tarfile.TarInfo(
|
|
f"{stem}/{'LICENSE' if bad == 'duplicate' else 'extra'}"
|
|
)
|
|
tar.addfile(member)
|
|
with self.assertRaises(ValueError):
|
|
cli.verify_archive(archive, VERSION, cli.TARGETS[0])
|
|
|
|
def test_checksums_require_exact_target_set_and_detect_tampering(self):
|
|
self.make_package()
|
|
with self.assertRaisesRegex(ValueError, "exactly"):
|
|
cli.checksum_lines(self.output, VERSION)
|
|
for target in cli.TARGETS[1:]:
|
|
self.make_package(target)
|
|
manifest = self.output / "SHA256SUMS"
|
|
lines = cli.checksum_lines(self.output, VERSION)
|
|
manifest.write_text(lines)
|
|
self.assertEqual(len(lines.splitlines()), 3)
|
|
cli.verify_checksums(self.output, VERSION)
|
|
# Still a valid archive, but a different payload from the published digest.
|
|
self.binary.write_text(self.binary.read_text() + "# changed\n")
|
|
self.make_package()
|
|
with self.assertRaisesRegex(ValueError, "SHA256SUMS"):
|
|
cli.verify_checksums(self.output, VERSION)
|
|
(self.output / "unexpected.tar.gz").write_bytes(b"extra")
|
|
with self.assertRaisesRegex(ValueError, "exactly"):
|
|
cli.checksum_lines(self.output, VERSION)
|
|
|
|
|
|
class RuntimeMetadataTest(unittest.TestCase):
|
|
def test_linux_baseline_and_architectures(self):
|
|
for target, machine in (
|
|
(cli.TARGETS[0], "Advanced Micro Devices X86-64"),
|
|
(cli.TARGETS[1], "AArch64"),
|
|
):
|
|
cli.check_linux_metadata(
|
|
f" Machine: {machine}\n",
|
|
" (NEEDED) Shared library: [libc.so.6]\n (NEEDED) Shared library: [libgcc_s.so.1]",
|
|
"GLIBC_2.28 GLIBCXX_3.4.25 CXXABI_1.3.11 GCC_7.0.0",
|
|
target,
|
|
)
|
|
|
|
def test_linux_rejects_wrong_arch_new_symbols_and_non_system_libraries(self):
|
|
header = " Machine: Advanced Micro Devices X86-64\n"
|
|
dynamic = " (NEEDED) Shared library: [libc.so.6]"
|
|
cases = [(" Machine: AArch64\n", dynamic, "GLIBC_2.28")]
|
|
for symbol in (
|
|
"GLIBC_2.29",
|
|
"GLIBCXX_3.4.26",
|
|
"CXXABI_1.3.12",
|
|
"GCC_12.0.0",
|
|
"GLIBC_PRIVATE",
|
|
):
|
|
cases.append((header, dynamic, symbol))
|
|
for extra in (
|
|
" (NEEDED) Shared library: [libssl.so.3]",
|
|
" (RUNPATH) Library runpath: [/opt/build/lib]",
|
|
):
|
|
cases.append((header, dynamic + extra, "GLIBC_2.28"))
|
|
for args in cases:
|
|
with self.subTest(args=args), self.assertRaises(ValueError):
|
|
cli.check_linux_metadata(*args, cli.TARGETS[0])
|
|
|
|
def test_macos_baseline_and_system_libraries(self):
|
|
cli.check_macos_metadata(
|
|
"arm64\n",
|
|
"cli:\n\t/usr/lib/libSystem.B.dylib (compatibility version 1.0.0)",
|
|
" minos 14.0\n",
|
|
)
|
|
|
|
def test_macos_rejects_wrong_arch_version_and_homebrew_dependencies(self):
|
|
for args in (
|
|
("x86_64", "", " minos 14.0\n"),
|
|
("arm64", "", " minos 15.0\n"),
|
|
("arm64", "", ""),
|
|
(
|
|
"arm64",
|
|
"\t/opt/homebrew/lib/libssl.dylib (compatibility version 3.0.0)",
|
|
" minos 14.0\n",
|
|
),
|
|
):
|
|
with self.subTest(args=args), self.assertRaises(ValueError):
|
|
cli.check_macos_metadata(*args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|