CI/CD infinity loop on a pedestal, surrounded by code panels, a checklist, server racks, a dashboard, and AI platform icons

Web Development · Blog

How to Automate Your AI Visibility Audit in CI/CD: The Developer's Guide to GEO Testing

Most GEO audits are manual checks run once a month in a marketing dashboard. That's the wrong model for a development team — because the things that break AI visibility (a component switching from SSR to CSR, a schema block getting stripped by a build optimization, a robots.txt rule added without review) happen at deploy time, not between marketing meetings. This guide shows you how to run a full AI visibility audit on every deploy using free, open-source tools, fail the build when your AI readiness score drops below a threshold, and catch GEO regressions before they reach production — the same way you gate on Lighthouse scores and bundle size budgets today.

At BalochDev, we run this pipeline on every push to main. Here's exactly how it works.


Why manual GEO audits fail

A Lighthouse score catches performance regressions. An axe-core run catches accessibility regressions. Until recently, nothing caught GEO regressions — the class of change that makes your site invisible to AI crawlers, breaks your JSON-LD schema, or silently removes your llms.txt from the build output.

The four things that silently break AI visibility after a deploy

1. A content component gets the "use client" directive. In Next.js App Router, adding "use client" to a component that wraps your article body moves it from server-rendered to client-rendered. The content disappears from the initial HTML. AI crawlers receive an empty shell. Your Lighthouse score is unaffected. Your Google ranking is unaffected (Googlebot renders JS). Your AI visibility drops to zero.

2. A build optimization strips JSON-LD script tags. Some HTML minifiers and build plugins strip <script> tags with unrecognized types or inline JSON. <script type="application/ld+json"> is the target. The schema you've carefully implemented is gone from production. No error. No warning.

3. A robots.txt rule gets added incorrectly. A developer adds Disallow: /blog to block a staging path and forgets to scope it to a specific user-agent. Every AI crawler is now blocked from your entire blog. This is the most common catastrophic GEO regression in our audit data.

4. llms.txt gets excluded from the build output. Static files that aren't explicitly included in the build output directory get dropped silently. If llms.txt lives in public/ and a deployment script changes the output path, it disappears without any build error.

Why Lighthouse doesn't catch these

Lighthouse runs a Chrome instance that executes JavaScript, renders the full DOM, and evaluates the result. It's designed to audit the user experience. AI crawlers don't execute JavaScript. A Lighthouse score of 100 and a completely client-rendered article body can coexist on the same page. You need a different audit layer specifically for machine readability.


The open-source GEO audit stack

All of these are free and open-source. None require an account for local use.

GEO Optimizer — the CLI core

GEO Optimizer runs 16 CLI commands, 8 scoring categories, 47 research-backed methods, 7 output formats, and 1,720 tests, grounded in the Princeton "GEO" paper (KDD 2024) and AutoGEO (ICLR 2026). It works as a CI/CD pipeline tool, Python library, MCP server, and Astro integration.

Install:

bash

pip install geo-optimizer-skill

Or run without installing, via uv:

bash

uvx --from geo-optimizer-skill geo audit --url https://yourdomain.com

Basic audit — note the CLI binary is geo, not geo-optimizer, and the URL is a flag, not a positional argument:

bash

geo audit --url https://yourdomain.com --format json > geo-report.json

The 8 scoring categories it checks (100 points total): Robots.txt (18 pts), llms.txt (18 pts), Schema JSON-LD (16 pts), Meta Tags (14 pts), Content (12 pts), Brand & Entity (10 pts), Signals (6 pts), AI Discovery (6 pts). Run geo audit --url <your-url> --format json yourself to see the exact current JSON schema — field names have shifted across versions, so treat any example output as illustrative rather than a fixed contract.

Other commands worth knowing:

bash

# Save history and fail CI on regression
geo audit --url https://yourdomain.com --save-history --regression

# What changed since the last snapshot?
geo drift --url https://yourdomain.com --fail-on warning

# Ask real AI engines whether your brand is cited (requires your own API key)
geo citations --brand "YourBrand" --domain yourdomain.com --topic "your product category"

geo-lint — the content-level linter

geo-lint is a static linter for your markdown/MDX content — 92 rules split across GEO (35), SEO (32), content quality (14), technical (8), and i18n (3). It's a dev dependency that lints content files against a config, not a tool that scans a built ./dist directory directly.

bash

npm install -D @ijonis/geo-lint

Create geo-lint.config.ts:

typescript

import { defineConfig } from '@ijonis/geo-lint';

export default defineConfig({
  siteUrl: 'https://yourdomain.com',
  contentPaths: [
    { dir: 'content/blog', type: 'blog', urlPrefix: '/blog/' },
  ],
});

Run it:

bash

npx geo-lint                    # Human-readable output
npx geo-lint --format=json      # Machine-readable, for CI

Works out of the box with .md/.mdx files; other formats need a custom adapter.

foglift-scan — CI-native page scanner

foglift-scan is an MIT-licensed CLI that runs the same 8-dimension AI Readiness engine behind Foglift's hosted product, against any public URL — no account required for local scans.

bash

npm install -g foglift-scan
foglift scan https://yourdomain.com --json

Add a threshold to fail CI below a chosen score:

bash

foglift scan https://yourdomain.com --json --threshold=70

curl simulation — the five-bot access check

The simplest and most reliable test: simulate what each AI crawler actually receives.

bash

# Check access (should return 200, not 403)
curl -s -o /dev/null -w "%{http_code}" -A "OAI-SearchBot" https://yourdomain.com
curl -s -o /dev/null -w "%{http_code}" -A "PerplexityBot" https://yourdomain.com
curl -s -o /dev/null -w "%{http_code}" -A "ClaudeBot" https://yourdomain.com
curl -s -o /dev/null -w "%{http_code}" -A "GPTBot" https://yourdomain.com
curl -s -o /dev/null -w "%{http_code}" -A "Google-Extended" https://yourdomain.com

# Check content (article text should appear in raw HTML)
curl -s -A "OAI-SearchBot" https://yourdomain.com/blog/your-article | grep -c "<h2>"

If any bot returns 403, that's a CDN/WAF block that needs immediate investigation regardless of everything else passing. Note that GPTBot access governs training inclusion only — for ChatGPT citation eligibility specifically, OAI-SearchBot's result is the one that matters.


Setting up GEO Optimizer in GitHub Actions

The simplest path — the official Action

GEO Optimizer ships a ready-made GitHub Action that covers most of what a custom workflow would otherwise need to build:

yaml

# .github/workflows/geo.yml
- uses: Auriti-Labs/[email protected]
  with:
    url: https://yourdomain.com
    min-score: 70        # Fail the build if the GEO score drops below 70
    format: sarif         # Upload findings to the GitHub Security tab

This works with GitHub Actions, GitLab CI, Jenkins, CircleCI, and any CI that runs Python.

A fuller custom workflow

If you want more granular control — separate bot-access checks, rendering verification, and a PR comment — build it out manually:

Add a requirements-geo.txt to your repo root:

txt

geo-optimizer-skill>=4.16.0
requests>=2.31.0

Test locally first:

bash

pip install -r requirements-geo.txt
geo audit --url https://yourdomain.com --format json

Save as .github/workflows/geo-audit.yml:

yaml

name: GEO Visibility Audit

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 9 * * 1'   # Weekly Monday 09:00 UTC regardless of deploys

jobs:
  geo-audit:
    name: AI Visibility Audit
    runs-on: ubuntu-latest

    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Set up Python
        uses: actions/setup-python@v5
        with:
          python-version: '3.11'
          cache: 'pip'

      - name: Install GEO Optimizer
        run: pip install geo-optimizer-skill

      - name: Set audit URL
        id: url
        run: |
          if [ "${{ github.event_name }}" = "push" ]; then
            echo "AUDIT_URL=${{ vars.PRODUCTION_URL }}" >> $GITHUB_ENV
          else
            echo "AUDIT_URL=${{ vars.PREVIEW_URL }}" >> $GITHUB_ENV
          fi

      - name: Run GEO audit
        id: geo
        run: |
          geo audit --url "$AUDIT_URL" --format json > geo-report.json
          SCORE=$(cat geo-report.json | python3 -c "import sys,json; print(json.load(sys.stdin)['score'])")
          echo "GEO_SCORE=$SCORE" >> $GITHUB_ENV
          echo "score=$SCORE" >> $GITHUB_OUTPUT

      - name: Check AI crawler access
        id: bots
        run: |
          check_bot() {
            STATUS=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 -A "$1" "$AUDIT_URL/robots.txt")
            if [ "$STATUS" != "200" ]; then
              echo "::error::$1 blocked — HTTP $STATUS"
              exit 1
            fi
            echo "$1: $STATUS OK"
          }
          check_bot "OAI-SearchBot"
          check_bot "PerplexityBot"
          check_bot "ClaudeBot"
          check_bot "GPTBot"
          check_bot "Googlebot"

      - name: Verify content in server-rendered HTML
        id: rendering
        run: |
          CONTENT=$(curl -s -A "OAI-SearchBot" "$AUDIT_URL/blog/how-to-get-your-business-found-by-ai-search")
          if echo "$CONTENT" | grep -q "AI search engines cite"; then
            echo "✅ Content present in server-rendered HTML"
          else
            echo "::error::Article body not found in server-rendered HTML — possible CSR regression"
            exit 1
          fi
          if echo "$CONTENT" | grep -q "application/ld+json"; then
            echo "✅ JSON-LD schema present in HTML"
          else
            echo "::error::JSON-LD schema not found in HTML — schema may be JS-injected"
            exit 1
          fi

      - name: Verify llms.txt
        run: |
          STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$AUDIT_URL/llms.txt")
          if [ "$STATUS" = "200" ]; then
            echo "✅ llms.txt found (HTTP $STATUS)"
          else
            echo "::warning::llms.txt not found (HTTP $STATUS) — not critical but recommended"
          fi

      - name: Enforce GEO score gate
        run: |
          THRESHOLD=70
          echo "GEO Score: $GEO_SCORE / 100"
          if [ "$GEO_SCORE" -lt "$THRESHOLD" ]; then
            echo "::error::GEO score $GEO_SCORE is below threshold $THRESHOLD — deploy blocked"
            exit 1
          else
            echo "✅ GEO score $GEO_SCORE meets threshold $THRESHOLD"
          fi

      - name: Upload GEO audit report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: geo-audit-report
          path: geo-report.json
          retention-days: 30

      - name: Comment GEO score on PR
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            const score = process.env.GEO_SCORE;
            const threshold = 70;
            const emoji = score >= threshold ? '✅' : '❌';
            const body = `## ${emoji} GEO Visibility Audit\n\n**AI Readiness Score: ${score}/100** (threshold: ${threshold})\n\nDownload the full report from the Actions artifacts above.`;
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: body
            });

Setting the AI readiness score gate

ScoreMeaningAction90–100Fully citation-eligibleMaintain70–89Citation-eligible, gaps presentMonitor50–69Partial eligibilityFix before next deployBelow 50Critical failures presentBlock deploy

Start at 70. Once your baseline is above 80, raise the threshold to 80. This prevents score drift over time without blocking deploys on day one.


What the audit actually checks

Gate 1 — robots.txt and crawler access

Checks whether OAI-SearchBot, PerplexityBot, ClaudeBot, Claude-SearchBot, and ChatGPT-User are explicitly allowed or covered by a permissive wildcard, whether training-only crawlers are blocked without also blocking citation crawlers, and whether Googlebot and Google-Extended are configured correctly (they're separate directives).

Gate 2 — Rendering verification

Fetches key pages with OAI-SearchBot as the user-agent and checks that <main>/<article> is present and non-empty, that headings contain real text, and that JSON-LD blocks are present in the raw <head> response. Failure here is the highest-severity finding — everything else is irrelevant if the AI receives an empty page.

Gate 3 — JSON-LD schema completeness

Checks for Organization schema (name, url, description, sameAs), Article schema (headline, datePublished, dateModified, author, publisher), FAQPage schema, and BreadcrumbList schema in the raw HTML response.

Gate 4 — llms.txt presence and validity

Checks for a 200 response at /llms.txt, correct content type, and a valid structure (H1, summary, URL entries). Missing llms.txt is a warning, not a build-blocking failure.

Gate 5 — Content structure signals

Checks for an answer-first opening in the first ~80 words, direct-answer section openings, reasonable passage length, FAQ section presence, and at least one quantified claim per few hundred words.


Generating and validating llms.txt automatically

A minimal valid llms.txt at your domain root:

markdown

# BalochDev

> An AI-first software development studio building products for GCC and international markets.

## Key pages

- [Blog](https://balochdev.com/blog): Technical guides on AI search, GEO, and software engineering
- [Services](https://balochdev.com/services): What we offer

Generate one from your sitemap:

bash

geo llms --base-url https://yourdomain.com --output ./public/llms.txt

Verify it made it into the build:

bash

ls -la ./public/llms.txt
npx serve ./out & sleep 2 && curl http://localhost:3000/llms.txt

Astro integration

GEO Optimizer's Astro integration is published as astro-geoready and generates llms.txt, /.well-known/ai.txt, and /ai/summary.json from your built routes at build time — without overwriting hand-curated files.

bash

npm install astro-geoready

javascript

// astro.config.mjs
import { defineConfig } from 'astro/config';
import geoReady from 'astro-geoready';

export default defineConfig({
  site: 'https://yourdomain.com',
  integrations: [
    geoReady({ siteName: 'Your Site' })
  ]
});

GitLab CI

yaml

# .gitlab-ci.yml
geo-audit:
  stage: test
  image: python:3.11-slim
  before_script:
    - pip install geo-optimizer-skill
  script:
    - geo audit --url $PRODUCTION_URL --format json > geo-report.json
    - python3 -c "
        import json, sys
        report = json.load(open('geo-report.json'))
        score = report['score']
        print(f'GEO Score: {score}/100')
        sys.exit(0 if score >= 70 else 1)
      "
  artifacts:
    paths:
      - geo-report.json
    when: always
  only:
    - main

What to do when the audit fails

Rendering failure — content not in HTML

Diagnosis:

bash

curl -s -A "OAI-SearchBot" https://yourdomain.com/blog/your-article | \
  grep -o '<article>.*</article>' | wc -c

If output is 0 or very low: CSR regression.

Fix in Next.js App Router: find the offending component and remove 'use client' if it has no client-side interactivity — it must be a Server Component:

typescript

// Before (broken):
'use client'
export function ArticleBody({ content }) {
  return <div dangerouslySetInnerHTML={{ __html: content }} />
}

// After (fixed): remove 'use client'
export function ArticleBody({ content }) {
  return <div dangerouslySetInnerHTML={{ __html: content }} />
}

Schema validation failure

Diagnosis:

bash

curl -s https://yourdomain.com/blog/your-article | grep -A 50 'application/ld+json'

If nothing returns, schema is JS-injected. Move it to <head> in your layout template as a static string:

typescript

// app/blog/[slug]/page.tsx
export default async function BlogPost({ params }) {
  const post = await getPost(params.slug);
  const schema = {
    "@context": "https://schema.org",
    "@type": "Article",
    "headline": post.title,
    "datePublished": post.publishedAt,
    "dateModified": post.updatedAt,
  };
  return (
    <>
      <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema) }} />
      <article>{/* content */}</article>
    </>
  );
}

Crawler block detected

Fix sequence:

  1. Check robots.txt — is the bot explicitly blocked?

  2. Check Cloudflare → Security → Bots → Block AI bots toggle

  3. Check Cloudflare → AI Crawl Control → Search crawlers setting

  4. Check WAF custom rules for user-agent matching

  5. Verify with: curl -I -A "OAI-SearchBot" https://yourdomain.com

llms.txt missing from build output

Fix: Ensure the generation script runs as part of postbuild and outputs to the correct directory. In Next.js, static files must be in ./public/ to be served at the root URL.


BalochDev's GEO score benchmark

After running this pipeline on balochdev.com for 90 days, the most common regressions we've caught in CI before they reached production: JSON-LD schema stripped by HTML minification (twice), and a blog list page switching from SSG to CSR after a data-fetching refactor (once). Both would have been invisible without the GEO audit gate — Google rankings were unaffected in both cases.

Our current threshold is 75. We'd recommend starting at 70 and increasing by 5 points per quarter as your baseline improves. (These are our own operational figures from running this pipeline, not a third-party benchmark.)


Frequently asked questions

What does a GEO audit check that Lighthouse doesn't? Lighthouse audits the rendered DOM in a headless Chrome instance that executes JavaScript. AI crawlers fetch raw HTML without executing JavaScript. A GEO audit specifically tests the raw HTML response — what the machine receives, not what the browser renders. A site can score 100 on Lighthouse and very low on a GEO audit simultaneously.

Can I use these tools without a CI/CD pipeline? Yes. Run geo audit --url https://yourdomain.com from any terminal, or use uvx --from geo-optimizer-skill geo audit --url https://yourdomain.com with no install at all. The CI/CD integration automates what you'd otherwise run manually before each deploy.

What's a good AI readiness score to gate deploys on? Start at 70. This blocks genuine regressions (CSR failures, blocked crawlers, missing schema) without being so strict it slows down normal development. Once your baseline is consistently above 80, raise the threshold to 80.

Does the GitHub Action slow down build times? The GEO audit typically adds well under two minutes to your CI pipeline — comparable to a Lighthouse audit. Run it as a parallel job rather than sequentially with your build step to avoid adding to total deploy time.

How is GEO Optimizer different from paid tools like Foglift's hosted product or Profound? Paid GEO platforms add automated recurring tracking, historical trends, competitor monitoring dashboards, alerting, and exportable reports. GEO Optimizer's free CLI is a point-in-time audit tool — it tells you your current score and what's broken. Use it in CI to prevent regressions; consider a paid tool when you need ongoing monitoring and competitive intelligence.

Should I run the audit on every commit or only on production deploys? Run the pre-deploy static checks (llms.txt presence, schema in HTML, geo-lint content checks) on every commit — they're fast and catch regressions early. Run the full live audit (bot access, content retrieval, live scoring) only on pushes to main or production branches, since it requires a deployed URL.


Sources & further reading