Skip to content

Commit 80291c8

Browse files
author
Z4urce
committed
[Init] Commit
1 parent 8c04131 commit 80291c8

37 files changed

+2766
-0
lines changed

.gitattributes

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
static/* linguist-vendored

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/.idea/
2+
/__pycache__/
3+
/logs/
4+
builds.db

README.md

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# Unity Build Server
2+
Flask based Unity build server with a web interface.
3+
Version 2.6
4+
5+
## How it works?
6+
1. The server handles HTTP requests over localhost:80.
7+
2. When you start a build trough its web interface, it reads the `projects.cfg` file and choses the config for the specific project.
8+
3. It tries to open the project's own `BuildSettings.txt` file which may contain additional configurations, arguments or overrides. (Not necessary, used to increase version)
9+
4. Executes the value of `updateProjectScript` (Used to pull the latest changes)
10+
5. The server opens unity using all the configuration entries as arguments.
11+
6. Unity runs the static method defined in `executeMethod` (Used to set internal project parameters)
12+
6. After the build is finished, runs `postBuildScript` if exists in configs (Used to upload addressables)
13+
14+
15+
## Installation
16+
17+
1. Use the package manager [pip](https://pip.pypa.io/en/stable/) to install the dependencies.
18+
19+
```bash
20+
pip install flask
21+
pip install psutil
22+
```
23+
24+
2. Configure the server as it is written bellow
25+
3. Copy the contents of the `unity_plugin` directory into your Unity project assets. You can modify it to your heart's content, as it will be certainly unique for each project.
26+
4. Create all necessary directories for your projects. (You might even want to clone them)
27+
5. Run the `_START_SERVER.ps1` (on Windows) or `build_server.py` trough Python
28+
29+
## Config
30+
### How to add your project
31+
Edit the `projects.cfg` file to add a new build project. Example:
32+
```json
33+
[
34+
{
35+
"project":"SimpleUnityProject",
36+
"engineVersion":"2019.1.2f1",
37+
"executeMethod":"Editor.Builder.BuildCommand.ExecuteBuild",
38+
"postBuildScript":"scripts/upload_addressables.ps1",
39+
"updateProjectScript":"scripts/update_workspace_git.ps1",
40+
"webhookUrl":"Slack/ Discord URL",
41+
"xcodeScript":"scripts/build_xcode.ps1"
42+
}
43+
]
44+
```
45+
Here you can add all the fundamental variables to your project, which will be later inherited by **all** build configurations for the project.
46+
47+
### How to add a build configuration to an existing project
48+
Edit (or create) a file called `{project}.project` (SimpleUnityProject.project) in the config directory. This file can extend and override the content for its corresponding project config (mentioned above) Example:
49+
```
50+
{
51+
#This is the name of this configuration
52+
"name":"SC Dev",
53+
#Unity engine version
54+
"engineVersion":"2018.2.14f1",
55+
#Version of the project, used at naming and passed as argument to Unity
56+
"projectVersion":"1.0.0",
57+
#Unique path to the project to ensure paralell builds
58+
"projectPath":"D:\\wkspaces\\SimpleUnityProject_Dev",
59+
#Static method to execute inside of the project. You may parse build args here
60+
"executeMethod":"Editor.Builder.BuildCommand.ExecuteBuild",
61+
#Build output directory
62+
"buildDirectory":"E:\\OneDrive - PXFD\\Builds\\SimpleUnityProject\\Dev",
63+
#Target platform
64+
"buildTarget":"Android",
65+
#Target environment, passed as argument
66+
"env":"Development",
67+
#Script to call before build, to pull the latest version of the game
68+
"updateProjectScript":"scripts\\update_workspace_git.ps1",
69+
#Script to call after build, to upload generated addressable assets,
70+
"postBuildScript":"scripts\\upload_addressables.ps1",
71+
#A Json of build reports will be sent to this URL. Perfect for Slack messages
72+
"webhookUrl":"Slack/ Discord URL",
73+
#This is a special script, necessary for iOS builds, handling the xcode build phase
74+
"xcodeScript":"scripts/build_xcode.ps1"
75+
}
76+
```
77+
Every entry of the configuration will be **passed as arguments** as well into unity.
78+
79+
It is possible to override most of these by committing a `BuildSettings.txt` file into the project root. The build server will **prioritize its content** over the projects config file. Example of BuildSettings.txt:
80+
```
81+
{
82+
"engineVersion":"2018.2.14f1",
83+
"projectVersion":"1.1.2",
84+
"executeMethod":"Editor.Builder.BuildCommand.ExecuteBuild"
85+
}
86+
```
87+
*After the build server pulls the latest version of this file, it will use the values of the `engineVersion`, `projectVersion` and `executeMethod` to build the project*
88+
89+
## iOS Build
90+
Is working on the `apple_system` branch. Will build your app and upload it straight to TestFlight. This makes it possible to build iOS from any platform.
91+
92+
You'll need the following things to make it work:
93+
- Download your provisioning licence file and name it "ios.mobileprovision" to your project root
94+
- Get it from here: https://developer.apple.com/account/ios/profile/
95+
- Import the xcode manipulation script to your unity project
96+
- Is in this repo, in the `unity_plugins` folder, called `XcodeBuildPostProcessor`
97+
- Unity Team Id: `HFN7ALEN9T` to build settings
98+
- A build configuration with `iOS` target

_START_SERVER.ps1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
python.exe build_server.py
2+
#flask run --host=0.0.0.0 --port=80

build_server.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
from flask import Flask, render_template, send_file, send_from_directory, request
2+
import subprocess
3+
import math
4+
import datetime
5+
import threading
6+
import os
7+
import utils
8+
import db_manager
9+
import cs_manager
10+
import webhook_manager
11+
12+
app = Flask(__name__)
13+
terminal_app = 'pwsh' if os.name is 'posix' else 'powershell'
14+
cs_manager.terminal_app = terminal_app
15+
16+
17+
@app.route('/')
18+
def main():
19+
projects = utils.get_project_names()
20+
sys_info = utils.get_system_info()
21+
return render_template('dashboard.html', projects=projects, sys_info=sys_info)
22+
23+
24+
@app.route('/project/<project_id>')
25+
def project_home(project_id):
26+
configs = utils.read_configs(project_id)
27+
build_data = db_manager.sql_fetch_last_builds_by_project_with_results(project_id, 6)
28+
calculate_progress_per_config(configs)
29+
builds = utils.process_build_data(build_data)
30+
return render_template('index.html', configs=configs, builds=builds)
31+
32+
33+
@app.route('/debug_project_view')
34+
def debug_project_view():
35+
configs = utils.read_configs('SimpleUnityProject')
36+
build_data = db_manager.sql_fetch_last_builds_by_project_with_results('SimpleUnityProject', 6)
37+
calculate_progress_per_config(configs)
38+
builds = utils.process_build_data(build_data)
39+
configs[0]['progress'] = 56
40+
configs[0]['buildInProgress'] = True
41+
configs[0]['progressColor'] = 'yellow'
42+
configs[0]['lastBuildId'] = builds[0][0]
43+
return render_template('index.html', configs=configs, builds=builds)
44+
45+
46+
@app.route('/favicon.ico')
47+
def favicon():
48+
return send_from_directory(os.path.join(app.root_path, 'static'),
49+
'favicon.ico', mimetype='image/vnd.microsoft.icon')
50+
51+
52+
@app.route('/init')
53+
def clear_cache():
54+
utils.clear_cache()
55+
return "Cache cleared"
56+
57+
58+
@app.route('/log/<build_name>')
59+
def log_page(build_name):
60+
log = utils.get_log(build_name)
61+
return render_template('log_layout.html', text=log)
62+
63+
64+
@app.route('/changelog/<project_id_raw>/<build_number>')
65+
def change_log_page(project_id_raw, build_number):
66+
project_id = project_id_raw.replace("_", " ")
67+
log = db_manager.sql_fetch_build_change_log(build_number, project_id)
68+
result = "Changes in build " + project_id + " " + build_number + "\n\n" + log
69+
return render_template('log_layout.html', text=result)
70+
71+
72+
@app.route('/config/<project_id_raw>')
73+
def config_page(project_id_raw):
74+
project_id = project_id_raw.replace("_", " ")
75+
text = utils.get_raw_project_config(project_id)
76+
return render_template('config_editor.html', text=text, project=project_id)
77+
78+
79+
@app.route('/config/<project_id_raw>/save')
80+
def save_config_page(project_id_raw):
81+
project_id = project_id_raw.replace("_", " ")
82+
if utils.try_overwrite_project_config_file(project_id, request.args['configs']):
83+
return "SUCCESS: Configuration file has been updated."
84+
return "FAILED: The specified input was not in valid JSON format"
85+
86+
87+
@app.route('/stop/<project_id_raw>/<config_id_raw>')
88+
def stop_build(project_id_raw, config_id_raw):
89+
project_id = project_id_raw.replace("_", " ")
90+
config_id = config_id_raw.replace("_", " ")
91+
92+
terminate_build_process(project_id, config_id)
93+
return '', 204
94+
95+
96+
@app.route('/build/<project_id_raw>/<config_id_raw>')
97+
def run_build(project_id_raw, config_id_raw):
98+
project_id = project_id_raw.replace("_", " ")
99+
config_id = config_id_raw.replace("_", " ")
100+
utils.clear_cache()
101+
config = utils.get_config(project_id, config_id)
102+
103+
if config is None:
104+
return "Error: No such config as " + config_id + " in " + project_id
105+
106+
if not is_config_build_in_progress(project_id, config_id):
107+
execute_build(config, request.args)
108+
109+
return '', 204
110+
111+
112+
def execute_build(config, override_args):
113+
change_log = cs_manager.get_cached_project_changes(config['projectPath'])
114+
update_project_workspace(config)
115+
utils.clear_cache()
116+
117+
config = utils.get_config(config['project'], config['name'])
118+
utils.override_config(config, override_args)
119+
120+
print(config)
121+
122+
build_number = db_manager.sql_get_next_build_number(config['project'])
123+
build_name = utils.assemble_build_name(config, build_number)
124+
build_path = config['buildDirectory'] + '/' + build_name
125+
log_path = "logs/" + build_name + ".log"
126+
127+
args = utils.get_args_by_config(config)
128+
unity_args = [utils.get_unity_path(config['engineVersion']), '-batchmode', '-logFile', log_path, '-buildPath',
129+
build_path, '-buildNumber', str(build_number), *args]
130+
unity_process = subprocess.Popen(unity_args)
131+
132+
def on_finish():
133+
result = db_manager.sql_fetch_result(config['project'], build_number)
134+
if result is not None and result[3] == utils.BuildResult.CANCELLED:
135+
print("Build " + build_number + " has been cancelled. Aborting execution of post build scripts")
136+
return
137+
138+
db_manager.sql_update_result(
139+
build_number, config['project'], datetime.datetime.now(), utils.BuildResult.POSTPROCESSING)
140+
run_post_build_script(config)
141+
result_code = utils.evaluate_build_result(build_name)
142+
send_build_status_web_message(config, '>Build ' + result_code.name.lower() + ': ' + build_name)
143+
db_manager.sql_update_result(build_number, config['project'], datetime.datetime.now(), result_code)
144+
process_xcode(config, result_code, build_name, build_number)
145+
146+
utils.add_on_finish_listener_to_process(unity_process, on_finish)
147+
db_manager.sql_insert_build(build_number, config['project'], config['name'], build_name, datetime.datetime.now(),
148+
unity_process.pid)
149+
150+
# Change log handling
151+
db_manager.sql_insert_change_log(build_number, config['project'], '\n'.join(change_log))
152+
send_change_log_web_message(config, '>' + build_name + '\n' + '\n'.join(change_log))
153+
154+
cs_manager.query_project_changes(config['projectPath'], forced=True)
155+
send_build_status_web_message(config, '>Build started: ' + build_name)
156+
157+
158+
def run_post_build_script(config):
159+
if 'postBuildScript' in config and config['postBuildScript']:
160+
subprocess.run([terminal_app, config['postBuildScript']])
161+
162+
163+
def send_build_status_web_message(config, text):
164+
print('Web message: ' + text)
165+
try_send_web_message('webhookUrl', config, text)
166+
167+
168+
def send_change_log_web_message(config, log):
169+
print('Sending change log')
170+
try_send_web_message('changelogUrl', config, log)
171+
172+
173+
def try_send_web_message(key, config, text):
174+
if key in config and config[key]:
175+
webhook_manager.send_message(config[key], text)
176+
177+
178+
def process_xcode(config, result_code, build_name, build_number):
179+
if result_code == result_code.SUCCESSFUL and 'xcodeScript' in config and config['buildTarget'] == 'iOS':
180+
send_build_status_web_message(config, '>Build' + build_name + " was sent to xcode for further processing")
181+
db_manager.sql_update_result(build_number, config['project'], datetime.datetime.now(),
182+
utils.BuildResult.POSTPROCESSING)
183+
184+
try:
185+
log_file = open("logs/" + build_name + ".log", "a")
186+
except:
187+
log_file = None
188+
189+
try:
190+
subprocess.run([terminal_app, config['xcodeScript'], config['buildDirectory'] + '/' + build_name],
191+
stdout=log_file, check=True)
192+
except subprocess.CalledProcessError:
193+
send_build_status_web_message(config, '>Build ' + build_name + " xcode post process *FAILED*")
194+
db_manager.sql_update_result(build_number, config['project'], datetime.datetime.now(),
195+
utils.BuildResult.FAILED)
196+
else:
197+
send_build_status_web_message(config, '>Build' + build_name + " xcode post process finished")
198+
db_manager.sql_update_result(build_number, config['project'], datetime.datetime.now(),
199+
utils.BuildResult.SUCCESSFUL)
200+
201+
if log_file is not None:
202+
log_file.close()
203+
204+
205+
def update_project_workspace(config):
206+
subprocess.run([terminal_app, config['updateProjectScript'], config['projectPath']])
207+
208+
209+
def query_project_changes_all():
210+
paths = utils.get_unique_project_paths()
211+
for path in paths:
212+
cs_manager.query_project_changes(path)
213+
214+
215+
def calculate_progress_per_config(configs):
216+
progress_strings = utils.get_progress_strings()
217+
word_value = 100 / len(progress_strings)
218+
for config in configs:
219+
config['progress'] = 0
220+
last_build = db_manager.sql_fetch_last_build_by_config(config['project'], config['name'])
221+
222+
if last_build is None:
223+
continue
224+
log = utils.get_log(last_build[3])
225+
for word in progress_strings:
226+
if word in log:
227+
config['progress'] = 100 - (word_value * progress_strings.index(word))
228+
break
229+
230+
config['lastBuildId'] = last_build[0]
231+
config['progress'] = math.ceil(config['progress'])
232+
config['buildInProgress'] = is_config_build_in_progress(config['project'], config['name'])
233+
config['progressColor'] = utils.get_progress_color(config['progress'], config['buildInProgress'])
234+
config['newChanges'] = cs_manager.get_cached_project_changes(config['projectPath'])
235+
236+
237+
def is_config_build_in_progress(project_id, config_name: str) -> bool:
238+
last_build = db_manager.sql_fetch_last_build_by_config(project_id, config_name)
239+
if last_build is None:
240+
return False
241+
pid = last_build[5]
242+
return utils.is_pid_unity_process(pid)
243+
244+
245+
def terminate_build_process(project_id, config_name: str):
246+
last_build = db_manager.sql_fetch_last_build_by_config(project_id, config_name)
247+
if last_build is None:
248+
return
249+
pid = last_build[5]
250+
if utils.kill_unity_process(pid):
251+
db_manager.sql_update_result(last_build[0], last_build[1], datetime.datetime.now(), utils.BuildResult.CANCELLED)
252+
return True
253+
return False
254+
255+
256+
stopFlag = threading.Event()
257+
thread = utils.PerpetualTimer(stopFlag, query_project_changes_all)
258+
thread.daemon = True
259+
thread.start()
260+
261+
db_manager.sql_create_builds_table()
262+
print("build_server initialized.")
263+
264+
if __name__ == '__main__':
265+
app.run(host="0.0.0.0", port=80)
266+
stopFlag.set()

0 commit comments

Comments
 (0)