Sticky PR Preview Comment with github-script
Post or update a sticky comment on a pull request from GitHub Actions using actions/github-script. Perfect for preview links and build info.
A GitHub Actions step that posts a sticky comment on the current pull request โ and updates it in place on every push instead of spamming new comments. This is the pattern I use for preview deployment links, bundle size diffs, and build info. Powered by actions/github-script, no GitHub App required.
Tested on actions/github-script@v7, GitHub-hosted runners.
When to Use This
- Posting a Vercel/Netlify preview URL on every PR
- Reporting bundle size diffs between base and PR
- Showing test coverage delta
- Summarizing CI results inline so reviewers don't have to click
Don't use this when you need rich UI (use a GitHub App instead) or when the comment must persist across PR closes (comments stick to PRs only).
Code
# .github/workflows/preview-comment.yml
name: Preview Comment
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
pull-requests: write # required to comment
jobs:
comment:
runs-on: ubuntu-latest
steps:
- name: Post or update preview comment
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- preview-comment -->';
const body = [
marker,
'### ๐ Preview Deployment',
'',
`**Preview URL:** https://pr-${context.payload.pull_request.number}.preview.example.com`,
`**Commit:** \`${context.sha.slice(0, 7)}\``,
`**Build:** ${context.runId}`,
'',
`_Last updated: ${new Date().toISOString()}_`,
].join('\n');
// Find existing sticky comment by marker
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
});
const existing = comments.find((c) => c.body?.includes(marker));
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
body,
});
}The HTML comment marker (<!-- preview-comment -->) is invisible in the rendered comment but lets the workflow find its previous comment on the next run. Without a marker, you'd post a new comment every time.
Usage
A common extension: include build size info from a previous step.
jobs:
build-and-comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm run build
- name: Calculate bundle size
id: size
run: |
SIZE=$(du -sh .next/static | cut -f1)
echo "size=$SIZE" >> $GITHUB_OUTPUT
- name: Post preview comment
uses: actions/github-script@v7
with:
script: |
const marker = '<!-- preview-comment -->';
const size = '${{ steps.size.outputs.size }}';
const body = [
marker,
'### ๐ Preview Deployment',
'',
`**Bundle size:** ${size}`,
`**Commit:** \`${context.sha.slice(0, 7)}\``,
].join('\n');
// ... same find-or-create pattern as above ...Pitfalls
permissions: pull-requests: writeis required. Without it, the workflow can't comment, and you get a 403 error from the GitHub API. Forgetting this is the #1 cause of "why doesn't my comment workflow work".- The marker must be unique per workflow. If two workflows use the same marker, they'll fight over the same comment. Use
<!-- preview-comment-bundle -->and<!-- preview-comment-tests -->for separate sticky comments. context.payload.pull_requestis undefined forpushevents. This pattern only works onpull_requesttriggers. For push events, usepulls.listto find the matching PR.actions/github-script@v7requires Node 20+ on the runner. ubuntu-latest has it; older self-hosted runners may not.- Forks can't comment by default. PRs from forks don't have write permission to the head repo. Use
pull_request_targetcarefully if you need to comment on fork PRs (with the security implications spelled out in the docs).
Related Snippets & Reading
- Next.js CI Workflow โ pairs with this for PR feedback
- Conditional Skip on Docs-Only Changes(coming soon) โ skip the comment job for docs PRs
- actions/github-script โ official action docs
Frequently Asked Questions
What does 'sticky' mean for a PR comment?
Sticky means the workflow updates the same comment on every push instead of posting a new one each time. The user sees one comment that always reflects the latest state, not a wall of duplicate messages from every CI run.
Why use actions/github-script instead of curling the GitHub API?
github-script gives you authenticated GitHub API access via the Octokit client, with the GITHUB_TOKEN already injected. No manual auth, no curl quoting hell, and you get TypeScript-like JS execution inside the workflow YAML.