---
title: "Post screenshots to a PR comment from GitHub Actions"
canonical_url: "https://krowk.com/guides/screenshots-from-github-actions"
last_updated: "2026-09-02T08:09:50.171Z"
meta:
  description: "A complete workflow: run the visual tests, upload what they produced, and comment the images on the pull request. Includes the fork-PR limitation and the raw-CLI version."
  "og:description": "A complete workflow: run the visual tests, upload what they produced, and comment the images on the pull request. Includes the fork-PR limitation and the raw-CLI version."
  "og:title": "Post screenshots to a PR comment from GitHub Actions"
---

# **Attach screenshots to a pull request from GitHub Actions**

Last updated August 27, 2026

A test run in CI produces exactly the evidence a reviewer wants and then throws it away. The artifacts tab is where screenshots go to be ignored: it is a zip, three clicks from the diff, and nobody downloads it to check whether a button moved.

Putting the images in a pull request comment is a two-step job — upload, then comment — because GitHub has no upload API to fold the two together.

## The workflow

.github/workflows/visual-evidence.yml

```
name: Visual evidence

on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  screenshots:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22

      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - run: npx playwright test          # writes screenshots/*.png

      - uses: krowkcom/cli@v1
        id: krowk
        with:
          files: |
            screenshots/**/*.png
            recordings/*.webm
          token: ${{ secrets.KROWK_TOKEN }}

      - uses: actions/github-script@v7
        with:
          script: |
            await github.rest.issues.createComment({
              ...context.repo,
              issue_number: context.issue.number,
              body: ${{ toJSON(steps.krowk.outputs.markdown) }},
            })
```

## What each part is doing

### The push step

`files` is the only input you have to supply. The runner already knows the rest — which repository, which commit, which branch, which pull request — and the action reads it from the environment, exactly as the CLI does on a laptop.

`token` is what keeps the links. Without it the push is keyless and the links expire 24 hours later, which is survivable for a fast review and not for a pull request that sits over a weekend. With it the uploads land in your workspace and nothing expires. Store it as a repository or environment secret; it is a workspace key, so mint one per repository and revoke that one when you need to.

### The comment step

`steps.krowk.outputs.markdown` is what the registry assembled for GitHub, and it goes into the comment body untouched. Wrap it in `toJSON(...)`: the block carries newlines and quotes, and a raw interpolation breaks the script.

The other three outputs, and every input the action takes, are in [the action reference](https://krowk.com/docs/github-actions).

Whether or not that step runs, the links land in the job's step summary. Which matters, because of forks.

## Fork pull requests

GitHub's own wording is unambiguous on half of this: "with the exception of `GITHUB_TOKEN`, secrets are not passed to the runner when a workflow is triggered from a forked repository". So `token:` arrives empty and the push falls back to keyless. The token itself is read-only on a fork unless a repository admin has turned on *Send write tokens to workflows from pull requests*, so by default the comment step fails too, and you get 24-hour links in the step summary instead. Guard the step if that red X bothers you:

yaml

```
if: github.event.pull_request.head.repo.full_name == github.repository
```

The usual answer is a second workflow on `pull_request_target`. Weigh it properly first: it is granted read/write even from a public fork, which is the whole reason it works and the whole reason it is worth being careful with.

## Without the action

The action is two bash steps. If you would rather see them, or you are not on GitHub-hosted runners:

yaml

```
- name: Push screenshots
  env:
    KROWK_TOKEN: ${{ secrets.KROWK_TOKEN }}
  run: |
    curl -fsSL https://krowk.com/install | bash
    krowk push screenshots/*.png \
      --title "$GITHUB_WORKFLOW #$GITHUB_RUN_NUMBER" \
      --destination github > block.md
```

Then post it with `gh pr comment --body-file block.md`. The install script picks the platform and verifies checksums; `KROWK_VERSION` pins a release. Do that in CI.

## Rate limits, for a matrix build

Every key has a per-minute ceiling on new uploads and it cannot be switched off: 120 creates a minute per key, 10 a minute per address without one. Past it the API answers `429` with `Retry-After`. A twelve-way matrix each pushing a handful of files is nowhere near that; a loop that has gone wrong hits it inside a minute.

[The GitHub Actions reference](https://krowk.com/docs/github-actions)

The reference for everything above lives in [~~the docs~~](https://krowk.com/docs#pasting).