---
title: Post a Hosted CI Screenshot in a GitHub Pull Request Comment
description: Upload a failed CI screenshot, then add its public URL to a pull request with GitHub CLI and limited permissions.
slug: github-pr-screenshot-comment
date: 2026-08-20
updated: 2026-08-20
last_tested: 2026-08-20
summary: Run untrusted pull request code with a read token, then upload and comment only from a separate trusted job.
cluster: Automation workflows
intent: workflow
sources:
  - title: GitHub GITHUB_TOKEN authentication
    url: https://docs.github.com/actions/reference/authentication-in-a-workflow
  - title: GitHub pull request target security
    url: https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target
  - title: GitHub CLI pr comment manual
    url: https://cli.github.com/manual/gh_pr_comment
  - title: imgd.dev OpenAPI document
    url: https://imgd.dev/openapi.json
---

# Post a Hosted CI Screenshot in a GitHub Pull Request Comment

Use two GitHub Actions jobs. Run pull request code in a test job with `contents: read`. Then use a separate trusted job to upload the screenshot and run `gh pr comment`. The trusted job never checks out or executes pull request code.

imgd.dev charges a one-time **$1 per GB** of storage and has **no free tier**. Fund the account before this workflow uploads. Every imgd.dev image is public. Do not capture private pages, secrets, personal data, or confidential test fixtures.

## The security boundary

A pull request from a fork can contain untrusted code and workflow changes. Do not give that code a write token or `IMGD_KEY`.

This workflow uses these controls:

1. The `test` job has only `contents: read`.
2. The `test` job does not receive `IMGD_KEY`.
3. The screenshot moves between jobs only as workflow data.
4. The `publish` job requests `pull-requests: write`.
5. The `publish` job runs only for a branch in the base repository.
6. The `publish` job does not check out project code.
7. All GitHub actions use full commit hashes.

Fork pull requests can run the read-only test job. The `publish` job skips them, so no public comment is added for a fork.

Do not replace this design with `pull_request_target` plus a fork checkout. That pattern can run attacker code with base secrets and a write token.

## Prepare the repository

Configure the test command to write a PNG only when a test fails:

```text
test-results/failure.png
```

Add a funded repository secret named `IMGD_KEY`. Do not put the key in the workflow file or a repository variable.

The workflow uses Node.js and `npm test`. Change those two setup steps if your project uses another test tool.

## Complete workflow

Save this file as `examples/workflows/github/pr-screenshot.yml`. Copy it to `.github/workflows/pr-screenshot.yml` in the target repository.

```yaml
name: Post a hosted pull request screenshot

on:
  pull_request:
    types: [opened, reopened, synchronize]

permissions: {}

env:
  SCREENSHOT_PATH: test-results/failure.png

jobs:
  test:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    outputs:
      has_screenshot: ${{ steps.screenshot.outputs.exists }}
    steps:
      - name: Check out the pull request merge commit
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false

      - name: Set up Node.js
        uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
        with:
          node-version: 24
          package-manager-cache: false

      - name: Install dependencies
        run: npm ci

      - name: Run the screenshot test
        id: tests
        continue-on-error: true
        run: npm test

      - name: Check the guarded screenshot path
        id: screenshot
        if: always()
        shell: bash
        run: |
          set -euo pipefail
          if [[ -f "$SCREENSHOT_PATH" ]]; then
            echo "exists=true" >> "$GITHUB_OUTPUT"
          else
            echo "exists=false" >> "$GITHUB_OUTPUT"
          fi

      - name: Store the failure screenshot as workflow data
        if: steps.tests.outcome == 'failure' && steps.screenshot.outputs.exists == 'true'
        uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
        with:
          path: ${{ env.SCREENSHOT_PATH }}
          archive: false
          retention-days: 1

      - name: Preserve the test failure
        if: steps.tests.outcome == 'failure'
        run: exit 1

  publish:
    needs: test
    if: >-
      always() &&
      needs.test.result == 'failure' &&
      needs.test.outputs.has_screenshot == 'true' &&
      github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    permissions:
      contents: read
      pull-requests: write
    steps:
      - name: Download the screenshot without project code
        uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
        with:
          name: failure.png
          path: ci-screenshot

      - name: Upload the screenshot to imgd.dev
        id: upload
        shell: bash
        env:
          IMGD_KEY: ${{ secrets.IMGD_KEY }}
          LOCAL_SCREENSHOT: ci-screenshot/failure.png
        run: |
          set -euo pipefail
          test -n "${IMGD_KEY:-}"
          test -f "$LOCAL_SCREENSHOT"

          response_file="$(mktemp)"
          trap 'rm -f "$response_file"' EXIT
          http_status="$(
            curl --silent --show-error \
              --output "$response_file" \
              --write-out '%{http_code}' \
              -X POST https://imgd.dev/v1/upload \
              -H "Authorization: Bearer $IMGD_KEY" \
              -F "file=@${LOCAL_SCREENSHOT};type=image/png"
          )"

          if [[ "$http_status" != "200" && "$http_status" != "202" ]]; then
            error="$(jq -r '.error // "unknown_error"' "$response_file")"
            fix="$(jq -r '.fix // "Read the response body."' "$response_file")"
            printf 'imgd.dev upload failed with HTTP %s and %s: %s\n' \
              "$http_status" "$error" "$fix" >&2
            exit 1
          fi

          url="$(
            jq -er \
              '.url | select(test("^https://i\\.imgd\\.dev/i/[0-9a-f]{64}$"))' \
              "$response_file"
          )"
          hash="$(jq -er '.hash | select(test("^[0-9a-f]{64}$"))' "$response_file")"
          printf 'url=%s\n' "$url" >> "$GITHUB_OUTPUT"
          printf 'hash=%s\n' "$hash" >> "$GITHUB_OUTPUT"

      - name: Add the public image to the pull request
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
          GH_REPO: ${{ github.repository }}
          PR_NUMBER: ${{ github.event.pull_request.number }}
          SCREENSHOT_URL: ${{ steps.upload.outputs.url }}
        run: |
          set -euo pipefail
          body_file="$(mktemp)"
          trap 'rm -f "$body_file"' EXIT
          printf '### CI failure screenshot\n\n' > "$body_file"
          printf '![CI failure screenshot for pull request %s](%s)\n\n' \
            "$PR_NUMBER" "$SCREENSHOT_URL" >> "$body_file"
          printf '[Open the public image](%s)\n' "$SCREENSHOT_URL" >> "$body_file"
          gh pr comment "$PR_NUMBER" --repo "$GH_REPO" --body-file "$body_file"
```

The temporary response and comment files are removed on success and failure. Each cURL process closes when its command ends.

## Expected upload JSON

The upload step accepts HTTP `202` for new bytes and HTTP `200` for deduplicated bytes. A new upload has this complete JSON shape:

```json
{
  "hash": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "url": "https://i.imgd.dev/i/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "status": "processing",
  "mime": "image/png",
  "bytes": 18452,
  "width": 1440,
  "height": 900,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes"
}
```

The comment can show a blurred image for a short time. The same URL shows the full image after a `live` result.

## Verify the workflow

Open a same-repository pull request that has a controlled failing test. Confirm these facts without exposing a secret:

1. The test job fails.
2. The guarded screenshot file exists.
3. The publish job downloads only `failure.png`.
4. The upload step reports HTTP `200` or `202`.
5. The pull request gets one Markdown image comment.
6. The image URL returns an `image/*` response.

Also open a test pull request from a fork. Confirm that the `publish` job skips and receives no repository secret.

Do not use a real customer image for this check. Use a non-sensitive test page that your repository already owns.

## Recover from imgd.dev errors

The upload step prints the API `fix` text and fails the trusted job.

| Status | Meaning | Recovery |
| --- | --- | --- |
| `401` | `IMGD_KEY` is absent or invalid. | Replace the repository secret with the correct key. Do not print it. |
| `402` | The account has no storage. | Buy storage, confirm funding, and rerun the job. There is no free tier. |
| `402` | The account has insufficient quota. | Add the suggested storage or delete unused images, then rerun. |
| `413` | The screenshot exceeds 20 MB. | Reduce the screenshot size, then rerun. |
| `415` | The file is not an accepted PNG. | Produce a real PNG or change both the path and multipart media type. |
| `429` | The account exceeded 60 uploads per minute. | Wait for the next minute, then rerun. |

A normal `401` body has this base shape:

```json
{
  "error": "invalid_api_key",
  "fix": "this key is not recognized; create a new account with POST /v1/accounts"
}
```

Do not create an account from CI as automatic recovery. Restore the existing secret through the repository security process.

## Recover from GitHub errors

A `gh pr comment` failure with HTTP `403` usually means the job lacks `pull-requests: write`. It can also mean repository policy blocks the operation.

Keep `GH_TOKEN` scoped to the comment step. Use the generated `github.token`; do not add a personal access token for this task.

If the screenshot file does not exist, the workflow keeps the test failure and skips the upload. Fix the test tool path before the next run.

## Security notes

- Never send `IMGD_KEY` to the test job.
- Never run fork code in the publish job.
- Never use `pull_request_target` to execute a fork checkout.
- Keep `contents: read` and `pull-requests: write` at the job level.
- Keep `persist-credentials: false` on the checkout step.
- Do not print the key, response headers, or shell traces.
- Treat the screenshot and its URL as public.
- Remove secrets and personal data before screenshot capture.

## When imgd.dev fits

imgd.dev fits public CI evidence that needs a stable URL in a pull request. It also fits repeated identical screenshots because uploads are content-addressed.

It does not fit private test evidence, fork comments that require base secrets, files above 20 MB, or a free upload. Use a private artifact system for confidential evidence.

## Next guides

- [Capture and host a Puppeteer screenshot](/blog/puppeteer-screenshot-upload/).
- [Add hosted images to Markdown and README files](/blog/host-images-markdown-readme/).
- [Convert a hosted screenshot to WebP or AVIF](/blog/resize-convert-image-webp-avif/).

For an agent, use the [integration procedure](/integrate.md). For human setup, open [the start page](/#start). Read the [service evaluation](/evaluate.md) before you select a host.

## Sources

- [GitHub GITHUB_TOKEN authentication](https://docs.github.com/actions/reference/authentication-in-a-workflow)
- [GitHub pull request target security](https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target)
- [GitHub CLI pr comment manual](https://cli.github.com/manual/gh_pr_comment)
- [imgd.dev OpenAPI document](https://imgd.dev/openapi.json)
