mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 17:01:45 +00:00
* fix(ui): make indexed access explicit across the Control UI Burns down all 322 ui-lane noUncheckedIndexedAccess errors: untrusted markdown/diff/patch parsing gets miss-tolerant guards (renderer rules degrade to empty output instead of throwing mid-render), accumulators use canonical sparse initialization, DOM lookups stay null-guarded, and length-checked constructions carry named invariants. * feat(tooling): enforce noUncheckedIndexedAccess in the core and ui lanes Flips the flag on for tsconfig.core.json and tsconfig.ui.json (covering src, all packages including the two deferred ones, and ui) and retires the strict-ratchet lane wholesale: config, script, projects reference, check/CI wiring, changed-lane routing, and sync test. The assertion-ban oxlint override stays as an independent surface.
2505 lines
108 KiB
YAML
2505 lines
108 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
|
|
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]
|
|
paths-ignore:
|
|
- "CHANGELOG.md"
|
|
|
|
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.
|
|
runner-admission:
|
|
permissions:
|
|
contents: read
|
|
runs-on: ubuntu-24.04
|
|
timeout-minutes: 3
|
|
env:
|
|
OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS: "90"
|
|
steps:
|
|
- name: Debounce canonical main pushes
|
|
if: github.event_name == 'push' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main'
|
|
run: |
|
|
set -euo pipefail
|
|
echo "Waiting ${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}s for a superseding main push before Blacksmith admission"
|
|
sleep "${OPENCLAW_MAIN_CI_DEBOUNCE_SECONDS}"
|
|
- name: Admit non-main CI runs immediately
|
|
if: github.event_name != 'push' || github.repository != 'openclaw/openclaw' || github.ref != 'refs/heads/main'
|
|
run: echo "No canonical main debounce required"
|
|
|
|
# Preflight: establish routing truth and job matrices once, then let real
|
|
# work fan out from a single source of truth.
|
|
preflight:
|
|
permissions:
|
|
contents: read
|
|
needs: [runner-admission]
|
|
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 20
|
|
outputs:
|
|
checkout_revision: ${{ steps.checkout_ref.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 }}
|
|
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 }}
|
|
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: Validate release-gate dispatch
|
|
if: github.event_name == 'workflow_dispatch' && inputs.release_gate
|
|
env:
|
|
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 [[ "$GITHUB_SHA" != "$TARGET_REF" ]]; then
|
|
echo "release_gate must run from the branch at target_ref" >&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=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
|
|
}
|
|
|
|
# Manual release-gate runs commonly validate the workflow ref's exact SHA.
|
|
# Fetch the branch/tag ref first so GitHub does not have to negotiate an
|
|
# arbitrary SHA fetch, then verify the resolved commit stayed pinned.
|
|
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" ]; then
|
|
echo "::error::checkout ref '$checkout_ref' resolved to '$resolved_sha'," \
|
|
"expected '$requested_sha'" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
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: 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: ${{ 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
|
|
if: github.event_name != 'workflow_dispatch'
|
|
uses: ./.github/actions/detect-docs-changes
|
|
|
|
- name: Detect changed scopes
|
|
id: changed_scope
|
|
if: github.event_name != 'workflow_dispatch' && steps.docs_scope.outputs.docs_only != 'true'
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
|
|
if [ "${{ github.event_name }}" = "push" ]; then
|
|
BASE="${{ github.event.before }}"
|
|
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
|
else
|
|
BASE="${{ github.event.pull_request.base.sha }}"
|
|
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD --merge-head-first-parent
|
|
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_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");
|
|
}
|
|
|
|
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 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 supportsFormatCheck =
|
|
targetWorkflow.split("pnpm format:check").length - 1 >= 2;
|
|
const runFormatCheck = !frozenTarget || supportsFormatCheck;
|
|
const checksFastCoreTasks = [];
|
|
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";
|
|
const runQaSmokeCi =
|
|
runNodeFull &&
|
|
(!frozenTarget || existsSync("extensions/qa-lab/src/ci-smoke-plan.ts"));
|
|
const nodeTestShards = runNodeFull
|
|
? createNodeTestPlan({
|
|
includeReleaseOnlyPluginShards: false,
|
|
compact: compactPullRequest,
|
|
}).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,
|
|
requires_go:
|
|
shard.shardName === "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);
|
|
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: runNodeFull,
|
|
run_checks_fast_core: checksFastCoreTasks.length > 0,
|
|
run_checks_fast: runNodeFull,
|
|
historical_target: historicalTarget,
|
|
compatibility_target: compatibilityTarget,
|
|
run_qa_smoke_ci: runQaSmokeCi,
|
|
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: nodeTestDistShards.length > 0,
|
|
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
|
|
? [
|
|
{ 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" },
|
|
{ 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
|
|
|
|
# Run dependency-free security checks in parallel with scope detection so the
|
|
# main Node jobs do not have to wait for Python/pre-commit setup.
|
|
security-fast:
|
|
permissions:
|
|
contents: read
|
|
needs: [runner-admission]
|
|
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || '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=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
|
|
}
|
|
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: Ensure security base commit
|
|
if: github.event_name != 'workflow_dispatch'
|
|
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: 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: ${{ 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[@]}"
|
|
|
|
- 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' && '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'
|
|
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-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: ${{ 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: Restore build-all step cache
|
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
with:
|
|
path: .artifacts/build-all-cache
|
|
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/model-catalog-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/model-catalog-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-build-all-v3-
|
|
|
|
- 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: Check Control UI i18n
|
|
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
|
|
run: pnpm ui:i18n:check
|
|
|
|
- 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' || '350' }}
|
|
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' && '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"
|
|
|
|
- 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' && '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"
|
|
|
|
- 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.
|
|
# Do not retry whole files: several rely on one-shot mocked browser globals.
|
|
pnpm --dir ui test --testTimeout=30000
|
|
else
|
|
pnpm --dir ui test
|
|
fi
|
|
|
|
checks-fast-core:
|
|
permissions:
|
|
contents: 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' && (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: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || '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-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-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-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
|
;;
|
|
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' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
|
timeout-minutes: 60
|
|
strategy:
|
|
fail-fast: false
|
|
max-parallel: 2
|
|
matrix:
|
|
include:
|
|
- name: profile 1/2
|
|
lane: profile-1
|
|
slug: profile-1-of-2
|
|
- name: profile 2/2
|
|
lane: profile-2
|
|
slug: profile-2-of-2
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
|
|
- 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") {
|
|
runs = smokePlan.createQaSmokeCiPart(partId).runs;
|
|
} else if (typeof smokePlan.createQaSmokeCiMatrix === "function") {
|
|
const partIndex = partId === "profile-1" ? 0 : partId === "profile-2" ? 1 : -1;
|
|
if (partIndex < 0) {
|
|
throw new Error(`unknown QA smoke CI profile part: ${partId}`);
|
|
}
|
|
runs = smokePlan
|
|
.createQaSmokeCiMatrix()
|
|
.include.filter((_, index) => index % 2 === partIndex);
|
|
} 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' && '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"
|
|
|
|
- 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' && '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"
|
|
|
|
- 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' && '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.19.0"
|
|
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=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' && (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"
|
|
|
|
- name: Setup Go for docs i18n
|
|
if: matrix.requires_go == true
|
|
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
|
with:
|
|
go-version-file: scripts/docs-i18n/go.mod
|
|
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
|
|
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
|
|
|
|
- name: Run Node test shard
|
|
env:
|
|
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_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
|
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
|
|
OPENCLAW_VITEST_NO_OUTPUT_RETRY: "1"
|
|
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 groups = JSON.parse(process.env.OPENCLAW_NODE_TEST_GROUPS_JSON ?? "null");
|
|
const plans = Array.isArray(groups) && groups.length > 0
|
|
? groups
|
|
: [{
|
|
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
|
|
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
|
|
includePatterns: JSON.parse(
|
|
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
|
|
),
|
|
shard_name: process.env.OPENCLAW_VITEST_SHARD_NAME,
|
|
}];
|
|
for (const plan of plans) {
|
|
const configs = plan.configs;
|
|
if (!Array.isArray(configs) || configs.length === 0) {
|
|
console.error("Missing node test shard configs");
|
|
process.exit(1);
|
|
}
|
|
const childEnv = {
|
|
...process.env,
|
|
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
|
|
};
|
|
if (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
|
|
for (const [key, value] of Object.entries(plan.env)) {
|
|
if (typeof value === "string") {
|
|
childEnv[key] = value;
|
|
}
|
|
}
|
|
}
|
|
if (Array.isArray(plan.includePatterns) && plan.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(plan.includePatterns), "utf8");
|
|
childEnv.OPENCLAW_VITEST_INCLUDE_FILE = includeFile;
|
|
} else {
|
|
delete childEnv.OPENCLAW_VITEST_INCLUDE_FILE;
|
|
}
|
|
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
|
|
|
|
# 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' && (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
|
|
runner: blacksmith-16vcpu-ubuntu-2404
|
|
- check_name: check-dependencies
|
|
task: dependencies
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
- check_name: check-test-types
|
|
task: test-types
|
|
runner: blacksmith-4vcpu-ubuntu-2404
|
|
steps:
|
|
- *linux_node_checkout_step
|
|
- name: Setup Node environment
|
|
uses: ./.github/actions/setup-node-env
|
|
with:
|
|
install-bun: "false"
|
|
|
|
- name: Run check shard
|
|
env:
|
|
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
|
|
FORMAT_CHECK: ${{ needs.preflight.outputs.run_format_check }}
|
|
OPENCLAW_LOCAL_CHECK: "0"
|
|
TASK: ${{ matrix.task }}
|
|
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
|
|
shell: bash
|
|
run: |
|
|
set -euo pipefail
|
|
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/pr-base"
|
|
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-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)
|
|
pnpm lint --threads=8
|
|
if [ "$FORMAT_CHECK" = "true" ]; then
|
|
pnpm format:check
|
|
fi
|
|
;;
|
|
dependencies)
|
|
if pnpm run --silent 2>/dev/null | grep -q '^ deadcode:dependencies$'; then
|
|
pnpm deadcode:dependencies
|
|
pnpm deadcode:unused-files
|
|
pnpm deadcode:report:ci:ts-unused
|
|
else
|
|
pnpm deadcode:ci
|
|
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
|
|
|
|
- name: Upload deadcode reports
|
|
if: ${{ always() && matrix.task == 'dependencies' }}
|
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
|
with:
|
|
name: deadcode-reports
|
|
path: .artifacts/deadcode
|
|
if-no-files-found: ignore
|
|
|
|
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' && (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
|
|
- 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-channels
|
|
group: extension-channels
|
|
runner: blacksmith-8vcpu-ubuntu-2404
|
|
- check_name: check-additional-extension-bundled
|
|
group: extension-bundled
|
|
runner: blacksmith-8vcpu-ubuntu-2404
|
|
- check_name: check-additional-extension-package-boundary
|
|
group: extension-package-boundary
|
|
runner: blacksmith-8vcpu-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"
|
|
|
|
- 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 }}
|
|
OPENCLAW_ADDITIONAL_BOUNDARY_SHARD: ${{ matrix.boundary_shard || '' }}
|
|
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
|
|
;;
|
|
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-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"
|
|
|
|
# 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' && '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"
|
|
|
|
- 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' && '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' && (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' && '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' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
|
timeout-minutes: 20
|
|
env:
|
|
HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }}
|
|
steps:
|
|
- *platform_checkout_step
|
|
|
|
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
|
run: |
|
|
brew update
|
|
if [[ -x ./scripts/install-swift-tools.sh ]]; then
|
|
brew install xcodegen
|
|
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
|
|
./scripts/install-swift-tools.sh "$swift_tools_dir"
|
|
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
|
"$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 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 --retry 3 \
|
|
--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-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: 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' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
|
timeout-minutes: 45
|
|
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: |
|
|
brew update
|
|
if [[ -x ./scripts/install-swift-tools.sh ]]; then
|
|
brew install xcodegen
|
|
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
|
|
./scripts/install-swift-tools.sh "$swift_tools_dir"
|
|
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
|
"$swift_tools_dir/swiftformat" --version
|
|
"$swift_tools_dir/swiftlint" version
|
|
else
|
|
# Frozen release targets before the pinned installer used Homebrew directly.
|
|
brew install xcodegen swiftlint swiftformat
|
|
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' && '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: Setup Java
|
|
uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # 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: Cache Android SDK
|
|
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
|
with:
|
|
path: ~/.android-sdk
|
|
key: ${{ runner.os }}-android-sdk-v1-cmdline-14742923-platform-37.0-build-tools-36.0.0
|
|
restore-keys: |
|
|
${{ runner.os }}-android-sdk-v1-
|
|
|
|
- name: Setup Android SDK cmdline-tools
|
|
run: |
|
|
set -euo pipefail
|
|
ANDROID_SDK_ROOT="$HOME/.android-sdk"
|
|
CMDLINE_TOOLS_VERSION="14742923"
|
|
ARCHIVE="commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip"
|
|
URL="https://dl.google.com/android/repository/${ARCHIVE}"
|
|
|
|
if [ ! -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then
|
|
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"
|
|
fi
|
|
|
|
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-37.0" \
|
|
"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 \
|
|
:app:assembleThirdPartyDebug \
|
|
:app:lintPlayDebug \
|
|
:app:lintThirdPartyDebug \
|
|
:benchmark:assembleDebug
|
|
;;
|
|
ktlint)
|
|
# Mirrors `pnpm android:lint`; keeps formatting drift out of main (see PR #100304 sweep).
|
|
./gradlew --no-daemon --build-cache :app:ktlintCheck :benchmark:ktlintCheck
|
|
;;
|
|
*)
|
|
echo "Unsupported Android task: $TASK" >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
ci-timings-summary:
|
|
permissions:
|
|
actions: read
|
|
contents: read
|
|
name: ci-timings-summary
|
|
needs:
|
|
- preflight
|
|
- security-fast
|
|
- pnpm-store-warmup
|
|
- build-artifacts
|
|
- checks-ui
|
|
- 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
|