Files
greptimedb/.github/scripts/tests/test_update_compat_versions.py
T
discord9 3e63b8abf3 ci: automate compatibility version window (#8608)
* ci: automate compatibility version window

Signed-off-by: discord9 <discord9@163.com>

* ci: address compat version window review feedback

Address all four review comments on the compat version window
automation:

- Keep the PR window to the sliding window only (latest patch of the two
  newest stable minor lines). Exact =vX.Y.Z anchors from case.toml are no
  longer unioned into from_versions; they are validated by the new
  --check-anchors mode and exercised by nightly runs via --nightly-window.
- Add --published-only: the window is computed over stable git tags that
  have a published, non-draft GitHub release carrying the sqlness compat
  artifacts (greptime-linux-amd64 tar.gz and sha256sum), so failed releases
  cannot land in the window.
- Run the updater Python tests plus the window/anchor consistency check in
  PR and merge-group CI (new compat-updater-check job in integration.yml).
- Regenerate tests/compatibility/ci.toml to the current sliding window
  [v1.0.2, v1.1.4].

Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>

---------

Signed-off-by: discord9 <discord9@163.com>
Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com>
2026-08-06 08:29:00 +00:00

333 lines
15 KiB
Python

#!/usr/bin/env python3
# Copyright 2023 Greptime Team
#
# Licensed 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 importlib.util
import io
import json
import tempfile
import unittest
import urllib.error
from pathlib import Path
from unittest import mock
SCRIPT = Path(__file__).resolve().parents[1] / "update-compat-versions.py"
SPEC = importlib.util.spec_from_file_location("update_compat_versions", SCRIPT)
assert SPEC is not None and SPEC.loader is not None
UPDATER = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(UPDATER)
class UpdateCompatVersionsTest(unittest.TestCase):
def write_case(self, root: Path, name: str, from_range: str) -> None:
case_dir = root / "tests" / "compatibility" / "cases" / name
case_dir.mkdir(parents=True)
(case_dir / "case.toml").write_text(
f'name = "{name}"\nfrom_range = {from_range}\n', encoding="utf-8"
)
def write_config(self, root: Path, versions: str, trailer: str = "") -> Path:
config = root / "tests" / "compatibility" / "ci.toml"
config.parent.mkdir(parents=True, exist_ok=True)
config.write_text(
"# Keep these comments when the window is regenerated.\n"
f"from_versions = {versions}\n{trailer}",
encoding="utf-8",
)
return config
def test_stable_tags_filter_prereleases_and_nightlies(self) -> None:
self.assertEqual(
["v1.0.0", "v1.0.2", "v1.1.0"],
UPDATER.stable_tags(
["v1.1.0-nightly-20260101", "v1.0.2", "v1.0.0", "v1.1.0", "v1.0.0-rc.1"]
),
)
def test_sliding_versions_roll_over_to_two_newest_minor_lines(self) -> None:
self.assertEqual(
["v1.9.4", "v2.0.1"],
UPDATER.sliding_versions(["v1.8.9", "v1.9.0", "v1.9.4", "v2.0.0", "v2.0.1"]),
)
def test_sliding_versions_supports_one_minor_line(self) -> None:
self.assertEqual(["v1.1.3"], UPDATER.sliding_versions(["v1.1.0", "v1.1.3"]))
def test_sliding_versions_rejects_no_stable_tags(self) -> None:
with self.assertRaisesRegex(UPDATER.CompatVersionError, "No stable release tags"):
UPDATER.sliding_versions(["v1.1.0-rc.1", "v1.1.0-nightly-20260101"])
def test_effective_versions_returns_only_the_sliding_window(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '["=v1.0.2", ">=v1.1.0"]')
versions = UPDATER.effective_versions(["v1.0.2", "v1.1.0", "v1.1.1", "v1.1.3"])
self.assertEqual(["v1.0.2", "v1.1.3"], versions)
def test_nightly_versions_includes_anchors_and_sliding(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '["=v1.0.2", ">=v1.1.0"]')
self.write_case(root, "more_history", '["=v1.1.0", "=v1.1.1"]')
versions = UPDATER.nightly_versions(
["v1.0.2", "v1.1.0", "v1.1.1", "v1.1.3"],
root / "tests" / "compatibility" / "cases",
)
self.assertEqual(["v1.0.2", "v1.1.0", "v1.1.1", "v1.1.3"], versions)
def test_exact_anchor_syntaxes_are_normalized(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(
root,
"historical",
'[" = v1.0.2 ", " == v1.1.0 ", " v1.1.1 ", "1.1.2", "v01.01.003-rc.1"]',
)
anchors = UPDATER.extract_pinned_anchors(root / "tests" / "compatibility" / "cases")
self.assertEqual(
{"v1.0.2", "v1.1.0", "v1.1.1", "v1.1.2", "v1.1.3"}, anchors
)
def test_ordered_ranges_and_wildcard_are_not_anchors(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(
root,
"non_exact",
'[">= v1.0.0", "<=v1.1.0", ">v1.0.2", "< v2.0.0", "*"]',
)
anchors = UPDATER.extract_pinned_anchors(root / "tests" / "compatibility" / "cases")
self.assertEqual(set(), anchors)
def test_update_is_sorted_and_idempotent(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = self.write_config(
root,
'[\n "v1.1.0",\n "v1.0.2",\n]',
"# This trailing comment must remain.\nother_setting = true\n",
)
self.write_case(root, "historical", '["=v1.0.2"]')
tags = ["v1.0.2", "v1.1.0", "v1.1.1", "v1.1.3"]
with mock.patch.object(UPDATER, "git_tags", return_value=tags):
self.assertEqual(0, UPDATER.main(["--repo-root", str(root), "--update"]))
first_update = config.read_text(encoding="utf-8")
self.assertEqual(0, UPDATER.main(["--repo-root", str(root), "--update"]))
self.assertEqual(first_update, config.read_text(encoding="utf-8"))
self.assertIn('from_versions = ["v1.0.2", "v1.1.3"]', first_update)
self.assertIn("# Keep these comments when the window is regenerated.", first_update)
self.assertIn("# This trailing comment must remain.\nother_setting = true\n", first_update)
def test_check_reports_a_unified_diff_for_stale_config(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_config(root, '["v1.0.2"]')
self.write_case(root, "historical", '["=v1.0.2"]')
stderr = io.StringIO()
with mock.patch.object(UPDATER, "git_tags", return_value=["v1.0.2", "v1.1.3"]):
with contextlib.redirect_stderr(stderr):
result = UPDATER.main(["--repo-root", str(root), "--check"])
self.assertEqual(1, result)
self.assertIn("--- a/tests/compatibility/ci.toml", stderr.getvalue())
self.assertIn("+++ b/tests/compatibility/ci.toml", stderr.getvalue())
def test_rejects_duplicate_config_versions(self) -> None:
with tempfile.TemporaryDirectory() as directory:
config = self.write_config(Path(directory), '["v1.0.2", "v1.0.2"]')
with self.assertRaisesRegex(UPDATER.CompatVersionError, "Duplicate from_versions"):
UPDATER.load_from_versions(config)
def test_rejects_unavailable_pinned_anchor(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '["=v1.0.2"]')
with self.assertRaisesRegex(UPDATER.CompatVersionError, "unavailable"):
UPDATER.nightly_versions(
["v1.1.0"], root / "tests" / "compatibility" / "cases"
)
def test_rejects_malformed_exact_constraint(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '["=v1.0"]')
with self.assertRaisesRegex(UPDATER.CompatVersionError, "Malformed version"):
UPDATER.extract_pinned_anchors(root / "tests" / "compatibility" / "cases")
def test_rejects_malformed_bare_exact_constraint(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '["v1.0"]')
with self.assertRaisesRegex(UPDATER.CompatVersionError, "Malformed version"):
UPDATER.extract_pinned_anchors(root / "tests" / "compatibility" / "cases")
def test_rejects_malformed_ordered_constraint(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '[">=v1.0"]')
with self.assertRaisesRegex(UPDATER.CompatVersionError, "Malformed version"):
UPDATER.extract_pinned_anchors(root / "tests" / "compatibility" / "cases")
def test_published_only_excludes_failed_release_tags(self) -> None:
self.assertEqual(
["v1.0.2", "v1.1.3"],
UPDATER.effective_versions(
["v1.0.2", "v1.1.3", "v1.1.4"], published={"v1.0.2", "v1.1.3"}
),
)
def test_published_only_empty_intersection_raises(self) -> None:
with self.assertRaisesRegex(UPDATER.CompatVersionError, "published release assets"):
UPDATER.effective_versions(["v1.0.2"], published={"v1.1.4"})
def test_required_asset_names(self) -> None:
self.assertEqual(
{
"greptime-linux-amd64-v1.1.4.tar.gz",
"greptime-linux-amd64-v1.1.4.sha256sum",
},
UPDATER.required_asset_names("v1.1.4"),
)
class _FakeResponse:
def __init__(self, payload) -> None:
self._payload = payload
def __enter__(self):
return self
def __exit__(self, *exc) -> None:
return None
def read(self) -> bytes:
return json.dumps(self._payload).encode("utf-8")
def _release(self, tag: str, *, draft=False, prerelease=False, assets=()) -> dict:
return {
"tag_name": tag,
"draft": draft,
"prerelease": prerelease,
"assets": [{"name": name} for name in assets],
}
def test_published_release_tags_parses_and_filters(self) -> None:
required = {
"greptime-linux-amd64-v1.1.4.tar.gz",
"greptime-linux-amd64-v1.1.4.sha256sum",
}
page_one = [
self._release(f"preview-{i}", assets=required) for i in range(96)
] + [
self._release("v1.1.4", assets=required),
self._release("v1.1.3", draft=True, assets=required),
self._release("v1.1.2", prerelease=True, assets=UPDATER.required_asset_names("v1.1.2")),
self._release("v1.1.1", assets={"greptime-linux-amd64-v1.1.1.tar.gz"}),
]
self.assertEqual(100, len(page_one))
page_two = [
self._release("v1.0.2", assets=UPDATER.required_asset_names("v1.0.2")),
self._release("v1.0.1-beta", assets=required),
]
calls: list[str] = []
def fake_urlopen(request, *args, **kwargs):
calls.append(request.full_url)
payload = page_two if "page=2" in request.full_url else page_one
return self._FakeResponse(payload)
with mock.patch.object(UPDATER.urllib.request, "urlopen", side_effect=fake_urlopen):
published = UPDATER.published_release_tags("GreptimeTeam", "greptimedb", None)
# Draft releases are excluded; prerelease releases with the required
# assets are included; releases missing assets or with non-stable tags
# are excluded.
self.assertEqual({"v1.1.4", "v1.1.2", "v1.0.2"}, published)
self.assertEqual(2, len(calls))
self.assertTrue(all("per_page=100" in url for url in calls))
def test_published_release_tags_http_error_raises(self) -> None:
with mock.patch.object(
UPDATER.urllib.request,
"urlopen",
side_effect=urllib.error.HTTPError(
"https://api.github.com/repos/GreptimeTeam/greptimedb/releases",
403,
"Forbidden",
None,
None,
),
):
with self.assertRaisesRegex(
UPDATER.CompatVersionError, "Unable to fetch published releases"
):
UPDATER.published_release_tags("GreptimeTeam", "greptimedb", None)
def test_check_anchors_rejects_unpublished_anchor(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '["=v1.0.2", "=v1.1.0"]')
with mock.patch.object(UPDATER, "git_tags", return_value=["v1.0.2", "v1.1.0"]):
with mock.patch.object(
UPDATER, "published_release_tags", return_value={"v1.0.2"}
):
result = UPDATER.main(["--check-anchors", "--repo-root", str(root)])
self.assertEqual(2, result)
def test_check_anchors_passes(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '["=v1.0.2", "=v1.1.0"]')
with mock.patch.object(UPDATER, "git_tags", return_value=["v1.0.2", "v1.1.0"]):
with mock.patch.object(
UPDATER, "published_release_tags", return_value={"v1.0.2", "v1.1.0"}
):
result = UPDATER.main(["--check-anchors", "--repo-root", str(root)])
self.assertEqual(0, result)
def test_nightly_window_prints_csv(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
self.write_case(root, "historical", '["=v1.0.2"]')
self.write_case(root, "more_history", '["=v1.1.0", "=v1.1.1"]')
stdout = io.StringIO()
with mock.patch.object(
UPDATER, "git_tags", return_value=["v1.0.2", "v1.1.0", "v1.1.1", "v1.1.3"]
):
with contextlib.redirect_stdout(stdout):
result = UPDATER.main(["--nightly-window", "--repo-root", str(root)])
self.assertEqual(0, result)
self.assertEqual("v1.0.2,v1.1.0,v1.1.1,v1.1.3\n", stdout.getvalue())
def test_update_with_published_only(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
config = self.write_config(root, '["v1.0.2"]')
self.write_case(root, "historical", '["=v1.0.2"]')
with mock.patch.object(
UPDATER, "git_tags", return_value=["v1.0.2", "v1.1.3", "v1.1.4"]
):
with mock.patch.object(
UPDATER, "published_release_tags", return_value={"v1.0.2", "v1.1.3"}
):
result = UPDATER.main(
["--update", "--published-only", "--repo-root", str(root)]
)
updated = config.read_text(encoding="utf-8")
self.assertEqual(0, result)
self.assertIn('from_versions = ["v1.0.2", "v1.1.3"]', updated)
if __name__ == "__main__":
unittest.main()