A technical, static AEO audit for CI. It builds your site, audits the rendered HTML offline (no deploy, no live crawl, no secrets — fully deterministic), and fails the PR when Answer Engine Optimization signals regress against a committed baseline. Add it with
uses: Canonry/aeo-audit-action@v4— it pulls the@ainyc/aeo-auditengine from npm at runtime, so there's no install step for consumers.
- Technical, not editorial. It scores the machine-checkable signals that decide whether an AI answer engine can parse, trust, and cite a page — structured data / JSON-LD, schema completeness & validity,
<title>and meta description, a single clean<h1>, heading structure, crawler access (robots.txt,llms.txt), content extractability, snippet eligibility, named entities, and more. It does not grade writing quality or rank you against competitors. - Static, not a live crawl. It runs against your built HTML (
./out,dist/,public/) — the exact files you deploy — parsed offline with zero network I/O. No staging URL, no secrets, no flaky live fetches: the same commit always scores the same. - A gate, not just a report. Every PR is diffed against a baseline with the engine's typed-and-tested
comparesubcommand, and the build fails on a real regression — a dropped score, a page that stopped auditing, a new structural defect, or a per-factor slide the aggregate hides — with the per-factor diff posted as a sticky PR comment.
# .github/workflows/aeo.yml
name: AEO Guard
on:
pull_request: { branches: [main] }
merge_group: # don't wedge the merge queue (see "Required checks")
permissions:
contents: read # never inherit write-all
concurrency: # cancel superseded runs on the same PR
group: aeo-${{ github.ref }}
cancel-in-progress: true
jobs:
aeo-gate:
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: write # only this job; for the sticky comment
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4 # the engine needs Node >= 20
with: { node-version: 20, cache: pnpm }
- uses: Canonry/aeo-audit-action@v4
with:
mode: static
build-command: "pnpm install --frozen-lockfile && pnpm run build"
target: "./out" # Next `output: export` / Astro `dist` / Hugo `public`
base-url: "https://www.example.com" # REQUIRED in practice — see "Set base-url"
baseline: committed
baseline-path: ".aeo/baseline.default.json"On the first run there is no committed baseline, so the gate passes and the comment tells you to seed one. Generate it and commit it:
npx @ainyc/aeo-audit@4 ./out --base-url https://www.example.com --format json > .aeo/baseline.default.json
git add .aeo/baseline.default.json && git commit -m "chore: seed AEO baseline"From then on, every PR is measured against that committed baseline.
A build fails when, beyond the configured tolerances, any of these happen (all configurable):
| Dimension | Default gate | Notes |
|---|---|---|
| Overall / aggregate score drop | > overall-tolerance (2) |
The headline number. |
| Single-page score drop | > page-tolerance (5) |
Catches one page tanking while the aggregate hides it. |
| Single-factor score drop | > factor-tolerance (8) |
e.g. structured-data sliding while content rises. |
| A page stops auditing | always | success → error is the strongest regression — and the aggregate (mean of success pages) would otherwise mask it. |
New severity:critical defect |
on (fail-on-new-critical) |
missing-h1, multiple-h1, missing-title. A known template defect arriving on a new page is report-only, not a regression. |
| Major report-schema change | always | Regenerate the baseline. |
| Removed pages / new warnings | report-only | Promote with fail-on: removed-pages,warnings. missing-meta-description is a warning — use require-meta or fail-on: warnings. |
Score, page, and factor deltas only gate when the two runs are comparable (same factor set, no major engine change); otherwise they're reported with a loud warning instead of failing. Defaults are deliberately noise-aware so the gate stays trusted; loosen any single tolerance without disabling the gate.
When you meant to change content and the score legitimately moved, refresh the committed baseline in the same PR — the reviewer approves the score change as a normal file diff:
npx @ainyc/aeo-audit@4 ./out --base-url https://www.example.com --format json > .aeo/baseline.default.jsonThe PR comment always prints this exact command. Avoid loosening a global tolerance for a one-off drop — that permanently weakens the gate for every future PR.
committed(default) — a JSON report committed atbaseline-path. Deterministic, reviewable, secret-free, and the accept-a-drop flow is just a file diff. Refresh it on merge with a separate job (below).base-rebuild— re-audit the PR's base SHA in the same run (needsactions/checkoutwithfetch-depth: 0). No stored artifact, always fresh, but ~2× build time and the base build must be reproducible in a worktree (use an isolated dependency store to avoid contaminating it with the head'snode_modules).artifact— download the latest successful default-branch baseline artifact (needsactions: read). Cheaper than base-rebuild, subject to artifact retention.
aeo-baseline:
if: github.event_name == 'push'
runs-on: ubuntu-latest
permissions:
contents: write # open the refresh PR
pull-requests: write
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- uses: Canonry/aeo-audit-action@v4
with:
mode-of-run: update-baseline
build-command: "pnpm install --frozen-lockfile && pnpm run build"
target: "./out"
base-url: "https://www.example.com"
baseline: committed
baseline-path: ".aeo/baseline.default.json"
comment: "false"
- uses: peter-evans/create-pull-request@v6
with:
add-paths: .aeo/baseline.default.json
branch: chore/aeo-baseline
commit-message: "chore: refresh AEO baseline"
title: "chore: refresh AEO baseline"It opens a reviewable PR rather than force-pushing the baseline to your default branch.
Give each site a site-id (namespaces the baseline path, the artifact name, and the sticky comment) and run a matrix:
strategy:
fail-fast: false
matrix:
site:
- { id: marketing, build: "pnpm --filter ./apps/marketing build", out: apps/marketing/out, url: "https://example.com" }
- { id: docs, build: "pnpm --filter ./apps/docs build", out: apps/docs/dist, url: "https://docs.example.com" }
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with: { version: 9 }
- uses: actions/setup-node@v4
with: { node-version: 20, cache: pnpm }
- uses: Canonry/aeo-audit-action@v4
with:
site-id: ${{ matrix.site.id }}
build-command: "pnpm install --frozen-lockfile && ${{ matrix.site.build }}"
target: ${{ matrix.site.out }}
base-url: ${{ matrix.site.url }}In static mode base-url maps files to real page URLs (out/about/index.html → <base>/about/) — it makes canonical / og:url checks meaningful and gives stable per-page diff keys. The default https://localhost will depress absolute scores. Set it to your production origin, identical on both sides.
- Use
pull_request, neverpull_request_target. On fork PRs the token is read-only with no secrets, so building the PR's code is safe-ish (the comment can't post and falls back to the job summary). On same-repo branch PRs the token and secrets ARE available while this action runs yourbuild-command— keep the gate job'spermissionsminimal (contents: read+pull-requests: write) and put no deploy/npm/cloud secrets in that job. build-command,target,base-url, andaudit-argsare trusted maintainer config — never wire PR-author-controlled values into them. Thewith:block is part of the PR diff, so protect.github/workflowswith branch/required-workflow rules.audit-argsrejects the SSRF-relaxing flags (--allow-local,--allow-private,--rewrite-sitemap-origin) and--lighthousein static mode. Defaultstaticmode does no network I/O at all.- The engine is pinned to
@ainyc/aeo-audit@4(never@latest). Pin this action and the third-party actions above to commit SHAs for full supply-chain protection.
Run the action on pull_request and merge_group so a required status check is produced for both PRs and the merge queue. Don't make the job conditional on the event in a way that skips it entirely — a required check that never reports leaves the merge queue pending forever.
See action.yml for the full list. Key outputs: verdict (pass/fail), result (pass/regression/improvement/no-baseline), score, delta, regression-count, regressed-factors, report-json, regression-json, comment-url. In update-baseline runs the gate outputs are empty.
0 clean / improvement / first-run-no-baseline; 1 regression (or below min-absolute-score, or require-meta failure); 2 misconfiguration (mode mismatch, unreadable report, incomparable factor-set/engine for a committed/artifact baseline, or a rejected audit-args flag).