Posting CI results as PR comments without spam Jump to heading
A bot that appends a comment on every push is a thread destroyer. After the fourth push the human conversation is scattered between machine output, and reviewers start scrolling past everything — including the comment that mattered. The fix is an upsert: one comment, identified by a hidden marker, rewritten on each run. This recipe builds it, including the awkward case of several parallel jobs wanting to write into the same comment, as part of pull request automation and bots.
When to use this approach Jump to heading
- A pipeline posts results to pull requests and the threads have become unreadable.
- Several jobs each want to report something — coverage, bundle size, migration plan.
- Reviewers have started muting the repository.
- You need the result visible to contributors from forks, where tokens are read-only.
- If exactly one job posts exactly once per pull request, the plain comment is fine.
Step 1 — Identify the comment with a hidden marker Jump to heading
An HTML comment is invisible in the rendered view and reliable to search for, which makes it a better key than matching on the author or the text.
MARKER='<!-- ci-summary:do-not-remove -->'
# Find the existing comment, if any
id=$(gh pr view "$PR" --json comments \
--jq --arg m "$MARKER" '.comments[] | select(.body | contains($m)) | .id' | head -1) # Verification: the marker is present but invisible in the rendered comment
gh pr view "$PR" --json comments --jq '.comments[].body' | grep -c 'ci-summary' Matching on the author instead breaks the moment a second bot posts, and matching on the title breaks when someone edits it. The marker survives both.
Step 2 — Upsert rather than append Jump to heading
#!/usr/bin/env sh
# scripts/pr-comment-upsert.sh — write one comment, rewrite it thereafter.
set -eu
PR="$1"; BODY_FILE="$2"
MARKER='<!-- ci-summary:do-not-remove -->'
{ printf '%s\n' "$MARKER"; cat "$BODY_FILE"; } > /tmp/body.md
id=$(gh pr view "$PR" --json comments \
--jq --arg m "$MARKER" '.comments[] | select(.body | contains($m)) | .id' | head -1)
if [ -n "$id" ]; then
gh api -X PATCH "repos/{owner}/{repo}/issues/comments/$id" -F body=@/tmp/body.md
else
gh pr comment "$PR" --body-file /tmp/body.md
fi # Verification: run it twice; the comment count must not increase
sh scripts/pr-comment-upsert.sh "$PR" summary.md
sh scripts/pr-comment-upsert.sh "$PR" summary.md
gh pr view "$PR" --json comments --jq '[.comments[] | select(.body | contains("ci-summary"))] | length' Step 3 — Let parallel jobs write into one comment Jump to heading
Several jobs writing simultaneously will overwrite each other. Give each a section marker and merge, or — simpler and more robust — have each job publish an artefact and let one final job compose the comment.
# Each reporting job writes a fragment
coverage:
steps:
- run: npm run coverage:summary > coverage.md
- uses: actions/upload-artifact@v4
with: { name: report-coverage, path: coverage.md }
size:
steps:
- run: npm run size:summary > size.md
- uses: actions/upload-artifact@v4
with: { name: report-size, path: size.md }
comment:
needs: [coverage, size]
if: always()
steps:
- uses: actions/download-artifact@v4
with: { pattern: 'report-*', merge-multiple: true }
- run: cat coverage.md size.md > summary.md
- run: sh scripts/pr-comment-upsert.sh "$PR" summary.md # Verification: the composed comment contains every section exactly once
grep -c '^### ' summary.md Step 4 — Keep the comment short enough to read Jump to heading
A summary that reproduces the full test output is a log with extra steps. Show the delta and link to the run.
# A good summary is a table of changes, not a dump
{
echo "### Checks"
echo
echo "| Report | Result | Change |"
echo "|---|---|---|"
printf '| coverage | %s%% | %+.1f |\n' "$COV" "$COV_DELTA"
printf '| bundle | %s kB | %+d kB |\n' "$SIZE" "$SIZE_DELTA"
echo
echo "[full run]($RUN_URL)"
} > summary.md # Verification: keep it under a screenful
wc -l summary.md | awk '{ if ($1 > 25) print "too long: " $1 " lines"; else print "ok" }' Step 5 — Prefer the check summary when the token is read-only Jump to heading
Pull requests from forks run with a read-only token, and reaching for an elevated trigger to work around that is a well-known escalation path. The run summary needs no permissions at all.
- name: Report without needing write access
if: always()
run: cat summary.md >> "$GITHUB_STEP_SUMMARY" SAFETY WARNING — do not use
pull_request_targetto obtain a writable token for commenting on fork contributions. That trigger runs in the base repository’s context with access to secrets while the contributor controls the branch, and checking out that branch under it hands your credentials to code you have not read. Post to the run summary, or comment from a separate workflow that triggers on run completion and never checks out the contributor’s code.
# Verification: the summary appears on the run without any write permission
gh run view --json jobs --jq '.jobs[].name' Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Should the comment be deleted when everything passes? Jump to heading
Leave it and show the green state. Deleting it means the absence of a comment is ambiguous — it could mean everything passed, or that the job failed to run at all. A short “all checks green” line is worth the space.
What about very noisy reports like a dependency diff? Jump to heading
Collapse them behind a disclosure element in the comment body, or link out to the artefact. The summary should answer “does this need my attention” in one glance; the detail belongs one click away.
Can we thread machine comments separately from human ones? Jump to heading
Some forges support that and the effect is mixed: separating the streams means fewer people read the machine one. A single well-maintained comment at the top of the thread is read more often than a well-organised second thread.
Related Jump to heading
- Pull Request Automation & Bots — the parent topic and the one-comment rule.
- Choosing Required Status Checks That Actually Gate — what to do with reports that should not block.
- Auto-Labelling Pull Requests by Changed Path — deciding which reports a given change even needs.