Upload a Playwright failure screenshot by setting screenshot: "only-on-failure", then add a GitHub Actions step with if: failure(). The step must also confirm that a screenshot exists. It can upload the file and add the returned public URL to $GITHUB_STEP_SUMMARY.

Before the CI upload

imgd.dev costs $1 per GB as a one-time storage purchase. It has no free tier. Every imgd.dev image is public to anyone with its URL. Do not upload a failure screenshot that contains secrets, private test data, or customer data.

Store the funded account key as a GitHub repository secret named IMGD_KEY. Do not put the key in the workflow file.

You can add the secret in Settings → Secrets and variables → Actions. You can also use this command, which asks for the value without putting it in the command:

gh secret set IMGD_KEY

GitHub does not send repository secrets to workflows from fork pull requests. The workflow below treats a missing secret as a safe skip.

Configure Playwright failure screenshots

Playwright documents only-on-failure as a screenshot mode. It writes screenshots to the test output directory, which is usually test-results.

Merge these values into playwright.config.ts:

import { defineConfig } from "@playwright/test";

export default defineConfig({
  outputDir: "test-results",
  use: {
    screenshot: "only-on-failure",
  },
});

Keep your existing browser projects, base URL, retries, and other settings. The two required values are the screenshot mode and a known output directory.

Add the guarded GitHub Actions workflow

Save this file as .github/workflows/playwright.yml. The secret exists only in the upload step. The Playwright test step cannot read it.

name: Playwright tests

on:
  push:
    branches:
      - main
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 30

    steps:
      - name: Check out the repository
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: lts/*
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Install Playwright browsers
        run: npx playwright install --with-deps

      - name: Run Playwright tests
        run: npx playwright test

      - name: Upload one failure screenshot
        if: ${{ failure() }}
        shell: bash
        env:
          IMGD_KEY: ${{ secrets.IMGD_KEY }}
        run: |
          set -eu

          SCREENSHOT_PATH="$(
            find test-results -type f -name '*.png' -print -quit 2>/dev/null || true
          )"

          if [ -z "$SCREENSHOT_PATH" ] || [ ! -f "$SCREENSHOT_PATH" ]; then
            printf '### Playwright failure screenshot\n\nNo screenshot file was present.\n' \
              >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi

          if [ -z "${IMGD_KEY:-}" ]; then
            printf '### Playwright failure screenshot\n\nThe IMGD_KEY secret was unavailable.\n' \
              >> "$GITHUB_STEP_SUMMARY"
            exit 0
          fi

          RESPONSE_PATH="$(mktemp)"
          trap 'rm -f "$RESPONSE_PATH"' EXIT

          HTTP_STATUS="$(
            curl --silent --show-error \
              --output "$RESPONSE_PATH" \
              --write-out '%{http_code}' \
              --request POST 'https://imgd.dev/v1/upload' \
              --header "Authorization: Bearer $IMGD_KEY" \
              --form "file=@$SCREENSHOT_PATH"
          )"

          read_json_string() {
            node -e '
              const fs = require("node:fs");
              const body = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
              const value = body[process.argv[2]];
              if (typeof value !== "string" || value.length === 0) process.exit(1);
              process.stdout.write(value);
            ' "$RESPONSE_PATH" "$1"
          }

          case "$HTTP_STATUS" in
            200|202)
              ;;
            *)
              FIX="$(read_json_string fix 2>/dev/null || true)"
              if [ -n "$FIX" ]; then
                printf 'Screenshot upload failed with HTTP %s: %s\n' \
                  "$HTTP_STATUS" "$FIX" >&2
              else
                cat "$RESPONSE_PATH" >&2
              fi
              exit 1
              ;;
          esac

          HASH="$(read_json_string hash)"
          IMAGE_URL="$(read_json_string url)"

          METADATA_STATUS="$(
            curl --silent --show-error \
              --output /dev/null \
              --write-out '%{http_code}' \
              "https://imgd.dev/v1/images/$HASH" \
              --header "Authorization: Bearer $IMGD_KEY"
          )"
          test "$METADATA_STATUS" = "200"

          PUBLIC_STATUS="$(
            curl --silent --show-error \
              --output /dev/null \
              --write-out '%{http_code}' \
              "$IMAGE_URL"
          )"
          test "$PUBLIC_STATUS" = "200"

          {
            printf '### Playwright failure screenshot\n\n'
            printf '[Open the public screenshot](%s)\n\n' "$IMAGE_URL"
            printf 'Upload response: HTTP `%s`\n' "$HTTP_STATUS"
          } >> "$GITHUB_STEP_SUMMARY"

The official GitHub documentation defines failure() as true after a prior step fails. The file check prevents an empty upload after setup failures.

The job summary uses GitHub-flavored Markdown. It contains only the public URL and the upload status. It does not contain the key.

Expected upload JSON

A new failure screenshot returns HTTP 202 with this shape:

{
  "hash": "0000000000000000000000000000000000000000000000000000000000000000",
  "url": "https://i.imgd.dev/i/0000000000000000000000000000000000000000000000000000000000000000",
  "status": "processing",
  "mime": "image/png",
  "bytes": 117204,
  "width": 1280,
  "height": 720,
  "alt_text": null,
  "unpublish_at": null,
  "note": "the url works immediately, serving a blurred placeholder until the moderation check finishes and it sharpens to the real image"
}

The public URL returns HTTP 200 immediately. It serves a blurred placeholder while moderation has the processing status.

Identical bytes return HTTP 200. That response includes deduplicated: true, and it uses the same content-addressed URL.

Verify the workflow

Use a test branch with a controlled failing assertion. Do not use production credentials or private test data.

After the job ends, open the workflow summary. Confirm these results:

  1. The Playwright step failed.
  2. The upload step found a PNG under test-results.
  3. The summary contains one public link.
  4. The link returns HTTP 200 without authentication.
  5. The logs do not show the key.

The metadata can later reach live, review, blocked, or error. A review state is terminal and remains blurred.

Recover from API errors

The workflow reads the server fix field and writes it to standard error. It does not print the request header.

Status Cause Action
401 The repository secret is absent or invalid. Replace the secret. Do not add the value to YAML or logs.
402 The account has no storage or has full storage. Buy one-time storage or delete unused images. Then rerun the job.
413 The screenshot is larger than 20 MB. Reduce the viewport, capture area, or PNG size.
415 Playwright produced an unsupported file type. Upload JPEG, PNG, GIF, WebP, or AVIF with the correct media type.

Do not retry these four responses without the required change. If the network fails after the server accepts bytes, rerun the step. Identical bytes produce the same URL.

If the key is empty on a fork pull request, the step writes a skip message. It does not change the failed test result.

Protect the repository and screenshot

Use the pull_request event for untrusted fork code. Do not use pull_request_target to run untrusted code with this secret.

Keep permissions: contents: read unless another step needs more access. The upload needs no GitHub write permission.

Treat the screenshot as a public release. Test pages can expose session values, user records, network data, and internal host names.

Decide if this workflow fits

This workflow fits public failure evidence from safe test environments. It gives each failed job a stable URL without a separate artifact download.

It does not fit confidential tests, private screenshots, files above 20 MB, unsupported formats, or teams that require a free tier. Read the service evaluation guide before adoption.

Result

A failed Playwright job now adds a verified public screenshot link to its summary. The key remains scoped to one guarded step.

For local evidence, use the command-line screenshot guide. For a reusable agent command, use the coding-agent screenshot guide.

Give an agent the integration procedure. A person can start from the home page.