Files
dotnet__skills/.github/workflows/evaluation-run.yml
Abhitej John 78054c1161 Migrate LLM evals to the Vally harness (#877)
* Make Vally the sole LLM eval engine, retiring skill-validator evaluate

Collapses the parallel skill-validator + Vally eval pipeline into a single
Vally-only path while preserving every capability skill-validator provided:
PR gate, PR comment, dotnet/skills-data historical push, and the dashboard.
The skill-validator `check` linter is retained (skill-check.yml) pending
microsoft/vally #463.

- evaluation.yml: delete build-validator and the skill-validator `evaluate`
  job; single `evaluate:` job now uses vally-evaluation.yml. Forward the
  COPILOT_PAT_0..9 pool onto it (from #911) so the reusable workflow's token
  gate is satisfied. Rewire comment-on-pr / report-status / publish-* /
  deploy-dashboard onto `evaluate` and vally-results-* artifacts.
- vally-evaluation.yml: pin @microsoft/vally-cli@0.9.0; keep the #911 prepare
  guard; write the generated per-plugin experiment to $GITHUB_WORKSPACE so
  relative eval globs resolve.
- adapt.mjs: reconcile #887's runCompareWithRetry + conclusive/unmatched
  hardening with the consolidation helpers (nonActivation, compareByStim,
  roleToDashboard, etc.). Add consolidate.mjs and gen-experiment.mjs.
- build-replay-sessions.ps1: read Vally executor-session-logs/events.jsonl
  instead of skill-validator session output.
- Rename all tests/*/*/eval.vally.yaml to eval.yaml (Vally format is now the
  only eval format); author Vally evals for the four skills that lacked one.
- CONTRIBUTING.md + InvestigatingResults: point contributors at the Vally
  harness; drop non-public links.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix setup-local-sdk eval for Vally schema

The hand-authored setup-local-sdk eval used skill-validator grader/environment
schema that Vally rejects at validation (no stimuli executed):
- file-contains/file-not-contains graders require 'value:' not 'substring:'
  (output-contains correctly keeps 'substring:').
- environment.files entries require 'src:'/'dest:' (copy a fixture) rather than
  inline 'path:'/'content:'. Moved the global.json body into fixtures/global.json.

Verified with 'vally lint --eval-spec' (0 errors) across all 97 eval.yaml.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix markdownlint violations in migration docs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address PR review feedback on eval migration

- Consolidate run-vally-evals.sh into a single neutral entrypoint
  eng/run-skill-evals.sh (deletes the thin wrapper; adapt.mjs stays in
  eng/vally-adapter/ since CI invokes it directly)
- Rename reusable workflow vally-evaluation.yml -> evaluation-run.yml and
  update all references/self-trigger regexes
- Rename local output dir vally-results -> eval-results
- Bake prerequisite preflight (Node 20+, GITHUB_TOKEN/gh) into the runner
- Simplify CONTRIBUTING "Running tests locally" prerequisites
- Scrub harness name from consumer-facing surfaces

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reinstate overfitting detection under the Vally harness

Overfitting detection previously ran inside skill-validator's `evaluate`
command, which the Vally migration replaced, so it silently stopped running.
Reinstate it as a standalone step wired into the Vally pipeline:

- Add `skill-validator overfitting` command that discovers skills, parses
  each eval.yaml, runs the existing OverfittingJudge, and emits
  [{plugin, skill, overfittingResult}] JSON (per-skill failures non-fatal,
  bounded parallelism).
- Add a Vally-format parser bridge (ParseEvalConfigFlexible) so the judge
  handles the current stimuli/graders eval.yaml format; the legacy
  scenarios-only parser rejected all 98 evals, so the judge never ran.
- adapt.mjs: merge overfitting results onto each verdict via
  --overfitting <file> (keyed by plugin/skill); output is byte-identical
  when the flag is absent.
- evaluation-run.yml: build skill-validator (shared cache with check),
  run the judge per leg reading GITHUB_TOKEN, and pass --overfitting to
  adapt.mjs so the dashboard/skills-data consume it unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Reconcile #919 Vally eval configs into the sole eval.yaml convention

PR #919 landed on main adding fresh Vally specs for three coverage-gap
skills (setup-local-sdk, convert-blazor-server-to-webapp, dotnet-webapi)
under the interim eval.vally.yaml filename. This branch collapses to a
single Vally spec named eval.yaml, so the merge left each skill with both
my migrated eval.yaml and #919's newer eval.vally.yaml (a git-clean but
semantic duplicate the pipeline would not discover).

Adopt #919's reviewed Vally content as the canonical eval.yaml for all
three skills and drop the redundant eval.vally.yaml files, preserving the
'sole eval.yaml, zero eval.vally.yaml' invariant. Content is byte-identical
to #919's blobs (b14121c6 / 47d43823 / 18a9362f), which already passed
vally-evaluate on main.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-21 15:22:31 -07:00

538 lines
27 KiB
YAML

# Reusable LLM skill evaluation — the sole eval harness (implemented on Vally).
#
# Runs each skill's tests/<plugin>/<skill>/eval.yaml through
# `vally experiment run` (baseline / skilled / plugin variants), then converts
# the output to per-skill results.json via eng/vally-adapter/adapt.mjs (which
# scores skilled-vs-baseline with `vally compare`). The main evaluation.yml
# workflow calls this via workflow_call after `discover`; downstream jobs
# (comment-on-pr, report-status, publish-*) consume the vally-results-* artifacts
# this produces. Can also be triggered manually via workflow_dispatch.
#
# NOTE: The static-analysis linter is a SEPARATE, independent workflow
# (skill-check.yml, `skill-validator check`) and is not affected by this file.
name: evaluation-run
on:
# Triggered by the main evaluation workflow after discover completes
workflow_call:
inputs:
entries:
description: 'JSON array of matrix entries from discover job'
required: true
type: string
head_sha:
description: 'PR head SHA to checkout'
required: false
type: string
default: ''
secrets:
# Passed by the caller via an explicit named mapping (see the vally-evaluate
# job in evaluation.yml). A reusable workflow does not inherit the caller's
# org/repo secrets, so the pool is forwarded explicitly — only these 10
# tokens cross the boundary, never `secrets: inherit` (which would forward
# every secret and reverse the blast-radius reduction from #868). Each name
# must be enumerated (no wildcard exists), matching shared/pat_pool.md, and
# the block is also what lets actionlint type the `secrets.COPILOT_PAT_*`
# references. The `environment: copilot-pat-pool` on the job below can still
# override individual entries via environment secrets if ever configured.
# Names with no backing secret resolve empty and are skipped by the selector.
COPILOT_PAT_0: { required: false }
COPILOT_PAT_1: { required: false }
COPILOT_PAT_2: { required: false }
COPILOT_PAT_3: { required: false }
COPILOT_PAT_4: { required: false }
COPILOT_PAT_5: { required: false }
COPILOT_PAT_6: { required: false }
COPILOT_PAT_7: { required: false }
COPILOT_PAT_8: { required: false }
COPILOT_PAT_9: { required: false }
# Manual trigger for testing
workflow_dispatch:
inputs:
plugin:
description: 'Plugin to evaluate (e.g., dotnet-maui)'
required: true
type: string
skill:
description: 'Skill to evaluate (optional, evaluates whole plugin if empty)'
required: false
type: string
env:
# Fallback model/judge used only if the experiment file's `overrides:` block
# can't be parsed. The experiment file is the source of truth (see below).
FALLBACK_MODEL: claude-opus-4.6
FALLBACK_JUDGE_MODEL: claude-opus-4.6
permissions:
contents: read
jobs:
# --------------------------------------------------------------------------
# For workflow_dispatch: build a single-entry matrix
# --------------------------------------------------------------------------
prepare:
# Only build the single-entry matrix for a *direct* manual dispatch. The
# workflow_dispatch event name propagates into workflow_call, so guarding on
# the event alone would (wrongly) run this during the reusable call, where
# the plugin/skill dispatch inputs are empty and validation throws. When the
# caller supplies `entries`, skip prepare entirely.
if: github.event_name == 'workflow_dispatch' && !inputs.entries
runs-on: ubuntu-latest
outputs:
entries: ${{ steps.build.outputs.entries }}
steps:
- id: build
shell: pwsh
env:
PLUGIN: ${{ inputs.plugin }}
SKILL: ${{ inputs.skill }}
run: |
# Validate dispatch inputs against a strict allowlist, then build the
# entries JSON. Values arrive via env (not inlined into the script) to
# avoid expression injection, and \A..\z anchor to the true string
# bounds (no trailing-newline bypass, unlike a bare $).
$p = $env:PLUGIN
$s = $env:SKILL
$namePattern = '\A[A-Za-z0-9._-]+\z'
# Reject '.' as well: it matches the charset and contains no '..', but
# a lone-dot component is a path-normalization token (e.g. plugin='.'
# broadens tests/. to the whole tree), so it must not pass.
if ($p -notmatch $namePattern -or $p -match '\.\.' -or $p -eq '.') {
throw "Invalid plugin '$p' (must match $namePattern, not be '.', and not contain '..')"
}
if ($s -and ($s -notmatch $namePattern -or $s -match '\.\.' -or $s -eq '.')) {
throw "Invalid skill '$s' (must match $namePattern, not be '.', and not contain '..')"
}
if ($s) {
$json = @(@{ name = "$p--$s"; plugin = $p; skills_path = "plugins/$p/skills/$s" }) | ConvertTo-Json -Compress -AsArray
} else {
$json = @(@{ name = $p; plugin = $p; skills_path = "plugins/$p/skills" }) | ConvertTo-Json -Compress -AsArray
}
"entries=$json" >> $env:GITHUB_OUTPUT
# --------------------------------------------------------------------------
# Run vally evaluation
# --------------------------------------------------------------------------
vally-evaluate:
needs: [prepare]
if: always() && !cancelled()
runs-on: ubuntu-latest
environment: copilot-pat-pool
timeout-minutes: 180
name: vally (${{ matrix.entry.name }})
strategy:
fail-fast: false
# Cap concurrent Copilot sessions: max-parallel jobs * --workers per job.
# Tokens come from the copilot-pat-pool (COPILOT_PAT_0..9), but keep this
# well below skill-validator's fan-out to stay under rate limits.
max-parallel: 4
matrix:
entry: ${{ fromJson(inputs.entries || needs.prepare.outputs.entries || '[]') }}
steps:
- name: Validate matrix entry
# Validate matrix values against a strict allowlist before use.
env:
ENTRY_PLUGIN: ${{ matrix.entry.plugin }}
ENTRY_NAME: ${{ matrix.entry.name }}
ENTRY_SKILLS_PATH: ${{ matrix.entry.skills_path }}
run: |
set -f
# LC_ALL=C avoids locale-dependent collation quirks in [A-Za-z] ranges.
# bash's [[ =~ ]] (POSIX ERE, no REG_NEWLINE) anchors ^/$ to the true
# start/end of the string, unlike .NET's $ which matches before a
# trailing newline — so ^...$ is sufficient here without \A/\z.
export LC_ALL=C
name_re='^[A-Za-z0-9._-]+$'
path_re='^plugins/[A-Za-z0-9._-]+/skills(/[A-Za-z0-9._-]+)?$'
# A lone-dot component ('.') matches the charset and holds no '..', but
# it is a path-normalization token, so reject it for names and for any
# path component ('.../skills/.' or 'plugins/./skills').
for val in "$ENTRY_PLUGIN" "$ENTRY_NAME"; do
if ! [[ "$val" =~ $name_re ]] || [[ "$val" == "." ]] || [[ "$val" == *".."* ]]; then
echo "::error::Invalid matrix value '$val' (must match $name_re, not be '.', and not contain '..')"; exit 1
fi
done
# Collect path segments via word-splitting (set -f prevents globbing).
# A whitespace-only value word-splits to zero segments and must
# hard-fail exactly like a truly empty one — a bare `-z` test would let
# e.g. a single space slip past and then skip the per-segment loop,
# leaving an effectively-empty skills_path to reach CLI-arg building.
segs=()
for seg in $ENTRY_SKILLS_PATH; do segs+=("$seg"); done
if [ ${#segs[@]} -eq 0 ]; then
echo "::error::Empty or whitespace-only skills_path in matrix entry"; exit 1
fi
# Each segment must additionally belong to THIS entry's plugin: the
# generic path_re only checks the shape, so bind the prefix to
# ENTRY_PLUGIN via literal comparison (ENTRY_PLUGIN is already
# allowlist-validated above). This blocks a mismatched entry like
# plugin=foo + skills_path=plugins/bar/skills, which the eval-filter
# builder would otherwise turn into nonsense paths under tests/foo.
skills_prefix="plugins/$ENTRY_PLUGIN/skills"
for seg in "${segs[@]}"; do
if ! [[ "$seg" =~ $path_re ]] || [[ "$seg" == *".."* ]]; then
echo "::error::Invalid skills_path segment '$seg' (must match $path_re and not contain '..')"; exit 1
fi
# Reject a lone-dot path component ('plugins/./skills' or '.../skills/.').
if [[ "$seg" == *"/./"* || "$seg" == */. ]]; then
echo "::error::skills_path segment '$seg' contains a '.' path component"; exit 1
fi
if [[ "$seg" != "$skills_prefix" && "$seg" != "$skills_prefix"/* ]]; then
echo "::error::skills_path segment '$seg' does not belong to plugin '$ENTRY_PLUGIN'"; exit 1
fi
done
- name: Checkout skills content
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
with:
ref: ${{ inputs.head_sha || '' }}
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
- name: Install vally
# Pin to an exact version so every eval run uses the same harness build;
# an unpinned `@microsoft/vally-cli` would resolve to `latest` and drift.
run: npm install -g @microsoft/vally-cli@0.9.0
- name: Select Copilot token from pool
id: select-token
env:
COPILOT_PAT_0: ${{ secrets.COPILOT_PAT_0 }}
COPILOT_PAT_1: ${{ secrets.COPILOT_PAT_1 }}
COPILOT_PAT_2: ${{ secrets.COPILOT_PAT_2 }}
COPILOT_PAT_3: ${{ secrets.COPILOT_PAT_3 }}
COPILOT_PAT_4: ${{ secrets.COPILOT_PAT_4 }}
COPILOT_PAT_5: ${{ secrets.COPILOT_PAT_5 }}
COPILOT_PAT_6: ${{ secrets.COPILOT_PAT_6 }}
COPILOT_PAT_7: ${{ secrets.COPILOT_PAT_7 }}
COPILOT_PAT_8: ${{ secrets.COPILOT_PAT_8 }}
COPILOT_PAT_9: ${{ secrets.COPILOT_PAT_9 }}
run: |
# Select from the copilot-pat-pool secrets (COPILOT_PAT_0..9). There is
# no fallback secret to plumb through and track usage of — an empty
# pool is a hard configuration failure.
TOKENS=()
for i in $(seq 0 9); do
var="COPILOT_PAT_$i"
val="${!var}"
if [ -n "$val" ]; then TOKENS+=("$val"); fi
done
if [ ${#TOKENS[@]} -eq 0 ]; then
echo "::error::No copilot-pat-pool entries (COPILOT_PAT_0..9) are configured"
exit 1
fi
IDX=$(( ${RANDOM} % ${#TOKENS[@]} ))
echo "::add-mask::${TOKENS[$IDX]}"
echo "token=${TOKENS[$IDX]}" >> $GITHUB_OUTPUT
- name: Find eval specs
id: find-evals
env:
DISPATCH_SKILL: ${{ inputs.skill }}
PLUGIN: ${{ matrix.entry.plugin }}
SKILLS_PATH: ${{ matrix.entry.skills_path }}
run: |
set -f
# For workflow_dispatch targeting a specific skill, only run that one
# eval. Otherwise derive the specs from this entry's skills_path so a
# subset leg (per-skill PR entry or shard) reports has_evals accurately
# instead of proceeding through token selection/install only to exit
# empty. A whole-plugin entry (skills_path is exactly
# plugins/<plugin>/skills) keeps the recursive find. Matrix values
# arrive via env (validated upstream), and set -f keeps the
# skills_path word-split glob-safe.
if [ -n "$DISPATCH_SKILL" ]; then
CANDIDATE="tests/$PLUGIN/${DISPATCH_SKILL}/eval.yaml"
if [ ! -f "$CANDIDATE" ]; then
echo "::error::workflow_dispatch requested skill '${DISPATCH_SKILL}' in plugin '${PLUGIN}' but no eval spec exists at '${CANDIDATE}'. Check the skill name (case-sensitive) and that the eval file is present."
exit 1
fi
EVALS="$CANDIDATE"
else
EVALS=""
for token in $SKILLS_PATH; do
rel="${token#plugins/}" # <plugin>/skills[/<skill>]
tp="${rel%%/skills*}" # <plugin>
skill="${rel#*/skills}" # "" or "/<skill>"
skill="${skill#/}" # "" or "<skill>"
if [ -n "$skill" ]; then
# Sharded/PR entry: only this skill's spec, if one exists.
# agent.* skills are excluded from the vally experiment.
case "$skill" in
agent.*) continue ;;
esac
CANDIDATE="tests/$tp/$skill/eval.yaml"
[ -f "$CANDIDATE" ] && EVALS="${EVALS}${CANDIDATE}"$'\n'
else
# Whole-plugin entry: every eval spec under the plugin, except
# agent.* skills, which the vally experiment glob excludes (so
# counting them would report has_evals=true for zero effective
# evals).
FOUND=$(find "tests/$tp" -name "eval.yaml" -type f -not -path "*/agent.*/*")
[ -n "$FOUND" ] && EVALS="${EVALS}${FOUND}"$'\n'
fi
done
EVALS=$(printf '%s' "$EVALS" | sed '/^$/d' | sort -u)
fi
if [ -z "$EVALS" ]; then
echo "No eval.yaml files found for ${PLUGIN}"
echo "has_evals=false" >> $GITHUB_OUTPUT
else
echo "has_evals=true" >> $GITHUB_OUTPUT
echo "Found $(printf '%s\n' "$EVALS" | wc -l | tr -d ' ') eval specs"
fi
# Build (or restore from cache) the skill-validator binary. Vally has no
# overfitting judge, so we retain skill-validator's standalone
# `overfitting` command (same rationale as retaining its `check` linter)
# and run it alongside the Vally comparison. The cache key matches
# skill-check.yml, so the archive is shared across both workflows.
- name: Cache skill-validator archive
id: cache-validator
if: steps.find-evals.outputs.has_evals == 'true'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v4
with:
path: skill-validator-dist.tar.gz
key: skill-validator-${{ runner.os }}-${{ hashFiles('eng/skill-validator/src/**', 'eng/skill-validator/Directory.Build.props', 'global.json') }}
- name: Setup .NET SDK
if: steps.find-evals.outputs.has_evals == 'true' && steps.cache-validator.outputs.cache-hit != 'true'
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v5
with:
global-json-file: global.json
- name: Build skill-validator
if: steps.find-evals.outputs.has_evals == 'true' && steps.cache-validator.outputs.cache-hit != 'true'
run: dotnet publish eng/skill-validator/src/SkillValidator.csproj
- name: Archive skill-validator
if: steps.find-evals.outputs.has_evals == 'true' && steps.cache-validator.outputs.cache-hit != 'true'
run: tar -czf skill-validator-dist.tar.gz -C artifacts/publish/SkillValidator/release .
- name: Extract skill-validator
if: steps.find-evals.outputs.has_evals == 'true' && steps.cache-validator.outputs.cache-hit == 'true'
run: |
mkdir -p artifacts/publish/SkillValidator/release
tar -xzf skill-validator-dist.tar.gz -C artifacts/publish/SkillValidator/release
- name: Run vally evaluations
if: steps.find-evals.outputs.has_evals == 'true'
env:
GITHUB_TOKEN: ${{ steps.select-token.outputs.token }}
RESULTS_DIR: artifacts/TestResults/vally/${{ matrix.entry.name }}
PLUGIN: ${{ matrix.entry.plugin }}
SKILLS_PATH: ${{ matrix.entry.skills_path }}
DISPATCH_SKILL: ${{ inputs.skill }}
run: |
set -f
# PLUGIN/SKILLS_PATH/DISPATCH_SKILL arrive via env (validated upstream)
# rather than inline GitHub-expression interpolation, to avoid
# injection; set -f keeps the SKILLS_PATH word-split glob-safe.
#
# Scope the canonical experiment with --eval-filter (repeatable),
# which intersects each pattern with the experiment's `evals:` glob.
# `agent.*` evals are excluded by that glob, so they drop out
# automatically; a missing plugins/<plugin>/skills/<skill> dir surfaces
# as a skilled-variant failure that adapt.mjs warns on.
#
# Honor the matrix entry's skills_path so per-skill PR entries and
# sharded entries run only their targeted skills, not the whole plugin:
# plugins/<plugin>/skills -> tests/<plugin>/** (whole plugin)
# plugins/<plugin>/skills/<skill> -> tests/<plugin>/<skill>
# A workflow_dispatch skill input overrides to that single skill.
FILTER_ARGS=()
if [ -n "$DISPATCH_SKILL" ]; then
FILTER_ARGS=(--eval-filter "tests/$PLUGIN/$DISPATCH_SKILL/eval.yaml")
else
for token in $SKILLS_PATH; do
rel="${token#plugins/}" # <plugin>/skills[/<skill>]
tp="${rel%%/skills*}" # <plugin>
skill="${rel#*/skills}" # "" or "/<skill>"
skill="${skill#/}" # "" or "<skill>"
if [ -n "$skill" ]; then
# agent.* evals are excluded from the experiment's `evals:` glob,
# and a skill without an eval.yaml has nothing to run — skip
# both so we never pass a filter that matches zero evals.
if [[ "$skill" == agent.* ]]; then
echo "Skipping $tp/$skill (agent.* evals are excluded from the vally experiment)"
continue
fi
candidate="tests/$tp/$skill/eval.yaml"
if [ ! -f "$candidate" ]; then
echo "Skipping $tp/$skill (no eval.yaml)"
continue
fi
FILTER_ARGS+=(--eval-filter "$candidate")
else
FILTER_ARGS+=(--eval-filter "tests/$tp/**/eval.yaml")
fi
done
fi
if [ ${#FILTER_ARGS[@]} -eq 0 ]; then
# Every targeted skill was skipped above (agent.* or eval-less), so
# there is nothing to evaluate for this entry.
echo "No matching vally eval filters for $PLUGIN"
exit 0
fi
# Model + comparison-judge model come from the experiment file's
# `overrides:` block (single source of truth), matching eng/run-skill-evals.sh.
read_override() {
awk -v k="$1" '
/^overrides:/ { f = 1; next }
/^[^[:space:]]/ { f = 0 }
f && $1 == k":" { print $2; exit }
' dotnet-skills.experiment.yaml
}
MODEL="$(read_override model)"; MODEL="${MODEL:-${{ env.FALLBACK_MODEL }}}"
JUDGE_MODEL="$(read_override judge_model)"; JUDGE_MODEL="${JUDGE_MODEL:-${{ env.FALLBACK_JUDGE_MODEL }}}"
# Run baseline (no skills) + skilled (the one skill) + plugin (the whole
# plugin) in a single experiment pass. The baseline is a true skill-free
# control because vally does no ambient skill discovery — skills load
# only from environment.skills.
EXPERIMENT_OUT="$RESULTS_DIR/_experiment"
mkdir -p "$EXPERIMENT_OUT"
# The `plugin` variant (whole plugin loaded) can't be expressed
# statically: vally's environment.skills has no glob and each entry must
# be a directory with a SKILL.md. Since this leg is scoped to a single
# plugin, generate a per-leg experiment that appends a `plugin` variant
# enumerating plugins/$PLUGIN/skills/*. Fall back to the static
# baseline+skilled experiment if generation fails (Plugin columns are
# then simply absent — the dashboard degrades gracefully).
#
# The generated file MUST live at the content root ($GITHUB_WORKSPACE),
# alongside the base experiment: vally resolves an experiment's relative
# `evals:` globs and `environment.skills` paths against the experiment
# FILE's own directory, not the process CWD. Writing it under
# $RESULTS_DIR would make `tests/*/.../eval.yaml` resolve to zero files
# ("eval patterns matched zero files") and drop every eval silently.
EXPERIMENT_FILE="dotnet-skills.experiment.yaml"
if GEN=$(node eng/vally-adapter/gen-experiment.mjs \
--plugin "$PLUGIN" \
--base dotnet-skills.experiment.yaml \
--out "$GITHUB_WORKSPACE/_experiment.$PLUGIN.plugin.yaml"); then
EXPERIMENT_FILE="$GEN"
echo "Using generated experiment with plugin variant: $EXPERIMENT_FILE"
else
echo "::warning::Failed to generate plugin variant for $PLUGIN; running baseline+skilled only"
fi
vally experiment run "$EXPERIMENT_FILE" \
"${FILTER_ARGS[@]}" \
--output-dir "$EXPERIMENT_OUT" \
--workers 5 \
2>&1 || echo "::warning::vally experiment run reported failures for $PLUGIN"
RUN_DIR=$(find "$EXPERIMENT_OUT" -mindepth 1 -maxdepth 1 -type d | sort | tail -1)
if [ -z "$RUN_DIR" ]; then
echo "::warning::No experiment output produced for $PLUGIN"
exit 0
fi
# Run the retained skill-validator overfitting judge over the same
# skills this leg evaluated, before adapting. It reads GITHUB_TOKEN
# (already exported above) for its single LLM call per skill. Scope it
# to this leg's targets: a workflow_dispatch skill overrides to that
# one skill, otherwise honor the entry's skills_path. Failures are
# non-fatal — overfitting is an informational signal and must never
# break an otherwise-successful eval leg.
OVERFIT_PATHS=()
if [ -n "$DISPATCH_SKILL" ]; then
OVERFIT_PATHS=("plugins/$PLUGIN/skills/$DISPATCH_SKILL")
else
for token in $SKILLS_PATH; do OVERFIT_PATHS+=("$token"); done
fi
OVERFIT_FILE="$GITHUB_WORKSPACE/_overfitting.$PLUGIN.json"
OVERFIT_ARGS=()
if artifacts/publish/SkillValidator/release/skill-validator overfitting \
"${OVERFIT_PATHS[@]}" \
--tests-dir tests \
--model "$JUDGE_MODEL" \
--output "$OVERFIT_FILE"; then
OVERFIT_ARGS=(--overfitting "$OVERFIT_FILE")
else
echo "::warning::overfitting judge failed for $PLUGIN; results will omit overfitting flags"
fi
# Split the per-variant experiment output by eval and run `vally
# compare` per skill (in the adapter) to produce the per-skill
# results.json downstream jobs consume. The adapter auto-detects the
# `plugin` variant when present. `vally` is on PATH here.
node eng/vally-adapter/adapt.mjs \
--experiment-dir "$RUN_DIR" \
--output-root "$RESULTS_DIR" \
--vally "vally" \
--repo-root "$GITHUB_WORKSPACE" \
--model "$MODEL" \
--judge-model "$JUDGE_MODEL" \
"${OVERFIT_ARGS[@]}"
# Surface how many verdicts were produced.
PRODUCED=$(find "$RESULTS_DIR" -name results.json -not -path "$EXPERIMENT_OUT/*" | wc -l | tr -d ' ')
echo "Produced $PRODUCED skill verdict(s) for $PLUGIN"
if [ "$PRODUCED" -eq 0 ]; then
echo "::warning::vally produced no skill verdicts for $PLUGIN — evals failed to run or validate (see logs above)"
fi
- name: Upload results
if: always() && steps.find-evals.outputs.has_evals == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: vally-results-${{ matrix.entry.name }}
path: artifacts/TestResults/vally/${{ matrix.entry.name }}/
include-hidden-files: true
retention-days: 14
- name: Write summary
if: always() && steps.find-evals.outputs.has_evals == 'true'
env:
ENTRY_NAME: ${{ matrix.entry.name }}
run: |
echo "## 🔬 Vally Evaluation Results: $ENTRY_NAME" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Skilled vs baseline, judged head-to-head by \`vally compare\`. A skill passes only on a complete comparison with a credible improvement (mean preference > 0 with its 95% CI above 0)." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
RESULTS_DIR="artifacts/TestResults/vally/$ENTRY_NAME"
while IFS= read -r RESULTS_JSON; do
if [ ! -f "$RESULTS_JSON" ]; then continue; fi
EVAL_NAME=$(basename "$(dirname "$RESULTS_JSON")")
CONCLUSIVE=$(node -e "const r=JSON.parse(require('fs').readFileSync(process.argv[1],'utf-8')); console.log(r.verdicts[0].conclusive)" "$RESULTS_JSON")
PASSED=$(node -e "const r=JSON.parse(require('fs').readFileSync(process.argv[1],'utf-8')); console.log(r.verdicts[0].passed)" "$RESULTS_JSON")
REASON=$(node -e "const r=JSON.parse(require('fs').readFileSync(process.argv[1],'utf-8')); console.log(r.verdicts[0].reason)" "$RESULTS_JSON")
if [ "$CONCLUSIVE" != "true" ]; then
ICON="⚠️"
elif [ "$PASSED" = "true" ]; then
ICON="✅"
else
ICON="❌"
fi
echo "### $ICON $EVAL_NAME" >> $GITHUB_STEP_SUMMARY
echo "$REASON" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
# Per-scenario table: mean preference score and win/tie/loss.
echo "| Scenario | Mean preference | Trials (W/T/L) |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-----------------|----------------|" >> $GITHUB_STEP_SUMMARY
node -e "
const r = JSON.parse(require('fs').readFileSync(process.argv[1], 'utf-8'));
for (const s of r.verdicts[0].scenarios) {
const icon = s.meanScore > 0 ? '▲' : s.meanScore < 0 ? '▼' : '=';
const pct = (s.meanScore >= 0 ? '+' : '') + (s.meanScore * 100).toFixed(1) + '%';
let w=0,t=0,l=0;
for (const tr of (s.trials||[])) { if (tr.score>0) w++; else if (tr.score<0) l++; else t++; }
console.log('| ' + icon + ' ' + s.scenarioName + ' | ' + pct + ' | ' + w + '/' + t + '/' + l + ' |');
}
" "$RESULTS_JSON" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
done < <(find "$RESULTS_DIR" -name results.json | sort)