Skip to content

CLOUDP-295785 - Calculate next version and release notes script #193

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 26 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
65fd607
WIP: changelog and versioning methods
MaciejKaras Jun 10, 2025
993753e
WIP: generate_changelog func
MaciejKaras Jun 10, 2025
f41c9f3
Working release notes generation
MaciejKaras Jun 10, 2025
30d5b04
Added tests for release notes generation
MaciejKaras Jun 11, 2025
a8e6782
Release with breaking change test
MaciejKaras Jun 11, 2025
6de3703
Added more releases
MaciejKaras Jun 12, 2025
c353737
Added release branch test cases
MaciejKaras Jun 12, 2025
c220f2e
Get the previous version based on current HEAD
MaciejKaras Jun 12, 2025
891d821
Added tests, gitgraph, docs and cmd input
MaciejKaras Jun 13, 2025
a2cdcb4
Add main method in versioning.py
MaciejKaras Jun 15, 2025
5465b98
Move main method to calculate_next_version.py
MaciejKaras Jun 16, 2025
20fded0
Optimize imports
MaciejKaras Jun 16, 2025
df195d1
Lint fix
MaciejKaras Jun 16, 2025
aebd634
Add changelog entry frontmatter text
MaciejKaras Jul 11, 2025
371499a
Added frontmatter validation
MaciejKaras Jul 13, 2025
a7d7f60
Script for generating changelog file
MaciejKaras Jul 13, 2025
136a939
Review fixes
MaciejKaras Jul 13, 2025
5dfa8cd
Review fixes v2
MaciejKaras Jul 14, 2025
ce49927
Review fixes v3
MaciejKaras Jul 14, 2025
240f2c9
Review fixes v4
MaciejKaras Jul 14, 2025
4a97699
Using ChangeEntry type
MaciejKaras Jul 15, 2025
76f0f74
Making release a module
MaciejKaras Jul 15, 2025
c9b6857
Fixing other kind of change issue + missing tests
MaciejKaras Jul 15, 2025
e51357b
Adding quotes to error message variables
MaciejKaras Jul 15, 2025
896db65
remove venv from .gitignore
MaciejKaras Jul 16, 2025
d55f322
fix unit tests
MaciejKaras Jul 16, 2025
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
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ pytest-mock==3.14.1
wrapt==1.17.2
botocore==1.38.33
boto3==1.38.33
python-frontmatter==1.1.0

# from kubeobject
freezegun==1.5.2
Expand Down
1 change: 1 addition & 0 deletions scripts/release/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# Makes 'release' a Python package.
60 changes: 60 additions & 0 deletions scripts/release/calculate_next_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import argparse
import pathlib

from git import Repo

from scripts.release.changelog import (
DEFAULT_CHANGELOG_PATH,
DEFAULT_INITIAL_GIT_TAG_VERSION,
)
from scripts.release.release_notes import calculate_next_version_with_changelog

if __name__ == "__main__":
parser = argparse.ArgumentParser(
description="Calculate the next version based on the changes since the previous version tag.",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"-p",
"--path",
default=".",
metavar="",
action="store",
type=pathlib.Path,
help="Path to the Git repository. Default is the current directory '.'",
)
parser.add_argument(
"-c",
"--changelog-path",
default=DEFAULT_CHANGELOG_PATH,
metavar="",
action="store",
type=str,
help=f"Path to the changelog directory relative to the repository root. Default is '{DEFAULT_CHANGELOG_PATH}'",
)
parser.add_argument(
"-s",
"--initial-commit-sha",
metavar="",
action="store",
type=str,
help="SHA of the initial commit to start from if no previous version tag is found.",
)
parser.add_argument(
"-v",
"--initial-version",
default=DEFAULT_INITIAL_GIT_TAG_VERSION,
metavar="",
action="store",
type=str,
help=f"Version to use if no previous version tag is found. Default is '{DEFAULT_INITIAL_GIT_TAG_VERSION}'",
)
args = parser.parse_args()

repo = Repo(args.path)

version, _ = calculate_next_version_with_changelog(
repo, args.changelog_path, args.initial_commit_sha, args.initial_version
)

print(version)
160 changes: 160 additions & 0 deletions scripts/release/changelog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import datetime
import os
import re
from enum import StrEnum

import frontmatter
from git import Commit, Repo

DEFAULT_CHANGELOG_PATH = "changelog/"
DEFAULT_INITIAL_GIT_TAG_VERSION = "1.0.0"
FILENAME_DATE_FORMAT = "%Y%m%d"
FRONTMATTER_DATE_FORMAT = "%Y-%m-%d"
MAX_TITLE_LENGTH = 50

PRELUDE_ENTRIES = ["prelude"]
BREAKING_CHANGE_ENTRIES = ["breaking", "major"]
FEATURE_ENTRIES = ["feat", "feature"]
BUGFIX_ENTRIES = ["fix", "bugfix", "hotfix", "patch"]


class ChangeKind(StrEnum):
PRELUDE = "prelude"
BREAKING = "breaking"
FEATURE = "feature"
FIX = "fix"
OTHER = "other"


class ChangeEntry:
def __init__(self, date: datetime, kind: ChangeKind, title: str, contents: str):
self.date = date
self.kind = kind
self.title = title
self.contents = contents


def get_changelog_entries(
base_commit: Commit,
repo: Repo,
changelog_sub_path: str,
) -> list[ChangeEntry]:
changelog = []

# Compare base commit with current working tree
diff_index = base_commit.diff(other=repo.head.commit, paths=changelog_sub_path)

# No changes since the previous version
if not diff_index:
return changelog

# Traverse added Diff objects only (change type 'A' for added files)
for diff_item in diff_index.iter_change_type("A"):
file_path = diff_item.b_path

change_entry = extract_changelog_entry(repo.working_dir, file_path)
changelog.append(change_entry)

return changelog


def extract_changelog_entry(working_dir: str, file_path: str) -> ChangeEntry:
file_name = os.path.basename(file_path)
date, kind = extract_date_and_kind_from_file_name(file_name)

abs_file_path = os.path.join(working_dir, file_path)
with open(abs_file_path, "r") as file:
file_content = file.read()

change_entry = extract_changelog_entry_from_contents(file_content)

if change_entry.date != date:
raise Exception(
f"{file_name} - date in front matter '{change_entry.date}' does not match date extracted from file name '{date}'"
)

if change_entry.kind != kind:
raise Exception(
f"{file_name} - kind in front matter '{change_entry.kind}' does not match kind extracted from file name '{kind}'"
)

return change_entry


def extract_date_and_kind_from_file_name(file_name: str) -> (datetime, ChangeKind):
match = re.match(r"(\d{8})_([a-zA-Z]+)_(.+)\.md", file_name)
if not match:
raise Exception(f"{file_name} - doesn't match expected pattern")

date_str, kind_str, _ = match.groups()
try:
date = parse_change_date(date_str, FILENAME_DATE_FORMAT)
except Exception as e:
raise Exception(f"{file_name} - {e}")

kind = get_change_kind(kind_str)

return date, kind


def parse_change_date(date_str: str, date_format: str) -> datetime:
try:
date = datetime.datetime.strptime(date_str, date_format).date()
except Exception:
raise Exception(f"date '{date_str}' is not in the expected format {date_format}")

return date


def get_change_kind(kind_str: str) -> ChangeKind:
if kind_str.lower() in PRELUDE_ENTRIES:
return ChangeKind.PRELUDE
if kind_str.lower() in BREAKING_CHANGE_ENTRIES:
return ChangeKind.BREAKING
elif kind_str.lower() in FEATURE_ENTRIES:
return ChangeKind.FEATURE
elif kind_str.lower() in BUGFIX_ENTRIES:
return ChangeKind.FIX
else:
return ChangeKind.OTHER


def extract_changelog_entry_from_contents(file_contents: str) -> ChangeEntry:
data = frontmatter.loads(file_contents)

kind = get_change_kind(str(data["kind"]))
date = parse_change_date(str(data["date"]), FRONTMATTER_DATE_FORMAT)
## Add newline to contents so the Markdown file also contains a newline at the end
contents = data.content + "\n"

return ChangeEntry(date=date, title=str(data["title"]), kind=kind, contents=contents)


def get_changelog_filename(title: str, kind: ChangeKind, date: datetime) -> str:
sanitized_title = sanitize_title(title)
filename_date = datetime.datetime.strftime(date, FILENAME_DATE_FORMAT)

return f"{filename_date}_{kind}_{sanitized_title}.md"


def sanitize_title(title: str) -> str:
# Only keep alphanumeric characters, dashes, underscores and spaces
regex = re.compile("[^a-zA-Z0-9-_ ]+")
title = regex.sub("", title)

# Replace multiple dashes, underscores and spaces with underscores
regex_underscore = re.compile("[-_ ]+")
title = regex_underscore.sub(" ", title).strip()

# Lowercase and split by space
words = [word.lower() for word in title.split(" ")]

result = words[0]

for word in words[1:]:
if len(result) + len("_") + len(word) <= MAX_TITLE_LENGTH:
result = result + "_" + word
else:
break

return result
138 changes: 138 additions & 0 deletions scripts/release/changelog_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import datetime
import unittest

from scripts.release.changelog import (
MAX_TITLE_LENGTH,
ChangeKind,
extract_changelog_entry_from_contents,
extract_date_and_kind_from_file_name,
sanitize_title,
)


class TestExtractChangelogDataFromFileName(unittest.TestCase):
def test_prelude(self):
date, kind = extract_date_and_kind_from_file_name("20250502_prelude_release_notes.md")
self.assertEqual(date, datetime.date(2025, 5, 2))
self.assertEqual(kind, ChangeKind.PRELUDE)

def test_breaking_changes(self):
date, kind = extract_date_and_kind_from_file_name("20250101_breaking_api_update.md")
self.assertEqual(date, datetime.date(2025, 1, 1))
self.assertEqual(kind, ChangeKind.BREAKING)

date, kind = extract_date_and_kind_from_file_name("20250508_breaking_remove_deprecated.md")
self.assertEqual(date, datetime.date(2025, 5, 8))
self.assertEqual(kind, ChangeKind.BREAKING)

date, kind = extract_date_and_kind_from_file_name("20250509_major_schema_change.md")
self.assertEqual(date, datetime.date(2025, 5, 9))
self.assertEqual(kind, ChangeKind.BREAKING)

def test_features(self):
date, kind = extract_date_and_kind_from_file_name("20250509_feature_new_dashboard.md")
self.assertEqual(date, datetime.date(2025, 5, 9))
self.assertEqual(kind, ChangeKind.FEATURE)

date, kind = extract_date_and_kind_from_file_name("20250511_feat_add_metrics.md")
self.assertEqual(date, datetime.date(2025, 5, 11))
self.assertEqual(kind, ChangeKind.FEATURE)

def test_fixes(self):
date, kind = extract_date_and_kind_from_file_name("20251210_fix_olm_missing_images.md")
self.assertEqual(date, datetime.date(2025, 12, 10))
self.assertEqual(kind, ChangeKind.FIX)

date, kind = extract_date_and_kind_from_file_name("20251010_bugfix_memory_leak.md")
self.assertEqual(date, datetime.date(2025, 10, 10))
self.assertEqual(kind, ChangeKind.FIX)

date, kind = extract_date_and_kind_from_file_name("20250302_hotfix_security_issue.md")
self.assertEqual(date, datetime.date(2025, 3, 2))
self.assertEqual(kind, ChangeKind.FIX)

date, kind = extract_date_and_kind_from_file_name("20250301_patch_typo_correction.md")
self.assertEqual(date, datetime.date(2025, 3, 1))
self.assertEqual(kind, ChangeKind.FIX)

def test_other(self):
date, kind = extract_date_and_kind_from_file_name("20250520_docs_update_readme.md")
self.assertEqual(date, datetime.date(2025, 5, 20))
self.assertEqual(kind, ChangeKind.OTHER)

date, kind = extract_date_and_kind_from_file_name("20250610_refactor_codebase.md")
self.assertEqual(date, datetime.date(2025, 6, 10))
self.assertEqual(kind, ChangeKind.OTHER)

def test_invalid_date(self):
with self.assertRaises(Exception) as context:
extract_date_and_kind_from_file_name("20250640_refactor_codebase.md")
self.assertEqual(
str(context.exception),
"20250640_refactor_codebase.md - date '20250640' is not in the expected format %Y%m%d",
)

def test_wrong_file_name_format_date(self):
with self.assertRaises(Exception) as context:
extract_date_and_kind_from_file_name("202yas_refactor_codebase.md")
self.assertEqual(str(context.exception), "202yas_refactor_codebase.md - doesn't match expected pattern")

def test_wrong_file_name_format_missing_title(self):
with self.assertRaises(Exception) as context:
extract_date_and_kind_from_file_name("20250620_change.md")
self.assertEqual(str(context.exception), "20250620_change.md - doesn't match expected pattern")


def test_strip_changelog_entry_frontmatter():
file_contents = """
---
title: This is my change
kind: feature
date: 2025-07-10
---

* **MongoDB**: public search preview release of MongoDB Search (Community Edition) is now available.
* Added new property [spec.search](https://www.mongodb.com/docs/kubernetes/current/mongodb/specification/#spec-search) to enable MongoDB Search.
"""

change_entry = extract_changelog_entry_from_contents(file_contents)

assert change_entry.title == "This is my change"
assert change_entry.kind == ChangeKind.FEATURE
assert change_entry.date == datetime.date(2025, 7, 10)
assert (
change_entry.contents
== """* **MongoDB**: public search preview release of MongoDB Search (Community Edition) is now available.
* Added new property [spec.search](https://www.mongodb.com/docs/kubernetes/current/mongodb/specification/#spec-search) to enable MongoDB Search.
"""
)


class TestSanitizeTitle(unittest.TestCase):
def test_basic_case(self):
self.assertEqual(sanitize_title("Simple Title"), "simple_title")

def test_non_alphabetic_chars(self):
self.assertEqual(sanitize_title("Title tha@t-_ contain's strange char&s!"), "title_that_contains_strange_chars")

def test_with_numbers_and_dashes(self):
self.assertEqual(sanitize_title("Title with 123 numbers to-go!"), "title_with_123_numbers_to_go")

def test_mixed_case(self):
self.assertEqual(sanitize_title("MiXeD CaSe TiTlE"), "mixed_case_title")

def test_length_limit(self):
long_title = "This is a very long title that should be truncated because it exceeds the maximum length"
sanitized_title = sanitize_title(long_title)
self.assertTrue(len(sanitized_title) <= MAX_TITLE_LENGTH)
self.assertEqual(sanitized_title, "this_is_a_very_long_title_that_should_be_truncated")

def test_leading_trailing_spaces(self):
sanitized_title = sanitize_title(" Title with spaces ")
self.assertEqual(sanitized_title, "title_with_spaces")

def test_empty_title(self):
self.assertEqual(sanitize_title(""), "")

def test_only_non_alphabetic(self):
self.assertEqual(sanitize_title("!@#"), "")
Loading