Skip to content

refactor(skills): migrate pr-risk-assessment from CLAUDE_CONFIG_DIR to FULLSEND_SKILLS_DIR #4315

refactor(skills): migrate pr-risk-assessment from CLAUDE_CONFIG_DIR to FULLSEND_SKILLS_DIR

refactor(skills): migrate pr-risk-assessment from CLAUDE_CONFIG_DIR to FULLSEND_SKILLS_DIR #4315

name: Functional Tests

Check warning on line 1 in .github/workflows/functional-tests.yml

View workflow run for this annotation

GitHub Actions / Functional Tests

Workflow execution policy warning (evaluate mode)

On December 2, 2026, GitHub will restrict `pull_request_target` on public repositories by default. To continue allowing the event trigger, configure an Actions policy. Learn more: https://gh.io/securely-using-pull_request_target#default-policy-for-pull_request_target
# PR-triggered functional tests use pull_request_target so fork PRs receive
# secrets. Authorization runs in a separate gate job (base checkout only)
# before the test job checks out the PR head — same pattern as fullsend e2e.
#
# Each change runs the full tier once, in the merge queue, on the commit that
# lands. PR runs use the release tier (one release:true case per agent)
# unless the PR carries the eval-full label. Applying eval-full does not
# start a run by itself (only ok-to-test does, so authorization stays
# tied to a fresh ok-to-test); it applies from the next push or
# ok-to-test run. There is no push-to-main run:
# the merge queue already tested that merge commit, and the nightly
# (functional-tests-nightly.yml) runs the full tier for every agent.
permissions: {}
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled]
merge_group:
workflow_dispatch:
inputs:
tier:
description: 'Eval tier: auto (script default), full or release'
required: false
type: choice
options: [auto, full, release]
default: auto
workflow_call:
inputs:
fullsend_ref:
description: 'fullsend ref to build from (main or release tag v*)'
required: false
type: string
default: main
tier:
# Empty keeps eval/run-functional.sh's caller-derived default
# (release for a cross-repo call), so callers that omit it -- and
# callers pinned to an older copy of this workflow -- behave the same.
description: "Eval tier: '' or auto (script default), full or release"
required: false
type: string
default: ''
secrets:
E2E_GCP_WIF_PROVIDER:
required: false
E2E_GCP_SERVICE_ACCOUNT:
required: false
E2E_GCP_PROJECT_ID:
required: false
EVAL_GH_TOKEN:
required: false
outputs:
agents_sha:
description: 'Resolved commit SHA of the agents checkout (for cross-repo tagging)'
value: ${{ jobs.detect.outputs.agents_sha }}
concurrency:
group: >-
${{ github.event_name == 'pull_request_target'
&& format('functional-{0}', github.event.pull_request.number)
|| github.repository != 'fullsend-ai/agents'
&& format('functional-call-{0}', github.run_id)
|| format('{0}-{1}', github.workflow, github.ref) }}
cancel-in-progress: >-
${{ github.event_name == 'pull_request_target'
|| github.ref != 'refs/heads/main' }}
jobs:
gate:
# Separate job so pull-requests: write stays out of the job that checks
# out fork head and runs tests with secrets.
# Never checkout github.event.pull_request.head.sha here.
if: >-
github.event_name == 'pull_request_target' &&
(github.event.action != 'labeled' || github.event.label.name == 'ok-to-test')
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
pull-requests: write
outputs:
authorized: ${{ steps.auth.outputs.authorized }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.sha }} # Base branch only — never checkout PR head in gate
- name: Check PR authorization
id: auth
uses: ./.github/actions/check-e2e-authorization
with:
pr_number: ${{ github.event.pull_request.number }}
repository: ${{ github.repository }}
pr_updated_at: ${{ github.event.pull_request.updated_at }}
event_action: ${{ github.event.action }}
pr_author_association: ${{ github.event.pull_request.author_association }}
pr_author_login: ${{ github.event.pull_request.user.login }}
label_actor_login: ${{ github.event.sender.login }}
# ---------------------------------------------------------------------------
# Detect which agents need functional testing based on changed files.
# Lightweight job: checkout + yq + select-eval-agents.sh → JSON array.
# ---------------------------------------------------------------------------
detect:
needs: gate
if: >-
!cancelled() &&
github.repository_owner == 'fullsend-ai' &&
(github.event_name != 'pull_request_target' || needs.gate.outputs.authorized == 'true')
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
outputs:
agents: ${{ steps.agents.outputs.agents }}
agents_sha: ${{ steps.checkout.outputs.commit }}
steps:
- name: Get changed files
id: changes
if: >-
github.repository == 'fullsend-ai/agents' &&
(github.event_name == 'pull_request_target' ||
github.event_name == 'merge_group')
env:
GH_TOKEN: ${{ github.token }}
EVENT_NAME: ${{ github.event_name }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
MERGE_GROUP_BASE: ${{ github.event.merge_group.base_sha }}
MERGE_GROUP_HEAD: ${{ github.event.merge_group.head_sha }}
run: |
if [ "$EVENT_NAME" = "merge_group" ]; then
FILES=$(gh api "repos/${REPO}/compare/${MERGE_GROUP_BASE}...${MERGE_GROUP_HEAD}" --jq '.files[].filename') || {
echo "::warning::Failed to fetch merge group files — running all functional tests as a precaution"
exit 0
}
FILE_COUNT=$(echo "$FILES" | wc -l)
if [ "$FILE_COUNT" -ge 300 ]; then
echo "::warning::Compare API returned $FILE_COUNT files (possible truncation at 300) — running all functional tests as a precaution"
exit 0
fi
else
FILES=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/files" --paginate --jq '.[].filename') || {
echo "::warning::Failed to fetch PR files — running all functional tests as a precaution"
exit 0
}
FILE_COUNT=$(echo "$FILES" | wc -l)
if [ "$FILE_COUNT" -ge 300 ]; then
echo "::warning::PR file list returned $FILE_COUNT files (possible truncation) — running all functional tests as a precaution"
exit 0
fi
fi
DELIM="ghadelim_$(openssl rand -hex 8)"
{
echo "files<<${DELIM}"
echo "$FILES"
echo "${DELIM}"
} >> "$GITHUB_OUTPUT"
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
id: checkout
with:
repository: ${{ github.repository != 'fullsend-ai/agents' && 'fullsend-ai/agents' || github.repository }}
ref: >-
${{ github.repository != 'fullsend-ai/agents'
&& 'main'
|| (github.event_name == 'pull_request_target'
&& github.event.pull_request.head.sha
|| github.sha) }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}
- name: Install yq
run: |
curl -sSfL "https://github.com/mikefarah/yq/releases/download/v4.47.1/yq_linux_amd64" -o /usr/local/bin/yq
echo "0fb28c6680193c41b364193d0c0fc4a03177aecde51cfc04d506b1517158c2fb /usr/local/bin/yq" | sha256sum -c
chmod +x /usr/local/bin/yq
- name: Select eval agents
id: agents
env:
CHANGED_FILES: ${{ steps.changes.outputs.files }}
run: |
if [ -n "$CHANGED_FILES" ]; then
AGENTS=$(echo "$CHANGED_FILES" | bash .github/scripts/select-eval-agents.sh)
else
# No diff (dispatch, workflow_call, or a failed diff fetch): run all
# agents with eval configs
AGENTS=$(for d in eval/*/eval.yaml; do basename "$(dirname "$d")"; done)
fi
JSON=$(echo "$AGENTS" | jq -Rsc '[split("\n")[] | select(length > 0)]')
echo "agents=$JSON" >> "$GITHUB_OUTPUT"
echo "Selected agents: $JSON"
# ---------------------------------------------------------------------------
# Run functional tests — one matrix leg per agent, in parallel.
# Each leg does full setup (python, fullsend, podman, etc.) independently.
# ---------------------------------------------------------------------------
functional-tests:
needs: [gate, detect]
if: >-
!cancelled() &&
github.repository_owner == 'fullsend-ai' &&
needs.detect.outputs.agents != '[]' &&
(github.event_name != 'pull_request_target' || needs.gate.outputs.authorized == 'true')
runs-on: ubuntu-24.04
# The full-tier review leg takes 39-43 min (9 live cases); 60 leaves
# headroom. The release tier runs one case per agent.
timeout-minutes: 60
permissions:
contents: read
id-token: write
strategy:
fail-fast: false
matrix:
agent: ${{ fromJSON(needs.detect.outputs.agents) }}
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.repository != 'fullsend-ai/agents' && 'fullsend-ai/agents' || github.repository }}
ref: >-
${{ github.repository != 'fullsend-ai/agents'
&& (needs.detect.outputs.agents_sha || 'main')
|| (github.event_name == 'pull_request_target'
&& github.event.pull_request.head.sha
|| github.sha) }}
persist-credentials: false
allow-unsafe-pr-checkout: ${{ github.event_name == 'pull_request_target' }}
submodules: true
- uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.12"
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
- name: Install agent-eval-harness
run: uv pip install --system -e 'eval/.agent-eval-harness[anthropic]'
- name: Install yq
run: |
curl -sSfL "https://github.com/mikefarah/yq/releases/download/v4.47.1/yq_linux_amd64" -o /usr/local/bin/yq
echo "0fb28c6680193c41b364193d0c0fc4a03177aecde51cfc04d506b1517158c2fb /usr/local/bin/yq" | sha256sum -c
chmod +x /usr/local/bin/yq
- name: Configure git identity
run: |
git config --global user.name "fullsend-eval[bot]"
git config --global user.email "fullsend-eval[bot]@users.noreply.github.com"
- name: Validate fullsend ref
env:
FULLSEND_REF: ${{ inputs.fullsend_ref || 'main' }}
run: |
if [[ "${FULLSEND_REF}" != "main" && ! "${FULLSEND_REF}" =~ ^v[0-9] ]]; then
echo "::error::fullsend_ref must be 'main' or a release tag (v*), got '${FULLSEND_REF}'"
exit 1
fi
- name: Clone fullsend
env:
GH_TOKEN: ${{ github.token }}
FULLSEND_REF: ${{ inputs.fullsend_ref || 'main' }}
run: |
FULLSEND_CHECKOUT="${RUNNER_TEMP}/fullsend"
git clone --depth 1 --branch "${FULLSEND_REF}" \
"https://x-access-token:${GH_TOKEN}@github.com/fullsend-ai/fullsend.git" \
"${FULLSEND_CHECKOUT}"
echo "FULLSEND_CHECKOUT=${FULLSEND_CHECKOUT}" >> "$GITHUB_ENV"
- name: Setup Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: ${{ env.FULLSEND_CHECKOUT }}/go.mod
cache-dependency-path: ${{ env.FULLSEND_CHECKOUT }}/go.sum
- name: Build fullsend
run: |
make -C "${FULLSEND_CHECKOUT}" go-build
echo "${FULLSEND_CHECKOUT}/bin" >> "$GITHUB_PATH"
"${FULLSEND_CHECKOUT}/bin/fullsend" --version
- name: Install Podman
run: |
sudo apt-get update
sudo apt-get install -y podman
- name: Configure rootless Podman
run: |
whoami_user="$(whoami)"
grep -q "^${whoami_user}:" /etc/subuid || sudo usermod --add-subuids 100000-165535 --add-subgids 100000-165535 "${whoami_user}"
podman system migrate
- name: Start Podman API service
run: |
# Socket-activated under the user's systemd, as in fullsend's
# action.yml. The OpenShell gateway is a systemd user service that
# pins the podman driver and needs this socket to answer when it
# starts. With a `podman system service &` started from a step
# instead, it failed with "no responsive Podman API socket found".
systemctl --user start podman.socket
SOCKET_PATH="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/podman/podman.sock"
for _i in $(seq 1 30); do
podman --url "unix://${SOCKET_PATH}" info >/dev/null 2>&1 && break
sleep 1
done
if ! podman --url "unix://${SOCKET_PATH}" info >/dev/null 2>&1; then
echo "::error::Podman socket not ready at ${SOCKET_PATH}"
systemctl --user status podman.socket podman.service --no-pager || true
journalctl --user -u podman.socket -u podman.service --no-pager -n 40 || true
exit 1
fi
# After Podman: the gateway pins compute_driver = "podman" and does not
# start without the Podman API socket.
- name: Configure OpenShell gateway
run: |
eval "$(grep -E '^OPENSHELL_(VERSION|SHA)=' "${FULLSEND_CHECKOUT}/.github/scripts/openshell-version.sh")"
if [ -z "$OPENSHELL_VERSION" ] || [ -z "$OPENSHELL_SHA" ]; then
echo "::error::Failed to resolve OpenShell version from fullsend checkout"
exit 1
fi
echo "OPENSHELL_VERSION=${OPENSHELL_VERSION}" >> "$GITHUB_ENV"
echo "OPENSHELL_SHA=${OPENSHELL_SHA}" >> "$GITHUB_ENV"
mkdir -p "$HOME/.config/openshell"
echo "OPENSHELL_BIND_ADDRESS=0.0.0.0" > "$HOME/.config/openshell/gateway.env"
cat > "$HOME/.config/openshell/gateway.toml" << EOF
[openshell]
version = 2
[openshell.gateway]
compute_driver = "podman"
[openshell.drivers.podman]
supervisor_image = "ghcr.io/nvidia/openshell/supervisor:${OPENSHELL_VERSION}"
health_check_interval_secs = 10
EOF
- name: Install OpenShell CLI
run: |
"${FULLSEND_CHECKOUT}/.github/scripts/install-openshell.sh"
- name: Install validation dependencies
run: pip install --quiet "jsonschema>=4.18.0"
- name: Check for secrets
id: secrets-check
run: |
if [ -z "$WIF_PROVIDER" ]; then
echo "::warning::GCP secrets are not configured. Skipping functional tests."
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi
env:
WIF_PROVIDER: ${{ secrets.E2E_GCP_WIF_PROVIDER }}
- name: Authenticate to GCP
if: steps.secrets-check.outputs.available == 'true'
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3.0.0
with:
workload_identity_provider: ${{ secrets.E2E_GCP_WIF_PROVIDER }}
service_account: ${{ secrets.E2E_GCP_SERVICE_ACCOUNT }}
- name: Prepare sandbox credentials
if: steps.secrets-check.outputs.available == 'true'
run: |
echo "HOST_GOOGLE_APPLICATION_CREDENTIALS=$GOOGLE_APPLICATION_CREDENTIALS" >> "$GITHUB_ENV"
if [[ -f scripts/prepare-sandbox-credentials.sh ]]; then
bash scripts/prepare-sandbox-credentials.sh
fi
- name: Run functional tests
if: steps.secrets-check.outputs.available == 'true'
env:
EVAL_ORG: ${{ vars.EVAL_ORG }}
GH_TOKEN: ${{ secrets.EVAL_GH_TOKEN }}
ANTHROPIC_VERTEX_PROJECT_ID: ${{ vars.EVALS_VERTEX_PROJECT_ID }}
GOOGLE_CLOUD_PROJECT: ${{ secrets.E2E_GCP_PROJECT_ID }}
CLOUD_ML_REGION: ${{ vars.EVALS_GCP_REGION }}
EVALS_HOST_CREDENTIALS: ${{ env.HOST_GOOGLE_APPLICATION_CREDENTIALS }}
FULLSEND_DIR: ${{ github.workspace }}
AGENT: ${{ matrix.agent }}
# An explicit tier input (dispatch, workflow_call) wins; a
# workflow_call sees its caller's event_name, so the input must
# be checked first. Otherwise merge_group runs full, and
# pull_request_target runs release, or full with the eval-full
# label. Anything else leaves EVAL_TIER empty, i.e. the script's
# default.
EVAL_TIER: >-
${{ (inputs.tier != '' && inputs.tier != 'auto') && inputs.tier
|| github.event_name == 'merge_group' && 'full'
|| github.event_name == 'pull_request_target'
&& (contains(github.event.pull_request.labels.*.name, 'eval-full') && 'full' || 'release')
|| '' }}
run: ./eval/run-functional.sh "$AGENT"
# Protected-path change (human review required): scrub eval artifacts before
# upload so ::add-mask:: values (EVAL_GH_TOKEN) are not archived. See PR #184.
- name: Scrub secrets from eval results
id: scrub-eval-results
if: always() && steps.secrets-check.outputs.available == 'true'
run: bash eval/scripts/scrub-eval-results.sh eval/runs /tmp/agent-eval
# Upload only when scrub succeeded — never publish if live secrets remain.
- name: Upload eval results
if: >-
always()
&& steps.secrets-check.outputs.available == 'true'
&& steps.scrub-eval-results.outcome == 'success'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: eval-results-${{ matrix.agent }}
path: |
eval/runs/
!eval/runs/**/.eval-env
retention-days: 30
# ---------------------------------------------------------------------------
# Roll-up job with a stable name for branch protection required checks.
# Require "functional-tests-complete" in repo settings — it handles all
# dynamic matrix shapes including empty (no agents to test).
# ---------------------------------------------------------------------------
functional-tests-complete:
needs: [gate, detect, functional-tests]
# Always runs. A pull_request_target `labeled` event for a label other
# than ok-to-test is handled inside check-rollup-result.sh, which mirrors
# the previous verdict on this commit rather than asserting a new one.
# Guarding this job with the gate's own `if:` was tried and rejected: a
# skipped job still publishes a check run that supersedes the previous
# one, and `skipped` satisfies a required check — so it would turn a
# genuinely failing run green the moment anyone labelled the PR. See #954.
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 1
permissions:
contents: read
# Read-only: the roll-up mirrors the previous check run of its own name
# on this commit when a label event carries no verdict.
checks: read
steps:
# Base branch only -- a PR must not be able to rewrite this script
# to force-pass its own roll-up check.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
repository: ${{ github.repository != 'fullsend-ai/agents' && 'fullsend-ai/agents' || github.repository }}
ref: >-
${{ github.repository != 'fullsend-ai/agents'
&& (needs.detect.outputs.agents_sha || 'main')
|| github.sha }}
persist-credentials: false
sparse-checkout: .github/scripts
- name: Check results
env:
EVENT_NAME: ${{ github.event_name }}
EVENT_ACTION: ${{ github.event.action }}
LABEL_NAME: ${{ github.event.label.name }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
GATE_RESULT: ${{ needs.gate.result }}
DETECT_RESULT: ${{ needs.detect.result }}
TESTS_RESULT: ${{ needs.functional-tests.result }}
CROSS_REPO: ${{ github.repository != 'fullsend-ai/agents' }}
GH_TOKEN: ${{ github.token }}
run: bash .github/scripts/check-rollup-result.sh