CI/CD (before deploy)

Scan preview URLs on every pull request, get deterministic artifacts, and block regressions with a single API call.

What CI/CD with SEODiff does

Quickstart: GitHub Actions (API-based)

This workflow calls POST /api/v1/validate and fails the job when SEODiff returns pass: false.

Prerequisites: curl and jq are available by default on ubuntu-latest.

name: SEODiff

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write

jobs:
  seodiff:
    runs-on: ubuntu-latest
    steps:
      - name: Determine preview URL
        id: preview
        run: |
          # Replace this with your deployment step output
          echo "url=https://preview.example.com" >> "$GITHUB_OUTPUT"

      - name: Validate preview with SEODiff
        id: seodiff
        env:
          SEODIFF_API_KEY: ${{ secrets.SEODIFF_API_KEY }}
          SEODIFF_BASE_URL: ${{ steps.preview.outputs.url }}
        run: |
          set -euo pipefail

          HTTP_CODE=$(curl -sS -o seodiff.json -w "%{http_code}" -X POST "https://api.seodiff.io/api/v1/validate" \
            -H "Authorization: Bearer $SEODIFF_API_KEY" \
            -H "Content-Type: application/json" \
            -d "{\
              \"base_url\": \"$SEODIFF_BASE_URL\",\
              \"preset\": \"fast\",\
              \"fail_on\": \"fetch_errors,non200_status,schema_missing_required,placeholder_hits\",\
              \"max_issue_rate\": 10,\
              \"wait\": true,\
              \"timeout_seconds\": 180\
            }")

          echo "http_code=$HTTP_CODE" >> "$GITHUB_OUTPUT"
          echo "report_url=$(jq -r '.report_url // ""' seodiff.json)" >> "$GITHUB_OUTPUT"
          echo "pass=$(jq -r '.pass // false' seodiff.json)" >> "$GITHUB_OUTPUT"

          # 200 = pass, 409 = fail, 202 = timed out waiting
          if [ "$HTTP_CODE" != "200" ]; then
            echo "SEODiff failed (http=$HTTP_CODE): $(jq -r '.reason // ""' seodiff.json)"
            cat seodiff.json
            exit 1
          fi
Why validate instead of scan?

Use /validate when you want a single request that returns a clear pass/fail decision. Use /scan when you want to enqueue work and poll later.

Add a PR comment (optional)

Fetch a PR-comment-ready Markdown summary and post it as a comment.

      - name: Comment on PR
        if: always()
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          SEODIFF_API_KEY: ${{ secrets.SEODIFF_API_KEY }}
        run: |
          SUMMARY_PATH=$(jq -r '.summary_markdown_url // ""' seodiff.json)
          if [ -n "$SUMMARY_PATH" ] && [ "$SUMMARY_PATH" != "null" ]; then
            curl -sS -H "Authorization: Bearer $SEODIFF_API_KEY" "https://api.seodiff.io$SUMMARY_PATH" > seodiff_summary.md
            gh pr comment ${{ github.event.pull_request.number }} --body-file seodiff_summary.md
          else
            PASS=$(jq -r '.pass // false' seodiff.json)
            REASON=$(jq -r '.reason // ""' seodiff.json)
            REPORT=$(jq -r '.report_url // ""' seodiff.json)

            BODY="## SEODiff\n\n**Pass:** ${PASS}\n\n${REASON}\n\n${REPORT}"
            gh pr comment ${{ github.event.pull_request.number }} --body "$BODY"
          fi

Failure rules (recommended defaults)

Start strict on the basics

  • fetch_errors and non200_status
  • schema_missing_required
  • placeholder_hits

Then expand coverage

  • Add more keys once templates stabilize.
  • Lower max_issue_rate gradually.
  • Consider a regression-only gate once baseline-by-API exists.

PR-friendly output

Common setup mistakes

Planned improvements (not available yet)

Related