Files
lancedb/ci/tests/test_package_cli.py
T
Colin Patrick McCabe 2044179a8b feat: add a CLI release binary (#4751)
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.
2026-10-30 12:54:54 -07:00

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