diff --git a/scripts/releasing/README.md b/scripts/releasing/README.md index 21778b0db..f6300ae9a 100644 --- a/scripts/releasing/README.md +++ b/scripts/releasing/README.md @@ -53,23 +53,59 @@ brew install gzip export PAIMON_GZIP="$(brew --prefix gzip)/bin/gzip" ``` -For example, update all version locations and review the diff: +During release preparation, check the release version metadata. The following +examples use `0.2.0` as the release version and `0.3.0` as the next development +version; replace these illustrative values with the versions for your release: + +```bash +scripts/releasing/bump_version.py --check 0.2.0 +``` + +After publishing a release, start the next development cycle and review the diff: ```bash scripts/releasing/bump_version.py 0.2.0 0.3.0 scripts/releasing/bump_version.py --check 0.3.0 ``` -The target version becomes the stable documentation label (for example, -`0.3.0 (stable)`). If its major.minor series differs from the previous stable -series, the previous release is moved to an archived entry. Archived labels -also show the full version, while their identifiers and URLs retain the -major.minor series. Patch releases update the stable label without adding -an archive. The documentation version may track the latest stable release -while the CMake version advances; release preparation updates both to the -target version. Use this command for release -preparation; it also promotes documentation and is not a development-only -version bump. +This single operation updates CMake and `docs/source/conf.py` to the next +development version and updates the documentation version switcher: + +- `main` points to `main/`, keeping both its display name and identifier `main`. + The documentation release string gains a `.dev` suffix (`0.3.0.dev` in this + example). +- The released version (`0.2.0 (stable)` in this example) points to the site root. + The old version argument is the release just published; the new development + version is never promoted here. +- If the previous stable belongs to a different major.minor series, it is + archived. Archived labels show the full version, while their identifiers and + URLs use major.minor. Patch releases within the stable series only update its + label. If the released version is already stable, no additional archive is + created. + +Run the two-version command after publishing the old version, since it marks +that version stable. It updates local metadata; publishing documentation remains +a separate step. + +To promote the current project version without starting a development cycle, +use the independent operation: + +```bash +scripts/releasing/bump_version.py --promote-stable 0.2.0 +``` + +This only updates `docs/source/_static/versions.json` and requires CMake and +`docs/source/conf.py` to use the version being promoted. Repeating it does not +add an archive. + +Both update operations support `--dry-run`, which validates the inputs and lists +the files that would be written without modifying them. `--root PATH` selects a +different source tree. `--check VERSION` remains a read-only consistency check: +CMake must match `VERSION`, and the documentation version may match either CMake +or the current stable version for compatibility with older refs. It validates +the version-list structure but does not require stable to equal `VERSION`. +The development entry is named `main`; the legacy `main (development)` label +remains accepted for older refs. ### Signing key setup and security @@ -232,8 +268,9 @@ than ASF's general one-hour minimum. ## Individual tools -- `bump_version.py`: consistently check or update CMake and documentation - version metadata. +- `bump_version.py`: check version metadata, start the next development cycle + while marking the released version stable, or independently promote stable + documentation. - `create_source_release.sh`: deterministically create an archive with GNU gzip, a SHA-512 checksum, and an optional detached signature from an immutable Git ref. diff --git a/scripts/releasing/bump_version.py b/scripts/releasing/bump_version.py index 92735d728..b07d04bd1 100755 --- a/scripts/releasing/bump_version.py +++ b/scripts/releasing/bump_version.py @@ -16,7 +16,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Check or update the Apache Paimon C++ project and documentation version.""" +"""Check versions or start the next development cycle after a release.""" import argparse import json @@ -163,10 +163,10 @@ def checked_files(root: Path, expected: str) -> Dict[Path, str]: 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" - versions_path = root / "docs/source/_static/versions.json" files[cmake_path] = replace_once( files[cmake_path], @@ -181,23 +181,45 @@ def updated_files(root: Path, current: str, new: str) -> Dict[Path, str]: f"documentation version {current}", ) + versions_path = root / "docs/source/_static/versions.json" versions = load_versions(versions_path) - previous_version = versions[1]["name"].split(" ")[0] - if series(previous_version) != series(new): - # Release preparation promotes the target series, even when the - # project version already belongs to that series. Archive the - # previous stable series only when it changes. - versions.insert(2, series_entry(previous_version)) - versions[1] = stable_entry(new) + 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="?") - parser.add_argument("new_version", nargs="?") - parser.add_argument("--check", metavar="VERSION") + 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] @@ -205,24 +227,30 @@ def main() -> int: args = parser.parse_args() try: - if args.check: + if args.check or args.promote_stable: if args.current_version or args.new_version: - raise VersionError("--check cannot be combined with version arguments") + 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 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 - ) + 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}") diff --git a/scripts/releasing/tests/test_release_tools.py b/scripts/releasing/tests/test_release_tools.py index 2e3089230..f1b20d658 100644 --- a/scripts/releasing/tests/test_release_tools.py +++ b/scripts/releasing/tests/test_release_tools.py @@ -688,10 +688,11 @@ def create_version_tree(self, root: Path) -> None: encoding="utf-8", ) - def test_version_tool_checks_and_updates_all_metadata(self) -> None: + def test_version_tool_bumps_dev_and_promotes_released_version(self) -> None: with tempfile.TemporaryDirectory() as temp: root = Path(temp) self.create_version_tree(root) + versions_path = root / "docs/source/_static/versions.json" self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.2.3") self.run_tool(VERSION_TOOL, "--root", str(root), "1.2.3", "1.3.0") self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.3.0") @@ -699,7 +700,6 @@ def test_version_tool_checks_and_updates_all_metadata(self) -> None: self.assertIn( 'version = "1.3.0"', (root / "docs/source/conf.py").read_text() ) - versions_path = root / "docs/source/_static/versions.json" versions = json.loads(versions_path.read_text(encoding="utf-8")) self.assertEqual( versions, @@ -710,7 +710,7 @@ def test_version_tool_checks_and_updates_all_metadata(self) -> None: "url": "https://paimon.apache.org/docs/cpp/main/", }, { - "name": "1.3.0 (stable)", + "name": "1.2.3 (stable)", "version": "", "url": "https://paimon.apache.org/docs/cpp/", "preferred": True, @@ -722,9 +722,24 @@ def test_version_tool_checks_and_updates_all_metadata(self) -> None: }, ], ) - # A patch release updates the label without adding an archive. + # The next cycle promotes the version just released, not the new dev version. self.run_tool(VERSION_TOOL, "--root", str(root), "1.3.0", "1.3.1") self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.3.1") + versions[1]["name"] = "1.3.0 (stable)" + versions.insert( + 2, + { + "name": "1.2.3", + "version": "1.2", + "url": "https://paimon.apache.org/docs/cpp/1.2/", + }, + ) + self.assertEqual( + json.loads(versions_path.read_text(encoding="utf-8")), versions + ) + # A patch release updates stable without adding another archive. + self.run_tool(VERSION_TOOL, "--root", str(root), "1.3.1", "1.3.2") + self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.3.2") versions[1]["name"] = "1.3.1 (stable)" self.assertEqual( json.loads(versions_path.read_text(encoding="utf-8")), versions @@ -739,7 +754,8 @@ def test_version_tool_promotes_series_on_patch_release(self) -> None: versions = json.loads( (root / "docs/source/_static/versions.json").read_text(encoding="utf-8") ) - self.assertEqual(versions[1]["name"], "1.2.4 (stable)") + self.assertEqual(versions[0]["name"], "main") + self.assertEqual(versions[1]["name"], "1.2.3 (stable)") self.assertEqual(versions[2]["version"], "1.1") self.assertEqual(len(versions), 3) @@ -754,6 +770,106 @@ def test_version_tool_updates_docs_from_previous_stable_release(self) -> None: self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.3.0") self.assertIn('version = "1.3.0"', docs_path.read_text(encoding="utf-8")) + def test_version_tool_promotes_existing_project_version(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.create_version_tree(root) + project_files = [root / "CMakeLists.txt", root / "docs/source/conf.py"] + project_before = [path.read_bytes() for path in project_files] + self.run_tool(VERSION_TOOL, "--root", str(root), "--promote-stable", "1.2.3") + self.assertEqual([path.read_bytes() for path in project_files], project_before) + self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.2.3") + versions_path = root / "docs/source/_static/versions.json" + versions = json.loads(versions_path.read_text(encoding="utf-8")) + self.assertEqual(versions[1]["name"], "1.2.3 (stable)") + self.assertEqual(versions[2]["name"], "1.1.0") + promoted = versions_path.read_bytes() + self.run_tool(VERSION_TOOL, "--root", str(root), "--promote-stable", "1.2.3") + self.assertEqual(versions_path.read_bytes(), promoted) + self.run_tool(VERSION_TOOL, "--root", str(root), "1.2.3", "1.3.0") + self.run_tool(VERSION_TOOL, "--root", str(root), "--check", "1.3.0") + self.assertEqual( + json.loads(versions_path.read_text(encoding="utf-8")), versions + ) + + def test_version_tool_validates_dev_label_and_accepts_legacy_labels(self) -> None: + for name, returncode in [ + ("main", 0), + ("main (development)", 0), + ("1.2.3 (dev)", 1), + ("1.3.0 (dev)", 1), + ("1.2 (dev)", 1), + ("1.2.3 (stable)", 1), + ]: + with self.subTest(name=name), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.create_version_tree(root) + versions_path = root / "docs/source/_static/versions.json" + versions = json.loads(versions_path.read_text(encoding="utf-8")) + versions[0]["name"] = name + versions_path.write_text(json.dumps(versions), encoding="utf-8") + self.run_tool( + VERSION_TOOL, "--root", str(root), "--check", "1.2.3", + expected_returncode=returncode, + ) + + def test_version_tool_dry_run_leaves_all_files_unchanged(self) -> None: + for arguments, expected_files in [ + ( + ("1.2.3", "1.3.0"), + ["CMakeLists.txt", "docs/source/conf.py", "docs/source/_static/versions.json"], + ), + (("--promote-stable", "1.2.3"), ["docs/source/_static/versions.json"]), + ]: + with self.subTest(arguments=arguments), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.create_version_tree(root) + paths = [path for path in root.rglob("*") if path.is_file()] + before = {path: path.read_bytes() for path in paths} + result = self.run_tool( + VERSION_TOOL, "--root", str(root), "--dry-run", *arguments + ) + self.assertEqual( + result.stdout.splitlines(), + [f"Would update {root / path}" for path in expected_files], + ) + self.assertEqual({path: path.read_bytes() for path in paths}, before) + + def test_version_tool_rejects_invalid_operations_without_writes(self) -> None: + for arguments, returncode in [ + (("--promote-stable", "1.3.0"), 1), + (("--promote-stable", "1.2"), 1), + (("--promote-stable", "1.2.3", "1.2.3", "1.3.0"), 1), + (("--check", "1.2.3", "--promote-stable", "1.2.3"), 2), + (("1.2.3", "1.2.3"), 1), + (("1.2.3", "1.3"), 1), + (("1.2.4", "1.3.0"), 1), + ]: + with self.subTest(arguments=arguments), tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.create_version_tree(root) + paths = [path for path in root.rglob("*") if path.is_file()] + before = {path: path.read_bytes() for path in paths} + self.run_tool( + VERSION_TOOL, "--root", str(root), *arguments, + expected_returncode=returncode, + ) + self.assertEqual({path: path.read_bytes() for path in paths}, before) + + def test_version_tool_requires_matching_docs_for_promotion(self) -> None: + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + self.create_version_tree(root) + docs_path = root / "docs/source/conf.py" + docs_path.write_text('version = "1.1.0"\n', encoding="utf-8") + versions_path = root / "docs/source/_static/versions.json" + before = versions_path.read_bytes() + self.run_tool( + VERSION_TOOL, "--root", str(root), "--promote-stable", "1.2.3", + expected_returncode=1, + ) + self.assertEqual(versions_path.read_bytes(), before) + def test_docs_channels_configure_version_warning(self) -> None: for channel, match, warning in [ (None, "main", True),