Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
7a54b98
docs(specs): add eval PR fork dispatch design
mrizzi May 7, 2026
62e083a
docs(plans): add eval PR fork dispatch implementation plan
mrizzi May 7, 2026
0bd527b
refactor(ci): strip eval-pr.yml to discovery-only with artifact upload
mrizzi May 7, 2026
de4e3b0
feat(ci): add eval-pr-run.yml for fork PR eval dispatch
mrizzi May 7, 2026
a712d42
docs(specs): add fork dispatch cross-reference to eval CI spec
mrizzi May 7, 2026
3569a1e
fix(ci): harden eval dispatch against artifact poisoning and injection
mrizzi May 7, 2026
b219c16
fix(ci): cross-validate PR number and remove unused head_sha output
mrizzi May 7, 2026
d3cc5f9
fix(ci): remove branches filter and drop author from artifact
mrizzi May 7, 2026
206a78d
fix(ci): remove artifact influence on PR selection
mrizzi May 7, 2026
f6f627b
docs(specs): sync spec with implementation
mrizzi May 8, 2026
fb1ce73
refactor(ci): eliminate artifact — discover skills from checkout
mrizzi May 8, 2026
7f0efc0
docs(specs): sync spec with artifact-free implementation
mrizzi May 8, 2026
2d1fb52
fix(ci): use pulls.list for fork PR resolution
mrizzi May 8, 2026
cd98189
fix(ci): add pagination to pulls.list for PR resolution
mrizzi May 8, 2026
e5dbf59
feat(ci): add commit status reporting for PR visibility
mrizzi May 8, 2026
9f0a6eb
fix(ci): resolve stale pending status when discover fails
mrizzi May 8, 2026
461672f
docs(specs): update report-status to reflect always() condition
mrizzi May 8, 2026
37266be
docs(plans): mark plan as superseded by evolved implementation
mrizzi May 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
309 changes: 309 additions & 0 deletions .github/workflows/eval-pr-run.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,309 @@
# Runs evals for PRs via workflow_run dispatch, enabling secret access
# for fork PRs. Triggered by eval-pr.yml completion (path-filtered gate).
#
# Trust model:
# - Collaborators with write/admin permission: evals run automatically
# - External contributors: require manual approval via eval-protected environment
#
# All PR identity and skill discovery is derived from the GitHub API and
# the checked-out PR merge commit — no data from the triggering workflow
# is trusted.
#
# See docs/specs/2026-05-07-eval-pr-fork-dispatch-design.md for full design.

name: Eval PR Run

on:
workflow_run:
workflows: ["Eval PR"]
types: [completed]

permissions:
contents: read
pull-requests: write
actions: read
statuses: write

jobs:
discover:
name: Discover PR Context
if: github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
outputs:
skills: ${{ steps.discover.outputs.skills }}
pr_number: ${{ steps.pr.outputs.pr_number }}
author: ${{ steps.pr.outputs.author }}
trusted: ${{ steps.pr.outputs.trusted }}
steps:
- name: Set pending commit status
uses: actions/github-script@v7
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.payload.workflow_run.head_sha,
state: 'pending',
description: 'Eval run in progress...',
context: 'Eval PR Run',
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
});

- name: Resolve PR identity and check trust
id: pr
uses: actions/github-script@v7
with:
script: |
// listPullRequestsAssociatedWithCommit returns empty for fork PRs
// because the commit lives in the fork's repo, not the base repo's
// commit graph. Use pulls.list filtered by base branch instead.
const headSha = context.payload.workflow_run.head_sha;
const prs = await github.paginate(github.rest.pulls.list, {
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
base: 'main',
per_page: 100
});

const pr = prs.find(p => p.head.sha === headSha);
if (!pr) {
core.setFailed(`No open PR targeting main found for commit ${headSha}`);
return;
}
core.setOutput('pr_number', pr.number.toString());
core.setOutput('base_sha', pr.base.sha);
core.setOutput('author', pr.user.login);

let trusted = false;
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username: pr.user.login
});
trusted = ['admin', 'write'].includes(data.permission);
console.log(`Author ${pr.user.login}: permission=${data.permission}, trusted=${trusted}`);
} catch (e) {
console.log(`Author ${pr.user.login}: not a collaborator (${e.status}), trusted=false`);
}

core.setOutput('trusted', trusted.toString());

- name: Checkout PR merge commit
uses: actions/checkout@v4
with:
ref: refs/pull/${{ steps.pr.outputs.pr_number }}/merge
fetch-depth: 0

- name: Discover changed skills
id: discover
env:
BASE_SHA: ${{ steps.pr.outputs.base_sha }}
run: |
changed_files=$(git diff --name-only "$BASE_SHA"...HEAD)
echo "Changed files:"
echo "$changed_files"

skills=""
for file in $changed_files; do
skill=""
if [[ "$file" =~ ^plugins/sdlc-workflow/skills/([^/]+)/ ]]; then
skill="${BASH_REMATCH[1]}"
elif [[ "$file" =~ ^evals/([^/]+)/ ]]; then
skill="${BASH_REMATCH[1]}"
fi

if [ -n "$skill" ] && [ -f "evals/${skill}/evals.json" ]; then
if [[ ! ",$skills," == *",$skill,"* ]]; then
skills="${skills:+${skills},}${skill}"
fi
fi
done

echo "skills=${skills}" >> "$GITHUB_OUTPUT"
echo "Discovered changed skills with evals: ${skills:-none}"

- name: Update status for approval gate
if: steps.pr.outputs.trusted != 'true' && steps.pr.outputs.pr_number != ''
uses: actions/github-script@v7
with:
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.payload.workflow_run.head_sha,
state: 'pending',
description: 'Waiting for approval in eval-protected environment',
context: 'Eval PR Run',
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
});

gate:
name: Approval Gate
needs: discover
if: >-
needs.discover.result == 'success' &&
needs.discover.outputs.trusted != 'true' &&
needs.discover.outputs.skills != ''
runs-on: ubuntu-latest
environment: eval-protected
steps:
- run: echo "Approved by reviewer"

run-evals:
name: Run PR Evals
needs: [discover, gate]
if: >-
!cancelled() &&
needs.discover.result == 'success' &&
needs.discover.outputs.skills != '' &&
(needs.discover.outputs.trusted == 'true' || needs.gate.result == 'success')
runs-on: ubuntu-latest
steps:
- name: Checkout PR merge commit
uses: actions/checkout@v4
with:
ref: refs/pull/${{ needs.discover.outputs.pr_number }}/merge
fetch-depth: 0

- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v3
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}

- name: Install Claude Code
run: curl -fsSL https://claude.ai/install.sh | bash

- name: Run PR evals
env:
SKILLS_CSV: ${{ needs.discover.outputs.skills }}
CLAUDE_CODE_USE_VERTEX: "1"
CLOUD_ML_REGION: ${{ secrets.GCP_CLOUD_ML_REGION }}
ANTHROPIC_DEFAULT_SONNET_MODEL: "claude-sonnet-4-6"
ANTHROPIC_DEFAULT_OPUS_MODEL: "claude-opus-4-6"
ANTHROPIC_MODEL: "claude-opus-4-6"
run: |
IFS=',' read -ra SKILLS <<< "$SKILLS_CSV"

for skill in "${SKILLS[@]}"; do
eval_count=$(jq '.evals | length' "evals/${skill}/evals.json")
workspace="/tmp/${skill}-eval-pr"
mkdir -p "${workspace}"

echo "=== Running PR evals for ${skill} (${eval_count} cases) ==="
echo "Workspace: ${workspace}"

claude -p "$(cat <<PROMPT
Use the sdlc-workflow:run-evals skill for skill /${skill}.
Evals path: evals/${skill}/evals.json
Workspace: ${workspace}
PROMPT
)" --permission-mode dontAsk \
--allowedTools Read Write Bash Skill Agent Glob \
2>&1 || {
echo "::warning::PR eval run failed for ${skill} (exit $?)"
continue
}

echo "--- Workspace contents ---"
find "${workspace}" -type f 2>/dev/null | head -80 || true
done

- name: Post eval results review
env:
SKILLS_CSV: ${{ needs.discover.outputs.skills }}
PR_NUMBER: ${{ needs.discover.outputs.pr_number }}
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const skills = process.env.SKILLS_CSV.split(',').filter(Boolean);
const prNumber = parseInt(process.env.PR_NUMBER);

let body = '## Eval Results\n\n';
for (const skill of skills) {
const summaryPath = `/tmp/${skill}-eval-pr/summary.md`;
if (fs.existsSync(summaryPath)) {
body += fs.readFileSync(summaryPath, 'utf8');
} else {
body += `### ${skill}\n\n> No results produced. See workflow logs.\n\n`;
}
}

const pluginJson = JSON.parse(fs.readFileSync('plugins/sdlc-workflow/.claude-plugin/plugin.json', 'utf8'));
const version = pluginJson.version;

body += '---\n';
body += `*Generated by [sdlc-workflow/run-evals](plugins/sdlc-workflow/skills/run-evals) v${version}*\n`;

const { data: reviews } = await github.rest.pulls.listReviews({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber
});
const marker = '## Eval Results';
const existing = reviews.find(r =>
r.user?.login === 'github-actions[bot]' && r.body?.startsWith(marker)
);

if (existing) {
await github.rest.pulls.updateReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
review_id: existing.id,
body
});
} else {
await github.rest.pulls.createReview({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: prNumber,
event: 'COMMENT',
body
});
}

report-status:
name: Report Status
needs: [discover, gate, run-evals]
if: always()
runs-on: ubuntu-latest
steps:
- name: Set final commit status
env:
DISCOVER_RESULT: ${{ needs.discover.result }}
EVALS_RESULT: ${{ needs.run-evals.result }}
GATE_RESULT: ${{ needs.gate.result }}
uses: actions/github-script@v7
with:
script: |
const discoverResult = process.env.DISCOVER_RESULT;
const evalsResult = process.env.EVALS_RESULT;
const gateResult = process.env.GATE_RESULT;

let state, description;
if (evalsResult === 'success') {
state = 'success';
description = 'Eval run completed — results posted as PR review';
} else if (discoverResult !== 'success') {
state = 'failure';
description = 'Eval run failed — could not resolve PR context';
} else if (gateResult === 'failure') {
state = 'failure';
description = 'Approval was rejected in eval-protected environment';
} else {
state = 'failure';
description = 'Eval run failed — check workflow logs';
}

await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: context.payload.workflow_run.head_sha,
state,
description,
context: 'Eval PR Run',
target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`
});
Loading
Loading