Skip to content

Commit 74db6fa

Browse files
uipreligaclaude
andauthored
feat: packaged CI gate — JUnit XML output + composite GitHub Action (#37)
* feat(reports): 1/3 — add reports_junit.py disk-driven JUnit XML writer New src/coder_eval/reports_junit.py: generate_junit_xml(run_dir) reads the run.json spine (RunSummary), optional */*/suite.json gates (SuiteRollup), and per-failed-row task.json (best-effort plain-dict) to build a JUnit XML string; write_junit_xml is the thin persist wrapper. Counts always equal emitted children; status classification goes through FinalStatus.category; all agent-derived text is scrubbed of illegal XML 1.0 chars. Production code never parses XML. Adds defusedxml (dev-only) for test-side round-trip parsing and a shared write_run_json conftest fixture reused by later phases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(cli): 2/3 — wire run --junit-xml and report -f junit Add --junit-xml to `coder-eval run` (writes the JUnit report after the run summary is persisted and before the failure exit-code gate, so a red run still produces a report; write errors propagate). Add 'junit' to `coder-eval report --format` (regenerates from any run dir; defaults to <run-dir>/junit.xml, -o overrides; missing run.json → clean red error). Output-only — does not enter the config merge. Docs: CI-pipeline tutorial JUnit section + CLAUDE.md tree entry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(ci): 3/3 — publish composite action, release automation, PR dogfood Add action.yml at the repo root: a composite action that installs a pinned coder-eval (or the local checkout via `version: local`), runs the suite, always emits run-dir/junit-path outputs and a run.md job summary, then exits with coder-eval's real exit code. Inputs cross into bash via env: only. release.yml now bumps the action's `version:` default inside the amended release commit (anchored on a `# <-- kept in sync` comment, with a loud grep guard) and force-moves the `v<major>` tag after pushing the release tag. pr-checks.yml gains a fork-gated `action-dogfood` job that exercises the action via `uses: ./` on one Haiku task and verifies the JUnit + run.json output — the sensor for this surface, since it isn't covered by pytest. The action is deliberately agent-agnostic: it installs no coding-agent runtime, so callers provide the Claude CLI (as the dogfood job does). Documented in the README and the CI tutorial along with the pull_request_target security caveat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(deps): lock defusedxml (dev-only, test-side XML parsing) Companion to the pyproject dev-group addition in 1/3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): code review fixes for the JUnit CI gate Harden reports_junit.py against schema-skewed / crafted run.json rows, which are untyped dicts (RunSummary.task_results is list[dict[str, Any]]) and may be blob-pulled from elsewhere: - Path containment: a crafted variant_id/task_id could steer the task.json lookup outside the run dir (absolute value discards run_dir, ".." walks up). Reject unsafe path components and verify containment after resolve(). - UnicodeDecodeError is a ValueError but NOT a json.JSONDecodeError, so a task.json with undecodable bytes aborted the whole report instead of falling back. Catch ValueError. - Ambiguous replicate: with replicate_index absent and several replicate dirs, the writer attributed replicate 00's failure detail to the row. Degrade to the status-only body instead of misattributing. - bool is an int subclass, so a bool replicate_index rendered as "[01]" and a bool duration as a time. Exclude bool explicitly. - Guard non-finite/negative durations (NaN would emit an invalid time="nan"). - Coerce non-str variant_id/task_path rather than crashing on dict keys/Path(). - Skipped testcases now use the suffix-stripped path, not the bare stem, so two skipped tasks sharing a basename keep distinct JUnit identities. - A row with no status reads as "<missing>" rather than "None". Found by the final multi-model review (gpt-5.6, gemini-3.1-pro, gpt-5.3-codex); each fix has a regression test that fails without it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): make skipped-task JUnit names platform-independent _skipped_suite built the testcase name with str(Path(path).with_suffix("")), which yields "tasks\opt" on Windows and "tasks/opt" on Linux. That made the JUnit testcase identity depend on the OS that generated the report, so the same logical run would split into two identities in CI history/flake tracking (and it broke the Windows smoke job). Normalize separators and parse with PurePosixPath so the emitted name is always "/"-separated. Adds a regression test feeding a Windows-style path. Also switch the action's model in the README/tutorial examples from Haiku to Sonnet (docs only; the dogfood job stays on Haiku for per-PR cost). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: re-trigger CI (GitHub dropped the force-push event) Co-Authored-By: Claude <noreply@anthropic.com> * feat(action): generic env passthrough + minimum-task-score gate Replace the single `anthropic-api-key` input with a generic `env` NAME=VALUE passthrough (exported for the run step only, never $GITHUB_ENV) and add an optional `minimum-task-score` floor read from the always-written run.json spine. Both default off, so existing behavior is unchanged. README, tutorial, and the dogfood job route ANTHROPIC_API_KEY through `env`. Code-review hardening (gemini-3.1-pro + gpt-5.3-codex + opus): - score gate skips non-finite weighted_score (json.loads parses NaN; NaN makes min()/>= order-dependent and could mask a below-floor task) - minimum-task-score validated (numeric, finite, in [0.0, 1.0]) with a clean ::error:: instead of a raw traceback - malformed env entries reported by position, never echoing the raw line, so a mis-prefixed secret can't leak into logs Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(reports): JUnit CI-gate review fixes + CE027 env-var lint Code-review fixes to the JUnit XML CI gate, plus a new doc/config lint rule and a producer→consumer parity test. reports_junit.py: - Load task.json for dataset rows: task_id "<suite>/<row_id>" is a real nested dir, so validate it as a contained relative path (_is_safe_relpath) instead of rejecting any '/'. Rejects absolute/backslash/'..'/Windows-drive values; the resolve()-containment check remains the backstop. - Guard the root <testsuites> time against NaN/inf like per-testcase time via a shared _time_attr helper; also degrade (not crash) on a pathologically large integer duration that overflows float()/'.3f'. - Render informational (gating=False) criteria as [INFO], not [FAIL]/[PASS], mirroring reports.py; only an explicit JSON `false` is informational (null / non-bool fails safe to gating). Docs: fix CODER_EVAL_API_BACKEND -> API_BACKEND (the real Settings env name; the CODER_EVAL_-prefixed spelling was silently dropped by Settings extra="ignore", selecting no backend). CE027 (tests/lint/doc_env_parity.py): new rule flagging framework-prefixed env-var assignments in README/action.yml/docs that no Settings field/alias or src/ consumer backs — the class that produced the API_BACKEND bug. Assignment-scoped with a hardened boundary and a real-consumer src scan to avoid false positives. Tests: dataset nested-load, nested '..'/drive rejection, informational [INFO] (passing + null-gating), root/huge-int time degradation, and a producer→consumer parity test running real eval_result_to_task_dict output through generate_junit_xml. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 793355b commit 74db6fa

17 files changed

Lines changed: 2162 additions & 9 deletions

.github/workflows/pr-checks.yml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,3 +831,61 @@ jobs:
831831
name: byoa-live-output
832832
path: tmp/
833833
retention-days: 7
834+
835+
action-dogfood:
836+
name: Action Dogfood (composite action, real API)
837+
runs-on: ubuntu-latest
838+
timeout-minutes: 15
839+
# Skip on fork PRs where secrets aren't available (matches e2e-smoke).
840+
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
841+
steps:
842+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
843+
844+
# The composite action is agent-agnostic and does NOT install a coding-agent
845+
# runtime. The dogfood task uses the default claude-code agent, so provide
846+
# Node + the Claude CLI here (as e2e-smoke does), before invoking the action.
847+
- name: Set up Node.js 20
848+
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
849+
with:
850+
node-version: "20"
851+
- name: Install Claude CLI
852+
run: npm install -g @anthropic-ai/claude-code
853+
854+
- name: Run coder-eval via local action
855+
id: dogfood
856+
uses: ./
857+
with:
858+
version: local
859+
tasks: tasks/hello_date.yaml
860+
model: claude-haiku-4-5-20251001
861+
run-dir: runs/ci-action-dogfood
862+
junit-path: runs/ci-action-dogfood/junit.xml
863+
# Credentials go through the generic env passthrough (the only channel);
864+
# ANTHROPIC_API_KEY reaching the run is proven by the API-backed task
865+
# succeeding. A floor of 0.0 passes for any produced score (exercises
866+
# the gate path green in CI without flakiness); the second line
867+
# exercises multi-line env parsing.
868+
minimum-task-score: "0.0"
869+
env: |
870+
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
871+
CE_DOGFOOD_MARKER=1
872+
873+
- name: Verify outputs and JUnit file
874+
env:
875+
JUNIT: ${{ steps.dogfood.outputs.junit-path }}
876+
RUNDIR: ${{ steps.dogfood.outputs.run-dir }}
877+
run: |
878+
set -euo pipefail
879+
test -n "$JUNIT" && test -f "$JUNIT" || { echo "junit output missing"; exit 1; }
880+
# Well-formedness check on a file this job just generated (trusted input;
881+
# our writer emits no DTDs/entities) — stdlib ET is fine here.
882+
python3 -c "import sys, xml.etree.ElementTree as ET; ET.parse(sys.argv[1])" "$JUNIT"
883+
test -f "$RUNDIR/run.json" || { echo "run.json missing"; exit 1; }
884+
885+
- name: Upload dogfood run on failure
886+
if: failure()
887+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
888+
with:
889+
name: action-dogfood-runs
890+
path: runs/ci-action-dogfood/
891+
retention-days: 7

.github/workflows/release.yml

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -182,23 +182,50 @@ jobs:
182182
echo "version=$V" >> "$GITHUB_OUTPUT"
183183
echo "Publishing version: $V"
184184
185-
- name: Regenerate uv.lock and amend release commit
185+
- name: Regenerate uv.lock, bump action.yml pin, and amend release commit
186186
if: steps.mode.outputs.prerelease != 'true' && steps.release.outputs.version != ''
187+
env:
188+
# Passed via env (not interpolated into the script) per GitHub's
189+
# injection guidance.
190+
VERSION: ${{ steps.release.outputs.version }}
187191
run: |
192+
set -euo pipefail
193+
git config user.email "github-actions[bot]@users.noreply.github.com"
194+
git config user.name "github-actions[bot]"
195+
# Bump the composite action's default `version:` pin to the just-released
196+
# version so `UiPath/coder_eval@vX.Y.Z` installs `coder-eval==X.Y.Z`. The
197+
# anchor is indentation-tolerant and keyed on the unique trailing
198+
# "# <-- kept in sync" comment; the grep guard fails the release loudly
199+
# if a reformat ever detaches it (rather than shipping a stale pin).
200+
sed -i -E 's/^([[:space:]]*default: ")[0-9]+\.[0-9]+\.[0-9]+(" # <-- kept in sync)/\1'"${VERSION}"'\2/' action.yml
201+
grep -q "default: \"${VERSION}\"" action.yml || { echo "action.yml version bump failed"; exit 1; }
202+
git add action.yml
203+
# Regenerate the lock too; stage it (a no-op if unchanged).
188204
uv lock
189-
if ! git diff --quiet uv.lock; then
190-
git config user.email "github-actions[bot]@users.noreply.github.com"
191-
git config user.name "github-actions[bot]"
192-
git add uv.lock
205+
git add uv.lock
206+
# Amend only if action.yml/uv.lock actually changed the tree.
207+
if ! git diff --cached --quiet; then
193208
git commit --amend --no-edit
194209
# Amend replaced the commit the tag points at; re-point it before pushing.
195-
git tag -f "v${{ steps.release.outputs.version }}"
210+
git tag -f "v${VERSION}"
196211
fi
197212
198213
- name: Push release commit and tags
199214
if: steps.mode.outputs.prerelease != 'true' && steps.release.outputs.version != ''
200215
run: git push origin main "v${{ steps.release.outputs.version }}"
201216

217+
- name: Move major action tag (vN -> this release)
218+
if: steps.release.outputs.version != ''
219+
env:
220+
VERSION: ${{ steps.release.outputs.version }}
221+
run: |
222+
set -euo pipefail
223+
# Consumers pin `UiPath/coder_eval@v0` (becomes `@v1` at 1.0.0). Force-move
224+
# the moving major tag to this release. Force on a missing tag creates it.
225+
MAJOR="v${VERSION%%.*}"
226+
git tag -f "$MAJOR" "v${VERSION}"
227+
git push -f origin "$MAJOR"
228+
202229
- name: Build wheel + sdist
203230
if: steps.ver.outputs.version != ''
204231
run: uv build

CLAUDE.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ coder_eval/
2020
├── orchestrator.py # Main evaluation loop
2121
├── reports.py # Markdown/JSON report generation (run-level + per-suite rollup via write_suite_rollups)
2222
├── reports_experiment.py # Experiment/cross-variant report generation
23+
├── reports_junit.py # JUnit XML report from a finalized run dir (run.json spine; for CI test-report ingestion)
2324
├── analysis.py # Command statistics aggregation
2425
├── logging_config.py # Structured logging setup
2526
├── path_utils.py # Run ID generation, path utilities
@@ -120,6 +121,7 @@ tasks/ # Task definition YAML files
120121
tests/ # Test suite
121122
docs/ # Documentation
122123
templates/ # Sandbox template directories
124+
action.yml # Published composite GitHub Action (coder-eval as a CI gate). release.yml maintains its `version:` default + the moving `v<major>` tag.
123125
```
124126

125127
## Key Architectural Patterns

README.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,74 @@ live in this repo — clone it or point the CLI at your own task files.) See
9696
[Tutorial 02 — Running coder_eval in CI](docs/tutorials/02-ci-pipeline.md) for
9797
the full setup.
9898

99+
## Use as a GitHub Action
100+
101+
A composite action at the repo root runs `coder-eval` as a CI gate — it installs
102+
the pinned CLI, runs your tasks, writes a JUnit XML report, appends `run.md` to
103+
the job summary, and fails the step on any task/gate failure:
104+
105+
```yaml
106+
- uses: UiPath/coder_eval@v0 # becomes @v1 once 1.0.0 ships; @vX.Y.Z pins exactly
107+
with:
108+
tasks: tests/tasks/**/*.yaml
109+
model: claude-sonnet-5
110+
env: |
111+
ANTHROPIC_API_KEY=${{ secrets.ANTHROPIC_API_KEY }}
112+
```
113+
114+
| Input | Default | Purpose |
115+
| --- | --- | --- |
116+
| `tasks` | *(all `tasks/`)* | Task YAML path(s)/glob |
117+
| `tags` | — | `--tags` filter |
118+
| `model` | — | `--model` override |
119+
| `extra-args` | — | Verbatim extra args (`--experiment`, `-D …`, …) |
120+
| `version` | pinned release | PyPI version, or `local` to install from the checkout |
121+
| `run-dir` | `runs/ci` | Run directory |
122+
| `junit-path` | `coder-eval-junit.xml` | Where to write the JUnit report |
123+
| `step-summary` | `true` | Append `run.md` to the job summary |
124+
| `env` | — | Credentials/backend passthrough: newline-separated `NAME=VALUE` pairs, exported for the run step only |
125+
| `minimum-task-score` | *(off)* | Strict floor (0.0–1.0): fail the step if any task's `weighted_score` is below it |
126+
127+
Outputs: `run-dir` and `junit-path`. Feed the JUnit file to your platform's
128+
test-report renderer — e.g. on GitHub Actions with
129+
[`mikepenz/action-junit-report`](https://github.com/mikepenz/action-junit-report):
130+
131+
```yaml
132+
- uses: mikepenz/action-junit-report@v5
133+
if: always()
134+
with:
135+
report_paths: coder-eval-junit.xml
136+
```
137+
138+
**Credentials and backend config** are the sole responsibility of `env` — a
139+
passthrough exported for the run step only (never written to `$GITHUB_ENV`, so
140+
it can't leak into later steps). Set whatever the run needs, Anthropic or not:
141+
142+
```yaml
143+
- uses: UiPath/coder_eval@v0
144+
with:
145+
tasks: tests/tasks/**/*.yaml
146+
minimum-task-score: "0.8" # fail the build if any task scores below 0.8
147+
env: |
148+
API_BACKEND=bedrock
149+
AWS_BEARER_TOKEN_BEDROCK=${{ secrets.BEDROCK_TOKEN }}
150+
```
151+
152+
`minimum-task-score` is a strict floor **on top of** coder-eval's own exit
153+
code: the step fails if *either* coder-eval exits non-zero *or* any task's
154+
`weighted_score` falls below the floor. Leave it unset to gate on the exit code
155+
alone.
156+
157+
> **Agent runtime is the caller's responsibility.** The action is agent-agnostic —
158+
> it installs `coder-eval` but no coding-agent runtime. Tasks using the default
159+
> `claude-code` agent need the `claude` CLI on `PATH` (`actions/setup-node` +
160+
> `npm install -g @anthropic-ai/claude-code`) in the job before the action runs.
161+
162+
> **Security.** Evaluated tasks execute agent-generated code. Do **not** run this
163+
> action under `pull_request_target` with secrets exposed to untrusted fork PRs —
164+
> use `pull_request` and gate on the same-repo condition, as this repo's own
165+
> dogfood job does.
166+
99167
## Telemetry
100168

101169
> 📊 **Usage telemetry is on by default.** `coder-eval` sends **anonymous** usage

0 commit comments

Comments
 (0)