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:
- The
testjob has onlycontents: read. - The
testjob does not receiveIMGD_KEY. - The screenshot moves between jobs only as workflow data.
- The
publishjob requestspull-requests: write. - The
publishjob runs only for a branch in the base repository. - The
publishjob does not check out project code. - 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 '\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:
- The test job fails.
- The guarded screenshot file exists.
- The publish job downloads only
failure.png. - The upload step reports HTTP
200or202. - The pull request gets one Markdown image comment.
- 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_KEYto the test job. - Never run fork code in the publish job.
- Never use
pull_request_targetto execute a fork checkout. - Keep
contents: readandpull-requests: writeat the job level. - Keep
persist-credentials: falseon 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.
- Add hosted images to Markdown and README files.
- Convert a hosted screenshot to WebP or AVIF.
For an agent, use the integration procedure. For human setup, open the start page. Read the service evaluation before you select a host.