Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 50 additions & 13 deletions scripts/releasing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
76 changes: 52 additions & 24 deletions scripts/releasing/bump_version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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],
Expand All @@ -181,48 +181,76 @@ 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]
)
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}")
Expand Down
126 changes: 121 additions & 5 deletions scripts/releasing/tests/test_release_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,18 +688,18 @@ 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")
self.assertIn("VERSION 1.3.0", (root / "CMakeLists.txt").read_text())
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,
Expand All @@ -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,
Expand All @@ -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
Expand All @@ -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)

Expand All @@ -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),
Expand Down
Loading