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:

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.

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:

{
  "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:

{
  "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

For an agent, use the integration procedure. For human setup, open the start page. Read the service evaluation before you select a host.

Sources