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'
Appending against upserting, over a five-push pull requestAppending produces one machine comment per push, so a pull request with five pushes and three reporting jobs accumulates fifteen comments around the human conversation. An upsert leaves exactly one, always showing the current state.Append per runUpsert one commentcomments after 5 pushes151shows current statescroll to the last onealwayshuman thread readableburiedintactimplementationone commandone scriptthree jobs times five pushes is the arithmetic that ruins a review thread

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
Fan out to report, fan in to commentParallel jobs each produce a fragment and upload it as an artefact. A single final job downloads the fragments, concatenates them in a fixed order, and performs one upsert — so no two jobs ever write to the comment at the same time.Parallel jobscoveragebundle sizemigrationsFragmentsone artefact eachComposefixed orderone documentSingle upsertone commentno racesthe alternative — each job editing the comment — loses sections to write races

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_target to 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'
Three places a result can landAn upserted comment is the most visible and needs write access. The run summary needs nothing and is attached to the check. An annotation points at a specific line and is ideal for findings the author must act on.Upserted commentmost visibleneeds write accessone per PRRun summaryno permissionsattached to the checkworks for forksInline annotationpoints at a linefor actionable findingsno thread noisepick per audience: the author, the reviewer, or the person fixing a specific line

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.