Self-hostable GitHub Action that runs a 2027.dev/evals agent-experience eval against your PR's preview deployment, then posts the result as a sticky PR comment + commit status.
Use this action if you want to keep GitHub creds inside your own runners and
avoid installing the managed 2027-evals GitHub App. The action authenticates
to 2027 with a per-org API key; the runtime GITHUB_TOKEN posts the comment
and commit status.
If you'd rather we manage everything end-to-end, install the 2027 Evals GitHub App instead — same UX, no workflow YAML required.
GITHUB_TOKEN is read-only on PRs from forked repositories, which means the
action cannot post comments or commit statuses. The action detects this case
and exits cleanly without starting an eval (so you don't burn budget on a
no-op).
If you want evals on fork PRs, you have two options:
pull_request_targettrigger. Runs in the context of the base repo with full secrets and a writable token. Read GitHub's security advisory first —pull_request_targetis dangerous if you check out untrusted code.- Skip the action on fork PRs. Add this guard:
if: github.event.pull_request.head.repo.full_name == github.repository
Go to https://2027.dev/evals/<orgDomain>/settings, scroll to API Keys,
name it (e.g. CI pipeline) and click Create key. The key is shown once —
copy it and save it as EVALS_API_KEY in your repo secrets
(Settings → Secrets and variables → Actions).
Open your prompt in the dashboard
(https://2027.dev/evals/<orgDomain>/prompts/<id>) and copy the UUID from the
prompt-id block under the title.
You can also list all prompt IDs via the API:
curl -H "Authorization: Bearer $EVALS_API_KEY" \
https://2027.dev/evals/api/v1/promptsPick the recipe that matches your preview platform:
| Platform | Recipe |
|---|---|
| Vercel, Mintlify, anything using GitHub's Deployments API | on: deployment_status (below) |
| Netlify | on: status with context filter (below) |
| Anything else, or you want full control | Run after your own deploy step (below) |
Triggers on the platform's success deployment event — target_url is the preview URL.
# .github/workflows/eval.yml
name: 2027 eval
on:
deployment_status:
jobs:
eval:
if: github.event.deployment_status.state == 'success'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: team2027/evals-action@v0.2.0
with:
api-key: ${{ secrets.EVALS_API_KEY }}
prompt-id: 12345678-1234-1234-1234-1234567890ab
url-map: |
{ "acme.com": "${{ github.event.deployment_status.target_url }}" }Netlify posts a legacy commit status (not a Deployment), so we trigger on status events and filter by context.
name: 2027 eval
on:
status:
jobs:
eval:
if: |
github.event.state == 'success' &&
contains(github.event.context, 'netlify/deploy-preview')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: team2027/evals-action@v0.2.0
with:
api-key: ${{ secrets.EVALS_API_KEY }}
prompt-id: 12345678-1234-1234-1234-1234567890ab
url-map: |
{ "acme.com": "${{ github.event.target_url }}" }Works on any platform — the action runs as a step right after your existing deploy step and consumes its output.
name: 2027 eval
on:
pull_request:
types: [opened, synchronize]
jobs:
deploy-and-eval:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- id: deploy
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
- uses: team2027/evals-action@v0.2.0
with:
api-key: ${{ secrets.EVALS_API_KEY }}
prompt-id: 12345678-1234-1234-1234-1234567890ab
url-map: |
{ "acme.com": "${{ steps.deploy.outputs.preview-url }}" }| Name | Required | Default | Description |
|---|---|---|---|
api-key |
yes | — | 2027 API key (store as repo secret) |
api-base-url |
no | https://2027.dev/evals |
Override for self-hosted evals deployments |
prompt-id |
yes | — | Prompt UUID. List via GET /api/v1/prompts. |
url-map |
yes | — | JSON object mapping production hostnames to preview URLs. Values must be full http(s) URLs (not bare hostnames). |
deployment-url |
no | first url-map value |
Required when url-map has more than one entry |
wait-timeout-minutes |
no | 20 |
Poll for at most this many minutes before exiting |
poll-interval-seconds |
no | 20 |
Seconds between status polls (used as base for backoff) |
timeout-fails |
no | false |
When true, a polling timeout marks the commit status as failure (blocks merge). Default false marks it success so checks don't get stuck pending. |
skip-comment |
no | false |
When true, the action does not post the sticky PR comment. Use this if you want to render your own comment from the outputs. |
skip-status |
no | false |
When true, the action does not set the commit status. Use this if you want to set your own status from the outputs. |
github-token |
no | ${{ github.token }} |
Token used to post the PR comment + commit status |
| Name | Description |
|---|---|
run-id |
UUID of the eval run on 2027 |
status-url |
API endpoint that reflects the run's current state |
final-status |
Terminal state observed before the action exited: completed, failed, superseded, or running (on timeout) |
prompt-title |
Human-readable title of the evaluated prompt |
report-slug |
Report slug if the run produced one, empty string otherwise |
report-url |
Full URL to the dashboard report page, empty string if no report |
failure-reason |
Server-provided failure reason if the run failed, empty string otherwise |
score |
Final score (0-100) when the run produced a report, empty string otherwise |
grade |
Final letter grade when the run produced a report, empty string otherwise |
baseline-score |
Score of the most recent prior published report for the same prompt, empty string if no baseline |
report-json |
Full report object as stringified JSON ({slug, url, score, grade, metrics, dimensions}). Forward-compatible — picks up new API fields without an action release. Empty string when no report. |
baseline-json |
Baseline object as stringified JSON ({score, grade}). Empty string when no baseline. |
Set skip-comment and/or skip-status to true and consume the outputs from a downstream step:
- id: eval
uses: team2027/evals-action@v0.2.0
with:
api-key: ${{ secrets.EVALS_API_KEY }}
prompt-id: 12345678-1234-1234-1234-1234567890ab
url-map: |
{ "acme.com": "${{ github.event.deployment_status.target_url }}" }
skip-comment: true
- uses: actions/github-script@v9
with:
script: |
const status = '${{ steps.eval.outputs.final-status }}'
const title = '${{ steps.eval.outputs.prompt-title }}'
const reportUrl = '${{ steps.eval.outputs.report-url }}'
const failure = '${{ steps.eval.outputs.failure-reason }}'
const score = '${{ steps.eval.outputs.score }}'
const grade = '${{ steps.eval.outputs.grade }}'
const baseline = '${{ steps.eval.outputs.baseline-score }}'
const delta = score && baseline ? ` (${Number(score) - Number(baseline) >= 0 ? '+' : ''}${Number(score) - Number(baseline)} vs baseline)` : ''
const body = status === 'completed' && reportUrl
? `🎉 **${title}** — ${grade} ${score}/100${delta} → [report](${reportUrl})`
: status === 'failed'
? `💥 **${title}** failed: ${failure}`
: `⏱ **${title}** still running`
await github.rest.issues.createComment({
...context.repo,
issue_number: context.payload.pull_request.number,
body,
})- The action calls
POST /api/v1/prompts/<prompt-id>/runto create a run, then pollsGET /api/v1/runs/<run-id>until completion or until thewait-timeout-minutesbudget expires. The PR comment and commit status are rendered inside the action from the response (status,prompt.title, optionalreport, optionalfailureReason). - On
completed→ commit statussuccess, links to the report when available, otherwise to the status page. - On
failed→ commit statuserror, action fails the build with the server'sfailureReason. - On
superseded→ commit statussuccess(a newer commit replaced this run). - On timeout → by default, commit status becomes
successwith a "still running" description so the check doesn't stay stuck pending; settimeout-fails: trueto usefailureinstead. Action exits 0 either way. - Polling resilience. Auth/lookup errors (
401/403/404) bail immediately.5xxand network errors get exponential backoff with jitter capped at 60s, honoringRetry-Afterif the server sends it.
The PR comment is sticky — re-runs of the same prompt update the same comment
via the marker <!-- 2027-eval-comment:<promptId> -->.
- Poll mode only. The action stays running for the duration of the eval. Fire-and-forget mode (queue completes asynchronously and pings back via webhook) is planned.
- Single prompt per call. Use
strategy.matrixin your workflow to fan out across multiple prompts.
api-base-url defaults to https://2027.dev/evals, the managed production
deployment. The input exists so the action can point at a different API host
in the future (self-hosted evals, staging) — there's no public alternative
host today.
The action logs the resolved API base on the first line of its output, so you can verify which deployment your CI is hitting.
Distributed from team2027/evals-action. Developed alongside the public REST API in team2027/evals — issues that span both repos are filed there.