Skip to content

Commit 29b7e7b

Browse files
eordanoclaude
andauthored
ci: address review findings across the status-comment pipeline
Fixes for the 10 open review threads: - ucb-build-links: URL_RE now requires /builds/<digits> under the Unity hosts, mirroring build.py's producer check — query-string-only paths (open-redirect bait) no longer validate. - pr-comment-artifact-url: artifacts list fetched with per_page=100 so unity_build_info_* cannot fall off page 1 and fake build-ran=false; the PAT expiry probe moved to gh api HEAD /rate_limit (token via env, never argv), gained continue-on-error + a 15s ceiling + a loud warning when the expiration header is absent; the dispatch-failed marker now fires on outcome != success so upstream failures (Find latest release, section upserts) no longer leave the performance section silent. - pr-comment-test-failures: slowest/failed test names render inside inline code with backticks/pipes stripped — markdown-shaped names read as text instead of first-party links/images. - test.yml: duration values are clamped to finite floats (float() admits nan/inf/1e999), keeping the timings JSON valid for the consumer's jq. - visual-regression: the two comment-writing jobs drop to contents:read + pull-requests:write. Narrowing secrets:inherit needs run-visual-suite.yml to declare workflow_call secrets first (it currently instructs callers to use inherit), so that part stays. - upsert-ci-status: fence-existence check is whole-line (grep -qxF) matching the awk matchers, so an embedded marker in a body line can no longer wedge a section; file-passed bodies are capped at 20k chars with a visible truncation note (GitHub's 65536 ceiling is shared by all sections); SECTION is validated against the fence set and fails fast; duplicate-comment GC is skipped for NO_CREATE callers; NO_CREATE waits one round before falling back to a standalone comment. Committed via API because repository rules require verified signatures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 34aad87 commit 29b7e7b

6 files changed

Lines changed: 84 additions & 15 deletions

File tree

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

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,24 @@ set -euo pipefail
3333
# find it, spawning duplicates).
3434
if [ -n "${SECTION_BODY_FILE:-}" ]; then
3535
SECTION_BODY="$(cat "$SECTION_BODY_FILE")"
36+
# GitHub caps an issue comment at 65536 chars across every section; keep one
37+
# writer from consuming the whole budget and failing an unrelated section's
38+
# PATCH with an opaque 422. Truncation is fine for a status section that
39+
# already links out to the full report.
40+
if [ "${#SECTION_BODY}" -gt 20000 ]; then
41+
echo "::warning::Section body is ${#SECTION_BODY} chars; truncating to 20000."
42+
SECTION_BODY="${SECTION_BODY:0:20000}"$'\n\n'"_…truncated; see the linked run for the full report._"
43+
fi
3644
fi
3745

46+
# Fail fast on a section name outside the fence set — an unknown name would
47+
# append a dead fence to the shared comment and then wedge the survive check
48+
# for 5 attempts, burning ~15 API calls per write from then on.
49+
case "${SECTION:-}" in
50+
build|lint|tests|performance|automation) ;;
51+
*) echo "::error::Unknown section '${SECTION:-}'."; exit 2 ;;
52+
esac
53+
3854
MARKER="<!-- ci-status -->"
3955
HEADER="### 🚦 CI Status"
4056
BOT="github-actions[bot]"
@@ -114,12 +130,23 @@ for attempt in 1 2 3 4 5; do
114130
COMMENT_ID="${IDS[0]:-}"
115131

116132
if [ -z "$COMMENT_ID" ] && [ -n "${NO_CREATE:-}" ]; then
117-
echo "No unified CI status comment exists and NO_CREATE is set; leaving creation to the repo's own workflows."
118-
exit 3
133+
# Lose one round before falling back: an external caller often lands here
134+
# seconds before the build workflow seeds the comment, and the standalone
135+
# fallback it would post instead is noise that never collapses.
136+
if [ "$attempt" -ge 2 ]; then
137+
echo "No unified CI status comment exists and NO_CREATE is set; leaving creation to the repo's own workflows."
138+
exit 3
139+
fi
140+
echo "No unified CI status comment yet (attempt $attempt); waiting for the repo's own workflows to seed it."
141+
sleep $((attempt * 2))
142+
continue
119143
fi
120144

121-
# Collapse accidental duplicates from a create race: keep the oldest, drop the rest.
122-
if [ "${#IDS[@]}" -gt 1 ]; then
145+
# Collapse accidental duplicates from a create race: keep the oldest, drop the
146+
# rest. Skipped for external callers — comment GC belongs to this repo's own
147+
# workflows, which run often enough to clean up within minutes, and a misfire
148+
# under a foreign token would delete evidence with nothing logged.
149+
if [ "${#IDS[@]}" -gt 1 ] && [ -z "${NO_CREATE:-}" ]; then
123150
for extra in "${IDS[@]:1}"; do
124151
echo "Deleting duplicate CI status comment $extra."
125152
gh api -X DELETE "/repos/$REPO/issues/comments/$extra" >/dev/null || true
@@ -138,7 +165,11 @@ for attempt in 1 2 3 4 5; do
138165
# instead of resetting the whole comment and wiping the other sections' state.
139166
if [ -z "$CURRENT_BODY" ]; then
140167
CURRENT_BODY="$(skeleton)"
141-
elif ! grep -qF "$START" <<< "$CURRENT_BODY"; then
168+
# -x: whole-line, matching replace_section/extract_section's $0==s exactly. A
169+
# substring hit on a marker embedded in a body line (which the strip filter
170+
# deliberately lets through) would skip fence creation here while the awk
171+
# matchers see nothing — leaving the section permanently unwritable.
172+
elif ! grep -qxF "$START" <<< "$CURRENT_BODY"; then
142173
CURRENT_BODY="$CURRENT_BODY"$'\n\n'"$(wrap_section "$SECTION" "$(section_default "$SECTION")")"
143174
fi
144175

.github/actions/ucb-build-links/action.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ runs:
4141
# The info files come out of the PR-controlled build workflow, so treat them as
4242
# untrusted input: accept only a numeric build id and a Unity dashboard URL with
4343
# a conservative charset before letting them anywhere near a comment body.
44-
URL_RE='^https://(cloud\.unity\.com|developer\.cloud\.unity3d\.com|dashboard\.unity3d\.com)/[A-Za-z0-9./_%~?=&#-]*$'
44+
# Mirrors the producer's '/builds/<id>' requirement (build.py) so the two
45+
# validators agree, and pins the id to digits — a query-string-only path
46+
# under a Unity host (open-redirect bait) no longer passes.
47+
URL_RE='^https://(cloud\.unity\.com|developer\.cloud\.unity3d\.com|dashboard\.unity3d\.com)/[A-Za-z0-9./_%~?=&#-]*/builds/[0-9]+[A-Za-z0-9./_%~?=&#-]*$'
4548
parse_info() {
4649
local target="$1"
4750
local dir="ucb_info_${target}"

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

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,11 @@ jobs:
8888
# uploaded as soon as the build id is known, so a build that failed before
8989
# producing player artifacts still posts a failure comment (with the Unity
9090
# Cloud link) instead of leaving the comment stuck on "Pending".
91-
NAMES=$(gh api "/repos/$OWNER/$REPO/actions/runs/$RUN_ID/artifacts" \
91+
# per_page=100: the default page holds 30 and a two-target run already
92+
# uploads ~a dozen artifacts — unity_build_info_* falling off page 1
93+
# would read as build-ran=false, the exact stuck-on-Pending bug the
94+
# flag exists to prevent.
95+
NAMES=$(gh api "/repos/$OWNER/$REPO/actions/runs/$RUN_ID/artifacts?per_page=100" \
9296
--jq '[.artifacts[].name]')
9397
PLAYER_COUNT=$(jq 'map(select(startswith("Decentraland_"))) | length' <<< "$NAMES")
9498
INFO_COUNT=$(jq 'map(select(startswith("unity_build_info_"))) | length' <<< "$NAMES")
@@ -308,13 +312,20 @@ jobs:
308312
# expiry canary — the 2026-06..08 outage was exactly this token expiring
309313
# with no warning anywhere a human looks.
310314
- name: Probe performance PAT expiry
315+
# Purely decorative — its whole output is one optional sentence, so it
316+
# must never gate the dispatched/dispatch-failed section writes below.
311317
if: steps.perf_dispatch.outcome == 'success'
318+
continue-on-error: true
312319
env:
313320
PAT: ${{ secrets.PERFORMANCE_TESTING_PAT }}
314321
run: |
315322
set -euo pipefail
316-
exp=$(curl -sI -H "Authorization: Bearer $PAT" https://api.github.com/repos/decentraland/performance-testing \
317-
| tr -d '\r' | grep -i '^github-authentication-token-expiration:' | cut -d' ' -f2- || true)
323+
# The expiry header rides every authenticated call; /rate_limit spends
324+
# no quota and names no repo that could drift. gh reads the token from
325+
# the environment, keeping it out of any process's argv. head -1 keeps
326+
# $GITHUB_ENV single-line even if the response ever repeats the header.
327+
exp=$(GH_TOKEN="$PAT" timeout 15 gh api --include --method HEAD /rate_limit 2>/dev/null \
328+
| tr -d '\r' | grep -i '^github-authentication-token-expiration:' | head -1 | cut -d' ' -f2- || true)
318329
msg=""
319330
if [ -n "$exp" ]; then
320331
exp_s=$(date -d "$exp" +%s 2>/dev/null || echo 0)
@@ -325,6 +336,8 @@ jobs:
325336
msg="⚠️ \`PERFORMANCE_TESTING_PAT\` expires in **$days days** ($exp) — rotate it before benchmark dispatches start failing."
326337
fi
327338
fi
339+
else
340+
echo "::warning::No github-authentication-token-expiration header returned — PAT expiry cannot be monitored."
328341
fi
329342
echo "PAT_EXPIRY_WARNING=$msg" >> "$GITHUB_ENV"
330343
@@ -349,8 +362,12 @@ jobs:
349362
# cause — it broke silently from 2026-06-15 to 2026-08-13) turns this job
350363
# red but leaves no trace on the PR; surface it in the performance section
351364
# so it cannot go unnoticed for weeks again.
365+
# outcome != 'success' rather than == 'failure': anything failing between
366+
# the checkout and the dispatch (Find latest release, the build-section
367+
# upsert) skips perf_dispatch, and a skipped dispatch is the same silence
368+
# on the PR as a failed one.
352369
- name: Mark performance section as dispatch-failed
353-
if: failure() && steps.perf_dispatch.outcome == 'failure'
370+
if: failure() && steps.perf_dispatch.outcome != 'success'
354371
uses: ./.github/actions/ci-status-comment
355372
with:
356373
pr-number: ${{ needs.pre-validation.outputs.pr-number }}
@@ -359,7 +376,7 @@ jobs:
359376
body: |-
360377
[![Performance](https://img.shields.io/badge/Performance-Dispatch%20failed-ff0000?logo=speedtest&logoColor=white&style=for-the-badge)](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
361378
362-
❌ Could not dispatch the bare-metal benchmark — see the [step log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). If it says "Repository not found, OR token has insufficient permissions", the `PERFORMANCE_TESTING_PAT` secret has expired and needs to be rotated.
379+
❌ Could not dispatch the bare-metal benchmark (the job failed before or during the dispatch) — see the [step log](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}). If it says "Repository not found, OR token has insufficient permissions", the `PERFORMANCE_TESTING_PAT` secret has expired and needs to be rotated.
363380
364381
comment-failed:
365382
needs: [pre-validation, check-build-ran]

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,22 @@ jobs:
118118
119119
# Suite duration + slowest tests come from the same untrusted artifact:
120120
# duration must be numeric before it reaches awk, and slowest entries are
121-
# type-checked in jq with names flattened to a single line.
121+
# type-checked in jq with names flattened to a single line. Names render
122+
# inside inline code (backticks/pipes stripped) so a name shaped like
123+
# markdown — a link, an <img>, a </details> — reads as text instead of
124+
# rendering as first-party comment furniture.
122125
d=$(jq -r '.duration // empty' "$file")
123126
if [[ "$d" =~ ^[0-9]+(\.[0-9]+)?$ ]]; then dur=$(fmt_secs "$d"); else dur="—"; fi
124127
slow=$(jq -r --arg mode "$mode" \
125-
'.slowest[]? | select((.seconds|type=="number") and (.name|type=="string")) | "- [\($mode)] \(.seconds)s \(.name | gsub("[\r\n]"; " "))"' \
128+
'.slowest[]? | select((.seconds|type=="number") and (.name|type=="string")) | "- [\($mode)] \(.seconds)s `\(.name | gsub("[\r\n`|]"; " "))`"' \
126129
"$file" 2>/dev/null || true)
127130
[ -n "$slow" ] && slowest_list="$slowest_list$slow"$'\n'
128131
129132
if [ "$f" -gt 0 ]; then
130133
if [ "$status" = "passed" ]; then status=failed; fi
131134
total_failed=$((total_failed + f))
132135
rows="$rows| $disp_cell | ❌ $f failed | $p | $f | $s | $dur |"$'\n'
133-
names=$(jq -r --arg mode "$mode" '.failed[] | "- [\($mode)] \(. | gsub("[\r\n]"; " "))"' "$file")
136+
names=$(jq -r --arg mode "$mode" '.failed[] | "- [\($mode)] `\(. | gsub("[\r\n`|]"; " "))`"' "$file")
134137
failed_list="$failed_list$names"$'\n'
135138
else
136139
rows="$rows| $disp_cell | ✅ Passed | $p | 0 | $s | $dur |"$'\n'

.github/workflows/test.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -805,7 +805,7 @@ jobs:
805805
TEST_MODE: ${{ matrix.testMode }}
806806
run: |
807807
python3 - <<'PY'
808-
import glob, json, os, xml.etree.ElementTree as ET
808+
import glob, json, math, os, xml.etree.ElementTree as ET
809809
810810
artifacts_path = os.environ["ARTIFACTS_PATH"]
811811
test_mode = os.environ["TEST_MODE"]
@@ -831,6 +831,11 @@ jobs:
831831
seconds = float(test_case.get("duration") or 0)
832832
except ValueError:
833833
seconds = 0.0
834+
# float() admits nan/inf/1e999 without raising, and json.dump
835+
# would then emit bare NaN/Infinity — invalid JSON that aborts
836+
# the consumer's very first jq read of this file.
837+
if not math.isfinite(seconds):
838+
seconds = 0.0
834839
duration += seconds
835840
timings.append((seconds, test_case.get("fullname") or test_case.get("name")))
836841
if case_result == "Passed":

.github/workflows/visual-regression.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,11 @@ jobs:
171171
needs: resolve
172172
if: needs.resolve.outputs.authorized == 'true' && needs.resolve.outputs.pr_number != ''
173173
runs-on: ubuntu-latest
174+
# This job only writes the status comment; the workflow-level contents:write
175+
# ceiling exists for the reusable suite call, not for it.
176+
permissions:
177+
contents: read
178+
pull-requests: write
174179
steps:
175180
- name: Checkout CI status action
176181
uses: actions/checkout@v6
@@ -215,6 +220,11 @@ jobs:
215220
needs: [resolve, run-suite]
216221
if: always() && needs.resolve.outputs.authorized == 'true' && needs.resolve.outputs.pr_number != ''
217222
runs-on: ubuntu-latest
223+
# This job only writes the status comment; the workflow-level contents:write
224+
# ceiling exists for the reusable suite call, not for it.
225+
permissions:
226+
contents: read
227+
pull-requests: write
218228
steps:
219229
- name: Checkout CI status action
220230
uses: actions/checkout@v6

0 commit comments

Comments
 (0)