Skip to content
Open
Show file tree
Hide file tree
Changes from 11 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
9 changes: 5 additions & 4 deletions .github/actions/ci-status-comment/action.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
name: Upsert CI Status Comment
description: >-
Create or update the single unified CI status comment on a PR, replacing only
the given section (build | lint | tests). Seeds a skeleton with all three
sections the first time it runs, and re-reads/retries so concurrent writers
(build vs. Unity Test) never clobber each other's section.
the given section (build | lint | tests | performance | automation). Seeds a
skeleton with every section the first time it runs, appends a missing section
fence to older comments, and re-reads/retries so concurrent writers (build
vs. Unity Test) never clobber each other's section.

inputs:
pr-number:
description: Pull request number to comment on.
required: true
section:
description: Which section to replace — one of build, lint, tests.
description: Which section to replace — one of build, lint, tests, performance, automation.
required: true
body:
description: Markdown for this section (inline badge + message). Rendered as-is between the section markers.
Expand Down
103 changes: 88 additions & 15 deletions .github/actions/ci-status-comment/upsert-ci-status.sh
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
#!/usr/bin/env bash
# Create or update the single unified CI status comment on a PR, replacing only
# one section (build | lint | tests). All three CI comment workflows call this
# through the ci-status-comment composite action, so the three separate bot
# comments collapse into one.
# one section (build | lint | tests | performance | automation). All CI comment
# workflows call this through the ci-status-comment composite action, so the
# separate bot comments collapse into one.
#
# The comment is keyed by the hidden <!-- ci-status --> marker and holds three
# sections, each fenced by its own start/end markers:
# The comment is keyed by the hidden <!-- ci-status --> marker and holds one
# fenced block per section:
#
# <!-- ci-status -->
# ### 🚦 CI Status
# <!-- ci:build:start --> …build… <!-- ci:build:end -->
# <!-- ci:lint:start --> …lint… <!-- ci:lint:end -->
# <!-- ci:tests:start --> …tests… <!-- ci:tests:end -->
# <!-- ci:build:start --> …build… <!-- ci:build:end -->
# <!-- ci:lint:start --> …lint… <!-- ci:lint:end -->
# <!-- ci:tests:start --> …tests… <!-- ci:tests:end -->
# <!-- ci:performance:start --> …performance… <!-- ci:performance:end -->
# <!-- ci:automation:start --> …automation… <!-- ci:automation:end -->
#
# Build and Unity Test run as independent workflows whose comment writers can
# fire at the same time, so a plain read-modify-write would drop a section or
Expand All @@ -20,6 +22,49 @@
# confirm the section landed and no duplicate slipped in — retrying otherwise.
set -euo pipefail

# Optional caller knobs (used by decentraland/performance-testing, which runs
# this script directly against unity-explorer's unified comment):
# SECTION_BODY_FILE — read the body from a file instead of $SECTION_BODY,
# for bodies too large to pass comfortably via env.
# NO_CREATE=1 — never create the unified comment; exit 3 when it does
# not exist so the caller can fall back to a standalone
# comment (a foreign-token creation would not be authored
# by github-actions[bot] and later writers would not
# find it, spawning duplicates).
if [ -n "${SECTION_BODY_FILE:-}" ]; then
SECTION_BODY="$(cat "$SECTION_BODY_FILE")"

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 stated motivation for this knob — "for bodies too large to pass comfortably via env" — is also what makes it worth a guard, because the next ceiling up is a hard one.

The file content correctly rejoins the existing path: it lands in $SECTION_BODY and goes through the same marker-strip at L76-77, so a foreign body can't open or close a section fence. That's the part that mattered most and it's right.

What the knob newly invites is a body that pushes NEW_BODY past GitHub's 65,536-character issue-comment limit. When that happens the PATCH at L153 returns 422, gh api exits non-zero, and set -euo pipefail aborts the script mid-loop — no truncation, no fallback, no ::warning::. The external caller sees a failed step whose log says HTTP 422 and nothing about the shared comment being full. Worse, the ceiling is on the combined body, so the failure surfaces on whichever writer happens to run when the total crosses over — which may well be the build or tests section, not the perf section that grew.

A cap on the section body keeps the failure local and legible, and truncation is the right behaviour for a status section that already links out to the full report:

if [ -n "${SECTION_BODY_FILE:-}" ]; then
  SECTION_BODY="$(cat "$SECTION_BODY_FILE")"
  # GitHub caps an issue comment at 65536 chars across every section; keep one
  # writer from consuming the whole budget and failing an unrelated section's PATCH.
  if [ "${#SECTION_BODY}" -gt 20000 ]; then
    echo "::warning::Section body is ${#SECTION_BODY} chars; truncating to 20000."
    SECTION_BODY="${SECTION_BODY:0:20000}"$'\n\n_…truncated; see the linked run for the full report._'
  fi
fi

Separately, while this is now a documented cross-repo entry point: $SECTION is still unvalidated. It only ever reaches START/END, so it isn't an injection concern — but a typo'd or drifting value (perf instead of performance, or anything carrying a run id) takes the L141 append branch, permanently adds a dead fence to that PR's comment, then fails the survive check for 5 attempts × a full paginated re-read. Two lines make the new contract fail fast instead of wedging the shared comment:

case "$SECTION" in
  build|lint|tests|performance|automation) ;;
  *) echo "::error::Unknown section '$SECTION'."; exit 2 ;;
esac

fi

# GitHub caps an issue comment at 65536 chars across every section; keep one
# writer — whichever path its body arrived by — from consuming the whole budget
# and failing an unrelated section's PATCH with an opaque 422. Truncation is
# fine for a status section that already links out to the full report.
if [ "${#SECTION_BODY}" -gt 20000 ]; then
echo "::warning::Section body is ${#SECTION_BODY} chars; truncating to 20000."
SECTION_BODY="${SECTION_BODY:0:20000}"
# Close constructs the cut may have severed — an unterminated code fence or
# <details> makes GitHub render everything after it in this comment inside
# the open block, visually eating the neighbouring sections.
if [ $(( $(grep -c '^```' <<< "$SECTION_BODY") % 2 )) -ne 0 ]; then
SECTION_BODY="$SECTION_BODY"$'\n''```'
fi
opens=$(grep -oi '<details' <<< "$SECTION_BODY" | wc -l || true)
closes=$(grep -oi '</details' <<< "$SECTION_BODY" | wc -l || true)
while [ "${opens:-0}" -gt "${closes:-0}" ]; do
SECTION_BODY="$SECTION_BODY"$'\n</details>'
closes=$((closes + 1))
done
SECTION_BODY="$SECTION_BODY"$'\n\n'"_…truncated; see the linked run for the full report._"
fi

# Fail fast on a section name outside the fence set — an unknown name would
# append a dead fence to the shared comment and then wedge the survive check
# for 5 attempts, burning ~15 API calls per write from then on.
case "${SECTION:-}" in
build|lint|tests|performance|automation) ;;
*) echo "::error::Unknown section '${SECTION:-}'."; exit 2 ;;
esac

MARKER="<!-- ci-status -->"
HEADER="### 🚦 CI Status"
BOT="github-actions[bot]"
Expand All @@ -33,6 +78,8 @@ section_default() {
build) printf '![Build](https://img.shields.io/badge/Build-Waiting-lightgrey?logo=unity&logoColor=white&style=for-the-badge)\n\n_Waiting for the build to start…_' ;;
lint) printf '![Lint](https://img.shields.io/badge/Lint-Waiting-lightgrey?logo=jetbrains&logoColor=white&style=for-the-badge)\n\n_Waiting for lint to start…_' ;;
tests) printf '![Tests](https://img.shields.io/badge/Tests-Waiting-lightgrey?logo=codecov&logoColor=white&style=for-the-badge)\n\n_Waiting for tests to start…_' ;;
automation) printf '![Automation](https://img.shields.io/badge/Automation-On%%20demand-lightgrey?logo=github&logoColor=white&style=for-the-badge)\n\n_On demand — comment `/visual-tests` on this PR to run the visual regression suite against its build._' ;;
performance) printf '![Performance](https://img.shields.io/badge/Performance-Waiting-lightgrey?logo=speedtest&logoColor=white&style=for-the-badge)\n\n_Bare-metal benchmarks run automatically after each successful build; results arrive as a separate comment. Add the `perf_test` label to run the in-repo Unity performance suite instead (skips normal CI and blocks merge while set)._' ;;
esac
}

Expand All @@ -41,11 +88,13 @@ wrap_section() { printf '<!-- ci:%s:start -->\n%s\n<!-- ci:%s:end -->' "$1" "$2"

# A fresh comment with every section defaulted to "waiting".
skeleton() {
printf '%s\n%s\n\n%s\n\n%s\n\n%s\n' \
printf '%s\n%s\n\n%s\n\n%s\n\n%s\n\n%s\n\n%s\n' \
"$MARKER" "$HEADER" \
"$(wrap_section build "$(section_default build)")" \
"$(wrap_section lint "$(section_default lint)")" \
"$(wrap_section tests "$(section_default tests)")"
"$(wrap_section tests "$(section_default tests)")" \
"$(wrap_section performance "$(section_default performance)")" \
"$(wrap_section automation "$(section_default automation)")"
}

# Emit the section body for this run to a file so awk can splice it verbatim,
Expand Down Expand Up @@ -94,8 +143,24 @@ for attempt in 1 2 3 4 5; do
while IFS= read -r line; do [ -n "$line" ] && IDS+=("$line"); done <<< "$(marker_ids "$COMMENTS")"
COMMENT_ID="${IDS[0]:-}"

# Collapse accidental duplicates from a create race: keep the oldest, drop the rest.
if [ "${#IDS[@]}" -gt 1 ]; then
if [ -z "$COMMENT_ID" ] && [ -n "${NO_CREATE:-}" ]; then

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 reasoning in the header comment is right, and it's the reasoning that makes me want one more line here.

NO_CREATE exists because a foreign token behaves differently from github-actions[bot] on the create path — the comment it authors wouldn't be found by later writers. That's a careful catch. But the very next block (L121-127) is the destroy path, and NO_CREATE doesn't touch it: an external caller that reaches this point with a COMMENT_ID still runs gh api -X DELETE over every marker-bearing bot comment except the oldest, with || true swallowing the outcome.

I'm not claiming a new capability — a token that can PATCH the comment can generally delete it too, so a hostile caller isn't gated by this either way. The concern is blast radius under the accidental case: garbage-collecting unity-explorer's own bot comments is repo-maintenance work that a benchmark runner in another repo has no business doing, and if it ever misfires (a pagination hiccup, a marker-bearing comment that isn't actually a duplicate) the evidence is deleted and the || true means nothing is logged. The repo's own workflows run on every build and will collapse a real duplicate within minutes regardless, so the external caller skipping it costs nothing:

  # Collapse accidental duplicates from a create race: keep the oldest, drop the
  # rest. Skipped for external callers — comment GC belongs to this repo's own
  # workflows, which run often enough to clean up within minutes.
  if [ "${#IDS[@]}" -gt 1 ] && [ -z "${NO_CREATE:-}" ]; then

Two smaller notes on the block as written:

No retry before giving up. This exits on attempt 1, so an external caller that arrives in the window before the build workflow seeds the comment falls back to a standalone comment even though the unified one appears seconds later. Since the loop is already here, letting it lose one round first would cut that noise — move the check to fire only on the last attempt, or [ "$attempt" -ge 2 ].

exit 3 is a contract with a repo we can't see from here. If the caller invokes this under its own set -e without capturing the status, exit 3 fails their job rather than triggering the fallback the comment describes. Worth making sure the performance-testing side actually branches on $? — the failure mode is a red benchmark job with no comment anywhere, which reads exactly like the silence the last two commits were written to remove.

# Lose one round before falling back: an external caller often lands here
# seconds before the build workflow seeds the comment, and the standalone
# fallback it would post instead is noise that never collapses.
if [ "$attempt" -ge 2 ]; then
echo "No unified CI status comment exists and NO_CREATE is set; leaving creation to the repo's own workflows."
exit 3
fi
echo "No unified CI status comment yet (attempt $attempt); waiting for the repo's own workflows to seed it."
sleep $((attempt * 2))
continue
fi

# Collapse accidental duplicates from a create race: keep the oldest, drop the
# rest. Skipped for external callers — comment GC belongs to this repo's own
# workflows, which run often enough to clean up within minutes, and a misfire
# under a foreign token would delete evidence with nothing logged.
if [ "${#IDS[@]}" -gt 1 ] && [ -z "${NO_CREATE:-}" ]; then
for extra in "${IDS[@]:1}"; do
echo "Deleting duplicate CI status comment $extra."
gh api -X DELETE "/repos/$REPO/issues/comments/$extra" >/dev/null || true
Expand All @@ -108,10 +173,18 @@ for attempt in 1 2 3 4 5; do
CURRENT_BODY=""
fi

# No unified comment yet, or one missing our section markers: start clean so
# all three sections are always present.
if [ -z "$CURRENT_BODY" ] || ! grep -qF "$START" <<< "$CURRENT_BODY"; then
# No unified comment yet: start from the full skeleton. A comment that exists
# but lacks our markers predates this section (e.g. it was written before the
# automation section existed) — append an empty fence for just our section
# instead of resetting the whole comment and wiping the other sections' state.
if [ -z "$CURRENT_BODY" ]; then
CURRENT_BODY="$(skeleton)"
# -x: whole-line, matching replace_section/extract_section's $0==s exactly. A
# substring hit on a marker embedded in a body line (which the strip filter
# deliberately lets through) would skip fence creation here while the awk
# matchers see nothing — leaving the section permanently unwritable.
elif ! grep -qxF "$START" <<< "$CURRENT_BODY"; then
CURRENT_BODY="$CURRENT_BODY"$'\n\n'"$(wrap_section "$SECTION" "$(section_default "$SECTION")")"
fi

NEW_BODY="$(replace_section "$CURRENT_BODY")"
Expand Down
145 changes: 145 additions & 0 deletions .github/actions/ucb-build-links/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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, one per
target, each pairing the GitHub job log, the Unity Cloud build page and
the Unity log artifact; 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 }}
windows-cell:
description: >-
The Windows row's link cell alone ("[GitHub job](…) · [Unity Cloud #N](…) ·
[Unity log](…)"), for callers composing their own rows; empty when unknown.
value: ${{ steps.fetch.outputs.windows-cell }}
mac-cell:
description: Mac twin of windows-cell.
value: ${{ steps.fetch.outputs.mac-cell }}

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.
# Mirrors the producer's '/builds/<id>' requirement (build.py) so the two
# validators agree, and pins the id to digits — a query-string-only path
# under a Unity host (open-redirect bait) no longer passes.
URL_RE='^https://(cloud\.unity\.com|developer\.cloud\.unity3d\.com|dashboard\.unity3d\.com)/[A-Za-z0-9./_%~?=&#-]*/builds/[0-9]+[A-Za-z0-9./_%~?=&#-]*$'
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
}

# Per-target GitHub job pages, from the trusted Actions API (jobs of the
# matrix job "Build (<target>)"), so each row pairs the Unity Cloud build
# page with the GitHub-side job log.
JOBS_JSON=$(gh api "/repos/$REPO_FULL/actions/runs/$RUN_ID/jobs?per_page=100" 2>/dev/null || echo '{"jobs":[]}')

# Suite id + artifact ids feed the per-target Unity log download links
# (artifact downloads hang off the check suite, not the run).
SUITE_ID=$(gh api "/repos/$REPO_FULL/actions/runs/$RUN_ID" --jq '.check_suite_id' 2>/dev/null || echo "")
[[ "$SUITE_ID" =~ ^[0-9]+$ ]] || SUITE_ID=""
ARTIFACTS_JSON=$(gh api "/repos/$REPO_FULL/actions/runs/$RUN_ID/artifacts?per_page=100" 2>/dev/null || echo '{"artifacts":[]}')

WINDOWS_CELL=""
MAC_CELL=""
ROWS=""
for entry in "windows64:Windows" "macos:Mac"; do
target="${entry%%:*}"
label="${entry#*:}"
parse_info "$target"
job_url=$(jq -r --arg n "Build ($target)" '.jobs[]? | select(.name==$n) | .html_url // empty' <<< "$JOBS_JSON" | head -1)
log_id=$(jq -r --arg n "${target}_launcher_unity_log" \
'.artifacts[]? | select(.name==$n and .expired==false) | .id' <<< "$ARTIFACTS_JSON" | head -1)
[[ "$log_id" =~ ^[0-9]+$ ]] || log_id=""

parts=()
[ -n "$job_url" ] && parts+=("[GitHub job](${job_url})")
# A URL without a valid id only occurs on a tampered artifact — drop the link
# rather than render an empty "[#](...)" label.
if [ -n "$REPLY_ID" ] && [ -n "$REPLY_URL" ]; then
parts+=("[Unity Cloud #${REPLY_ID}](${REPLY_URL})")
elif [ -n "$REPLY_ID" ]; then
parts+=("Unity Cloud #${REPLY_ID}")
fi
if [ -n "$log_id" ] && [ -n "$SUITE_ID" ]; then
parts+=("[Unity log](${GITHUB_SERVER_URL:-https://github.com}/${REPO_FULL}/suites/${SUITE_ID}/artifacts/${log_id})")
fi

cell=""
if [ "${#parts[@]}" -gt 0 ]; then
cell=$(printf '%s · ' "${parts[@]}")
cell="${cell% · }"
ROWS+="| ${label} | ${cell} |"$'\n'
fi
case "$target" in
windows64) WINDOWS_CELL="$cell" ;;
macos) MAC_CELL="$cell" ;;
esac
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}"
echo "windows-cell<<${DELIM}"
printf '%s' "$WINDOWS_CELL"
echo "${DELIM}"
echo "mac-cell<<${DELIM}"
printf '%s' "$MAC_CELL"
echo "${DELIM}"
} >> "$GITHUB_OUTPUT"
17 changes: 17 additions & 0 deletions .github/workflows/build-unitycloud.yml
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,10 @@ jobs:
QUEUE_TIMEOUT: 14400
BUILD_TIMEOUT: 10800
TARGET: t_${{ matrix.target }}
# For the live PR status-comment update the moment the Unity-side

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.

This line changes what the build job is, and this workflow has no permissions: block anywhere — top-level or per-job. Until now that was benign inattention; from here it's a load-bearing dependency.

build.py now shells out to upsert-ci-status.sh, which POSTs, PATCHes and DELETEs issue comments. Whether those calls work is decided entirely by the repo/org default-token setting, which isn't visible from this file. If that default is ever flipped to read-only — a common hardening step, and one nobody would connect to a Unity build — the live links stop appearing and the only evidence is a note: live status-comment update failed: line buried in a 3-hour build log, because maybe_update_live_comment swallows everything by design.

In the other direction, the job runs PR-authored code (build.py, upsert-ci-status.sh — both from the head checkout at L562-566) holding whatever the default grants across every scope. That's not an escalation on pull_request (forks get a read-only token and no secrets, so the write path simply no-ops there, and an in-repo branch author already has write), but it's a wider grant than the job uses.

pr-comment-artifact-url.yml already proves the minimal set for this exact script — contents: read + pull-requests: write — and I checked the rest of this job against it: the curl at L800 hits /releases/latest (contents: read), and every actions/upload-artifact uses ACTIONS_RUNTIME_TOKEN, not GITHUB_TOKEN. So a job-level block is sufficient:

  build:
    name: Build
    runs-on: ubuntu-latest
    needs: prebuild
    permissions:
      contents: read
      pull-requests: write

Worth confirming against prebuild and build-gate before adding it top-level — job-level on build alone is the change that carries no risk to the other jobs.

Unrelated and pre-existing, but adjacent enough to mention while you're in this file: L800's curl -sSL -H "Authorization: Bearer $GITHUB_TOKEN" puts the token in argv, the same shape that got fixed for PERFORMANCE_TESTING_PAT in 29b7e7b02. gh api reads it from the environment. Not this PR's doing.

# build id is known; empty PR number (push/dispatch) disables it.
GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ github.event.pull_request.number || '' }}
BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
COMMIT_SHA: ${{ needs.prebuild.outputs.commit_sha }}
BUILD_OPTIONS: ${{ needs.prebuild.outputs.options }}
Expand Down Expand Up @@ -976,6 +980,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
Loading
Loading