INTERMEDIATEGITHUB-ACTIONSCI/CD

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.

By Tested on actions/github-script@v7, GitHub-hosted runners

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: write is 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_request is undefined for push events. This pattern only works on pull_request triggers. For push events, use pulls.list to find the matching PR.
  • actions/github-script@v7 requires 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_target carefully if you need to comment on fork PRs (with the security implications spelled out in the docs).
  • 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.

X (Twitter)LinkedIn