Skip to content

feat: packaged CI gate — JUnit XML output + composite GitHub Action#37

Open
uipreliga wants to merge 8 commits into
mainfrom
feat/ci-gate-junit-action
Open

feat: packaged CI gate — JUnit XML output + composite GitHub Action#37
uipreliga wants to merge 8 commits into
mainfrom
feat/ci-gate-junit-action

Conversation

@uipreliga

Copy link
Copy Markdown
Collaborator

Makes coder-eval consumable as a CI gate: a JUnit XML report generated from any run directory, plus a composite GitHub Action published from the repo root.

What's in it

1. src/coder_eval/reports_junit.py (new)generate_junit_xml(run_dir) reads a finalized run directory and returns JUnit XML; write_junit_xml is the thin persist wrapper (mirrors the build_run_summary/write_run_summary seam).

  • Reads the run.json spine via the RunSummary model (SSOT, not hand-parsed), optional */*/suite.json gates via SuiteRollup, and per-failed-row task.json as a plain dict (best-effort, so schema skew in blob-pulled/older runs degrades to a status-only body instead of aborting).
  • One <testsuite> per variant, one <testcase> per task row ([NN] suffix for replicates); skipped and suite-gates synthetic suites. Count attributes are derived from the emitted children, so they can't drift.
  • Status classification goes through FinalStatus(value).category — an explicit allowlist with an explicit unknown-value fallback, never a denylist (CE018).
  • No new runtime dependencies. Production code builds and serializes an element tree and never parses XML, so XXE / billion-laughs have no surface; defusedxml is added dev-only for test-side round-trips.

2. CLI wiring — output-only flags; neither enters the 5-layer config merge.

  • coder-eval run --junit-xml PATH — written after the run summary is persisted and before the failure exit-code gate, so a red run still emits a report. A write error propagates (a silently missing file would make a downstream reporter fail confusingly).
  • coder-eval report <run-dir> -f junit [-o PATH] — regenerates from any existing run dir (defaults to <run-dir>/junit.xml).
  • Both entry points call the same writer, so they can't drift.

3. action.yml + release automation + dogfood

  • Composite action: pinned-PyPI install with a version: local escape hatch, always emits run-dir/junit-path outputs and appends run.md to $GITHUB_STEP_SUMMARY, then exits with coder-eval's real exit code. Every input crosses into bash via env: — zero ${{ }} interpolation into script bodies.
  • release.yml bumps the action's pinned version: default inside the amended release commit (anchored on a # <-- kept in sync comment, with a grep guard that fails the release loudly rather than shipping a stale pin) and force-moves the v<major> tag.
  • pr-checks.yml gains a fork-gated action-dogfood job — the sensor for this surface, since it isn't reachable from pytest.

Design note (deviation from the original plan)

The action is deliberately agent-agnostic: it installs coder-eval but no coding-agent runtime. claude-agent-sdk does not bundle the claude binary, so callers provide it — the dogfood job installs Node + @anthropic-ai/claude-code itself, and the README/tutorial document this. Keeps the action usable for codex/antigravity consumers instead of forcing a Claude CLI install on everyone.

Review fixes

A multi-model review (gpt-5.6, gemini-3.1-pro, gpt-5.3-codex) found several robustness gaps around the untyped task_results row dicts, all fixed with regression tests that fail without the fix:

  • Path containment — a crafted variant_id/task_id could steer the task.json lookup outside the run dir (an absolute value discards run_dir; .. walks up). A test confirmed a real leak before the fix.
  • UnicodeDecodeError is a ValueError but not a json.JSONDecodeError, so undecodable bytes aborted the whole report despite the documented graceful degrade.
  • Ambiguous replicate — with replicate_index absent and several replicate dirs, replicate 00's failure detail was misattributed to the row; now degrades instead.
  • bool is an int subclass — a bool replicate_index rendered as [01]; NaN/inf durations emitted an invalid time="nan".
  • Non-str variant_id/task_path crashed the dict key / Path(); same-basename skipped tasks collapsed into one JUnit identity.

Testing

  • make verify green: 3454 passed, 2 skipped (both pre-existing environment skips), 90.98% coverage (threshold 80%); reports_junit.py ~98%.
  • All new tests are hermetic — run dirs built by hand under tmp_path, no agents, no API.
  • Phase 3 has no pytest surface by design; validated by a YAML syntax gate, a locally rehearsed release sed (proven to rewrite only the version line and preserve the trailing comment), an empirical simulation of the action's argv building under set -uo pipefail, and actionlint (clean on all additions).

What can't be verified until this PR runs

  • action-dogfood — this PR both adds the job and is its first subject. Composite-action quirks (setup-uv PATH propagation, $GITHUB_ACTION_PATH install, output plumbing) are only provable in CI.
  • Release automation — the version bump and moving major tag need a real release dispatch. Both have loud-failure guards.

🤖 Generated with Claude Code

uipreliga and others added 5 commits July 21, 2026 17:47
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
Companion to the pyproject dev-group addition in 1/3.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
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) <[email protected]>
@github-actions

Copy link
Copy Markdown

Claude Code is working…

I'll analyze this and get back to you.

View job run

# Every time attribute must be a finite, non-negative float (JUnit requirement).
for case in cases:
t = float(case.get("time"))
assert t == t and t >= 0.0 # not NaN, not negative
_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) <[email protected]>
@uipreliga
uipreliga force-pushed the feat/ci-gate-junit-action branch from 186fa90 to f6c02fe Compare July 22, 2026 01:39
@uipreliga uipreliga closed this Jul 22, 2026
@uipreliga uipreliga reopened this Jul 22, 2026
uipreliga and others added 2 commits July 21, 2026 18:53
…tion

# Conflicts:
#	.github/workflows/release.yml
#	uv.lock

Co-Authored-By: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants