openclaw/.github/workflows/ci-check-testbox.yml
Vincent Koc 32dc6c861d
fix(ci): cap Testbox concurrency and idle spend (#162678)
* fix(ci): cap Testbox concurrency and idle spend

* test(ci): align workflow guards with Testbox admission

* fix(ci): declare optional Testbox admission inputs

* fix(ci): narrow Testbox CLI rejection errors

* test(ci): verify admitted Testbox workflow behavior

* fix(ci): reserve large Testboxes for memory-heavy proof

* fix(ci): default routine Testboxes to one hour

* docs(ci): clarify Testbox profiles and total job deadlines

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-10-02 02:16:05 +07:00

227 lines
10 KiB
YAML

name: Blacksmith Testbox
on:
workflow_dispatch:
inputs:
testbox_id:
type: string
description: "Testbox session ID"
required: true
timeout_minutes:
type: number
description: "GitHub job runtime; explicit long-proof requests may use up to 240 minutes"
default: 60
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
paths:
- "scripts/ci-testbox-budget.mjs"
- ".github/workflows/ci-check-testbox.yml"
- ".github/actions/prepare-testbox-shell/**"
- ".github/actions/setup-node-env/**"
- ".github/actions/setup-pnpm-store-cache/**"
- ".github/actions/ensure-base-commit/**"
- ".github/actions/git-owner/**"
- "scripts/ci-hydrate-testbox-env.sh"
- "scripts/ci-hydrate-live-auth.sh"
- "scripts/lib/merge-head-diff-base.mjs"
- "scripts/lib/pnpm-lockfile-documents.mjs"
- "package.json"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
- "patches/**"
- ".npmrc"
- ".pnpmfile.*"
- "pnpmfile.cjs"
- "node-version.mjs"
- "**/package.json"
- "scripts/check-install-dependency-ownership.mjs"
- "scripts/postinstall-bundled-plugins.mjs"
- "scripts/lib/package-dist-imports.mjs"
- "scripts/lib/javascript-statements.mjs"
- "scripts/lib/package-lifecycle-marker.mjs"
- "scripts/lib/fs-safe-prebuild.mjs"
- "scripts/windows-cmd-helpers.mjs"
- "scripts/preinstall-package-manager-warning.mjs"
- "scripts/prepare-git-hooks.mjs"
permissions:
contents: read
concurrency:
# Admission precedes job skips: isolate passive drafts before they can cancel useful work.
# Keep conversion events in the PR group where subscribed, as an intentional cancellation signal.
group: >-
${{ github.event_name == 'pull_request' && github.event.pull_request.draft && github.event.action != 'converted_to_draft'
&& format('{0}-passive-draft-{1}', github.workflow, github.run_id)
|| (github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id)) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
jobs:
admission:
if: github.event_name == 'workflow_dispatch'
runs-on: ubuntu-24.04
timeout-minutes: 3
permissions:
contents: read
actions: read
outputs:
group: ${{ steps.budget.outputs.group }}
runner: ${{ steps.budget.outputs.runner }}
minutes: ${{ steps.budget.outputs.minutes }}
expires_at: ${{ steps.budget.outputs.expires_at }}
steps:
- name: Checkout admission policy
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
sparse-checkout: scripts/ci-testbox-budget.mjs
sparse-checkout-cone-mode: false
persist-credentials: false
- name: Admit Testbox within the shared budget
id: budget
env:
GH_TOKEN: ${{ github.token }}
TESTBOX_PROFILE: check
TESTBOX_ID: ${{ inputs.testbox_id }}
TESTBOX_MINUTES: ${{ inputs.timeout_minutes }}
run: node scripts/ci-testbox-budget.mjs admit
check:
needs: admission
if: ${{ !cancelled() && ((github.event_name == 'pull_request' && !github.event.pull_request.draft) || needs.admission.result == 'success') }}
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && needs.admission.outputs.group || format('testbox-validation-{0}-{1}', github.workflow, github.run_id) }}
cancel-in-progress: false
permissions:
contents: read
name: "check"
# Pull requests only validate the hydration steps; real Testbox leases arrive via dispatch.
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || needs.admission.outputs.runner }}
timeout-minutes: ${{ fromJSON(needs.admission.outputs.minutes || '60') }}
steps:
# Testbox lifecycle actions require Blacksmith VM metadata; PRs validate our setup steps only.
- name: Begin Testbox
if: github.event_name == 'workflow_dispatch'
uses: useblacksmith/begin-testbox@233448af4bfdc6fca509a7f0974411ac6d8a8043
with:
testbox_id: ${{ inputs.testbox_id }}
- name: Reject expired Testbox admission
if: github.event_name == 'workflow_dispatch'
shell: bash
env:
TESTBOX_EXPIRES_AT: ${{ needs.admission.outputs.expires_at }}
run: |
if [[ ! "$TESTBOX_EXPIRES_AT" =~ ^[0-9]+$ ]] ||
(( $(date +%s) >= TESTBOX_EXPIRES_AT / 1000 )); then
echo "Testbox waited more than 10 minutes; request a fresh lease." >&2
exit 1
fi
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
# Dispatch hydrates the pinned workflow ref; changed-gate sync later
# reconstructs its exact base and final tree. PR validation keeps both commits.
fetch-depth: ${{ github.event_name == 'pull_request' && '2' || '1' }}
persist-credentials: false
- name: Bound Testbox idle lifetime
if: github.event_name == 'workflow_dispatch'
env:
TESTBOX_EXPIRES_AT: ${{ needs.admission.outputs.expires_at }}
run: node scripts/ci-testbox-budget.mjs configure
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
semantic-checks: "true"
cache-mode: restore
install-bun: "false"
# Testbox hydration uses the ordinary pnpm store cache. Canonical CI
# owns the exact dependency archive and never delegates here.
- name: Prepare Testbox shell
uses: ./.github/actions/prepare-testbox-shell
with:
base-ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || 'HEAD' }}
go-version: "1.27.1"
- name: Hydrate Testbox provider env helper
shell: bash
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
FAL_KEY: ${{ secrets.FAL_KEY }}
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
run: bash scripts/ci-hydrate-testbox-env.sh
- name: Run Testbox
# Temporary local-listener fix: https://github.com/useblacksmith/run-testbox/pull/15
# Return to useblacksmith/run-testbox after the fix lands upstream.
uses: steipete/run-testbox@2b6b1be536ec7f3c73757fedf5460a27ab4856b4
if: github.event_name == 'workflow_dispatch' && always()
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
- name: Close Testbox SSH sessions
if: github.event_name == 'workflow_dispatch' && always()
shell: bash
run: |
set -euo pipefail
# Testbox state stores Blacksmith's external forwarded port. Resolve
# sshd's VM-local listener because that is the sport visible to ss.
runner_ssh_local_port="$(sudo sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }')"
if [[ ! "$runner_ssh_local_port" =~ ^[0-9]+$ ]] ||
(( runner_ssh_local_port < 1 || runner_ssh_local_port > 65535 )); then
echo "No valid local SSH listener port found; skipping session cleanup"
exit 0
fi
# run-testbox has no post hook. Close only Testbox client sockets so
# Blacksmith's runner teardown does not wait for its 290-second grace.
timeout --signal=KILL 5s sudo ss -K state established \
"( sport = :${runner_ssh_local_port} )" || true