Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 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
4bae98b
Merge branch 'dev' into feat/unity-cloud-build-link
dalkia Aug 28, 2026
68c4d99
ci: add an on-demand inworld section to the unified CI status comment
dalkia Aug 28, 2026
31f2d3e
Merge branch 'dev' into feat/unity-cloud-build-link
dalkia Aug 31, 2026
81cffe6
Merge branch 'dev' into feat/unity-cloud-build-link
dalkia Aug 31, 2026
d10978b
docs(ci): note why the build job's pull-requests:write is safe
dalkia Sep 1, 2026
8552495
fix(ci): size the per-section comment cap to fit GitHub's 65536 ceiling
dalkia Sep 1, 2026
d4eb6b2
fix(ci): stop create-release-branch clobbering the unified CI status …
dalkia Sep 1, 2026
b738c35
Merge branch 'dev' into feat/unity-cloud-build-link
dalkia Sep 1, 2026
ba21b34
Update .github/actions/ci-status-comment/action.yml
dalkia Sep 2, 2026
af13e50
Merge branch 'dev' into feat/unity-cloud-build-link
dalkia Sep 2, 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
16 changes: 12 additions & 4 deletions .github/actions/ci-status-comment/action.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,27 @@
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
the given section (build | lint | tests | performance | automation |
inworld). Seeds a skeleton with every always-present section the first time
it runs, appends a missing section fence to older comments (and for the
on-demand inworld section), 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, inworld.
required: true
body:
description: Markdown for this section (inline badge + message). Rendered as-is between the section markers.
description: >-
Markdown for this section (inline badge + message). Rendered between the
section markers after dropping marker-shaped lines; bodies over 10000
chars are truncated with fences/<details> re-closed and a truncation note.
Callers must keep it under ~120KB: it travels as one env string, and
Linux rejects any single env entry over 128KiB (E2BIG) before the
truncation here can run.
required: true
github-token:
description: Token with pull-requests:write used to read and upsert the comment.
Expand Down
270 changes: 270 additions & 0 deletions .github/actions/ci-status-comment/test-upsert-ci-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
#!/usr/bin/env bash
# Functional tests for upsert-ci-status.sh against a stubbed gh whose comment
# store is a JSON file — no network, no repo. Run from anywhere:
# bash .github/actions/ci-status-comment/test-upsert-ci-status.sh
set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
UPSERT="$SCRIPT_DIR/upsert-ci-status.sh"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

# --- gh stub ----------------------------------------------------------------
# Supports exactly the calls the script makes; comments live in $STORE as a
# JSON array of {id, user:{login}, body}.
mkdir -p "$WORK/bin"
cat > "$WORK/bin/gh" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
exec python3 "$GH_STUB_PY" "$@"
STUB
chmod +x "$WORK/bin/gh"
cat > "$WORK/gh-stub.py" <<'PY'
import json, os, sys

store = os.environ['STORE']

def load():
with open(store) as f:
return json.load(f)

def save(comments):
with open(store, 'w') as f:
json.dump(comments, f)

args = sys.argv[1:]
if args[0] != 'api':
sys.exit(f'gh stub: unsupported subcommand {args[0]}')
args = args[1:]

method = 'GET'
if '-X' in args:
i = args.index('-X')
method = args[i + 1]
del args[i:i + 2]
read_stdin = '--input' in args
path = next(a for a in args if a.startswith('/'))

comments = load()
if method == 'GET':
# Optional eventual-consistency simulation: while the STALE_READS_FILE
# counter is positive and a pre-PATCH snapshot exists, serve the snapshot
# instead of the live store and decrement the counter.
stale_file = os.environ.get('STALE_READS_FILE')
snapshot = store + '.prev'
if stale_file and os.path.exists(stale_file) and os.path.exists(snapshot):
remaining = int(open(stale_file).read().strip() or 0)
if remaining > 0:
with open(snapshot) as f:
comments = json.load(f)
with open(stale_file, 'w') as f:
f.write(str(remaining - 1))
# --paginate --slurp shape: array of pages.
print(json.dumps([comments]))
elif method == 'POST':
body = json.load(sys.stdin)['body']
new_id = max([c['id'] for c in comments], default=0) + 1
comments.append({'id': new_id, 'user': {'login': 'github-actions[bot]'}, 'body': body})
save(comments)
print(json.dumps({'id': new_id}))
elif method == 'PATCH':
cid = int(path.rsplit('/', 1)[1])
body = json.load(sys.stdin)['body']
if os.environ.get('STALE_READS_FILE'):
# Snapshot the pre-PATCH store so stale GETs can serve it.
with open(store + '.prev', 'w') as f:
json.dump(comments, f)
for c in comments:
if c['id'] == cid:
c['body'] = body
save(comments)
print(json.dumps({'id': cid}))
elif method == 'DELETE':
cid = int(path.rsplit('/', 1)[1])
save([c for c in comments if c['id'] != cid])
PY

export PATH="$WORK/bin:$PATH"
export GH_STUB_PY="$WORK/gh-stub.py"
export STORE="$WORK/comments.json"
export REPO="example/repo" PR_NUMBER="1" GITHUB_TOKEN="stub"

FAILED=0
fail() { echo "FAIL: $1"; FAILED=1; }
pass() { echo "ok: $1"; }

reset_store() { echo "${1:-[]}" > "$STORE"; }

body_of() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[int(sys.argv[2])]["body"])' "$STORE" "${1:-0}"; }
count() { python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1]))))' "$STORE"; }

run_upsert() { (cd "$WORK" && SECTION="$1" SECTION_BODY="$2" bash "$UPSERT"); }

# --- 1. create from skeleton -------------------------------------------------
reset_store
run_upsert build "BUILD-CONTENT" >/dev/null
[ "$(count)" = 1 ] || fail "create: expected 1 comment"
BODY="$(body_of)"
grep -q 'BUILD-CONTENT' <<< "$BODY" || fail "create: build content missing"
grep -q '<!-- ci:performance:start -->' <<< "$BODY" || fail "create: performance fence missing"
grep -q '<!-- ci:inworld:start -->' <<< "$BODY" && fail "create: on-demand inworld fence in skeleton"
grep -q '🚦 CI Status' <<< "$BODY" || fail "create: emoji header missing"
grep -q 'decentraland_256x256' <<< "$BODY" && fail "create: retired logo header present"
pass "create seeds skeleton with all always-present sections"

# --- 2. section update preserves the others ---------------------------------
run_upsert tests "TESTS-CONTENT" >/dev/null
BODY="$(body_of)"
grep -q 'BUILD-CONTENT' <<< "$BODY" || fail "update: build content lost"
grep -q 'TESTS-CONTENT' <<< "$BODY" || fail "update: tests content missing"
pass "section update preserves other sections"

# --- 3. missing fence appended, others intact --------------------------------
reset_store "$(python3 - <<'PY'
import json
body = ('<!-- ci-status -->\n### <picture><img src="https://ui.decentraland.org/decentraland_256x256.png"'
' width="30" alt="Decentraland"></picture> CI Status\n'
"<!-- ci:build:start -->\nOLD-BUILD\n<!-- ci:build:end -->")
print(json.dumps([{"id": 5, "user": {"login": "github-actions[bot]"}, "body": body}]))
PY
)"
run_upsert automation "AUTO-CONTENT" >/dev/null
BODY="$(body_of)"
grep -q 'OLD-BUILD' <<< "$BODY" || fail "append: existing section wiped"
grep -q 'AUTO-CONTENT' <<< "$BODY" || fail "append: new section missing"
grep -q '🚦 CI Status' <<< "$BODY" || fail "append: logo header not migrated"
grep -q 'decentraland_256x256' <<< "$BODY" && fail "append: retired logo header still present"
pass "missing fence appended + header migrated"

# --- 3b. on-demand section: appended to an existing comment, and on create ----
# inworld is not in the skeleton; a write must append its fence to a comment
# seeded without it — and a write that has to create the comment must append
# the fence to the fresh skeleton too, not wedge the survive check.
reset_store
run_upsert build "BUILD-FIRST" >/dev/null
run_upsert inworld "INWORLD-CONTENT" >/dev/null
[ "$(count)" = 1 ] || fail "inworld append: expected 1 comment"
BODY="$(body_of)"
grep -q 'BUILD-FIRST' <<< "$BODY" || fail "inworld append: build content lost"
grep -q 'INWORLD-CONTENT' <<< "$BODY" || fail "inworld append: content missing"
[ "$(grep -cF '<!-- ci:inworld:start -->' <<< "$BODY")" = 1 ] || fail "inworld append: fence count wrong"
reset_store
OUT="$(run_upsert inworld "INWORLD-SEEDS")"
grep -q 'updated (attempt 1)' <<< "$OUT" || fail "inworld create: did not settle on attempt 1"
BODY="$(body_of)"
grep -q 'INWORLD-SEEDS' <<< "$BODY" || fail "inworld create: content missing"
grep -q '<!-- ci:build:start -->' <<< "$BODY" || fail "inworld create: skeleton sections missing"
pass "on-demand inworld section appended on update and on create"

# --- 4. marker-shaped body lines are stripped --------------------------------
reset_store
run_upsert build "$(printf 'SAFE\n<!-- ci:lint:start -->\nALSO-SAFE')" >/dev/null
BODY="$(body_of)"
[ "$(grep -c '<!-- ci:lint:start -->' <<< "$BODY")" = 1 ] || fail "strip: injected fence survived"
grep -q 'ALSO-SAFE' <<< "$BODY" || fail "strip: legitimate line lost"
pass "marker-shaped body lines stripped"

# --- 5. duplicate GC keeps the oldest ----------------------------------------
reset_store "$(python3 - <<'PY'
import json
mk = lambda i: {"id": i, "user": {"login": "github-actions[bot]"},
"body": "<!-- ci-status -->\nhdr\n<!-- ci:build:start -->\nB%d\n<!-- ci:build:end -->" % i}
print(json.dumps([mk(3), mk(9)]))
PY
)"
run_upsert build "DEDUPED" >/dev/null
[ "$(count)" = 1 ] || fail "gc: duplicate not deleted"
grep -q 'DEDUPED' <<< "$(body_of)" || fail "gc: content missing on survivor"
pass "duplicate collapse keeps one comment with the write"

# --- 6. NO_CREATE exits 3 without creating -----------------------------------
reset_store
set +e
(cd "$WORK" && SECTION=performance SECTION_BODY=X NO_CREATE=1 bash "$UPSERT") >/dev/null 2>&1
RC=$?
set -e
[ "$RC" = 3 ] || fail "no-create: expected exit 3, got $RC"
[ "$(count)" = 0 ] || fail "no-create: comment was created"
pass "NO_CREATE exits 3, creates nothing"

# --- 7. unknown section exits 2 ----------------------------------------------
set +e
(cd "$WORK" && SECTION=bogus SECTION_BODY=X bash "$UPSERT") >/dev/null 2>&1
RC=$?
set -e
[ "$RC" = 2 ] || fail "allowlist: expected exit 2, got $RC"
pass "unknown section exits 2"

# --- 8. oversized body truncates and re-closes constructs --------------------
reset_store
BIG="$WORK/big-body.md"
{
echo '<details><summary>big</summary>'
echo '```'
for i in $(seq 1 3000); do echo "line $i of filler to overflow the cap"; done
} > "$BIG"
(cd "$WORK" && SECTION=tests SECTION_BODY= SECTION_BODY_FILE="$BIG" bash "$UPSERT") >/dev/null
BODY="$(body_of)"
grep -q 'truncated' <<< "$BODY" || fail "truncate: no truncation note"
[ "$(( $(grep -c '^```' <<< "$BODY") % 2 ))" = 0 ] || fail "truncate: unbalanced code fence"
SECTION_CONTENT="$(awk '/^<!-- ci:tests:start -->$/{grab=1;next} /^<!-- ci:tests:end -->$/{grab=0} grab' <<< "$BODY")"
[ "${#SECTION_CONTENT}" -le 10000 ] || fail "truncate: section is ${#SECTION_CONTENT} chars, closers re-inflated past the cap"
pass "oversized body truncated with constructs closed"

# --- 9. embedded own-section markers must not scramble the comment ------------
# The wedge shape: the body smuggles in this section's own end marker (which
# would truncate the fence) and the top-level comment marker. The script strips
# such lines, so the write must settle on the first attempt with the structure
# intact — one marker, one end fence — and later writers must still land.
reset_store
OUT="$(run_upsert build "$(printf 'BEFORE\n<!-- ci:build:end -->\n <!-- ci-status -->\nAFTER')")"
grep -q 'updated (attempt 1)' <<< "$OUT" || fail "wedge: write did not settle on attempt 1"
[ "$(count)" = 1 ] || fail "wedge: expected 1 comment"
BODY="$(body_of)"
grep -q 'BEFORE' <<< "$BODY" || fail "wedge: content before marker lost"
grep -q 'AFTER' <<< "$BODY" || fail "wedge: content after marker lost"
[ "$(grep -c '<!-- ci:build:end -->' <<< "$BODY")" = 1 ] || fail "wedge: embedded end marker survived"
[ "$(grep -c '<!-- ci-status -->' <<< "$BODY")" = 1 ] || fail "wedge: embedded comment marker survived"
run_upsert lint "LINT-AFTER-WEDGE" >/dev/null
BODY="$(body_of)"
grep -q 'LINT-AFTER-WEDGE' <<< "$BODY" || fail "wedge: later section write lost"
grep -q 'BEFORE' <<< "$BODY" || fail "wedge: later write wiped earlier section"
pass "embedded section markers stripped, comment structure intact"

# --- 10. stale re-read after PATCH retries and converges ----------------------
# Create-race shape: the PATCH lands, but the confirming re-read returns a
# stale body whose section content differs from WANT. The script must treat
# that as unsettled, retry, and converge once reads are fresh again.
reset_store "$(python3 - <<'PY'
import json
body = ("<!-- ci-status -->\n### 🚦 CI Status\n"
"<!-- ci:build:start -->\nPRE-RACE\n<!-- ci:build:end -->")
print(json.dumps([{"id": 7, "user": {"login": "github-actions[bot]"}, "body": body}]))
PY
)"
echo 1 > "$WORK/stale-reads"
OUT="$( (cd "$WORK" && SECTION=build SECTION_BODY="RACE-CONVERGED" STALE_READS_FILE="$WORK/stale-reads" bash "$UPSERT") )"
grep -q "not settled (attempt 1)" <<< "$OUT" || fail "race: stale re-read did not trigger a retry"
grep -q 'updated (attempt 2)' <<< "$OUT" || fail "race: did not converge on attempt 2"
grep -q 'RACE-CONVERGED' <<< "$(body_of)" || fail "race: final body missing converged content"
[ "$(cat "$WORK/stale-reads")" = 0 ] || fail "race: stale read was not consumed"
pass "stale re-read retried until convergence"

# --- 11. CRLF body normalized in place, fences not duplicated -----------------
reset_store "$(python3 - <<'PY'
import json
body = ("<!-- ci-status -->\r\n### 🚦 CI Status\r\n"
"<!-- ci:build:start -->\r\nOLD-BUILD\r\n<!-- ci:build:end -->")
print(json.dumps([{"id": 9, "user": {"login": "github-actions[bot]"}, "body": body}]))
PY
)"
run_upsert build "CRLF-BUILD" >/dev/null
[ "$(count)" = 1 ] || fail "crlf: expected 1 comment"
BODY="$(body_of)"
[ "$(grep -cF '<!-- ci:build:start -->' <<< "$BODY")" = 1 ] || fail "crlf: build fence duplicated"
grep -q 'CRLF-BUILD' <<< "$BODY" || fail "crlf: new content missing"
grep -q 'OLD-BUILD' <<< "$BODY" && fail "crlf: stale content still rendered"
grep -q $'\r' <<< "$BODY" && fail "crlf: body still carries CR"
pass "CRLF body replaced in place, no duplicate fences"

[ "$FAILED" = 0 ] && echo "ALL PASS" || { echo "FAILURES PRESENT"; exit 1; }
Loading
Loading