Skip to content

Commit 69c9a62

Browse files
committed
Added utils scripts
1 parent a9a8868 commit 69c9a62

File tree

3 files changed

+316
-0
lines changed

3 files changed

+316
-0
lines changed

Tools/scripts/Utils/config.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""
2+
Configuration for NGO Release Automation
3+
"""
4+
5+
def getDefaultRepoBranch():
6+
"""
7+
Returns the name of Tools repo default branch.
8+
This will be used to for example push changelog update for the release.
9+
In general this branch is the default working branch
10+
"""
11+
return 'develop-2.0.0'
12+
13+
def getNetcodeGithubRepo():
14+
"""Returns the name of MP Tools repo."""
15+
return 'Unity-Technologies/com.unity.netcode.gameobjects'
16+
17+
def getNetcodePackageName():
18+
"""Returns the name of the MP Tools package."""
19+
return 'com.unity.netcode.gameobjects'
20+
21+
def getPackageManifestPath():
22+
"""Returns the path to the Netcode package manifest."""
23+
24+
return 'com.unity.netcode.gameobjects/package.json'
25+
26+
def getPackageValidationExceptionsPath():
27+
"""Returns the path to the Netcode ValidationExceptions."""
28+
29+
return 'com.unity.netcode.gameobjects/ValidationExceptions.json'
30+
31+
def getPackageChangelogPath():
32+
"""Returns the path to the Netcode package manifest."""
33+
34+
return 'com.unity.netcode.gameobjects/CHANGELOG.md'
35+
36+
def getNetcodeReleaseBranchName(package_version):
37+
"""
38+
Returns the branch name for the Netcode release.
39+
"""
40+
return f"release/{package_version}"
41+
42+
def getNetcodeProjectID():
43+
"""
44+
Returns the Unity project ID for the DOTS monorepo.
45+
Useful when for example triggering Yamato jobs
46+
"""
47+
return '1201'
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
"""Helper class for common operations."""
2+
#!/usr/bin/env python3
3+
import json
4+
import os
5+
import re
6+
import datetime
7+
8+
from config import getPackageChangelogPath
9+
10+
UNRELEASED_CHANGELOG_SECTION_TEMPLATE = r"""
11+
## [Unreleased]
12+
13+
### Added
14+
15+
16+
### Changed
17+
18+
19+
### Deprecated
20+
21+
22+
### Removed
23+
24+
25+
### Fixed
26+
27+
28+
### Security
29+
30+
31+
### Obsolete
32+
"""
33+
34+
def get_package_version_from_manifest(filepath):
35+
"""
36+
Reads the package.json file and returns the version specified in it.
37+
"""
38+
39+
if not os.path.exists(filepath):
40+
print("get_manifest_json_version function couldn't find a specified filepath")
41+
return None
42+
43+
with open(filepath, 'rb') as f:
44+
json_text = f.read()
45+
data = json.loads(json_text)
46+
47+
return data['version']
48+
49+
50+
def update_package_version_by_patch(changelog_file):
51+
"""
52+
Updates the package version in the package.json file.
53+
This function will bump the package version by a patch.
54+
55+
The usual usage would be to bump package version during/after release to represent the "current package state" which progresses since the release branch was created
56+
"""
57+
58+
if not os.path.exists(changelog_file):
59+
raise FileNotFoundError(f"The file {changelog_file} does not exist.")
60+
61+
with open(changelog_file, 'r', encoding='UTF-8') as f:
62+
package_json = json.load(f)
63+
64+
version_parts = package_json['version'].split('.')
65+
if len(version_parts) != 3:
66+
raise ValueError("Version format is not valid. Expected format: 'major.minor.patch'.")
67+
68+
# Increment the patch version
69+
version_parts[2] = str(int(version_parts[2]) + 1)
70+
new_version = '.'.join(version_parts)
71+
72+
package_json['version'] = new_version
73+
74+
with open(changelog_file, 'w', encoding='UTF-8') as f:
75+
json.dump(package_json, f, indent=4)
76+
77+
return new_version
78+
79+
def update_validation_exceptions(validation_file, package_version):
80+
"""
81+
Updates the ValidationExceptions.json file with the new package version.
82+
"""
83+
84+
# If files do not exist, exit
85+
if not os.path.exists(validation_file):
86+
return
87+
88+
# Update the PackageVersion in the exceptions
89+
with open(validation_file, 'rb') as f:
90+
json_text = f.read()
91+
data = json.loads(json_text)
92+
updated = False
93+
for exceptionElements in ["WarningExceptions", "ErrorExceptions"]:
94+
exceptions = data.get(exceptionElements)
95+
96+
if exceptions is not None:
97+
for exception in exceptions:
98+
if 'PackageVersion' in exception:
99+
exception['PackageVersion'] = package_version
100+
updated = True
101+
102+
# If no exceptions were updated, we do not need to write the file
103+
if not updated:
104+
return
105+
106+
with open(validation_file, 'w', encoding='UTF-8', newline='\n') as json_file:
107+
json.dump(data, json_file, ensure_ascii=False, indent=2)
108+
json_file.write("\n") # Add newline cause Py JSON does not
109+
print(f" updated `{validation_file}`")
110+
111+
def update_changelog(new_version, add_unreleased_template=False):
112+
"""
113+
Cleans the [Unreleased] section of the changelog by removing empty subsections,
114+
then replaces the '[Unreleased]' tag with the new version and release date.
115+
If the version header already exists, it will remove the [Unreleased] section and add any entries under the present version.
116+
If add_unreleased_template is specified then it will also include the template at the top of the file
117+
118+
1 - Cleans the [Unreleased] section by removing empty subsections.
119+
2 - Checks if the version header already has its section in the changelog.
120+
3 - If it does, it removes the [Unreleased] section and its content.
121+
4 - If it does not, it replaces the [Unreleased] section with the new version and today's date.
122+
"""
123+
124+
new_changelog_entry = f'## [{new_version}] - {datetime.date.today().isoformat()}'
125+
version_header_to_find_if_exists = f'## [{new_version}]'
126+
changelog_path = getPackageChangelogPath()
127+
128+
if not os.path.exists(changelog_path):
129+
print("CHANGELOG path is incorrect, the script will terminate without updating the CHANGELOG")
130+
return None
131+
132+
with open(changelog_path, 'r', encoding='UTF-8') as f:
133+
changelog_text = f.read()
134+
135+
# This pattern finds a line starting with '###', followed by its newline,
136+
# and then two more lines that contain only whitespace.
137+
# The re.MULTILINE flag allows '^' to match the start of each line.
138+
pattern = re.compile(r"^###.*\n\n\n", re.MULTILINE)
139+
140+
# Replace every match with an empty string. The goal is to remove empty CHANGELOG subsections.
141+
cleaned_content = pattern.sub('', changelog_text)
142+
143+
if version_header_to_find_if_exists in changelog_text:
144+
print(f"A changelog entry for version '{new_version}' already exists. The script will just remove Unreleased section and its content.")
145+
changelog_text = re.sub(r'(?s)## \[Unreleased(.*?)(?=## \[)', '', changelog_text)
146+
else:
147+
# Replace the [Unreleased] section with the new version + cleaned subsections
148+
print("Latest CHANGELOG entry will be modified to: " + new_changelog_entry)
149+
changelog_text = re.sub(r'## \[Unreleased\]', new_changelog_entry, cleaned_content)
150+
151+
# Accounting for the very top of the changelog format
152+
header_end_pos = changelog_text.find('---', 1)
153+
insertion_point = changelog_text.find('\n', header_end_pos)
154+
155+
final_content = ""
156+
if add_unreleased_template:
157+
print("Adding [Unreleased] section template to the top of the changelog.")
158+
final_content = (
159+
changelog_text[:insertion_point] +
160+
f"\n{UNRELEASED_CHANGELOG_SECTION_TEMPLATE}" +
161+
changelog_text[insertion_point:]
162+
)
163+
else:
164+
final_content = (
165+
changelog_text[:insertion_point] +
166+
changelog_text[insertion_point:]
167+
)
168+
169+
# Write the changes
170+
with open(changelog_path, 'w', encoding='UTF-8') as file:
171+
file.write(final_content)
172+
173+
return final_content

Tools/scripts/Utils/git_utils.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
"""Helper class for Git repo operations."""
2+
import subprocess
3+
import sys
4+
from git import Repo
5+
from github import Github
6+
from github import GithubException
7+
8+
class GithubUtils:
9+
def __init__(self, access_token, repo):
10+
self.github = Github(base_url="https://api.github.com",
11+
login_or_token=access_token)
12+
self.repo = self.github.get_repo(repo)
13+
14+
def is_branch_present(self, branch_name):
15+
try:
16+
self.repo.get_branch(branch_name)
17+
return True # Branch exists
18+
19+
except GithubException as ghe:
20+
if ghe.status == 404:
21+
return False # Branch does not exist
22+
print(f"An error occurred with the GitHub API: {ghe.status}", file=sys.stderr)
23+
print(f"Error details: {ghe.data}", file=sys.stderr)
24+
sys.exit(1)
25+
26+
def get_local_repo():
27+
root_dir = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'],
28+
universal_newlines=True, stderr=subprocess.STDOUT).strip()
29+
return Repo(root_dir)
30+
31+
32+
def get_latest_git_revision(branch_name):
33+
"""Gets the latest commit SHA for a given branch using git rev-parse."""
34+
try:
35+
subprocess.run(
36+
['git', 'fetch', 'origin'],
37+
capture_output=True,
38+
text=True,
39+
check=True
40+
)
41+
remote_branch_name = f'origin/{branch_name}'
42+
# Executes the git command: git rev-parse <remote_branch_name>
43+
result = subprocess.run(
44+
['git', 'rev-parse', remote_branch_name],
45+
capture_output=True,
46+
text=True,
47+
check=True
48+
)
49+
return result.stdout.strip()
50+
except FileNotFoundError:
51+
print("Error: 'git' command not found. Is Git installed and in your PATH?", file=sys.stderr)
52+
sys.exit(1)
53+
except subprocess.CalledProcessError as e:
54+
print(f"Error: Failed to get revision for branch '{branch_name}'.", file=sys.stderr)
55+
print(f"Git stderr: {e.stderr}", file=sys.stderr)
56+
sys.exit(1)
57+
58+
def create_branch_execute_commands_and_push(github_token, github_repo, branch_name, commit_message, command_to_run=None):
59+
"""
60+
Creates a new branch with the specified name, performs specified action, commits the current changes and pushes it to the repo.
61+
Note that command_to_run should be a single command that will be executed using subprocess.run. For multiple commands consider using a Python script file.
62+
"""
63+
64+
try:
65+
# Initialize PyGithub and get the repository object
66+
github_manager = GithubUtils(github_token, github_repo)
67+
68+
if github_manager.is_branch_present(branch_name):
69+
print(f"Branch '{branch_name}' already exists. Exiting.")
70+
sys.exit(1)
71+
72+
repo = get_local_repo()
73+
74+
new_branch = repo.create_head(branch_name, repo.head.commit)
75+
new_branch.checkout()
76+
print(f"Created and checked out new branch: {branch_name}")
77+
78+
if command_to_run:
79+
print(f"\nExecuting command on branch '{branch_name}': {' '.join(command_to_run)}")
80+
subprocess.run(command_to_run, text=True, check=True)
81+
82+
print("Executed release.py script successfully.")
83+
84+
repo.git.add('.')
85+
repo.index.commit(commit_message, skip_hooks=True)
86+
repo.git.push("origin", branch_name)
87+
88+
print(f"Successfully created, updated and pushed new branch: {branch_name}")
89+
90+
except GithubException as e:
91+
print(f"An error occurred with the GitHub API: {e.status}", file=sys.stderr)
92+
print(f"Error details: {e.data}", file=sys.stderr)
93+
sys.exit(1)
94+
except Exception as e:
95+
print(f"An unexpected error occurred: {e}", file=sys.stderr)
96+
sys.exit(1)

0 commit comments

Comments
 (0)