Skip to content

Commit 8f4123e

Browse files
eordanoclaude
andauthored
ci: close the verified review findings across the status-comment pipeline
- artifact-url grants actions:read (the composite's cross-run artifact reads 403 without it), gains a comment-cancelled job so a cancelled build can't leave the live In-progress claim up, and marks the link/size/compose steps continue-on-error so the status write always lands - upsert-ci-status normalizes CRLF out of the body and every API read (a web-UI edit resubmits \r\n and defeated the whole-line marker matching), retries failed POST/PATCH inside the loop instead of dying under set -e, and warns when the whole comment nears GitHub's 65k cap; new functional test covers the CRLF round trip - the composite documents the 128KiB env-transport limit and test-failures bounds its only unbounded list at composition - build.py: comment reads distinguish 'absent' from 'unreadable' so a transient 502 can't compose a section that wipes the sibling row (page bound raised 3->30); failed upsert writes no longer count as asserts; record + reconcile both run every poll, so a missing dashboard href or a failed info-file write keeps retrying instead of stranding - visual-regression orders the Running write before the suite so it can never overwrite the final verdict, and probes the Allure URL before rendering it as a link - pr-comment-perf resolves fork-PR numbers via the commit->PRs lookup when workflow_run.pull_requests is empty - the unit tests silence build.py's prints so its ::notice:: line stops annotating the test job's check run Not changed: the dashboard URL's org/project ids in public comments stay by explicit earlier decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 447ab7a commit 8f4123e

9 files changed

Lines changed: 148 additions & 23 deletions

File tree

.github/actions/ci-status-comment/action.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ inputs:
1818
Markdown for this section (inline badge + message). Rendered between the
1919
section markers after dropping marker-shaped lines; bodies over 20000
2020
chars are truncated with fences/<details> re-closed and a truncation note.
21+
Callers must keep it under ~120KB: it travels as one env string, and
22+
Linux rejects any single env entry over 128KiB (E2BIG) before the
23+
truncation here can run.
2124
required: true
2225
github-token:
2326
description: Token with pull-requests:write used to read and upsert the comment.

.github/actions/ci-status-comment/test-upsert-ci-status.sh

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,4 +229,21 @@ grep -q 'RACE-CONVERGED' <<< "$(body_of)" || fail "race: final body missing conv
229229
[ "$(cat "$WORK/stale-reads")" = 0 ] || fail "race: stale read was not consumed"
230230
pass "stale re-read retried until convergence"
231231

232+
# --- 11. CRLF body normalized in place, fences not duplicated -----------------
233+
reset_store "$(python3 - <<'PY'
234+
import json
235+
body = ("<!-- ci-status -->\r\n### 🚦 CI Status\r\n"
236+
"<!-- ci:build:start -->\r\nOLD-BUILD\r\n<!-- ci:build:end -->")
237+
print(json.dumps([{"id": 9, "user": {"login": "github-actions[bot]"}, "body": body}]))
238+
PY
239+
)"
240+
run_upsert build "CRLF-BUILD" >/dev/null
241+
[ "$(count)" = 1 ] || fail "crlf: expected 1 comment"
242+
BODY="$(body_of)"
243+
[ "$(grep -cF '<!-- ci:build:start -->' <<< "$BODY")" = 1 ] || fail "crlf: build fence duplicated"
244+
grep -q 'CRLF-BUILD' <<< "$BODY" || fail "crlf: new content missing"
245+
grep -q 'OLD-BUILD' <<< "$BODY" && fail "crlf: stale content still rendered"
246+
grep -q $'\r' <<< "$BODY" && fail "crlf: body still carries CR"
247+
pass "CRLF body replaced in place, no duplicate fences"
248+
232249
[ "$FAILED" = 0 ] && echo "ALL PASS" || { echo "FAILURES PRESENT"; exit 1; }

.github/actions/ci-status-comment/upsert-ci-status.sh

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ if [ -n "${SECTION_BODY_FILE:-}" ]; then
3636
SECTION_BODY="$(cat "$SECTION_BODY_FILE")"
3737
fi
3838

39+
# Everything below matches markers as whole lines, which CRLF endings defeat —
40+
# and GitHub's web editor resubmits an edited comment with \r\n. Normalize the
41+
# body here and every API read below, so one manual edit cannot make each
42+
# writer append a duplicate fence beneath a stale, still-rendering one.
43+
SECTION_BODY="${SECTION_BODY//$'\r'/}"
44+
3945
# GitHub caps an issue comment at 65536 chars across every section; keep one
4046
# writer — whichever path its body arrived by — from consuming the whole budget
4147
# and failing an unrelated section's PATCH with an opaque 422. Truncation is
@@ -190,6 +196,7 @@ for attempt in 1 2 3 4 5; do
190196

191197
if [ -n "$COMMENT_ID" ]; then
192198
CURRENT_BODY=$(jq -r --arg id "$COMMENT_ID" '.[] | select(.id==($id|tonumber)) | .body' <<< "$(flatten_pages "$COMMENTS")")
199+
CURRENT_BODY="${CURRENT_BODY//$'\r'/}"
193200
else
194201
CURRENT_BODY=""
195202
fi
@@ -215,13 +222,29 @@ for attempt in 1 2 3 4 5; do
215222

216223
NEW_BODY="$(replace_section "$CURRENT_BODY")"
217224

225+
# Near GitHub's 65536-char comment cap the write starts 422ing; the per-
226+
# section CAP cannot see the other sections, so at least say why.
227+
if [ "${#NEW_BODY}" -gt 65000 ]; then
228+
echo "::warning::Unified comment is ${#NEW_BODY} chars — at/over GitHub's 65536 cap; a section needs a tighter cap."
229+
fi
230+
231+
# A failed write must land in the retry loop, not kill the script under
232+
# set -e — that would fail this section's job and strand the section stale.
218233
if [ -z "$COMMENT_ID" ]; then
219-
RESULT=$(jq -n --arg b "$NEW_BODY" '{body:$b}' \
220-
| gh api -X POST "/repos/$REPO/issues/$PR_NUMBER/comments" --input -)
234+
if ! RESULT=$(jq -n --arg b "$NEW_BODY" '{body:$b}' \
235+
| gh api -X POST "/repos/$REPO/issues/$PR_NUMBER/comments" --input -); then
236+
echo "Create failed (attempt $attempt); retrying."
237+
sleep $((attempt * 2))
238+
continue
239+
fi
221240
COMMENT_ID=$(jq -r '.id' <<< "$RESULT")
222241
else
223-
jq -n --arg b "$NEW_BODY" '{body:$b}' \
224-
| gh api -X PATCH "/repos/$REPO/issues/comments/$COMMENT_ID" --input - >/dev/null
242+
if ! jq -n --arg b "$NEW_BODY" '{body:$b}' \
243+
| gh api -X PATCH "/repos/$REPO/issues/comments/$COMMENT_ID" --input - >/dev/null; then
244+
echo "Update failed (attempt $attempt); retrying."
245+
sleep $((attempt * 2))
246+
continue
247+
fi
225248
fi
226249

227250
# Re-read and confirm our section landed on the surviving comment, and that no
@@ -231,6 +254,7 @@ for attempt in 1 2 3 4 5; do
231254
RIDS=()
232255
while IFS= read -r line; do [ -n "$line" ] && RIDS+=("$line"); done <<< "$(marker_ids "$RECHECK")"
233256
LIVE_BODY=$(jq -r --arg id "$COMMENT_ID" '.[] | select(.id==($id|tonumber)) | .body' <<< "$(flatten_pages "$RECHECK")")
257+
LIVE_BODY="${LIVE_BODY//$'\r'/}"
234258

235259
# A write that landed on a younger duplicate is doomed: GC keeps the oldest,
236260
# so this section's content would vanish with the duplicate. Retry on the

.github/workflows/pr-comment-artifact-url.yml

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,11 @@ on:
2121
- "main"
2222
workflow_dispatch:
2323
permissions:
24-
contents: read
24+
contents: read
2525
pull-requests: write
26+
# ucb-build-links reads the build run's artifacts and jobs cross-run — the
27+
# same reason the sibling comment workflows grant it.
28+
actions: read
2629

2730
jobs:
2831
pre-validation:
@@ -132,6 +135,32 @@ jobs:
132135
133136
Build skipped — no changes detected under `Explorer/`.
134137
138+
# A cancelled run matches neither the success nor the failure gate, and the
139+
# build job's live writer may have left an In-progress badge and rows up —
140+
# without this the comment claims a build is running forever.
141+
comment-cancelled:
142+
needs: pre-validation
143+
if: github.event.action == 'completed' && github.event.workflow_run.conclusion == 'cancelled' && needs.pre-validation.outputs.pr-number != ''
144+
runs-on: ubuntu-latest
145+
steps:
146+
- name: Checkout CI status action
147+
uses: actions/checkout@v6
148+
with:
149+
sparse-checkout: .github/actions/ci-status-comment
150+
sparse-checkout-cone-mode: false
151+
persist-credentials: false
152+
153+
- name: Post cancelled build section
154+
uses: ./.github/actions/ci-status-comment
155+
with:
156+
pr-number: ${{ needs.pre-validation.outputs.pr-number }}
157+
section: build
158+
github-token: ${{ github.token }}
159+
body: |-
160+
[![Build](https://img.shields.io/badge/Build-Cancelled-lightgrey?logo=unity&logoColor=white&style=for-the-badge)](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }})
161+
162+
Build cancelled — push a new commit or re-run the workflow to refresh this section.
163+
135164
comment-success:
136165
needs: [pre-validation, check-build-ran]
137166
if: github.event.workflow_run.conclusion == 'success' && needs.pre-validation.outputs.pr-number != '' && needs.check-build-ran.outputs.player-artifacts == 'true'
@@ -218,6 +247,7 @@ jobs:
218247
echo "BUILD_DATE=$BUILD_DATE" >> "$GITHUB_ENV"
219248
220249
- name: Download size reports
250+
continue-on-error: true
221251
env:
222252
GITHUB_TOKEN: ${{ github.token }}
223253
OWNER: ${{ github.repository_owner }}
@@ -253,6 +283,7 @@ jobs:
253283
fi
254284
255285
- name: Fetch Unity Cloud build links
286+
continue-on-error: true
256287
id: ucb
257288
uses: ./.github/actions/ucb-build-links
258289
with:
@@ -265,6 +296,7 @@ jobs:
265296
# body so a link whose id could not be resolved is dropped instead of
266297
# rendering broken.
267298
- name: Compose platform rows
299+
continue-on-error: true
268300
env:
269301
WINDOWS_CELL: ${{ steps.ucb.outputs.windows-cell }}
270302
MAC_CELL: ${{ steps.ucb.outputs.mac-cell }}
@@ -419,6 +451,7 @@ jobs:
419451
persist-credentials: false
420452

421453
- name: Fetch Unity Cloud build links
454+
continue-on-error: true
422455
id: ucb
423456
uses: ./.github/actions/ucb-build-links
424457
with:

.github/workflows/pr-comment-perf.yml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,20 @@ jobs:
3131
id: pr
3232
env:
3333
WORKFLOW_RUN_EVENT_OBJ: ${{ toJSON(github.event.workflow_run) }}
34+
GITHUB_TOKEN: ${{ github.token }}
35+
REPO: ${{ github.repository }}
3436
run: |
3537
PR_NUMBER=$(jq -r '.pull_requests[0].number' <<< "$WORKFLOW_RUN_EVENT_OBJ")
36-
echo "PR number: $PR_NUMBER"
38+
# workflow_run leaves pull_requests empty for fork-origin PRs; the
39+
# commit->PRs lookup still resolves those, so a fork's perf verdict
40+
# is not silently dropped.
41+
if [[ -z "$PR_NUMBER" || "$PR_NUMBER" == "null" ]]; then
42+
HEAD_SHA=$(jq -r '.head_sha // empty' <<< "$WORKFLOW_RUN_EVENT_OBJ")
43+
if [ -n "$HEAD_SHA" ]; then
44+
PR_NUMBER=$(gh api "/repos/$REPO/commits/$HEAD_SHA/pulls" --jq '.[0].number // empty' 2>/dev/null || true)
45+
fi
46+
fi
47+
echo "PR number: ${PR_NUMBER:-<none>}"
3748
if [[ -z "$PR_NUMBER" || "$PR_NUMBER" == "null" ]]; then
3849
echo "No PR associated with this run, skipping."
3950
echo "pr-number=" >> "$GITHUB_OUTPUT"

.github/workflows/pr-comment-test-failures.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,13 @@ jobs:
151151
fi
152152
done
153153
154+
# The only unbounded list in this body. The composite's env transport
155+
# rejects any single env string over 128KiB (E2BIG) before its own
156+
# 20k truncation can run, so bound it at composition.
157+
if [ "${#failed_list}" -gt 60000 ]; then
158+
failed_list="${failed_list:0:60000}"$'\n'"- …list truncated — see the run for the full set."$'\n'
159+
fi
160+
154161
case "$status" in
155162
incomplete) badge="https://img.shields.io/badge/Tests-Incomplete-d29922?logo=codecov&logoColor=white&style=for-the-badge"; headline="$warnings" ;;
156163
failed) badge="https://img.shields.io/badge/Tests-Failed!-ff0000?logo=codecov&logoColor=white&style=for-the-badge"; headline="Some Unity tests failed ❌" ;;

.github/workflows/visual-regression.yml

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,8 +197,12 @@ jobs:
197197
198198
run-suite:
199199
name: Run visual suite
200-
needs: resolve
201-
if: needs.resolve.outputs.authorized == 'true'
200+
# automation-pending in needs: orders the two writers of the automation
201+
# section — the "Running!" write must precede the suite (and so the final
202+
# verdict), or a queue-delayed pending write can overwrite the verdict and
203+
# stick. !cancelled() keeps the suite running when pending skips or fails.
204+
needs: [resolve, automation-pending]
205+
if: ${{ !cancelled() && needs.resolve.outputs.authorized == 'true' }}
202206
# @main pins us to the merged version of the reusable workflow so PRs to
203207
# explorer-automation that touch run-visual-suite.yml don't accidentally
204208
# affect every unity-explorer PR's visual run.
@@ -256,6 +260,15 @@ jobs:
256260
# passes neither, so keep the three in lockstep if that ever changes.
257261
REPORT_URL="${PUBLIC_URL_PREFIX}/@dcl/${REPO//\//-}/visual-regression/test/macos/${PR_NUMBER}/${COMMIT_SHA}/index.html"
258262
263+
# The callee only syncs a report to S3 when the suite produced one —
264+
# probe before rendering the link so a dead run doesn't present a
265+
# 404 as a working report.
266+
if curl -sfIL --max-time 15 "$REPORT_URL" >/dev/null 2>&1; then
267+
REPORT_ROW="| Allure report | [Open report]($REPORT_URL) |"
268+
else
269+
REPORT_ROW="| Allure report | not produced — see the workflow run |"
270+
fi
271+
259272
DELIM="EOF_${RANDOM}${RANDOM}_$$"
260273
{
261274
echo "body<<$DELIM"
@@ -266,7 +279,7 @@ jobs:
266279
echo "| Name | Link |"
267280
echo "| -------- | ----------------------- |"
268281
echo "| Commit | [\`$COMMIT_SHA\`](${GITHUB_SERVER_URL:-https://github.com}/${REPO}/commit/${COMMIT_SHA}) |"
269-
echo "| Allure report | [Open report]($REPORT_URL) |"
282+
echo "$REPORT_ROW"
270283
echo "| Workflow run | [View run]($RUN_URL) |"
271284
echo ""
272285
echo "<sub>Triggered via \`/visual-tests\` · the detailed per-platform comment is posted separately.</sub>"

scripts/cloudbuild/build.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -709,17 +709,20 @@ def _github_api(path):
709709

710710

711711
def _build_section_of_status_comment():
712-
"""Current text between the build fences of the unified CI status comment, or ''.
712+
"""Current text between the build fences of the unified CI status comment.
713713
714714
Oldest marker-bearing bot comment wins, matching upsert-ci-status.sh's
715-
duplicate-collapse rule, so both read the same comment.
715+
duplicate-collapse rule, so both read the same comment. Returns '' when the
716+
comment genuinely does not exist, and None when it could not be determined
717+
(API failure, or the page bound ran out) — writing on None would compose a
718+
section without the sibling platform's row and wipe it.
716719
"""
717720
repo = os.getenv('GITHUB_REPOSITORY')
718721
pr = os.getenv('PR_NUMBER')
719-
for page in (1, 2, 3):
722+
for page in range(1, 31):
720723
resp = _github_api(f'/repos/{repo}/issues/{pr}/comments?per_page=100&page={page}')
721724
if resp.status_code != 200:
722-
return ''
725+
return None
723726
comments = resp.json()
724727
for comment in comments:
725728
body = comment.get('body') or ''
@@ -728,8 +731,8 @@ def _build_section_of_status_comment():
728731
end = body.find('<!-- ci:build:end -->')
729732
return body[start:end] if 0 <= start < end else ''
730733
if len(comments) < 100:
731-
break
732-
return ''
734+
return ''
735+
return None
733736

734737

735738
def _platform_key():
@@ -776,12 +779,15 @@ def upsert_live_comment(build_id, only_if_missing=False):
776779
Each matrix job re-reads the section and carries the other target's live
777780
row along, so concurrent first writes converge on both rows instead of
778781
clobbering each other; write races on the comment itself are the upsert
779-
script's problem. Returns whether a write was attempted.
782+
script's problem. Returns True when a write landed, False when the row was
783+
already present, and None when the read or the write failed.
780784
"""
781785
platform = _platform_key()
782786
label = {'windows64': 'Windows', 'macos': 'Mac'}.get(platform, platform)
783787
marker = f'{LIVE_MARKER_PREFIX}{platform} -->'
784788
section = _build_section_of_status_comment()
789+
if section is None:
790+
return None
785791
if only_if_missing and marker in section:
786792
return False
787793

@@ -819,11 +825,11 @@ def upsert_live_comment(build_id, only_if_missing=False):
819825
SECTION='build',
820826
SECTION_BODY='',
821827
SECTION_BODY_FILE=body_file)
822-
subprocess.run(['bash', CI_STATUS_SCRIPT], env=env, timeout=180, check=False)
828+
result = subprocess.run(['bash', CI_STATUS_SCRIPT], env=env, timeout=180, check=False)
823829
finally:
824830
if body_file:
825831
os.unlink(body_file)
826-
return True
832+
return True if result.returncode == 0 else None
827833

828834

829835
def maybe_update_live_comment(build_id, reconcile=False, force=False):
@@ -850,10 +856,13 @@ def maybe_update_live_comment(build_id, reconcile=False, force=False):
850856
return
851857
try:
852858
_live_comment_last_attempt = time.time()
853-
if upsert_live_comment(build_id, only_if_missing=reconcile):
859+
outcome = upsert_live_comment(build_id, only_if_missing=reconcile)
860+
if outcome is True:
854861
_live_comment_asserts += 1
855862
_live_comment_confirms = 0
856-
elif reconcile:
863+
elif outcome is False and reconcile:
864+
# Only a confirmed present row spends the probe budget; a failed
865+
# read or write (None) must leave both counters for the retry.
857866
_live_comment_confirms += 1
858867
except Exception as e:
859868
print(f'note: live status-comment update failed: {e}')
@@ -956,10 +965,12 @@ def run_poll_loop(id, build_already_active=False, resumed_build_elapsed=0):
956965

957966
keep_polling, status, response_json = poll_build(id)
958967

959-
if dashboard_url is None:
968+
# Both run every poll: record keeps retrying until the info file lands
969+
# AND a dashboard href arrives, and reconcile self-heals the live row
970+
# whether or not an href ever qualifies (it rate-limits internally).
971+
if dashboard_url is None or not _build_link_info_written:
960972
record_build_link_info(id, response_json)
961-
else:
962-
maybe_update_live_comment(id, reconcile=True)
973+
maybe_update_live_comment(id, reconcile=True)
963974

964975
queued_reason = response_json.get('queuedReason')
965976
if queued_reason and status in QUEUE_STATUSES:

scripts/cloudbuild/test_build_helpers.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import sys
1010
import tempfile
1111
import unittest
12+
from unittest import mock
1213

1314
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
1415
import build # noqa: E402
@@ -92,6 +93,11 @@ def setUp(self):
9293
old_cwd = os.getcwd()
9394
self.addCleanup(os.chdir, old_cwd)
9495
os.chdir(tmp.name)
96+
# Silence build.py's prints: its ::notice:: line is a live workflow
97+
# command when the test job itself runs on the Actions runner.
98+
silencer = mock.patch('builtins.print')
99+
silencer.start()
100+
self.addCleanup(silencer.stop)
95101
# PR_NUMBER unset keeps maybe_update_live_comment inert.
96102
self.set_env(TARGET='windows64-x', ORG_ID='org1', PROJECT_ID='proj1', PR_NUMBER=None)
97103
build.dashboard_url = None

0 commit comments

Comments
 (0)