blob: edc0cb06133888ebfd95b54e50b7c908e91bf1ad [file]
# 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 contextlib
import io
import os
import shutil
import subprocess
import sys
import tarfile
import tempfile
import tomllib
import unittest
from pathlib import Path
from scripts import version
class TestReleaseVersion(unittest.TestCase):
def setUp(self):
directory = tempfile.TemporaryDirectory()
self.addCleanup(directory.cleanup)
self.root = Path(directory.name)
paths = ["pyproject.toml", *(f"{module}/pyproject.toml" for module in version.MODULES)]
for relative in (*paths, *version.DOCS, *version.DEPLOYMENT):
target = self.root / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(version.ROOT / relative, target)
self.stdout = io.StringIO()
self.stderr = io.StringIO()
def run_command(self, *args):
with contextlib.redirect_stdout(self.stdout), contextlib.redirect_stderr(self.stderr):
return version.main(list(args), root=self.root)
def bump_root(self):
path = self.root / "pyproject.toml"
current = version.release_config(self.root)["version"]
path.write_text(path.read_text().replace(f'version = "{current}"', 'version = "1.9.2"'))
def test_root_bump_syncs_packages_dependency_and_deployment(self):
config = self.root / "pyproject.toml"
server = "registry.example:5000/team/server@sha256:" + "a" * 64
config.write_text(config.read_text().replace(version.release_config(self.root)["server-image"], server))
self.bump_root()
self.assertEqual(0, self.run_command("sync"))
for module in version.MODULES:
project = tomllib.loads((self.root / module / "pyproject.toml").read_text())["project"]
self.assertEqual("1.9.2", project["version"])
if any(dependency.startswith("hugegraph-python>=") for dependency in project.get("dependencies", [])):
self.assertIn("hugegraph-python>=1.9.2,<2", project["dependencies"])
if module == "hugegraph-llm":
self.assertIn("hugegraph-llm-sdk==1.9.2", project["dependencies"])
compose = (self.root / version.DEPLOYMENT[0]).read_text()
self.assertIn(f"image: ${{HUGEGRAPH_SERVER_IMAGE:-{server}}}", compose)
self.assertIn("hugegraph/rag:1.9.2", compose)
self.assertIn("hugegraph-mcp@1.9.2", (self.root / version.DOCS[0]).read_text())
self.assertIn("hugegraph_llm_sdk-1.9.2-py3", (self.root / "hugegraph-llm-sdk/README.md").read_text())
self.assertEqual(0, self.run_command("check"))
def test_check_reports_drift_without_writing(self):
self.bump_root()
before = {p: p.read_bytes() for p in self.root.rglob("*") if p.is_file()}
self.assertEqual(1, self.run_command("check"))
self.assertIn("hugegraph-mcp/pyproject.toml", self.stdout.getvalue())
self.assertIn('"${PYTHON_FOR_RELEASE:-python3}" scripts/version.py sync', self.stderr.getvalue())
self.assertEqual(before, {p: p.read_bytes() for p in before})
def test_sync_is_idempotent(self):
self.assertEqual(0, self.run_command("sync"))
self.stdout.truncate(0)
self.stdout.seek(0)
self.assertEqual(0, self.run_command("sync"))
self.assertEqual("", self.stdout.getvalue())
def test_consecutive_custom_image_changes_keep_documentation_synchronized(self):
for index in (1, 2):
config = self.root / "pyproject.toml"
previous = version.release_config(self.root)
server = f"registry.example:5000/team/server{index}@sha256:" + str(index) * 64
llm = f"registry.example:5000/team/rag{index}"
config.write_text(
config.read_text().replace(previous["server-image"], server).replace(previous["llm-image"], llm)
)
self.assertEqual(1, self.run_command("check"))
self.assertEqual(0, self.run_command("sync"))
readme = (self.root / "hugegraph-llm/README.md").read_text()
self.assertIn(server, readme)
self.assertIn(llm + ":1.8.0", readme)
if index == 2:
self.assertNotIn("server1@", readme)
self.assertEqual(0, self.run_command("check"))
self.assertEqual(0, self.run_command("sync"))
self.bump_root()
self.assertEqual(0, self.run_command("sync"))
self.assertIn("registry.example:5000/team/rag2:1.9.2", (self.root / "hugegraph-llm/README.md").read_text())
self.assertEqual(0, self.run_command("check"))
def test_ci_tool_minimums_follow_root_dev_requirements(self):
path = self.root / "pyproject.toml"
previous = version.release_config(self.root)["ruff-version"]
previous_ty = version.release_config(self.root)["ty-version"]
path.write_text(path.read_text().replace(f"ruff>={previous}", "ruff>=0.17.99"))
self.assertEqual("0.17.99", version.release_config(self.root)["ruff-version"])
self.assertEqual(previous_ty, version.release_config(self.root)["ty-version"])
def test_tool_versions_must_remain_open_ended_requirements(self):
path = self.root / "pyproject.toml"
path.write_text(path.read_text().replace("ruff>=", "ruff=="))
self.assertEqual(1, self.run_command("get", "ruff-version"))
self.assertIn("Declare ruff with >=", self.stderr.getvalue())
def test_history_minimums_and_protocol_versions_are_preserved(self):
self.bump_root()
readme = self.root / version.DOCS[0]
historical = "\nMigration from `1.7.1`; Server >= 1.7.0; protocol_version=1.\n"
readme.write_text(readme.read_text() + historical)
self.assertEqual(0, self.run_command("sync"))
self.assertIn(historical, readme.read_text())
def test_invalid_metadata_fails_before_any_write(self):
self.bump_root()
path = self.root / "hugegraph-mcp/pyproject.toml"
path.write_text(path.read_text().replace('version = "', 'missing_version = "'))
before = {p: p.read_bytes() for p in self.root.rglob("*") if p.is_file()}
self.assertEqual(1, self.run_command("sync"))
self.assertEqual(before, {p: p.read_bytes() for p in before})
def test_get_reads_root_config_without_modifying_metadata(self):
self.bump_root()
self.assertEqual(0, self.run_command("get", "version"))
self.assertEqual("1.9.2\n", self.stdout.getvalue())
def copy_scripts(self, *names):
(self.root / "scripts").mkdir(exist_ok=True)
for name in ("version.py", *names):
shutil.copyfile(version.ROOT / "scripts" / name, self.root / "scripts" / name)
def test_build_script_reads_root_tag_from_another_directory(self):
self.bump_root()
self.run_command("sync")
self.copy_scripts("build_llm_image.sh")
docker = self.root / "bin" / "docker"
docker.parent.mkdir()
docker.write_text('#!/bin/sh\nprintf "%s\\n" "$@"\n')
docker.chmod(0o755)
result = subprocess.run(
["bash", str(self.root / "scripts/build_llm_image.sh")],
cwd=self.root / "bin",
env={
**os.environ,
"PATH": str(docker.parent) + os.pathsep + os.environ["PATH"],
"PYTHON_FOR_RELEASE": sys.executable,
},
check=True,
text=True,
capture_output=True,
)
self.assertIn("hugegraph/rag:1.9.2", result.stdout.splitlines())
def test_release_script_rejects_mismatching_version_before_packaging(self):
self.run_command("sync")
self.copy_scripts("apache-release.sh")
result = subprocess.run(
["bash", str(self.root / "scripts/apache-release.sh"), "99.99.99"],
env={**os.environ, "PYTHON_FOR_RELEASE": sys.executable},
capture_output=True,
text=True,
check=False,
)
self.assertNotEqual(0, result.returncode)
self.assertIn("Release version must match root", result.stderr)
self.assertFalse((self.root / "scripts/dist").exists())
def git(self, *args):
return subprocess.check_output(["git", "-C", str(self.root), *args], text=True).strip()
def prepare_release_branch(self, *, stale_path=None):
self.run_command("sync")
self.copy_scripts("apache-release.sh")
shutil.copyfile(version.ROOT / ".gitattributes", self.root / ".gitattributes")
self.git("init", "-q")
self.git("config", "user.name", "Release Test")
self.git("config", "user.email", "release-test@example.invalid")
self.git("config", "commit.gpgsign", "false")
self.git("add", ".")
self.git("commit", "-qm", "consistent checkout")
base_branch = self.git("branch", "--show-current")
current = version.release_config(self.root)["version"]
self.git("checkout", "-qb", f"release-{current}")
if stale_path:
target = self.root / stale_path
target.write_text(target.read_text().replace(current, "0.0.0"))
(self.root / "release-branch-only.txt").write_text("from the selected release commit\n")
self.git("add", ".")
self.git("commit", "-qm", "release branch snapshot")
self.git("checkout", "-q", base_branch)
self.assertEqual(0, self.run_command("check"))
def invoke_release_before_signing(self):
# Block signing/upload commands even if the regression reaches them.
tools = self.root / "test-tools"
tools.mkdir()
for name in ("gpg", "svn"):
tool = tools / name
tool.write_text('#!/bin/sh\ntouch "$RELEASE_EXTERNAL_CALLED"\nexit 91\n')
tool.chmod(0o755)
snapshots = self.root / "snapshots"
snapshots.mkdir()
result = subprocess.run(
["bash", str(self.root / "scripts/apache-release.sh")],
cwd=self.root,
env={
**os.environ,
"PATH": str(tools) + os.pathsep + os.environ["PATH"],
"PYTHON_FOR_RELEASE": sys.executable,
"TMPDIR": str(snapshots),
"RELEASE_EXTERNAL_CALLED": str(self.root / "external-command-called"),
},
capture_output=True,
text=True,
check=False,
)
self.assertEqual([], list(snapshots.iterdir()), "Release snapshot must be cleaned up")
self.assertEqual(1, self.git("worktree", "list", "--porcelain").count("worktree "))
return result
def test_release_checks_subpackage_versions_in_actual_release_branch(self):
self.prepare_release_branch(stale_path="hugegraph-mcp/pyproject.toml")
result = self.invoke_release_before_signing()
self.assertNotEqual(0, result.returncode)
self.assertIn("hugegraph-mcp/pyproject.toml", result.stdout)
self.assertFalse((self.root / "scripts/dist").exists())
self.assertFalse((self.root / "external-command-called").exists())
def test_release_checks_documentation_in_actual_release_branch(self):
self.prepare_release_branch(stale_path="hugegraph-mcp/README.md")
result = self.invoke_release_before_signing()
self.assertNotEqual(0, result.returncode)
self.assertIn("hugegraph-mcp/README.md", result.stdout)
self.assertFalse((self.root / "scripts/dist").exists())
self.assertFalse((self.root / "external-command-called").exists())
def test_release_checks_export_ignored_metadata_before_packaging(self):
self.prepare_release_branch(stale_path="hugegraph-ml/pyproject.toml")
result = self.invoke_release_before_signing()
self.assertNotEqual(0, result.returncode)
self.assertIn("hugegraph-ml/pyproject.toml", result.stdout)
self.assertFalse((self.root / "scripts/dist").exists())
self.assertFalse((self.root / "external-command-called").exists())
def test_release_branch_wins_over_same_named_tag(self):
self.prepare_release_branch()
current = version.release_config(self.root)["version"]
self.git("tag", f"release-{current}", "HEAD")
result = self.invoke_release_before_signing()
self.assertEqual(91, result.returncode, result.stdout + result.stderr)
archives = list((self.root / "scripts/dist/apache-hugegraph-ai").glob("*.tar.gz"))
with tarfile.open(archives[0]) as archive:
marker = archive.extractfile(f"apache-hugegraph-ai-{current}-src/release-branch-only.txt")
assert marker is not None
self.assertEqual(b"from the selected release commit\n", marker.read())
def test_release_archives_validated_branch_and_stops_at_test_signer(self):
self.prepare_release_branch()
result = self.invoke_release_before_signing()
self.assertEqual(91, result.returncode, result.stdout + result.stderr)
archives = list((self.root / "scripts/dist/apache-hugegraph-ai").glob("*.tar.gz"))
self.assertEqual(1, len(archives))
current = version.release_config(self.root)["version"]
with tarfile.open(archives[0]) as archive:
prefix = f"apache-hugegraph-ai-{current}-src/"
names = archive.getnames()
self.assertFalse(any(name.startswith(prefix + "hugegraph-ml/") for name in names))
self.assertFalse(any(name.startswith(prefix + "docker/") for name in names))
self.assertNotIn(prefix + ".gitattributes", names)
marker = archive.extractfile(f"apache-hugegraph-ai-{current}-src/release-branch-only.txt")
assert marker is not None
self.assertEqual(b"from the selected release commit\n", marker.read())
if __name__ == "__main__":
unittest.main()