Skip to content

Commit 1d86bec

Browse files
author
Maksim Ryzhikov
committed
chore: add scripts for release
1 parent 873ebb4 commit 1d86bec

File tree

10 files changed

+305
-0
lines changed

10 files changed

+305
-0
lines changed

scripts/release_version.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
#!/usr/bin/env python3
2+
3+
# A script that increments the language server version,
4+
# updates the npm package versions of the editor extensions,
5+
# updates the changelog and creates an annotated Git tag.
6+
7+
import argparse
8+
import subprocess
9+
import re
10+
import os
11+
import tempfile
12+
from pathlib import Path
13+
14+
from utils.cli import prompt_by, title
15+
from utils.properties import PropertiesFile
16+
from utils.changelog import ChangelogFile
17+
18+
class Version:
19+
def __init__(self, major, minor, patch):
20+
self.major = major
21+
self.minor = minor
22+
self.patch = patch
23+
24+
def __str__(self):
25+
return f"{self.major}.{self.minor}.{self.patch}"
26+
27+
def parse_version(s):
28+
match = re.search(r"(\d+)\.(\d+)\.(\d+)", s)
29+
if match == None:
30+
raise ValueError(f"Incorrectly formatted version: {s}")
31+
return Version(int(match.group(1)), int(match.group(2)), int(match.group(3)))
32+
33+
def increment_major(ver):
34+
return Version(ver.major + 1, 0, 0)
35+
36+
def increment_minor(ver):
37+
return Version(ver.major, ver.minor + 1, 0)
38+
39+
def increment_patch(ver):
40+
return Version(ver.major, ver.minor, ver.patch + 1)
41+
42+
def command_output(cmd, cwd):
43+
return subprocess.check_output(cmd, cwd=cwd).decode("utf-8").strip()
44+
45+
def git_last_tag(repo_path):
46+
return command_output(["git", "describe", "--abbrev=0"], cwd=repo_path)
47+
48+
def git_history_since_last_tag(repo_path):
49+
return re.split(r"[\r\n]+", command_output(["git", "log", "--oneline", f"{git_last_tag(repo_path)}..HEAD"], cwd=repo_path))
50+
51+
def git_branch(repo_path):
52+
return command_output(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=repo_path)
53+
54+
def git_working_dir_is_clean(repo_path):
55+
return len(command_output(["git", "status", "--porcelain"], cwd=repo_path)) == 0
56+
57+
INCREMENTS = {
58+
"major": increment_major,
59+
"minor": increment_minor,
60+
"patch": increment_patch
61+
}
62+
EDITOR = os.environ.get("EDITOR", "vim") # https://stackoverflow.com/questions/6309587/call-up-an-editor-vim-from-a-python-script
63+
PROJECT_DIR = Path(__file__).parent.parent
64+
PROJECT_VERSION_KEY = "projectVersion"
65+
66+
def main():
67+
parser = argparse.ArgumentParser(description="A small utility for updating the project's version and creating tags.")
68+
parser.add_argument("--bump-only", action="store_true", help="Whether only the version should be bumped, without tagging the current version.")
69+
70+
args = parser.parse_args()
71+
72+
title("Project Version Updater")
73+
74+
if not git_working_dir_is_clean(PROJECT_DIR):
75+
print("Commit any pending changes first to make sure the working directory is in a clean state!")
76+
return
77+
if git_branch(PROJECT_DIR) != "main":
78+
print("Switch to the main branch first!")
79+
return
80+
81+
properties = PropertiesFile(str(PROJECT_DIR / "gradle.properties"))
82+
version = parse_version(properties[PROJECT_VERSION_KEY])
83+
84+
# Current version
85+
86+
if not args.bump_only:
87+
print()
88+
print(f"Releasing version {version}.")
89+
print()
90+
91+
# Fetch new changelog message from user
92+
temp = tempfile.NamedTemporaryFile(delete=False)
93+
temp_path = Path(temp.name).absolute()
94+
95+
history = git_history_since_last_tag(PROJECT_DIR)
96+
formatted_history = [f"# {commit}" for commit in history]
97+
initial_message = [
98+
"",
99+
"",
100+
"# Please enter a changelog/release message.",
101+
f"# This is the history since the last tag:"
102+
] + formatted_history
103+
104+
with open(temp_path, "w") as temp_contents:
105+
temp_contents.write("\n".join(initial_message))
106+
107+
subprocess.call([EDITOR, str(temp_path)])
108+
109+
with open(temp_path, "r") as temp_contents:
110+
changelog_message = [line.strip() for line in temp_contents.readlines() if not line.startswith("#") and len(line.strip()) > 0]
111+
112+
temp.close()
113+
temp_path.unlink()
114+
115+
if not changelog_message:
116+
print("No message, exiting...")
117+
return
118+
119+
print("Updating changelog...")
120+
changelog = ChangelogFile(PROJECT_DIR / "CHANGELOG.md")
121+
changelog.prepend_version(version, changelog_message)
122+
123+
print("Creating Git tag...")
124+
tag_message = "\n".join([f"Version {version}", ""] + changelog_message)
125+
subprocess.run(["git", "tag", "-a", f"{version}", "-m", tag_message], cwd=PROJECT_DIR)
126+
127+
# Next version
128+
129+
increment = None
130+
while increment not in INCREMENTS.keys():
131+
increment = input("How do you want to increment? [major/minor/patch] ")
132+
133+
new_version = INCREMENTS[increment](version)
134+
135+
# Apply new (development) version to project
136+
print(f"Updating next dev version to {new_version}...")
137+
properties[PROJECT_VERSION_KEY] = str(new_version)
138+
139+
print("Creating Git commit for next dev version...")
140+
commit_message = f"Bump version to {new_version}"
141+
subprocess.run(["git", "add", "."], cwd=PROJECT_DIR)
142+
subprocess.run(["git", "commit", "-m", commit_message], cwd=PROJECT_DIR)
143+
144+
main()

scripts/utils/__init__.py

Whitespace-only changes.
168 Bytes
Binary file not shown.
Binary file not shown.
1.68 KB
Binary file not shown.
Binary file not shown.

scripts/utils/changelog.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import pathlib
2+
import re
3+
import collections
4+
5+
class ChangelogFile:
6+
def __init__(self, file_path):
7+
self.file_path = file_path
8+
9+
if not file_path.exists():
10+
raise ValueError(f"{file_path} does not exist!")
11+
12+
self.parse()
13+
14+
def parse(self):
15+
self.first_paragraph = []
16+
self.versions = collections.deque()
17+
18+
with open(self.file_path, "r") as contents:
19+
parsing_first_paragraph = False
20+
version = None
21+
version_message = None
22+
23+
for line in contents.readlines():
24+
trimmed_line = line.strip()
25+
title_match = re.search(r"^#\s+(.+)", trimmed_line)
26+
version_match = re.search(r"^##\s+\[(.+)\]", trimmed_line)
27+
28+
if title_match != None:
29+
parsing_first_paragraph = True
30+
self.title = title_match.group(1)
31+
elif version_match != None:
32+
if version != None:
33+
self.versions.append((version, version_message))
34+
parsing_first_paragraph = False
35+
version = version_match.group(1)
36+
version_message = []
37+
elif parsing_first_paragraph:
38+
self.first_paragraph.append(trimmed_line)
39+
elif version != None and len(trimmed_line) > 0:
40+
version_message.append(trimmed_line)
41+
42+
if version_message != None and len(version_message) > 0:
43+
self.versions.append((version, version_message))
44+
45+
def prepend_version(self, version, version_message):
46+
self.versions.appendleft((version, version_message))
47+
self.save()
48+
49+
def save(self):
50+
lines = [f"# {self.title}"] + self.first_paragraph
51+
52+
for (version, version_message) in self.versions:
53+
lines.append(f"## [{version}]")
54+
lines += version_message
55+
lines.append("")
56+
57+
with open(self.file_path, "w") as contents:
58+
contents.write("\n".join(lines))

scripts/utils/cli.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import sys
2+
import re
3+
4+
alphanum_pattern = re.compile(r"(\d+)|(\D+)")
5+
6+
def title(s, padding=2):
7+
length = len(s) + (2 * padding)
8+
print("=" * length)
9+
print(f" {s} ")
10+
print("=" * length)
11+
12+
def confirm(what):
13+
result = input(what + " [y/n] ")
14+
return result.lower().startswith("y")
15+
16+
def alphanum_sort_key(item):
17+
# Source: https://stackoverflow.com/questions/2669059/how-to-sort-alpha-numeric-set-in-python
18+
return tuple(int(num) if num else alpha for num, alpha in alphanum_pattern.findall(item))
19+
20+
def require_not_none(description, x):
21+
if x == None:
22+
sys.exit(description + " not present")
23+
24+
def prompt_by(what, nodes, describer, default=None):
25+
node_dict = {describer(node): node for node in nodes}
26+
sorted_described = sorted(node_dict.keys(), key=alphanum_sort_key)
27+
28+
print()
29+
print(sorted_described)
30+
print()
31+
32+
last_entry = sorted_described[-1] if len(sorted_described) > 0 else None
33+
choice = input(f"Enter a {what} to choose [default: {last_entry}]: ").strip()
34+
print()
35+
36+
if len(choice) == 0 and last_entry:
37+
return node_dict[last_entry]
38+
elif choice not in node_dict.keys():
39+
return default
40+
else:
41+
return node_dict[choice]

scripts/utils/lists.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
def first(ls):
2+
return ls[0] if len(ls) > 0 else None

scripts/utils/properties.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import os
2+
import fileinput
3+
import itertools
4+
5+
class PropertiesFile:
6+
def __init__(self, file_path):
7+
self.file_path = file_path
8+
self.entries = {}
9+
10+
if not os.path.exists(file_path):
11+
raise ValueError(file_path + " does not exist!")
12+
13+
self.parse()
14+
15+
def __getitem__(self, key):
16+
return self.entries[key]["value"]
17+
18+
def __setitem__(self, key, value):
19+
str_value = str(value)
20+
21+
if key in self.entries:
22+
line_index = self.entries[key]["line"]
23+
else:
24+
line_index = self.total_lines
25+
self.total_lines += 1
26+
27+
self.write_line(line_index, key + "=" + str_value)
28+
self.entries[key] = {"line": line_index, "value": str_value}
29+
30+
def apply_changes(self, change_dict):
31+
for key, value in change_dict.items():
32+
self[key] = value
33+
34+
def write_line(self, index, content):
35+
with open(self.file_path, "r") as file:
36+
lines = file.readlines()
37+
38+
line_count = len(lines)
39+
40+
if line_count > 0:
41+
ending = "".join(itertools.takewhile(str.isspace, lines[0][::-1]))[::-1]
42+
else:
43+
ending = os.linesep
44+
45+
content_with_ending = content.rstrip() + ending
46+
47+
if index >= line_count:
48+
lines.append(content_with_ending)
49+
else:
50+
lines[index] = content_with_ending
51+
52+
with open(self.file_path, "w") as file:
53+
file.writelines(lines)
54+
55+
def parse(self):
56+
with open(self.file_path, "r") as file:
57+
for i, line in enumerate(file.readlines()):
58+
entry = line.split("=")
59+
self.entries[entry[0].strip()] = {"line": i, "value": entry[1].strip()}
60+
self.total_lines = i + 1

0 commit comments

Comments
 (0)