diff --git a/.yamato/ngo-publish.yml b/.yamato/ngo-publish.yml new file mode 100644 index 0000000000..e43eeb3ea3 --- /dev/null +++ b/.yamato/ngo-publish.yml @@ -0,0 +1,12 @@ +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 # 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 + - python Tools/scripts/ReleaseAutomation/run_release_preparation.py \ No newline at end of file diff --git a/Tools/scripts/ReleaseAutomation/release_config.py b/Tools/scripts/ReleaseAutomation/release_config.py new file mode 100644 index 0000000000..e9d40387ba --- /dev/null +++ b/Tools/scripts/ReleaseAutomation/release_config.py @@ -0,0 +1,154 @@ +"""Netcode configuration for the release process automation.""" + +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 +from release import make_package_release_ready + +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 = './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' + 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_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", + } + ] + + 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 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 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. + ] + } + ] + + 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(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) + + 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..2c57472023 --- /dev/null +++ b/Tools/scripts/ReleaseAutomation/run_release_preparation.py @@ -0,0 +1,37 @@ +"""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) + +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: + 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("\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/Utils/commitChangelogAndPackageVersionUpdates.py b/Tools/scripts/Utils/commitChangelogAndPackageVersionUpdates.py new file mode 100644 index 0000000000..f58d461216 --- /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('--prune', '--prune-tags') + 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) diff --git a/Tools/scripts/Utils/general_utils.py b/Tools/scripts/Utils/general_utils.py new file mode 100644 index 0000000000..088611108c --- /dev/null +++ b/Tools/scripts/Utils/general_utils.py @@ -0,0 +1,170 @@ +"""Helper class for common operations.""" +#!/usr/bin/env python3 +import json +import os +import re +import datetime + +UNRELEASED_CHANGELOG_SECTION_TEMPLATE = r""" +## [Unreleased] + +### Added + + +### Changed + + +### Deprecated + + +### Removed + + +### Fixed + + +### Security + + +### Obsolete +""" + +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(package_manifest_path): + print("get_manifest_json_version function couldn't find a specified manifest_path") + return None + + 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(package_manifest_path): + """ + 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(package_manifest_path): + raise FileNotFoundError(f"The file {package_manifest_path} does not exist.") + + with open(package_manifest_path, 'r', encoding='UTF-8') as f: + package_manifest = json.load(f) + + 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_package_version = '.'.join(version_parts) + + package_manifest['version'] = new_package_version + + with open(package_manifest_path, 'w', encoding='UTF-8') as f: + json.dump(package_manifest, f, indent=4) + + return new_package_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 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: + 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}`") + + + +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. + 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}]' + + 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('(https://docs-multiplayer.unity3d.com).', 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) diff --git a/Tools/scripts/Utils/git_utils.py b/Tools/scripts/Utils/git_utils.py new file mode 100644 index 0000000000..7ead099710 --- /dev/null +++ b/Tools/scripts/Utils/git_utils.py @@ -0,0 +1,78 @@ +"""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 +from git import Repo, Actor +from github import GithubException +from release_config import ReleaseConfig + +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 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}'.") from e + +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_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: + 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(config.release_branch_name, repo.head.commit) + new_branch.checkout() + + if config.command_to_run_on_release_branch: + 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) + 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.release_branch_name) + + 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) from e + except Exception as 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 new file mode 100644 index 0000000000..3abd2d080c --- /dev/null +++ b/Tools/scripts/Utils/triggerYamatoJobsForReleasePreparation.py @@ -0,0 +1,134 @@ +""" +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 + +PARENT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), '../')) +sys.path.insert(0, PARENT_DIR) + +import requests +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, timeout=10) + + 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, timeout=10) + + 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("\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 new file mode 100644 index 0000000000..61b7a050ef --- /dev/null +++ b/Tools/scripts/Utils/verifyReleaseConditions.py @@ -0,0 +1,96 @@ +""" +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 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 +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.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.") + + 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("\n--- ERROR: Release Verification failed ---", file=sys.stderr) + print(f"Reason: {e}", file=sys.stderr) + sys.exit(1) diff --git a/Tools/scripts/release.py b/Tools/scripts/release.py index 7c6d1c2b8e..f448dc861d 100644 --- a/Tools/scripts/release.py +++ b/Tools/scripts/release.py @@ -6,96 +6,42 @@ 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 sys import subprocess import platform -package_name = 'com.unity.netcode.gameobjects' +from Utils.general_utils import get_package_version_from_manifest, update_changelog, update_validation_exceptions # nopep8 -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. - """ +def make_package_release_ready(manifest_path, changelog_path, validation_exceptions_path, package_version): - 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) + if not os.path.exists(manifest_path): + print(f" Path does not exist: {manifest_path}") + sys.exit(1) - with open(changelog_path, 'r', encoding='UTF-8') as f: - changelog_text = f.read() + if not os.path.exists(changelog_path): + print(f" Path does not exist: {changelog_path}") + sys.exit(1) - # 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) + if package_version is None: + print(f"Package version not found at {manifest_path}") + sys.exit(1) - # 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'] + # Update the ValidationExceptions.json file + # with the new package version OR remove it if not a release branch + update_validation_exceptions(validation_exceptions_path, package_version) + # Clean the CHANGELOG and add latest entry + # 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 = f'{package_name}/package.json' - package_version = get_manifest_json_version(manifest_path) + manifest_path = 'com.unity.netcode.gameobjects/package.json' + changelog_path = 'com.unity.netcode.gameobjects/CHANGELOG.md' + validation_exceptions_path = './ValidationExceptions.json' + package_version = get_package_version_from_manifest(manifest_path) - # Update the ValidationExceptions.json file - # with the new package version OR remove it if not a release branch - update_validation_exceptions(package_version) - # Clean the CHANGELOG and add latest entry - update_changelog(package_version) + make_package_release_ready(manifest_path, changelog_path, validation_exceptions_path, package_version) diff --git a/ValidationExceptions.json b/ValidationExceptions.json new file mode 100644 index 0000000000..c6b271631e --- /dev/null +++ b/ValidationExceptions.json @@ -0,0 +1,4 @@ +{ + "ErrorExceptions": [], + "WarningExceptions": [] +}