Connecting CI
evalship reads eval results from GitHub Actions workflow artifacts. When a workflow run in your repository completes, GitHub sends the evalship App a workflow_run event with the action completed. evalship downloads that run's artifacts, parses the result files inside, and processes the results for the commit.
There is no evalship action to add, and no token or secret to configure.
Requirements
- The eval workflow runs on
pull_requestand onpushto the default branch. Pull request runs get the comment. Default branch runs are the baseline that pull requests are compared against; without them, every pull request looks like a first run. - The eval command writes a results file: JUnit XML, promptfoo JSON or evalship JSON. See Result formats.
- An
actions/upload-artifact@v4step withif: always()uploads the file. Withoutif: always()the upload is skipped when an eval fails, which is the run you most want reported.
- uses: actions/upload-artifact@v4
if: always()
with:
name: evalship-results
path: eval-results.xml
Artifact names
The name evalship-results is recommended, not required. By default evalship scans every artifact of every completed workflow run for result files, so a workflow that already uploads JUnit XML needs no change.
To read only some artifacts, list name globs in .evalship.yml:
results:
artifacts:
- evalship-results
- "evals-*"
What evalship reads
| Item | Limit |
|---|---|
| Artifact size | Up to 20 MB. Larger artifacts are skipped. |
| File types | .xml and .json files inside the artifact. Other files are ignored. |
| Result file size | Up to 10 MB per file. |
| Cases per file | Up to 20,000. |
.xml and .json files that aren't eval results, such as package.json or a coverage report, are skipped. If an artifact whose name contains eval, junit, result, test or promptfoo holds no readable result file, the report page shows "Results aren't reaching evalship" with the workflow and artifact names.
To upload several files, list them or upload a directory:
with:
name: evalship-results
path: |
eval-results.xml
promptfoo-results.json
Examples
Each example writes a results file and uploads it. Add the env your evals need.
pytest
- run: pytest tests/evals --junitxml=eval-results.xml -o junit_family=xunit1
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: evalship-results
path: eval-results.xml
-o junit_family=xunit1 keeps record_property values in the XML, which is how scores and thresholds reach evalship. See Result formats.
promptfoo
- run: npx promptfoo@latest eval -c promptfooconfig.yaml -o eval-results.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: evalship-results
path: eval-results.json
Vitest
- run: npx vitest run evals --reporter=junit --outputFile=eval-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: evalship-results
path: eval-results.xml
Jest
Install jest-junit as a dev dependency.
- run: npx jest evals --ci --reporters=default --reporters=jest-junit
env:
JEST_JUNIT_OUTPUT_FILE: eval-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: evalship-results
path: eval-results.xml
RSpec
Add gem "rspec_junit_formatter" to the test group of your Gemfile.
- run: bundle exec rspec spec/evals --format progress --format RspecJunitFormatter --out eval-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: evalship-results
path: eval-results.xml
Go
- run: go install github.com/jstemmer/go-junit-report/v2@latest
- run: go test -v ./evals/... 2>&1 | go-junit-report -set-exit-code > eval-results.xml
- uses: actions/upload-artifact@v4
if: always()
with:
name: evalship-results
path: eval-results.xml
Full workflow
A complete .github/workflows/evals.yml for a Python repository with main as the default branch:
name: evals
on:
pull_request:
push:
branches: [main]
jobs:
evals:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -r requirements.txt
- run: pytest tests/evals --junitxml=eval-results.xml -o junit_family=xunit1
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- uses: actions/upload-artifact@v4
if: always()
with:
name: evalship-results
path: eval-results.xml
Several workflows or artifacts
Results are grouped by commit. All artifacts from all workflow runs for the same commit SHA merge into one run. evalship processes it about 15 seconds after the latest artifact arrives; if another workflow finishes later, its results are added and the comment is updated. When two artifacts report the same suite and key, the one read last wins.
A push run on a branch with an open pull request counts for that pull request when the commit is the pull request's head.
Fork pull requests
Fork pull requests work without extra setup. The pull_request workflow in the fork's context uploads the artifact, which needs no secret, and the evalship App reads it from your repository.
GitHub doesn't pass secrets to workflows triggered from forks. If your evals call a paid API, they fail on fork pull requests and evalship would report those failures as regressions. To skip evals on forks, add a condition to the job:
jobs:
evals:
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name == github.repository
Fork pull requests then get no results and no comment.
Timing and retention
evalship downloads artifacts when the completed event arrives, usually within seconds of the run finishing, and stores the parsed results. Your repository's artifact retention setting doesn't matter after that. Artifacts that have already expired when the event arrives are skipped.
Re-running the workflow uploads new artifacts and updates the results for the commit. Re-running the evalship check from GitHub re-processes the stored results without downloading anything.
Other CI systems
Only GitHub Actions is supported for now. The results have to be a workflow artifact in the same repository.
Troubleshooting
| Symptom | Likely cause |
|---|---|
No comment and no evalship check |
The workflow didn't run for the commit, the upload step is missing or lacks if: always(), the artifact is over 20 MB, the results file isn't .xml or .json, or results.artifacts excludes the artifact name. |
| A check but no comment | The pull request is closed, comment.enabled is false, the results are for an older commit than the pull request's head, or the repository is private and not enabled. See Pricing and billing. |
| Every comment says it's the first run | No completed run on the default branch. Add the push trigger for the default branch and merge. |
| Cases show up as removed and new on every run | Case keys change between runs. See stable keys in Result formats. |