From 69c9a621ba08ee1a4b006a17c6b2aad530b55ae8 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:14:29 +0200 Subject: [PATCH 01/25] 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 cd41f6c31c980f7baa161e4cc5a8b732736464c9 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:14:44 +0200 Subject: [PATCH 02/25] 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 16a494ca902ce69445bd8b74e0335a607bbe48fb Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:14:54 +0200 Subject: [PATCH 03/25] 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 dad46ec2d741b4609307453a5077b137ee2c3020 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:15:02 +0200 Subject: [PATCH 04/25] 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 2abefae53d5d37dc238a425955fd90b42a4d13f0 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:15:11 +0200 Subject: [PATCH 05/25] 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 21b625faa373558043f352cb4158d006b608c57b Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:15:18 +0200 Subject: [PATCH 06/25] 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 8a2297bf39115b799a4a02c31b975a2ebb9fbbe3 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:15:25 +0200 Subject: [PATCH 07/25] 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 01d989badb95c787cabb4a24700b6eb4fc300454 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:25:18 +0200 Subject: [PATCH 08/25] Specific changes for NGOv1.X --- .yamato/ngo-publish.yml | 2 +- .../triggerYamatoJobsForNetcodeReleaseValidation.py | 8 -------- Tools/scripts/Utils/config.py | 2 +- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.yamato/ngo-publish.yml b/.yamato/ngo-publish.yml index f1513d4a5e..5ac352506e 100644 --- a/.yamato/ngo-publish.yml +++ b/.yamato/ngo-publish.yml @@ -3,7 +3,7 @@ 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 + - branch: develop # 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: diff --git a/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py b/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py index acdc8efa01..ac5b755ab9 100644 --- a/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py +++ b/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py @@ -132,14 +132,6 @@ def trigger_NGO_release_preparation_jobs(): { "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", } ] diff --git a/Tools/scripts/Utils/config.py b/Tools/scripts/Utils/config.py index fde0f1c2dd..2f1929f45f 100644 --- a/Tools/scripts/Utils/config.py +++ b/Tools/scripts/Utils/config.py @@ -8,7 +8,7 @@ def getDefaultRepoBranch(): 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' + return 'develop' def getNetcodeGithubRepo(): """Returns the name of MP Tools repo.""" From 513eec0003f08c5a545a5a0a576e20617426c1a9 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 10:34:30 +0200 Subject: [PATCH 09/25] 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 408781707ea94cbf3ae6095609ce71ecdfac4e27 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 15 Aug 2025 11:06:42 +0200 Subject: [PATCH 10/25] 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 ac5b755ab9..7314af8ee7 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 327169aa9d05ae2f086965a8e26c1c8ef9971e7e Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 13:40:42 +0200 Subject: [PATCH 11/25] 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 3a591339cd43cd1e1b6afb486564d3fbbc33646e Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 13:41:32 +0200 Subject: [PATCH 12/25] 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 48b68741c03237c505af54ee05586121ac3b8384 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 13:42:05 +0200 Subject: [PATCH 13/25] 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 52845bc51cf7a1b3ec0215f39ca7cb373773b0c9 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 13:54:52 +0200 Subject: [PATCH 14/25] Updated config and other files --- .../netcodeReleaseBranchCreation.py | 53 ---- .../ReleaseAutomation/release_config.py | 187 +++++++++++++ .../run_release_preparation.py | 35 +++ ...erYamatoJobsForNetcodeReleaseValidation.py | 248 ------------------ 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(+), 412 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 7314af8ee7..0000000000 --- a/Tools/scripts/ReleaseAutomation/triggerYamatoJobsForNetcodeReleaseValidation.py +++ /dev/null @@ -1,248 +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", - } - ] - - 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 2f1929f45f..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' - -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 2fe7966daba8a03e52b619fbdc12a292538371ef Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 14:01:19 +0200 Subject: [PATCH 15/25] 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 5ac352506e..09cd8cb1c0 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 d8b4879fb5d6497f6c64b473ea74051ba3429e02 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 14:06:34 +0200 Subject: [PATCH 16/25] 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 09cd8cb1c0..e43eeb3ea3 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 bee92ec27fd071c9fb0258bb084e24234590d243 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 14:20:55 +0200 Subject: [PATCH 17/25] 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 8f6bebadaa5003055f5315b1be46291bfe9f30d3 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Thu, 11 Sep 2025 14:52:45 +0200 Subject: [PATCH 18/25] specific corrections to 1.X --- .../ReleaseAutomation/release_config.py | 37 +------------------ 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/Tools/scripts/ReleaseAutomation/release_config.py b/Tools/scripts/ReleaseAutomation/release_config.py index f543c753bc..5adba214c7 100644 --- a/Tools/scripts/ReleaseAutomation/release_config.py +++ b/Tools/scripts/ReleaseAutomation/release_config.py @@ -33,7 +33,7 @@ def __init__(self): 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.default_repo_branch = 'develop' # 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 @@ -56,14 +56,6 @@ def __init__(self): { "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", } ] @@ -86,15 +78,6 @@ def __init__(self): { "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": [ @@ -113,15 +96,6 @@ def __init__(self): { "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": [ @@ -139,15 +113,6 @@ def __init__(self): { "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 - ] } ] From a77ee9a78b730032c52c61669f3b2813467751d5 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 11:29:47 +0200 Subject: [PATCH 19/25] 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 5adba214c7..28aaddcdd4 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 962d2f685cb6378a76548008dd0229ba69decf46 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 12:15:14 +0200 Subject: [PATCH 20/25] 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 4ed4b861741fd52786267fa7a0793c15db887bb6 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 13:08:44 +0200 Subject: [PATCH 21/25] 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 28aaddcdd4..d280fe71d2 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' # 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 1adffeca682a22519d0b9e2b2ff22e7948337fc4 Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 13:17:51 +0200 Subject: [PATCH 22/25] 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 8bbc1b3f15eda6f1273d46206aa20f32d7c555ae Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 13:27:25 +0200 Subject: [PATCH 23/25] 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 cbfe8b19480cf5e0573f6e2c39914afd3286921e Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 15:57:22 +0200 Subject: [PATCH 24/25] 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 d280fe71d2..e9d40387ba 100644 --- a/Tools/scripts/ReleaseAutomation/release_config.py +++ b/Tools/scripts/ReleaseAutomation/release_config.py @@ -138,7 +138,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 e3d335d11efdc9160284c82a37db67598106c60f Mon Sep 17 00:00:00 2001 From: michal-chrobot Date: Fri, 12 Sep 2025 16:48:00 +0200 Subject: [PATCH 25/25] 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: