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.25.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). runner takes one name — fake, pydantic-ai, langchain, copilot,
claude-code or cli — or a comma-separated list to run every case through each
(runner: pydantic-ai,langchain). Install every framework named:
install-spec: skill-lens[pydantic-ai,langchain]==…; a product runner needs the product
installed instead, see Running under a product. base-url
points a keyed runner at a self-hosted endpoint the job can reach — a self-hosted GitHub
runner beside the model server, typically — and is usually better committed as base_url
in skill-lens.toml (see Self-hosted endpoints)
than passed per job. Three more inputs are about the environment rather than the run:
| Input | Default | Purpose |
|---|---|---|
install-spec |
skill-lens[pydantic-ai]==0.25.0 |
Passed verbatim to uv tool install. Accepts a PyPI name, a pinned version, a git ref, or a local path. Add the langchain extra (skill-lens[pydantic-ai,langchain], pinned the same way) when a job runs the LangChain runner. |
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.
allow-scripts: true mirrors keep-workspace: it passes --allow-scripts, so the skill's
bundled scripts run on the job's runner. Enabling it in CI runs unvetted code with the job's
filesystem readable. The script's environment is rebuilt from an allowlist, so GITHUB_TOKEN
and every other secret in env are not inherited — but that is the only layer between a
script and the token. actions/checkout persists the job token on disk by default
(persist-credentials: true, in .git/config of the checkout), readable by the same user the
script runs as; and a stock ubuntu-latest runner may not ship bwrap, or may refuse
unprivileged user namespaces, in which case no OS sandbox applies (the report's scripts: on,
sandbox: none (...) line says which), the network is open and nothing stops a script reading
the token and sending it out. If you enable scripts, tell the checkout not to persist the
token, and give the job only the permissions the run needs:
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: EmadMokhtar/skill-evaluator@v0.25.0
with:
path: ./skills
allow-scripts: true
Files on disk (the checkout, the runner's tools) stay readable either way. Turn scripts on only for skills you would run by hand. See Running bundled scripts.
The input being unset is not the same as false. Unset, the action lets skill-lens.toml
decide — and in a pull_request workflow that file comes from the pull request, which can
set allow_scripts = true and script_sandbox = "off" for itself. A fork's PR gets no
secrets under plain pull_request, so a real runner fails at the missing key first; a
pull_request_target workflow, a collaborator's PR, or a self-hosted runner has no such
backstop. In those workflows pass allow-scripts: false explicitly, which overrides the
file:
- uses: EmadMokhtar/skill-evaluator@v0.25.0
with:
path: ./skills
allow-scripts: false
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.25.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}"
Gating on the delta¶
The complete workflow above is already comparative: baseline: previous re-runs every case
against the skill's previous version, and repeat: 3 samples each arm three times. Both are
ordinary inputs, and min-delta is the third — it turns the delta the report carries into a
verdict:
- uses: EmadMokhtar/skill-evaluator@v0.25.0
with:
path: ./skills
runner: pydantic-ai
model: openai:gpt-4o-mini
baseline: previous # none, or previous resolved from git
repeat: 3 # sample each arm three times
min-delta: "0.0" # must not regress
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
baseline: previous reads git history, so the checkout must be unshallow —
fetch-depth: 0, as in the workflow above. min-delta without baseline is a user error
(exit 2), and a delta gate with nothing comparable fails rather than passing vacuously:
the rules are in Comparative evals. Two arms sampled
three times is six runs per case — see Concurrency and cost.
Running the matrix¶
One job, one report, every case through both frameworks:
- uses: EmadMokhtar/skill-evaluator@v0.25.0
with:
path: ./skills
install-spec: "skill-lens[pydantic-ai,langchain]==0.25.0"
runner: pydantic-ai,langchain
model: openai:gpt-4o-mini
markdown-output: skill-lens.md
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
A case failing under either framework fails the job, and the summary names the runner on every failing line. A two-runner job spends twice.
The alternative is a GitHub strategy.matrix over runner: [pydantic-ai, langchain], one
job per framework with the single-name runner: input. It needs no list support and shows
one check per framework in the pull request, at the cost of one summary and one JUnit
file per job rather than one for the whole matrix.
Running under a product¶
A product runner needs the product installed on the runner
and its token in the environment; no provider API key and no framework extra. Pin runner:
so a pull request cannot pick the product for itself:
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm install -g @github/copilot
- uses: EmadMokhtar/skill-evaluator@v0.25.0
with:
path: ./skills
runner: copilot
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_TOKEN }}
Each case spends the product's quota; the Plan: line prints the ceiling. The product runs
with its permission prompts disabled, and it holds its token and whatever it can read on the
runner (the product sandbox narrows what it inherits,
not what it can read), so give the token only to jobs whose checkout you trust — see
Security for why pinning runner: alone is not enough. The
model input is refused (exit 2) when only product runners are named and no keyed judge
falls back to it, and so is judge-model when the file's judge is a product
(copilot, claude-code or cli — see
Judging with a product); set the product's model with
[runners.<name>] args in skill-lens.toml, which serves the runner and the judge alike.
The product runs under the product sandbox: by default a
temporary home directory and an allowlisted environment, so it signs in from its token
(COPILOT_GITHUB_TOKEN, CLAUDE_CODE_OAUTH_TOKEN), never from a login on the runner, and it
inherits only the allowlist: its own variables (Copilot just the first of
COPILOT_GITHUB_TOKEN, GH_TOKEN, GITHUB_TOKEN that is set), pass_env, and the plain
ones such as PATH. It can still read the job's other variables the way any same-user
process can, which Security explains. On a stock ubuntu-latest runner bwrap may be
missing, or unprivileged user namespaces refused, and then no OS sandbox applies; the product
line on the report says which. The product-sandbox input passes --product-sandbox. Unset,
skill-lens.toml decides — and in a pull_request workflow that file comes from the pull
request, which can set product_sandbox = "off" for itself. Where the checkout is a pull
request you do not trust, pass required, so the run stops (exit 2) rather than running
without a sandbox:
- uses: EmadMokhtar/skill-evaluator@v0.25.0
with:
path: ./skills
runner: claude-code
product-sandbox: required
env:
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
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.
To cap what a job spends, set max-spend (or commit max_spend_usd): once the run has
recorded that many US dollars, no new case starts, the cases left are reported as not run, and
the gate fails. Cases already running when it is crossed finish, so with concurrency: 4 a job
can end above the ceiling by what four cases spend. See
The spend ceiling.
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.