Files
openclaw/.github/workflows/ci.yml
2026-04-24 20:45:32 +01:00

2183 lines
81 KiB
YAML

name: CI
on:
push:
branches: [main]
paths-ignore:
- "**/*.md"
- "docs/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
permissions:
contents: read
concurrency:
group: ${{ 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: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
# 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
runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
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 }}
has_changed_extensions: ${{ steps.manifest.outputs.has_changed_extensions }}
changed_extensions_matrix: ${{ steps.manifest.outputs.changed_extensions_matrix }}
run_build_artifacts: ${{ steps.manifest.outputs.run_build_artifacts }}
run_checks_fast: ${{ steps.manifest.outputs.run_checks_fast }}
checks_fast_core_matrix: ${{ steps.manifest.outputs.checks_fast_core_matrix }}
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
checks_node_extensions_matrix: ${{ steps.manifest.outputs.checks_node_extensions_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
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 }}
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
run_extension_fast: ${{ steps.manifest.outputs.run_extension_fast }}
extension_fast_matrix: ${{ steps.manifest.outputs.extension_fast_matrix }}
run_check: ${{ steps.manifest.outputs.run_check }}
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_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_android_job: ${{ steps.manifest.outputs.run_android_job }}
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure preflight base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect docs-only changes
id: docs_scope
uses: ./.github/actions/detect-docs-changes
- name: Detect changed scopes
id: changed_scope
if: steps.docs_scope.outputs.docs_only != 'true'
shell: bash
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
else
BASE="${{ github.event.pull_request.base.sha }}"
fi
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
- name: Detect changed extensions
id: changed_extensions
if: steps.docs_scope.outputs.docs_only != 'true' && steps.changed_scope.outputs.run_node == 'true'
env:
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
BASE_REF: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import { listChangedExtensionIds } from "./scripts/lib/changed-extensions.mjs";
const extensionIds = listChangedExtensionIds({
base: process.env.BASE_SHA,
head: "HEAD",
fallbackBaseRef: process.env.BASE_REF,
unavailableBaseBehavior: "all",
});
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
appendFileSync(process.env.GITHUB_OUTPUT, `changed_extensions_matrix=${matrix}\n`, "utf8");
EOF
- name: Build CI manifest
id: manifest
env:
OPENCLAW_CI_DOCS_ONLY: ${{ steps.docs_scope.outputs.docs_only }}
OPENCLAW_CI_DOCS_CHANGED: ${{ steps.docs_scope.outputs.docs_changed }}
OPENCLAW_CI_RUN_NODE: ${{ steps.changed_scope.outputs.run_node || 'false' }}
OPENCLAW_CI_RUN_MACOS: ${{ steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_ANDROID: ${{ steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ steps.changed_scope.outputs.run_skills_python || 'false' }}
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_HAS_CHANGED_EXTENSIONS: ${{ steps.changed_extensions.outputs.has_changed_extensions || 'false' }}
OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX: ${{ steps.changed_extensions.outputs.changed_extensions_matrix || '{"include":[]}' }}
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import {
createNodeTestShards,
} from "./scripts/lib/ci-node-test-plan.mjs";
import {
createChannelContractTestShards,
} from "./scripts/lib/channel-contract-test-plan.mjs";
import {
createExtensionTestShards,
DEFAULT_EXTENSION_TEST_SHARD_COUNT,
} from "./scripts/lib/extension-test-plan.mjs";
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 parseJson = (value, fallback) => {
try {
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
};
const createMatrix = (include) => ({ include });
const outputPath = process.env.GITHUB_OUTPUT;
const eventName = process.env.GITHUB_EVENT_NAME ?? "pull_request";
const isPush = eventName === "push";
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
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 runMacos =
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
const runAndroid =
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
const runWindows =
parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && !docsOnly && 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 hasChangedExtensions =
parseBoolean(process.env.OPENCLAW_CI_HAS_CHANGED_EXTENSIONS) && !docsOnly;
const changedExtensionsMatrix = hasChangedExtensions
? parseJson(process.env.OPENCLAW_CI_CHANGED_EXTENSIONS_MATRIX, { include: [] })
: { include: [] };
const extensionTestShardCount = isCanonicalRepository
? DEFAULT_EXTENSION_TEST_SHARD_COUNT
: Math.max(DEFAULT_EXTENSION_TEST_SHARD_COUNT, 36);
const extensionShardMatrix = createMatrix(
runNode
? createExtensionTestShards({
shardCount: extensionTestShardCount,
}).map((shard) => ({
check_name: shard.checkName,
extensions_csv: shard.extensionIds.join(","),
runner: isCanonicalRepository && [0, 3, 4].includes(shard.index)
? "blacksmith-8vcpu-ubuntu-2404"
: isCanonicalRepository
? "blacksmith-4vcpu-ubuntu-2404"
: "ubuntu-24.04",
shard_index: shard.index + 1,
task: "extensions-batch",
}))
: [],
);
const nodeTestShards = runNode
? createNodeTestShards().map((shard) => ({
check_name: shard.checkName,
runtime: "node",
task: "test-shard",
shard_name: shard.shardName,
configs: shard.configs,
includePatterns: shard.includePatterns,
requires_dist: shard.requiresDist,
runner: shard.runner,
}))
: [];
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
const nodeTestDistShards = nodeTestShards.filter((shard) => shard.requires_dist);
const manifest = {
docs_only: docsOnly,
docs_changed: docsChanged,
run_node: runNode,
run_macos: runMacos,
run_android: runAndroid,
run_skills_python: runSkillsPython,
run_windows: runWindows,
has_changed_extensions: hasChangedExtensions,
changed_extensions_matrix: changedExtensionsMatrix,
run_build_artifacts: runNode,
run_checks_fast: runNode,
checks_fast_core_matrix: createMatrix(
runNode
? [
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
{
check_name: "checks-fast-contracts-plugins",
runtime: "node",
task: "contracts-plugins",
},
]
: [],
),
channel_contracts_matrix: createMatrix(runNode ? createChannelContractTestShards() : []),
checks_node_extensions_matrix: extensionShardMatrix,
run_checks: runNode,
checks_matrix: createMatrix(
runNode
? [
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
]
: [],
),
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_extension_fast: hasChangedExtensions && !isPush,
extension_fast_matrix: createMatrix(
hasChangedExtensions && !isPush
? (changedExtensionsMatrix.include ?? []).map((entry) => ({
check_name: `extension-fast-${entry.extension}`,
extension: entry.extension,
}))
: [],
),
run_check: runNode,
run_check_additional: runNode,
run_build_smoke: runNode,
run_check_docs: docsChanged,
run_control_ui_i18n: runControlUiI18n,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
checks_windows_matrix: createMatrix(
runWindows
? [{ check_name: "checks-windows-node-test", runtime: "node", task: "test" }]
: [],
),
run_macos_node: runMacos,
macos_node_matrix: createMatrix(
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
),
run_macos_swift: runMacos,
run_android_job: runAndroid,
android_matrix: createMatrix(
runAndroid
? [
{ check_name: "android-test-play", task: "test-play" },
{ check_name: "android-test-third-party", task: "test-third-party" },
{ check_name: "android-build-play", task: "build-play" },
]
: [],
),
};
for (const [key, value] of Object.entries(manifest)) {
appendFileSync(
outputPath,
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`,
"utf8",
);
}
EOF
# Run the fast security/SCM checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
security-scm-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
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Ensure security base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.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: ${{ github.event.pull_request.base.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: Setup Python
id: setup-python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Restore pre-commit cache
uses: actions/cache@v5
with:
path: .cache/pre-commit-security-fast
key: pre-commit-security-fast-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('.pre-commit-config.yaml') }}
restore-keys: |
pre-commit-security-fast-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-
- name: Install pre-commit
run: python -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: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.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[@]}"
security-dependency-audit:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: "24.x"
check-latest: false
- name: Audit production dependencies
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
security-fast:
permissions: {}
needs: [security-scm-fast, security-dependency-audit]
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify fast security jobs
env:
DEPENDENCY_AUDIT_RESULT: ${{ needs.security-dependency-audit.result }}
SCM_RESULT: ${{ needs.security-scm-fast.result }}
run: |
set -euo pipefail
failed=0
for result in \
"security-scm-fast=${SCM_RESULT}" \
"security-dependency-audit=${DEPENDENCY_AUDIT_RESULT}"
do
job="${result%%=*}"
status="${result#*=}"
if [ "$status" != "success" ]; then
echo "::error::${job} ended with ${status}"
failed=1
fi
done
exit "$failed"
# 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'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-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:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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: Ensure secrets base commit (PR fast path)
if: github.event_name == 'pull_request'
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event.pull_request.base.ref }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Build dist
run: pnpm build:ci-artifacts
- name: Build Control UI
run: pnpm ui:build
- name: Check Control UI i18n
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
run: pnpm ui:i18n:check
- name: Cache dist build
uses: actions/cache@v5
with:
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ github.sha }}
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
- name: Upload built runtime artifacts
uses: actions/upload-artifact@v7
with:
name: dist-runtime-build
path: dist-runtime-build.tar.zst
retention-days: 1
- name: Upload A2UI bundle artifact
uses: actions/upload-artifact@v7
with:
name: canvas-a2ui-bundle
path: src/canvas-host/a2ui/
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: Smoke test built bundled runtime deps
run: pnpm test:build:bundled-runtime-deps
- name: Check CLI startup memory
run: pnpm test:startup:memory
- 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=6144 \
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=6144 \
OPENCLAW_VITEST_MAX_WORKERS=2 \
node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts
fi
if [ "$RUN_GATEWAY_WATCH" = "true" ]; then
start_check "gateway-watch" node scripts/check-gateway-watch-regression.mjs --skip-build --ready-timeout-ms 5000
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
for name in channels core-support-boundary gateway-watch; do
echo "${name}-result=${results[$name]}" >> "$GITHUB_OUTPUT"
done
- name: Upload gateway watch regression artifacts
if: always() && needs.preflight.outputs.run_check_additional == 'true'
uses: actions/upload-artifact@v7
with:
name: gateway-watch-regression
path: .local/gateway-watch-regression/
retention-days: 7
checks-fast-core:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_fast_core_matrix) }}
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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"
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
env:
OPENCLAW_TEST_PROJECTS_PARALLEL: 3
TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
bundled)
pnpm test:bundled
;;
contracts-channels)
pnpm test:contracts:channels
;;
contracts-plugins)
pnpm test:contracts:plugins
;;
*)
echo "Unsupported checks-fast task: $TASK" >&2
exit 1
;;
esac
checks-fast-channel-contracts-shard:
permissions:
contents: read
name: ${{ matrix.checkName }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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"
- 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-fast-channel-contracts:
permissions:
contents: read
name: checks-fast-contracts-channels
needs: [preflight, checks-fast-channel-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify channel contract shards
env:
SHARD_RESULT: ${{ needs.checks-fast-channel-contracts-shard.result }}
run: |
if [ "$SHARD_RESULT" = "cancelled" ]; then
echo "Channel contract shards were cancelled, usually because a newer commit superseded this run." >&2
exit 1
fi
if [ "$SHARD_RESULT" != "success" ]; then
echo "Channel contract shards failed: $SHARD_RESULT" >&2
exit 1
fi
checks-fast-protocol:
permissions:
contents: read
name: "checks-fast-protocol"
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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"
- name: Run protocol check
run: pnpm protocol:check
checks-node-extensions-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_extensions_matrix) }}
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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"
- name: Run extension shard
env:
NODE_OPTIONS: --max-old-space-size=6144
OPENCLAW_EXTENSION_BATCH_PARALLEL: 2
OPENCLAW_VITEST_MAX_WORKERS: 1
OPENCLAW_EXTENSION_BATCH: ${{ matrix.extensions_csv }}
run: pnpm test:extensions:batch -- "$OPENCLAW_EXTENSION_BATCH"
checks-node-extensions:
permissions:
contents: read
name: checks-node-extensions
needs: [preflight, checks-node-extensions-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify extension shards
env:
SHARD_RESULT: ${{ needs.checks-node-extensions-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Extension shard checks failed: $SHARD_RESULT" >&2
exit 1
fi
checks:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
steps:
- name: Verify ${{ matrix.task }} (${{ matrix.runtime }})
env:
TASK: ${{ matrix.task }}
CHANNELS_RESULT: ${{ needs.build-artifacts.outputs['channels-result'] }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
channels)
if [ "$CHANNELS_RESULT" != "success" ]; then
echo "Channel tests failed in build-artifacts: $CHANNELS_RESULT" >&2
exit 1
fi
;;
*)
echo "Unsupported checks task: $TASK" >&2
exit 1
;;
esac
checks-node-compat:
permissions:
contents: read
name: checks-node-compat-node22
needs: [preflight]
if: needs.preflight.outputs.run_node == 'true' && github.event_name == 'push'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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:
node-version: "22.18.0"
cache-key-suffix: "node22"
install-bun: "false"
- 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=6144
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.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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:
node-version: "${{ matrix.node_version || '24.x' }}"
cache-key-suffix: "${{ matrix.cache_key_suffix || 'node24' }}"
install-bun: "false"
- name: Configure Node test resources
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
- name: Run Node test shard
env:
NODE_OPTIONS: --max-old-space-size=6144
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_TEST_PROJECTS_PARALLEL: "2"
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'EOF'
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { join } from "node:path";
const configs = JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]");
if (!Array.isArray(configs) || configs.length === 0) {
console.error("Missing node test shard configs");
process.exit(1);
}
const includePatterns = JSON.parse(process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null");
const childEnv = { ...process.env };
if (Array.isArray(includePatterns) && includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",
`node-test-include-${process.env.GITHUB_JOB ?? "local"}-${Date.now()}.json`,
);
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
}
const result = spawnSync("pnpm", ["exec", "node", "scripts/test-projects.mjs", ...configs], {
env: childEnv,
stdio: "inherit",
});
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
EOF
checks-node-core-test-dist-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_dist_matrix) }}
steps:
- name: Verify Node test shard
env:
CORE_SUPPORT_BOUNDARY_RESULT: ${{ needs.build-artifacts.outputs['core-support-boundary-result'] }}
SHARD_NAME: ${{ matrix.shard_name }}
shell: bash
run: |
set -euo pipefail
case "$SHARD_NAME" in
core-support-boundary)
if [ "$CORE_SUPPORT_BOUNDARY_RESULT" != "success" ]; then
echo "Core support boundary shard failed in build-artifacts: $CORE_SUPPORT_BOUNDARY_RESULT" >&2
exit 1
fi
;;
*)
echo "Unsupported built-artifact shard: $SHARD_NAME" >&2
exit 1
;;
esac
checks-node-core-test:
permissions:
contents: read
name: checks-node-core
needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify node test shards
env:
DIST_SHARD_RESULT: ${{ needs.checks-node-core-test-dist-shard.result }}
NONDIST_SHARD_RESULT: ${{ needs.checks-node-core-test-nondist-shard.result }}
RUN_DIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
RUN_NONDIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_nondist }}
run: |
if [ "$RUN_NONDIST_SHARDS" = "true" ] && [ "$NONDIST_SHARD_RESULT" != "success" ]; then
echo "Node non-dist test shards failed: $NONDIST_SHARD_RESULT" >&2
exit 1
fi
if [ "$RUN_DIST_SHARDS" = "true" ] && [ "$DIST_SHARD_RESULT" != "success" ]; then
echo "Node dist test shards failed: $DIST_SHARD_RESULT" >&2
exit 1
fi
extension-fast:
permissions:
contents: read
name: "extension-fast"
needs: [preflight]
if: needs.preflight.outputs.run_extension_fast == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.extension_fast_matrix) }}
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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"
- name: Run changed extension tests
env:
OPENCLAW_CHANGED_EXTENSION: ${{ matrix.extension }}
run: |
set -euo pipefail
if [ "$OPENCLAW_CHANGED_EXTENSION" = "telegram" ]; then
export OPENCLAW_VITEST_MAX_WORKERS=1
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--max-old-space-size=6144"
pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION" -- --pool=forks
exit 0
fi
pnpm test:extension "$OPENCLAW_CHANGED_EXTENSION"
# 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.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- check_name: check-preflight-guards
task: preflight-guards
runner: ubuntu-24.04
- check_name: check-prod-types
task: prod-types
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-lint
task: lint
runner: blacksmith-16vcpu-ubuntu-2404
- check_name: check-policy-guards
task: policy-guards
runner: ubuntu-24.04
- check_name: check-test-types
task: test-types
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-strict-smoke
task: strict-smoke
runner: ubuntu-24.04
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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"
- name: Run check shard
env:
OPENCLAW_LOCAL_CHECK: "0"
TASK: ${{ matrix.task }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
preflight-guards)
pnpm check:no-conflict-markers
pnpm tool-display:check
pnpm check:host-env-policy:swift
;;
prod-types)
pnpm tsgo:prod
;;
lint)
pnpm lint --threads=8
;;
policy-guards)
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
;;
test-types)
pnpm check:test-types
;;
strict-smoke)
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
*)
echo "Unsupported check task: $TASK" >&2
exit 1
;;
esac
check:
permissions:
contents: read
name: "check"
needs: [preflight, check-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify check shards
env:
SHARD_RESULT: ${{ needs.check-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Check shards failed: $SHARD_RESULT" >&2
exit 1
fi
check-additional-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- check_name: check-additional-boundaries
group: boundaries
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
group: extension-bundled
- check_name: check-additional-extension-package-boundary
group: extension-package-boundary
- check_name: check-additional-runtime-topology-architecture
group: runtime-topology-architecture
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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"
- name: Cache extension package boundary artifacts
id: extension-package-boundary-cache
if: matrix.group == 'extension-package-boundary'
uses: actions/cache@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', '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/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 {} +
touch -t 200001010000 \
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
- name: Run additional check shard
env:
ADDITIONAL_CHECK_GROUP: ${{ matrix.group }}
RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }}
OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY: 4
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 6
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
;;
extension-channels)
run_check "lint:extensions:channels" pnpm run lint:extensions:channels
;;
extension-bundled)
run_check "lint:extensions:bundled" pnpm run lint:extensions:bundled
;;
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"
check-additional:
permissions:
contents: read
name: "check-additional"
needs: [preflight, check-additional-shard, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify additional check shards
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
GATEWAY_RESULT: ${{ needs.build-artifacts.outputs.gateway-watch-result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build artifact job failed: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
if [ "$GATEWAY_RESULT" != "success" ]; then
echo "Gateway topology check failed: $GATEWAY_RESULT" >&2
exit 1
fi
build-smoke:
permissions:
contents: read
name: "build-smoke"
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify build smoke
env:
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
run: |
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build smoke checks failed in build-artifacts: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
# 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: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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"
- name: Check docs
run: pnpm check:docs
skills-python:
permissions:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_skills_python_job == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Setup Python
uses: actions/setup-python@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 skills
- name: Test skill Python scripts
run: python -m pytest -q skills
checks-windows:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_windows == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025' }}
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=6144
# 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
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- 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
uses: actions/setup-node@v6
with:
node-version: 24.x
check-latest: false
- name: Setup pnpm + cache store
id: pnpm-cache
uses: ./.github/actions/setup-pnpm-store-cache
with:
pnpm-version: "10.33.0"
cache-key-suffix: "node24"
use-restore-keys: "false"
use-actions-cache: "true"
- 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: Save pnpm store cache
if: steps.pnpm-cache.outputs.cache-enabled == 'true' && steps.pnpm-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
continue-on-error: true
with:
path: ${{ steps.pnpm-cache.outputs.store-path }}
key: ${{ steps.pnpm-cache.outputs.primary-key }}
- 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.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest' }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.macos_node_matrix) }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- 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.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v6
with:
persist-credentials: false
submodules: false
- name: Install XcodeGen / SwiftLint / SwiftFormat
run: brew install xcodegen swiftlint swiftformat
- 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@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@v5
with:
path: apps/macos/.build
key: ${{ runner.os }}-swift-build-v2-${{ 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/**', 'Swabble/Package.swift', 'Swabble/Sources/**') }}
restore-keys: |
${{ runner.os }}-swift-build-v2-${{ steps.swift-toolchain.outputs.key }}-
- name: Preserve Swift build cache hit
if: steps.swift-build-cache.outputs.cache-hit == '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 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 \
Swabble/Package.swift
- name: Show toolchain
run: |
sw_vers
xcodebuild -version
swift --version
- name: Swift lint
run: |
swiftlint --config .swiftlint.yml
swiftformat --lint apps/macos/Sources --config .swiftformat
- 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
echo "swift build failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
- 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 --show-codecov-path; then
exit 0
fi
echo "swift test failed (attempt $attempt/3). Retrying…"
sleep $((attempt * 20))
done
exit 1
android:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_android_job == 'true'
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ github.sha }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
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 -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
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: Setup Java
uses: actions/setup-java@v5
with:
distribution: temurin
# Keep sdkmanager on the stable JDK path for Linux CI runners.
java-version: 17
cache: gradle
cache-dependency-path: |
apps/android/**/*.gradle*
apps/android/**/gradle-wrapper.properties
apps/android/gradle/libs.versions.toml
- name: Setup Android SDK cmdline-tools
run: |
set -euo pipefail
ANDROID_SDK_ROOT="$HOME/.android-sdk"
CMDLINE_TOOLS_VERSION="12266719"
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
URL="https://dl.google.com/android/repository/${ARCHIVE}"
mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools"
curl -fsSL "$URL" -o "/tmp/${ARCHIVE}"
rm -rf "$ANDROID_SDK_ROOT/cmdline-tools/latest"
unzip -q "/tmp/${ARCHIVE}" -d "$ANDROID_SDK_ROOT/cmdline-tools"
mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest"
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "ANDROID_HOME=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin" >> "$GITHUB_PATH"
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
- name: Install Android SDK packages
run: |
yes | sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --licenses >/dev/null
sdkmanager --sdk_root="${ANDROID_SDK_ROOT}" --install \
"platform-tools" \
"platforms;android-36" \
"build-tools;36.0.0"
- 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
;;
test-third-party)
./gradlew --no-daemon --build-cache :app:testThirdPartyDebugUnitTest
;;
build-play)
./gradlew --no-daemon --build-cache :app:assemblePlayDebug
;;
*)
echo "Unsupported Android task: $TASK" >&2
exit 1
;;
esac