Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
00ea5fd
ci: deep-link the Unity Cloud build page from CI and the PR status co…
eordano Aug 12, 2026
68875b9
ci: address security review findings on Unity Cloud build links
eordano Aug 13, 2026
26cb13e
ci: turn the CI status comment into a link hub (jobs, reports, timing…
eordano Aug 13, 2026
8ee55d7
ci: add a performance section to the CI status comment
eordano Aug 13, 2026
3b856eb
ci: surface a failed performance-test dispatch in the PR status comment
eordano Aug 13, 2026
94e21fc
ci: warn about PERFORMANCE_TESTING_PAT expiry inside the CI status co…
eordano Aug 13, 2026
34aad87
ci: let external callers write CI status sections (file body, no-create)
eordano Aug 13, 2026
29b7e7b
ci: address review findings across the status-comment pipeline
eordano Aug 13, 2026
1c93440
ci: clamp the duration accumulator and truncate section bodies struct…
eordano Aug 13, 2026
414b0f3
ci: collapse the build table to one row per platform and link Unity C…
eordano Aug 13, 2026
b5186df
ci: derive the live row's platform from TARGET's stable prefix
eordano Aug 13, 2026
4eb8b84
ci: drop the live-build intro line and stop #N autolinking to issues
eordano Aug 13, 2026
53dc7e3
ci: link the Unity Cloud build log page before the API deep link arrives
eordano Aug 13, 2026
23e9577
ci: point the constructed dashboard link at the cloud.unity.com build…
eordano Aug 13, 2026
a94f73d
ci: DCL logo header, and durations for builds, lint and tests
eordano Aug 13, 2026
0532ad4
ci: review-round fixes across the status-comment pipeline
eordano Aug 13, 2026
5d2384c
ci: commit tests for the status-comment plumbing, unify durations, la…
eordano Aug 13, 2026
63a0cf4
ci: drop the logo from the CI status comment header
eordano Aug 13, 2026
a9f8e93
test: follow the header back to the emoji spelling
eordano Aug 13, 2026
536e961
ci: close review findings 2-13 across the status-comment pipeline
eordano Aug 14, 2026
447ab7a
ci: give every Unity Cloud Build job a least-privilege permissions block
eordano Aug 14, 2026
8f4123e
ci: close the verified review findings across the status-comment pipe…
eordano Aug 16, 2026
e480a3a
fix: close review must-fix items on the Unity Cloud build-link PR
eordano Aug 17, 2026
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
93 changes: 93 additions & 0 deletions .github/actions/ucb-build-links/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
name: Fetch Unity Cloud Build Links
description: >-
Download the unity_build_info_* artifacts of a Unity Cloud Build run and emit
sanitized markdown linking each build id to its Unity Cloud dashboard page:
bare table rows for appending to an existing two-column table, and a standalone
table section for comment bodies that have no table of their own.

inputs:
run-id:
description: Workflow run id of the Unity Cloud Build run whose artifacts to read.
required: true
github-token:
description: Token used to download the run's artifacts.
required: true

outputs:
rows:
description: >-
"| Name | Link |"-shaped rows for an existing two-column table; empty when
no valid build info was found.
value: ${{ steps.fetch.outputs.rows }}
section:
description: >-
Standalone table (header + rows); empty when no valid build info was found.
value: ${{ steps.fetch.outputs.section }}

runs:
using: composite
steps:
- name: Download and sanitize Unity Cloud build info
id: fetch
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
RUN_ID: ${{ inputs.run-id }}
REPO_FULL: ${{ github.repository }}
run: |
set -euo pipefail

# The info files come out of the PR-controlled build workflow, so treat them as
# untrusted input: accept only a numeric build id and a Unity dashboard URL with
# a conservative charset before letting them anywhere near a comment body.
URL_RE='^https://(cloud\.unity\.com|developer\.cloud\.unity3d\.com|dashboard\.unity3d\.com)/[A-Za-z0-9./_%~?=&#-]*$'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The producer now requires '/builds/' in candidate (build.py:641) but the consumer still accepts any path under the three hosts, and the charset admits ?, =, & and %. On the tampered-artifact path that leaves one narrow primitive: a fork can upload DASHBOARD_URL=https://cloud.unity.com/<something>?next=https%3A%2F%2Fevil.example, which passes validation and renders as [#123](…) in the comment — a maintainer-facing link that reads as first-party. It only goes anywhere if Unity has an open redirect, so this is speculative, not a known bug.

Cheap to close by mirroring the producer's own constraint, which also keeps the two validators in sync:

Suggested change
URL_RE='^https://(cloud\.unity\.com|developer\.cloud\.unity3d\.com|dashboard\.unity3d\.com)/[A-Za-z0-9./_%~?=&#-]*$'
URL_RE='^https://(cloud\.unity\.com|developer\.cloud\.unity3d\.com|dashboard\.unity3d\.com)/[A-Za-z0-9./_%~?=&#-]*/builds/[0-9]+[A-Za-z0-9./_%~?=&#-]*$'

(Worth a quick check against a real dashboard URL first — if the id segment is followed by nothing, the trailing class still matches empty.)

parse_info() {
local target="$1"
local dir="ucb_info_${target}"
REPLY_ID=""
REPLY_URL=""
if gh run download "$RUN_ID" \
--repo "$REPO_FULL" \
--name "unity_build_info_${target}_launcher" \
--dir "$dir" 2>"${dir}.err"; then
REPLY_ID=$(grep -m1 '^BUILD_ID=' "$dir/unity_cloud_build_info.env" | cut -d= -f2- || true)
REPLY_URL=$(grep -m1 '^DASHBOARD_URL=' "$dir/unity_cloud_build_info.env" | cut -d= -f2- || true)
[[ "$REPLY_ID" =~ ^[0-9]+$ ]] || REPLY_ID=""
[[ "$REPLY_URL" =~ $URL_RE ]] || REPLY_URL=""
else
# Absence is normal for runs predating the info artifact; still surface the
# gh error so an auth/permission regression doesn't silently eat the rows.
echo "note: could not fetch unity_build_info_${target}_launcher: $(tr '\n' ' ' < "${dir}.err")"
fi
}

ROWS=""
for entry in "windows64:Windows" "macos:Mac"; do
target="${entry%%:*}"
label="${entry#*:}"
parse_info "$target"
# A URL without a valid id only occurs on a tampered artifact — drop the row
# rather than render an empty "[#](...)" label.
if [ -n "$REPLY_ID" ] && [ -n "$REPLY_URL" ]; then
ROWS+="| Unity Cloud build (${label}) | [#${REPLY_ID}](${REPLY_URL}) |"$'\n'
elif [ -n "$REPLY_ID" ]; then
ROWS+="| Unity Cloud build (${label}) | #${REPLY_ID} |"$'\n'
fi
done

SECTION=""
if [ -n "$ROWS" ]; then
SECTION="| Name | Link |"$'\n'"| -------- | ----------------------- |"$'\n'"$ROWS"
fi

# The payload derives from artifact bytes, so the heredoc delimiter must not be
# guessable content even though the validation above already forbids newlines.
DELIM="UCB_EOF_${RANDOM}${RANDOM}_$$"
{
echo "rows<<${DELIM}"
printf '%s' "$ROWS"
echo "${DELIM}"
echo "section<<${DELIM}"
printf '%s' "$SECTION"
echo "${DELIM}"
} >> "$GITHUB_OUTPUT"
13 changes: 13 additions & 0 deletions .github/workflows/build-unitycloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -976,6 +976,19 @@ jobs:
path: unity_cloud_log.log
if-no-files-found: error

# Written by build.py as soon as the Unity-side build id is known, so it exists for
# failed builds too. The PR status comment uses it to deep-link the Unity Cloud
# build page instead of asking humans to search cloud.unity.com by hand.
- name: Upload Unity Cloud build info
if: ${{ always() && hashFiles('unity_cloud_build_info.env') != '' }}
uses: actions/upload-artifact@v6
with:
name: unity_build_info_${{ matrix.target }}_${{ needs.prebuild.outputs.install_source }}
path: unity_cloud_build_info.env
if-no-files-found: error
# Only consumed by the immediately-following PR status comment run.
retention-days: 7

- name: Print cloud logs
if: ${{ always() && hashFiles('unity_cloud_log.log') != '' }}
run: cat unity_cloud_log.log
Expand Down
63 changes: 49 additions & 14 deletions .github/workflows/pr-comment-artifact-url.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
section: build
github-token: ${{ github.token }}
body: |-
![Build](https://img.shields.io/badge/Build-Pending!-ffff00?logo=github&style=for-the-badge) <img src="https://ui.decentraland.org/decentraland_256x256.png" width="30">
[![Build](https://img.shields.io/badge/Build-Pending!-ffff00?logo=github&style=for-the-badge)](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}) <img src="https://ui.decentraland.org/decentraland_256x256.png" width="30">

New build in progress, come back later!

Expand All @@ -71,6 +71,7 @@ jobs:
runs-on: ubuntu-latest
outputs:
build-ran: ${{ steps.check.outputs.build-ran }}
player-artifacts: ${{ steps.check.outputs.player-artifacts }}
steps:
- name: Check if build jobs actually ran
id: check
Expand All @@ -80,19 +81,32 @@ jobs:
REPO: ${{ github.event.repository.name }}
RUN_ID: ${{ github.event.workflow_run.id }}
run: |
# Check if any build artifacts exist (they only exist when Build jobs ran)
ARTIFACT_COUNT=$(gh api "/repos/$OWNER/$REPO/actions/runs/$RUN_ID/artifacts" \
--jq '[.artifacts[] | select(.name | startswith("Decentraland_"))] | length')
echo "Build artifact count: $ARTIFACT_COUNT"
if [ "$ARTIFACT_COUNT" -gt 0 ]; then
# player-artifacts: Decentraland_* zips exist. comment-success interpolates
# their artifact ids into download URLs, so the success/skipped split must
# keep gating on this and only this.
# build-ran: any evidence a Unity-side build started. unity_build_info_* is
# uploaded as soon as the build id is known, so a build that failed before
# producing player artifacts still posts a failure comment (with the Unity
# Cloud link) instead of leaving the comment stuck on "Pending".
NAMES=$(gh api "/repos/$OWNER/$REPO/actions/runs/$RUN_ID/artifacts" \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit, pre-existing but this PR adds two more artifacts per run: gh api without --paginate returns only the first page (30 items) of /artifacts. A full two-target run currently uploads ~10–12 artifacts, so there's headroom — but build-ran silently going false because unity_build_info_* fell off page 1 would reproduce exactly the stuck-on-Pending bug this PR is fixing, and it'd be a puzzling one to diagnose.

Suggested change
NAMES=$(gh api "/repos/$OWNER/$REPO/actions/runs/$RUN_ID/artifacts" \
NAMES=$(gh api --paginate "/repos/$OWNER/$REPO/actions/runs/$RUN_ID/artifacts?per_page=100" \

Note --paginate emits one JSON object per page, so the --jq '[.artifacts[].name]' would need --slurp or a flattening filter — ?per_page=100 alone is the smaller change if you'd rather not touch the jq.

--jq '[.artifacts[].name]')
PLAYER_COUNT=$(jq 'map(select(startswith("Decentraland_"))) | length' <<< "$NAMES")
INFO_COUNT=$(jq 'map(select(startswith("unity_build_info_"))) | length' <<< "$NAMES")
echo "Player artifact count: $PLAYER_COUNT; build info artifact count: $INFO_COUNT"
if [ "$PLAYER_COUNT" -gt 0 ]; then
echo "player-artifacts=true" >> "$GITHUB_OUTPUT"
else
echo "player-artifacts=false" >> "$GITHUB_OUTPUT"
fi
if [ "$PLAYER_COUNT" -gt 0 ] || [ "$INFO_COUNT" -gt 0 ]; then
echo "build-ran=true" >> "$GITHUB_OUTPUT"
else
echo "build-ran=false" >> "$GITHUB_OUTPUT"
fi

comment-skipped:
needs: [pre-validation, check-build-ran]
if: github.event.workflow_run.conclusion == 'success' && needs.pre-validation.outputs.pr-number != '' && needs.check-build-ran.outputs.build-ran == 'false'
if: github.event.workflow_run.conclusion == 'success' && needs.pre-validation.outputs.pr-number != '' && needs.check-build-ran.outputs.player-artifacts == 'false'
runs-on: ubuntu-latest
steps:
- name: Checkout CI status action
Expand All @@ -109,19 +123,21 @@ jobs:
section: build
github-token: ${{ github.token }}
body: |-
![Build](https://img.shields.io/badge/Build-Skipped-yellow?logo=unity&logoColor=white&style=for-the-badge) <img src="https://ui.decentraland.org/decentraland_256x256.png" width="30">
[![Build](https://img.shields.io/badge/Build-Skipped-yellow?logo=unity&logoColor=white&style=for-the-badge)](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}) <img src="https://ui.decentraland.org/decentraland_256x256.png" width="30">

Build skipped — no changes detected under `Explorer/`.

comment-success:
needs: [pre-validation, check-build-ran]
if: github.event.workflow_run.conclusion == 'success' && needs.pre-validation.outputs.pr-number != '' && needs.check-build-ran.outputs.build-ran == 'true'
if: github.event.workflow_run.conclusion == 'success' && needs.pre-validation.outputs.pr-number != '' && needs.check-build-ran.outputs.player-artifacts == 'true'
runs-on: ubuntu-latest
steps:
- name: Checkout CI status action
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions/ci-status-comment
sparse-checkout: |
.github/actions/ci-status-comment
.github/actions/ucb-build-links
sparse-checkout-cone-mode: false
persist-credentials: false

Expand Down Expand Up @@ -230,14 +246,21 @@ jobs:
echo "SIZE_REPORT=" >> "$GITHUB_ENV"
fi

- name: Fetch Unity Cloud build links
id: ucb
uses: ./.github/actions/ucb-build-links
with:
run-id: ${{ env.PREVIOUS_JOB_ID }}
github-token: ${{ github.token }}

- name: Update build section
uses: ./.github/actions/ci-status-comment
with:
pr-number: ${{ needs.pre-validation.outputs.pr-number }}
section: build
github-token: ${{ github.token }}
body: |-
![Build](https://img.shields.io/badge/Build-Success!-3fb950?logo=unity&logoColor=white&style=for-the-badge) <img src="https://ui.decentraland.org/decentraland_256x256.png" width="30">
[![Build](https://img.shields.io/badge/Build-Success!-3fb950?logo=unity&logoColor=white&style=for-the-badge)](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ env.PREVIOUS_JOB_ID }}) <img src="https://ui.decentraland.org/decentraland_256x256.png" width="30">

Windows and Mac build successful in Unity Cloud! You can find a link to the downloadable artifact below.

Expand All @@ -250,6 +273,7 @@ jobs:
| Download Mac | ${{ github.server_url }}/${{ github.repository }}/suites/${{ env.SUITE_ID }}/artifacts/${{ env.MAC_ARTIFACT_ID }} |
| Download Mac S3 | ${{ format('{0}/{1}/Decentraland_macos.zip', vars.EXPLORER_TEAM_S3_BUCKET_PUBLIC_URL, env.ARTIFACT_S3_DESTINATION_PATH) }} |
| Built on | ${{ env.BUILD_DATE }} |
${{ steps.ucb.outputs.rows }}

${{ env.SIZE_REPORT }}

Expand Down Expand Up @@ -286,18 +310,29 @@ jobs:
- name: Checkout CI status action
uses: actions/checkout@v6
with:
sparse-checkout: .github/actions/ci-status-comment
sparse-checkout: |
.github/actions/ci-status-comment
.github/actions/ucb-build-links
sparse-checkout-cone-mode: false
persist-credentials: false

- name: Fetch Unity Cloud build links
id: ucb
uses: ./.github/actions/ucb-build-links
with:
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ github.token }}

- name: Update build section
uses: ./.github/actions/ci-status-comment
with:
pr-number: ${{ needs.pre-validation.outputs.pr-number }}
section: build
github-token: ${{ github.token }}
body: |-
![Build](https://img.shields.io/badge/Build-Failed!-ff0000?logo=unity&logoColor=white&style=for-the-badge) <img src="https://ui.decentraland.org/decentraland_256x256.png" width="30">
[![Build](https://img.shields.io/badge/Build-Failed!-ff0000?logo=unity&logoColor=white&style=for-the-badge)](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}) <img src="https://ui.decentraland.org/decentraland_256x256.png" width="30">

Build failed! Check the logs to see what went wrong.
Build failed! Check the [logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}) to see what went wrong.
If the error repeats please consider the `clean-build` tag.

${{ steps.ucb.outputs.section }}
58 changes: 57 additions & 1 deletion scripts/cloudbuild/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ def _extract_member(self, member, targetpath, pwd):

build_healthy = True

# Deep link to this build in the Unity Cloud dashboard, captured from the first build
# response that carries one. Persisted to BUILD_LINK_INFO_PATH so the workflow can
# upload it and the PR status comment can link the build directly.
BUILD_LINK_INFO_PATH = 'unity_cloud_build_info.env'
dashboard_url = None
_build_link_info_written = False

parser = argparse.ArgumentParser()
parser.add_argument('--resume', help='Resume tracking a running build stored in build_info.json', action='store_true')
parser.add_argument('--cancel', help='Cancel a running build stored in build_info.json', action='store_true')
Expand Down Expand Up @@ -616,6 +623,45 @@ def try_resume_build():
return None


def record_build_link_info(id, response_json):
"""Persist the Unity Cloud dashboard deep link for this build (best-effort).

Build API responses carry dashboard links; the workflow uploads the written file
as an artifact so the PR status comment can link the build id directly instead
of telling humans to search cloud.unity.com by hand.
"""
global dashboard_url, _build_link_info_written

links = response_json.get('links') or {}
href = None
# dashboard_summary is the build's page and dashboard_log its log tab;
# dashboard_url can be just the dashboard root, so a candidate only
# qualifies when it is an absolute link to this specific build (the
# comment workflow rejects anything else, so don't persist it either).
for key in ('dashboard_summary', 'dashboard_log', 'dashboard_url'):
candidate = (links.get(key) or {}).get('href')
if candidate and candidate.startswith('https://') and '/builds/' in candidate:
href = candidate
break

if _build_link_info_written and not href:
return

try:
with open(BUILD_LINK_INFO_PATH, 'w') as f:
f.write(f'BUILD_TARGET={os.getenv("TARGET")}\n')
f.write(f'BUILD_ID={id}\n')
if href:
f.write(f'DASHBOARD_URL={href}\n')
except OSError as e:
print(f'Warning: could not write {BUILD_LINK_INFO_PATH}: {e}')

if href:
dashboard_url = href
print(f'::notice::Unity Cloud build #{id} ({os.getenv("TARGET")}): {href}')
_build_link_info_written = True


def write_step_summary(target, build_id, final_status, phase_durations, queue_reasons, queue_elapsed, build_elapsed):
"""Append a phase breakdown to $GITHUB_STEP_SUMMARY (best-effort)."""
summary_path = os.environ.get('GITHUB_STEP_SUMMARY')
Expand All @@ -635,6 +681,8 @@ def fmt(seconds):
lines.append('')
lines.append(f'- Target: `{target}`')
lines.append(f'- Build ID: `{build_id}`')
if dashboard_url:
lines.append(f'- Unity Cloud build page: {dashboard_url}')
lines.append(f'- Final outcome: `{final_status}`')
if queue_reasons:
lines.append(f"- Queue reasons seen: {', '.join(f'`{r}`' for r in sorted(queue_reasons))}")
Expand Down Expand Up @@ -711,6 +759,9 @@ def run_poll_loop(id, build_already_active=False, resumed_build_elapsed=0):

keep_polling, status, response_json = poll_build(id)

if dashboard_url is None:
record_build_link_info(id, response_json)

queued_reason = response_json.get('queuedReason')
if queued_reason and status in QUEUE_STATUSES:
queue_reasons.add(queued_reason)
Expand Down Expand Up @@ -852,6 +903,10 @@ def get_clean_build_bool():
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)
# Write the link info file (target + id, no URL yet) immediately so it exists
# even if the runner dies before the first poll; the poll loop upgrades it
# with the dashboard URL once a response carries one.
record_build_link_info(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}"')

final_outcome, phase_durations, queue_reasons, queue_elapsed, build_elapsed = run_poll_loop(
Expand Down Expand Up @@ -920,7 +975,8 @@ def probe_latest_build():
download_log(id)

if not build_healthy:
print(f'Build unhealthy - check the downloaded logs or go to https://cloud.unity.com/ and search for target "{os.getenv('TARGET')}" and build ID "{id}"')
where = dashboard_url or f'https://cloud.unity.com/ (search for target "{os.getenv("TARGET")}" and build ID "{id}")'
print(f'Build unhealthy - check the downloaded logs or the Unity Cloud build page: {where}')
sys.exit(1)

# Cleanup (only if build is healthy and not release)
Expand Down
Loading