Running skill-lens in CI¶
skill-lens is built to be a CI gate: the exit code is the contract — 0 gate passed, 1 gate
failed, 2 a user or authoring error. Everything else on this page is about making that
verdict legible.
Reports¶
| Flag | Format | Read by |
|---|---|---|
--json-output |
JSON | Tooling, dashboards, artifact storage |
--junit-output |
JUnit XML | GitHub, GitLab, Jenkins, CircleCI, Buildkite test panes |
--markdown-output |
Markdown | $GITHUB_STEP_SUMMARY, PR comments |
The JUnit mapping — including why only the candidate arm becomes test cases, and why an
<error> can carry an evaluator's own diagnostic rather than the runner's — is documented in
Gating and exit codes.
skill-lens never talks to the GitHub API. It renders a Markdown file; your workflow decides where that goes.
--markdown-max-chars only makes sense together with --markdown-output: it is rejected as a
user error (exit 2) without it, and rejected outright below 1. Leave it unset for a step
summary, which allows 1 MiB; set it near GitHub's 65,536-character comment cap when the same
file will also be posted as a PR comment. Below the budget, truncation gives up detail before
it gives up meaning — optional sections (totals, per-skill table, delta, failure detail) are
dropped first, then gate reasons are elided behind a truthful +N more reasons count so a
clipped comment never implies the reasons it shows were all of them. See
--markdown-max-chars for the exact rule, including the one case (a budget too small
to hold even the verdict) that falls back to a hard character cut.
The composite action¶
- uses: EmadMokhtar/skill-evaluator@v0.2.0
with:
path: ./skills
runner: pydantic-ai
model: openai:gpt-4o-mini
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Pin an exact tag, as shown above — until 1.0 a minor release may change behaviour, so there is
no floating v0 tag to follow. Every release tags the tree in which install-spec already
pins the matching version, so the action and the CLI it installs cannot drift apart.
To run against a commit that has not been released, pin the action to that commit SHA and give
install-spec the same ref (skill-lens[pydantic-ai] @
git+https://github.com/EmadMokhtar/skill-evaluator@<commit-sha>), or reference the action as
uses: ./ from a workflow inside this repository.
Every skill-lens run flag is available as a kebab-cased input (--min-pass-rate becomes
min-pass-rate), plus three inputs about the environment rather than the run:
| Input | Default | Purpose |
|---|---|---|
install-spec |
skill-lens[pydantic-ai]==0.2.0 |
Passed verbatim to uv tool install. Accepts a PyPI name, a pinned version, a git ref, or a local path. |
working-directory |
. |
Directory to run in. |
step-summary |
true |
Append the Markdown summary to $GITHUB_STEP_SUMMARY. |
Outputs: exit-code, passed, pass-rate, json-report, junit-report, markdown-report.
json-output, junit-output and markdown-output default to real paths rather than being
unset, because the action reads the JSON back to produce passed and pass-rate.
json-report, junit-report and markdown-report are always absolute paths, resolved
against working-directory before they are written to $GITHUB_OUTPUT. A relative path is only
meaningful inside the directory the run step used; a later step (upload-artifact, a custom
script) has no reason to share that directory, so a relative output would be wrong whenever
working-directory is not ..
The action runs its steps with shell: bash, which GitHub Actions executes under
bash --noprofile --norc -eo pipefail — -e is already on before the action's own script
runs a line. The run step captures the CLI's exit code explicitly (code=0; skill-lens run
"${args[@]}" || code=$?) precisely so that -e cannot swallow a red gate before it is
recorded, every reporting step after it carries if: always() so a failed run still gets its
summary published and its outputs read, and the final step re-raises with exit
"${CODE:-1}" — an empty code (the run step never completing at all: a failed install, a
cancelled job) fails closed rather than defaulting to success. A gate that cannot prove it
passed must fail.
The run step also deletes any report already sitting at json-output, junit-output and
markdown-output before invoking the CLI. Exit code 2 means the CLI wrote nothing, so without
that deletion a report left over from an earlier invocation in the same job — same working-directory,
same default filenames — would be published and read as if it belonged to the run that just
failed.
Two CI jobs guard that behavior end to end, beyond the unit test that only compares
action.yml's inputs against the CLI's flags: action-smoke runs the action against a
small passing fixture skill and asserts all three report files appear and are well-formed;
action-smoke-failing-gate runs it against a fixture that deliberately fails and asserts
the exit code is 1, passed is "false", and the reports still exist. The second job is the
one that matters — a passing-only fixture would happily pass even if a future edit broke the
exit-code capture (a bare failing command under -e, a step that silently swallows $?), so
only a fixture that is supposed to go red can catch a regression in how red gets reported.
A complete workflow¶
# Gate pull requests on a skill-lens run, using the composite action.
#
# Copy this into .github/workflows/ in your own repository. Files under
# examples/ are inert -- only .github/workflows/ is executed by GitHub.
name: skill-lens
on:
pull_request:
permissions:
contents: read
pull-requests: write
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
# --baseline previous resolves the skill's prior version from git
# history, so a shallow clone would leave it with nothing to compare.
fetch-depth: 0
- name: Evaluate the skills
id: eval
uses: EmadMokhtar/skill-evaluator@v0.2.0
continue-on-error: true
with:
path: ./skills
runner: pydantic-ai
model: openai:gpt-4o-mini
baseline: previous
repeat: 3
concurrency: 4
# GitHub caps a comment at 65536 characters. Detail blocks are
# dropped first, then gate reasons are elided behind a "+N more"
# count; a budget too small for the verdict itself is cut outright.
markdown-max-chars: "60000"
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Upload the reports
if: always()
uses: actions/upload-artifact@v4
with:
name: skill-lens-reports
path: |
${{ steps.eval.outputs.json-report }}
${{ steps.eval.outputs.junit-report }}
${{ steps.eval.outputs.markdown-report }}
- name: Comment on the pull request
# GitHub withholds write tokens from fork-triggered `pull_request` runs,
# so an unguarded comment step fails on exactly the PRs an open-source
# project gets most. Fork PRs still get the step summary, the JUnit
# rendering and the correct exit code. See docs/ci.md for the
# `workflow_run` pattern if you need comments on forks too --
# `pull_request_target` is not the answer.
if: github.event.pull_request.head.repo.full_name == github.repository
uses: actions/github-script@v7
env:
SUMMARY: ${{ steps.eval.outputs.markdown-report }}
with:
script: |
const fs = require('fs');
const summaryPath = process.env.SUMMARY;
if (!summaryPath || !fs.existsSync(summaryPath)) {
core.info('No Markdown summary was written; skipping the comment.');
return;
}
const marker = '<!-- skill-lens -->';
const body = marker + '\n' + fs.readFileSync(summaryPath, 'utf8');
const comments = await github.paginate(github.rest.issues.listComments, {
...context.repo,
issue_number: context.issue.number,
per_page: 100,
});
const existing = comments.find(
(c) => c.user.type === 'Bot' && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
...context.repo, comment_id: existing.id, body,
});
} else {
await github.rest.issues.createComment({
...context.repo, issue_number: context.issue.number, body,
});
}
- name: Fail the build if the gate failed
# `continue-on-error` above let the comment and artifact steps run, so
# the gate's verdict is re-raised here. An empty exit code means the
# evaluation never completed, which must fail rather than pass.
if: always() && steps.eval.outputs.exit-code != '0'
env:
CODE: ${{ steps.eval.outputs.exit-code }}
run: exit "${CODE:-1}"
Without the action¶
# The same gate without the composite action, for repositories that would
# rather call the CLI directly.
#
# Copy this into .github/workflows/ in your own repository.
name: skill-lens (CLI)
on:
pull_request:
permissions:
contents: read
jobs:
evaluate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Install skill-lens
run: uv tool install "skill-lens[pydantic-ai]"
- name: Evaluate the skills
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
skill-lens run ./skills \
--runner pydantic-ai \
--model openai:gpt-4o-mini \
--concurrency 4 \
--json-output skill-lens-report.json \
--junit-output skill-lens-junit.xml \
--markdown-output skill-lens-summary.md
- name: Publish the summary
# `always()` so a failing gate still reports why.
if: always()
run: cat skill-lens-summary.md >> "$GITHUB_STEP_SUMMARY"
Pull request comments on forks¶
GitHub deliberately gives pull_request runs triggered from a fork a read-only token, so a
comment step fails there no matter what permissions: says. The example above guards the
comment step with:
if: github.event.pull_request.head.repo.full_name == github.repository
Fork PRs still get the step summary, the JUnit rendering and the correct exit code — the substance of the report. Only the comment is skipped.
If you need comments on fork PRs, use the two-workflow workflow_run pattern: the
pull_request workflow runs the evaluation and uploads the Markdown as an artifact, never
holding a write token; a second workflow triggered on workflow_run downloads it and posts the
comment with pull-requests: write.
Do not reach for pull_request_target instead. It runs the base repository's workflow with
a write token and access to secrets, and checking out the pull request's head under it is one
of the best-known ways to hand a fork's code your repository's credentials.
Concurrency and cost¶
--concurrency N runs N cases at once. The work is network-bound — one provider round trip per
case against sub-millisecond of local work — so this overlaps waiting rather than using more
cores, and the practical ceiling is your provider's rate limit.
It does not change what a run costs. --baseline and --repeat do: --baseline previous
--repeat 3 is six runs per case, not one. skill-lens run prints a ceiling estimate before it
starts whenever the runner needs an API key.
Discovery (walking skills, loading eval files, filtering by --tag) always finishes, for every
skill, before any case runs — independent of --concurrency. A malformed eval file anywhere
therefore aborts the whole run before a single provider call is made, rather than after some
other skill's cases already ran and were paid for.