mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-23 17:21:17 +00:00
* perf(ci): cut the pre-fan-out critical path on canonical runs Two changes to the run head and matrix shape: 1. runner-admission was a hosted 90s sleep every run queued behind (observed 1.7min hosted-queue latency before the sleep started). The debounce now lives at the tail of preflight: heavy jobs all need preflight, so a superseding main push still cancels the run before fan-out while only one 4 vCPU runner has been spent, and preflight's own work usually exceeds the window so the residual sleep is zero. security-fast (hosted, dependency-free) starts immediately. 2. Canonical main pushes now use the compact bin plan like PRs: the 82-job named matrix drained the runner pool for ~4.5min (job starts trickled from minute 5.2 to 9.7 in run 29592647843) with no branch-protection consumer for per-shard names on main. Coverage is identical; dispatch/release-gate runs keep the full named matrix. * perf(test): boot TUI PTY suite fixtures concurrently tsx+TUI startup dominated the harness file's wall time and the three suite PTYs booted serially. Boot them concurrently in beforeAll (allSettled so a failed boot still assigns survivors for afterAll cleanup); the env-specific fixtures never receive input, so their tests only await their own readiness output. The slow-startup test now proves frame ordering on the append-only output, which the old sequential waits did not. File wall 10.2s -> ~4.9s, 3/3 repeat runs green. * docs(ci): align gate and debounce descriptions with the removed admission job * test(tooling): wait for readiness file content, not existence writeFileSync creates the file before its bytes land, so the existence poll raced the child's write on loaded runners and read an empty ready file (observed in compact-small-4, run 29615028678). Poll for non-empty content at both readiness sites.
3405 lines
166 KiB
YAML
3405 lines
166 KiB
YAML
name: CI
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
inputs:
|
|
target_ref:
|
|
description: Optional branch, tag, or full commit SHA to validate instead of the workflow ref
|
|
required: false
|
|
default: ""
|
|
type: string
|
|
include_android:
|
|
description: Run Android lanes for this manual CI dispatch.
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
release_gate:
|
|
description: Run an exact-SHA maintainer release-gate fallback when PR CI is capacity-stalled.
|
|
required: false
|
|
default: false
|
|
type: boolean
|
|
pull_request_number:
|
|
description: Pull request number required by the exact-SHA release gate.
|
|
required: false
|
|
default: ""
|
|
type: string
|
|
dispatch_id:
|
|
description: Optional parent workflow dispatch identifier
|
|
required: false
|
|
default: ""
|
|
type: string
|
|
historical_target_tag:
|
|
description: Semver release tag authorizing compatibility fallbacks for its exact commit
|
|
required: false
|
|
default: ""
|
|
type: string
|
|
release_candidate_ref:
|
|
description: Canonical release branch authorizing compatibility fallbacks for its exact head
|
|
required: false
|
|
default: ""
|
|
type: string
|
|
push:
|
|
branches: [main]
|
|
paths-ignore:
|
|
- "**/*.md"
|
|
- "docs/**"
|
|
pull_request:
|
|
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.dispatch_id != '' && format('CI {0}', inputs.dispatch_id) || (github.event_name == 'workflow_dispatch' && inputs.release_gate && format('CI release gate {0}', inputs.target_ref) || 'CI') }}
|
|
|
|
concurrency:
|
|
group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && format('{0}-v7-{1}', github.workflow, github.ref) || format('{0}-v7-{1}-{2}', github.workflow, github.ref, github.sha))) }}
|
|
cancel-in-progress: ${{ github.event_name == 'pull_request' || (github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main') }}
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
|
|
|
jobs:
|
|
# Keep the canonical main queue quiet long enough for a follow-up push to
|
|
# cancel this run before it registers the Blacksmith matrix.
|
|
# Preflight: establish routing truth and job matrices once, then let real
|
|
# work fan out from a single source of truth.
|
|
preflight:
|
|
permissions:
|
|
contents: read
|
|
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
|
env:
|
|
OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS: "90"
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
outputs:
|
|
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
|
|
diff_base_revision: ${{ steps.diff_base.outputs.sha }}
|
|
docs_only: ${{ steps.manifest.outputs.docs_only }}
|
|
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
|
|
run_node: ${{ steps.manifest.outputs.run_node }}
|
|
run_macos: ${{ steps.manifest.outputs.run_macos }}
|
|
run_android: ${{ steps.manifest.outputs.run_android }}
|
|
run_skills_python: ${{ steps.manifest.outputs.run_skills_python }}
|
|
run_skills_python_job: ${{ steps.manifest.outputs.run_skills_python_job }}
|
|
run_windows: ${{ steps.manifest.outputs.run_windows }}
|
|
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
|
|
run_checks_fast_core: ${{ steps.manifest.outputs.run_checks_fast_core }}
|
|
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
|
|
historical_target: ${{ steps.manifest.outputs.historical_target }}
|
|
run_qa_smoke_ci: ${{ steps.manifest.outputs.run_qa_smoke_ci }}
|
|
run_prompt_snapshots: ${{ steps.manifest.outputs.run_prompt_snapshots }}
|
|
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
|
|
run_plugin_contracts_shards: ${{ steps.manifest.outputs.run_plugin_contracts_shards }}
|
|
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
|
|
run_channel_contracts_shards: ${{ steps.manifest.outputs.run_channel_contracts_shards }}
|
|
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
|
|
run_checks: ${{ steps.manifest.outputs.run_checks }}
|
|
run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }}
|
|
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
|
|
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
|
|
run_check: ${{ steps.manifest.outputs.run_check }}
|
|
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
|
|
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
|
|
run_format_check: ${{ steps.manifest.outputs.run_format_check }}
|
|
compatibility_target: ${{ steps.manifest.outputs.compatibility_target }}
|
|
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
|
|
strict_control_ui_i18n: ${{ steps.changed_scope.outputs.strict_control_ui_i18n }}
|
|
run_ui_tests: ${{ steps.manifest.outputs.run_ui_tests }}
|
|
run_native_i18n: ${{ steps.manifest.outputs.run_native_i18n }}
|
|
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
|
|
checks_windows_matrix: ${{ steps.manifest.outputs.checks_windows_matrix }}
|
|
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
|
|
macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
|
|
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
|
|
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
|
|
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
|
|
run_protocol_event_coverage: ${{ steps.manifest.outputs.run_protocol_event_coverage }}
|
|
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
|
|
steps:
|
|
- name: Record debounce epoch
|
|
if: github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
|
|
run: echo "OPENCLAW_DEBOUNCE_EPOCH=$(date +%s)" >> "$GITHUB_ENV"
|
|
|
|
- name: Validate release-gate dispatch
|
|
if: github.event_name == 'workflow_dispatch' && inputs.release_gate
|
|
env:
|
|
HISTORICAL_TARGET_TAG: ${{ inputs.historical_target_tag }}
|
|
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
|
TARGET_REF: ${{ inputs.target_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [[ ! "$TARGET_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "release_gate requires target_ref to be a full commit SHA" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! "$PULL_REQUEST_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
|
|
echo "release_gate requires pull_request_number" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ "$GITHUB_SHA" != "$TARGET_REF" ]]; then
|
|
echo "release_gate must run from the branch at target_ref" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$HISTORICAL_TARGET_TAG" ]]; then
|
|
echo "release_gate cannot be combined with historical_target_tag" >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Checkout
|
|
env:
|
|
CHECKOUT_REPO: ${{ github.repository }}
|
|
CHECKOUT_REF: ${{ inputs.target_ref || github.sha }}
|
|
CHECKOUT_EVENT_REF: ${{ github.ref }}
|
|
CHECKOUT_FALLBACK_REF: ${{ github.sha }}
|
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
git init "$GITHUB_WORKSPACE"
|
|
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
|
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
|
fetch_checkout_ref() {
|
|
local ref="$1"
|
|
local fetch_status
|
|
for attempt in 1 2 3; do
|
|
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
|
-c protocol.version=2 \
|
|
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
|
"+${ref}:refs/remotes/origin/checkout" && return 0
|
|
fetch_status="$?"
|
|
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
|
return "$fetch_status"
|
|
fi
|
|
if [ "$attempt" = "3" ]; then
|
|
return "$fetch_status"
|
|
fi
|
|
echo "::warning::checkout fetch for '$ref' timed out on attempt $attempt; retrying"
|
|
sleep 5
|
|
done
|
|
}
|
|
|
|
# Diff-base resolution needs the head's parent commits and trees
|
|
# (merge-head-diff-base.mjs and the name-only diffs), but not the
|
|
# parents' blobs. Fetching them blob-less keeps preflight several
|
|
# times faster than a full --depth=2 snapshot fetch.
|
|
fetch_parent_metadata() {
|
|
local sha="$1"
|
|
for attempt in 1 2 3; do
|
|
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
|
-c protocol.version=2 \
|
|
fetch --no-tags --prune --no-recurse-submodules --depth=2 \
|
|
--filter=blob:none origin "$sha" && return 0
|
|
if [ "$attempt" = "3" ]; then
|
|
return 1
|
|
fi
|
|
echo "::warning::parent metadata fetch for '$sha' failed on attempt $attempt; retrying"
|
|
sleep 5
|
|
done
|
|
}
|
|
|
|
# Manual release-gate runs commonly validate the workflow ref's exact SHA.
|
|
# Fetch the branch/tag ref first, then refetch the SHA if that ref moves
|
|
# while the manual run waits for a runner.
|
|
checkout_ref="$CHECKOUT_REF"
|
|
requested_sha=""
|
|
if [[ "$CHECKOUT_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
|
requested_sha="$CHECKOUT_REF"
|
|
if [[
|
|
"$GITHUB_EVENT_NAME" == "workflow_dispatch" &&
|
|
"$CHECKOUT_REF" == "$CHECKOUT_FALLBACK_REF" &&
|
|
-n "$CHECKOUT_EVENT_REF"
|
|
]]; then
|
|
checkout_ref="$CHECKOUT_EVENT_REF"
|
|
fi
|
|
fi
|
|
|
|
if fetch_checkout_ref "$checkout_ref"; then
|
|
:
|
|
else
|
|
fetch_status="$?"
|
|
if [ "$fetch_status" = "124" ] || [ "$fetch_status" = "137" ]; then
|
|
echo "::error::checkout fetch for '$checkout_ref' timed out"
|
|
exit "$fetch_status"
|
|
fi
|
|
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
|
|
exit "$fetch_status"
|
|
fi
|
|
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
|
|
fetch_checkout_ref "$CHECKOUT_FALLBACK_REF"
|
|
fi
|
|
|
|
if [ -n "$requested_sha" ]; then
|
|
resolved_sha="$(git -C "$GITHUB_WORKSPACE" rev-parse refs/remotes/origin/checkout)"
|
|
if [ "$resolved_sha" != "$requested_sha" ] && [ "$checkout_ref" != "$requested_sha" ]; then
|
|
echo "::notice::checkout ref '$checkout_ref' moved to '$resolved_sha'; fetching requested SHA '$requested_sha'"
|
|
checkout_ref="$requested_sha"
|
|
fetch_checkout_ref "$checkout_ref"
|
|
resolved_sha="$(git -C "$GITHUB_WORKSPACE" rev-parse refs/remotes/origin/checkout)"
|
|
fi
|
|
if [ "$resolved_sha" != "$requested_sha" ]; then
|
|
echo "::error::checkout ref '$checkout_ref' resolved to '$resolved_sha'," \
|
|
"expected '$requested_sha'" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
fetch_parent_metadata "$(git -C "$GITHUB_WORKSPACE" rev-parse refs/remotes/origin/checkout)"
|
|
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
|
|
|
- name: Resolve checkout SHA
|
|
id: checkout_ref
|
|
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Resolve exact diff base
|
|
id: diff_base
|
|
env:
|
|
EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha || '' }}
|
|
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
|
RELEASE_GATE: ${{ inputs.release_gate }}
|
|
run: |
|
|
set -euo pipefail
|
|
base_sha="$EVENT_BASE_SHA"
|
|
head_sha="$(git rev-parse HEAD)"
|
|
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
|
# A long-lived PR event can retain an old base SHA. The tested merge
|
|
# commit's first parent is the exact target tree for this run.
|
|
base_sha="$(node scripts/lib/merge-head-diff-base.mjs \
|
|
--base "$base_sha" --head HEAD --prefer-first-parent)"
|
|
fi
|
|
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$RELEASE_GATE" = "true" ]; then
|
|
merge_ref="refs/remotes/origin/release-gate-merge"
|
|
timeout --signal=TERM --kill-after=10s 120s git fetch \
|
|
--no-tags --no-recurse-submodules --depth=2 origin \
|
|
"+refs/pull/${PULL_REQUEST_NUMBER}/merge:${merge_ref}"
|
|
release_gate_head="$(git rev-parse "${merge_ref}^2")"
|
|
target_head="$(git rev-parse HEAD)"
|
|
if [ "$release_gate_head" != "$target_head" ]; then
|
|
echo "release_gate pull request head ${release_gate_head} does not match target ${target_head}" >&2
|
|
exit 1
|
|
fi
|
|
base_sha="$(git rev-parse "${merge_ref}^1")"
|
|
head_sha="$(git rev-parse "$merge_ref")"
|
|
fi
|
|
echo "sha=$base_sha" >> "$GITHUB_OUTPUT"
|
|
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Validate historical release target
|
|
id: historical_target
|
|
if: inputs.historical_target_tag != ''
|
|
env:
|
|
EXPECTED_SHA: ${{ steps.checkout_ref.outputs.sha }}
|
|
HISTORICAL_TARGET_TAG: ${{ inputs.historical_target_tag }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ ! "$HISTORICAL_TARGET_TAG" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then
|
|
echo "historical_target_tag must be a canonical OpenClaw release tag." >&2
|
|
exit 1
|
|
fi
|
|
tag_ref="refs/tags/${HISTORICAL_TARGET_TAG}"
|
|
remote="$(git remote get-url origin)"
|
|
tag_sha="$(git ls-remote --tags "$remote" "${tag_ref}^{}" | awk 'NR == 1 { print $1 }')"
|
|
if [[ -z "$tag_sha" ]]; then
|
|
tag_sha="$(git ls-remote --tags "$remote" "$tag_ref" | awk 'NR == 1 { print $1 }')"
|
|
fi
|
|
if [[ "$tag_sha" != "$EXPECTED_SHA" ]]; then
|
|
echo "Historical release tag ${HISTORICAL_TARGET_TAG} does not resolve to ${EXPECTED_SHA}." >&2
|
|
exit 1
|
|
fi
|
|
echo "eligible=true" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Validate release candidate target
|
|
id: release_candidate_target
|
|
if: inputs.release_candidate_ref != ''
|
|
env:
|
|
EXPECTED_SHA: ${{ steps.checkout_ref.outputs.sha }}
|
|
RELEASE_CANDIDATE_REF: ${{ inputs.release_candidate_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [[ ! "$RELEASE_CANDIDATE_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then
|
|
echo "release_candidate_ref must be a canonical OpenClaw release branch." >&2
|
|
exit 1
|
|
fi
|
|
remote="$(git remote get-url origin)"
|
|
branch_sha="$(git ls-remote --heads "$remote" "refs/heads/${RELEASE_CANDIDATE_REF}" | awk 'NR == 1 { print $1 }')"
|
|
if [[ "$branch_sha" != "$EXPECTED_SHA" ]]; then
|
|
echo "Release candidate branch ${RELEASE_CANDIDATE_REF} does not resolve to ${EXPECTED_SHA}." >&2
|
|
exit 1
|
|
fi
|
|
echo "eligible=true" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Ensure preflight base commit
|
|
if: github.event_name != 'workflow_dispatch'
|
|
uses: ./.github/actions/ensure-base-commit
|
|
with:
|
|
base-sha: ${{ steps.diff_base.outputs.sha }}
|
|
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
|
|
|
- name: Detect docs-only changes
|
|
id: docs_scope
|
|
if: github.event_name != 'workflow_dispatch'
|
|
uses: ./.github/actions/detect-docs-changes
|
|
with:
|
|
base-sha: ${{ steps.diff_base.outputs.sha }}
|
|
|
|
- name: Detect changed scopes
|
|
id: changed_scope
|
|
if: (github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true') || (github.event_name == 'workflow_dispatch' && inputs.release_gate)
|
|
shell: bash
|
|
env:
|
|
OPENCLAW_ALLOW_RELEASE_GENERATED_MIX: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [ "${{ github.event_name }}" = "push" ]; then
|
|
BASE="${{ steps.diff_base.outputs.sha }}"
|
|
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
|
elif [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
BASE="${{ steps.diff_base.outputs.sha }}"
|
|
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD --merge-head-first-parent
|
|
else
|
|
BASE="${{ steps.diff_base.outputs.sha }}"
|
|
HEAD_SHA="${{ steps.diff_base.outputs.head_sha }}"
|
|
node scripts/ci-changed-scope.mjs --base "$BASE" --head "$HEAD_SHA"
|
|
fi
|
|
|
|
- name: Build CI manifest
|
|
id: manifest
|
|
env:
|
|
OPENCLAW_CI_DOCS_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }}
|
|
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
|
|
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
|
|
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
|
|
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
|
|
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
|
|
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
|
|
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
|
|
OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }}
|
|
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}
|
|
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }}
|
|
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
|
OPENCLAW_CI_RUN_UI_TESTS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }}
|
|
OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}
|
|
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_scope.outputs.changed_paths_json || 'null' }}
|
|
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
|
|
OPENCLAW_CI_HISTORICAL_TARGET: ${{ steps.historical_target.outputs.eligible || 'false' }}
|
|
OPENCLAW_CI_RELEASE_CANDIDATE_TARGET: ${{ steps.release_candidate_target.outputs.eligible || 'false' }}
|
|
OPENCLAW_CI_WORKFLOW_REVISION: ${{ github.sha }}
|
|
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
|
|
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
node --input-type=module <<'EOF'
|
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
|
|
|
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
|
|
const checkoutRevision = process.env.OPENCLAW_CI_CHECKOUT_REVISION ?? "";
|
|
const workflowRevision = process.env.OPENCLAW_CI_WORKFLOW_REVISION ?? "";
|
|
const historicalTargetApproved = process.env.OPENCLAW_CI_HISTORICAL_TARGET === "true";
|
|
const historicalTarget =
|
|
eventName === "workflow_dispatch" &&
|
|
historicalTargetApproved &&
|
|
checkoutRevision !== workflowRevision;
|
|
const releaseCandidateTarget =
|
|
eventName === "workflow_dispatch" &&
|
|
process.env.OPENCLAW_CI_RELEASE_CANDIDATE_TARGET === "true" &&
|
|
checkoutRevision !== workflowRevision;
|
|
const compatibilityTarget = historicalTarget || releaseCandidateTarget;
|
|
const frozenTarget =
|
|
eventName === "workflow_dispatch" && checkoutRevision !== workflowRevision;
|
|
|
|
const nodeTestPlan = await import("./scripts/lib/ci-node-test-plan.mjs");
|
|
const createNodeTestPlan =
|
|
typeof nodeTestPlan.createNodeTestShardBundles === "function"
|
|
? nodeTestPlan.createNodeTestShardBundles
|
|
: compatibilityTarget
|
|
? nodeTestPlan.createNodeTestShards
|
|
: undefined;
|
|
if (typeof createNodeTestPlan !== "function") {
|
|
throw new Error("CI target does not export a supported Node test shard planner");
|
|
}
|
|
|
|
let changedNodeTestPlan = {};
|
|
if (existsSync("./scripts/lib/ci-changed-node-test-plan.mjs")) {
|
|
try {
|
|
changedNodeTestPlan = await import("./scripts/lib/ci-changed-node-test-plan.mjs");
|
|
} catch (error) {
|
|
console.warn(`Changed Node test planner import failed; using compact full suite: ${error}`);
|
|
}
|
|
}
|
|
|
|
const importTargetPlan = async (path) => {
|
|
if (existsSync(path)) {
|
|
return import(path);
|
|
}
|
|
if (!compatibilityTarget) {
|
|
throw new Error(`Current CI target does not provide ${path}`);
|
|
}
|
|
return {};
|
|
};
|
|
const channelContractPlan = await importTargetPlan(
|
|
"./scripts/lib/channel-contract-test-plan.mjs",
|
|
);
|
|
const createChannelContractTestShards =
|
|
typeof channelContractPlan.createChannelContractTestShards === "function"
|
|
? channelContractPlan.createChannelContractTestShards
|
|
: () => [];
|
|
|
|
const parseBoolean = (value, fallback = false) => {
|
|
if (value === undefined) return fallback;
|
|
const normalized = value.trim().toLowerCase();
|
|
if (normalized === "true" || normalized === "1") return true;
|
|
if (normalized === "false" || normalized === "0" || normalized === "") return false;
|
|
return fallback;
|
|
};
|
|
|
|
const pluginContractPlan = await importTargetPlan(
|
|
"./scripts/lib/plugin-contract-test-plan.mjs",
|
|
);
|
|
const createPluginContractTestShards =
|
|
typeof pluginContractPlan.createPluginContractTestShards === "function"
|
|
? pluginContractPlan.createPluginContractTestShards
|
|
: () => [
|
|
{
|
|
checkName: "checks-fast-contracts-plugins-legacy",
|
|
includePatterns: ["src/plugins/contracts/**/*.test.ts"],
|
|
runtime: "node",
|
|
task: "contracts-plugins",
|
|
},
|
|
];
|
|
const createMatrix = (include) => ({ include });
|
|
const outputPath = process.env.GITHUB_OUTPUT;
|
|
const packageScripts = JSON.parse(readFileSync("package.json", "utf8")).scripts ?? {};
|
|
const hasPackageScript = (name) => typeof packageScripts[name] === "string";
|
|
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
|
|
const changedPaths = (() => {
|
|
try {
|
|
const value = JSON.parse(process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "null");
|
|
return Array.isArray(value) && value.every((path) => typeof path === "string")
|
|
? value
|
|
: null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
|
|
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
|
|
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
|
|
const runNodeFastOnly =
|
|
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
|
|
const runNodeFull = runNode && !runNodeFastOnly;
|
|
const runNodeFastPluginContracts =
|
|
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS);
|
|
const runNodeFastCiRouting =
|
|
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING);
|
|
const runPluginContractShards = runNodeFull || runNodeFastPluginContracts;
|
|
const runMacos =
|
|
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
|
|
const supportsCurrentMacosSwiftCi =
|
|
existsSync("scripts/install-swift-tools.sh") &&
|
|
existsSync("scripts/lint-swift.sh") &&
|
|
existsSync("scripts/format-swift.sh");
|
|
const supportsIosBuild = hasPackageScript("ios:build");
|
|
const supportsCurrentIosCi = supportsIosBuild && supportsCurrentMacosSwiftCi;
|
|
const runIosBuild =
|
|
parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) &&
|
|
!docsOnly &&
|
|
isCanonicalRepository &&
|
|
(!frozenTarget ||
|
|
supportsCurrentIosCi ||
|
|
(releaseCandidateTarget && supportsIosBuild));
|
|
const runAndroid =
|
|
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
|
|
const runWindows =
|
|
parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) &&
|
|
!docsOnly &&
|
|
!runNodeFastOnly &&
|
|
isCanonicalRepository;
|
|
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly;
|
|
const runControlUiI18n =
|
|
parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly;
|
|
const runUiTests = parseBoolean(process.env.OPENCLAW_CI_RUN_UI_TESTS) && !docsOnly;
|
|
const supportsNativeI18n =
|
|
hasPackageScript("native:i18n:check") &&
|
|
hasPackageScript("android:i18n:check") &&
|
|
hasPackageScript("apple:i18n:check");
|
|
const runNativeI18n =
|
|
parseBoolean(process.env.OPENCLAW_CI_RUN_NATIVE_I18N) &&
|
|
!docsOnly &&
|
|
(!frozenTarget || supportsNativeI18n);
|
|
const targetWorkflow = existsSync(".github/workflows/ci.yml")
|
|
? readFileSync(".github/workflows/ci.yml", "utf8")
|
|
: "";
|
|
const supportsCurrentAndroidCi = targetWorkflow.includes("android-ci-contract-v2");
|
|
const useCompatibleAndroidCi = compatibilityTarget && !supportsCurrentAndroidCi;
|
|
const supportsFormatCheck =
|
|
targetWorkflow.split("pnpm format:check").length - 1 >= 2;
|
|
const runFormatCheck = !frozenTarget || supportsFormatCheck;
|
|
const checksFastCoreTasks =
|
|
runNode && !frozenTarget
|
|
? [
|
|
{
|
|
check_name: "checks-fast-max-lines-ratchet",
|
|
runtime: "node",
|
|
task: "max-lines-ratchet",
|
|
},
|
|
]
|
|
: [];
|
|
if (runNodeFull) {
|
|
checksFastCoreTasks.push(
|
|
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
|
|
{ check_name: "checks-fast-bun-launcher", runtime: "bun", task: "bun-launcher" },
|
|
);
|
|
} else {
|
|
if (runNodeFastCiRouting) {
|
|
checksFastCoreTasks.push({
|
|
check_name: "checks-fast-ci-routing",
|
|
runtime: "node",
|
|
task: "ci-routing",
|
|
});
|
|
}
|
|
}
|
|
|
|
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
|
|
// Canonical pushes also use compact bins: 80+ single-group jobs
|
|
// drain the runner pool for minutes, and per-shard check names on
|
|
// main have no branch-protection consumers. Dispatch (release
|
|
// validation) keeps the full named matrix.
|
|
const compactPlan =
|
|
isCanonicalRepository && (eventName === "pull_request" || eventName === "push");
|
|
let changedNodeTestShards = null;
|
|
if (
|
|
compactPullRequest &&
|
|
changedPaths &&
|
|
typeof changedNodeTestPlan.createChangedNodeTestShards === "function"
|
|
) {
|
|
try {
|
|
changedNodeTestShards = changedNodeTestPlan.createChangedNodeTestShards(changedPaths);
|
|
} catch (error) {
|
|
console.warn(`Changed Node test planning failed; using compact full suite: ${error}`);
|
|
}
|
|
}
|
|
// Heavy packaging lanes run only when the diff touches surfaces they
|
|
// exist to prove: test-only diffs cannot change dist bytes, and QA
|
|
// smoke only sees changes on its scenario surface or inside the
|
|
// packaged CLI's import graph. QA gating is diff-based, so it also
|
|
// applies when test targeting fell back to the full compact suite.
|
|
const changedScopeHasBuildImpact =
|
|
changedNodeTestShards === null ||
|
|
typeof changedNodeTestPlan.hasBuildArtifactAffectingChange !== "function" ||
|
|
changedNodeTestPlan.hasBuildArtifactAffectingChange(changedPaths);
|
|
const changedScopeHasQaImpact =
|
|
changedPaths === null ||
|
|
eventName !== "pull_request" ||
|
|
typeof changedNodeTestPlan.hasQaSmokeAffectingChange !== "function" ||
|
|
changedNodeTestPlan.hasQaSmokeAffectingChange(changedPaths);
|
|
// Prompt snapshots only change when the generator's import graph or
|
|
// its fixtures do; unaffected PR diffs skip the regeneration lane.
|
|
const changedScopeHasPromptSnapshotImpact =
|
|
changedPaths === null ||
|
|
eventName !== "pull_request" ||
|
|
typeof changedNodeTestPlan.hasPromptSnapshotAffectingChange !== "function" ||
|
|
changedNodeTestPlan.hasPromptSnapshotAffectingChange(changedPaths);
|
|
const runBuildArtifacts = runNodeFull && changedScopeHasBuildImpact;
|
|
const runQaSmokeCi =
|
|
runNodeFull &&
|
|
changedScopeHasQaImpact &&
|
|
(!frozenTarget || existsSync("extensions/qa-lab/src/ci-smoke-plan.ts"));
|
|
const rawNodeTestShards = runNodeFull
|
|
? changedNodeTestShards
|
|
? changedNodeTestShards
|
|
: createNodeTestPlan({
|
|
includeReleaseOnlyPluginShards: false,
|
|
compact: compactPlan,
|
|
})
|
|
: [];
|
|
const assignVitestFsCacheWriter =
|
|
typeof nodeTestPlan.assignVitestFsCacheWriter === "function"
|
|
? nodeTestPlan.assignVitestFsCacheWriter
|
|
: (shards) =>
|
|
shards.map((shard, index) => ({
|
|
...shard,
|
|
saveVitestFsCache: index === 0,
|
|
}));
|
|
const nodeTestShards = assignVitestFsCacheWriter(rawNodeTestShards).map((shard) => ({
|
|
check_name: shard.checkName,
|
|
runtime: "node",
|
|
task: "test-shard",
|
|
shard_name: shard.shardName,
|
|
groups: shard.groups,
|
|
configs: shard.configs,
|
|
env: shard.env,
|
|
includePatterns: shard.includePatterns,
|
|
requires_dist: shard.requiresDist,
|
|
runner: shard.runner,
|
|
timeout_minutes: shard.timeoutMinutes,
|
|
plan_concurrency: shard.planConcurrency,
|
|
save_vitest_fs_cache: shard.saveVitestFsCache,
|
|
targets: shard.targets,
|
|
requires_go:
|
|
shard.shardName.startsWith("core-tooling") ||
|
|
shard.groups?.some((group) => group.shard_name.startsWith("core-tooling")),
|
|
}));
|
|
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
|
|
const nodeTestDistShards = nodeTestShards.filter((shard) => shard.requires_dist);
|
|
// Targeted jobs cannot discover repository-scanning boundary tests
|
|
// through imports. Keep the full boundary gate inside build-artifacts
|
|
// when that lane runs; test-only targeted plans carry their own
|
|
// nondist changed-boundary shard instead.
|
|
const runNodeCoreDist =
|
|
(changedNodeTestShards !== null && runBuildArtifacts) ||
|
|
nodeTestDistShards.length > 0;
|
|
const channelContractShards = runNodeFull ? createChannelContractTestShards() : [];
|
|
const protocolCoverageRequested = runNode || runIosBuild || runAndroid;
|
|
const runProtocolEventCoverage =
|
|
protocolCoverageRequested &&
|
|
(!frozenTarget || existsSync("scripts/check-protocol-event-coverage.mjs"));
|
|
|
|
const manifest = {
|
|
docs_only: docsOnly,
|
|
docs_changed: docsChanged,
|
|
run_node: runNode,
|
|
run_macos: runMacos,
|
|
run_android: runAndroid,
|
|
run_skills_python: runSkillsPython,
|
|
run_windows: runWindows,
|
|
run_build_artifacts: runBuildArtifacts,
|
|
run_checks_fast_core: checksFastCoreTasks.length > 0,
|
|
run_checks_fast: runNodeFull,
|
|
historical_target: historicalTarget,
|
|
compatibility_target: compatibilityTarget,
|
|
run_qa_smoke_ci: runQaSmokeCi,
|
|
run_prompt_snapshots: runNodeFull && changedScopeHasPromptSnapshotImpact,
|
|
checks_fast_core_matrix: createMatrix(checksFastCoreTasks),
|
|
run_plugin_contracts_shards: runPluginContractShards,
|
|
plugin_contracts_matrix: createMatrix(
|
|
runPluginContractShards ? createPluginContractTestShards() : [],
|
|
),
|
|
run_channel_contracts_shards: channelContractShards.length > 0,
|
|
channel_contracts_matrix: createMatrix(channelContractShards),
|
|
run_checks: runNodeFull,
|
|
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
|
|
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
|
|
run_checks_node_core_dist: runNodeCoreDist,
|
|
run_check: runNodeFull,
|
|
run_check_additional: runNodeFull,
|
|
run_check_docs: docsChanged && eventName !== "push",
|
|
run_format_check: runFormatCheck,
|
|
run_control_ui_i18n: runControlUiI18n,
|
|
run_ui_tests: runUiTests,
|
|
run_native_i18n: runNativeI18n,
|
|
run_skills_python_job: runSkillsPython,
|
|
run_checks_windows: runWindows,
|
|
checks_windows_matrix: createMatrix(
|
|
runWindows
|
|
? [
|
|
{
|
|
check_name: "checks-windows-node-test",
|
|
runtime: "node",
|
|
task: "test",
|
|
runner: "blacksmith-8vcpu-windows-2025",
|
|
},
|
|
]
|
|
: [],
|
|
),
|
|
run_macos_node: runMacos,
|
|
macos_node_matrix: createMatrix(
|
|
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
|
|
),
|
|
run_macos_swift:
|
|
runMacos && (!frozenTarget || compatibilityTarget || supportsCurrentMacosSwiftCi),
|
|
run_ios_build: runIosBuild,
|
|
run_android_job: runAndroid,
|
|
run_protocol_event_coverage: runProtocolEventCoverage,
|
|
android_matrix: createMatrix(
|
|
runAndroid
|
|
? [
|
|
// android-ci-contract-v3: phone variants, Wear modules, Android lint, benchmark, and ktlint.
|
|
{
|
|
check_name: "android-test-play",
|
|
task: useCompatibleAndroidCi ? "test-play-compat" : "test-play",
|
|
},
|
|
{ check_name: "android-test-third-party", task: "test-third-party" },
|
|
...(!useCompatibleAndroidCi
|
|
? [{ check_name: "android-test-wear", task: "test-wear" }]
|
|
: []),
|
|
{
|
|
check_name: "android-build-play",
|
|
task: useCompatibleAndroidCi ? "build-play-compat" : "build-play",
|
|
},
|
|
...(!useCompatibleAndroidCi
|
|
? [
|
|
{ check_name: "android-build-wear", task: "build-wear" },
|
|
{ check_name: "android-ktlint", task: "ktlint" },
|
|
]
|
|
: []),
|
|
]
|
|
: [],
|
|
),
|
|
};
|
|
|
|
for (const [key, value] of Object.entries(manifest)) {
|
|
appendFileSync(
|
|
outputPath,
|
|
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
EOF
|
|
|
|
- name: Check mobile protocol event coverage
|
|
if: steps.manifest.outputs.run_protocol_event_coverage == 'true'
|
|
run: node scripts/check-protocol-event-coverage.mjs
|
|
|
|
# Fan-out debounce: every heavy job needs preflight, so holding this job
|
|
# open until the debounce window elapses lets a superseding main push
|
|
# cancel the run while only one 4 vCPU runner has been spent. Preflight's
|
|
# own work usually exceeds the window, making the residual sleep zero.
|
|
- name: Debounce canonical main fan-out
|
|
if: github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
|
|
run: |
|
|
set -euo pipefail
|
|
elapsed=$(( $(date +%s) - OPENCLAW_DEBOUNCE_EPOCH ))
|
|
remaining=$(( OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS - elapsed ))
|
|
if [ "$remaining" -gt 0 ]; then
|
|
echo "Holding fan-out ${remaining}s for a superseding main push"
|
|
sleep "$remaining"
|
|
else
|
|
echo "Debounce window already elapsed (${elapsed}s)"
|
|
fi
|
|
|
|
# Run dependency-free security checks on a hosted runner in parallel with
|
|
# scope detection. No downstream job waits for Python/pre-commit setup.
|
|
security-fast:
|
|
permissions:
|
|
contents: read
|
|
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 20
|
|
env:
|
|
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
|
|
steps:
|
|
- name: Checkout
|
|
env:
|
|
CHECKOUT_REPO: ${{ github.repository }}
|
|
CHECKOUT_REF: ${{ inputs.target_ref || github.sha }}
|
|
CHECKOUT_FALLBACK_REF: ${{ github.sha }}
|
|
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
|
run: |
|
|
set -euo pipefail
|
|
git init "$GITHUB_WORKSPACE"
|
|
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
|
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
|
fetch_checkout_ref() {
|
|
local ref="$1"
|
|
local fetch_status
|
|
for attempt in 1 2 3; do
|
|
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
|
-c protocol.version=2 \
|
|
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
|
|
"+${ref}:refs/remotes/origin/checkout" && return 0
|
|
fetch_status="$?"
|
|
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
|
return "$fetch_status"
|
|
fi
|
|
if [ "$attempt" = "3" ]; then
|
|
return "$fetch_status"
|
|
fi
|
|
echo "::warning::checkout fetch for '$ref' timed out on attempt $attempt; retrying"
|
|
sleep 5
|
|
done
|
|
}
|
|
if fetch_checkout_ref "$CHECKOUT_REF"; then
|
|
:
|
|
else
|
|
fetch_status="$?"
|
|
if [ "$fetch_status" = "124" ] || [ "$fetch_status" = "137" ]; then
|
|
echo "::error::checkout fetch for '$CHECKOUT_REF' timed out"
|
|
exit "$fetch_status"
|
|
fi
|
|
if [ "$GITHUB_EVENT_NAME" != "workflow_dispatch" ] || [ "$CHECKOUT_REF" = "$CHECKOUT_FALLBACK_REF" ]; then
|
|
exit "$fetch_status"
|
|
fi
|
|
echo "::warning::workflow_dispatch target_ref '$CHECKOUT_REF' is unavailable; falling back to head SHA '$CHECKOUT_FALLBACK_REF'"
|
|
fetch_checkout_ref "$CHECKOUT_FALLBACK_REF"
|
|
fi
|
|
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
|
|
|
- name: Resolve security diff base
|
|
id: diff_base
|
|
env:
|
|
EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha || '' }}
|
|
run: |
|
|
set -euo pipefail
|
|
base_sha="$EVENT_BASE_SHA"
|
|
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
|
# Do not execute a helper from the untrusted PR tree before trusted
|
|
# pre-commit configuration is selected.
|
|
read -r head_sha first_parent second_parent extra <<< \
|
|
"$(git rev-list --parents -n 1 HEAD)"
|
|
if [[
|
|
"$head_sha" = "$(git rev-parse HEAD)" &&
|
|
"$first_parent" =~ ^[0-9a-f]{40}$ &&
|
|
"$second_parent" =~ ^[0-9a-f]{40}$ &&
|
|
-z "${extra:-}"
|
|
]]; then
|
|
base_sha="$first_parent"
|
|
fi
|
|
fi
|
|
echo "sha=$base_sha" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Ensure security base commit
|
|
if: github.event_name != 'workflow_dispatch'
|
|
uses: ./.github/actions/ensure-base-commit
|
|
with:
|
|
base-sha: ${{ steps.diff_base.outputs.sha }}
|
|
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
|
|
|
- name: Prepare trusted pre-commit config
|
|
if: github.event_name == 'pull_request'
|
|
env:
|
|
BASE_SHA: ${{ steps.diff_base.outputs.sha }}
|
|
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
|
|
if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null &&
|
|
git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
|
|
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
|
|
elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \
|
|
> "$trusted_config" 2>/dev/null; then
|
|
echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead."
|
|
else
|
|
echo "::warning title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}; falling back to the checked-out config."
|
|
rm -f "$trusted_config"
|
|
exit 0
|
|
fi
|
|
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
|
|
|
|
- name: Resolve Python runtime
|
|
id: setup-python
|
|
run: |
|
|
set -euo pipefail
|
|
python3 --version
|
|
version="$(python3 - <<'PY'
|
|
import platform
|
|
print(platform.python_version())
|
|
PY
|
|
)"
|
|
echo "python-version=${version}" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Install pre-commit
|
|
run: python3 -m pip install --disable-pip-version-check pre-commit==4.2.0
|
|
|
|
- name: Detect committed private keys
|
|
run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key
|
|
|
|
- name: Audit changed GitHub workflows with zizmor
|
|
env:
|
|
BASE_SHA: ${{ steps.diff_base.outputs.sha }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then
|
|
echo "No usable base SHA detected; skipping zizmor."
|
|
exit 0
|
|
fi
|
|
|
|
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
|
echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor."
|
|
exit 0
|
|
fi
|
|
|
|
mapfile -t workflow_files < <(
|
|
git diff --name-only "${BASE_SHA}" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml'
|
|
)
|
|
if [ "${#workflow_files[@]}" -eq 0 ]; then
|
|
echo "No workflow changes detected; skipping zizmor."
|
|
exit 0
|
|
fi
|
|
|
|
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
|
|
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
|
|
|
- name: Setup Node.js
|
|
env:
|
|
REQUESTED_NODE_VERSION: "24.x"
|
|
run: |
|
|
set -euo pipefail
|
|
source .github/actions/setup-pnpm-store-cache/ensure-node.sh
|
|
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
|
|
|
- name: Audit production dependencies
|
|
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
|
|
|
|
# Warm the lockfile- and pnpm-pinned store without blocking Linux Node shards.
|
|
# On a cold key this job owns the save for later workflow runs.
|
|
pnpm-store-warmup:
|
|
permissions:
|
|
contents: read
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
steps:
|
|
- &linux_node_checkout_step
|
|
name: Checkout
|
|
shell: bash
|
|
env:
|
|
CHECKOUT_REPO: ${{ github.repository }}
|
|
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
workdir="$GITHUB_WORKSPACE"
|
|
reset_checkout_dir() {
|
|
mkdir -p "$workdir"
|
|
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
|
}
|
|
|
|
checkout_attempt() {
|
|
local attempt="$1"
|
|
|
|
reset_checkout_dir
|
|
git init "$workdir" >/dev/null
|
|
git config --global --add safe.directory "$workdir"
|
|
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
|
git -C "$workdir" config gc.auto 0
|
|
|
|
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
|
-c protocol.version=2 \
|
|
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
|
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
|
|
|
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
|
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
|
echo "checkout attempt ${attempt}/5 succeeded"
|
|
}
|
|
|
|
for attempt in 1 2 3 4 5; do
|
|
if checkout_attempt "$attempt"; then
|
|
exit 0
|
|
fi
|
|
echo "checkout attempt ${attempt}/5 failed"
|
|
sleep $((attempt * 5))
|
|
done
|
|
|
|
echo "checkout failed after 5 attempts" >&2
|
|
exit 1
|
|
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
save-actions-cache: "true"
|
|
|
|
# Build dist once for Node-relevant changes and share it with downstream jobs.
|
|
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
|
|
# test/build feedback sooner instead of waiting behind a full `check` pass.
|
|
build-artifacts:
|
|
permissions:
|
|
contents: read
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
|
# 32 vCPU: the dist build sits on the light-run critical path alongside
|
|
# check-lint/check-dependencies; tsdown parallelizes across the extra
|
|
# cores for roughly the same billed core-minutes.
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
outputs:
|
|
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
|
|
core-support-boundary-result: ${{ steps.built_artifact_checks.outputs['core-support-boundary-result'] }}
|
|
gateway-watch-result: ${{ steps.built_artifact_checks.outputs['gateway-watch-result'] }}
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Ensure secrets base commit (PR fast path)
|
|
if: github.event_name == 'pull_request'
|
|
uses: ./.github/actions/ensure-base-commit
|
|
with:
|
|
base-sha: ${{ needs.preflight.outputs.diff_base_revision }}
|
|
fetch-ref: ${{ github.event.pull_request.base.ref }}
|
|
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
node-compile-cache: "true"
|
|
node-compile-cache-scope: "build"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
runtime-cache-sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
save-node-compile-cache: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'true' || 'false' }}
|
|
|
|
- name: Restore build-all step cache
|
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
with:
|
|
path: .artifacts/build-all-cache
|
|
key: ${{ runner.os }}-build-all-v4-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-private-local-only-subpaths.json', 'scripts/lib/plugin-sdk-deprecated-public-subpaths.json', 'scripts/lib/plugin-sdk-deprecated-barrel-subpaths.json', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/**', 'packages/**', '!src/**/dist/**', '!src/**/node_modules/**', '!packages/**/dist/**', '!packages/**/node_modules/**') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-build-all-v4-
|
|
|
|
- name: Restore dist build cache
|
|
id: dist_build_cache
|
|
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
with:
|
|
path: |
|
|
dist/
|
|
dist-runtime/
|
|
packages/*/dist/
|
|
extensions/*/src/host/**/.bundle.hash
|
|
extensions/*/src/host/**/*.bundle.js
|
|
key: ${{ runner.os }}-dist-build-v3-${{ needs.preflight.outputs.checkout_revision }}
|
|
|
|
- name: Build dist
|
|
if: steps.dist_build_cache.outputs.cache-hit != 'true'
|
|
env:
|
|
NODE_OPTIONS: --max-old-space-size=8192
|
|
run: pnpm build:ci-artifacts
|
|
|
|
- name: Pack built runtime artifacts
|
|
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime packages/*/dist
|
|
|
|
- name: Upload built runtime artifacts
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: dist-runtime-build
|
|
path: dist-runtime-build.tar.zst
|
|
retention-days: 1
|
|
|
|
- name: Upload bundled plugin asset artifacts
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: bundled-plugin-assets
|
|
path: |
|
|
extensions/*/src/host/**/.bundle.hash
|
|
extensions/*/src/host/**/*.bundle.js
|
|
include-hidden-files: true
|
|
retention-days: 1
|
|
|
|
- name: Smoke test CLI launcher help
|
|
run: node openclaw.mjs --help
|
|
|
|
- name: Smoke test CLI launcher status json
|
|
run: node openclaw.mjs status --json --timeout 1
|
|
|
|
- name: Smoke test built bundled plugin singleton
|
|
run: pnpm test:build:singleton
|
|
|
|
- name: Check CLI startup memory
|
|
shell: bash
|
|
env:
|
|
# GitHub-hosted Linux reports a higher RSS baseline than Blacksmith for
|
|
# the same built CLI. Keep the tighter Blacksmith regression ceiling.
|
|
OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB: ${{ runner.environment == 'github-hosted' && '425' || '400' }}
|
|
run: |
|
|
set +e
|
|
pnpm test:startup:memory
|
|
status=$?
|
|
if [[ -f .artifacts/startup-memory/summary.md ]]; then
|
|
cat .artifacts/startup-memory/summary.md >> "$GITHUB_STEP_SUMMARY"
|
|
fi
|
|
exit "$status"
|
|
|
|
- name: Upload startup memory report
|
|
if: always()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: startup-memory
|
|
path: .artifacts/startup-memory/
|
|
if-no-files-found: ignore
|
|
retention-days: 7
|
|
|
|
- name: Run built artifact checks
|
|
id: built_artifact_checks
|
|
if: needs.preflight.outputs.run_checks == 'true' || needs.preflight.outputs.run_checks_node_core_dist == 'true' || needs.preflight.outputs.run_check_additional == 'true'
|
|
env:
|
|
RUN_CHANNELS: ${{ needs.preflight.outputs.run_checks }}
|
|
RUN_CORE_SUPPORT_BOUNDARY: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
|
|
RUN_GATEWAY_WATCH: ${{ needs.preflight.outputs.run_check_additional }}
|
|
shell: bash
|
|
run: |
|
|
set -uo pipefail
|
|
|
|
names=()
|
|
pids=()
|
|
logs=()
|
|
declare -A results=(
|
|
["channels"]="skipped"
|
|
["core-support-boundary"]="skipped"
|
|
["gateway-watch"]="skipped"
|
|
)
|
|
|
|
start_check() {
|
|
local name="$1"
|
|
shift
|
|
local log="${RUNNER_TEMP}/${name}.log"
|
|
names+=("$name")
|
|
logs+=("$log")
|
|
echo "starting ${name}: $*"
|
|
"$@" >"$log" 2>&1 &
|
|
pids+=("$!")
|
|
}
|
|
|
|
if [ "$RUN_CHANNELS" = "true" ]; then
|
|
start_check "channels" env \
|
|
NODE_OPTIONS=--max-old-space-size=8192 \
|
|
OPENCLAW_VITEST_MAX_WORKERS=1 \
|
|
pnpm test:channels
|
|
fi
|
|
|
|
if [ "$RUN_CORE_SUPPORT_BOUNDARY" = "true" ]; then
|
|
start_check "core-support-boundary" env \
|
|
NODE_OPTIONS=--max-old-space-size=8192 \
|
|
OPENCLAW_VITEST_MAX_WORKERS=2 \
|
|
node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts
|
|
fi
|
|
|
|
for index in "${!pids[@]}"; do
|
|
name="${names[$index]}"
|
|
log="${logs[$index]}"
|
|
pid="${pids[$index]}"
|
|
|
|
if wait "$pid"; then
|
|
result="success"
|
|
else
|
|
result="failure"
|
|
fi
|
|
|
|
echo "::group::${name} log"
|
|
cat "$log"
|
|
echo "::endgroup::"
|
|
results["$name"]="$result"
|
|
done
|
|
|
|
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
|
|
log="${RUNNER_TEMP}/gateway-watch.log"
|
|
echo "starting gateway-watch: node scripts/check-gateway-watch-regression.mjs --skip-build"
|
|
if node scripts/check-gateway-watch-regression.mjs --skip-build >"$log" 2>&1; then
|
|
result="success"
|
|
else
|
|
result="failure"
|
|
fi
|
|
|
|
echo "::group::gateway-watch log"
|
|
cat "$log"
|
|
echo "::endgroup::"
|
|
results["gateway-watch"]="$result"
|
|
fi
|
|
|
|
for name in channels core-support-boundary gateway-watch; do
|
|
echo "${name}-result=${results[$name]}" >> "$GITHUB_OUTPUT"
|
|
done
|
|
|
|
failures=0
|
|
for name in channels core-support-boundary gateway-watch; do
|
|
if [ "${results[$name]}" = "failure" ]; then
|
|
echo "::error title=${name} failed::${name} failed"
|
|
failures=1
|
|
fi
|
|
done
|
|
exit "$failures"
|
|
|
|
- name: Save dist build cache
|
|
if: steps.dist_build_cache.outputs.cache-hit != 'true'
|
|
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
continue-on-error: true
|
|
with:
|
|
path: |
|
|
dist/
|
|
dist-runtime/
|
|
packages/*/dist/
|
|
extensions/*/src/host/**/.bundle.hash
|
|
extensions/*/src/host/**/*.bundle.js
|
|
key: ${{ steps.dist_build_cache.outputs.cache-primary-key }}
|
|
|
|
- name: Upload gateway watch regression artifacts
|
|
if: always() && needs.preflight.outputs.run_check_additional == 'true'
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: gateway-watch-regression
|
|
path: .local/gateway-watch-regression/
|
|
retention-days: 7
|
|
|
|
native-i18n:
|
|
permissions:
|
|
contents: read
|
|
needs: [preflight]
|
|
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_native_i18n == 'true' }}
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 10
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
with:
|
|
ref: ${{ needs.preflight.outputs.checkout_revision }}
|
|
persist-credentials: false
|
|
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
- name: Check native app i18n inventory
|
|
run: pnpm native:i18n:check
|
|
|
|
- name: Check Android app i18n resources
|
|
run: pnpm android:i18n:check
|
|
|
|
- name: Check Apple app i18n catalogs
|
|
run: pnpm apple:i18n:check
|
|
|
|
checks-ui:
|
|
permissions:
|
|
contents: read
|
|
name: checks-ui
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_ui_tests == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
env:
|
|
COMPATIBILITY_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
node-version: "24.x"
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
- name: Install Playwright Chromium
|
|
run: |
|
|
if [[ "$COMPATIBILITY_TARGET" == "true" ]]; then
|
|
# Legacy Vitest configs cannot pass a discovered system browser to Playwright.
|
|
# Install the managed browser revision pinned by the selected target instead.
|
|
pnpm --dir ui exec playwright install chromium
|
|
else
|
|
node scripts/ensure-playwright-chromium.mjs
|
|
fi
|
|
|
|
- name: Lint Control UI window.open usage
|
|
run: pnpm lint:ui:no-raw-window-open
|
|
|
|
- name: Test Control UI
|
|
run: |
|
|
if [[ "$COMPATIBILITY_TARGET" == "true" ]]; then
|
|
# Frozen targets can contain timing-sensitive tests fixed on current main.
|
|
# Give legacy browser fixtures enough headroom on shared hosted runners.
|
|
# Isolate files because older suites can still leak module mocks between tests.
|
|
# Do not retry whole files: several rely on one-shot mocked browser globals.
|
|
pnpm --dir ui test --testTimeout=30000 --isolate
|
|
else
|
|
pnpm --dir ui test
|
|
fi
|
|
|
|
control-ui-i18n:
|
|
permissions:
|
|
contents: read
|
|
name: control-ui-i18n
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 10
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
node-version: "24.x"
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
- name: Check Control UI locale parity
|
|
# Source-only drift stays advisory because the post-merge bot owns
|
|
# repair. Generated locale changes and full release CI remain strict.
|
|
continue-on-error: ${{ needs.preflight.outputs.strict_control_ui_i18n != 'true' }}
|
|
run: pnpm ui:i18n:check
|
|
|
|
checks-fast-core:
|
|
permissions:
|
|
contents: read
|
|
pull-requests: read
|
|
name: ${{ matrix.check_name }}
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
|
timeout-minutes: 60
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 12
|
|
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Prepare release-gate max-lines merge tree
|
|
if: matrix.task == 'max-lines-ratchet' && github.event_name == 'workflow_dispatch' && inputs.release_gate
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
|
TARGET_SHA: ${{ inputs.target_ref }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
prepared=false
|
|
for attempt in {1..6}; do
|
|
pr_info="$(
|
|
gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PULL_REQUEST_NUMBER}" |
|
|
jq -r --arg repo "$GITHUB_REPOSITORY" --arg target "$TARGET_SHA" '
|
|
select(.state == "open" and .head.sha == $target and .base.repo.full_name == $repo)
|
|
| [.base.sha, (if .mergeable == null then "unknown" else (.mergeable | tostring) end)]
|
|
| @tsv
|
|
'
|
|
)"
|
|
if [[ -z "$pr_info" ]]; then
|
|
echo "release-gate pull request must be open and match the target head" >&2
|
|
exit 1
|
|
fi
|
|
IFS=$'\t' read -r base_sha mergeable <<<"$pr_info"
|
|
if [[ "$mergeable" == "false" ]]; then
|
|
echo "release-gate pull request is not mergeable" >&2
|
|
exit 1
|
|
fi
|
|
if [[ "$mergeable" == "true" ]] &&
|
|
git fetch --no-tags --depth=2 origin \
|
|
"+refs/pull/${PULL_REQUEST_NUMBER}/merge:refs/remotes/origin/ci-max-lines-merge"; then
|
|
merge_sha="$(git rev-parse refs/remotes/origin/ci-max-lines-merge)"
|
|
read -r merge_base merge_head extra_parent <<<"$(git show -s --format=%P "$merge_sha")"
|
|
if [[ "$merge_base" == "$base_sha" && "$merge_head" == "$TARGET_SHA" && -z "$extra_parent" ]]; then
|
|
prepared=true
|
|
break
|
|
fi
|
|
fi
|
|
if [[ "$attempt" != "6" ]]; then
|
|
sleep 5
|
|
fi
|
|
done
|
|
if [[ "$prepared" != "true" ]]; then
|
|
echo "release-gate merge tree did not refresh to the current pull request base and head" >&2
|
|
exit 1
|
|
fi
|
|
git checkout --detach "$merge_sha"
|
|
echo "RATCHET_RELEASE_BASE_SHA=${base_sha}" >> "$GITHUB_ENV"
|
|
echo "RATCHET_RELEASE_MERGE_TREE=true" >> "$GITHUB_ENV"
|
|
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || 'false' }}
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
|
env:
|
|
GH_TOKEN: ${{ matrix.task == 'max-lines-ratchet' && github.token || '' }}
|
|
OPENCLAW_TEST_PROJECTS_PARALLEL: 3
|
|
RATCHET_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
|
RATCHET_EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || '' }}
|
|
RATCHET_MANUAL_TARGET_SHA: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.checkout_revision || '' }}
|
|
RATCHET_PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
|
|
TASK: ${{ matrix.task }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
has_package_script() {
|
|
node -e '
|
|
const scripts = require("./package.json").scripts ?? {};
|
|
process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1);
|
|
' "$1"
|
|
}
|
|
case "$TASK" in
|
|
bundled-protocol)
|
|
pnpm test:bundled
|
|
pnpm protocol:check
|
|
;;
|
|
contracts-plugins-ci-routing)
|
|
pnpm test:contracts:plugins
|
|
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-run-node-test-shard.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
|
;;
|
|
ci-routing)
|
|
pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope.test.ts test/scripts/changed-lanes.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-run-node-test-shard.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
|
;;
|
|
max-lines-ratchet)
|
|
if ! has_package_script "check:max-lines-ratchet"; then
|
|
echo "Current CI targets must provide check:max-lines-ratchet." >&2
|
|
exit 1
|
|
fi
|
|
base_sha="${RATCHET_EVENT_BASE_SHA:-${RATCHET_RELEASE_BASE_SHA:-}}"
|
|
if [[ "$base_sha" == "0000000000000000000000000000000000000000" ]]; then
|
|
base_sha=""
|
|
fi
|
|
base_ref=""
|
|
if [[ -n "${RATCHET_PR_HEAD_SHA:-}" ]]; then
|
|
mapfile -t merge_parents < <(git cat-file -p HEAD | sed -n 's/^parent //p')
|
|
if [[ "${#merge_parents[@]}" != "2" || "${merge_parents[1]:-}" != "$RATCHET_PR_HEAD_SHA" ]]; then
|
|
echo "Pull request checkout is not the expected two-parent merge tree." >&2
|
|
exit 1
|
|
fi
|
|
merge_base="${merge_parents[0]}"
|
|
git fetch --no-tags --depth=1 origin \
|
|
"+${merge_base}:refs/remotes/origin/ci-max-lines-base"
|
|
base_ref="refs/remotes/origin/ci-max-lines-base"
|
|
elif [[ -n "$base_sha" ]]; then
|
|
git fetch --no-tags --depth=1 origin \
|
|
"+${base_sha}:refs/remotes/origin/ci-max-lines-base"
|
|
base_ref="refs/remotes/origin/ci-max-lines-base"
|
|
elif [[ -n "${RATCHET_MANUAL_TARGET_SHA:-}" ]]; then
|
|
default_branch="${RATCHET_DEFAULT_BRANCH:-main}"
|
|
default_sha="$(
|
|
git ls-remote origin "refs/heads/${default_branch}" | awk 'NR == 1 { print $1 }'
|
|
)"
|
|
if [[ ! "$default_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "Could not resolve the default branch head for the max-lines ratchet." >&2
|
|
exit 1
|
|
fi
|
|
merge_base_sha="$(
|
|
gh api --method GET \
|
|
"repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${RATCHET_MANUAL_TARGET_SHA}" \
|
|
--jq '.merge_base_commit.sha'
|
|
)"
|
|
if [[ ! "$merge_base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "Could not resolve the manual target merge base for the max-lines ratchet." >&2
|
|
exit 1
|
|
fi
|
|
git fetch --no-tags --depth=1 origin \
|
|
"+${merge_base_sha}:refs/remotes/origin/ci-max-lines-base"
|
|
base_ref="refs/remotes/origin/ci-max-lines-base"
|
|
else
|
|
default_branch="${RATCHET_DEFAULT_BRANCH:-main}"
|
|
git fetch --no-tags --depth=1 origin \
|
|
"+refs/heads/${default_branch}:refs/remotes/origin/ci-max-lines-base"
|
|
base_ref="refs/remotes/origin/ci-max-lines-base"
|
|
fi
|
|
unset GH_TOKEN
|
|
pnpm check:max-lines-ratchet --base "$base_ref"
|
|
if [[ "${RATCHET_RELEASE_MERGE_TREE:-}" == "true" ]]; then
|
|
node scripts/run-oxlint.mjs src ui/src packages extensions
|
|
fi
|
|
;;
|
|
bun-launcher)
|
|
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
|
|
;;
|
|
*)
|
|
echo "Unsupported checks-fast task: $TASK" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
qa-smoke-ci-profile:
|
|
permissions:
|
|
contents: read
|
|
name: QA Smoke CI (${{ matrix.name }})
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_qa_smoke_ci == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 60
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 4
|
|
matrix:
|
|
include:
|
|
- name: profile 1/4
|
|
lane: profile-1
|
|
slug: profile-1-of-4
|
|
- name: profile 2/4
|
|
lane: profile-2
|
|
slug: profile-2-of-4
|
|
docker_cache: true
|
|
- name: profile 3/4
|
|
lane: profile-3
|
|
slug: profile-3-of-4
|
|
- name: profile 4/4
|
|
lane: profile-4
|
|
slug: profile-4-of-4
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
- name: Set up Blacksmith Docker layer cache
|
|
if: ${{ matrix.docker_cache == true && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') }}
|
|
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
|
with:
|
|
max-cache-size-mb: 800000
|
|
|
|
- name: Build QA smoke runtime
|
|
env:
|
|
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
|
NODE_OPTIONS: --max-old-space-size=8192
|
|
run: |
|
|
node scripts/build-all.mjs qaRuntime
|
|
pnpm ui:build
|
|
package_args=(
|
|
--skip-build
|
|
--output-dir .artifacts/qa-e2e/smoke-ci-package
|
|
--output-name openclaw-current.tgz
|
|
)
|
|
if grep -Fq -- '--allow-unreleased-changelog' scripts/package-openclaw-for-docker.mjs; then
|
|
package_args=(--allow-unreleased-changelog "${package_args[@]}")
|
|
fi
|
|
node scripts/package-openclaw-for-docker.mjs "${package_args[@]}"
|
|
|
|
- name: Run smoke profile part
|
|
env:
|
|
PROFILE_PART: ${{ matrix.lane }}
|
|
PROFILE_PART_SLUG: ${{ matrix.slug }}
|
|
OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && '0' || '1500' }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
output_dir=".artifacts/qa-e2e/smoke-ci-profile-${PROFILE_PART_SLUG}"
|
|
export OPENCLAW_BUILD_PRIVATE_QA=1
|
|
export OPENCLAW_ENABLE_PRIVATE_QA_CLI=1
|
|
export OPENCLAW_DISABLE_BUNDLED_PLUGINS=0
|
|
export OPENCLAW_QA_REDACT_PUBLIC_METADATA=1
|
|
export OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS=180000
|
|
export NODE_OPTIONS=--max-old-space-size=16384
|
|
export OPENCLAW_CURRENT_PACKAGE_TGZ="$PWD/.artifacts/qa-e2e/smoke-ci-package/openclaw-current.tgz"
|
|
PROFILE_RUNS_TSV="$(
|
|
node --import tsx --input-type=module <<'EOF'
|
|
const smokePlan = await import("./extensions/qa-lab/src/ci-smoke-plan.ts");
|
|
const partId = process.env.PROFILE_PART ?? "";
|
|
let runs;
|
|
if (typeof smokePlan.createQaSmokeCiPart === "function") {
|
|
try {
|
|
runs = smokePlan.createQaSmokeCiPart(partId).runs;
|
|
} catch (error) {
|
|
// Frozen/compat targets ship older planners that declare fewer
|
|
// profile parts; the declared parts still cover every scenario.
|
|
if (/unknown QA smoke CI profile part/.test(String(error)) && partId !== "profile-1") {
|
|
// stdout is the TSV contract consumed below. Keep diagnostics on stderr
|
|
// so an empty compatibility shard remains empty instead of becoming a run.
|
|
console.error(`[skip] ${partId} is not declared by this checkout's smoke plan`);
|
|
process.exit(0);
|
|
}
|
|
throw error;
|
|
}
|
|
} else if (typeof smokePlan.createQaSmokeCiMatrix === "function") {
|
|
// Legacy planners select the entire profile and can mix long-lived
|
|
// execution kinds. Reuse the current bounded smoke contract and
|
|
// isolate each scenario so one invocation cannot pin a profile part.
|
|
const compatibilityScenarioIds = new Set([
|
|
"control-ui-chat-flow-playwright",
|
|
"system-agent-ring-zero-setup",
|
|
"dreaming-shadow-trial-report",
|
|
"gateway-smoke",
|
|
"group-visible-reply-tool",
|
|
"long-running-release-audit",
|
|
"luna-thinking-visibility-switch",
|
|
"matrix-restart-resume",
|
|
"personal-task-followthrough-status",
|
|
"plugin-lifecycle-hot-reload",
|
|
"subagent-completion-direct-fallback",
|
|
"telegram-commands-command",
|
|
]);
|
|
const partIndex = partId === "profile-1" ? 0 : partId === "profile-2" ? 1 : -1;
|
|
if (partIndex < 0) {
|
|
throw new Error(`unknown QA smoke CI profile part: ${partId}`);
|
|
}
|
|
const scenarioCatalog = await import("./extensions/qa-lab/src/scenario-catalog.ts");
|
|
const scenarioKindById = new Map(
|
|
scenarioCatalog
|
|
.readQaScenarioPack()
|
|
.scenarios.map((scenario) => [scenario.id, scenario.execution.kind]),
|
|
);
|
|
const legacyRuns = smokePlan
|
|
.createQaSmokeCiMatrix()
|
|
.include.filter((_, index) => index % 2 === partIndex);
|
|
runs = legacyRuns.flatMap((run) =>
|
|
run.scenario_ids.flatMap((scenarioId) => {
|
|
if (!compatibilityScenarioIds.has(scenarioId)) {
|
|
return [];
|
|
}
|
|
const kind = scenarioKindById.get(scenarioId);
|
|
if (!kind) {
|
|
throw new Error(`legacy QA smoke scenario not found: ${scenarioId}`);
|
|
}
|
|
return [
|
|
{
|
|
...run,
|
|
slug: `${run.slug}-${kind}-${scenarioId}`,
|
|
scenario_ids: [scenarioId],
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
} else {
|
|
throw new Error("QA smoke plan does not expose a supported CI planner.");
|
|
}
|
|
for (const run of runs) {
|
|
const scenarioIds = Buffer.from(JSON.stringify(run.scenario_ids)).toString("base64");
|
|
process.stdout.write(`${run.slug}\t${scenarioIds}\n`);
|
|
}
|
|
EOF
|
|
)"
|
|
if [[ -z "${PROFILE_RUNS_TSV//[[:space:]]/}" ]]; then
|
|
echo "No QA smoke runs assigned to ${PROFILE_PART}; skipping this compatibility shard."
|
|
exit 0
|
|
fi
|
|
qa_exit_code=0
|
|
while IFS=$'\t' read -r run_slug scenario_ids_base64; do
|
|
export SCENARIO_IDS_BASE64="$scenario_ids_base64"
|
|
mapfile -t scenario_ids < <(
|
|
node -e 'for (const id of JSON.parse(Buffer.from(process.env.SCENARIO_IDS_BASE64, "base64"))) console.log(id)'
|
|
)
|
|
scenario_args=()
|
|
for scenario_id in "${scenario_ids[@]}"; do
|
|
scenario_args+=(--scenario "$scenario_id")
|
|
done
|
|
timeout --signal=TERM --kill-after=15s 10m node openclaw.mjs qa run \
|
|
--repo-root . \
|
|
--qa-profile smoke-ci \
|
|
--concurrency 10 \
|
|
--output-dir "$output_dir/$run_slug" \
|
|
"${scenario_args[@]}" || qa_exit_code=$?
|
|
done <<< "$PROFILE_RUNS_TSV"
|
|
echo "QA smoke profile evidence: \`${output_dir}\`" >> "$GITHUB_STEP_SUMMARY"
|
|
if [ "$qa_exit_code" -ne 0 ]; then
|
|
echo "::error title=QA smoke profile failed::smoke-ci profile part ${PROFILE_PART_SLUG} exited ${qa_exit_code}; evidence upload will still run"
|
|
exit "$qa_exit_code"
|
|
fi
|
|
|
|
- name: Upload QA smoke profile evidence
|
|
if: always()
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: qa-smoke-profile-${{ matrix.slug }}-${{ github.run_id }}-${{ github.run_attempt }}
|
|
path: .artifacts/qa-e2e/smoke-ci-profile-${{ matrix.slug }}/
|
|
if-no-files-found: warn
|
|
retention-days: 7
|
|
|
|
checks-fast-plugin-contracts-shard:
|
|
permissions:
|
|
contents: read
|
|
name: ${{ matrix.checkName }}
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 60
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 12
|
|
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
- name: Run plugin contract shard
|
|
env:
|
|
OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
include_file="$RUNNER_TEMP/plugin-contract-include.json"
|
|
INCLUDE_FILE="$include_file" node --input-type=module <<'EOF'
|
|
import { writeFileSync } from "node:fs";
|
|
|
|
const includePatterns = JSON.parse(process.env.OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON ?? "[]");
|
|
if (!Array.isArray(includePatterns) || includePatterns.length === 0) {
|
|
console.error("Missing plugin contract include patterns");
|
|
process.exit(1);
|
|
}
|
|
writeFileSync(process.env.INCLUDE_FILE, JSON.stringify(includePatterns), "utf8");
|
|
EOF
|
|
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:plugins
|
|
|
|
checks-fast-channel-contracts-shard:
|
|
permissions:
|
|
contents: read
|
|
name: ${{ matrix.checkName }}
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_channel_contracts_shards == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 60
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 12
|
|
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
- name: Run channel contract shard
|
|
env:
|
|
OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
include_file="$RUNNER_TEMP/channel-contract-include.json"
|
|
INCLUDE_FILE="$include_file" node --input-type=module <<'EOF'
|
|
import { writeFileSync } from "node:fs";
|
|
|
|
const includePatterns = JSON.parse(process.env.OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON ?? "[]");
|
|
if (!Array.isArray(includePatterns) || includePatterns.length === 0) {
|
|
console.error("Missing channel contract include patterns");
|
|
process.exit(1);
|
|
}
|
|
writeFileSync(process.env.INCLUDE_FILE, JSON.stringify(includePatterns), "utf8");
|
|
EOF
|
|
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
|
|
|
|
checks-node-compat:
|
|
permissions:
|
|
contents: read
|
|
name: checks-node-compat-node22
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 60
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
node-version: "22.22.3"
|
|
install-bun: "false"
|
|
build-all-cache-scope: full
|
|
|
|
- name: Configure Node test resources
|
|
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
|
|
|
|
- name: Run Node 22 compatibility
|
|
env:
|
|
NODE_OPTIONS: --max-old-space-size=8192
|
|
run: |
|
|
pnpm build
|
|
pnpm ui:build
|
|
node openclaw.mjs --help
|
|
node openclaw.mjs status --json --timeout 1
|
|
pnpm test:build:singleton
|
|
|
|
checks-node-core-test-nondist-shard:
|
|
permissions:
|
|
contents: read
|
|
name: ${{ matrix.check_name }}
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
|
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
|
|
strategy:
|
|
fail-fast: false
|
|
# The canonical main path waits for the admission debounce above, so
|
|
# widen this large matrix within the current runner-registration budget.
|
|
max-parallel: 28
|
|
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
node-version: "${{ matrix.node_version || '24.x' }}"
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
runtime-cache-sticky-disk: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
vitest-fs-cache: "true"
|
|
node-compile-cache: "true"
|
|
node-compile-cache-scope: "test"
|
|
# One matrix job is the designated warm-seed writer; the action only
|
|
# honors it outside pull_request events, so PR shards always read the
|
|
# protected snapshot without committing competing writes.
|
|
save-vitest-fs-cache: ${{ matrix.save_vitest_fs_cache && 'true' || 'false' }}
|
|
save-node-compile-cache: ${{ matrix.save_vitest_fs_cache && 'true' || 'false' }}
|
|
|
|
- name: Setup Go for docs i18n
|
|
if: matrix.requires_go == true
|
|
# The current workflow validates frozen targets whose go.mod may predate this patch pin.
|
|
# Keep the runner toolchain owned by the workflow while using the target only for cache keys.
|
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version: "1.25.12"
|
|
cache-dependency-path: scripts/docs-i18n/go.sum
|
|
|
|
- name: Verify docs i18n Go toolchain
|
|
if: matrix.requires_go == true
|
|
run: test "$(go env GOVERSION)" = "go1.25.12"
|
|
|
|
- name: Configure Node test resources
|
|
# Scale the in-process Vitest worker budget with the cores the job
|
|
# actually received: runner labels over-promise under fleet load
|
|
# (observed: a 16 vCPU label delivering a 4-CPU cgroup) and dispatch
|
|
# runs fall back to hosted runners regardless of matrix.runner.
|
|
# Serial-plan bins (planConcurrency 1) get the full budget; anything
|
|
# that can overlap Vitest processes keeps the proven 2-worker cap.
|
|
# Timing-sensitive groups stay pinned to 2 via plan-level env.
|
|
env:
|
|
SHARD_PLAN_CONCURRENCY: ${{ matrix.plan_concurrency || '' }}
|
|
run: |
|
|
cores="$(nproc)"
|
|
if [ "$SHARD_PLAN_CONCURRENCY" != "1" ]; then
|
|
workers=2
|
|
elif [ "$cores" -ge 12 ]; then
|
|
workers=6
|
|
elif [ "$cores" -ge 6 ]; then
|
|
workers=4
|
|
else
|
|
workers=3
|
|
fi
|
|
echo "detected cores=$cores plan_concurrency=${SHARD_PLAN_CONCURRENCY:-default} -> workers=$workers"
|
|
echo "OPENCLAW_VITEST_MAX_WORKERS=$workers" >> "$GITHUB_ENV"
|
|
|
|
- name: Run Node test shard
|
|
# actionlint 1.7.11 lacks GitHub's current job.workflow_* fields. Serialize the
|
|
# non-secret job context, then validate the defining workflow identity below.
|
|
env:
|
|
JOB_CONTEXT_JSON: ${{ toJSON(job) }}
|
|
NODE_OPTIONS: --max-old-space-size=8192
|
|
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
|
|
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
|
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
|
|
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
|
OPENCLAW_NODE_TEST_TARGETS_JSON: ${{ toJson(matrix.targets) }}
|
|
# Frozen targets can carry integration hooks whose cold setup exceeds
|
|
# the current 120-second default on shared release runners.
|
|
OPENCLAW_NODE_TEST_VITEST_ARGS_JSON: ${{ needs.preflight.outputs.compatibility_target == 'true' && '["--hookTimeout=300000"]' || '[]' }}
|
|
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
|
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
|
|
OPENCLAW_VITEST_NO_OUTPUT_RETRY: "1"
|
|
OPENCLAW_NODE_TEST_PLAN_CONCURRENCY: ${{ matrix.plan_concurrency }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
runner="scripts/ci-run-node-test-shard.mjs"
|
|
if [[ ! -f "$runner" ]]; then
|
|
# Frozen release targets can predate the workflow-owned shard runner.
|
|
# Load only that runner from the exact trusted workflow commit while
|
|
# keeping its child tests rooted in the checked-out candidate.
|
|
job_workflow_repository=$(jq -r '.workflow_repository // empty' <<<"$JOB_CONTEXT_JSON")
|
|
job_workflow_sha=$(jq -r '.workflow_sha // empty' <<<"$JOB_CONTEXT_JSON")
|
|
if [[ ! "$job_workflow_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
|
echo "invalid job workflow repository: $job_workflow_repository" >&2
|
|
exit 1
|
|
fi
|
|
if [[ ! "$job_workflow_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
|
echo "invalid job workflow SHA: $job_workflow_sha" >&2
|
|
exit 1
|
|
fi
|
|
harness_root="${RUNNER_TEMP}/openclaw-ci-shard-runner"
|
|
workflow_remote="https://github.com/${job_workflow_repository}.git"
|
|
git fetch --no-tags --depth=1 "$workflow_remote" "$job_workflow_sha"
|
|
for file in \
|
|
scripts/ci-run-node-test-shard.mjs \
|
|
scripts/lib/direct-run.mjs \
|
|
scripts/lib/local-heavy-check-runtime.mjs; do
|
|
mkdir -p "${harness_root}/$(dirname "$file")"
|
|
git show "${job_workflow_sha}:${file}" > "${harness_root}/${file}"
|
|
done
|
|
runner="${harness_root}/${runner}"
|
|
fi
|
|
node "$runner"
|
|
|
|
# Types, lint, and format check shards.
|
|
check-shard:
|
|
permissions:
|
|
contents: read
|
|
name: ${{ matrix.check_name }}
|
|
needs: [preflight]
|
|
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 12
|
|
matrix:
|
|
include:
|
|
- check_name: check-guards
|
|
task: guards
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
- check_name: check-shrinkwrap
|
|
task: shrinkwrap
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
- check_name: check-bundled-channel-config-metadata
|
|
task: bundled-channel-config-metadata
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
- check_name: check-prod-types
|
|
task: prod-types
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
- check_name: check-lint
|
|
task: lint
|
|
# Top light-run pole (~186s of shard work); oxlint shard
|
|
# concurrency scales with cores at similar billed core-minutes.
|
|
runner: blacksmith-32vcpu-ubuntu-2404
|
|
- check_name: check-dependencies
|
|
task: dependencies
|
|
# Concurrent Knip scans need cores and memory headroom; the wall
|
|
# clock roughly halves for similar billed core-minutes.
|
|
runner: blacksmith-32vcpu-ubuntu-2404
|
|
- check_name: check-test-types
|
|
task: test-types
|
|
# Slowest static lane (~3.5min on 4 vCPU); extra cores shorten the
|
|
# PR critical path for roughly the same billed core-minutes.
|
|
runner: blacksmith-16vcpu-ubuntu-2404
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
# check-lint's shard runner rebuilds the same plugin-sdk boundary
|
|
# artifacts the boundary lane snapshots (~72s cold); restore them from
|
|
# the shared sticky. Strictly read-only (commit: false): only the
|
|
# protected boundary lane may publish this repository-global snapshot.
|
|
- name: Mount extension boundary sticky disk
|
|
if: matrix.task == 'lint' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
|
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
|
|
with:
|
|
# One stable disk for the whole repository. The v1 per-PR/per-config
|
|
# keys minted a new backing disk for every PR and toolchain change
|
|
# and helped saturate Blacksmith's installation-wide sticky-disk
|
|
# budget, 429-failing every mount. Snapshot validity lives in the
|
|
# in-job marker checked below instead of the key.
|
|
key: ${{ github.repository }}-ext-boundary-v2
|
|
path: /var/tmp/openclaw-ext-boundary
|
|
commit: "false"
|
|
|
|
- name: Restore extension boundary artifacts from sticky disk
|
|
if: matrix.task == 'lint' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
|
shell: bash
|
|
env:
|
|
# Config/toolchain inputs the tree-OID gate below cannot see; must
|
|
# stay identical to the boundary lane's fingerprint composition.
|
|
BOUNDARY_CONFIG_HASH: ${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'package.json', 'pnpm-lock.yaml') }}
|
|
run: |
|
|
set -euo pipefail
|
|
sticky_root=/var/tmp/openclaw-ext-boundary
|
|
if [ ! -f "$sticky_root/.snapshot-ready" ]; then
|
|
echo "boundary artifact snapshot not seeded yet; lint prepares cold"
|
|
exit 0
|
|
fi
|
|
# Same tree-OID gate as the boundary lane: restore only artifacts
|
|
# produced from identical generative sources, so stale snapshots can
|
|
# neither false-skip rebuilds nor leave ghost declarations.
|
|
current_trees="$({ git rev-parse HEAD:src/plugin-sdk HEAD:packages HEAD:extensions HEAD:src/auto-reply HEAD:src/types HEAD:src/plugins/types.ts HEAD:src/video-generation HEAD:src/channels; node --version; corepack pnpm --version 2>/dev/null || pnpm --version; echo "$BOUNDARY_CONFIG_HASH"; })"
|
|
if [ ! -f "$sticky_root/.source-trees" ] || [ "$current_trees" != "$(cat "$sticky_root/.source-trees")" ]; then
|
|
echo "boundary source trees changed since snapshot; lint prepares cold"
|
|
exit 0
|
|
fi
|
|
for payload in dist packages extensions; do
|
|
if [ -d "$sticky_root/$payload" ]; then
|
|
rsync -a "$sticky_root/$payload/" "$payload/"
|
|
fi
|
|
done
|
|
|
|
- name: Run check shard
|
|
env:
|
|
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
|
|
FORMAT_CHECK: ${{ needs.preflight.outputs.run_format_check }}
|
|
RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }}
|
|
RUN_UI_TESTS: ${{ needs.preflight.outputs.run_ui_tests }}
|
|
OPENCLAW_LOCAL_CHECK: "0"
|
|
TASK: ${{ matrix.task }}
|
|
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && needs.preflight.outputs.diff_base_revision || '' }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
has_package_script() {
|
|
node -e '
|
|
const scripts = require("./package.json").scripts ?? {};
|
|
process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1);
|
|
' "$1"
|
|
}
|
|
case "$TASK" in
|
|
guards)
|
|
pnpm check:no-conflict-markers
|
|
pnpm tool-display:check
|
|
pnpm check:host-env-policy:swift
|
|
pnpm dup:check:coverage
|
|
if [ -n "$PR_BASE_SHA" ]; then
|
|
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/ci-base"
|
|
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/ci-base --head HEAD --no-merge-base
|
|
fi
|
|
pnpm deps:patches:check
|
|
pnpm lint:webhook:no-low-level-body-read
|
|
pnpm lint:auth:no-pairing-store-group
|
|
pnpm lint:auth:pairing-account-scope
|
|
pnpm check:import-cycles
|
|
;;
|
|
shrinkwrap)
|
|
pnpm deps:shrinkwrap:check
|
|
;;
|
|
bundled-channel-config-metadata)
|
|
pnpm check:bundled-channel-config-metadata
|
|
;;
|
|
prod-types)
|
|
pnpm tsgo:prod
|
|
;;
|
|
lint)
|
|
# The i18n verify covers keys extracted from every ui/ source,
|
|
# not just ui/src/i18n; any ui-touching diff (run_ui_tests) or
|
|
# i18n-tooling diff must run it. oxlint always runs.
|
|
if [ "$RUN_CONTROL_UI_I18N" = "true" ] || [ "$RUN_UI_TESTS" = "true" ]; then
|
|
pnpm lint --threads=8
|
|
else
|
|
echo "[skip] changed scope cannot affect control-UI i18n catalogs"
|
|
node scripts/run-oxlint-shards.mjs --threads=8
|
|
fi
|
|
if [ "$FORMAT_CHECK" = "true" ]; then
|
|
pnpm format:check
|
|
fi
|
|
;;
|
|
dependencies)
|
|
if has_package_script "deadcode:dependencies" &&
|
|
has_package_script "deadcode:unused-files"; then
|
|
# The three deadcode scripts spawn independent Knip scans over
|
|
# separate configs; run them concurrently (with buffered logs
|
|
# so output stays readable) instead of paying ~3 minutes of
|
|
# serial scanning.
|
|
dc_scripts=(deadcode:dependencies deadcode:unused-files)
|
|
if has_package_script "deadcode:exports"; then
|
|
dc_scripts+=(deadcode:exports)
|
|
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
|
|
echo "Current CI targets must provide the deadcode:exports package script." >&2
|
|
exit 1
|
|
fi
|
|
if [ "$(nproc)" -lt 8 ]; then
|
|
# Hosted/dispatch runners are too small for up to seven
|
|
# concurrent Knip processes; keep the serial path there.
|
|
for dc in "${dc_scripts[@]}"; do
|
|
pnpm "$dc"
|
|
done
|
|
else
|
|
dc_pids=()
|
|
dc_logs=()
|
|
for dc in "${dc_scripts[@]}"; do
|
|
dc_log="$(mktemp -t deadcode-log.XXXXXX)"
|
|
dc_logs+=("$dc_log")
|
|
pnpm "$dc" >"$dc_log" 2>&1 &
|
|
dc_pids+=($!)
|
|
done
|
|
dc_failures=0
|
|
for i in "${!dc_scripts[@]}"; do
|
|
if wait "${dc_pids[$i]}"; then
|
|
echo "[ok] ${dc_scripts[$i]}"
|
|
else
|
|
echo "::error title=${dc_scripts[$i]} failed::${dc_scripts[$i]} failed"
|
|
dc_failures=1
|
|
fi
|
|
cat "${dc_logs[$i]}"
|
|
done
|
|
if [ "$dc_failures" -ne 0 ]; then
|
|
exit 1
|
|
fi
|
|
fi
|
|
elif [[ "$HISTORICAL_TARGET" == "true" ]] && has_package_script "deadcode:ci"; then
|
|
pnpm deadcode:ci
|
|
else
|
|
echo "Target does not provide a supported deadcode check." >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
test-types)
|
|
pnpm check:test-types
|
|
if pnpm run --silent 2>/dev/null | grep -q '^ tsgo:scripts$'; then
|
|
pnpm tsgo:scripts
|
|
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
|
|
echo "Current CI targets must provide the tsgo:scripts package script." >&2
|
|
exit 1
|
|
fi
|
|
if pnpm run --silent 2>/dev/null | grep -q '^ tsgo:test:root$'; then
|
|
pnpm tsgo:test:root
|
|
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
|
|
echo "Current CI targets must provide the tsgo:test:root package script." >&2
|
|
exit 1
|
|
fi
|
|
;;
|
|
*)
|
|
echo "Unsupported check task: $TASK" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
check-additional-shard:
|
|
permissions:
|
|
contents: read
|
|
name: ${{ matrix.check_name }}
|
|
needs: [preflight]
|
|
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 12
|
|
matrix:
|
|
include:
|
|
- check_name: check-additional-boundaries-a
|
|
group: boundaries
|
|
boundary_shard: 1/4
|
|
runner: blacksmith-8vcpu-ubuntu-2404
|
|
- check_name: check-additional-boundaries-bcd
|
|
group: boundaries
|
|
boundary_shard: 2/4,3/4,4/4
|
|
runner: blacksmith-8vcpu-ubuntu-2404
|
|
# Prompt snapshot regeneration evaluates the full agent tool/prompt
|
|
# import graph and used to own the boundaries-a wall clock; keep it
|
|
# in its own lane.
|
|
- check_name: check-prompt-snapshots
|
|
group: prompt-snapshots
|
|
runner: blacksmith-8vcpu-ubuntu-2404
|
|
- check_name: check-session-accessor-boundary
|
|
group: session-accessor-boundary
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
- check_name: check-session-transcript-reader-boundary
|
|
group: session-transcript-reader-boundary
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
- check_name: check-sqlite-session-schema-baseline
|
|
group: sqlite-session-schema-baseline
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
- check_name: check-sqlite-session-flip-proof
|
|
group: sqlite-session-flip-proof
|
|
runner: blacksmith-8vcpu-ubuntu-2404
|
|
- check_name: check-additional-extension-package-boundary
|
|
group: extension-package-boundary
|
|
# Light-run critical-path pole: cold runs spend ~100s in nine
|
|
# parallel plugin-sdk dts builds plus ~58s compiling 122 plugins
|
|
# at concurrency 6 on 8 vCPUs. Both phases scale with cores at
|
|
# similar billed core-minutes.
|
|
runner: blacksmith-32vcpu-ubuntu-2404
|
|
- check_name: check-additional-runtime-topology-architecture
|
|
group: runtime-topology-architecture
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
# Same-repo runs carry boundary artifacts on a Blacksmith sticky disk:
|
|
# the GitHub cache below is evicted so quickly under the repo quota that
|
|
# its prefix restore never hits, leaving every run to rebuild ~113s of
|
|
# plugin-sdk declarations. Fork PRs must never produce writable
|
|
# repository-global snapshots, so they keep the GitHub cache path.
|
|
- name: Mount extension boundary sticky disk
|
|
if: matrix.group == 'extension-package-boundary' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
|
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
|
|
with:
|
|
# One stable disk for the whole repository. The v1 per-PR/per-config
|
|
# keys minted a new backing disk for every PR and toolchain change
|
|
# and helped saturate Blacksmith's installation-wide sticky-disk
|
|
# budget, 429-failing every mount. Snapshot validity lives in the
|
|
# in-job marker checked below instead of the key, so source and
|
|
# toolchain changes refresh this disk in place.
|
|
key: ${{ github.repository }}-ext-boundary-v2
|
|
path: /var/tmp/openclaw-ext-boundary
|
|
# Single semantic writer: only protected pushes commit, so
|
|
# pull_request clones stay read-only and the snapshot tracks main.
|
|
# Explicit true (not on-change/if-missing) because the allocated-byte
|
|
# heuristic can miss a same-size refresh, permanently stranding
|
|
# consumers on a stale marker. v1.4.0 skips commit after
|
|
# failed/cancelled steps, so a broken build cannot poison this key.
|
|
commit: ${{ github.event_name != 'pull_request' && 'true' || 'false' }}
|
|
|
|
- name: Restore extension boundary artifacts from sticky disk
|
|
id: boundary-sticky-restore
|
|
if: matrix.group == 'extension-package-boundary' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
|
shell: bash
|
|
env:
|
|
# Config/toolchain inputs the tree-OID gate below cannot see; must
|
|
# stay identical to the seed step and check-lint's restore gate.
|
|
BOUNDARY_CONFIG_HASH: ${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'package.json', 'pnpm-lock.yaml') }}
|
|
run: |
|
|
set -euo pipefail
|
|
sticky_root=/var/tmp/openclaw-ext-boundary
|
|
if [ ! -f "$sticky_root/.snapshot-ready" ]; then
|
|
echo "restored=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
# Restore only when the generative source trees AND the runner
|
|
# toolchain match the snapshotting run. Tree OIDs cover source
|
|
# content exactly; the node/pnpm versions and the config hash cover
|
|
# toolchain/config/scripts/lockfile changes the OIDs cannot see.
|
|
# Restored artifacts are valid by construction: no clock-skew mtime
|
|
# race can false-skip a rebuild, and deleted/renamed sources or a
|
|
# toolchain bump build cold.
|
|
current_trees="$({ git rev-parse HEAD:src/plugin-sdk HEAD:packages HEAD:extensions HEAD:src/auto-reply HEAD:src/types HEAD:src/plugins/types.ts HEAD:src/video-generation HEAD:src/channels; node --version; corepack pnpm --version 2>/dev/null || pnpm --version; echo "$BOUNDARY_CONFIG_HASH"; })"
|
|
if [ ! -f "$sticky_root/.source-trees" ] || [ "$current_trees" != "$(cat "$sticky_root/.source-trees")" ]; then
|
|
echo "boundary source trees changed since snapshot; building cold"
|
|
echo "restored=false" >> "$GITHUB_OUTPUT"
|
|
exit 0
|
|
fi
|
|
for payload in dist packages extensions; do
|
|
if [ -d "$sticky_root/$payload" ]; then
|
|
rsync -a "$sticky_root/$payload/" "$payload/"
|
|
fi
|
|
done
|
|
echo "restored=true" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Cache extension package boundary artifacts
|
|
id: extension-package-boundary-cache
|
|
if: matrix.group == 'extension-package-boundary'
|
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
with:
|
|
path: |
|
|
dist/plugin-sdk
|
|
packages/plugin-sdk/dist
|
|
extensions/*/dist/.boundary-tsc.tsbuildinfo
|
|
extensions/*/dist/.boundary-tsc.stamp
|
|
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/plugins/types.ts', 'src/auto-reply/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-extension-package-boundary-v1-
|
|
|
|
- name: Preserve extension package boundary cache hit
|
|
if: matrix.group == 'extension-package-boundary' && steps.extension-package-boundary-cache.outputs.cache-hit == 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
find extensions \
|
|
-path '*/dist' -prune -o \
|
|
-path '*/node_modules' -prune -o \
|
|
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
|
-exec touch -t 200001010000 {} +
|
|
find src \
|
|
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
|
-exec touch -t 200001010000 {} +
|
|
if [ -d packages/llm-core/src ]; then
|
|
find packages/llm-core/src \
|
|
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
|
-exec touch -t 200001010000 {} +
|
|
fi
|
|
if [ -d packages/model-catalog-core/src ]; then
|
|
find packages/model-catalog-core/src \
|
|
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
|
|
-exec touch -t 200001010000 {} +
|
|
fi
|
|
cache_inputs=(
|
|
tsconfig.json \
|
|
tsconfig.plugin-sdk.dts.json \
|
|
packages/plugin-sdk/tsconfig.json \
|
|
packages/llm-core/package.json \
|
|
packages/model-catalog-core/package.json \
|
|
scripts/check-extension-package-tsc-boundary.mjs \
|
|
scripts/prepare-extension-package-boundary-artifacts.mjs \
|
|
scripts/write-plugin-sdk-entry-dts.ts \
|
|
scripts/lib/plugin-sdk-entrypoints.json \
|
|
scripts/lib/plugin-sdk-entries.mjs \
|
|
package.json \
|
|
pnpm-lock.yaml
|
|
)
|
|
for cache_input in "${cache_inputs[@]}"; do
|
|
if [ -e "$cache_input" ]; then
|
|
touch -t 200001010000 "$cache_input"
|
|
fi
|
|
done
|
|
|
|
- name: Run additional check shard
|
|
env:
|
|
ADDITIONAL_CHECK_GROUP: ${{ matrix.group }}
|
|
RUN_PROMPT_SNAPSHOTS: ${{ needs.preflight.outputs.run_prompt_snapshots }}
|
|
OPENCLAW_ADDITIONAL_BOUNDARY_SHARD: ${{ matrix.boundary_shard || '' }}
|
|
OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY: 4
|
|
# Matches the boundary lane's 32 vCPU runner (cores/2); the
|
|
# script's default caps at 6 to protect laptops.
|
|
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 16
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
failures=0
|
|
|
|
run_check() {
|
|
local label="$1"
|
|
shift
|
|
|
|
echo "::group::${label}"
|
|
if "$@"; then
|
|
echo "[ok] ${label}"
|
|
else
|
|
echo "::error title=${label} failed::${label} failed"
|
|
failures=1
|
|
fi
|
|
echo "::endgroup::"
|
|
}
|
|
|
|
case "$ADDITIONAL_CHECK_GROUP" in
|
|
boundaries)
|
|
node scripts/run-additional-boundary-checks.mjs
|
|
;;
|
|
prompt-snapshots)
|
|
# No presence fallback: the boundary runner previously invoked
|
|
# this unconditionally, and silent success would drop snapshot
|
|
# drift coverage. The manifest gates the lane on the generator's
|
|
# import graph and fixtures; diffs outside both cannot change
|
|
# generated snapshots.
|
|
if [ "$RUN_PROMPT_SNAPSHOTS" != "true" ]; then
|
|
echo "[skip] changed scope cannot affect generated prompt snapshots"
|
|
else
|
|
run_check "prompt:snapshots:check" pnpm prompt:snapshots:check
|
|
fi
|
|
;;
|
|
session-accessor-boundary)
|
|
if [ ! -f scripts/check-session-accessor-boundary.mjs ]; then
|
|
echo "[skip] session accessor boundary check is not present in this checkout"
|
|
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-accessor-boundary"] ? 0 : 1);'; then
|
|
echo "[skip] session accessor boundary script is not present in package.json"
|
|
else
|
|
run_check "lint:tmp:session-accessor-boundary" pnpm run lint:tmp:session-accessor-boundary
|
|
fi
|
|
if [ ! -f scripts/check-sqlite-transaction-boundary.mjs ]; then
|
|
echo "[skip] SQLite transaction boundary check is not present in this checkout"
|
|
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:sqlite-transaction-boundary"] ? 0 : 1);'; then
|
|
echo "[skip] SQLite transaction boundary script is not present in package.json"
|
|
else
|
|
run_check "lint:tmp:sqlite-transaction-boundary" pnpm run lint:tmp:sqlite-transaction-boundary
|
|
fi
|
|
;;
|
|
session-transcript-reader-boundary)
|
|
if [ ! -f scripts/check-session-transcript-reader-boundary.mjs ]; then
|
|
echo "[skip] session transcript reader boundary check is not present in this checkout"
|
|
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:session-transcript-reader-boundary"] ? 0 : 1);'; then
|
|
echo "[skip] session transcript reader boundary script is not present in package.json"
|
|
else
|
|
run_check "lint:tmp:session-transcript-reader-boundary" pnpm run lint:tmp:session-transcript-reader-boundary
|
|
fi
|
|
;;
|
|
sqlite-session-schema-baseline)
|
|
if ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["sqlite:sessions-schema:check"] ? 0 : 1);'; then
|
|
echo "[skip] SQLite sessions/transcripts schema baseline script is not present in package.json"
|
|
else
|
|
run_check "sqlite:sessions-schema:check" pnpm run sqlite:sessions-schema:check
|
|
fi
|
|
;;
|
|
sqlite-session-flip-proof)
|
|
if [ ! -f test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts ]; then
|
|
echo "[skip] SQLite sessions/transcripts flip proof is not present in this checkout"
|
|
else
|
|
run_check "sqlite sessions/transcripts flip proof" node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts test/scripts/sqlite-sessions-transcripts-flip-proof.e2e.test.ts
|
|
fi
|
|
;;
|
|
extension-package-boundary)
|
|
run_check "test:extensions:package-boundary:compile" pnpm run test:extensions:package-boundary:compile
|
|
run_check "test:extensions:package-boundary:canary" pnpm run test:extensions:package-boundary:canary
|
|
;;
|
|
runtime-topology-architecture)
|
|
run_check "check:architecture" pnpm check:architecture
|
|
;;
|
|
*)
|
|
echo "Unsupported additional check group: $ADDITIONAL_CHECK_GROUP" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
exit "$failures"
|
|
|
|
# Only the protected writer refreshes the snapshot (pull_request mounts
|
|
# never commit, so seeding there would burn wall clock on a discarded
|
|
# clone). Seed the payload before the sticky post-action flushes.
|
|
# rsync -aR mirrors the repo-relative layout the restore step replays.
|
|
- name: Seed extension boundary sticky disk
|
|
if: success() && steps.boundary-sticky-restore.outputs.restored == 'false' && matrix.group == 'extension-package-boundary' && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request' && github.repository == 'openclaw/openclaw'
|
|
shell: bash
|
|
env:
|
|
# Must stay identical to the restore gates above.
|
|
BOUNDARY_CONFIG_HASH: ${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'package.json', 'pnpm-lock.yaml') }}
|
|
run: |
|
|
set -euo pipefail
|
|
sticky_root=/var/tmp/openclaw-ext-boundary
|
|
# A stale marker must not survive a mid-seed failure: drop readiness
|
|
# first so a partial payload can never be restored as valid.
|
|
rm -rf "$sticky_root/.snapshot-ready" "$sticky_root/.source-trees" "$sticky_root/dist" "$sticky_root/packages" "$sticky_root/extensions"
|
|
if [ -d dist/plugin-sdk ]; then
|
|
rsync -aR dist/plugin-sdk "$sticky_root/"
|
|
fi
|
|
if [ -d packages/plugin-sdk/dist ]; then
|
|
rsync -aR packages/plugin-sdk/dist "$sticky_root/"
|
|
fi
|
|
find extensions -maxdepth 3 \( -name '.boundary-tsc.tsbuildinfo' -o -name '.boundary-tsc.stamp' \) \
|
|
-exec rsync -aR {} "$sticky_root/" \;
|
|
{ git rev-parse HEAD:src/plugin-sdk HEAD:packages HEAD:extensions HEAD:src/auto-reply HEAD:src/types HEAD:src/plugins/types.ts HEAD:src/video-generation HEAD:src/channels; node --version; corepack pnpm --version 2>/dev/null || pnpm --version; echo "$BOUNDARY_CONFIG_HASH"; } > "$sticky_root/.source-trees"
|
|
touch "$sticky_root/.snapshot-ready"
|
|
|
|
# Validate docs (format, lint, broken links) only when docs files changed.
|
|
check-docs:
|
|
permissions:
|
|
contents: read
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_check_docs == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
# Blacksmith same-repo runs clone dependencies from a sticky disk.
|
|
# Fork PRs must keep actions/cache: sticky snapshots are writable,
|
|
# repository-global state and must never be produced by fork code.
|
|
sticky-disk: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
|
use-actions-cache: ${{ github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'false' || 'true' }}
|
|
|
|
- name: Check formatting
|
|
if: needs.preflight.outputs.run_format_check == 'true'
|
|
run: pnpm format:check
|
|
|
|
- name: Checkout ClawHub docs source
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
workdir="$GITHUB_WORKSPACE/clawhub-source"
|
|
started_at="$(date +%s)"
|
|
|
|
reset_checkout_dir() {
|
|
mkdir -p "$workdir"
|
|
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
|
}
|
|
|
|
checkout_attempt() {
|
|
local attempt="$1"
|
|
|
|
reset_checkout_dir
|
|
git init "$workdir" >/dev/null
|
|
git -C "$workdir" config gc.auto 0
|
|
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
|
|
|
|
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
|
-c protocol.version=2 \
|
|
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
|
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
|
|
|
|
git -C "$workdir" checkout --force --detach refs/remotes/origin/checkout || return 1
|
|
echo "ClawHub checkout attempt ${attempt}/5 succeeded"
|
|
}
|
|
|
|
for attempt in 1 2 3 4 5; do
|
|
if checkout_attempt "$attempt"; then
|
|
elapsed="$(( $(date +%s) - started_at ))"
|
|
echo "ClawHub checkout completed in ${elapsed}s"
|
|
exit 0
|
|
fi
|
|
echo "ClawHub checkout attempt ${attempt}/5 failed"
|
|
sleep $((attempt * 5))
|
|
done
|
|
|
|
echo "ClawHub checkout failed after 5 attempts" >&2
|
|
exit 1
|
|
|
|
- name: Check docs
|
|
env:
|
|
OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source
|
|
run: pnpm check:docs
|
|
|
|
skills-python:
|
|
permissions:
|
|
contents: read
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_skills_python_job == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
steps:
|
|
- name: Checkout
|
|
env:
|
|
CHECKOUT_REPO: ${{ github.repository }}
|
|
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
|
run: |
|
|
set -euo pipefail
|
|
git init "$GITHUB_WORKSPACE"
|
|
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
|
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
|
fetch_checkout_ref() {
|
|
local fetch_status
|
|
for attempt in 1 2 3; do
|
|
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
|
-c protocol.version=2 \
|
|
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
|
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
|
fetch_status="$?"
|
|
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
|
return "$fetch_status"
|
|
fi
|
|
if [ "$attempt" = "3" ]; then
|
|
return "$fetch_status"
|
|
fi
|
|
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
|
sleep 5
|
|
done
|
|
}
|
|
fetch_checkout_ref
|
|
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
|
|
|
- name: Setup Python
|
|
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
|
|
with:
|
|
python-version: "3.12"
|
|
|
|
- name: Install Python tooling
|
|
run: |
|
|
python -m pip install --upgrade pip
|
|
python -m pip install pytest ruff pyyaml
|
|
|
|
- name: Lint Python skill scripts
|
|
run: python -m ruff check --config skills/pyproject.toml skills
|
|
|
|
- name: Test skill Python scripts
|
|
run: python -m pytest -q -c skills/pyproject.toml skills
|
|
|
|
checks-windows:
|
|
permissions:
|
|
contents: read
|
|
name: ${{ matrix.check_name }}
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_checks_windows == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && (matrix.runner || 'blacksmith-8vcpu-windows-2025') || 'windows-2025') }}
|
|
timeout-minutes: 60
|
|
env:
|
|
NODE_OPTIONS: --max-old-space-size=8192
|
|
# Keep total concurrency predictable on the smaller Windows runner.
|
|
OPENCLAW_VITEST_MAX_WORKERS: 1
|
|
OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD: 1
|
|
defaults:
|
|
run:
|
|
shell: bash
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 2
|
|
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
|
|
steps:
|
|
- &platform_checkout_step
|
|
name: Checkout
|
|
env:
|
|
CHECKOUT_REPO: ${{ github.repository }}
|
|
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
|
run: |
|
|
set -euo pipefail
|
|
git init "$GITHUB_WORKSPACE"
|
|
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
|
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
|
fetch_timeout_seconds=90
|
|
fetch_checkout_ref_once() {
|
|
git -C "$GITHUB_WORKSPACE" \
|
|
-c protocol.version=2 \
|
|
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
|
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
|
local fetch_pid="$!"
|
|
local elapsed=0
|
|
while kill -0 "$fetch_pid" 2>/dev/null; do
|
|
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
|
kill -TERM "$fetch_pid" 2>/dev/null || true
|
|
sleep 10
|
|
kill -KILL "$fetch_pid" 2>/dev/null || true
|
|
wait "$fetch_pid" || true
|
|
return 124
|
|
fi
|
|
sleep 1
|
|
elapsed=$((elapsed + 1))
|
|
done
|
|
wait "$fetch_pid"
|
|
}
|
|
fetch_checkout_ref() {
|
|
local fetch_status
|
|
for attempt in 1 2 3; do
|
|
fetch_checkout_ref_once && return 0
|
|
fetch_status="$?"
|
|
if [ "$fetch_status" != "124" ] && [ "$fetch_status" != "137" ]; then
|
|
return "$fetch_status"
|
|
fi
|
|
if [ "$attempt" = "3" ]; then
|
|
return "$fetch_status"
|
|
fi
|
|
echo "::warning::checkout fetch for '$CHECKOUT_SHA' timed out on attempt $attempt; retrying"
|
|
sleep 5
|
|
done
|
|
}
|
|
fetch_checkout_ref
|
|
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
|
|
|
- name: Try to exclude workspace from Windows Defender (best-effort)
|
|
shell: pwsh
|
|
run: |
|
|
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
|
|
if (-not $cmd) {
|
|
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
|
|
exit 0
|
|
}
|
|
|
|
try {
|
|
# Defender sometimes intercepts process spawning (vitest workers). If this fails
|
|
# (eg hardened images), keep going and rely on worker limiting above.
|
|
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
|
|
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
|
|
Write-Host "Defender exclusions applied."
|
|
} catch {
|
|
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
|
}
|
|
|
|
- name: Setup Node.js
|
|
env:
|
|
REQUESTED_NODE_VERSION: "22.x"
|
|
run: |
|
|
set -euo pipefail
|
|
source .github/actions/setup-pnpm-store-cache/ensure-node.sh
|
|
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
|
|
|
- name: Setup pnpm
|
|
uses: ./.github/actions/setup-pnpm-store-cache
|
|
with:
|
|
node-version: 22.x
|
|
|
|
- name: Runtime versions
|
|
run: |
|
|
node -v
|
|
npm -v
|
|
pnpm -v
|
|
|
|
- name: Capture node path
|
|
run: |
|
|
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
|
if command -v cygpath >/dev/null 2>&1; then
|
|
node_bin="$(cygpath -u "$node_bin")"
|
|
fi
|
|
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
|
|
|
|
- name: Install dependencies
|
|
env:
|
|
CI: true
|
|
run: |
|
|
export PATH="$NODE_BIN:$PATH"
|
|
which node
|
|
node -v
|
|
pnpm -v
|
|
# Persist Windows-native postinstall outputs in the pnpm store so restored
|
|
# caches can skip repeated rebuild/download work on later shards/runs.
|
|
pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true
|
|
|
|
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
|
env:
|
|
TASK: ${{ matrix.task }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
case "$TASK" in
|
|
test)
|
|
# Linux owns the full repo test suite. Keep the Windows runner focused on
|
|
# Windows-native process/path wrappers so platform regressions fail fast.
|
|
pnpm test:windows:ci
|
|
;;
|
|
*)
|
|
echo "Unsupported Windows checks task: $TASK" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
macos-node:
|
|
permissions:
|
|
contents: read
|
|
name: ${{ matrix.check_name }}
|
|
needs: [preflight]
|
|
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-15' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-6vcpu-macos-15' || 'macos-15') }}
|
|
timeout-minutes: 20
|
|
strategy:
|
|
fail-fast: false
|
|
matrix: ${{ fromJson(needs.preflight.outputs.macos_node_matrix) }}
|
|
steps:
|
|
- *platform_checkout_step
|
|
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
|
|
- name: TS tests (macOS)
|
|
env:
|
|
NODE_OPTIONS: --max-old-space-size=4096
|
|
OPENCLAW_VITEST_MAX_WORKERS: 2
|
|
TASK: ${{ matrix.task }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
case "$TASK" in
|
|
test)
|
|
# Linux owns the full repo test suite. Keep macOS CI focused on
|
|
# launchd/Homebrew/runtime path coverage and the process-group wrapper.
|
|
pnpm test:macos:ci
|
|
;;
|
|
*)
|
|
echo "Unsupported macOS node task: $TASK" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
macos-swift:
|
|
permissions:
|
|
contents: read
|
|
name: "macos-swift"
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_macos_swift == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
|
# Release dispatches use the hosted runner and may still be finalizing large
|
|
# Swift caches after every test passes; keep that post-job work non-blocking.
|
|
timeout-minutes: ${{ github.event_name == 'workflow_dispatch' && 30 || 20 }}
|
|
env:
|
|
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
|
|
steps:
|
|
- *platform_checkout_step
|
|
|
|
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
|
run: |
|
|
if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then
|
|
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
|
|
./scripts/install-xcodegen.sh "$swift_tools_dir"
|
|
./scripts/install-swift-tools.sh "$swift_tools_dir"
|
|
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
|
"$swift_tools_dir/xcodegen" --version
|
|
"$swift_tools_dir/swiftformat" --version
|
|
"$swift_tools_dir/swiftlint" version
|
|
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
|
# Frozen release targets before the pinned installer used one of these
|
|
# reviewed formatter contracts. Fail closed for any unknown minimum.
|
|
brew update
|
|
brew install xcodegen swiftlint
|
|
swiftformat_min_version="$(awk '$1 == "--min-version" { print $2; exit }' config/swiftformat)"
|
|
case "$swiftformat_min_version" in
|
|
""|0.61.1)
|
|
swiftformat_version="0.61.1"
|
|
swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"
|
|
;;
|
|
0.62.1)
|
|
swiftformat_version="0.62.1"
|
|
swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a"
|
|
;;
|
|
*)
|
|
echo "Unsupported frozen-target SwiftFormat minimum: $swiftformat_min_version" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
swiftformat_archive="$RUNNER_TEMP/swiftformat-$swiftformat_version.zip"
|
|
swift_tools_dir="$RUNNER_TEMP/openclaw-legacy-swift-tools"
|
|
curl --fail --location --silent --show-error \
|
|
--connect-timeout 10 --max-time 120 \
|
|
--retry 3 --retry-max-time 120 \
|
|
--output "$swiftformat_archive" \
|
|
"https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip"
|
|
if [[ "$(shasum -a 256 "$swiftformat_archive" | awk '{print $1}')" != "$swiftformat_checksum" ]]; then
|
|
echo "SwiftFormat $swiftformat_version archive checksum mismatch" >&2
|
|
exit 1
|
|
fi
|
|
mkdir -p "$swift_tools_dir"
|
|
unzip -q "$swiftformat_archive" -d "$swift_tools_dir"
|
|
chmod +x "$swift_tools_dir/swiftformat"
|
|
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
|
[[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]]
|
|
else
|
|
echo "Current CI targets must provide scripts/install-xcodegen.sh and scripts/install-swift-tools.sh." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Detect Swift toolchain cache key
|
|
id: swift-toolchain
|
|
run: |
|
|
set -euo pipefail
|
|
xcode_version="$(xcodebuild -version | tr '\n' ' ' | sed 's/ */ /g; s/ $//')"
|
|
swift_version="$(swift --version | head -n 1)"
|
|
toolchain_key="$(printf '%s\n%s\n' "$xcode_version" "$swift_version" | shasum -a 256 | awk '{print $1}')"
|
|
echo "key=$toolchain_key" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Cache SwiftPM
|
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
with:
|
|
path: ~/Library/Caches/org.swift.swiftpm
|
|
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-swiftpm-
|
|
|
|
- name: Cache Swift build directory
|
|
id: swift-build-cache
|
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
with:
|
|
path: apps/macos/.build
|
|
key: ${{ runner.os }}-swift-build-v3-${{ steps.swift-toolchain.outputs.key }}-${{ hashFiles('apps/macos/Package.swift', 'apps/macos/Package.resolved', 'apps/macos/Sources/**', 'apps/macos/Tests/**', 'apps/shared/OpenClawKit/Package.swift', 'apps/shared/OpenClawKit/Sources/**', 'apps/swabble/Package.swift', 'apps/swabble/Sources/**') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-swift-build-v3-${{ steps.swift-toolchain.outputs.key }}-
|
|
|
|
- name: Validate Swift build cache
|
|
id: validate-swift-build-cache
|
|
run: |
|
|
set -euo pipefail
|
|
cache_valid=true
|
|
sparkle_info="apps/macos/.build/artifacts/sparkle/Sparkle/Sparkle.xcframework/Info.plist"
|
|
if [[ -d apps/macos/.build && ! -f "$sparkle_info" ]]; then
|
|
echo "::warning::Swift build cache is missing Sparkle; resetting the local SwiftPM build directory."
|
|
swift package --package-path apps/macos reset
|
|
cache_valid=false
|
|
fi
|
|
echo "cache-valid=$cache_valid" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Preserve Swift build cache hit
|
|
if: steps.swift-build-cache.outputs.cache-hit == 'true' && steps.validate-swift-build-cache.outputs.cache-valid == 'true'
|
|
run: |
|
|
set -euo pipefail
|
|
# Exact source-hash cache hits already match these inputs; checkout
|
|
# mtimes are the only reason SwiftPM rebuilds cached products.
|
|
find apps/macos/Sources apps/macos/Tests apps/shared/OpenClawKit/Sources apps/swabble/Sources apps/macos/.build/checkouts \
|
|
-type f -exec touch -t 200001010000 {} +
|
|
touch -t 200001010000 \
|
|
apps/macos/Package.swift \
|
|
apps/macos/Package.resolved \
|
|
apps/shared/OpenClawKit/Package.swift \
|
|
apps/swabble/Package.swift
|
|
|
|
- name: Show toolchain
|
|
run: |
|
|
sw_vers
|
|
xcodebuild -version
|
|
swift --version
|
|
|
|
- name: Swift lint
|
|
run: |
|
|
if [[ -x ./scripts/lint-swift.sh && -x ./scripts/format-swift.sh ]]; then
|
|
./scripts/lint-swift.sh macos
|
|
./scripts/format-swift.sh macos
|
|
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
|
# Frozen release targets before the shared wrappers used these commands directly.
|
|
swiftlint lint --config config/swiftlint.yml
|
|
swiftformat --lint apps/macos/Sources --config config/swiftformat --exclude '**/OpenClawProtocol,**/HostEnvSecurityPolicy.generated.swift'
|
|
else
|
|
echo "Current CI targets must provide the Swift lint and format wrappers." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Swift build (release)
|
|
run: |
|
|
set -euo pipefail
|
|
for attempt in 1 2 3; do
|
|
# The macOS lane validates the desktop app build; the CLI product is
|
|
# intentionally left to its own narrower surfaces instead of making
|
|
# this lane rebuild the whole package graph.
|
|
if swift build --package-path apps/macos --product OpenClaw --configuration release; then
|
|
exit 0
|
|
fi
|
|
if [[ "$attempt" -eq 3 ]]; then
|
|
break
|
|
fi
|
|
echo "swift build failed (attempt $attempt/3). Retrying…"
|
|
# SwiftPM can invalidate a restored binary artifact while planning.
|
|
# Reset so the next attempt downloads a complete dependency graph.
|
|
swift package --package-path apps/macos reset
|
|
sleep $((attempt * 20))
|
|
done
|
|
exit 1
|
|
|
|
- name: OpenClawKit Talk-trait opt-out (no ElevenLabsKit when default traits disabled)
|
|
run: |
|
|
set -euo pipefail
|
|
# Guard: chat-only consumers build OpenClawKit with the Talk trait
|
|
# disabled and must NOT link ElevenLabsKit. Assert that future sources
|
|
# under OpenClawKit cannot silently reintroduce an unconditional
|
|
# ElevenLabsKit dependency while the manifest still looks correct.
|
|
deps="$(swift package --package-path apps/shared/OpenClawKit show-dependencies --disable-default-traits)"
|
|
echo "$deps"
|
|
if grep -qi 'elevenlabs' <<<"$deps"; then
|
|
echo "::error::ElevenLabsKit resolved with the Talk trait disabled; keep it gated behind the Talk trait."
|
|
exit 1
|
|
fi
|
|
swift build --package-path apps/shared/OpenClawKit --target OpenClawKit --disable-default-traits
|
|
|
|
- name: OpenClawKit tests
|
|
run: |
|
|
set -euo pipefail
|
|
openclawkit_scratch="$(mktemp -d "$RUNNER_TEMP/openclawkit.XXXXXX")"
|
|
trap 'rm -rf "$openclawkit_scratch"' EXIT
|
|
swift test \
|
|
--package-path apps/shared/OpenClawKit \
|
|
--scratch-path "$openclawkit_scratch" \
|
|
--parallel
|
|
|
|
- name: Swift test
|
|
run: |
|
|
set -euo pipefail
|
|
for attempt in 1 2 3; do
|
|
if swift test --package-path apps/macos --parallel --enable-code-coverage; then
|
|
exit 0
|
|
fi
|
|
echo "swift test failed (attempt $attempt/3). Retrying…"
|
|
sleep $((attempt * 20))
|
|
done
|
|
exit 1
|
|
|
|
ios-build:
|
|
permissions:
|
|
contents: read
|
|
name: "ios-build"
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_ios_build == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
|
timeout-minutes: 45
|
|
env:
|
|
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
|
|
steps:
|
|
- *platform_checkout_step
|
|
|
|
- name: Select Xcode 26
|
|
run: |
|
|
set -euo pipefail
|
|
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
|
|
if [ -d "$xcode_app/Contents/Developer" ]; then
|
|
sudo xcode-select -s "$xcode_app/Contents/Developer"
|
|
break
|
|
fi
|
|
done
|
|
xcodebuild -version
|
|
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
|
|
if [[ "$xcode_version" != 26.* ]]; then
|
|
echo "error: expected Xcode 26.x, got $xcode_version" >&2
|
|
exit 1
|
|
fi
|
|
swift --version
|
|
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
|
|
- name: Install iOS Swift tooling
|
|
run: |
|
|
if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then
|
|
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
|
|
./scripts/install-xcodegen.sh "$swift_tools_dir"
|
|
./scripts/install-swift-tools.sh "$swift_tools_dir"
|
|
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
|
"$swift_tools_dir/xcodegen" --version
|
|
"$swift_tools_dir/swiftformat" --version
|
|
"$swift_tools_dir/swiftlint" version
|
|
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
|
# The generated Xcode project runs SwiftFormat during the build, so
|
|
# frozen targets must keep the formatter contract they were authored for.
|
|
brew update
|
|
brew install xcodegen swiftlint
|
|
swiftformat_min_version="$(awk '$1 == "--min-version" { print $2; exit }' config/swiftformat)"
|
|
case "$swiftformat_min_version" in
|
|
""|0.61.1)
|
|
swiftformat_version="0.61.1"
|
|
swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"
|
|
;;
|
|
0.62.1)
|
|
swiftformat_version="0.62.1"
|
|
swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a"
|
|
;;
|
|
*)
|
|
echo "Unsupported frozen-target SwiftFormat minimum: $swiftformat_min_version" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
swiftformat_archive="$RUNNER_TEMP/swiftformat-$swiftformat_version.zip"
|
|
swift_tools_dir="$RUNNER_TEMP/openclaw-legacy-swift-tools"
|
|
curl --fail --location --silent --show-error \
|
|
--connect-timeout 10 --max-time 120 \
|
|
--retry 3 --retry-max-time 120 \
|
|
--output "$swiftformat_archive" \
|
|
"https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip"
|
|
if [[ "$(shasum -a 256 "$swiftformat_archive" | awk '{print $1}')" != "$swiftformat_checksum" ]]; then
|
|
echo "SwiftFormat $swiftformat_version archive checksum mismatch" >&2
|
|
exit 1
|
|
fi
|
|
mkdir -p "$swift_tools_dir"
|
|
unzip -q "$swiftformat_archive" -d "$swift_tools_dir"
|
|
chmod +x "$swift_tools_dir/swiftformat"
|
|
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
|
[[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]]
|
|
# Legacy generated Xcode phases prepend Homebrew ahead of GITHUB_PATH.
|
|
# Point that lookup at the verified binary or the build can bypass the pin.
|
|
swiftformat_link="$(brew --prefix)/bin/swiftformat"
|
|
ln -sfn "$swift_tools_dir/swiftformat" "$swiftformat_link"
|
|
[[ "$("$swiftformat_link" --version)" == "$swiftformat_version" ]]
|
|
else
|
|
echo "Current CI targets must provide scripts/install-xcodegen.sh and scripts/install-swift-tools.sh." >&2
|
|
exit 1
|
|
fi
|
|
|
|
- name: Swift lint
|
|
run: |
|
|
if [[ -x ./scripts/lint-swift.sh && -x ./scripts/format-swift.sh ]]; then
|
|
./scripts/lint-swift.sh ios
|
|
./scripts/format-swift.sh ios
|
|
else
|
|
# Frozen release targets before the iOS lint lane have no equivalent target-owned step.
|
|
echo "Swift lint wrappers are absent; skipping iOS lint for this frozen target."
|
|
fi
|
|
|
|
- name: Build iOS app
|
|
run: pnpm ios:build
|
|
|
|
android:
|
|
permissions:
|
|
contents: read
|
|
name: ${{ matrix.check_name }}
|
|
needs: [preflight]
|
|
if: needs.preflight.outputs.run_android_job == 'true'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 2
|
|
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
|
|
steps:
|
|
- name: Checkout
|
|
shell: bash
|
|
env:
|
|
CHECKOUT_REPO: ${{ github.repository }}
|
|
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
workdir="$GITHUB_WORKSPACE"
|
|
reset_checkout_dir() {
|
|
mkdir -p "$workdir"
|
|
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
|
}
|
|
|
|
checkout_attempt() {
|
|
local attempt="$1"
|
|
|
|
reset_checkout_dir
|
|
git init "$workdir" >/dev/null
|
|
git config --global --add safe.directory "$workdir"
|
|
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
|
git -C "$workdir" config gc.auto 0
|
|
|
|
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
|
-c protocol.version=2 \
|
|
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
|
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
|
|
|
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
|
test -x "$workdir/apps/android/gradlew" || return 1
|
|
echo "checkout attempt ${attempt}/5 succeeded"
|
|
}
|
|
|
|
for attempt in 1 2 3 4 5; do
|
|
if checkout_attempt "$attempt"; then
|
|
exit 0
|
|
fi
|
|
echo "checkout attempt ${attempt}/5 failed"
|
|
sleep $((attempt * 5))
|
|
done
|
|
|
|
echo "checkout failed after 5 attempts" >&2
|
|
exit 1
|
|
|
|
- name: Checkout CI Android toolchain action
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
with:
|
|
ref: ${{ github.workflow_sha }}
|
|
path: .ci-workflow
|
|
sparse-checkout: .github/actions/setup-android-toolchain
|
|
persist-credentials: false
|
|
|
|
- name: Setup Android toolchain
|
|
# Frozen targets keep their Gradle task contract, but CI toolchain pins remain
|
|
# workflow-owned as before. Load them from the workflow revision so old targets work.
|
|
uses: ./.ci-workflow/.github/actions/setup-android-toolchain
|
|
|
|
# Same-repo runs carry the Gradle user home (dependency, wrapper, and
|
|
# build caches) on a Blacksmith sticky disk: the setup-java gradle cache
|
|
# above is evicted so quickly under the repo quota that it rarely
|
|
# restores, leaving every run to re-resolve the full dependency graph.
|
|
# Fork PRs and manual dispatches must never produce writable
|
|
# repository-global snapshots, so they keep the GitHub cache path (their
|
|
# runs-on already selects non-Blacksmith ubuntu-24.04 runners).
|
|
- name: Mount Gradle sticky disk
|
|
if: github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
|
uses: useblacksmith/stickydisk@5b350170ae4ef55b536b548ef5f5896e76a6b54f # v1.4.0
|
|
with:
|
|
# One stable disk per matrix task. The v1 per-PR/per-dependency-hash
|
|
# keys minted a new backing disk for every PR and dependency bump
|
|
# and helped saturate Blacksmith's installation-wide sticky-disk
|
|
# budget, 429-failing every mount. Task scope stays in the key: each
|
|
# matrix task resolves a different dependency set, and a shared key
|
|
# would let a light task (ktlint) pin a thin snapshot the heavy
|
|
# build lanes can never repair. Dependency-file changes live in the
|
|
# runtime fingerprint marker below instead of the key.
|
|
key: ${{ github.repository }}-gradle-v2-${{ matrix.task }}
|
|
path: /var/tmp/openclaw-gradle
|
|
# Single semantic writer: only protected pushes commit, so
|
|
# pull_request clones stay read-only and the snapshot tracks main.
|
|
# Explicit true (not on-change) because the allocated-byte heuristic
|
|
# can miss a same-size refresh and strand the fingerprint marker;
|
|
# v1.4.0 still skips commit after failed/cancelled steps, so a
|
|
# broken build cannot poison this key.
|
|
commit: ${{ github.event_name != 'pull_request' && 'true' || 'false' }}
|
|
|
|
- name: Point Gradle at the sticky disk
|
|
if: github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
|
shell: bash
|
|
env:
|
|
# Gradle caches are content-addressed, so a stale snapshot is safe
|
|
# to restore (obsolete entries are simply ignored). The fingerprint
|
|
# only bounds disk growth: when dependency inputs change, the writer
|
|
# rebuilds its snapshot from scratch so retired dependency artifacts
|
|
# do not accumulate on the O(1) key forever.
|
|
GRADLE_DEPS_FINGERPRINT: ${{ hashFiles('apps/android/**/*.gradle*', 'apps/android/**/gradle-wrapper.properties', 'apps/android/gradle/libs.versions.toml') }}
|
|
STICKY_WRITER: ${{ github.event_name != 'pull_request' && 'true' || 'false' }}
|
|
run: |
|
|
set -euo pipefail
|
|
sticky_root=/var/tmp/openclaw-gradle
|
|
marker="$sticky_root/.openclaw-gradle-deps-fingerprint"
|
|
disk_fingerprint=""
|
|
if [ -f "$marker" ]; then
|
|
disk_fingerprint="$(<"$marker")"
|
|
fi
|
|
if [ "$disk_fingerprint" != "$GRADLE_DEPS_FINGERPRINT" ]; then
|
|
if [ "$STICKY_WRITER" = "true" ]; then
|
|
echo "Gradle dependency inputs changed; rebuilding snapshot cold to drop retired artifacts"
|
|
rm -rf "$sticky_root/gradle-user-home"
|
|
printf '%s\n' "$GRADLE_DEPS_FINGERPRINT" > "$marker"
|
|
else
|
|
echo "Gradle dependency inputs changed since snapshot; content-addressed caches stay safe to reuse"
|
|
fi
|
|
fi
|
|
mkdir -p "$sticky_root/gradle-user-home"
|
|
echo "GRADLE_USER_HOME=$sticky_root/gradle-user-home" >> "$GITHUB_ENV"
|
|
|
|
- name: Run Android ${{ matrix.task }}
|
|
working-directory: apps/android
|
|
env:
|
|
TASK: ${{ matrix.task }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
case "$TASK" in
|
|
test-play)
|
|
./gradlew --no-daemon --build-cache \
|
|
:app:testPlayDebugUnitTest \
|
|
:wear-shared:testDebugUnitTest
|
|
;;
|
|
test-play-compat)
|
|
# Frozen targets predate the Wear shared project. Keep their app-owned
|
|
# Play unit tests without importing current Android modules.
|
|
./gradlew --no-daemon --build-cache :app:testPlayDebugUnitTest
|
|
;;
|
|
test-third-party)
|
|
./gradlew --no-daemon --build-cache :app:testThirdPartyDebugUnitTest
|
|
;;
|
|
test-wear)
|
|
./gradlew --no-daemon --build-cache :wear:testDebugUnitTest
|
|
;;
|
|
build-play)
|
|
./gradlew --no-daemon --build-cache \
|
|
:app:assemblePlayDebug \
|
|
:app:assembleThirdPartyDebug \
|
|
:app:lintPlayDebug \
|
|
:app:lintThirdPartyDebug \
|
|
:benchmark:assembleDebug \
|
|
:wear-shared:assembleDebug \
|
|
:wear-shared:lintDebug
|
|
;;
|
|
build-wear)
|
|
./gradlew --no-daemon --build-cache \
|
|
:wear:assembleDebug \
|
|
:wear:lintDebug
|
|
;;
|
|
build-play-compat)
|
|
# Frozen targets keep their target-owned Android build contract. New lint rules
|
|
# must not retroactively reject a previously validated release branch.
|
|
./gradlew --no-daemon --build-cache :app:assemblePlayDebug
|
|
;;
|
|
ktlint)
|
|
# Mirrors `pnpm android:lint`; keeps formatting drift out of main (see PR #100304 sweep).
|
|
./gradlew --no-daemon --build-cache \
|
|
:app:ktlintCheck \
|
|
:benchmark:ktlintCheck \
|
|
:wear:ktlintCheck \
|
|
:wear-shared:ktlintCheck
|
|
;;
|
|
*)
|
|
echo "Unsupported Android task: $TASK" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
ci-gate:
|
|
permissions:
|
|
contents: read
|
|
name: openclaw/ci-gate
|
|
needs:
|
|
- preflight
|
|
- security-fast
|
|
- pnpm-store-warmup
|
|
- build-artifacts
|
|
- native-i18n
|
|
- checks-ui
|
|
- control-ui-i18n
|
|
- checks-fast-core
|
|
- qa-smoke-ci-profile
|
|
- checks-fast-plugin-contracts-shard
|
|
- checks-fast-channel-contracts-shard
|
|
- checks-node-compat
|
|
- checks-node-core-test-nondist-shard
|
|
- check-shard
|
|
- check-additional-shard
|
|
- check-docs
|
|
- skills-python
|
|
- checks-windows
|
|
- macos-node
|
|
- macos-swift
|
|
- ios-build
|
|
- android
|
|
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
steps:
|
|
- name: Verify selected CI lanes
|
|
shell: bash
|
|
env:
|
|
REQUIRED_RESULTS: |
|
|
preflight=${{ needs.preflight.result }}
|
|
security-fast=${{ needs.security-fast.result }}
|
|
SELECTED_RESULTS: |
|
|
pnpm-store-warmup=${{ needs.pnpm-store-warmup.result }}
|
|
build-artifacts=${{ needs.build-artifacts.result }}
|
|
native-i18n=${{ needs.native-i18n.result }}
|
|
checks-ui=${{ needs.checks-ui.result }}
|
|
control-ui-i18n=${{ needs.control-ui-i18n.result }}
|
|
checks-fast-core=${{ needs.checks-fast-core.result }}
|
|
qa-smoke-ci-profile=${{ needs.qa-smoke-ci-profile.result }}
|
|
checks-fast-plugin-contracts-shard=${{ needs.checks-fast-plugin-contracts-shard.result }}
|
|
checks-fast-channel-contracts-shard=${{ needs.checks-fast-channel-contracts-shard.result }}
|
|
checks-node-compat=${{ needs.checks-node-compat.result }}
|
|
checks-node-core-test-nondist-shard=${{ needs.checks-node-core-test-nondist-shard.result }}
|
|
check-shard=${{ needs.check-shard.result }}
|
|
check-additional-shard=${{ needs.check-additional-shard.result }}
|
|
check-docs=${{ needs.check-docs.result }}
|
|
skills-python=${{ needs.skills-python.result }}
|
|
checks-windows=${{ needs.checks-windows.result }}
|
|
macos-node=${{ needs.macos-node.result }}
|
|
macos-swift=${{ needs.macos-swift.result }}
|
|
ios-build=${{ needs.ios-build.result }}
|
|
android=${{ needs.android.result }}
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
failures=0
|
|
while IFS= read -r entry; do
|
|
[[ -n "$entry" ]] || continue
|
|
name="${entry%%=*}"
|
|
result="${entry#*=}"
|
|
echo "${name}: ${result}"
|
|
if [[ "$result" != "success" ]]; then
|
|
echo "::error title=Required CI job did not succeed::${name} finished with ${result}"
|
|
failures=1
|
|
fi
|
|
done <<< "$REQUIRED_RESULTS"
|
|
|
|
while IFS= read -r entry; do
|
|
[[ -n "$entry" ]] || continue
|
|
name="${entry%%=*}"
|
|
result="${entry#*=}"
|
|
echo "${name}: ${result}"
|
|
case "$result" in
|
|
success | skipped) ;;
|
|
*)
|
|
echo "::error title=Selected CI job did not succeed::${name} finished with ${result}"
|
|
failures=1
|
|
;;
|
|
esac
|
|
done <<< "$SELECTED_RESULTS"
|
|
|
|
exit "$failures"
|
|
|
|
ci-timings-summary:
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
name: ci-timings-summary
|
|
needs:
|
|
- preflight
|
|
- security-fast
|
|
- pnpm-store-warmup
|
|
- build-artifacts
|
|
- checks-ui
|
|
- control-ui-i18n
|
|
- checks-fast-core
|
|
- checks-fast-plugin-contracts-shard
|
|
- checks-fast-channel-contracts-shard
|
|
- checks-node-compat
|
|
- checks-node-core-test-nondist-shard
|
|
- check-shard
|
|
- check-additional-shard
|
|
- check-docs
|
|
- skills-python
|
|
- checks-windows
|
|
- macos-node
|
|
- macos-swift
|
|
- ios-build
|
|
- android
|
|
# Re-enable this job when we want to collect CI timing data for timing optimization.
|
|
if: ${{ false && !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 5
|
|
steps:
|
|
- name: Checkout timing summary helper
|
|
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
|
with:
|
|
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || needs.preflight.outputs.checkout_revision || github.sha }}
|
|
fetch-depth: 1
|
|
fetch-tags: false
|
|
persist-credentials: false
|
|
submodules: false
|
|
|
|
- name: Write CI timing summary
|
|
env:
|
|
GH_TOKEN: ${{ github.token }}
|
|
run: |
|
|
node scripts/ci-run-timings.mjs "$GITHUB_RUN_ID" --limit 25 > ci-timings-summary.txt
|
|
cat ci-timings-summary.txt >> "$GITHUB_STEP_SUMMARY"
|
|
|
|
- name: Upload CI timing summary
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: ci-timings-summary
|
|
path: ci-timings-summary.txt
|
|
retention-days: 14
|