// action · overview

GitHub Action

the suite as a merge gate

The EvalShift GitHub Action turns your golden suite into a merge gate. On every pull request it runs the suite against both models, pushes the result to EvalShift Cloud, compares it to the latest run on your base branch, and — by default — fails the check when your migration policy says the candidate is not safe to ship. It is a composite action that installs Python and the pinned CLI — the evalshift-version default in action.yml, 1.6.2 at the time of writing, is bumped by the action’s pin workflow after each CLI release — nothing compiled, nothing containerised. What it adds on top of the CLI: a plan preflight, Cloud push, baseline lookup, cross-branch diff, the policy verdict, one self-updating PR comment, a commit status, and an exit code.

The Action is open source under MIT — the code lives at evalshift/evalshift-action ↗.

+
optional — but recommended
Like EvalShift Cloud, which it builds on, the Action is strictly opt-in: the CLI and SDK work fully without it, and evalshift compare --policy-gate (or --gate critical,high) gives you local-only CI gating for free. But the full setup we recommend for teams is all three — SDK capture, CLI evaluation, and the Action as the PR gate. See the recommended workflow.

Who this is for

You already have a golden suite and you run it by hand before shipping a model or prompt change. That works right up until it doesn’t: someone edits a system prompt on a Friday, nobody re-runs the suite, and the regression ships. The Action closes that gap — it makes “did this change make the model worse?” a required check, answered by the same statistics you’d get locally, on a pull request, before anyone can merge.

If you don’t have a suite yet, start with the CLI — Getting started gives you a working project in one command. Come back here once evalshift compare passes locally.

Prerequisites

RequirementHow to get it
evalshift.yaml committedevalshift init writes a capture-first starter
A golden JSONL suite committedevalshift capture sync promotes recorded production captures into .evalshift/suites/<name>/ and wires each under suites: — select it with suite-name. If .evalshift/ is gitignored, change that line to .evalshift/* and add !.evalshift/suites/ and !.evalshift/toolsets/ (a tool-calling suite’s sidecars) so both can be committed. Or hand-write golden.jsonl at the repository root, which the Action uses when no suite is named.
Repository secret EVALSHIFT_TOKENA service-account key, not a personal token: EvalShift Cloud → Settings → API tokens → Service accounts, role member, scopes run:create + run:read + policy:read. Starts with es_. See Cloud setup.
A migration_policy block in evalshift.yamlWhat the default fail-on: policy gates on. evalshift init scaffolds one; without it the run is reported as ungated. See Migration policy.
Provider API keys as repository secretsOne per provider your config calls — the source and target models, plus the judge and embedding models. See Secrets and provider keys.
Any repository, public or privatePrivate repositories are included on every plan, the 30-day trial included. The Action still reports the repository’s visibility on every run. See pricing.

Verify locally first. If evalshift compare --yes doesn’t pass on your machine, it will not pass on a runner — you’ll just pay for the model calls to find out.

Quick start

Create .github/workflows/evalshift.yml:

yaml
name: evalshift

on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read
  pull-requests: write
  issues: write
  statuses: write

jobs:
  evalshift:
    runs-on: ubuntu-latest
    env:
      EVALSHIFT_NONINTERACTIVE: "1"
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} # the embedder init borrows for semantic
    steps:
      - uses: actions/checkout@v7
      - uses: evalshift/evalshift-action@v0
        with:
          token: ${{ secrets.EVALSHIFT_TOKEN }}
          suite-name: support_agent # the suites: key capture sync wrote; drop it for a root golden.jsonl
          fail-on: policy # the default; gates on your migration policy

evalshift init --ci scaffolds a fuller, per-suite version of this: a discover job that lists the suites under .evalshift/suites/, one job per suite selecting it with suite-name, a pinned evalshift-version, your provider keys mapped from repository secrets, and an evalshift gate job that collapses them into the one check to require in branch protection.

Keep the push: branches: [main] trigger. The default fail-on: policy judges each run against its migration policy and needs no baseline, but the diff in the PR comment — and the diff-based regression and any-slice-regression modes — compare against the most recent run on your base branch. Without trunk runs, every PR reports “no baseline”, and the diff-based modes pass unconditionally.

+
the first PR has no diff
The comment says no compatible baseline was found. That’s correct behavior, not a misconfiguration — there’s no trunk run yet to diff against. The policy verdict still applies. Merge it, let the push trigger record a baseline on main, and the next PR gets a real comparison.

Secrets and provider keys

The Action does not manage provider credentials. It passes the job environment through to the CLI unchanged, so set the key as a job-level env: entry (ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY, DEEPSEEK_API_KEY) and the CLI picks it up. Which keys you need follows from your config: defaults.source_model and defaults.target_model, plus each llm_judge’s judge_model and semantic’s embedding_model — comparing across two providers means both keys:

yaml
    env:
      EVALSHIFT_NONINTERACTIVE: "1"
      OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
      ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}

Anthropic and DeepSeek have no embeddings endpoint, so evalshift init points semantic at OpenAI’s or Gemini’s, and that key belongs in env: too. The CLI checks every key before the first model call: a missing model key, or one for a blocking evaluator, fails the run; an advisory (blocking: false) evaluator without its key is skipped with a warning. Passing locally proves only the keys your shell exports — map each one into the job, too.

EVALSHIFT_NONINTERACTIVE: "1" is recommended. The Action already passes --yes, which skips the CLI’s cost confirmation, but the env var covers any other prompt — and a prompt on a runner means a hung job.

Where to next