|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Create or update the single unified CI status comment on a PR, replacing only |
| 3 | +# one section (build | lint | tests). All three CI comment workflows call this |
| 4 | +# through the ci-status-comment composite action, so the three separate bot |
| 5 | +# comments collapse into one. |
| 6 | +# |
| 7 | +# The comment is keyed by the hidden <!-- ci-status --> marker and holds three |
| 8 | +# sections, each fenced by its own start/end markers: |
| 9 | +# |
| 10 | +# <!-- ci-status --> |
| 11 | +# ### 🚦 CI Status |
| 12 | +# <!-- ci:build:start --> …build… <!-- ci:build:end --> |
| 13 | +# <!-- ci:lint:start --> …lint… <!-- ci:lint:end --> |
| 14 | +# <!-- ci:tests:start --> …tests… <!-- ci:tests:end --> |
| 15 | +# |
| 16 | +# Build and Unity Test run as independent workflows whose comment writers can |
| 17 | +# fire at the same time, so a plain read-modify-write would drop a section or |
| 18 | +# create a duplicate comment. Each attempt collapses any duplicates (keeping the |
| 19 | +# oldest), rewrites only its own section on that comment, then re-reads to |
| 20 | +# confirm the section landed and no duplicate slipped in — retrying otherwise. |
| 21 | +set -euo pipefail |
| 22 | + |
| 23 | +MARKER="<!-- ci-status -->" |
| 24 | +HEADER="### 🚦 CI Status" |
| 25 | +BOT="github-actions[bot]" |
| 26 | +START="<!-- ci:${SECTION}:start -->" |
| 27 | +END="<!-- ci:${SECTION}:end -->" |
| 28 | + |
| 29 | +# Neutral "waiting" placeholder for a section that has not reported yet. Used |
| 30 | +# only when seeding a brand-new comment; a real run always overwrites its own. |
| 31 | +section_default() { |
| 32 | + case "$1" in |
| 33 | + build) printf '\n\n_Waiting for the build to start…_' ;; |
| 34 | + lint) printf '\n\n_Waiting for lint to start…_' ;; |
| 35 | + tests) printf '\n\n_Waiting for tests to start…_' ;; |
| 36 | + esac |
| 37 | +} |
| 38 | + |
| 39 | +# One section, fenced by its start/end markers. |
| 40 | +wrap_section() { printf '<!-- ci:%s:start -->\n%s\n<!-- ci:%s:end -->' "$1" "$2" "$1"; } |
| 41 | + |
| 42 | +# A fresh comment with every section defaulted to "waiting". |
| 43 | +skeleton() { |
| 44 | + printf '%s\n%s\n\n%s\n\n%s\n\n%s\n' \ |
| 45 | + "$MARKER" "$HEADER" \ |
| 46 | + "$(wrap_section build "$(section_default build)")" \ |
| 47 | + "$(wrap_section lint "$(section_default lint)")" \ |
| 48 | + "$(wrap_section tests "$(section_default tests)")" |
| 49 | +} |
| 50 | + |
| 51 | +# Emit the section body for this run to a file so awk can splice it verbatim, |
| 52 | +# free of shell quoting concerns. Parts of the body (lint findings, failed test |
| 53 | +# names) originate in the untrusted pull_request job, so drop any line shaped |
| 54 | +# like a section marker before writing it — a body line must never open or close |
| 55 | +# a section fence, or it would scramble the comment structure / wedge the survive |
| 56 | +# check below. |
| 57 | +printf '%s\n' "$SECTION_BODY" \ |
| 58 | + | grep -vE '^[[:space:]]*<!-- ci[-:][^>]*-->[[:space:]]*$' > section_body.md || true |
| 59 | +WANT="$(cat section_body.md)" |
| 60 | + |
| 61 | +# Replace the content between START and END in $1 with section_body.md. |
| 62 | +replace_section() { |
| 63 | + awk -v s="$START" -v e="$END" -v f="section_body.md" ' |
| 64 | + $0==s { print; while ((getline line < f) > 0) print line; close(f); skip=1; next } |
| 65 | + $0==e { print; skip=0; next } |
| 66 | + skip { next } |
| 67 | + { print } |
| 68 | + ' <<< "$1" |
| 69 | +} |
| 70 | + |
| 71 | +# Trimmed content currently between START and END in $1 (for the survive check). |
| 72 | +extract_section() { |
| 73 | + awk -v s="$START" -v e="$END" ' |
| 74 | + $0==s { grab=1; next } |
| 75 | + $0==e { grab=0; next } |
| 76 | + grab { print } |
| 77 | + ' <<< "$1" |
| 78 | +} |
| 79 | + |
| 80 | +# Normalise the comment list to a flat array, whether `--paginate --slurp` hands |
| 81 | +# back a flat array of comments or an array of per-page arrays. |
| 82 | +flatten_pages() { jq -c '[.[] | if type=="array" then .[] else . end]' <<< "$1"; } |
| 83 | + |
| 84 | +# IDs of every marker-bearing bot comment on the PR, oldest first. Flattened |
| 85 | +# first so sort_by orders globally rather than only within a page. |
| 86 | +marker_ids() { |
| 87 | + jq -r --arg m "$MARKER" --arg bot "$BOT" \ |
| 88 | + '[.[] | select(.user.login==$bot and (.body|contains($m)))] | sort_by(.id) | .[].id' <<< "$(flatten_pages "$1")" |
| 89 | +} |
| 90 | + |
| 91 | +for attempt in 1 2 3 4 5; do |
| 92 | + COMMENTS=$(gh api "/repos/$REPO/issues/$PR_NUMBER/comments" --paginate --slurp) |
| 93 | + IDS=() |
| 94 | + while IFS= read -r line; do [ -n "$line" ] && IDS+=("$line"); done <<< "$(marker_ids "$COMMENTS")" |
| 95 | + COMMENT_ID="${IDS[0]:-}" |
| 96 | + |
| 97 | + # Collapse accidental duplicates from a create race: keep the oldest, drop the rest. |
| 98 | + if [ "${#IDS[@]}" -gt 1 ]; then |
| 99 | + for extra in "${IDS[@]:1}"; do |
| 100 | + echo "Deleting duplicate CI status comment $extra." |
| 101 | + gh api -X DELETE "/repos/$REPO/issues/comments/$extra" >/dev/null || true |
| 102 | + done |
| 103 | + fi |
| 104 | + |
| 105 | + if [ -n "$COMMENT_ID" ]; then |
| 106 | + CURRENT_BODY=$(jq -r --arg id "$COMMENT_ID" '.[] | select(.id==($id|tonumber)) | .body' <<< "$(flatten_pages "$COMMENTS")") |
| 107 | + else |
| 108 | + CURRENT_BODY="" |
| 109 | + fi |
| 110 | + |
| 111 | + # No unified comment yet, or one missing our section markers: start clean so |
| 112 | + # all three sections are always present. |
| 113 | + if [ -z "$CURRENT_BODY" ] || ! grep -qF "$START" <<< "$CURRENT_BODY"; then |
| 114 | + CURRENT_BODY="$(skeleton)" |
| 115 | + fi |
| 116 | + |
| 117 | + NEW_BODY="$(replace_section "$CURRENT_BODY")" |
| 118 | + |
| 119 | + if [ -z "$COMMENT_ID" ]; then |
| 120 | + RESULT=$(jq -n --arg b "$NEW_BODY" '{body:$b}' \ |
| 121 | + | gh api -X POST "/repos/$REPO/issues/$PR_NUMBER/comments" --input -) |
| 122 | + COMMENT_ID=$(jq -r '.id' <<< "$RESULT") |
| 123 | + else |
| 124 | + jq -n --arg b "$NEW_BODY" '{body:$b}' \ |
| 125 | + | gh api -X PATCH "/repos/$REPO/issues/comments/$COMMENT_ID" --input - >/dev/null |
| 126 | + fi |
| 127 | + |
| 128 | + # Re-read and confirm our section landed on the surviving comment, and that no |
| 129 | + # concurrent writer left a duplicate behind. |
| 130 | + sleep 1 |
| 131 | + RECHECK=$(gh api "/repos/$REPO/issues/$PR_NUMBER/comments" --paginate --slurp) |
| 132 | + RIDS=() |
| 133 | + while IFS= read -r line; do [ -n "$line" ] && RIDS+=("$line"); done <<< "$(marker_ids "$RECHECK")" |
| 134 | + LIVE_BODY=$(jq -r --arg id "$COMMENT_ID" '.[] | select(.id==($id|tonumber)) | .body' <<< "$(flatten_pages "$RECHECK")") |
| 135 | + |
| 136 | + # Success means our section landed on the comment we wrote — nothing more. |
| 137 | + # Duplicate collapsing is best-effort cleanup (the DELETE above may lack |
| 138 | + # permission); a duplicate we could not remove must not block reporting that |
| 139 | + # already succeeded, or every run would burn all 5 attempts and warn forever. |
| 140 | + if [ "$(extract_section "$LIVE_BODY")" = "$WANT" ]; then |
| 141 | + echo "CI status '$SECTION' section updated (attempt $attempt)." |
| 142 | + if [ "${#RIDS[@]}" -gt 1 ]; then |
| 143 | + echo "A duplicate CI status comment remains (could not be deleted); it will be retried next run." |
| 144 | + fi |
| 145 | + exit 0 |
| 146 | + fi |
| 147 | + |
| 148 | + echo "Section '$SECTION' not settled (attempt $attempt); retrying." |
| 149 | + sleep $((attempt * 2)) |
| 150 | +done |
| 151 | + |
| 152 | +echo "::warning::Could not confirm the '$SECTION' CI status section after 5 attempts." |
| 153 | +exit 0 |
0 commit comments