blob: b07d04bd1b04cb20fb88a46bec733207c8301d1a [file]
#!/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.
"""Check versions or start the next development cycle after a release."""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+$")
DOCS_URL = "https://paimon.apache.org/docs/cpp/"
MAIN_ENTRY_VERSION = "main"
def series(version: str) -> str:
"""The major.minor documentation series a version belongs to."""
return ".".join(version.split(".")[:2])
def stable_entry(version: str) -> Dict[str, object]:
"""Dropdown entry for the latest release published at the site root."""
return {
"name": f"{version} (stable)",
"version": "",
"url": DOCS_URL,
"preferred": True,
}
def series_entry(version: str) -> Dict[str, object]:
"""Dropdown entry for an archived release series under <series>/."""
return {
"name": version,
"version": series(version),
"url": f"{DOCS_URL}{series(version)}/",
}
class VersionError(RuntimeError):
"""Version metadata is missing or inconsistent."""
def require_version(value: str) -> None:
if VERSION_PATTERN.fullmatch(value) is None:
raise VersionError(f"invalid version {value!r}; expected MAJOR.MINOR.PATCH")
def replace_once(text: str, pattern: str, replacement: str, description: str) -> str:
result, count = re.subn(pattern, replacement, text, flags=re.MULTILINE)
if count != 1:
raise VersionError(f"expected one {description}, found {count}")
return result
def load_versions(path: Path) -> List[Dict[str, object]]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as error:
raise VersionError(f"cannot read {path}: {error}") from error
if not isinstance(value, list) or not all(isinstance(item, dict) for item in value):
raise VersionError(f"{path} must contain a JSON array of objects")
return value
def check_version_list(versions: List[Dict[str, object]]) -> None:
"""Validate the dev/stable/archived layout of versions.json."""
if len(versions) < 2:
raise VersionError(
"versions.json must contain a dev entry followed by a stable entry"
)
dev = versions[0]
if (
dev.get("version") != MAIN_ENTRY_VERSION
# Older Git refs retain the previous display label.
or dev.get("name") not in ("main", "main (development)")
or dev.get("url") != f"{DOCS_URL}main/"
):
raise VersionError(
"the first versions.json entry must be the main-branch docs: "
f"name 'main', version {MAIN_ENTRY_VERSION!r}, "
f"url {DOCS_URL}main/"
)
stable = versions[1]
name = stable.get("name")
match = (
re.fullmatch(r"(\d+\.\d+\.\d+) \(stable\)", name)
if isinstance(name, str)
else None
)
if (
match is None
or stable.get("version") != ""
or stable.get("url") != DOCS_URL
or stable.get("preferred") is not True
):
raise VersionError(
"the second versions.json entry must be the stable docs: name "
"'X.Y.Z (stable)', version '', url the site root, preferred true"
)
for item in versions[2:]:
item_name = item.get("name")
if (
not isinstance(item_name, str)
or VERSION_PATTERN.fullmatch(item_name) is None
or item.get("version") != series(item_name)
or item.get("url") != f"{DOCS_URL}{series(item_name)}/"
):
raise VersionError(
"archived versions.json entries must use name X.Y.Z, "
f"version X.Y and url {DOCS_URL}X.Y/, found {item!r}"
)
def checked_files(root: Path, expected: str) -> Dict[Path, str]:
cmake_path = root / "CMakeLists.txt"
docs_path = root / "docs/source/conf.py"
versions_path = root / "docs/source/_static/versions.json"
cmake = cmake_path.read_text(encoding="utf-8")
docs = docs_path.read_text(encoding="utf-8")
versions = load_versions(versions_path)
cmake_matches = re.findall(
r"^[ \t]*VERSION[ \t]+(\d+\.\d+\.\d+)[ \t]*$", cmake, re.MULTILINE
)
docs_matches = re.findall(
r'^version = "(\d+\.\d+\.\d+)"$', docs, re.MULTILINE
)
if cmake_matches != [expected]:
raise VersionError(f"CMake version is {cmake_matches}, expected [{expected!r}]")
check_version_list(versions)
stable_version = versions[1]["name"].split(" ")[0]
if docs_matches not in ([expected], [stable_version]):
raise VersionError(
f"documentation version is {docs_matches}, expected project version "
f"{expected!r} or stable documentation version {stable_version!r}"
)
return {
cmake_path: cmake,
docs_path: docs,
versions_path: json.dumps(versions, indent=4) + "\n",
}
def updated_files(root: Path, current: str, new: str) -> Dict[Path, str]:
"""Promote the released version and start the next development cycle."""
files = checked_files(root, current)
cmake_path = root / "CMakeLists.txt"
docs_path = root / "docs/source/conf.py"
files[cmake_path] = replace_once(
files[cmake_path],
rf"^([ \t]*VERSION[ \t]+){re.escape(current)}([ \t]*)$",
rf"\g<1>{new}\g<2>",
f"CMake VERSION {current}",
)
files[docs_path] = replace_once(
files[docs_path],
r'^version = "\d+\.\d+\.\d+"$',
f'version = "{new}"',
f"documentation version {current}",
)
versions_path = root / "docs/source/_static/versions.json"
versions = load_versions(versions_path)
promote_stable(versions, current)
versions[0]["name"] = "main"
files[versions_path] = json.dumps(versions, indent=4) + "\n"
return files
def promote_stable(versions: List[Dict[str, object]], version: str) -> None:
"""Update the stable label, archiving the previous stable series if needed."""
previous_version = versions[1]["name"].split(" ")[0]
if series(previous_version) != series(version):
versions.insert(2, series_entry(previous_version))
versions[1] = stable_entry(version)
def promoted_files(root: Path, version: str) -> Dict[Path, str]:
"""Promote the current project version without changing project metadata."""
files = checked_files(root, version)
docs = files[root / "docs/source/conf.py"]
if re.findall(r'^version = "(\d+\.\d+\.\d+)"$', docs, re.MULTILINE) != [version]:
raise VersionError("documentation version must match the version being promoted")
versions_path = root / "docs/source/_static/versions.json"
versions = load_versions(versions_path)
promote_stable(versions, version)
return {versions_path: json.dumps(versions, indent=4) + "\n"}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("current_version", nargs="?", help="just-released project version")
parser.add_argument("new_version", nargs="?", help="next development version")
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--check", metavar="VERSION", help="validate version metadata")
mode.add_argument(
"--promote-stable",
metavar="VERSION",
help="promote the current project version to stable docs",
)
parser.add_argument("--dry-run", action="store_true")
parser.add_argument(
"--root", type=Path, default=Path(__file__).resolve().parents[2]
)
args = parser.parse_args()
try:
if args.check or args.promote_stable:
if args.current_version or args.new_version:
raise VersionError(
"--check and --promote-stable cannot be combined with version arguments"
)
if args.check:
require_version(args.check)
checked_files(args.root.resolve(), args.check)
print(f"Release version metadata is consistent: {args.check}")
return 0
if args.promote_stable:
require_version(args.promote_stable)
files = promoted_files(args.root.resolve(), args.promote_stable)
else:
if not args.current_version or not args.new_version:
raise VersionError("CURRENT_VERSION and NEW_VERSION are required")
require_version(args.current_version)
require_version(args.new_version)
if args.current_version == args.new_version:
raise VersionError("current and new versions must differ")
files = updated_files(
args.root.resolve(), args.current_version, args.new_version
)
for path, content in files.items():
if args.dry_run:
print(f"Would update {path}")
else:
path.write_text(content, encoding="utf-8")
print(f"Updated {path}")
return 0
except (OSError, VersionError) as error:
print(f"Version update failed: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())