Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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")"
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
# 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
111 changes: 111 additions & 0 deletions .github/actions/ucb-build-links/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
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, pairing
each target's Unity Cloud build page with its GitHub job log; 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.
# 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" \

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.

_launcher is hardcoded, but install_source is an input with a second legal value.

The producer names the artifact with the resolved input (build-unitycloud.yml:986):

name: unity_build_info_${{ matrix.target }}_${{ needs.prebuild.outputs.install_source }}

and install_source is a choice over launcher | epic (build-unitycloud.yml:95-102), defaulting to launcher. For an epic build the download misses, parse_info emits only a note:, and the Unity Cloud rows vanish with no signal.

Worth noting the sibling check in pr-comment-artifact-url.yml is already source-agnostic (startswith("unity_build_info_")), so build-ran goes true while this action finds nothing — the two halves disagree. Suggest resolving the name by prefix instead of assuming the suffix:

name=$(gh api "/repos/$REPO_FULL/actions/runs/$RUN_ID/artifacts?per_page=100" \
  --jq --arg p "unity_build_info_${target}_" '[.artifacts[].name | select(startswith($p))][0] // empty')

--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":[]}')

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)

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 lookup can never match — the "· [GitHub job]" half of every row is silently dropped.

build-unitycloud.yml:549 names the matrix job with a bare literal:

  build:
    name: Build
    strategy:
      matrix:
        target: ${{ fromJSON(needs.prebuild.outputs.targets) }}

GitHub only appends matrix values to the display name when name: is omitted. With an explicit name: Build, every leg is reported as exactly Build, so select(.name=="Build (windows64)") yields nothing and job_url is always empty.

Contrast test.yml:542, which is why the analogous lookup in pr-comment-test-failures.yml works:

    name: Test (${{ matrix.testMode }})

Two ways out — either interpolate the target into the producer's job name (name: Build (${{ matrix.target }}) in build-unitycloud.yml), or match on the matrix leg instead of the display name here, e.g. select(.name | startswith("Build")) | select(.steps? // [] | ...). The first is cleaner and matches the existing convention. Note this fails silently (empty cell), so it won't show up as a red job.


cell=""
# 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
cell="[Unity Cloud #${REPLY_ID}](${REPLY_URL})"
elif [ -n "$REPLY_ID" ]; then
cell="Unity Cloud #${REPLY_ID}"
fi
if [ -n "$cell" ] && [ -n "$job_url" ]; then
cell="${cell} · [GitHub job](${job_url})"
fi
if [ -n "$cell" ]; then
ROWS+="| ${label} build | ${cell} |"$'\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
Loading
Loading