← Files Dataiku HeadlessARCHIVED FILE

scripts/check_release.py

4.35 KB · Nov 3, 2026 · 19:02 UTC

↓ Download file

#!/usr/bin/env python3

# Copyright 2026 Dataiku SAS
#
# 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.

"""Validate release versions and extract the matching changelog section."""

import argparse
import json
from pathlib import Path
import re
import subprocess

try:
    import tomllib
except ModuleNotFoundError:  # Python 3.10 test environments
    import tomli as tomllib


def validate(root: Path) -> str:
    project = tomllib.loads((root / "pyproject.toml").read_text())
    version = project["project"]["version"]
    if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version):
        raise ValueError(f"Expected a stable release version, got {version}")
    required = {
        f".{name}-plugin/plugin.json"
        for name in ("claude", "codex", "cortex", "cursor")
    }
    for relative in required:
        if not (root / relative).is_file():
            raise ValueError(f"Missing release manifest: {relative}")
    manifests = sorted(root.glob(".*-plugin/plugin.json"))
    if (root / "plugin.json").exists():
        manifests.append(root / "plugin.json")
    configured = project["tool"]["commitizen"]["version_files"]
    for path in manifests:
        relative = path.relative_to(root).as_posix()
        if relative not in [entry.split(":", 1)[0] for entry in configured]:
            raise ValueError(f"{relative} is missing from Commitizen version_files")
        if json.loads(path.read_text())["version"] != version:
            raise ValueError(f"{relative} must carry version {version}")
    marketplace = json.loads((root / ".claude-plugin/marketplace.json").read_text())
    for entry in marketplace["plugins"]:
        if entry["name"] == project["project"]["name"]:
            if entry.get("version", version) != version:
                raise ValueError("Marketplace version differs from project version")
    return version


def release_notes(root: Path, version: str) -> str:
    changelog = (root / "CHANGELOG.md").read_text()
    sections = re.split(r"(?m)(?=^## )", changelog)
    for section in sections:
        if re.match(rf"^## v{re.escape(version)} \(\d{{4}}-\d{{2}}-\d{{2}}\)", section):
            return section.strip() + "\n"
    raise ValueError(f"No changelog section for v{version}")


def validate_target_branch(branch: str, current: str) -> str:
    match = re.fullmatch(r"release/([0-9]+\.[0-9]+\.[0-9]+)", branch)
    if not match:
        raise ValueError("Expected release/X.Y.Z")
    target = match[1]
    if tuple(map(int, target.split("."))) <= tuple(map(int, current.split("."))):
        raise ValueError(
            "Target must be newer than the current version; already finalized branches do not need another bump"
        )
    return target


def validate_merge(root: Path, release_head: str) -> None:
    def git(*args):
        return subprocess.check_output(
            ["git", "-C", str(root), *args], text=True
        ).strip()

    parents = git("rev-list", "--parents", "-n", "1", "HEAD").split()[1:]
    if len(parents) != 2 or parents[1] != release_head:
        raise ValueError(
            "Release PR must use a merge commit preserving the release branch head"
        )
    if git("rev-parse", "HEAD^{tree}") != git("rev-parse", f"{release_head}^{{tree}}"):
        raise ValueError(
            "Merge includes changes outside the release branch; reconcile main before releasing"
        )


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--notes", action="store_true")
    parser.add_argument("--target-branch")
    parser.add_argument("--merge-head")
    args = parser.parse_args()
    root = Path.cwd()
    version = validate(root)
    if args.target_branch:
        validate_target_branch(args.target_branch, version)
    if args.merge_head:
        validate_merge(root, args.merge_head)
    print(
        release_notes(root, version) if args.notes else version,
        end="" if args.notes else "\n",
    )

SHA-256: 49d537a24e31f15b0c4f4070a4e3e41b9da90410ef9828897d7466319cc3503a