Posting Preview URLs as Sticky Pull Request Comments

The preview deployed successfully and the URL is in a workflow log nobody opens. A sticky comment puts it on the pull request itself — one comment, updated in place on every push, showing whether the current commit’s preview is building, ready, or broken.

When to use this pattern

  • Preview environments exist but reviewers have to hunt for the URL.
  • Your bot currently posts a new comment per push, burying the discussion.
  • Contributors from forks need the link too, and the current workflow cannot comment for them.

Prerequisites

The two problems a naive bot has

Posting a comment is trivial. Doing it once, and doing it at all from a fork, are the parts that need design.

Append-Per-Push vs Sticky, and the Fork Permission Split On the left, four pushes produce four comments of which only the last is current. On the right, four pushes update a single comment. Underneath, a table shows that the pull_request trigger has a read-only token on fork pull requests while pull_request_target runs with base-repository permissions. APPEND ON EVERY PUSH preview: pr-482-a1b2 — stale preview: pr-482-c3d4 — stale preview: pr-482-e5f6 — stale preview: pr-482-8d31 — current Four comments, three misleading, discussion pushed off-screen. STICKY — one comment, updated Preview ready https://pr-482.preview.example.com commit 8d31f0a · deployed 40s ago <!-- preview-bot --> marker, invisible to readers Always current; the marker is how the next run finds it. FORK PERMISSIONS on: pull_request read-only token on forks — comment POST returns 403 on: pull_request_target base-repo permissions — never check out fork code

Complete working example

# .github/workflows/preview-comment.yml
name: Preview Comment
on:
  # pull_request_target runs with the BASE repository's token, so it can comment on
  # fork pull requests. It must NOT check out or execute the fork's code.
  pull_request_target:
    types: [opened, synchronize, reopened, closed]

permissions:
  pull-requests: write

jobs:
  comment:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/github-script@v7
        with:
          script: |
            const MARKER = '<!-- preview-bot -->';   // hidden; how we find our own comment
            const pr = context.payload.pull_request;
            const sha = pr.head.sha.slice(0, 7);
            const url = `https://pr-${pr.number}.preview.example.com`;

            const body = pr.state === 'closed'
              ? `${MARKER}\n### 🧹 Preview torn down\nEnvironment for \`${sha}\` has been removed.`
              : [
                  MARKER,
                  '### 🔭 Preview environment',
                  '',
                  `| | |`,
                  `|---|---|`,
                  `| **URL** | ${url} |`,
                  `| **Commit** | \`${sha}\` |`,
                  `| **Status** | ${{ needs.deploy.result == 'success' && '✅ ready' || '❌ failed' }} |`,
                  '',
                  `<sub>Updated automatically on every push.</sub>`,
                ].join('\n');

            // Find our previous comment by marker, then PATCH rather than POST.
            const { data: comments } = await github.rest.issues.listComments({
              ...context.repo, issue_number: pr.number, per_page: 100,
            });
            const existing = comments.find((c) => c.body?.includes(MARKER));

            if (existing) {
              await github.rest.issues.updateComment({
                ...context.repo, comment_id: existing.id, body,
              });
            } else {
              await github.rest.issues.createComment({
                ...context.repo, issue_number: pr.number, body,
              });
            }

For the simpler same-repository case, gh is enough:

#!/usr/bin/env bash
# scripts/sticky-comment.sh — find-or-update by marker, no dependencies beyond gh.
set -euo pipefail
PR="${1:?pr number}"; BODY_FILE="${2:?body file}"
MARKER='<!-- preview-bot -->'

id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" --paginate \
      --jq "map(select(.body | contains(\"${MARKER}\"))) | .[0].id // empty")

if [ -n "$id" ]; then
  gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${id}" -F body=@"$BODY_FILE"
else
  gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" -F body=@"$BODY_FILE"
fi

Step-by-step walkthrough

The marker is an HTML comment, so readers never see it. GitHub renders Markdown and drops HTML comments, which makes <!-- preview-bot --> a durable, invisible identifier. Matching on the visible heading text instead breaks the moment someone edits the wording.

List then patch, rather than storing the comment ID. Storing the ID somewhere would mean managing state across runs. Listing is one API call, works after a re-run or a workflow rename, and is naturally idempotent.

pull_request_target is a sharp tool. It runs with write permissions in the context of the base repository. That is what makes fork comments possible, and it is also why the job must never check out or execute the fork’s code — doing so hands a write-capable token to an untrusted contributor. Keep this workflow to commenting only; the deploy itself belongs in a separate, unprivileged workflow.

One Comment, Four States On opened, the comment is created showing the preview building. On synchronize it is updated with the new commit and a ready status. On a failed deploy it is updated to show the failure and keeps the previous commit visible. On closed it is replaced with a teardown notice. opened comment created status: building synchronize same comment, PATCHed status: ready · 8d31f0a deploy failed status: failed, with log link no stale "ready" left behind closed torn down notice link removed All four write to the same comment ID, found by marker — so the pull request never accumulates a history of dead links. The failed state is the one most implementations omit, and it is the one that decides whether the comment can be trusted.

Report failure, not only success. A comment that appears only on success leaves a stale “ready” link after a failed push. Updating the same comment to show the failure is what keeps it trustworthy, and is why the status row exists rather than a bare URL.

Handle the closed event. On close, replace the body with a teardown notice. Otherwise the pull request keeps a link to an environment that no longer exists, which reliably produces a “the preview is broken” report weeks later — and interacts with the schedule in automating preview environment teardown.

Verification

# 1. Exactly one bot comment exists after several pushes.
gh api "repos/acme/shop/issues/482/comments" --paginate \
  --jq '[.[] | select(.body | contains("<!-- preview-bot -->"))] | length'    # → 1

# 2. It reflects the current head commit.
gh api "repos/acme/shop/issues/482/comments" --paginate \
  --jq '.[] | select(.body | contains("<!-- preview-bot -->")) | .body' | grep -o '`[0-9a-f]\{7\}`'
gh pr view 482 --json headRefOid --jq '.headRefOid[:7]'                        # must match

# 3. The comment survives a fork pull request.
gh pr list --json number,isCrossRepository --jq '.[] | select(.isCrossRepository) | .number'

Expected from the first command is 1 regardless of how many times the branch has been pushed. Anything higher means the marker lookup is failing — most often because the marker string in the search differs by a space from the one in the body.

Making the comment worth reading

Once the mechanics work, the comment becomes prime real estate on every pull request, and it is worth deciding deliberately what earns a place in it. The URL and the commit are the minimum. Beyond that, the useful additions are ones a reviewer would otherwise have to go looking for: the result of the environment parity gate, a link to the end-to-end run against this preview, and the deployed bundle size against the base branch.

What does not belong is anything that changes on every run without meaning — build durations, run identifiers, timestamps to the second. Each of those turns the comment into a notification source, and a comment that notifies people ten times a day gets muted, which removes the benefit entirely. The test is simple: if a reviewer would not act differently because of a line, it does not go in the comment.

What Earns a Line in the Comment The left comment carries the preview URL, commit, parity-gate result and end-to-end status — four lines a reviewer acts on. The right comment adds run identifier, build duration, runner label and cache statistics, none of which change a decision, and which cause the comment to notify on every push. actionable preview URL head commit — is this my change? parity gate result end-to-end run against this preview noise workflow run id 17420993311 build took 4m 12s runner: ubuntu-latest cache hit rate 91% Every line on the right changes per run, so the comment edits constantly — and an edit that never matters trains people to ignore it.

Common pitfalls

Using pull_request_target and checking out the head ref. This is the well-known privilege-escalation footgun: it executes untrusted code with a write token. Comment-only workflows must not check out the pull request’s code at all.

Matching the comment by author instead of marker. Several bots share the github-actions identity, so an author match can find and overwrite an unrelated bot’s comment. The marker is specific to yours.

Forgetting --paginate. A busy pull request has more than 30 comments; without pagination the lookup misses the existing comment and posts a duplicate, which then makes the next lookup ambiguous.

Keeping the comment trustworthy

A comment reviewers trust gets used; one they have learned to distrust gets ignored, and rebuilding that trust is far harder than earning it. Three properties determine which you get.

It reflects the current commit. Showing the head SHA lets a reviewer confirm the preview matches what they are looking at. Without it, the reviewer has no way to tell whether the link points at their colleague’s latest push or at something from yesterday, and the safe assumption becomes not to rely on it.

It reports failure. A comment that appears only on success leaves a stale “ready” link after a failed deploy, which is the single fastest way to lose trust. Updating the same comment to show the failure, with a link to the log, keeps it honest at the cost of a few lines of code.

It says when the environment is gone. On close, replacing the body with a teardown notice stops the pull request carrying a dead link into its archive, and prevents the report — weeks later — that “the preview is broken”.

Choosing what else goes in it

Once the comment works it becomes prime real estate, and the temptation is to fill it. The test worth applying to each candidate line is whether a reviewer would do something differently because of it. The parity-gate result, the end-to-end run status and a bundle-size delta against the base branch all pass that test. Build durations, run identifiers and cache statistics do not.

The cost of failing the test is not merely clutter: every line that changes on each run causes the comment to edit, and a comment that edits ten times a day gets muted. Muting removes the benefit entirely, which makes an over-full comment worse than a minimal one.

← Back to Automated Preview Deployments on Pull Requests