Automated pull request comments are useful until every new commit creates another one.
A test summary, deployment preview, infrastructure plan or automated review normally represents the latest state of the branch. Posting another comment on every workflow run turns that state into a timeline, leaving reviewers to work out which result still matters.
I’d treat the comment as managed state instead:
- Give it a stable identifier.
- Find the existing comment.
- Update it when it exists.
- Create it only when it doesn’t.
That gives the pull request one place for the current result.
GitHub comments don’t give you a custom identifier for your own automation, so an HTML comment works well:
<!-- managed-report:project-checks -->
The marker remains in the Markdown source without appearing in the rendered comment.
It should identify the logical report rather than the current workflow run:
<!-- managed-report:test-summary --><!-- managed-report:deployment-preview --><!-- managed-report:security-scan -->
If a pull request should only ever have one test summary, every run uses the same marker. The visible heading can change later without affecting how the workflow finds the comment.
I also wouldn’t match on the marker alone. If another bot or a user happens to post the same text, the workflow shouldn’t overwrite their comment. For a workflow using the normal GITHUB_TOKEN, I would check both the marker and github-actions[bot].
Create or update the comment
There are a few details I would not skip:
- paginate when reading comments;
- match the hidden marker;
- verify the expected bot owns the comment;
- preserve the marker when updating;
- create a comment only when no owned match exists.
The ownership check matters because searching for the marker alone could match text posted by a user or another automation.
For workflows using the normal GITHUB_TOKEN, that might mean checking for:
comment.user?.type === "Bot" &&comment.user?.login === "github-actions[bot]"
If you’re authenticating as a GitHub App, use the identity that actually publishes your comments instead.
GitHub’s own docs repository uses the same general pattern with a hidden marker, expected comment author and create-or-update action, so this isn’t a particularly unusual workaround.

A complete GitHub Actions example
The workflow below generates a Markdown report and then publishes it as one managed PR comment.
name: Pull request report
on:
pull_request:
types:
- opened
- synchronize
- reopened
permissions:
contents: read
pull-requests: write
concurrency:
group: managed-report-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
report:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Generate report
run: |
./scripts/generate-report > report.md
- name: Create or update pull request comment
if: >
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
uses: actions/github-script@v9
env:
REPORT_MARKER: "<!-- managed-report:project-checks -->"
REPORT_PATH: report.md
with:
github-token: ${{ github.token }}
script: |
const fs = require("fs");
const marker = process.env.REPORT_MARKER;
const report = fs.readFileSync(process.env.REPORT_PATH, "utf8");
const body = `${marker}\n${report}`;
const comments = await github.paginate(
github.rest.issues.listComments,
{
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100
}
);
const existing = comments.find((comment) =>
comment.user?.type === "Bot" &&
comment.user?.login === "github-actions[bot]" &&
typeof comment.body === "string" &&
comment.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body
});
core.info(`Updated managed comment ${existing.id}`);
return;
}
const created = await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body
});
core.info(`Created managed comment ${created.data.id}`);
actions/checkout@v7 and actions/github-script@v9 are the current major releases at the time of writing. GitHub Script v9 also changed how additional Octokit clients are created because @actions/github is now ESM-only, although that doesn’t affect the injected github client used above.
I’m deliberately reading the generated report from a file rather than putting its contents directly into the JavaScript through a GitHub Actions expression.
Generated Markdown can contain quotes, backticks and ${...} fragments. Keeping the report as data rather than injecting it into executable JavaScript makes the boundary much easier to reason about.
Watch for two jobs creating the same comment
Concurrency is the part that catches otherwise correct implementations.
Two jobs can both list the existing comments before either has created one. Both see no match, and both create a comment.
A concurrency group helps prevent older workflow runs from publishing after a newer commit arrives:
I wouldn’t use concurrency to compensate for unclear job ownership, though.
If several jobs contribute results, I prefer one final aggregation job:

The individual jobs produce data. One component owns presentation.
That is easier to reason about than ten matrix jobs all trying to find and update the same comment.
Keep the comment useful
Removing duplicate comments doesn’t help much if the remaining comment contains hundreds of lines of raw output.
I normally put the current result first:
## Project checks
128 passed · 2 warnings · 0 failures
<details>
<summary>View detailed results</summary>
Detailed results here.
</details>
Updated for `1a2b3c4`.
The commit SHA makes it obvious which version of the pull request the report represents.
For larger results, I’d keep the useful summary in the PR and store the full logs or reports as workflow artifacts.
When a warning disappears, I generally update the same comment to a clean result rather than delete it:
## Project checks
All checks passed.
Updated for `5d93f71`.
That tells the reviewer the old warning is no longer current and confirms that the latest commit has been checked.
Keep untrusted execution separate from publication
A pull_request workflow from a fork will normally receive restricted permissions, which means the publication step may not be able to write a comment.
I wouldn’t solve that by moving the whole workflow to pull_request_target.
pull_request_target executes in the context of the base repository and can have access to privileges unavailable to the fork. GitHub has added stronger protections to actions/checkout v7 for common unsafe fork checkout patterns, but I would still keep untrusted execution separate from trusted publication.
If an external PR needs a comment, a better design is:

Otherwise, make comments optional and retain the report as an artifact.
Report generation shouldn’t need write access merely because one possible presentation layer happens to be a PR comment.
The bit I would test first when adding this pattern to an existing workflow is the ownership check. If the comment is posted by a GitHub App rather than github-actions[bot], the lookup will never match and every run will create another comment.
The second thing I’d check is whether more than one job can reach the create path. If that is possible, fix the job ownership before doing anything else.
After that, the implementation is fairly uneventful: one stable marker, one known owner, a paginated lookup and one job responsible for publishing the result.