Skip to content
Merged
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
33 changes: 33 additions & 0 deletions .github/actions/ci-status-comment/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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.

inputs:
pr-number:
description: Pull request number to comment on.
required: true
section:
description: Which section to replace — one of build, lint, tests.
required: true
body:
description: Markdown for this section (inline badge + message). Rendered as-is between the section markers.
required: true
github-token:
description: Token with pull-requests:write used to read and upsert the comment.
required: true

runs:
using: composite
steps:
- name: Upsert unified CI status comment
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ inputs.pr-number }}
SECTION: ${{ inputs.section }}
SECTION_BODY: ${{ inputs.body }}
run: bash "$GITHUB_ACTION_PATH/upsert-ci-status.sh"
153 changes: 153 additions & 0 deletions .github/actions/ci-status-comment/upsert-ci-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
#!/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.
Comment thread
dalkia marked this conversation as resolved.
#
# The comment is keyed by the hidden <!-- ci-status --> marker and holds three
# sections, each fenced by its own start/end markers:
#
# <!-- 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 -->
#
# 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
# create a duplicate comment. Each attempt collapses any duplicates (keeping the
# oldest), rewrites only its own section on that comment, then re-reads to
# confirm the section landed and no duplicate slipped in — retrying otherwise.
set -euo pipefail

MARKER="<!-- ci-status -->"
HEADER="### 🚦 CI Status"
BOT="github-actions[bot]"
START="<!-- ci:${SECTION}:start -->"
END="<!-- ci:${SECTION}:end -->"
Comment thread
dalkia marked this conversation as resolved.

# Neutral "waiting" placeholder for a section that has not reported yet. Used
Comment thread
dalkia marked this conversation as resolved.
# only when seeding a brand-new comment; a real run always overwrites its own.
section_default() {
case "$1" in
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…_' ;;
esac
}

# One section, fenced by its start/end markers.
wrap_section() { printf '<!-- ci:%s:start -->\n%s\n<!-- ci:%s:end -->' "$1" "$2" "$1"; }

# A fresh comment with every section defaulted to "waiting".
skeleton() {
printf '%s\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)")"
}

# Emit the section body for this run to a file so awk can splice it verbatim,
# free of shell quoting concerns. Parts of the body (lint findings, failed test
# names) originate in the untrusted pull_request job, so drop any line shaped
# like a section marker before writing it — a body line must never open or close
# a section fence, or it would scramble the comment structure / wedge the survive
# check below.
printf '%s\n' "$SECTION_BODY" \
| grep -vE '^[[:space:]]*<!-- ci[-:][^>]*-->[[:space:]]*$' > section_body.md || true
WANT="$(cat section_body.md)"

# Replace the content between START and END in $1 with section_body.md.
replace_section() {
awk -v s="$START" -v e="$END" -v f="section_body.md" '
$0==s { print; while ((getline line < f) > 0) print line; close(f); skip=1; next }
$0==e { print; skip=0; next }
skip { next }
{ print }
' <<< "$1"
}

# Trimmed content currently between START and END in $1 (for the survive check).
extract_section() {
awk -v s="$START" -v e="$END" '
$0==s { grab=1; next }
$0==e { grab=0; next }
grab { print }
' <<< "$1"
}

# Normalise the comment list to a flat array, whether `--paginate --slurp` hands
# back a flat array of comments or an array of per-page arrays.
flatten_pages() { jq -c '[.[] | if type=="array" then .[] else . end]' <<< "$1"; }

# IDs of every marker-bearing bot comment on the PR, oldest first. Flattened
# first so sort_by orders globally rather than only within a page.
marker_ids() {
jq -r --arg m "$MARKER" --arg bot "$BOT" \
'[.[] | select(.user.login==$bot and (.body|contains($m)))] | sort_by(.id) | .[].id' <<< "$(flatten_pages "$1")"
}

for attempt in 1 2 3 4 5; do
COMMENTS=$(gh api "/repos/$REPO/issues/$PR_NUMBER/comments" --paginate --slurp)
Comment thread
dalkia marked this conversation as resolved.
IDS=()
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
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
done
fi
Comment thread
dalkia marked this conversation as resolved.

if [ -n "$COMMENT_ID" ]; then
CURRENT_BODY=$(jq -r --arg id "$COMMENT_ID" '.[] | select(.id==($id|tonumber)) | .body' <<< "$(flatten_pages "$COMMENTS")")
else
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
CURRENT_BODY="$(skeleton)"
fi

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

if [ -z "$COMMENT_ID" ]; then
RESULT=$(jq -n --arg b "$NEW_BODY" '{body:$b}' \
| gh api -X POST "/repos/$REPO/issues/$PR_NUMBER/comments" --input -)
COMMENT_ID=$(jq -r '.id' <<< "$RESULT")
else
jq -n --arg b "$NEW_BODY" '{body:$b}' \
| gh api -X PATCH "/repos/$REPO/issues/comments/$COMMENT_ID" --input - >/dev/null
fi

# Re-read and confirm our section landed on the surviving comment, and that no
# concurrent writer left a duplicate behind.
sleep 1
RECHECK=$(gh api "/repos/$REPO/issues/$PR_NUMBER/comments" --paginate --slurp)
RIDS=()
while IFS= read -r line; do [ -n "$line" ] && RIDS+=("$line"); done <<< "$(marker_ids "$RECHECK")"
LIVE_BODY=$(jq -r --arg id "$COMMENT_ID" '.[] | select(.id==($id|tonumber)) | .body' <<< "$(flatten_pages "$RECHECK")")

# Success means our section landed on the comment we wrote — nothing more.
# Duplicate collapsing is best-effort cleanup (the DELETE above may lack
# permission); a duplicate we could not remove must not block reporting that
# already succeeded, or every run would burn all 5 attempts and warn forever.
if [ "$(extract_section "$LIVE_BODY")" = "$WANT" ]; then
echo "CI status '$SECTION' section updated (attempt $attempt)."
if [ "${#RIDS[@]}" -gt 1 ]; then
echo "A duplicate CI status comment remains (could not be deleted); it will be retried next run."
fi
exit 0
fi

echo "Section '$SECTION' not settled (attempt $attempt); retrying."
sleep $((attempt * 2))
done

echo "::warning::Could not confirm the '$SECTION' CI status section after 5 attempts."
exit 0
Loading
Loading