Skip to content

[WIP] Add support for eval-level default expect checks #461

[WIP] Add support for eval-level default expect checks

[WIP] Add support for eval-level default expect checks #461

Workflow file for this run

name: CI
on:
push:
branches: [main]
paths-ignore:
- 'docs/**'
- '*.md'
- '.github/workflows/docs.yml'
pull_request:
branches: [main]
paths-ignore:
- 'docs/**'
- '*.md'
- '.github/workflows/docs.yml'
# Cancel superseded runs for the same PR so a new push does not leave the
# previous run occupying a shared GitHub-hosted runner slot. Pushes to main
# fall back to the unique run_id, giving every main run its own group so it is
# never cancelled — not while executing (cancel-in-progress is false off PRs)
# and not while queued (a shared group drops an older pending run when a newer
# one lands, regardless of cancel-in-progress), so the full matrix and the
# coverage-badge commit job always run to completion. The group is prefixed
# with github.workflow because concurrency groups are repo-wide, not scoped to
# a single workflow.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# Default the workflow token to read-only. Jobs that execute PR-supplied
# code (build/test, e2e, lint) must never be able to push to the repo, even
# on same-repository PRs where GITHUB_TOKEN would otherwise be writable.
# Only the dedicated badge-commit job below opts back into contents: write.
permissions:
contents: read
jobs:
build:
name: Build & Test
runs-on: ${{ matrix.os }}
strategy:
# Surface failures on every OS independently instead of cancelling the
# whole matrix when one runner fails.
fail-fast: false
matrix:
# Windows runners sit in a smaller, longer-queuing pool. PRs run
# Linux-only to keep the shared-runner queue short; the full matrix
# (including windows-latest) still runs on pushes to main.
os: ${{ github.event_name == 'pull_request' && fromJSON('["ubuntu-latest"]') || fromJSON('["ubuntu-latest", "windows-latest"]') }}
go-version: ["1.25.x"]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: ${{ matrix.go-version }}
cache: true
- name: Verify dependencies
run: go mod verify
- name: Build
run: go build ./...
# Force bash on Windows runners too: pwsh's legacy native-argument
# passing splits `-coverprofile=coverage.out` and feeds `.out` to go
# as a package import path, producing `FAIL .out [setup failed]`.
# Git Bash is preinstalled on windows-latest and parses args verbatim.
- name: Test (with race detector and coverage)
shell: bash
run: go test -race -timeout 120s -covermode=atomic -coverpkg=./... -coverprofile=coverage.out ./...
# Self-hosted coverage badge: parse the total from `go tool cover -func`
# and rewrite .github/badges/coverage.json. The shields.io endpoint badge
# in README.md reads this JSON via raw.githubusercontent.com.
# Ubuntu-only: the Windows leg of the matrix uses a different shell
# toolchain and we only need one canonical coverage number.
- name: Compute coverage and update badge
id: coverage
if: matrix.os == 'ubuntu-latest'
run: |
pct=$(go tool cover -func=coverage.out | awk '/^total:/ {gsub("%","",$3); print $3}')
if [ -z "$pct" ]; then
echo "could not parse total coverage" >&2
exit 1
fi
color=$(awk -v p="$pct" 'BEGIN {
if (p+0 >= 80) print "brightgreen";
else if (p+0 >= 60) print "yellow";
else print "red";
}')
mkdir -p .github/badges
printf '{\n "schemaVersion": 1,\n "label": "coverage",\n "message": "%s%%",\n "color": "%s"\n}\n' \
"$pct" "$color" > .github/badges/coverage.json
echo "pct=$pct" >> "$GITHUB_OUTPUT"
echo "color=$color" >> "$GITHUB_OUTPUT"
echo "::notice::Total coverage: ${pct}% (${color})"
# Hand the badge to the push-only update-coverage-badge job below via
# an artifact. PRs (including forks) still run the compute step above
# for visibility, but skip the upload so they can never trigger a push.
#
# include-hidden-files: true is required — the badge lives under
# .github/, and actions/upload-artifact defaults to excluding files
# whose path contains any dot-prefixed segment. Without this the
# upload would silently match nothing and, combined with
# if-no-files-found: error, fail the build job on every push to main.
- name: Upload coverage badge artifact
if: matrix.os == 'ubuntu-latest' && github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-badge
path: .github/badges/coverage.json
if-no-files-found: error
include-hidden-files: true
retention-days: 1
update-coverage-badge:
name: Commit coverage badge
needs: build
# Gate the entire job — not just individual steps — on push-to-main so
# the contents: write token below is never issued for pull_request runs.
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: badges
- name: Download coverage badge artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: coverage-badge
path: .github/badges/
- name: Commit updated coverage badge
run: |
if git diff --quiet -- .github/badges/coverage.json; then
echo "coverage badge unchanged, nothing to commit"
exit 0
fi
pct=$(awk -F'"' '/"message"/ {print $4}' .github/badges/coverage.json)
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add .github/badges/coverage.json
git commit -m "chore(ci): update coverage badge to ${pct} [skip ci]"
git push origin badges
e2e-windows:
# Windows e2e is intentionally narrower than the Linux e2e: it does not
# set SKILL_UP_FULL_E2E, so the LLM-dependent tests in e2e/cli_test.go,
# e2e/agent_test.go and e2e/mcp_test.go self-skip. The mock-engine and
# script-judge contract tests still exercise the Windows-specific code
# paths added by issue #31 — shell selection, MSYS argv handling,
# QuoteWindows, NewShellCmd's WaitDelay, the script-judge interpreter
# dispatch — through the full CLI pipeline. Real-LLM coverage stays on
# the Linux e2e job below.
name: E2E (none runtime, Windows)
# Skip on PRs to avoid the longer-queuing Windows runner pool; the
# Windows-specific code paths are still exercised on every push to main.
if: github.event_name != 'pull_request'
runs-on: windows-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.25.x"
cache: true
# Match the Build & Test step's shell choice so pwsh's legacy argv
# passing does not split `-coverprofile`-style flags.
- name: Run e2e tests (none runtime, quick mode)
shell: bash
run: go test -tags e2e -timeout 1200s -count=1 -v ./e2e
env:
SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts
- name: Upload e2e workspace artifacts
if: always() && hashFiles('e2e-artifacts/**') != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: e2e-windows-workspaces
path: e2e-artifacts/
if-no-files-found: ignore
retention-days: 14
e2e:
name: E2E (none runtime)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# Fork PRs run without access to repository secrets. Detect that early
# and skip the rest of the job so we don't burn CI minutes installing
# CLIs only to have every LLM-dependent test hit a 401.
- name: Check secret availability
id: secrets
env:
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
run: |
if [ -z "$DASHSCOPE_API_KEY" ]; then
echo "::notice::Skipping e2e: DASHSCOPE_API_KEY not provisioned (likely a fork PR)."
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
if: steps.secrets.outputs.available == 'true'
with:
go-version: "1.25.x"
cache: true
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
if: steps.secrets.outputs.available == 'true'
with:
node-version: "22"
- name: Install claude-code CLI
if: steps.secrets.outputs.available == 'true'
run: |
npm install -g --include=optional @anthropic-ai/claude-code
claude --version
- name: Install codex CLI
if: steps.secrets.outputs.available == 'true'
run: |
# Pin to the version declared in internal/agent/codex.go (codexDefaultVersion).
npm install -g --include=optional '@openai/codex@0.80.0'
codex --version
- name: Install qwen-code CLI
if: steps.secrets.outputs.available == 'true'
run: |
npm install -g @qwen-code/qwen-code
qwen --version
- name: Install qodercli (pinned v1.0.14)
if: steps.secrets.outputs.available == 'true'
env:
QODER_VERSION: "1.0.14"
QODER_SHA256: "4d83ec3d3a0948b73012cfdd6e2757be0a5da5c89a288425a5607fd42be1de7b"
run: |
TARBALL=$(mktemp)
curl -fsSL "https://qoder-ide.oss-accelerate.aliyuncs.com/qodercli/releases/${QODER_VERSION}/qodercli-linux-x64.tar.gz" -o "$TARBALL"
echo "$QODER_SHA256 $TARBALL" | sha256sum -c -
mkdir -p "$HOME/.qoder/bin"
tar -xzf "$TARBALL" -C "$HOME/.qoder/bin"
rm -f "$TARBALL"
echo "$HOME/.qoder/bin" >> "$GITHUB_PATH"
- name: Verify qodercli on PATH
if: steps.secrets.outputs.available == 'true'
run: |
which qodercli
qodercli --version || true
- name: Run e2e tests (none runtime, full mode)
if: steps.secrets.outputs.available == 'true'
# SKILL_UP_FULL_E2E unlocks tests that drive a real model. DashScope's
# Anthropic- and OpenAI-compatible endpoints back claude-code and codex
# respectively; QODER_PERSONAL_ACCESS_TOKEN authenticates qodercli.
# SKILL_UP_E2E_ARTIFACT_DIR tells e2e tests to copy their workspace
# (stdout.json / response.md / transcript / result.json …) to a stable
# path before t.TempDir cleanup, so the upload-artifact step below can
# surface them for post-mortem.
run: go test -tags e2e -timeout 1800s -count=1 -v ./e2e
env:
SKILL_UP_FULL_E2E: "1"
SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
# claude_code + dashscope provider → routed through the Anthropic-compat endpoint.
DASHSCOPE_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic
# Pin every anthropic-provider hop to qwen3.6-plus so no e2e path
# accidentally calls real Claude, regardless of which model the
# eval.yaml (or any hard-coded fallback elsewhere in the codebase)
# would otherwise pick.
ANTHROPIC_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
ANTHROPIC_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic
ANTHROPIC_MODEL: qwen3.6-plus
# Same idea for openai-provider hops (codex test pins "gpt-5.4" in
# its eval.yaml; OPENAI_MODEL overrides that to qwen3.6-plus).
OPENAI_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
OPENAI_BASE_URL: https://dashscope.aliyuncs.com/compatible-mode/v1
OPENAI_MODEL: qwen3.6-plus
DASHSCOPE_MODEL: qwen3.6-plus
QODER_PERSONAL_ACCESS_TOKEN: ${{ secrets.QODER_ACCESS_TOKEN }}
- name: Upload e2e workspace artifacts
if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-artifacts/**') != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: e2e-workspaces
path: e2e-artifacts/
if-no-files-found: ignore
retention-days: 14
e2e-opensandbox:
name: E2E (opensandbox runtime)
runs-on: ubuntu-latest
# Pulling the execd + sandbox images and bootstrapping the agent inside the
# sandbox is slower than the none-runtime job; give it generous headroom.
timeout-minutes: 35
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# The opensandbox runtime is self-hosted on the runner (see below), so no
# OPENSANDBOX_* secret is needed — but the agent under test still calls a
# real model, which needs DASHSCOPE_API_KEY. Fork PRs lack it: skip early.
- name: Check secret availability
id: secrets
env:
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
run: |
if [ -z "$DASHSCOPE_API_KEY" ]; then
echo "::notice::Skipping opensandbox e2e: DASHSCOPE_API_KEY not provisioned (likely a fork PR)."
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
if: steps.secrets.outputs.available == 'true'
with:
go-version: "1.25.x"
cache: true
- name: Install uv
if: steps.secrets.outputs.available == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
- name: Install OpenSandbox server
if: steps.secrets.outputs.available == 'true'
run: |
# Pin the server version: it must stay compatible with the execd
# image pinned in the next step, and an unpinned "latest" would let
# an unrelated future release silently change behavior or break CI.
uv tool install 'opensandbox-server==0.1.13'
# uv tool install drops console scripts under ~/.local/bin.
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Start OpenSandbox server
if: steps.secrets.outputs.available == 'true'
env:
# Image the sandbox containers boot from. node:22 is a full Debian
# image that already ships node 22 + npm + curl + git, so skill-up's
# in-sandbox agent bootstrap is a near no-op. Bare ubuntu:latest does
# not work — it lacks curl/git/node and the bootstrap fails.
OPENSANDBOX_IMAGE: node:22
# execd is the bootstrap binary OpenSandbox injects into every
# sandbox. Pinned together with opensandbox-server 0.1.13 above;
# bump both in lockstep if the server log reports an execd
# compatibility error.
OPENSANDBOX_EXECD_IMAGE: opensandbox/execd:v1.0.16
run: |
docker version
api_key="ci-$(openssl rand -hex 16)"
config="$RUNNER_TEMP/sandbox.toml"
# network_mode = "host" lets the natively-running server and the
# sandbox containers it spawns all share the runner's loopback, so
# the e2e process reaches both without bridge endpoint rewriting.
cat > "$config" <<EOF
[server]
host = "127.0.0.1"
port = 8080
api_key = "$api_key"
[runtime]
type = "docker"
execd_image = "$OPENSANDBOX_EXECD_IMAGE"
[docker]
network_mode = "host"
EOF
log="$RUNNER_TEMP/opensandbox-server.log"
nohup opensandbox-server --config "$config" > "$log" 2>&1 &
for i in $(seq 1 60); do
if curl -fsS http://127.0.0.1:8080/health 2>/dev/null | grep -q healthy; then
echo "OpenSandbox server is healthy"
break
fi
if [ "$i" -eq 60 ]; then
echo "::error::OpenSandbox server did not become healthy within 120s"
cat "$log"
exit 1
fi
sleep 2
done
{
echo "OPENSANDBOX_API_KEY=$api_key"
echo "OPENSANDBOX_BASE_URL=http://127.0.0.1:8080"
echo "OPENSANDBOX_IMAGE=$OPENSANDBOX_IMAGE"
} >> "$GITHUB_ENV"
- name: Run e2e tests (opensandbox runtime)
if: steps.secrets.outputs.available == 'true'
# Exercise the codex agent here: claude_code already has real-model
# coverage in the none-runtime job, whereas codex is otherwise only
# tested against a fake binary. This run is its sole real coverage.
run: go test -tags e2e -timeout 1800s -count=1 -v -run TestAgent_Codex_OpenSandboxRuntime ./e2e
env:
# codex speaks the OpenAI wire API → supply its key, endpoint and
# model via OPENAI_* (DashScope's OpenAI-compatible endpoint,
# authenticated with the DashScope key).
OPENAI_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
OPENAI_BASE_URL: https://dashscope.aliyuncs.com/compatible-mode/v1
OPENAI_MODEL: qwen3.6-plus
# Copies the in-sandbox agent workspace out before t.TempDir cleanup
# so the upload step below can surface it for post-mortem.
SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-opensandbox-artifacts
- name: Upload OpenSandbox server log
if: always() && steps.secrets.outputs.available == 'true'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: opensandbox-server-log
path: ${{ runner.temp }}/opensandbox-server.log
if-no-files-found: ignore
retention-days: 14
- name: Upload opensandbox e2e workspace artifacts
if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-opensandbox-artifacts/**') != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: e2e-opensandbox-workspaces
path: e2e-opensandbox-artifacts/
if-no-files-found: ignore
retention-days: 14
e2e-docker:
name: E2E (docker runtime)
runs-on: ubuntu-latest
# Container lifecycle + image pull on a clean runner; comfortably under
# 15 min in practice but leave headroom for Docker Hub rate-limit retries.
timeout-minutes: 20
# No API keys / LLM credentials needed: these tests drive the docker
# runtime against a real daemon directly, no agent or model in the loop.
# That also means they run on fork PRs, unlike the other two e2e jobs.
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.25.x"
cache: true
- name: Verify docker is available
# ubuntu-latest runners ship docker pre-installed and running; this
# makes the dependency explicit so the failure mode is obvious if
# GitHub ever changes the image.
run: |
docker version
docker info
- name: Pre-pull base image
# Pre-pulling outside the test binary keeps the per-test timeouts
# tight and surfaces registry / rate-limit issues as their own step
# rather than a misleading test failure.
run: docker pull alpine:3.20
- name: Run docker integration tests
# `docker_integration` is the build tag added in internal/runtime/
# docker_integration_test.go. Tests skip cleanly if the daemon is
# missing, so this command is also safe to run locally.
run: go test -tags docker_integration -timeout 600s -count=1 -v ./internal/runtime/
e2e-docker-full:
name: E2E (docker runtime, full LLM)
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Check secret availability
id: secrets
env:
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
run: |
if [ -z "$DASHSCOPE_API_KEY" ]; then
echo "::notice::Skipping docker full e2e: DASHSCOPE_API_KEY not provisioned (likely a fork PR)."
echo "available=false" >> "$GITHUB_OUTPUT"
else
echo "available=true" >> "$GITHUB_OUTPUT"
fi
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
if: steps.secrets.outputs.available == 'true'
with:
go-version: "1.25.x"
cache: true
- name: Pre-pull Docker image
if: steps.secrets.outputs.available == 'true'
run: docker pull node:22
- name: Run docker full e2e
if: steps.secrets.outputs.available == 'true'
run: go test -tags e2e -timeout 1800s -count=1 -v -run TestAgent_ClaudeCode_DockerRuntime ./e2e
env:
SKILL_UP_FULL_E2E: "1"
SKILL_UP_E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-docker-full-artifacts
ANTHROPIC_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
ANTHROPIC_BASE_URL: https://dashscope.aliyuncs.com/apps/anthropic
ANTHROPIC_MODEL: qwen3.6-plus
- name: Upload docker full e2e artifacts
if: always() && steps.secrets.outputs.available == 'true' && hashFiles('e2e-docker-full-artifacts/**') != ''
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: e2e-docker-full-workspaces
path: e2e-docker-full-artifacts/
if-no-files-found: ignore
retention-days: 14
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.25.x"
cache: true
- name: Check formatting
run: |
if [ "$(gofmt -l . | wc -l)" -gt 0 ]; then
echo "Following files are not formatted:"
gofmt -l .
exit 1
fi
- name: golangci-lint
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9
with:
version: v2.11.4
- name: Install revive
run: go install github.com/mgechev/revive@v1.10.0
- name: revive
run: revive -config revive.toml ./...
release-dryrun:
name: GoReleaser Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6
with:
go-version: "1.25.x"
cache: true
- name: GoReleaser check
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7
with:
distribution: goreleaser
version: "~> v2"
args: check
- name: GoReleaser snapshot build
uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7
with:
distribution: goreleaser
version: "~> v2"
args: release --snapshot --clean --skip=publish