Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,13 @@ Explorer/Assets/StreamingAssets/AssetBundles/**/*.meta -filter=lfs -diff=lfs -me
*.asset binary
*.controller binary
*.asset text diff=yaml merge=yaml

# Root-level compiler rsp files must check out byte-identical to what
# scripts/generate-ignore-warnings.sh writes (LF), or its drift check rewrites
# them on CRLF-converting builders and re-triggers a Bee DAG rebuild.
Explorer/Assets/*.rsp text eol=lf

# Shell scripts must check out LF everywhere: CRLF-converting Windows builders
# fail them with "$'\r': command not found" (seen on every Windows cloud build
# running the UBA preBuildScript).
*.sh text eol=lf
4 changes: 3 additions & 1 deletion .github/workflows/build-release-main-page.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ jobs:
uses: dawidd6/action-download-artifact@v6
with:
run_id: ${{ github.event.workflow_run.id }}
name: Decentraland_.*
# Anchored to the four player zips: Decentraland_.* also matched the multi-GB
# *_debug_symbols artifacts, downloading them only to discard them.
name: Decentraland_(windows64|macos)(_epic)?$
Comment thread
mikhail-dcl marked this conversation as resolved.
name_is_regexp: true
skip_unpack: true

Expand Down
38 changes: 24 additions & 14 deletions .github/workflows/build-unitycloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ jobs:
POLL_TIME: 60
QUEUE_POLL_TIME: 120
STALE_POLL_THRESHOLD: 600
LOG_STALL_THRESHOLD: 900
QUEUE_TIMEOUT: 14400
BUILD_TIMEOUT: 10800
TARGET: t_${{ matrix.target }}
Expand All @@ -599,6 +600,19 @@ jobs:
PARAM_IS_RELEASE_BUILD: ${{ inputs.is_release_build }}
PARAM_UNITY_EXTRA_PARAMS: '-disable-assembly-updater'

# Must run before any always() log/report steps: on cancellation GitHub grants a 5-minute
# grace window, and the DELETE that frees the Unity-side queue slot has to win that race
# (a large-log upload ahead of it can eat the whole window). Also runs on failure() so an
# outer-step timeout doesn't leave a Unity-side build holding a slot.
- name: Cancel Unity Cloud build
if: ${{ cancelled() || failure() }}
timeout-minutes: 2
env:
API_KEY: ${{ secrets.UNITY_CLOUD_API_KEY }}
ORG_ID: ${{ secrets.UNITY_CLOUD_ORG_ID }}
PROJECT_ID: ${{ secrets.UNITY_CLOUD_PROJECT_ID }}
run: python -u scripts/cloudbuild/build.py --cancel || true

- name: Locate Windows main executable
if: matrix.target == 'windows64' && (inputs.is_release_build == true || github.event.inputs.force_sign == 'true')
run: |
Expand Down Expand Up @@ -877,14 +891,19 @@ jobs:
echo "[${ARTIFACT_URL}](${ARTIFACT_URL})"
} >> "$GITHUB_STEP_SUMMARY"

# Symbol artifacts have no downstream consumer (Sentry uploads from the on-disk build/
# folder below; release drafting attaches only the player zips) — keep them only where a
# human might need post-hoc symbolication, and never at default retention.
- name: Upload debug symbols
if: ${{ inputs.is_release_build == true }}
uses: actions/upload-artifact@v6
with:
name: ${{ env.artifact_name }}_debug_symbols
path: |
build/**/*_BackUpThisFolder_ButDontShipItWithYourGame
build/**/*_BurstDebugInformation_DoNotShip
if-no-files-found: error
retention-days: 7

- name: Upload debug symbols to Sentry
if: ${{ needs.prebuild.outputs.sentry_enabled == 'true' }}
Expand All @@ -899,22 +918,22 @@ jobs:
--project "$SENTRY_PROJECT" \
"build"

# Will run always (even if failing)
# Runs even on failure, but only once a log actually exists (a run cancelled in-queue
# never writes one, and a guaranteed-failing upload would burn the cancellation grace window).
- name: Upload cloud logs
if: always()
if: ${{ always() && hashFiles('unity_cloud_log.log') != '' }}
uses: actions/upload-artifact@v6
with:
name: ${{ matrix.target }}_${{ needs.prebuild.outputs.install_source }}_unity_log
path: unity_cloud_log.log
if-no-files-found: error

# Will run always (even if failing)
- name: Print cloud logs
if: always()
if: ${{ always() && hashFiles('unity_cloud_log.log') != '' }}
run: cat unity_cloud_log.log

- name: Extract and display errors
if: always()
if: ${{ always() && hashFiles('unity_cloud_log.log') != '' }}
run: |
echo "=== Extracted Errors for ${{ matrix.target }} ${{ needs.prebuild.outputs.install_source }} ==="
grep -iE "error c|fatal" unity_cloud_log.log | sed 's/^/\x1b[31m/' | sed 's/$/\x1b[0m/' || echo "No 'error c' or 'fatal' errors found in ${{ matrix.target }} log."
Expand All @@ -931,15 +950,6 @@ jobs:
path: shader_compilation_report.log
if-no-files-found: warn

# Also runs on failure() so an outer-step timeout doesn't leave a Unity-side build holding a slot.
- name: Cancel Unity Cloud build
if: ${{ cancelled() || failure() }}
env:
API_KEY: ${{ secrets.UNITY_CLOUD_API_KEY }}
ORG_ID: ${{ secrets.UNITY_CLOUD_ORG_ID }}
PROJECT_ID: ${{ secrets.UNITY_CLOUD_PROJECT_ID }}
run: python -u scripts/cloudbuild/build.py --cancel || true

build-gate:
name: Build Gate (Windows + macOS)
runs-on: ubuntu-latest
Expand Down
14 changes: 14 additions & 0 deletions Explorer/Assets/csc.rsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-nowarn:8618
Comment thread
mikhail-dcl marked this conversation as resolved.
-nowarn:8625
-nowarn:8602
-nowarn:8604
-nowarn:8619
-nowarn:8620
-nowarn:8603
-nowarn:8600
-nowarn:8601
-nowarn:0649
-nowarn:0414
-nowarn:0168
-nowarn:0219
-nowarn:8632
7 changes: 7 additions & 0 deletions Explorer/Assets/csc.rsp.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions Explorer/Assets/gmcs.rsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-nowarn:8618
-nowarn:8625
-nowarn:8602
-nowarn:8604
-nowarn:8619
-nowarn:8620
-nowarn:8603
-nowarn:8600
-nowarn:8601
-nowarn:0649
-nowarn:0414
-nowarn:0168
-nowarn:0219
-nowarn:8632
7 changes: 7 additions & 0 deletions Explorer/Assets/gmcs.rsp.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions Explorer/Assets/mcs.rsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-nowarn:8618
-nowarn:8625
-nowarn:8602
-nowarn:8604
-nowarn:8619
-nowarn:8620
-nowarn:8603
-nowarn:8600
-nowarn:8601
-nowarn:0649
-nowarn:0414
-nowarn:0168
-nowarn:0219
-nowarn:8632
7 changes: 7 additions & 0 deletions Explorer/Assets/mcs.rsp.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions Explorer/Assets/smcs.rsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-nowarn:8618
-nowarn:8625
-nowarn:8602
-nowarn:8604
-nowarn:8619
-nowarn:8620
-nowarn:8603
-nowarn:8600
-nowarn:8601
-nowarn:0649
-nowarn:0414
-nowarn:0168
-nowarn:0219
-nowarn:8632
7 changes: 7 additions & 0 deletions Explorer/Assets/smcs.rsp.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 14 additions & 0 deletions Explorer/Assets/us.rsp
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-nowarn:8618
-nowarn:8625
-nowarn:8602
-nowarn:8604
-nowarn:8619
-nowarn:8620
-nowarn:8603
-nowarn:8600
-nowarn:8601
-nowarn:0649
-nowarn:0414
-nowarn:0168
-nowarn:0219
-nowarn:8632
7 changes: 7 additions & 0 deletions Explorer/Assets/us.rsp.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

90 changes: 86 additions & 4 deletions scripts/cloudbuild/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ def _extract_member(self, member, targetpath, pwd):
POLL_TIME = int(os.getenv('POLL_TIME', '60'))
QUEUE_POLL_TIME = int(os.getenv('QUEUE_POLL_TIME', '120'))
STALE_THRESHOLD = int(os.getenv('STALE_POLL_THRESHOLD', '600'))
# If the build log has not grown for this many seconds while the build is active,
# the build is presumed deadlocked and is cancelled so the retry lands on a fresh
# builder VM. 15 min is generous enough to survive silent IL2CPP / shader phases.
LOG_STALL_THRESHOLD = int(os.getenv('LOG_STALL_THRESHOLD', '900'))

# Queue time and active build time use separate budgets so a long Unity Cloud
# queue does not eat into the actual build window.
Expand Down Expand Up @@ -300,7 +304,7 @@ def cancel_build(id):
except requests.exceptions.RequestException as e:
print(f'Pre-cancel status check failed ({e}); attempting cancel anyway.')

response = requests.delete(f'{URL}/buildtargets/{os.getenv('TARGET')}/builds/{id}', headers=HEADERS)
response = requests.delete(f'{URL}/buildtargets/{os.getenv('TARGET')}/builds/{id}', headers=HEADERS, timeout=30)

if response.status_code == 204:
print('Build canceled successfully')
Expand Down Expand Up @@ -467,6 +471,39 @@ def download_log(id):

print('Build log ready!')

def get_log_byte_count(id):
"""Return the current byte length of the build log, or None on any error.

Uses a HEAD request first (zero-body, cheapest). If the server does not
honour HEAD, falls back to a single-byte Range request and reads the total
from the Content-Range response header. Never downloads the full log.
"""
url = f'{URL}/buildtargets/{os.getenv("TARGET")}/builds/{id}/log'
try:
resp = requests.head(url, headers=HEADERS, timeout=30)
if resp.status_code == 200 and 'Content-Length' in resp.headers:
return int(resp.headers['Content-Length'])
# Range fallback: fetch exactly one byte; read the total from Content-Range.
resp = requests.get(
url,
headers={**HEADERS, 'Range': 'bytes=0-0'},
timeout=30,
stream=True,
)
resp.close()
if resp.status_code in (200, 206):
content_range = resp.headers.get('Content-Range', '')
m = re.search(r'/(\d+)$', content_range)
if m:
return int(m.group(1))
# Server returned 200 without Content-Range → use Content-Length.
if 'Content-Length' in resp.headers:
return int(resp.headers['Content-Length'])
except requests.exceptions.RequestException as e:
print(f'Warning: log size probe failed ({e})')
return None
Comment thread
mikhail-dcl marked this conversation as resolved.


def delete_build(id):
response = requests.delete(f'{URL}/buildtargets/{os.getenv('TARGET')}/builds/{id}/artifacts', headers=HEADERS)

Expand Down Expand Up @@ -618,6 +655,10 @@ def run_poll_loop(id, build_already_active=False, resumed_build_elapsed=0):
attempt can reattach via try_resume_build and keep the queue position.
BUILD_TIMEOUT still cancels — a runaway active build should not keep
holding a Unity Cloud slot.
LOG_STALL_THRESHOLD: if the build log has not grown for this many seconds
while the build is active, the build is cancelled and retried on a fresh
builder VM (exit 99). This catches deadlocked builders that keep reporting
status=started while producing no output.

Returns (final_outcome, phase_durations, queue_reasons, queue_elapsed, build_elapsed).
"""
Expand All @@ -630,6 +671,8 @@ def run_poll_loop(id, build_already_active=False, resumed_build_elapsed=0):
last_status = None
last_status_change = now
last_poll = now
last_log_byte_count = None # most recently observed log size in bytes
last_log_growth = None # wall-clock time of the last log-size increase

while True:
now = time.time()
Expand Down Expand Up @@ -663,8 +706,32 @@ def run_poll_loop(id, build_already_active=False, resumed_build_elapsed=0):

if build_start is None and status in ACTIVE_STATUSES:
build_start = now
last_log_growth = now # start the stall clock from when the build went active
print(f'Build picked up by builder after {datetime.timedelta(seconds=int(now - queue_start))} in queue.')

# Log-stall watchdog: probe log size on every poll tick while the build
# is active. A deadlocked builder keeps status=started but its log stops
# growing. Cancel and retry (exit 99 → fresh builder VM) after the
# configured threshold. If the probe itself fails we skip rather than
# false-positive cancel.
if status in ACTIVE_STATUSES:
log_bytes = get_log_byte_count(id)
if log_bytes is not None:
if last_log_byte_count is None or log_bytes > last_log_byte_count:
last_log_byte_count = log_bytes
last_log_growth = now
elif last_log_growth is not None and (now - last_log_growth) > LOG_STALL_THRESHOLD:
stall_duration = datetime.timedelta(seconds=int(now - last_log_growth))
print(
f'Build log has not grown for {stall_duration} '
f'(threshold {datetime.timedelta(seconds=LOG_STALL_THRESHOLD)}). '
f'Builder appears deadlocked — cancelling and retrying on a fresh VM.'
)
cancel_build(id)
queue_elapsed = (build_start or now) - queue_start
build_elapsed = now - (build_start or now)
return 'log_stall', phase_durations, queue_reasons, queue_elapsed, build_elapsed
Comment thread
mikhail-dcl marked this conversation as resolved.

if status != last_status:
queue_elapsed = (build_start or now) - queue_start
build_elapsed = (now - build_start) if build_start else 0
Expand Down Expand Up @@ -707,6 +774,17 @@ def run_poll_loop(id, build_already_active=False, resumed_build_elapsed=0):
id = build_info["id"]

if args.cancel:
if id is None:
# The runner died between the build POST and the id write; the queued build is
# findable only as the target's latest non-terminal build.
latest = get_latest_build(os.getenv('TARGET'))
if latest and latest.get('buildStatus') not in TERMINAL_STATUSES:
id = latest['build']
print(f'No build id persisted; cancelling latest non-terminal build #{id} on {os.getenv("TARGET")}')
else:
print('No build id persisted and no non-terminal build found; nothing to cancel.')
utils.delete_build_info()
sys.exit(0)
Comment thread
mikhail-dcl marked this conversation as resolved.
cancel_build(id)
utils.delete_build_info()
sys.exit(0)
Expand Down Expand Up @@ -741,6 +819,9 @@ def get_clean_build_bool():
else:
raise ValueError(f"Invalid boolean value for CLEAN_BUILD: {value}")

# Persist the target before the POST: if the runner dies mid-request, --cancel can still
# find the queued build via the target's latest-build lookup.
utils.persist_build_info(os.getenv('TARGET'), None)
id = run_build(os.getenv('BRANCH_NAME'), get_clean_build_bool())
utils.persist_build_info(os.getenv('TARGET'), id)
print(f'For more info and live logs, go to https://cloud.unity.com/ and search for target "{os.getenv('TARGET')}" and build ID "{id}"')
Expand All @@ -752,14 +833,15 @@ def get_clean_build_bool():
)
write_step_summary(os.getenv('TARGET'), id, final_outcome, phase_durations, queue_reasons, queue_elapsed, build_elapsed)

if final_outcome in ('queue_timeout', 'build_timeout'):
if final_outcome == 'build_timeout':
if final_outcome in ('queue_timeout', 'build_timeout', 'log_stall'):
if final_outcome in ('build_timeout', 'log_stall'):
# Build was cancelled; the persisted info points to a dead build.
# Delete it so the next retry creates a fresh build on a different VM.
utils.delete_build_info()
try:
download_log(id)
except Exception as e:
print(f'Warning: could not download log after timeout: {e}')
print(f'Warning: could not download log after {final_outcome}: {e}')
sys.exit(RETRYABLE_EXIT_CODE)

utils.delete_build_info()
Expand Down
Loading
Loading