From 73aade26efd642dd46d91e4d5864237fbe729ad4 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:14:29 +0200 Subject: [PATCH 01/23] Added utils scripts --- Tools/scripts/Utils/config.py | 47 ++++++++ Tools/scripts/Utils/general_utils.py | 173 +++++++++++++++++++++++++++ Tools/scripts/Utils/git_utils.py | 96 +++++++++++++++ 3 files changed, 316 insertions(+) create mode 100644 Tools/scripts/Utils/config.py create mode 100644 Tools/scripts/Utils/general_utils.py create mode 100644 Tools/scripts/Utils/git_utils.py diff --git a/Tools/scripts/Utils/config.py b/Tools/scripts/Utils/config.py new file mode 100644 index 0000000000..fde0f1c2dd --- /dev/null +++ b/Tools/scripts/Utils/config.py @@ -0,0 +1,47 @@ +""" +Configuration for NGO Release Automation +""" + +def getDefaultRepoBranch(): + """ + Returns the name of Tools repo default branch. + This will be used to for example push changelog update for the release. + In general this branch is the default working branch + """ + return 'develop-2.0.0' + +def getNetcodeGithubRepo(): + """Returns the name of MP Tools repo.""" + return 'Unity-Technologies/com.unity.netcode.gameobjects' + +def getNetcodePackageName(): + """Returns the name of the MP Tools package.""" + return 'com.unity.netcode.gameobjects' + +def getPackageManifestPath(): + """Returns the path to the Netcode package manifest.""" + + return 'com.unity.netcode.gameobjects/package.json' + +def getPackageValidationExceptionsPath(): + """Returns the path to the Netcode ValidationExceptions.""" + + return 'com.unity.netcode.gameobjects/ValidationExceptions.json' + +def getPackageChangelogPath(): + """Returns the path to the Netcode package manifest.""" + + return 'com.unity.netcode.gameobjects/CHANGELOG.md' + +def getNetcodeReleaseBranchName(package_version): + """ + Returns the branch name for the Netcode release. + """ + return f"release/{package_version}" + +def getNetcodeProjectID(): + """ + Returns the Unity project ID for the DOTS monorepo. + Useful when for example triggering Yamato jobs + """ + return '1201' diff --git a/Tools/scripts/Utils/general_utils.py b/Tools/scripts/Utils/general_utils.py new file mode 100644 index 0000000000..12a196c1c5 --- /dev/null +++ b/Tools/scripts/Utils/general_utils.py @@ -0,0 +1,173 @@ +"""Helper class for common operations.""" +#!/usr/bin/env python3 +import json +import os +import re +import datetime + +from config import getPackageChangelogPath + +UNRELEASED_CHANGELOG_SECTION_TEMPLATE = r""" +## [Unreleased] + +### Added + + +### Changed + + +### Deprecated + + +### Removed + + +### Fixed + + +### Security + + +### Obsolete +""" + +def get_package_version_from_manifest(filepath): + """ + Reads the package.json file and returns the version specified in it. + """ + + if not os.path.exists(filepath): + print("get_manifest_json_version function couldn't find a specified filepath") + return None + + with open(filepath, 'rb') as f: + json_text = f.read() + data = json.loads(json_text) + + return data['version'] + + +def update_package_version_by_patch(changelog_file): + """ + Updates the package version in the package.json file. + This function will bump the package version by a patch. + + 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 + """ + + if not os.path.exists(changelog_file): + raise FileNotFoundError(f"The file {changelog_file} does not exist.") + + with open(changelog_file, 'r', encoding='UTF-8') as f: + package_json = json.load(f) + + version_parts = package_json['version'].split('.') + if len(version_parts) != 3: + raise ValueError("Version format is not valid. Expected format: 'major.minor.patch'.") + + # Increment the patch version + version_parts[2] = str(int(version_parts[2]) + 1) + new_version = '.'.join(version_parts) + + package_json['version'] = new_version + + with open(changelog_file, 'w', encoding='UTF-8') as f: + json.dump(package_json, f, indent=4) + + return new_version + +def update_validation_exceptions(validation_file, package_version): + """ + Updates the ValidationExceptions.json file with the new package version. + """ + + # If files do not exist, exit + if not os.path.exists(validation_file): + return + + # Update the PackageVersion in the exceptions + with open(validation_file, 'rb') as f: + json_text = f.read() + data = json.loads(json_text) + updated = False + for exceptionElements in ["WarningExceptions", "ErrorExceptions"]: + exceptions = data.get(exceptionElements) + + if exceptions is not None: + for exception in exceptions: + if 'PackageVersion' in exception: + exception['PackageVersion'] = package_version + updated = True + + # If no exceptions were updated, we do not need to write the file + if not updated: + return + + with open(validation_file, 'w', encoding='UTF-8', newline='\n') as json_file: + json.dump(data, json_file, ensure_ascii=False, indent=2) + json_file.write("\n") # Add newline cause Py JSON does not + print(f" updated `{validation_file}`") + +def update_changelog(new_version, add_unreleased_template=False): + """ + Cleans the [Unreleased] section of the changelog by removing empty subsections, + then replaces the '[Unreleased]' tag with the new version and release date. + If the version header already exists, it will remove the [Unreleased] section and add any entries under the present version. + If add_unreleased_template is specified then it will also include the template at the top of the file + + 1 - Cleans the [Unreleased] section by removing empty subsections. + 2 - Checks if the version header already has its section in the changelog. + 3 - If it does, it removes the [Unreleased] section and its content. + 4 - If it does not, it replaces the [Unreleased] section with the new version and today's date. + """ + + new_changelog_entry = f'## [{new_version}] - {datetime.date.today().isoformat()}' + version_header_to_find_if_exists = f'## [{new_version}]' + changelog_path = getPackageChangelogPath() + + if not os.path.exists(changelog_path): + print("CHANGELOG path is incorrect, the script will terminate without updating the CHANGELOG") + return None + + with open(changelog_path, 'r', encoding='UTF-8') as f: + changelog_text = f.read() + + # This pattern finds a line starting with '###', followed by its newline, + # and then two more lines that contain only whitespace. + # The re.MULTILINE flag allows '^' to match the start of each line. + pattern = re.compile(r"^###.*\n\n\n", re.MULTILINE) + + # Replace every match with an empty string. The goal is to remove empty CHANGELOG subsections. + cleaned_content = pattern.sub('', changelog_text) + + if version_header_to_find_if_exists in changelog_text: + print(f"A changelog entry for version '{new_version}' already exists. The script will just remove Unreleased section and its content.") + changelog_text = re.sub(r'(?s)## \[Unreleased(.*?)(?=## \[)', '', changelog_text) + else: + # Replace the [Unreleased] section with the new version + cleaned subsections + print("Latest CHANGELOG entry will be modified to: " + new_changelog_entry) + changelog_text = re.sub(r'## \[Unreleased\]', new_changelog_entry, cleaned_content) + + # Accounting for the very top of the changelog format + header_end_pos = changelog_text.find('---', 1) + insertion_point = changelog_text.find('\n', header_end_pos) + + final_content = "" + if add_unreleased_template: + print("Adding [Unreleased] section template to the top of the changelog.") + final_content = ( + changelog_text[:insertion_point] + + f"\n{UNRELEASED_CHANGELOG_SECTION_TEMPLATE}" + + changelog_text[insertion_point:] + ) + else: + final_content = ( + changelog_text[:insertion_point] + + changelog_text[insertion_point:] + ) + + # Write the changes + with open(changelog_path, 'w', encoding='UTF-8') as file: + file.write(final_content) + + return final_content diff --git a/Tools/scripts/Utils/git_utils.py b/Tools/scripts/Utils/git_utils.py new file mode 100644 index 0000000000..3e65ad88d6 --- /dev/null +++ b/Tools/scripts/Utils/git_utils.py @@ -0,0 +1,96 @@ +"""Helper class for Git repo operations.""" +import subprocess +import sys +from git import Repo +from github import Github +from github import GithubException + +class GithubUtils: + def __init__(self, access_token, repo): + self.github = Github(base_url="https://api.github.com", + login_or_token=access_token) + self.repo = self.github.get_repo(repo) + + def is_branch_present(self, branch_name): + try: + self.repo.get_branch(branch_name) + return True # Branch exists + + except GithubException as ghe: + if ghe.status == 404: + return False # Branch does not exist + print(f"An error occurred with the GitHub API: {ghe.status}", file=sys.stderr) + print(f"Error details: {ghe.data}", file=sys.stderr) + sys.exit(1) + +def get_local_repo(): + root_dir = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], + universal_newlines=True, stderr=subprocess.STDOUT).strip() + return Repo(root_dir) + + +def get_latest_git_revision(branch_name): + """Gets the latest commit SHA for a given branch using git rev-parse.""" + try: + subprocess.run( + ['git', 'fetch', 'origin'], + capture_output=True, + text=True, + check=True + ) + remote_branch_name = f'origin/{branch_name}' + # Executes the git command: git rev-parse + result = subprocess.run( + ['git', 'rev-parse', remote_branch_name], + capture_output=True, + text=True, + check=True + ) + return result.stdout.strip() + except FileNotFoundError: + print("Error: 'git' command not found. Is Git installed and in your PATH?", file=sys.stderr) + sys.exit(1) + except subprocess.CalledProcessError as e: + print(f"Error: Failed to get revision for branch '{branch_name}'.", file=sys.stderr) + print(f"Git stderr: {e.stderr}", file=sys.stderr) + sys.exit(1) + +def create_branch_execute_commands_and_push(github_token, github_repo, branch_name, commit_message, command_to_run=None): + """ + Creates a new branch with the specified name, performs specified action, commits the current changes and pushes it to the repo. + 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. + """ + + try: + # Initialize PyGithub and get the repository object + github_manager = GithubUtils(github_token, github_repo) + + if github_manager.is_branch_present(branch_name): + print(f"Branch '{branch_name}' already exists. Exiting.") + sys.exit(1) + + repo = get_local_repo() + + new_branch = repo.create_head(branch_name, repo.head.commit) + new_branch.checkout() + print(f"Created and checked out new branch: {branch_name}") + + if command_to_run: + print(f"\nExecuting command on branch '{branch_name}': {' '.join(command_to_run)}") + subprocess.run(command_to_run, text=True, check=True) + + print("Executed release.py script successfully.") + + repo.git.add('.') + repo.index.commit(commit_message, skip_hooks=True) + repo.git.push("origin", branch_name) + + print(f"Successfully created, updated and pushed new branch: {branch_name}") + + except GithubException as e: + print(f"An error occurred with the GitHub API: {e.status}", file=sys.stderr) + print(f"Error details: {e.data}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) From 0a3c08a76b603f744cc83363d023a64ad8b255d5 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:14:44 +0200 Subject: [PATCH 02/23] Added script to verify if release conditions are met --- .../verifyNetcodeReleaseConditions.py | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py diff --git a/Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py b/Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py new file mode 100644 index 0000000000..30cbe023c0 --- /dev/null +++ b/Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py @@ -0,0 +1,135 @@ +""" +Determines if NGO release automation job should run. + +The script will check the following conditions: +1. **Is today a release Saturday?** + - The script checks if today is a Saturday that falls on the 4-week cycle for Netcode releases. +2. **Is the [Unreleased] section of the CHANGELOG.md not empty?** + - The script checks if the [Unreleased] section in the CHANGELOG.md contains meaningful entries. +3. **Does the release branch already exist?** + - If the release branch for the target release already exists, the script will not run. + - For this you need to use separate function, see verifyNetcodeReleaseConditions definition +""" +#!/usr/bin/env python3 +import datetime +import re +import sys +import os + +UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../Utils')) +sys.path.insert(0, UTILS_DIR) +from general_utils import get_package_version_from_manifest # nopep8 +from git_utils import GithubUtils # nopep8 +from config import getPackageManifestPath, getNetcodeGithubRepo, getPackageChangelogPath, getNetcodeReleaseBranchName # nopep8 + +def is_release_date(weekday, release_week_cycle, anchor_date): + """ + Checks if today is a weekday that falls on the release_week_cycle starting from anchor_date . + Returns True if it is, False otherwise. + """ + today = datetime.date.today() + # Condition 1: Must be a given weekday + # Note as for example you could run a job that utilizes the fact that weekly trigger as per https://internaldocs.unity.com/yamato_continuous_integration/usage/jobs/recurring-jobs/#cron-syntax runs every Saturday, between 2 and 8 AM UTC depending on the load + if today.weekday() != weekday: + return False + + # Condition 2: Must be on a release_week_cycle interval from the anchor_date. + days_since_anchor = (today - anchor_date).days + weeks_since_anchor = days_since_anchor / 7 + + # We run on the first week of every release_week_cycle (e.g., week 0, 4, 8, ...) + if weeks_since_anchor % release_week_cycle == 0: + return True + + return False + +def is_changelog_empty(changelog_path): + """ + Checks if the [Unreleased] section in the CHANGELOG.md contains meaningful entries. + It is considered "empty" if the section only contains headers (like ### Added) but no actual content. + """ + if not os.path.exists(changelog_path): + print(f"Error: Changelog file not found at {changelog_path}") + sys.exit(1) + + with open(changelog_path, 'r', encoding='UTF-8') as f: + content = f.read() + + # This pattern starts where Unreleased section is placed + # Then it matches in the first group all empty sections (only lines that are empty or start with ##) + # The second group matches the start of the next Changelog entry (## [). + # if both groups are matched it means that the Unreleased section is empty. + pattern = re.compile(r"^## \[Unreleased\]\n((?:^###.*\n|^\s*\n)*)(^## \[)", re.MULTILINE) + match = pattern.search(content) + + # If we find a match for the "empty unreleased changelog entry" pattern, it means the changelog IS empty. + if match: + print("Found an [Unreleased] section containing no release notes.") + return True + + # If the pattern does not match, it means there must be meaningful content. + return False + +def verifyNetcodeReleaseConditions(): + """ + Checks conditions and exits with appropriate status code. + """ + + tools_manifest_path = getPackageManifestPath() + tools_changelog_path = getPackageChangelogPath() + tools_package_version = get_package_version_from_manifest(tools_manifest_path) + tools_github_repo = getNetcodeGithubRepo() + tools_release_branch_name = getNetcodeReleaseBranchName(tools_package_version) + tools_github_token = os.environ.get("GITHUB_TOKEN") + + # An anchor date that was a Saturday. This is used to establish the 4-week cycle. + # You can set this to any past Saturday that you want to mark as the start of a cycle (week 0). We use 2025-07-19 as starting point (previous release date). + anchor_saturday = datetime.date(2025, 7, 19) + + print("--- Checking conditions for NGO release ---") + + if not os.path.exists(tools_manifest_path): + print(f" Path does not exist: {tools_manifest_path}") + sys.exit(1) + + if not os.path.exists(tools_changelog_path): + print(f" Path does not exist: {tools_changelog_path}") + sys.exit(1) + + if tools_package_version is None: + print(f"Package version not found at {tools_manifest_path}") + sys.exit(1) + + if not tools_github_token: + print("Error: GITHUB_TOKEN environment variable not set.", file=sys.stderr) + sys.exit(1) + + if not is_release_date(weekday=5, release_week_cycle=4, anchor_date=anchor_saturday): + print("Condition not met: Today is not the scheduled release Saturday.") + print("Job will not run. Exiting.") + sys.exit(1) + + print("Condition met: Today is a scheduled release Saturday.") + + if is_changelog_empty(tools_changelog_path): + print("Condition not met: The [Unreleased] section of the changelog is empty.") + print("Job will not run. Exiting.") + sys.exit(1) + + print("Condition met: The changelog contains entries to be released.") + + # Initialize PyGithub and get the repository object + github_manager = GithubUtils(tools_github_token, tools_github_repo) + + if github_manager.is_branch_present(tools_release_branch_name): + print("Condition not met: The release branch already exists.") + print("Job will not run. Exiting.") + sys.exit(1) + + print("Condition met: The release branch does not yet exist.") + + print("\nAll conditions met. The release preparation job can proceed.") + sys.exit(0) + +if __name__ == "__main__": + verifyNetcodeReleaseConditions() From 76d2e78104f5b3ab00d7a9a2a83d521b4e7493a0 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:14:54 +0200 Subject: [PATCH 03/23] Added script to create proper release branch --- .../netcodeReleaseBranchCreation.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py diff --git a/Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py b/Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py new file mode 100644 index 0000000000..d527d21ba4 --- /dev/null +++ b/Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py @@ -0,0 +1,53 @@ +""" +This script automates the creation of a release branch for the NGO package. + +It performs the following steps: +1. Creates a new release branch named 'release/'. +2. Executes the release.py script that prepares branch for release by +updating changelog and ValidationExceptions. +3. Commits all changes made by the script. +4. Pushes the new branch to the remote repository. +""" +#!/usr/bin/env python3 +import sys +import os + +UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../Utils')) +sys.path.insert(0, UTILS_DIR) +from general_utils import get_package_version_from_manifest # nopep8 +from git_utils import create_branch_execute_commands_and_push # nopep8 +from config import getPackageManifestPath, getNetcodeReleaseBranchName, getNetcodeGithubRepo # nopep8 + +def createToolsReleaseBranch(): + """ + Creates a new release branch for the NGO package. + It also runs release.py script that prepares the branch for release by updating the changelog, ValidationExceptions etc + """ + + ngo_manifest_path = getPackageManifestPath() + ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) + ngo_github_repo = getNetcodeGithubRepo() + ngo_release_branch_name = getNetcodeReleaseBranchName(ngo_package_version) + ngo_github_token = os.environ.get("GITHUB_TOKEN") + + if not os.path.exists(ngo_manifest_path): + print(f" Path does not exist: {ngo_manifest_path}") + sys.exit(1) + + if ngo_package_version is None: + print(f"Package version not found at {ngo_manifest_path}") + sys.exit(1) + + if not ngo_github_token: + print("Error: GITHUB_TOKEN environment variable not set.", file=sys.stderr) + sys.exit(1) + + commit_message = f"Preparing Netcode package of version {ngo_package_version} for the release" + command_to_run_on_release_branch = ['python', 'Tools/scripts/release.py'] + + create_branch_execute_commands_and_push(ngo_github_token, ngo_github_repo, ngo_release_branch_name, commit_message, command_to_run_on_release_branch) + + + +if __name__ == "__main__": + createToolsReleaseBranch() From eedf1d7aaa872d6ed0c6a5c065a2207c1117d9bd Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:15:02 +0200 Subject: [PATCH 04/23] Added script to trigger Yamato jobs for release --- ...erYamatoJobsForNetcodeReleaseValidation.py | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py diff --git a/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py b/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py new file mode 100644 index 0000000000..acdc8efa01 --- /dev/null +++ b/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py @@ -0,0 +1,256 @@ +""" +This script triggers .yamato/wrench/publish-trigger.yml#all_promotion_related_jobs_promotiontrigger to facilitate NGO release process +We still need to manually set up Packageworks but this script will already trigger required jobs so we don't need to wait for them +The goal is to already trigger those on Saturday when release branch is being created so on Monday we can already see the results + +Additionally the job also triggers build automation job that will prepare builds for the Playtest. + +Requirements: +- A Long Lived Yamato API Token must be available as an environment variable. +""" +#!/usr/bin/env python3 +import os +import sys +import requests + +UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../Utils')) +sys.path.insert(0, UTILS_DIR) +from general_utils import get_package_version_from_manifest # nopep8 +from git_utils import get_latest_git_revision # nopep8 +from config import getPackageManifestPath, getNetcodeReleaseBranchName, getNetcodeProjectID # nopep8 + +YAMATO_API_URL = "https://yamato-api.cds.internal.unity3d.com/jobs" + +def trigger_wrench_promotion_job_on_yamato(yamato_api_token, project_id, branch_name, revision_sha): + """ + Triggers publish-trigger.yml#all_promotion_related_jobs_promotiontrigger job (via the REST API) to run release validation. + This function basically query the job that NEEDS to pass in order to release via Packageworks + Note that this will not publish/promote anything by itself but will just trigger the job that will run all the required tests and validations. + + For the arguments we need to pass the Yamato API Long Lived Token, project ID, branch name and revision SHA on which we want to trigger the job. + """ + + headers = { + "Authorization": f"ApiKey {yamato_api_token}", + "Content-Type": "application/json" + } + + data = { + "source": { + "branchname": branch_name, + "revision": revision_sha, + }, + "links": { + "project": f"/projects/{project_id}", + "jobDefinition": f"/projects/{project_id}/revisions/{revision_sha}/job-definitions/.yamato%2Fwrench%2Fpublish-trigger.yml%23all_promotion_related_jobs_promotiontrigger" + } + } + + print(f"Triggering job on branch {branch_name}...\n") + response = requests.post(YAMATO_API_URL, headers=headers, json=data) + + if response.status_code in [200, 201]: + data = response.json() + print(f"Successfully triggered '{data['jobDefinitionName']}' where full path is '{data['jobDefinition']['filename']}' on {branch_name} branch and {revision_sha} revision.") + else: + print(f"Failed to trigger job. Status: {response.status_code}", file=sys.stderr) + print("Error:", response.text, file=sys.stderr) + sys.exit(1) + + +def trigger_automated_builds_job_on_yamato(yamato_api_token, project_id, branch_name, revision_sha, samples_to_build, build_automation_configs): + """ + Triggers Yamato jobs (via the REST API) to prepare builds for Playtest. + Build Automation is based on https://github.cds.internal.unity3d.com/unity/dots/pull/14314 + + For the arguments we need to pass the Yamato API Long Lived Token, project ID, branch name and revision SHA on which we want to trigger the job. + On top of that we should pass samples_to_build in format like + + samples_to_build = [ + { + "name": "NetcodeSamples", + "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_NetcodeSamples_project", + } + ] + + Note that "name" is just a human readable name of the sample (for debug message )and "jobDefinition" is the path to the job definition in the Yamato project. This path needs to be URL encoded, so for example / or # signs need to be replaced with %2F and %23 respectively. + + You also need to pass build_automation_configs which will specify arguments for the build automation job. It should be in the following format: + + build_automation_configs = [ + { + "job_name": "Build Sample for Windows with minimal supported editor (2022.3), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "2022.3" } + ] + } + ] + + Again, note that the "job_name" is used for debug message and "variables" is a list of environment variables that will be passed to the job. Each variable should be a dictionary with "key" and "value" fields. + + The function will trigger builds for each sample in samples_to_build with each configuration in build_automation_configs. + """ + + headers = { + "Authorization": f"ApiKey {yamato_api_token}", + "Content-Type": "application/json" + } + + for sample in samples_to_build: + for config in build_automation_configs: + data = { + "source": { + "branchname": branch_name, + "revision": revision_sha, + }, + "links": { + "project": f"/projects/{project_id}", + "jobDefinition": f"/projects/{project_id}/revisions/{revision_sha}/job-definitions/{sample['jobDefinition']}" + }, + "environmentVariables": config["variables"] + } + + print(f"Triggering the build of {sample['name']} with a configuration '{config['job_name']}' on branch {branch_name}...\n") + response = requests.post(YAMATO_API_URL, headers=headers, json=data) + + if response.status_code in [200, 201]: + print("The job was successfully triggered \n") + else: + print(f"Failed to trigger job. Status: {response.status_code}", file=sys.stderr) + print(" Error:", response.text, file=sys.stderr) + # I will continue the job since it has a limited amount of requests and I don't want to block the whole script if one of the jobs fails + + + +def trigger_NGO_release_preparation_jobs(): + """Triggers Wrench dry run promotion josb and build automation for anticipation for Playtesting and Packageworks setup for NGO.""" + + samples_to_build = [ + { + "name": "BossRoom", + "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_BossRoom_project", + }, + { + "name": "Asteroids", + "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_Asteroids_project", + }, + { + "name": "SocialHub", + "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_SocialHub_project", + } + ] + + build_automation_configs = [ + { + "job_name": "Build Sample for Windows with minimal supported editor (2022.3), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor + ] + }, + { + "job_name": "Build Sample for Windows with latest functional editor (6000.2), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. + ] + }, + { + "job_name": "Build Sample for Windows with latest editor (trunk), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "trunk" } # latest editor + ] + }, + { + "job_name": "Build Sample for MacOS with minimal supported editor (2022.3), burst OFF, Mono", + "variables": [ + { "key": "BURST_ON_OFF", "value": "off" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, + { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor + ] + }, + { + "job_name": "Build Sample for MacOS with latest functional editor (6000.2), burst OFF, Mono", + "variables": [ + { "key": "BURST_ON_OFF", "value": "off" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, + { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. + ] + }, + { + "job_name": "Build Sample for MacOS with latest editor (trunk), burst OFF, Mono", + "variables": [ + { "key": "BURST_ON_OFF", "value": "off" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, + { "key": "UNITY_VERSION", "value": "trunk" } # latest editor + ] + }, + { + "job_name": "Build Sample for Android with minimal supported editor (2022.3), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor + ] + }, + { + "job_name": "Build Sample for Android with latest functional editor (6000.2), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. + ] + }, + { + "job_name": "Build Sample for Android with latest editor (trunk), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "trunk" } # latest editor + ] + } + ] + + ngo_manifest_path = getPackageManifestPath() + ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) + ngo_release_branch_name = getNetcodeReleaseBranchName(ngo_package_version) + ngo_yamato_api_token = os.environ.get("NETCODE_YAMATO_API_KEY") + + ngo_project_ID = getNetcodeProjectID() + revision_sha = get_latest_git_revision(ngo_release_branch_name) + + if not os.path.exists(ngo_manifest_path): + print(f" Path does not exist: {ngo_manifest_path}") + sys.exit(1) + + if ngo_package_version is None: + print(f"Package version not found at {ngo_manifest_path}") + sys.exit(1) + + if not ngo_yamato_api_token: + print("Error: NETCODE_YAMATO_API_KEY environment variable not set.", file=sys.stderr) + sys.exit(1) + + trigger_wrench_promotion_job_on_yamato(ngo_yamato_api_token, ngo_project_ID, ngo_release_branch_name, revision_sha) + trigger_automated_builds_job_on_yamato(ngo_yamato_api_token, ngo_project_ID, ngo_release_branch_name, revision_sha, samples_to_build, build_automation_configs) + + + +if __name__ == "__main__": + trigger_NGO_release_preparation_jobs() From a44973dc94cddfc706b039ad4ce1b0b54e979cd6 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:15:11 +0200 Subject: [PATCH 05/23] Added script to update changelog and package version on default branch --- ...etcodeChangelogAndPackageVersionUpdates.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py diff --git a/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py b/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py new file mode 100644 index 0000000000..d268465752 --- /dev/null +++ b/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py @@ -0,0 +1,92 @@ +""" +Creates a pull request to update the changelog for a new release using the GitHub API. +Quite often the changelog gets distorted between the time we branch for the release and the time we will branch back. +To mitigate this we want to create changelog update PR straight away and merge it fast while proceeding with the release. + +This script performs the following actions: +1. Reads the package version from the package.json file and updates the CHANGELOG.md file while also cleaning it from empty sections. +2. Updates the package version in the package.json file by incrementing the patch version to represent the current package state. +3. Commits the change and pushes to the branch. + +Requirements: +- A GITHUB TOKEN with 'repo' scope must be available as an environment variable. +""" +#!/usr/bin/env python3 +import os +import sys +from github import GithubException + +UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../Utils')) +sys.path.insert(0, UTILS_DIR) +from general_utils import get_package_version_from_manifest, update_changelog, update_package_version_by_patch # nopep8 +from git_utils import get_local_repo, GithubUtils # nopep8 +from config import getNetcodePackageName, getPackageManifestPath, getNetcodeGithubRepo, getDefaultRepoBranch, getPackageChangelogPath # nopep8 + +def updateNetcodeChangelogAndPackageVersionAndPush(): + """ + The function updates the changelog and package version for NGO in anticipation of a new release. + This means that it will clean and update the changelog for the current package version, then it will add new Unreleased section template at the top + and finally it will update the package version in the package.json file by incrementing the patch version to signify the current state of the package. + + This assumes that at the same time you already branched off for the release. Otherwise it may be confusing + """ + + ngo_package_name = getNetcodePackageName() + ngo_manifest_path = getPackageManifestPath() + ngo_changelog_path = getPackageChangelogPath() + ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) + ngo_github_repo = getNetcodeGithubRepo() + ngo_default_repo_branch_to_push_to = getDefaultRepoBranch() # The branch to which the changes will be pushed. For our purposes it's a default repo branch. + ngo_github_token = os.environ.get("GITHUB_TOKEN") + + print(f"Using branch: {ngo_default_repo_branch_to_push_to} for pushing changes") + + if not os.path.exists(ngo_manifest_path): + print(f" Path does not exist: {ngo_manifest_path}") + sys.exit(1) + + if not os.path.exists(ngo_changelog_path): + print(f" Path does not exist: {ngo_changelog_path}") + sys.exit(1) + + if ngo_package_version is None: + print(f"Package version not found at {ngo_manifest_path}") + sys.exit(1) + + if not ngo_github_token: + print("Error: GITHUB_TOKEN environment variable not set.", file=sys.stderr) + sys.exit(1) + + try: + # Initialize PyGithub and get the repository object + GithubUtils(ngo_github_token, ngo_github_repo) + + commit_message = f"Updated changelog and package version for NGO in anticipation of v{ngo_package_version} release" + + repo = get_local_repo() + repo.git.fetch() + repo.git.checkout(ngo_default_repo_branch_to_push_to) + repo.git.pull("origin", ngo_default_repo_branch_to_push_to) + + # Update the changelog file with adding new [Unreleased] section + update_changelog(ngo_package_version, ngo_package_name, add_unreleased_template=True) + # Update the package version by patch to represent the "current package state" after release + update_package_version_by_patch(ngo_manifest_path) + + repo.git.add(ngo_changelog_path) + repo.git.add(ngo_manifest_path) + repo.index.commit(commit_message, skip_hooks=True) + repo.git.push("origin", ngo_default_repo_branch_to_push_to) + + print(f"Successfully updated and pushed the changelog on branch: {ngo_default_repo_branch_to_push_to}") + + except GithubException as e: + print(f"An error occurred with the GitHub API: {e.status}", file=sys.stderr) + print(f"Error details: {e.data}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + updateNetcodeChangelogAndPackageVersionAndPush() From 370e36595ffe2b32044e4c7ccdc015e1ee008f65 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:15:18 +0200 Subject: [PATCH 06/23] updated release.py script --- Tools/scripts/release.py | 100 ++++++++------------------------------- 1 file changed, 20 insertions(+), 80 deletions(-) diff --git a/Tools/scripts/release.py b/Tools/scripts/release.py index 7c6d1c2b8e..359367c8bd 100644 --- a/Tools/scripts/release.py +++ b/Tools/scripts/release.py @@ -13,89 +13,29 @@ import subprocess import platform -package_name = 'com.unity.netcode.gameobjects' - -def update_changelog(new_version): - """ - Cleans the [Unreleased] section of the changelog by removing empty subsections, - then replaces the '[Unreleased]' tag with the new version and release date. - """ - - changelog_entry = f'## [{new_version}] - {datetime.date.today().isoformat()}' - changelog_path = f'{package_name}/CHANGELOG.md' - print("Latest CHANGELOG entry will be modified to: " + changelog_entry) - - with open(changelog_path, 'r', encoding='UTF-8') as f: - changelog_text = f.read() - - # This pattern finds a line starting with '###', followed by its newline, - # and then two more lines that contain only whitespace. - # The re.MULTILINE flag allows '^' to match the start of each line. - pattern = re.compile(r"^###.*\n\n\n", re.MULTILINE) - - # Replace every match with an empty string. The goal is to remove empty CHANGELOG subsections. - cleaned_content = pattern.sub('', changelog_text) - - # Replace the [Unreleased] section with the new version + cleaned subsections - changelog_text = re.sub(r'## \[Unreleased\]', changelog_entry, cleaned_content) - - # Write the changes - with open(changelog_path, 'w', encoding='UTF-8', newline='\n') as file: - file.write(changelog_text) - - -def update_validation_exceptions(new_version): - """ - Updates the ValidationExceptions.json file with the new package version. - """ - - validation_file = f'{package_name}/ValidationExceptions.json' - - # If files do not exist, exit - if not os.path.exists(validation_file): - return - - # Update the PackageVersion in the exceptions - with open(validation_file, 'rb') as f: - json_text = f.read() - data = json.loads(json_text) - updated = False - for exceptionElements in ["WarningExceptions", "ErrorExceptions"]: - exceptions = data.get(exceptionElements) - - if exceptions is not None: - for exception in exceptions: - if 'PackageVersion' in exception: - exception['PackageVersion'] = new_version - updated = True - - # If no exceptions were updated, we do not need to write the file - if not updated: - return - - with open(validation_file, 'w', encoding='UTF-8', newline='\n') as json_file: - json.dump(data, json_file, ensure_ascii=False, indent=2) - json_file.write("\n") # Add newline cause Py JSON does not - print(f" updated `{validation_file}`") - - -def get_manifest_json_version(filename): - """ - Reads the package.json file and returns the version specified in it. - """ - with open(filename, 'rb') as f: - json_text = f.read() - data = json.loads(json_text) - - return data['version'] - +UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), './Utils')) +sys.path.insert(0, UTILS_DIR) +from general_utils import get_package_version_from_manifest, update_changelog, update_validation_exceptions # nopep8 +from config import getNetcodePackageName, getPackageManifestPath, getPackageValidationExceptionsPath # nopep8 if __name__ == '__main__': - manifest_path = f'{package_name}/package.json' - package_version = get_manifest_json_version(manifest_path) + + ngo_package_name = getNetcodePackageName() + ngo_manifest_path = getPackageManifestPath() + ngo_validation_exceptions_path = getPackageValidationExceptionsPath() + ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) + + if not os.path.exists(ngo_manifest_path): + print(f" Path does not exist: {ngo_manifest_path}") + sys.exit(1) + + if ngo_package_version is None: + print(f"Package version not found at {ngo_manifest_path}") + sys.exit(1) # Update the ValidationExceptions.json file # with the new package version OR remove it if not a release branch - update_validation_exceptions(package_version) + update_validation_exceptions(ngo_validation_exceptions_path, ngo_package_version) # Clean the CHANGELOG and add latest entry - update_changelog(package_version) + # package version is already know as is always corresponds to current package state + update_changelog(ngo_package_version) From 1942991000d99cced40a17eb894db5c32186b80c Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:15:25 +0200 Subject: [PATCH 07/23] Created a job to run release automation --- .yamato/ngo-publish.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .yamato/ngo-publish.yml diff --git a/.yamato/ngo-publish.yml b/.yamato/ngo-publish.yml new file mode 100644 index 0000000000..f1513d4a5e --- /dev/null +++ b/.yamato/ngo-publish.yml @@ -0,0 +1,22 @@ +ngo_release_preparation: + name: "NGO release preparation" + agent: { type: Unity::VM, flavor: b1.small, image: package-ci/ubuntu-22.04:v4 } + triggers: + recurring: + - branch: develop-2.0.0 # We make new releases from this branch + frequency: weekly # Run at some point every Saturday. Note that it's restricted to every 4th Saturday inside the script + rerun: always + commands: + - pip install PyGithub + - pip install GitPython + # This script checks if requirements for automated release preparations are fulfilled. + # It checks if it's the correct time to run the release automation (every 4th week, end of Netcode sprint), if there is anything in the CHANGELOG to release and if the release branch wasnb't already created. + - python Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py + # If the conditions are met, this script branches off, runs release.py that will set up Wrench, regenerate recipes, clean changelog etc and pushes the new release branch. + # By doing this we ensure that package is potentially release ready. Note that package version is already always corresponding to current package state + - python Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py + # We still need to manually set up Packageworks but this script will already trigger required jobs so we don't need to wait for them. + # Additionally it will also trigger multiple builds with different configurations that we can use for Playtesting + - python Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py + # If the conditions are met, it will commit changelog update and patch package version bump (to reflect current state of the package on main branch) + - python Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py \ No newline at end of file From ab271af3a8a9f850e329da70be8d6b251991ec28 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:34:30 +0200 Subject: [PATCH 08/23] import correction --- Tools/scripts/release.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Tools/scripts/release.py b/Tools/scripts/release.py index 359367c8bd..5aff3d5ca1 100644 --- a/Tools/scripts/release.py +++ b/Tools/scripts/release.py @@ -6,12 +6,8 @@ Note that this script NEEDS TO BE RUN FROM THE ROOT of the project. """ #!/usr/bin/env python3 -import datetime -import json import os -import re -import subprocess -import platform +import sys UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), './Utils')) sys.path.insert(0, UTILS_DIR) From 0a550f31e9b401308bc66b818882523308201886 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 11:06:42 +0200 Subject: [PATCH 09/23] corrected UpdateChangelog logic --- ...commitNetcodeChangelogAndPackageVersionUpdates.py | 2 +- .../triggerYamatoJobsForNetcodeReleaseValidation.py | 2 +- Tools/scripts/Utils/general_utils.py | 11 +---------- Tools/scripts/release.py | 12 +++++++++--- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py b/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py index d268465752..f7008efd2a 100644 --- a/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py +++ b/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py @@ -69,7 +69,7 @@ def updateNetcodeChangelogAndPackageVersionAndPush(): repo.git.pull("origin", ngo_default_repo_branch_to_push_to) # Update the changelog file with adding new [Unreleased] section - update_changelog(ngo_package_version, ngo_package_name, add_unreleased_template=True) + update_changelog(ngo_changelog_path, ngo_package_version, ngo_package_name, add_unreleased_template=True) # Update the package version by patch to represent the "current package state" after release update_package_version_by_patch(ngo_manifest_path) diff --git a/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py b/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py index acdc8efa01..3216bebe67 100644 --- a/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py +++ b/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py @@ -126,7 +126,7 @@ def trigger_automated_builds_job_on_yamato(yamato_api_token, project_id, branch_ def trigger_NGO_release_preparation_jobs(): - """Triggers Wrench dry run promotion josb and build automation for anticipation for Playtesting and Packageworks setup for NGO.""" + """Triggers Wrench dry run promotion jobs and build automation for anticipation for Playtesting and Packageworks setup for NGO.""" samples_to_build = [ { diff --git a/Tools/scripts/Utils/general_utils.py b/Tools/scripts/Utils/general_utils.py index 12a196c1c5..ccd6e94e91 100644 --- a/Tools/scripts/Utils/general_utils.py +++ b/Tools/scripts/Utils/general_utils.py @@ -5,8 +5,6 @@ import re import datetime -from config import getPackageChangelogPath - UNRELEASED_CHANGELOG_SECTION_TEMPLATE = r""" ## [Unreleased] @@ -108,7 +106,7 @@ def update_validation_exceptions(validation_file, package_version): json_file.write("\n") # Add newline cause Py JSON does not print(f" updated `{validation_file}`") -def update_changelog(new_version, add_unreleased_template=False): +def update_changelog(changelog_path, new_version, add_unreleased_template=False): """ Cleans the [Unreleased] section of the changelog by removing empty subsections, then replaces the '[Unreleased]' tag with the new version and release date. @@ -123,11 +121,6 @@ def update_changelog(new_version, add_unreleased_template=False): new_changelog_entry = f'## [{new_version}] - {datetime.date.today().isoformat()}' version_header_to_find_if_exists = f'## [{new_version}]' - changelog_path = getPackageChangelogPath() - - if not os.path.exists(changelog_path): - print("CHANGELOG path is incorrect, the script will terminate without updating the CHANGELOG") - return None with open(changelog_path, 'r', encoding='UTF-8') as f: changelog_text = f.read() @@ -169,5 +162,3 @@ def update_changelog(new_version, add_unreleased_template=False): # Write the changes with open(changelog_path, 'w', encoding='UTF-8') as file: file.write(final_content) - - return final_content diff --git a/Tools/scripts/release.py b/Tools/scripts/release.py index 5aff3d5ca1..36641db1ba 100644 --- a/Tools/scripts/release.py +++ b/Tools/scripts/release.py @@ -12,19 +12,25 @@ UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), './Utils')) sys.path.insert(0, UTILS_DIR) from general_utils import get_package_version_from_manifest, update_changelog, update_validation_exceptions # nopep8 -from config import getNetcodePackageName, getPackageManifestPath, getPackageValidationExceptionsPath # nopep8 +from config import getNetcodePackageName, getPackageManifestPath, getPackageValidationExceptionsPath, getPackageChangelogPath # nopep8 if __name__ == '__main__': ngo_package_name = getNetcodePackageName() ngo_manifest_path = getPackageManifestPath() ngo_validation_exceptions_path = getPackageValidationExceptionsPath() - ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) + ngo_changelog_path = getPackageChangelogPath() if not os.path.exists(ngo_manifest_path): print(f" Path does not exist: {ngo_manifest_path}") sys.exit(1) + if not os.path.exists(ngo_changelog_path): + print(f" Path CHANGELOG does not exist: {ngo_changelog_path}") + sys.exit(1) + + ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) + if ngo_package_version is None: print(f"Package version not found at {ngo_manifest_path}") sys.exit(1) @@ -34,4 +40,4 @@ update_validation_exceptions(ngo_validation_exceptions_path, ngo_package_version) # Clean the CHANGELOG and add latest entry # package version is already know as is always corresponds to current package state - update_changelog(ngo_package_version) + update_changelog(ngo_changelog_path, ngo_package_version) From 2e0fa91e547fa7791cfd3eade2f88a33f5465e74 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 13:40:42 +0200 Subject: [PATCH 10/23] release.py update --- Tools/scripts/release.py | 50 ++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/Tools/scripts/release.py b/Tools/scripts/release.py index 36641db1ba..8533f4bff7 100644 --- a/Tools/scripts/release.py +++ b/Tools/scripts/release.py @@ -6,38 +6,42 @@ Note that this script NEEDS TO BE RUN FROM THE ROOT of the project. """ #!/usr/bin/env python3 +import json import os +import re import sys +import subprocess +import platform -UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), './Utils')) -sys.path.insert(0, UTILS_DIR) -from general_utils import get_package_version_from_manifest, update_changelog, update_validation_exceptions # nopep8 -from config import getNetcodePackageName, getPackageManifestPath, getPackageValidationExceptionsPath, getPackageChangelogPath # nopep8 +from Utils.general_utils import get_package_version_from_manifest, update_changelog, update_validation_exceptions # nopep8 -if __name__ == '__main__': - - ngo_package_name = getNetcodePackageName() - ngo_manifest_path = getPackageManifestPath() - ngo_validation_exceptions_path = getPackageValidationExceptionsPath() - ngo_changelog_path = getPackageChangelogPath() - - if not os.path.exists(ngo_manifest_path): - print(f" Path does not exist: {ngo_manifest_path}") - sys.exit(1) - - if not os.path.exists(ngo_changelog_path): - print(f" Path CHANGELOG does not exist: {ngo_changelog_path}") +def make_package_release_ready(manifest_path, changelog_path, validation_exceptions_path, package_version): + + if not os.path.exists(manifest_path): + print(f" Path does not exist: {manifest_path}") sys.exit(1) - ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) + if not os.path.exists(changelog_path): + print(f" Path does not exist: {changelog_path}") + sys.exit(1) - if ngo_package_version is None: - print(f"Package version not found at {ngo_manifest_path}") + if package_version is None: + print(f"Package version not found at {manifest_path}") sys.exit(1) # Update the ValidationExceptions.json file # with the new package version OR remove it if not a release branch - update_validation_exceptions(ngo_validation_exceptions_path, ngo_package_version) + update_validation_exceptions(validation_exceptions_path, package_version) # Clean the CHANGELOG and add latest entry - # package version is already know as is always corresponds to current package state - update_changelog(ngo_changelog_path, ngo_package_version) + # package version is already know as explained in + # https://github.cds.internal.unity3d.com/unity/dots/pull/14318 + update_changelog(changelog_path, package_version) + + +if __name__ == '__main__': + manifest_path = 'com.unity.netcode.gameobjects/package.json' + changelog_path = 'com.unity.netcode.gameobjects/CHANGELOG.md' + validation_exceptions_path = 'com.unity.netcode.gameobjects/ValidationExceptions.json' + package_version = get_package_version_from_manifest(manifest_path) + + make_package_release_ready(manifest_path, changelog_path, validation_exceptions_path, package_version) From f0b257b272950f204aa99c9501c2f1e643c3bafb Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 13:41:32 +0200 Subject: [PATCH 11/23] verifyReleaseConditions update --- .../verifyNetcodeReleaseConditions.py | 135 ------------------ .../scripts/Utils/verifyReleaseConditions.py | 95 ++++++++++++ 2 files changed, 95 insertions(+), 135 deletions(-) delete mode 100644 Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py create mode 100644 Tools/scripts/Utils/verifyReleaseConditions.py diff --git a/Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py b/Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py deleted file mode 100644 index 30cbe023c0..0000000000 --- a/Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py +++ /dev/null @@ -1,135 +0,0 @@ -""" -Determines if NGO release automation job should run. - -The script will check the following conditions: -1. **Is today a release Saturday?** - - The script checks if today is a Saturday that falls on the 4-week cycle for Netcode releases. -2. **Is the [Unreleased] section of the CHANGELOG.md not empty?** - - The script checks if the [Unreleased] section in the CHANGELOG.md contains meaningful entries. -3. **Does the release branch already exist?** - - If the release branch for the target release already exists, the script will not run. - - For this you need to use separate function, see verifyNetcodeReleaseConditions definition -""" -#!/usr/bin/env python3 -import datetime -import re -import sys -import os - -UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../Utils')) -sys.path.insert(0, UTILS_DIR) -from general_utils import get_package_version_from_manifest # nopep8 -from git_utils import GithubUtils # nopep8 -from config import getPackageManifestPath, getNetcodeGithubRepo, getPackageChangelogPath, getNetcodeReleaseBranchName # nopep8 - -def is_release_date(weekday, release_week_cycle, anchor_date): - """ - Checks if today is a weekday that falls on the release_week_cycle starting from anchor_date . - Returns True if it is, False otherwise. - """ - today = datetime.date.today() - # Condition 1: Must be a given weekday - # Note as for example you could run a job that utilizes the fact that weekly trigger as per https://internaldocs.unity.com/yamato_continuous_integration/usage/jobs/recurring-jobs/#cron-syntax runs every Saturday, between 2 and 8 AM UTC depending on the load - if today.weekday() != weekday: - return False - - # Condition 2: Must be on a release_week_cycle interval from the anchor_date. - days_since_anchor = (today - anchor_date).days - weeks_since_anchor = days_since_anchor / 7 - - # We run on the first week of every release_week_cycle (e.g., week 0, 4, 8, ...) - if weeks_since_anchor % release_week_cycle == 0: - return True - - return False - -def is_changelog_empty(changelog_path): - """ - Checks if the [Unreleased] section in the CHANGELOG.md contains meaningful entries. - It is considered "empty" if the section only contains headers (like ### Added) but no actual content. - """ - if not os.path.exists(changelog_path): - print(f"Error: Changelog file not found at {changelog_path}") - sys.exit(1) - - with open(changelog_path, 'r', encoding='UTF-8') as f: - content = f.read() - - # This pattern starts where Unreleased section is placed - # Then it matches in the first group all empty sections (only lines that are empty or start with ##) - # The second group matches the start of the next Changelog entry (## [). - # if both groups are matched it means that the Unreleased section is empty. - pattern = re.compile(r"^## \[Unreleased\]\n((?:^###.*\n|^\s*\n)*)(^## \[)", re.MULTILINE) - match = pattern.search(content) - - # If we find a match for the "empty unreleased changelog entry" pattern, it means the changelog IS empty. - if match: - print("Found an [Unreleased] section containing no release notes.") - return True - - # If the pattern does not match, it means there must be meaningful content. - return False - -def verifyNetcodeReleaseConditions(): - """ - Checks conditions and exits with appropriate status code. - """ - - tools_manifest_path = getPackageManifestPath() - tools_changelog_path = getPackageChangelogPath() - tools_package_version = get_package_version_from_manifest(tools_manifest_path) - tools_github_repo = getNetcodeGithubRepo() - tools_release_branch_name = getNetcodeReleaseBranchName(tools_package_version) - tools_github_token = os.environ.get("GITHUB_TOKEN") - - # An anchor date that was a Saturday. This is used to establish the 4-week cycle. - # You can set this to any past Saturday that you want to mark as the start of a cycle (week 0). We use 2025-07-19 as starting point (previous release date). - anchor_saturday = datetime.date(2025, 7, 19) - - print("--- Checking conditions for NGO release ---") - - if not os.path.exists(tools_manifest_path): - print(f" Path does not exist: {tools_manifest_path}") - sys.exit(1) - - if not os.path.exists(tools_changelog_path): - print(f" Path does not exist: {tools_changelog_path}") - sys.exit(1) - - if tools_package_version is None: - print(f"Package version not found at {tools_manifest_path}") - sys.exit(1) - - if not tools_github_token: - print("Error: GITHUB_TOKEN environment variable not set.", file=sys.stderr) - sys.exit(1) - - if not is_release_date(weekday=5, release_week_cycle=4, anchor_date=anchor_saturday): - print("Condition not met: Today is not the scheduled release Saturday.") - print("Job will not run. Exiting.") - sys.exit(1) - - print("Condition met: Today is a scheduled release Saturday.") - - if is_changelog_empty(tools_changelog_path): - print("Condition not met: The [Unreleased] section of the changelog is empty.") - print("Job will not run. Exiting.") - sys.exit(1) - - print("Condition met: The changelog contains entries to be released.") - - # Initialize PyGithub and get the repository object - github_manager = GithubUtils(tools_github_token, tools_github_repo) - - if github_manager.is_branch_present(tools_release_branch_name): - print("Condition not met: The release branch already exists.") - print("Job will not run. Exiting.") - sys.exit(1) - - print("Condition met: The release branch does not yet exist.") - - print("\nAll conditions met. The release preparation job can proceed.") - sys.exit(0) - -if __name__ == "__main__": - verifyNetcodeReleaseConditions() diff --git a/Tools/scripts/Utils/verifyReleaseConditions.py b/Tools/scripts/Utils/verifyReleaseConditions.py new file mode 100644 index 0000000000..26e8a16c9f --- /dev/null +++ b/Tools/scripts/Utils/verifyReleaseConditions.py @@ -0,0 +1,95 @@ +""" +Determines if Release conditions are met. + +The script will check the following conditions: +1. **Is today a release day?** + - The script checks if today is a specified in ReleaseConfig weekday that falls on the release cycle of the team. +2. **Is the [Unreleased] section of the CHANGELOG.md not empty?** + - The script checks if the [Unreleased] section in the CHANGELOG.md contains meaningful entries. +3. **Does the release branch already exist?** + - If the release branch for the target release already exists, the script will not run. +""" +#!/usr/bin/env python3 +import datetime +import re +import sys +import os + +PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../ReleaseAutomation')) +sys.path.insert(0, PARENT_DIR) + +from release_config import ReleaseConfig + +def is_release_date(weekday, release_week_cycle, anchor_date): + """ + Checks if today is a weekday that falls on the release_week_cycle starting from anchor_date . + Returns True if it is, False otherwise. + """ + today = datetime.date.today() + # Check first if today is given weekday + # Note as for example you could run a job that utilizes the fact that weekly trigger as per https://internaldocs.unity.com/yamato_continuous_integration/usage/jobs/recurring-jobs/#cron-syntax runs every Saturday, between 2 and 8 AM UTC depending on the load + if today.weekday() != weekday: + return False + + # Condition 2: Must be on a release_week_cycle interval from the anchor_date. + days_since_anchor = (today - anchor_date).days + weeks_since_anchor = days_since_anchor / 7 + + # We run on the first week of every release_week_cycle (e.g., week 0, 4, 8, ...) + return weeks_since_anchor % release_week_cycle == 0 + + +def is_changelog_empty(changelog_path): + """ + Checks if the [Unreleased] section in the CHANGELOG.md contains meaningful entries. + It is considered "empty" if the section only contains headers (like ### Added) but no actual content. + """ + if not os.path.exists(changelog_path): + raise FileNotFoundError(f"Changelog file not found at {changelog_path}") + + with open(changelog_path, 'r', encoding='UTF-8') as f: + content = f.read() + + # This pattern starts where Unreleased section is placed + # Then it matches in the first group all empty sections (only lines that are empty or start with ##) + # The second group matches the start of the next Changelog entry (## [). + # if both groups are matched it means that the Unreleased section is empty. + pattern = re.compile(r"^## \[Unreleased\]\n((?:^###.*\n|^\s*\n)*)(^## \[)", re.MULTILINE) + match = pattern.search(content) + + # If we find a match for the "empty unreleased changelog entry" pattern, it means the changelog IS empty. + return match + + +def verifyReleaseConditions(config: ReleaseConfig): + """ + Function to verify if the release automation job should run. + This function checks the following conditions: + 1. If today is a scheduled release day (based on release cycle, weekday and anchor date). + 2. If the [Unreleased] section of the CHANGELOG.md is not empty. + 3. If the release branch does not already exist. + """ + + error_messages = [] + + try: + if not is_release_date(config.weekday, config.release_week_cycle, config.anchor_date): + error_messages.append(f"Condition not met: Today is not the scheduled release day. It should be weekday: {config.weekday}, every {config.release_week_cycle} weeks starting from {config.anchor_date}.") + + if is_changelog_empty(config.changelog_path): + error_messages.append("Condition not met: The [Unreleased] section of the changelog has no meaningful entries.") + + if config.github_manager.is_branch_present(config.release_branch_name): + error_messages.append("Condition not met: The release branch already exists.") + + if error_messages: + print("\n--- Release conditions not met: ---") + for i, msg in enumerate(error_messages, 1): + print(f"{i}. {msg}") + print("\nJob will not run. Exiting.") + sys.exit(1) + + except Exception as e: + print(f"\n--- ERROR: Release Verification failed ---", file=sys.stderr) + print(f"Reason: {e}", file=sys.stderr) + sys.exit(1) From 397019aa8ab8816abde565bb6baed8804f700693 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 13:42:05 +0200 Subject: [PATCH 12/23] commit function update --- ...etcodeChangelogAndPackageVersionUpdates.py | 92 ------------------- ...commitChangelogAndPackageVersionUpdates.py | 68 ++++++++++++++ 2 files changed, 68 insertions(+), 92 deletions(-) delete mode 100644 Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py create mode 100644 Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py diff --git a/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py b/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py deleted file mode 100644 index f7008efd2a..0000000000 --- a/Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py +++ /dev/null @@ -1,92 +0,0 @@ -""" -Creates a pull request to update the changelog for a new release using the GitHub API. -Quite often the changelog gets distorted between the time we branch for the release and the time we will branch back. -To mitigate this we want to create changelog update PR straight away and merge it fast while proceeding with the release. - -This script performs the following actions: -1. Reads the package version from the package.json file and updates the CHANGELOG.md file while also cleaning it from empty sections. -2. Updates the package version in the package.json file by incrementing the patch version to represent the current package state. -3. Commits the change and pushes to the branch. - -Requirements: -- A GITHUB TOKEN with 'repo' scope must be available as an environment variable. -""" -#!/usr/bin/env python3 -import os -import sys -from github import GithubException - -UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../Utils')) -sys.path.insert(0, UTILS_DIR) -from general_utils import get_package_version_from_manifest, update_changelog, update_package_version_by_patch # nopep8 -from git_utils import get_local_repo, GithubUtils # nopep8 -from config import getNetcodePackageName, getPackageManifestPath, getNetcodeGithubRepo, getDefaultRepoBranch, getPackageChangelogPath # nopep8 - -def updateNetcodeChangelogAndPackageVersionAndPush(): - """ - The function updates the changelog and package version for NGO in anticipation of a new release. - This means that it will clean and update the changelog for the current package version, then it will add new Unreleased section template at the top - and finally it will update the package version in the package.json file by incrementing the patch version to signify the current state of the package. - - This assumes that at the same time you already branched off for the release. Otherwise it may be confusing - """ - - ngo_package_name = getNetcodePackageName() - ngo_manifest_path = getPackageManifestPath() - ngo_changelog_path = getPackageChangelogPath() - ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) - ngo_github_repo = getNetcodeGithubRepo() - ngo_default_repo_branch_to_push_to = getDefaultRepoBranch() # The branch to which the changes will be pushed. For our purposes it's a default repo branch. - ngo_github_token = os.environ.get("GITHUB_TOKEN") - - print(f"Using branch: {ngo_default_repo_branch_to_push_to} for pushing changes") - - if not os.path.exists(ngo_manifest_path): - print(f" Path does not exist: {ngo_manifest_path}") - sys.exit(1) - - if not os.path.exists(ngo_changelog_path): - print(f" Path does not exist: {ngo_changelog_path}") - sys.exit(1) - - if ngo_package_version is None: - print(f"Package version not found at {ngo_manifest_path}") - sys.exit(1) - - if not ngo_github_token: - print("Error: GITHUB_TOKEN environment variable not set.", file=sys.stderr) - sys.exit(1) - - try: - # Initialize PyGithub and get the repository object - GithubUtils(ngo_github_token, ngo_github_repo) - - commit_message = f"Updated changelog and package version for NGO in anticipation of v{ngo_package_version} release" - - repo = get_local_repo() - repo.git.fetch() - repo.git.checkout(ngo_default_repo_branch_to_push_to) - repo.git.pull("origin", ngo_default_repo_branch_to_push_to) - - # Update the changelog file with adding new [Unreleased] section - update_changelog(ngo_changelog_path, ngo_package_version, ngo_package_name, add_unreleased_template=True) - # Update the package version by patch to represent the "current package state" after release - update_package_version_by_patch(ngo_manifest_path) - - repo.git.add(ngo_changelog_path) - repo.git.add(ngo_manifest_path) - repo.index.commit(commit_message, skip_hooks=True) - repo.git.push("origin", ngo_default_repo_branch_to_push_to) - - print(f"Successfully updated and pushed the changelog on branch: {ngo_default_repo_branch_to_push_to}") - - except GithubException as e: - print(f"An error occurred with the GitHub API: {e.status}", file=sys.stderr) - print(f"Error details: {e.data}", file=sys.stderr) - sys.exit(1) - except Exception as e: - print(f"An unexpected error occurred: {e}", file=sys.stderr) - sys.exit(1) - -if __name__ == "__main__": - updateNetcodeChangelogAndPackageVersionAndPush() diff --git a/Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py b/Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py new file mode 100644 index 0000000000..1dae888461 --- /dev/null +++ b/Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py @@ -0,0 +1,68 @@ +""" +Creates a direct commit to specified branch (in the config) to update the changelog, package version and validation exceptions for a new release using the GitHub API. +Quite often the changelog gets distorted between the time we branch for the release and the time we will branch back. +To mitigate this we want to create changelog update PR straight away and merge it fast while proceeding with the release. + +This will also allow us to skip any PRs after releasing, unless, we made some changes on this branch. + +""" +#!/usr/bin/env python3 +import os +import sys +from github import GithubException +from git import Actor + +PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) +sys.path.insert(0, PARENT_DIR) + +from ReleaseAutomation.release_config import ReleaseConfig +from Utils.general_utils import get_package_version_from_manifest, update_changelog, update_package_version_by_patch, update_validation_exceptions +from Utils.git_utils import get_local_repo + +def commitChangelogAndPackageVersionUpdates(config: ReleaseConfig): + """ + The function updates the changelog and package version of the package in anticipation of a new release. + This means that it will + 1) Clean and update the changelog for the current package version. + 2) Add new Unreleased section template at the top. + 3) Update the package version in the package.json file by incrementing the patch version to signify the current state of the package. + 4) Update package version in the validation exceptions to match the new package version. + + This assumes that at the same time you already branched off for the release. Otherwise it may be confusing + """ + + try: + if not config.github_manager.is_branch_present(config.default_repo_branch): + print(f"Branch '{config.default_repo_branch}' does not exist. Exiting.") + sys.exit(1) + + repo = get_local_repo() + repo.git.fetch() + repo.git.checkout(config.default_repo_branch) + repo.git.pull("origin", config.default_repo_branch) + + # Update the changelog file with adding new [Unreleased] section + update_changelog(config.changelog_path, config.package_version, add_unreleased_template=True) + # Update the package version by patch to represent the "current package state" after release + updated_package_version = update_package_version_by_patch(config.manifest_path) + update_validation_exceptions(config.validation_exceptions_path, updated_package_version) + + repo.git.add(config.changelog_path) + repo.git.add(config.manifest_path) + repo.git.add(config.validation_exceptions_path) + + author = Actor(config.commiter_name, config.commiter_email) + committer = Actor(config.commiter_name, config.commiter_email) + + repo.index.commit(config.commit_message, author=author, committer=committer, skip_hooks=True) + repo.git.push("origin", config.default_repo_branch) + + print(f"Successfully updated and pushed the changelog on branch: {config.default_repo_branch}") + + except GithubException as e: + print(f"An error occurred with the GitHub API: {e.status}", file=sys.stderr) + print(f"Error details: {e.data}", file=sys.stderr) + sys.exit(1) + except Exception as e: + print(f"An unexpected error occurred: {e}", file=sys.stderr) + sys.exit(1) From 19770a291afca3401c72c4f2eaaf421ac7fb7a16 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 13:54:52 +0200 Subject: [PATCH 13/23] Updated config and other files --- .../netcodeReleaseBranchCreation.py | 53 ---- .../ReleaseAutomation/release_config.py | 187 +++++++++++++ .../run_release_preparation.py | 35 +++ ...erYamatoJobsForNetcodeReleaseValidation.py | 256 ------------------ Tools/scripts/Utils/config.py | 47 ---- Tools/scripts/Utils/general_utils.py | 36 +-- Tools/scripts/Utils/git_utils.py | 73 ++--- .../triggerYamatoJobsForReleasePreparation.py | 133 +++++++++ .../scripts/Utils/verifyReleaseConditions.py | 4 +- 9 files changed, 404 insertions(+), 420 deletions(-) delete mode 100644 Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py create mode 100644 Tools/scripts/ReleaseAutomation/release_config.py create mode 100644 Tools/scripts/ReleaseAutomation/run_release_preparation.py delete mode 100644 Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py delete mode 100644 Tools/scripts/Utils/config.py create mode 100644 Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py diff --git a/Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py b/Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py deleted file mode 100644 index d527d21ba4..0000000000 --- a/Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -This script automates the creation of a release branch for the NGO package. - -It performs the following steps: -1. Creates a new release branch named 'release/'. -2. Executes the release.py script that prepares branch for release by -updating changelog and ValidationExceptions. -3. Commits all changes made by the script. -4. Pushes the new branch to the remote repository. -""" -#!/usr/bin/env python3 -import sys -import os - -UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../Utils')) -sys.path.insert(0, UTILS_DIR) -from general_utils import get_package_version_from_manifest # nopep8 -from git_utils import create_branch_execute_commands_and_push # nopep8 -from config import getPackageManifestPath, getNetcodeReleaseBranchName, getNetcodeGithubRepo # nopep8 - -def createToolsReleaseBranch(): - """ - Creates a new release branch for the NGO package. - It also runs release.py script that prepares the branch for release by updating the changelog, ValidationExceptions etc - """ - - ngo_manifest_path = getPackageManifestPath() - ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) - ngo_github_repo = getNetcodeGithubRepo() - ngo_release_branch_name = getNetcodeReleaseBranchName(ngo_package_version) - ngo_github_token = os.environ.get("GITHUB_TOKEN") - - if not os.path.exists(ngo_manifest_path): - print(f" Path does not exist: {ngo_manifest_path}") - sys.exit(1) - - if ngo_package_version is None: - print(f"Package version not found at {ngo_manifest_path}") - sys.exit(1) - - if not ngo_github_token: - print("Error: GITHUB_TOKEN environment variable not set.", file=sys.stderr) - sys.exit(1) - - commit_message = f"Preparing Netcode package of version {ngo_package_version} for the release" - command_to_run_on_release_branch = ['python', 'Tools/scripts/release.py'] - - create_branch_execute_commands_and_push(ngo_github_token, ngo_github_repo, ngo_release_branch_name, commit_message, command_to_run_on_release_branch) - - - -if __name__ == "__main__": - createToolsReleaseBranch() diff --git a/Tools/scripts/ReleaseAutomation/release_config.py b/Tools/scripts/ReleaseAutomation/release_config.py new file mode 100644 index 0000000000..351ab875e4 --- /dev/null +++ b/Tools/scripts/ReleaseAutomation/release_config.py @@ -0,0 +1,187 @@ +import datetime +import sys +import os +from github import Github +from github import GithubException + +PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) +sys.path.insert(0, PARENT_DIR) + +from Utils.general_utils import get_package_version_from_manifest # nopep8 +from release import make_package_release_ready # nopep8 + +class GithubUtils: + def __init__(self, access_token, repo): + self.github = Github(base_url="https://api.github.com", + login_or_token=access_token) + self.repo = self.github.get_repo(repo) + + def is_branch_present(self, branch_name): + try: + self.repo.get_branch(branch_name) + return True # Branch exists + + except GithubException as ghe: + if ghe.status == 404: + return False # Branch does not exist + raise Exception(f"An error occurred with the GitHub API: {ghe.status}", data=ghe.data) + +class ReleaseConfig: + """A simple class to hold all shared configuration.""" + def __init__(self): + self.manifest_path = 'com.unity.netcode.gameobjects/package.json' + self.changelog_path = 'com.unity.netcode.gameobjects/CHANGELOG.md' + self.validation_exceptions_path = 'com.unity.netcode.gameobjects/ValidationExceptions.json' + self.github_repo = 'Unity-Technologies/com.unity.netcode.gameobjects' + self.default_repo_branch = 'develop-2.0.0' # Changelog and package version change will be pushed to this branch + self.yamato_project_id = '1201' + self.command_to_run_on_release_branch = make_package_release_ready + + self.release_weekday = 5 # Saturday + self.release_week_cycle = 4 # Release every 4 weeks + self.anchor_date = datetime.date(2025, 7, 19) # Anchor date for the release cycle (previous release Saturday) + + self.package_version = get_package_version_from_manifest(self.manifest_path) + self.release_branch_name = f"release/{self.package_version}" # Branch from which we want to release + self.commit_message = f"Updated changelog and package version for Netcode in anticipation of v{self.package_version} release" + + GITHUB_TOKEN_NAME = "NETCODE_GITHUB_CDS_TOKEN" + YAMATO_API_KEY_NAME = "NETCODE_YAMATO_API_KEY" + self.github_token = os.environ.get(GITHUB_TOKEN_NAME) + self.yamato_api_token = os.environ.get(YAMATO_API_KEY_NAME) + self.commiter_name = "netcode-automation" + self.commiter_email = "svc-netcode-sdk@unity3d.com" + + self.yamato_samples_to_build = [ + { + "name": "BossRoom", + "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_BossRoom_project", + }, + { + "name": "Asteroids", + "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_Asteroids_project", + }, + { + "name": "SocialHub", + "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_SocialHub_project", + } + ] + + self.yamato_build_automation_configs = [ + { + "job_name": "Build Sample for Windows with minimal supported editor (2022.3), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor + ] + }, + { + "job_name": "Build Sample for Windows with latest functional editor (6000.2), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. + ] + }, + { + "job_name": "Build Sample for Windows with latest editor (trunk), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "trunk" } # latest editor + ] + }, + { + "job_name": "Build Sample for MacOS with minimal supported editor (2022.3), burst OFF, Mono", + "variables": [ + { "key": "BURST_ON_OFF", "value": "off" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, + { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor + ] + }, + { + "job_name": "Build Sample for MacOS with latest functional editor (6000.2), burst OFF, Mono", + "variables": [ + { "key": "BURST_ON_OFF", "value": "off" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, + { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. + ] + }, + { + "job_name": "Build Sample for MacOS with latest editor (trunk), burst OFF, Mono", + "variables": [ + { "key": "BURST_ON_OFF", "value": "off" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, + { "key": "UNITY_VERSION", "value": "trunk" } # latest editor + ] + }, + { + "job_name": "Build Sample for Android with minimal supported editor (2022.3), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor + ] + }, + { + "job_name": "Build Sample for Android with latest functional editor (6000.2), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. + ] + }, + { + "job_name": "Build Sample for Android with latest editor (trunk), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "trunk" } # latest editor + ] + } + ] + + error_messages = [] + if not os.path.exists(self.manifest_path): + error_messages.append(f" Path does not exist: {self.manifest_path}") + + if not os.path.exists(self.changelog_path): + error_messages.append(f" Path does not exist: {self.changelog_path}") + + if not os.path.exists(self.validation_exceptions_path): + error_messages.append(f" Path does not exist: {self.validation_exceptions_path}") + + if not callable(self.command_to_run_on_release_branch): + error_messages.append("command_to_run_on_release_branch is not a function! Actual value:", self.command_to_run_on_release_branch) + + if self.package_version is None: + error_messages.append(f"Package version not found at {self.manifest_path}") + + if not self.github_token: + error_messages.append(f"Error: {GITHUB_TOKEN_NAME} environment variable not set.") + + if not self.yamato_api_token: + error_messages.append("Error: {YAMATO_API_KEY_NAME} environment variable not set.") + + # Initialize PyGithub and get the repository object + self.github_manager = GithubUtils(self.github_token, self.github_repo) + + if not self.github_manager.is_branch_present(self.default_repo_branch): + error_messages.append(f"Branch '{self.default_repo_branch}' does not exist.") + + if self.github_manager.is_branch_present(self.release_branch_name): + error_messages.append(f"Branch '{self.release_branch_name}' is already present in the repo.") + + if error_messages: + summary = "Failed to initialize NetcodeReleaseConfig due to invalid setup:\n" + "\n".join(f"- {msg}" for msg in error_messages) + raise ValueError(summary) diff --git a/Tools/scripts/ReleaseAutomation/run_release_preparation.py b/Tools/scripts/ReleaseAutomation/run_release_preparation.py new file mode 100644 index 0000000000..f9b8db7d8c --- /dev/null +++ b/Tools/scripts/ReleaseAutomation/run_release_preparation.py @@ -0,0 +1,35 @@ +import sys +import os + +PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) +sys.path.insert(0, PARENT_DIR) + +from ReleaseAutomation.release_config import ReleaseConfig # nopep8 +from Utils.git_utils import create_branch_execute_commands_and_push # nopep8 +from Utils.verifyReleaseConditions import verifyReleaseConditions # nopep8 +from Utils.commitChangelogAndPackageVersionUpdates import commitChangelogAndPackageVersionUpdates # nopep8 +from Utils.triggerYamatoJobsForReleasePreparation import trigger_release_preparation_jobs # nopep8 + +def PrepareNetcodePackageForRelease(): + try: + config = ReleaseConfig() + + print("\nStep 1: Verifying release conditions...") + verifyReleaseConditions(config) + + print("\nStep 2: Creating release branch...") + create_branch_execute_commands_and_push(config) + + print("\nStep 3: Triggering Yamato validation jobs...") + trigger_release_preparation_jobs(config) + + print("\nStep 4: Committing changelog and version updates...") + commitChangelogAndPackageVersionUpdates(config) + + except Exception as e: + print(f"\n--- ERROR: Netcode release process failed ---", file=sys.stderr) + print(f"Reason: {e}", file=sys.stderr) + sys.exit(1) + +if __name__ == "__main__": + PrepareNetcodePackageForRelease() diff --git a/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py b/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py deleted file mode 100644 index 3216bebe67..0000000000 --- a/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -This script triggers .yamato/wrench/publish-trigger.yml#all_promotion_related_jobs_promotiontrigger to facilitate NGO release process -We still need to manually set up Packageworks but this script will already trigger required jobs so we don't need to wait for them -The goal is to already trigger those on Saturday when release branch is being created so on Monday we can already see the results - -Additionally the job also triggers build automation job that will prepare builds for the Playtest. - -Requirements: -- A Long Lived Yamato API Token must be available as an environment variable. -""" -#!/usr/bin/env python3 -import os -import sys -import requests - -UTILS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../Utils')) -sys.path.insert(0, UTILS_DIR) -from general_utils import get_package_version_from_manifest # nopep8 -from git_utils import get_latest_git_revision # nopep8 -from config import getPackageManifestPath, getNetcodeReleaseBranchName, getNetcodeProjectID # nopep8 - -YAMATO_API_URL = "https://yamato-api.cds.internal.unity3d.com/jobs" - -def trigger_wrench_promotion_job_on_yamato(yamato_api_token, project_id, branch_name, revision_sha): - """ - Triggers publish-trigger.yml#all_promotion_related_jobs_promotiontrigger job (via the REST API) to run release validation. - This function basically query the job that NEEDS to pass in order to release via Packageworks - Note that this will not publish/promote anything by itself but will just trigger the job that will run all the required tests and validations. - - For the arguments we need to pass the Yamato API Long Lived Token, project ID, branch name and revision SHA on which we want to trigger the job. - """ - - headers = { - "Authorization": f"ApiKey {yamato_api_token}", - "Content-Type": "application/json" - } - - data = { - "source": { - "branchname": branch_name, - "revision": revision_sha, - }, - "links": { - "project": f"/projects/{project_id}", - "jobDefinition": f"/projects/{project_id}/revisions/{revision_sha}/job-definitions/.yamato%2Fwrench%2Fpublish-trigger.yml%23all_promotion_related_jobs_promotiontrigger" - } - } - - print(f"Triggering job on branch {branch_name}...\n") - response = requests.post(YAMATO_API_URL, headers=headers, json=data) - - if response.status_code in [200, 201]: - data = response.json() - print(f"Successfully triggered '{data['jobDefinitionName']}' where full path is '{data['jobDefinition']['filename']}' on {branch_name} branch and {revision_sha} revision.") - else: - print(f"Failed to trigger job. Status: {response.status_code}", file=sys.stderr) - print("Error:", response.text, file=sys.stderr) - sys.exit(1) - - -def trigger_automated_builds_job_on_yamato(yamato_api_token, project_id, branch_name, revision_sha, samples_to_build, build_automation_configs): - """ - Triggers Yamato jobs (via the REST API) to prepare builds for Playtest. - Build Automation is based on https://github.cds.internal.unity3d.com/unity/dots/pull/14314 - - For the arguments we need to pass the Yamato API Long Lived Token, project ID, branch name and revision SHA on which we want to trigger the job. - On top of that we should pass samples_to_build in format like - - samples_to_build = [ - { - "name": "NetcodeSamples", - "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_NetcodeSamples_project", - } - ] - - Note that "name" is just a human readable name of the sample (for debug message )and "jobDefinition" is the path to the job definition in the Yamato project. This path needs to be URL encoded, so for example / or # signs need to be replaced with %2F and %23 respectively. - - You also need to pass build_automation_configs which will specify arguments for the build automation job. It should be in the following format: - - build_automation_configs = [ - { - "job_name": "Build Sample for Windows with minimal supported editor (2022.3), burst ON, IL2CPP", - "variables": [ - { "key": "BURST_ON_OFF", "value": "on" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, - { "key": "UNITY_VERSION", "value": "2022.3" } - ] - } - ] - - Again, note that the "job_name" is used for debug message and "variables" is a list of environment variables that will be passed to the job. Each variable should be a dictionary with "key" and "value" fields. - - The function will trigger builds for each sample in samples_to_build with each configuration in build_automation_configs. - """ - - headers = { - "Authorization": f"ApiKey {yamato_api_token}", - "Content-Type": "application/json" - } - - for sample in samples_to_build: - for config in build_automation_configs: - data = { - "source": { - "branchname": branch_name, - "revision": revision_sha, - }, - "links": { - "project": f"/projects/{project_id}", - "jobDefinition": f"/projects/{project_id}/revisions/{revision_sha}/job-definitions/{sample['jobDefinition']}" - }, - "environmentVariables": config["variables"] - } - - print(f"Triggering the build of {sample['name']} with a configuration '{config['job_name']}' on branch {branch_name}...\n") - response = requests.post(YAMATO_API_URL, headers=headers, json=data) - - if response.status_code in [200, 201]: - print("The job was successfully triggered \n") - else: - print(f"Failed to trigger job. Status: {response.status_code}", file=sys.stderr) - print(" Error:", response.text, file=sys.stderr) - # I will continue the job since it has a limited amount of requests and I don't want to block the whole script if one of the jobs fails - - - -def trigger_NGO_release_preparation_jobs(): - """Triggers Wrench dry run promotion jobs and build automation for anticipation for Playtesting and Packageworks setup for NGO.""" - - samples_to_build = [ - { - "name": "BossRoom", - "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_BossRoom_project", - }, - { - "name": "Asteroids", - "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_Asteroids_project", - }, - { - "name": "SocialHub", - "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_SocialHub_project", - } - ] - - build_automation_configs = [ - { - "job_name": "Build Sample for Windows with minimal supported editor (2022.3), burst ON, IL2CPP", - "variables": [ - { "key": "BURST_ON_OFF", "value": "on" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, - { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor - ] - }, - { - "job_name": "Build Sample for Windows with latest functional editor (6000.2), burst ON, IL2CPP", - "variables": [ - { "key": "BURST_ON_OFF", "value": "on" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, - { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. - ] - }, - { - "job_name": "Build Sample for Windows with latest editor (trunk), burst ON, IL2CPP", - "variables": [ - { "key": "BURST_ON_OFF", "value": "on" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, - { "key": "UNITY_VERSION", "value": "trunk" } # latest editor - ] - }, - { - "job_name": "Build Sample for MacOS with minimal supported editor (2022.3), burst OFF, Mono", - "variables": [ - { "key": "BURST_ON_OFF", "value": "off" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, - { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor - ] - }, - { - "job_name": "Build Sample for MacOS with latest functional editor (6000.2), burst OFF, Mono", - "variables": [ - { "key": "BURST_ON_OFF", "value": "off" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, - { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. - ] - }, - { - "job_name": "Build Sample for MacOS with latest editor (trunk), burst OFF, Mono", - "variables": [ - { "key": "BURST_ON_OFF", "value": "off" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "mac" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "mono" }, - { "key": "UNITY_VERSION", "value": "trunk" } # latest editor - ] - }, - { - "job_name": "Build Sample for Android with minimal supported editor (2022.3), burst ON, IL2CPP", - "variables": [ - { "key": "BURST_ON_OFF", "value": "on" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, - { "key": "UNITY_VERSION", "value": "2022.3" } # Minimal supported editor - ] - }, - { - "job_name": "Build Sample for Android with latest functional editor (6000.2), burst ON, IL2CPP", - "variables": [ - { "key": "BURST_ON_OFF", "value": "on" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, - { "key": "UNITY_VERSION", "value": "6000.2" } # Editor that most our users will use (not alpha). Sometimes when testing on trunk we have weird editor issues not caused by us so the preference will be to test on latest editor that our users will use. - ] - }, - { - "job_name": "Build Sample for Android with latest editor (trunk), burst ON, IL2CPP", - "variables": [ - { "key": "BURST_ON_OFF", "value": "on" }, - { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "android" }, - { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, - { "key": "UNITY_VERSION", "value": "trunk" } # latest editor - ] - } - ] - - ngo_manifest_path = getPackageManifestPath() - ngo_package_version = get_package_version_from_manifest(ngo_manifest_path) - ngo_release_branch_name = getNetcodeReleaseBranchName(ngo_package_version) - ngo_yamato_api_token = os.environ.get("NETCODE_YAMATO_API_KEY") - - ngo_project_ID = getNetcodeProjectID() - revision_sha = get_latest_git_revision(ngo_release_branch_name) - - if not os.path.exists(ngo_manifest_path): - print(f" Path does not exist: {ngo_manifest_path}") - sys.exit(1) - - if ngo_package_version is None: - print(f"Package version not found at {ngo_manifest_path}") - sys.exit(1) - - if not ngo_yamato_api_token: - print("Error: NETCODE_YAMATO_API_KEY environment variable not set.", file=sys.stderr) - sys.exit(1) - - trigger_wrench_promotion_job_on_yamato(ngo_yamato_api_token, ngo_project_ID, ngo_release_branch_name, revision_sha) - trigger_automated_builds_job_on_yamato(ngo_yamato_api_token, ngo_project_ID, ngo_release_branch_name, revision_sha, samples_to_build, build_automation_configs) - - - -if __name__ == "__main__": - trigger_NGO_release_preparation_jobs() diff --git a/Tools/scripts/Utils/config.py b/Tools/scripts/Utils/config.py deleted file mode 100644 index fde0f1c2dd..0000000000 --- a/Tools/scripts/Utils/config.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Configuration for NGO Release Automation -""" - -def getDefaultRepoBranch(): - """ - Returns the name of Tools repo default branch. - This will be used to for example push changelog update for the release. - In general this branch is the default working branch - """ - return 'develop-2.0.0' - -def getNetcodeGithubRepo(): - """Returns the name of MP Tools repo.""" - return 'Unity-Technologies/com.unity.netcode.gameobjects' - -def getNetcodePackageName(): - """Returns the name of the MP Tools package.""" - return 'com.unity.netcode.gameobjects' - -def getPackageManifestPath(): - """Returns the path to the Netcode package manifest.""" - - return 'com.unity.netcode.gameobjects/package.json' - -def getPackageValidationExceptionsPath(): - """Returns the path to the Netcode ValidationExceptions.""" - - return 'com.unity.netcode.gameobjects/ValidationExceptions.json' - -def getPackageChangelogPath(): - """Returns the path to the Netcode package manifest.""" - - return 'com.unity.netcode.gameobjects/CHANGELOG.md' - -def getNetcodeReleaseBranchName(package_version): - """ - Returns the branch name for the Netcode release. - """ - return f"release/{package_version}" - -def getNetcodeProjectID(): - """ - Returns the Unity project ID for the DOTS monorepo. - Useful when for example triggering Yamato jobs - """ - return '1201' diff --git a/Tools/scripts/Utils/general_utils.py b/Tools/scripts/Utils/general_utils.py index ccd6e94e91..b8bc53cfed 100644 --- a/Tools/scripts/Utils/general_utils.py +++ b/Tools/scripts/Utils/general_utils.py @@ -29,23 +29,23 @@ ### Obsolete """ -def get_package_version_from_manifest(filepath): +def get_package_version_from_manifest(package_manifest_path): """ Reads the package.json file and returns the version specified in it. """ - if not os.path.exists(filepath): - print("get_manifest_json_version function couldn't find a specified filepath") + if not os.path.exists(package_manifest_path): + print("get_manifest_json_version function couldn't find a specified manifest_path") return None - with open(filepath, 'rb') as f: + with open(package_manifest_path, 'rb') as f: json_text = f.read() data = json.loads(json_text) return data['version'] -def update_package_version_by_patch(changelog_file): +def update_package_version_by_patch(package_manifest_path): """ Updates the package version in the package.json file. This function will bump the package version by a patch. @@ -53,26 +53,27 @@ def update_package_version_by_patch(changelog_file): 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 """ - if not os.path.exists(changelog_file): - raise FileNotFoundError(f"The file {changelog_file} does not exist.") + if not os.path.exists(package_manifest_path): + raise FileNotFoundError(f"The file {package_manifest_path} does not exist.") - with open(changelog_file, 'r', encoding='UTF-8') as f: - package_json = json.load(f) + with open(package_manifest_path, 'r', encoding='UTF-8') as f: + package_manifest = json.load(f) - version_parts = package_json['version'].split('.') + version_parts = get_package_version_from_manifest(package_manifest_path).split('.') if len(version_parts) != 3: raise ValueError("Version format is not valid. Expected format: 'major.minor.patch'.") # Increment the patch version version_parts[2] = str(int(version_parts[2]) + 1) - new_version = '.'.join(version_parts) + new_package_version = '.'.join(version_parts) - package_json['version'] = new_version + package_manifest['version'] = new_package_version - with open(changelog_file, 'w', encoding='UTF-8') as f: - json.dump(package_json, f, indent=4) + with open(package_manifest_path, 'w', encoding='UTF-8') as f: + json.dump(package_manifest, f, indent=4) + + return new_package_version - return new_version def update_validation_exceptions(validation_file, package_version): """ @@ -99,12 +100,15 @@ def update_validation_exceptions(validation_file, package_version): # If no exceptions were updated, we do not need to write the file if not updated: + print(f"No validation exceptions were updated in {validation_file}.") return with open(validation_file, 'w', encoding='UTF-8', newline='\n') as json_file: json.dump(data, json_file, ensure_ascii=False, indent=2) json_file.write("\n") # Add newline cause Py JSON does not - print(f" updated `{validation_file}`") + print(f"updated `{validation_file}`") + + def update_changelog(changelog_path, new_version, add_unreleased_template=False): """ diff --git a/Tools/scripts/Utils/git_utils.py b/Tools/scripts/Utils/git_utils.py index 3e65ad88d6..4168c556bb 100644 --- a/Tools/scripts/Utils/git_utils.py +++ b/Tools/scripts/Utils/git_utils.py @@ -1,27 +1,14 @@ """Helper class for Git repo operations.""" import subprocess import sys -from git import Repo -from github import Github +import os +from git import Repo, Actor from github import GithubException -class GithubUtils: - def __init__(self, access_token, repo): - self.github = Github(base_url="https://api.github.com", - login_or_token=access_token) - self.repo = self.github.get_repo(repo) +PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../ReleaseAutomation')) +sys.path.insert(0, PARENT_DIR) - def is_branch_present(self, branch_name): - try: - self.repo.get_branch(branch_name) - return True # Branch exists - - except GithubException as ghe: - if ghe.status == 404: - return False # Branch does not exist - print(f"An error occurred with the GitHub API: {ghe.status}", file=sys.stderr) - print(f"Error details: {ghe.data}", file=sys.stderr) - sys.exit(1) +from release_config import ReleaseConfig def get_local_repo(): root_dir = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], @@ -47,50 +34,44 @@ def get_latest_git_revision(branch_name): check=True ) return result.stdout.strip() + except FileNotFoundError: - print("Error: 'git' command not found. Is Git installed and in your PATH?", file=sys.stderr) - sys.exit(1) + raise Exception("Git command not found. Please ensure Git is installed and available in your PATH.") except subprocess.CalledProcessError as e: - print(f"Error: Failed to get revision for branch '{branch_name}'.", file=sys.stderr) - print(f"Git stderr: {e.stderr}", file=sys.stderr) - sys.exit(1) + raise Exception(f"Failed to get the latest revision for branch '{branch_name}'.") -def create_branch_execute_commands_and_push(github_token, github_repo, branch_name, commit_message, command_to_run=None): +def create_branch_execute_commands_and_push(config: ReleaseConfig): """ Creates a new branch with the specified name, performs specified action, commits the current changes and pushes it to the repo. - 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. + Note that command_to_run_on_release_branch (within the Config) should be a single command that will be executed using subprocess.run. For multiple commands consider using a Python script file. """ try: - # Initialize PyGithub and get the repository object - github_manager = GithubUtils(github_token, github_repo) - - if github_manager.is_branch_present(branch_name): - print(f"Branch '{branch_name}' already exists. Exiting.") - sys.exit(1) + if config.github_manager.is_branch_present(config.release_branch_name): + raise Exception(f"Branch '{config.release_branch_name}' already exists.") repo = get_local_repo() - new_branch = repo.create_head(branch_name, repo.head.commit) + new_branch = repo.create_head(config.release_branch_name, repo.head.commit) new_branch.checkout() - print(f"Created and checked out new branch: {branch_name}") - if command_to_run: - print(f"\nExecuting command on branch '{branch_name}': {' '.join(command_to_run)}") - subprocess.run(command_to_run, text=True, check=True) + if config.command_to_run_on_release_branch: + print(f"\nExecuting command on branch '{config.release_branch_name}': {' '.join(config.command_to_run_on_release_branch.__name__)}") + config.command_to_run_on_release_branch(config.manifest_path, config.changelog_path, config.validation_exceptions_path, config.package_version) + + repo.git.add('.yamato/') # regenerated jobs + repo.git.add('Packages/') # for example changelog and package.json updates + repo.git.add('Tools/CI/Monorepo.Cookbook/Settings') # Modified WrenchSettings - print("Executed release.py script successfully.") + author = Actor(config.commiter_name, config.commiter_email) + committer = Actor(config.commiter_name, config.commiter_email) - repo.git.add('.') - repo.index.commit(commit_message, skip_hooks=True) - repo.git.push("origin", branch_name) + repo.index.commit(config.commit_message, author=author, committer=committer, skip_hooks=True) + repo.git.push("origin", config.release_branch_name) - print(f"Successfully created, updated and pushed new branch: {branch_name}") + print(f"Successfully created, updated and pushed new branch: {config.release_branch_name}") except GithubException as e: - print(f"An error occurred with the GitHub API: {e.status}", file=sys.stderr) - print(f"Error details: {e.data}", file=sys.stderr) - sys.exit(1) + raise GithubException(f"An error occurred with the GitHub API: {e.status}", data=e.data) except Exception as e: - print(f"An unexpected error occurred: {e}", file=sys.stderr) - sys.exit(1) + raise Exception(f"An unexpected error occurred: {e}") diff --git a/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py b/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py new file mode 100644 index 0000000000..4c2cad0014 --- /dev/null +++ b/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py @@ -0,0 +1,133 @@ +""" +This script triggers .yamato/wrench/publish-trigger.yml#all_promotion_related_jobs_promotiontrigger to facilitate package release process +We still need to manually set up Packageworks but this script will already trigger required jobs so we don't need to wait for them +The goal is to already trigger those when release branch is being created so after Packageworks setup we can already see the results + +Additionally the job also triggers build automation job that will prepare builds for the Playtest. +""" +#!/usr/bin/env python3 +import os +import sys +import requests + +PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) +sys.path.insert(0, PARENT_DIR) + +from ReleaseAutomation.release_config import ReleaseConfig +from Utils.git_utils import get_latest_git_revision + +YAMATO_API_URL = "https://yamato-api.cds.internal.unity3d.com/jobs" + +def trigger_wrench_promotion_job_on_yamato(yamato_api_token, project_id, branch_name, revision_sha): + """ + Triggers publish-trigger.yml#all_promotion_related_jobs_promotiontrigger job (via the REST API) to run release validation. + This function basically query the job that NEEDS to pass in order to release via Packageworks + Note that this will not publish/promote anything by itself but will just trigger the job that will run all the required tests and validations. + + For the arguments we need to pass the Yamato API Long Lived Token, project ID, branch name and revision SHA on which we want to trigger the job. + """ + + headers = { + "Authorization": f"ApiKey {yamato_api_token}", + "Content-Type": "application/json" + } + + data = { + "source": { + "branchname": branch_name, + "revision": revision_sha, + }, + "links": { + "project": f"/projects/{project_id}", + "jobDefinition": f"/projects/{project_id}/revisions/{revision_sha}/job-definitions/.yamato%2Fwrench%2Fpublish-trigger.yml%23all_promotion_related_jobs_promotiontrigger" + } + } + + print(f"Triggering job on branch {branch_name}...\n") + response = requests.post(YAMATO_API_URL, headers=headers, json=data) + + if response.status_code in [200, 201]: + data = response.json() + print(f"Successfully triggered '{data['jobDefinitionName']}' where full path is '{data['jobDefinition']['filename']}' on {branch_name} branch and {revision_sha} revision.") + else: + raise Exception(f"Failed to trigger job. Status: {response.status_code}, Error: {response.text}") + + +def trigger_automated_builds_job_on_yamato(yamato_api_token, project_id, branch_name, revision_sha, samples_to_build, build_automation_configs): + """ + Triggers Yamato jobs (via the REST API) to prepare builds for Playtest. + Build Automation is based on https://github.cds.internal.unity3d.com/unity/dots/pull/14314 + + For the arguments we need to pass the Yamato API Long Lived Token, project ID, branch name and revision SHA on which we want to trigger the job. + On top of that we should pass samples_to_build in format like + + samples_to_build = [ + { + "name": "NetcodeSamples", + "jobDefinition": f".yamato%2Fproject-builders%2Fproject-builders.yml%23build_NetcodeSamples_project", + } + ] + + Note that "name" is just a human readable name of the sample (for debug message )and "jobDefinition" is the path to the job definition in the Yamato project. This path needs to be URL encoded, so for example / or # signs need to be replaced with %2F and %23 respectively. + + You also need to pass build_automation_configs which will specify arguments for the build automation job. It should be in the following format: + + build_automation_configs = [ + { + "job_name": "Build Sample for Windows with minimal supported editor (2022.3), burst ON, IL2CPP", + "variables": [ + { "key": "BURST_ON_OFF", "value": "on" }, + { "key": "PLATFORM_WIN64_MAC_ANDROID", "value": "win64" }, + { "key": "SCRIPTING_BACKEND_IL2CPP_MONO", "value": "il2cpp" }, + { "key": "UNITY_VERSION", "value": "2022.3" } + ] + } + ] + + Again, note that the "job_name" is used for debug message and "variables" is a list of environment variables that will be passed to the job. Each variable should be a dictionary with "key" and "value" fields. + + The function will trigger builds for each sample in samples_to_build with each configuration in build_automation_configs. + """ + + headers = { + "Authorization": f"ApiKey {yamato_api_token}", + "Content-Type": "application/json" + } + + for sample in samples_to_build: + for config in build_automation_configs: + data = { + "source": { + "branchname": branch_name, + "revision": revision_sha, + }, + "links": { + "project": f"/projects/{project_id}", + "jobDefinition": f"/projects/{project_id}/revisions/{revision_sha}/job-definitions/{sample['jobDefinition']}" + }, + "environmentVariables": config["variables"] + } + + print(f"Triggering the build of {sample['name']} with a configuration '{config['job_name']}' on branch {branch_name}...\n") + response = requests.post(YAMATO_API_URL, headers=headers, json=data) + + if not response.status_code in [200, 201]: + print(f"Failed to trigger job. Status: {response.status_code}", file=sys.stderr) + print(" Error:", response.text, file=sys.stderr) + # I will continue the job since it has a limited amount of requests and I don't want to block the whole script if one of the jobs fails + + + +def trigger_release_preparation_jobs(config: ReleaseConfig): + """Triggers Wrench dry run promotion jobs and build automation for anticipation for Playtesting and Packageworks setup for Netcode.""" + + try: + revision_sha = get_latest_git_revision(config.release_branch_name) + + trigger_wrench_promotion_job_on_yamato(config.yamato_api_token, config.yamato_project_id, config.release_branch_name, revision_sha) + trigger_automated_builds_job_on_yamato(config.yamato_api_token, config.yamato_project_id, config.release_branch_name, revision_sha, config.yamato_samples_to_build, config.yamato_build_automation_configs) + + except Exception as e: + print(f"\n--- ERROR: Job failed ---", file=sys.stderr) + print(f"Reason: {e}", file=sys.stderr) + sys.exit(1) diff --git a/Tools/scripts/Utils/verifyReleaseConditions.py b/Tools/scripts/Utils/verifyReleaseConditions.py index 26e8a16c9f..5dadc1b516 100644 --- a/Tools/scripts/Utils/verifyReleaseConditions.py +++ b/Tools/scripts/Utils/verifyReleaseConditions.py @@ -73,8 +73,8 @@ def verifyReleaseConditions(config: ReleaseConfig): error_messages = [] try: - if not is_release_date(config.weekday, config.release_week_cycle, config.anchor_date): - error_messages.append(f"Condition not met: Today is not the scheduled release day. It should be weekday: {config.weekday}, every {config.release_week_cycle} weeks starting from {config.anchor_date}.") + if not is_release_date(config.release_weekday, config.release_week_cycle, config.anchor_date): + error_messages.append(f"Condition not met: Today is not the scheduled release day. It should be weekday: {config.release_weekday}, every {config.release_week_cycle} weeks starting from {config.anchor_date}.") if is_changelog_empty(config.changelog_path): error_messages.append("Condition not met: The [Unreleased] section of the changelog has no meaningful entries.") From ed56587427291e78b2c23d759bbb08b65a85cb6c Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 14:01:19 +0200 Subject: [PATCH 14/23] Corrected job commands --- .yamato/ngo-publish.yml | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/.yamato/ngo-publish.yml b/.yamato/ngo-publish.yml index f1513d4a5e..d5a6bfff61 100644 --- a/.yamato/ngo-publish.yml +++ b/.yamato/ngo-publish.yml @@ -9,14 +9,4 @@ ngo_release_preparation: commands: - pip install PyGithub - pip install GitPython - # This script checks if requirements for automated release preparations are fulfilled. - # It checks if it's the correct time to run the release automation (every 4th week, end of Netcode sprint), if there is anything in the CHANGELOG to release and if the release branch wasnb't already created. - - python Tools/scripts/ReleaseAutomation/verifyNetcodeReleaseConditions.py - # If the conditions are met, this script branches off, runs release.py that will set up Wrench, regenerate recipes, clean changelog etc and pushes the new release branch. - # By doing this we ensure that package is potentially release ready. Note that package version is already always corresponding to current package state - - python Tools/scripts/ReleaseAutomation/netcodeReleaseBranchCreation.py - # We still need to manually set up Packageworks but this script will already trigger required jobs so we don't need to wait for them. - # Additionally it will also trigger multiple builds with different configurations that we can use for Playtesting - - python Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py - # If the conditions are met, it will commit changelog update and patch package version bump (to reflect current state of the package on main branch) - - python Tools/scripts/ReleaseAutomation/commitNetcodeChangelogAndPackageVersionUpdates.py \ No newline at end of file + - python Tools/CI/Netcode/ReleaseAutomation/run_release_preparation.py \ No newline at end of file From e2cdbedd4adec29f8b4067cb527ce4eeec7e670d Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 14:06:34 +0200 Subject: [PATCH 15/23] command typo --- .yamato/ngo-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.yamato/ngo-publish.yml b/.yamato/ngo-publish.yml index d5a6bfff61..30bbfb6d14 100644 --- a/.yamato/ngo-publish.yml +++ b/.yamato/ngo-publish.yml @@ -9,4 +9,4 @@ ngo_release_preparation: commands: - pip install PyGithub - pip install GitPython - - python Tools/CI/Netcode/ReleaseAutomation/run_release_preparation.py \ No newline at end of file + - python Tools/scripts/ReleaseAutomation/run_release_preparation.py \ No newline at end of file From 5894ea27aca9d36959b5aa8f4834de32123a2b94 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 14:20:55 +0200 Subject: [PATCH 16/23] secret correction --- Tools/scripts/ReleaseAutomation/release_config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/scripts/ReleaseAutomation/release_config.py b/Tools/scripts/ReleaseAutomation/release_config.py index 351ab875e4..f543c753bc 100644 --- a/Tools/scripts/ReleaseAutomation/release_config.py +++ b/Tools/scripts/ReleaseAutomation/release_config.py @@ -45,7 +45,7 @@ def __init__(self): self.release_branch_name = f"release/{self.package_version}" # Branch from which we want to release self.commit_message = f"Updated changelog and package version for Netcode in anticipation of v{self.package_version} release" - GITHUB_TOKEN_NAME = "NETCODE_GITHUB_CDS_TOKEN" + GITHUB_TOKEN_NAME = "NETCODE_GITHUB_TOKEN" YAMATO_API_KEY_NAME = "NETCODE_YAMATO_API_KEY" self.github_token = os.environ.get(GITHUB_TOKEN_NAME) self.yamato_api_token = os.environ.get(YAMATO_API_KEY_NAME) From d7e6c50a44b60baeeafe0bba98dd0a5cbfd80917 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 11:29:47 +0200 Subject: [PATCH 17/23] nitpicks --- .../ReleaseAutomation/release_config.py | 8 +++++--- .../run_release_preparation.py | 17 ++++++++-------- Tools/scripts/Utils/git_utils.py | 20 +++++++++---------- .../triggerYamatoJobsForReleasePreparation.py | 12 +++++------ .../scripts/Utils/verifyReleaseConditions.py | 10 +++++----- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/Tools/scripts/ReleaseAutomation/release_config.py b/Tools/scripts/ReleaseAutomation/release_config.py index f543c753bc..2596fb893d 100644 --- a/Tools/scripts/ReleaseAutomation/release_config.py +++ b/Tools/scripts/ReleaseAutomation/release_config.py @@ -1,4 +1,6 @@ -import datetime +"""Netcode configuration for the release process automation.""" + +import datetime import sys import os from github import Github @@ -7,8 +9,8 @@ PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) sys.path.insert(0, PARENT_DIR) -from Utils.general_utils import get_package_version_from_manifest # nopep8 -from release import make_package_release_ready # nopep8 +from Utils.general_utils import get_package_version_from_manifest +from release import make_package_release_ready class GithubUtils: def __init__(self, access_token, repo): diff --git a/Tools/scripts/ReleaseAutomation/run_release_preparation.py b/Tools/scripts/ReleaseAutomation/run_release_preparation.py index f9b8db7d8c..d0ca68d5e0 100644 --- a/Tools/scripts/ReleaseAutomation/run_release_preparation.py +++ b/Tools/scripts/ReleaseAutomation/run_release_preparation.py @@ -1,14 +1,15 @@ -import sys -import os +"""Automation for package release process.""" PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) sys.path.insert(0, PARENT_DIR) -from ReleaseAutomation.release_config import ReleaseConfig # nopep8 -from Utils.git_utils import create_branch_execute_commands_and_push # nopep8 -from Utils.verifyReleaseConditions import verifyReleaseConditions # nopep8 -from Utils.commitChangelogAndPackageVersionUpdates import commitChangelogAndPackageVersionUpdates # nopep8 -from Utils.triggerYamatoJobsForReleasePreparation import trigger_release_preparation_jobs # nopep8 +import sys +import os +from ReleaseAutomation.release_config import ReleaseConfig +from Utils.git_utils import create_branch_execute_commands_and_push +from Utils.verifyReleaseConditions import verifyReleaseConditions +from Utils.commitChangelogAndPackageVersionUpdates import commitChangelogAndPackageVersionUpdates +from Utils.triggerYamatoJobsForReleasePreparation import trigger_release_preparation_jobs def PrepareNetcodePackageForRelease(): try: @@ -27,7 +28,7 @@ def PrepareNetcodePackageForRelease(): commitChangelogAndPackageVersionUpdates(config) except Exception as e: - print(f"\n--- ERROR: Netcode release process failed ---", file=sys.stderr) + print("\n--- ERROR: Netcode release process failed ---", file=sys.stderr) print(f"Reason: {e}", file=sys.stderr) sys.exit(1) diff --git a/Tools/scripts/Utils/git_utils.py b/Tools/scripts/Utils/git_utils.py index 4168c556bb..4862c1223b 100644 --- a/Tools/scripts/Utils/git_utils.py +++ b/Tools/scripts/Utils/git_utils.py @@ -1,13 +1,13 @@ """Helper class for Git repo operations.""" + +PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../ReleaseAutomation')) +sys.path.insert(0, PARENT_DIR) + import subprocess import sys import os from git import Repo, Actor from github import GithubException - -PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../ReleaseAutomation')) -sys.path.insert(0, PARENT_DIR) - from release_config import ReleaseConfig def get_local_repo(): @@ -35,10 +35,10 @@ def get_latest_git_revision(branch_name): ) return result.stdout.strip() - except FileNotFoundError: - raise Exception("Git command not found. Please ensure Git is installed and available in your PATH.") + except FileNotFoundError as exc: + raise Exception("Git command not found. Please ensure Git is installed and available in your PATH.") from exc except subprocess.CalledProcessError as e: - raise Exception(f"Failed to get the latest revision for branch '{branch_name}'.") + raise Exception(f"Failed to get the latest revision for branch '{branch_name}'.") from e def create_branch_execute_commands_and_push(config: ReleaseConfig): """ @@ -57,7 +57,7 @@ def create_branch_execute_commands_and_push(config: ReleaseConfig): if config.command_to_run_on_release_branch: print(f"\nExecuting command on branch '{config.release_branch_name}': {' '.join(config.command_to_run_on_release_branch.__name__)}") - config.command_to_run_on_release_branch(config.manifest_path, config.changelog_path, config.validation_exceptions_path, config.package_version) + config.command_to_run_on_release_branch(config.manifest_path, config.changelog_path, config.validation_exceptions_path, config.package_version, config.package_name_regex) repo.git.add('.yamato/') # regenerated jobs repo.git.add('Packages/') # for example changelog and package.json updates @@ -72,6 +72,6 @@ def create_branch_execute_commands_and_push(config: ReleaseConfig): print(f"Successfully created, updated and pushed new branch: {config.release_branch_name}") except GithubException as e: - raise GithubException(f"An error occurred with the GitHub API: {e.status}", data=e.data) + raise GithubException(f"An error occurred with the GitHub API: {e.status}", data=e.data) from e except Exception as e: - raise Exception(f"An unexpected error occurred: {e}") + raise Exception(f"An unexpected error occurred: {e}") from e diff --git a/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py b/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py index 4c2cad0014..d9333eef54 100644 --- a/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py +++ b/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py @@ -6,13 +6,13 @@ Additionally the job also triggers build automation job that will prepare builds for the Playtest. """ #!/usr/bin/env python3 -import os -import sys -import requests PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) sys.path.insert(0, PARENT_DIR) +import os +import sys +import requests from ReleaseAutomation.release_config import ReleaseConfig from Utils.git_utils import get_latest_git_revision @@ -44,7 +44,7 @@ def trigger_wrench_promotion_job_on_yamato(yamato_api_token, project_id, branch_ } print(f"Triggering job on branch {branch_name}...\n") - response = requests.post(YAMATO_API_URL, headers=headers, json=data) + response = requests.post(YAMATO_API_URL, headers=headers, json=data, timeout=10) if response.status_code in [200, 201]: data = response.json() @@ -109,7 +109,7 @@ def trigger_automated_builds_job_on_yamato(yamato_api_token, project_id, branch_ } print(f"Triggering the build of {sample['name']} with a configuration '{config['job_name']}' on branch {branch_name}...\n") - response = requests.post(YAMATO_API_URL, headers=headers, json=data) + response = requests.post(YAMATO_API_URL, headers=headers, json=data, timeout=10) if not response.status_code in [200, 201]: print(f"Failed to trigger job. Status: {response.status_code}", file=sys.stderr) @@ -128,6 +128,6 @@ def trigger_release_preparation_jobs(config: ReleaseConfig): trigger_automated_builds_job_on_yamato(config.yamato_api_token, config.yamato_project_id, config.release_branch_name, revision_sha, config.yamato_samples_to_build, config.yamato_build_automation_configs) except Exception as e: - print(f"\n--- ERROR: Job failed ---", file=sys.stderr) + print("\n--- ERROR: Job failed ---", file=sys.stderr) print(f"Reason: {e}", file=sys.stderr) sys.exit(1) diff --git a/Tools/scripts/Utils/verifyReleaseConditions.py b/Tools/scripts/Utils/verifyReleaseConditions.py index 5dadc1b516..925ff05394 100644 --- a/Tools/scripts/Utils/verifyReleaseConditions.py +++ b/Tools/scripts/Utils/verifyReleaseConditions.py @@ -10,14 +10,14 @@ - If the release branch for the target release already exists, the script will not run. """ #!/usr/bin/env python3 -import datetime -import re -import sys -import os PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../ReleaseAutomation')) sys.path.insert(0, PARENT_DIR) +import datetime +import re +import sys +import os from release_config import ReleaseConfig def is_release_date(weekday, release_week_cycle, anchor_date): @@ -90,6 +90,6 @@ def verifyReleaseConditions(config: ReleaseConfig): sys.exit(1) except Exception as e: - print(f"\n--- ERROR: Release Verification failed ---", file=sys.stderr) + print("\n--- ERROR: Release Verification failed ---", file=sys.stderr) print(f"Reason: {e}", file=sys.stderr) sys.exit(1) From b6c6023bc639b0658b24aee189374a35b704f6dc Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 12:15:14 +0200 Subject: [PATCH 18/23] Added Validation exceptions file --- Tools/scripts/ReleaseAutomation/run_release_preparation.py | 5 +++-- Tools/scripts/Utils/git_utils.py | 5 +++-- .../scripts/Utils/triggerYamatoJobsForReleasePreparation.py | 5 +++-- Tools/scripts/Utils/verifyReleaseConditions.py | 5 +++-- com.unity.netcode.gameobjects/ValidationExceptions.json | 4 ++++ 5 files changed, 16 insertions(+), 8 deletions(-) create mode 100644 com.unity.netcode.gameobjects/ValidationExceptions.json diff --git a/Tools/scripts/ReleaseAutomation/run_release_preparation.py b/Tools/scripts/ReleaseAutomation/run_release_preparation.py index d0ca68d5e0..2c57472023 100644 --- a/Tools/scripts/ReleaseAutomation/run_release_preparation.py +++ b/Tools/scripts/ReleaseAutomation/run_release_preparation.py @@ -1,10 +1,11 @@ """Automation for package release process.""" +import sys +import os + PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) sys.path.insert(0, PARENT_DIR) -import sys -import os from ReleaseAutomation.release_config import ReleaseConfig from Utils.git_utils import create_branch_execute_commands_and_push from Utils.verifyReleaseConditions import verifyReleaseConditions diff --git a/Tools/scripts/Utils/git_utils.py b/Tools/scripts/Utils/git_utils.py index 4862c1223b..170810a376 100644 --- a/Tools/scripts/Utils/git_utils.py +++ b/Tools/scripts/Utils/git_utils.py @@ -1,11 +1,12 @@ """Helper class for Git repo operations.""" +import sys +import os + PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../ReleaseAutomation')) sys.path.insert(0, PARENT_DIR) import subprocess -import sys -import os from git import Repo, Actor from github import GithubException from release_config import ReleaseConfig diff --git a/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py b/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py index d9333eef54..3abd2d080c 100644 --- a/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py +++ b/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py @@ -7,11 +7,12 @@ """ #!/usr/bin/env python3 +import os +import sys + PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) sys.path.insert(0, PARENT_DIR) -import os -import sys import requests from ReleaseAutomation.release_config import ReleaseConfig from Utils.git_utils import get_latest_git_revision diff --git a/Tools/scripts/Utils/verifyReleaseConditions.py b/Tools/scripts/Utils/verifyReleaseConditions.py index 925ff05394..61b7a050ef 100644 --- a/Tools/scripts/Utils/verifyReleaseConditions.py +++ b/Tools/scripts/Utils/verifyReleaseConditions.py @@ -11,13 +11,14 @@ """ #!/usr/bin/env python3 +import sys +import os + PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../ReleaseAutomation')) sys.path.insert(0, PARENT_DIR) import datetime import re -import sys -import os from release_config import ReleaseConfig def is_release_date(weekday, release_week_cycle, anchor_date): diff --git a/com.unity.netcode.gameobjects/ValidationExceptions.json b/com.unity.netcode.gameobjects/ValidationExceptions.json new file mode 100644 index 0000000000..c6b271631e --- /dev/null +++ b/com.unity.netcode.gameobjects/ValidationExceptions.json @@ -0,0 +1,4 @@ +{ + "ErrorExceptions": [], + "WarningExceptions": [] +} From fc47a7d237f1a49e49ae89eeac3ccd5e33f5dbed Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 13:08:44 +0200 Subject: [PATCH 19/23] typo corrections --- Tools/scripts/ReleaseAutomation/release_config.py | 2 +- Tools/scripts/Utils/git_utils.py | 8 ++++---- Tools/scripts/release.py | 2 +- ...ValidationExceptions.json => ValidationExceptions.json | 0 4 files changed, 6 insertions(+), 6 deletions(-) rename com.unity.netcode.gameobjects/ValidationExceptions.json => ValidationExceptions.json (100%) diff --git a/Tools/scripts/ReleaseAutomation/release_config.py b/Tools/scripts/ReleaseAutomation/release_config.py index 2596fb893d..d78a1b4baa 100644 --- a/Tools/scripts/ReleaseAutomation/release_config.py +++ b/Tools/scripts/ReleaseAutomation/release_config.py @@ -33,7 +33,7 @@ class ReleaseConfig: def __init__(self): self.manifest_path = 'com.unity.netcode.gameobjects/package.json' self.changelog_path = 'com.unity.netcode.gameobjects/CHANGELOG.md' - self.validation_exceptions_path = 'com.unity.netcode.gameobjects/ValidationExceptions.json' + self.validation_exceptions_path = './ValidationExceptions.json' self.github_repo = 'Unity-Technologies/com.unity.netcode.gameobjects' self.default_repo_branch = 'develop-2.0.0' # Changelog and package version change will be pushed to this branch self.yamato_project_id = '1201' diff --git a/Tools/scripts/Utils/git_utils.py b/Tools/scripts/Utils/git_utils.py index 170810a376..0dd8fd9b3c 100644 --- a/Tools/scripts/Utils/git_utils.py +++ b/Tools/scripts/Utils/git_utils.py @@ -58,11 +58,11 @@ def create_branch_execute_commands_and_push(config: ReleaseConfig): if config.command_to_run_on_release_branch: print(f"\nExecuting command on branch '{config.release_branch_name}': {' '.join(config.command_to_run_on_release_branch.__name__)}") - config.command_to_run_on_release_branch(config.manifest_path, config.changelog_path, config.validation_exceptions_path, config.package_version, config.package_name_regex) + config.command_to_run_on_release_branch(config.manifest_path, config.changelog_path, config.validation_exceptions_path, config.package_version) - repo.git.add('.yamato/') # regenerated jobs - repo.git.add('Packages/') # for example changelog and package.json updates - repo.git.add('Tools/CI/Monorepo.Cookbook/Settings') # Modified WrenchSettings + repo.git.add(config.changelog_path) + repo.git.add(config.manifest_path) + repo.git.add(config.validation_exceptions_path) author = Actor(config.commiter_name, config.commiter_email) committer = Actor(config.commiter_name, config.commiter_email) diff --git a/Tools/scripts/release.py b/Tools/scripts/release.py index 8533f4bff7..f448dc861d 100644 --- a/Tools/scripts/release.py +++ b/Tools/scripts/release.py @@ -41,7 +41,7 @@ def make_package_release_ready(manifest_path, changelog_path, validation_excepti if __name__ == '__main__': manifest_path = 'com.unity.netcode.gameobjects/package.json' changelog_path = 'com.unity.netcode.gameobjects/CHANGELOG.md' - validation_exceptions_path = 'com.unity.netcode.gameobjects/ValidationExceptions.json' + validation_exceptions_path = './ValidationExceptions.json' package_version = get_package_version_from_manifest(manifest_path) make_package_release_ready(manifest_path, changelog_path, validation_exceptions_path, package_version) diff --git a/com.unity.netcode.gameobjects/ValidationExceptions.json b/ValidationExceptions.json similarity index 100% rename from com.unity.netcode.gameobjects/ValidationExceptions.json rename to ValidationExceptions.json From 89fe73dbf9a04dbe41cc4fb8d4f9031d1925f295 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 13:17:51 +0200 Subject: [PATCH 20/23] corrected changelog header pos --- Tools/scripts/Utils/general_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/scripts/Utils/general_utils.py b/Tools/scripts/Utils/general_utils.py index b8bc53cfed..a7c4fc78c9 100644 --- a/Tools/scripts/Utils/general_utils.py +++ b/Tools/scripts/Utils/general_utils.py @@ -146,7 +146,7 @@ def update_changelog(changelog_path, new_version, add_unreleased_template=False) changelog_text = re.sub(r'## \[Unreleased\]', new_changelog_entry, cleaned_content) # Accounting for the very top of the changelog format - header_end_pos = changelog_text.find('---', 1) + header_end_pos = changelog_text.find('(https://docs-multiplayer.unity3d.com).', 1) insertion_point = changelog_text.find('\n', header_end_pos) final_content = "" From 0a3704b800017f28355965c7a3a249d17f010d50 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 13:27:25 +0200 Subject: [PATCH 21/23] Added flags --- Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py b/Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py index 1dae888461..f58d461216 100644 --- a/Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py +++ b/Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py @@ -37,7 +37,7 @@ def commitChangelogAndPackageVersionUpdates(config: ReleaseConfig): sys.exit(1) repo = get_local_repo() - repo.git.fetch() + repo.git.fetch('--prune', '--prune-tags') repo.git.checkout(config.default_repo_branch) repo.git.pull("origin", config.default_repo_branch) From 33431cb2d47c922ad17361fde3631ad340bf5d92 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 15:57:22 +0200 Subject: [PATCH 22/23] small typos --- Tools/scripts/ReleaseAutomation/release_config.py | 2 +- Tools/scripts/Utils/git_utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Tools/scripts/ReleaseAutomation/release_config.py b/Tools/scripts/ReleaseAutomation/release_config.py index d78a1b4baa..20da5bb293 100644 --- a/Tools/scripts/ReleaseAutomation/release_config.py +++ b/Tools/scripts/ReleaseAutomation/release_config.py @@ -173,7 +173,7 @@ def __init__(self): error_messages.append(f"Error: {GITHUB_TOKEN_NAME} environment variable not set.") if not self.yamato_api_token: - error_messages.append("Error: {YAMATO_API_KEY_NAME} environment variable not set.") + error_messages.append(f"Error: {YAMATO_API_KEY_NAME} environment variable not set.") # Initialize PyGithub and get the repository object self.github_manager = GithubUtils(self.github_token, self.github_repo) diff --git a/Tools/scripts/Utils/git_utils.py b/Tools/scripts/Utils/git_utils.py index 0dd8fd9b3c..7ead099710 100644 --- a/Tools/scripts/Utils/git_utils.py +++ b/Tools/scripts/Utils/git_utils.py @@ -57,7 +57,7 @@ def create_branch_execute_commands_and_push(config: ReleaseConfig): new_branch.checkout() if config.command_to_run_on_release_branch: - print(f"\nExecuting command on branch '{config.release_branch_name}': {' '.join(config.command_to_run_on_release_branch.__name__)}") + print(f"\nExecuting command on branch '{config.release_branch_name}': {config.command_to_run_on_release_branch.__name__}") config.command_to_run_on_release_branch(config.manifest_path, config.changelog_path, config.validation_exceptions_path, config.package_version) repo.git.add(config.changelog_path) From 19828e54d5b73dd49f017da26da9d56fc6f45ad0 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 16:48:00 +0200 Subject: [PATCH 23/23] suggestion from comment --- Tools/scripts/Utils/general_utils.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Tools/scripts/Utils/general_utils.py b/Tools/scripts/Utils/general_utils.py index a7c4fc78c9..088611108c 100644 --- a/Tools/scripts/Utils/general_utils.py +++ b/Tools/scripts/Utils/general_utils.py @@ -92,11 +92,13 @@ def update_validation_exceptions(validation_file, package_version): for exceptionElements in ["WarningExceptions", "ErrorExceptions"]: exceptions = data.get(exceptionElements) - if exceptions is not None: - for exception in exceptions: - if 'PackageVersion' in exception: - exception['PackageVersion'] = package_version - updated = True + if exceptions is None: + continue + + for exception in exceptions: + if 'PackageVersion' in exception: + exception['PackageVersion'] = package_version + updated = True # If no exceptions were updated, we do not need to write the file if not updated: