mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
* fix(release): record dependency advisories without blocking releases Release dependency evidence now blocks only on known malware. Vulnerability advisories of every severity are recorded in the evidence summary and surfaced as GitHub warning annotations, and CI dispatched by Full Release Validation or release publication reports a failing production audit as a warning. The per-release risk-acceptance table existed only to accept advisory blockers and is removed. The release skills also record that main CI health never gates a release and that every failed test gets an explicit real-blocker-or-flake decision. * fix(ci): keep release audit relaxation within the workflow size budget ci.yml sits at the 480000-byte guard, so the release-dispatch check moves into a trusted harness script that security-fast already checks out.
7509 lines
469 KiB
YAML
7509 lines
469 KiB
YAML
name: CI
|
||
|
||
on:
|
||
schedule:
|
||
- cron: "23 * * * *"
|
||
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
|
||
runner_backend:
|
||
description: Runner qualification override; requires an exact-head canonical PR release_gate dispatch.
|
||
required: false
|
||
default: default
|
||
type: choice
|
||
options: [default, hybrid, runson]
|
||
ci_shape:
|
||
description: Main-push coverage for an admitted exact-head qualification; default preserves ordinary dispatch behavior.
|
||
required: false
|
||
default: default
|
||
type: choice
|
||
options: [default, main]
|
||
capture_ui_proof:
|
||
description: Capture and upload sanitized Control UI screenshots from real-Gateway tests.
|
||
required: false
|
||
default: false
|
||
type: boolean
|
||
include_android:
|
||
description: Run Android lanes for this manual CI dispatch.
|
||
required: false
|
||
default: false
|
||
type: boolean
|
||
validation_tier:
|
||
description: Full release coverage, or complete main coverage without release-only proofs.
|
||
required: false
|
||
default: full
|
||
type: choice
|
||
options: [full, main]
|
||
release_scope:
|
||
description: Release qualification scope; npm-beta and npm-stable defer native apps for a validated exact release target.
|
||
required: false
|
||
default: full
|
||
type: choice
|
||
options: [full, npm-beta, npm-stable]
|
||
release_gate:
|
||
description: Run an exact-SHA maintainer release-gate fallback when PR CI is capacity-stalled.
|
||
required: false
|
||
default: false
|
||
type: boolean
|
||
pull_request_number:
|
||
description: Pull request number required by the exact-SHA release gate.
|
||
required: false
|
||
default: ""
|
||
type: string
|
||
dispatch_id:
|
||
description: Optional parent workflow dispatch identifier
|
||
required: false
|
||
default: ""
|
||
type: string
|
||
historical_target_tag:
|
||
description: Semver release tag authorizing compatibility fallbacks for its exact commit
|
||
required: false
|
||
default: ""
|
||
type: string
|
||
release_candidate_ref:
|
||
description: Canonical release branch authorizing compatibility fallbacks for its exact head
|
||
required: false
|
||
default: ""
|
||
type: string
|
||
target_context_ref:
|
||
description: Canonical release branch context authorizing compatibility fallbacks for an exact-SHA target
|
||
required: false
|
||
default: ""
|
||
type: string
|
||
push:
|
||
branches: [main]
|
||
paths-ignore:
|
||
- "**/*.md"
|
||
- "docs/**"
|
||
pull_request:
|
||
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
|
||
|
||
permissions:
|
||
contents: read
|
||
|
||
run-name: ${{ github.event_name == 'workflow_dispatch' && inputs.dispatch_id != '' && format('CI {0}', inputs.dispatch_id) || (github.event_name == 'workflow_dispatch' && inputs.release_gate && format('CI release gate {0}', inputs.target_ref) || 'CI') }}
|
||
|
||
concurrency:
|
||
# Keep main parity slots; isolate passive drafts except converted_to_draft.
|
||
group: >-
|
||
${{ github.event_name == 'pull_request' && github.event.pull_request.draft &&
|
||
github.event.action != 'converted_to_draft' && format('{0}-draft-v1-{1}', github.workflow, github.run_id) ||
|
||
github.event_name == 'schedule' && format('{0}-hourly-main-v2-{1}', github.workflow, github.run_id) ||
|
||
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' && github.event_name == 'push' && github.ref == 'refs/heads/main' && format('{0}-v8-{1}-{2}', github.workflow, github.ref, (endsWith(format('{0}', github.run_number), '0') || endsWith(format('{0}', github.run_number), '2') || endsWith(format('{0}', github.run_number), '4') || endsWith(format('{0}', github.run_number), '6') || endsWith(format('{0}', github.run_number), '8')) && 'a' || 'b') || (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' }}
|
||
|
||
env:
|
||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||
IOS_SCREENSHOT_FASTLANE_VERSION: "2.240.1"
|
||
IOS_SCREENSHOT_NODE_VERSION: "24.16.0"
|
||
IOS_SCREENSHOT_XCODE_VERSION: "Xcode 27.0 Build version 27A266a"
|
||
NODE_VERSION: "24.21.0"
|
||
|
||
jobs:
|
||
preflight:
|
||
permissions:
|
||
contents: read
|
||
actions: read
|
||
pull-requests: read
|
||
if: ${{ (github.event_name != 'schedule' || (github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main')) && ((github.event_name != 'push' || github.ref != 'refs/heads/main' || vars.OPENCLAW_CI_ON_PUSH == 'true') && (github.event_name != 'pull_request' || !github.event.pull_request.draft)) }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || (contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && github.run_attempt != 1)) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')))) || ((vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || (contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && github.run_attempt != 1)) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')) }}
|
||
timeout-minutes: 20
|
||
outputs:
|
||
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
|
||
pr_job_count: ${{ steps.manifest.outputs.pr_job_count }}
|
||
pr_check_job_count: ${{ steps.manifest.outputs.pr_check_job_count }}
|
||
candidate_trust: ${{ steps.candidate_trust.outputs.trust }}
|
||
cache_mode: ${{ steps.candidate_trust.outputs.cache_mode }}
|
||
cache_write_allowed: ${{ steps.candidate_trust.outputs.cache_write_allowed }}
|
||
hosted_runner_profile_contract: ${{ steps.runner_profile.outputs.hosted_runner_profile_contract }}
|
||
runner_profile: ${{ steps.runner_profile.outputs.runner_profile }} # hosted-runner-profile-contract-v1
|
||
node_runner_backend: ${{ steps.runner_profile.outputs.node_runner_backend }} # runson-runner-profile-contract-v1
|
||
ci_qualification: ${{ steps.runner_profile.outputs.ci_qualification }}
|
||
ci_shape: ${{ steps.runner_profile.outputs.ci_shape }}
|
||
qualification_runner_backend: ${{ steps.runner_profile.outputs.qualification_runner_backend }}
|
||
hybrid_hosted_offload: ${{ steps.manifest.outputs.hybrid_hosted_offload }}
|
||
hybrid_hosted_checks: ${{ steps.manifest.outputs.hybrid_hosted_checks }}
|
||
hybrid_hosted_main_checks: ${{ steps.manifest.outputs.hybrid_hosted_main_checks }}
|
||
hybrid_hosted_base_rows: ${{ steps.manifest.outputs.hybrid_hosted_base_rows }}
|
||
hybrid_hosted_total_rows: ${{ steps.manifest.outputs.hybrid_hosted_total_rows }}
|
||
diff_base_revision: ${{ steps.diff_base.outputs.sha }}
|
||
diff_head_revision: ${{ steps.diff_base.outputs.head_sha }}
|
||
docs_only: ${{ steps.manifest.outputs.docs_only }}
|
||
docs_changed: ${{ steps.manifest.outputs.docs_changed }}
|
||
release_scope: ${{ steps.manifest.outputs.release_scope }}
|
||
release_fast_lane: ${{ steps.manifest.outputs.release_fast_lane }}
|
||
validation_tier: ${{ steps.manifest.outputs.validation_tier }}
|
||
run_node: ${{ steps.manifest.outputs.run_node }}
|
||
# docker-seed-e2e-contract-v1: frozen targets without this marker skip the lane.
|
||
run_docker_seed_e2e: ${{ steps.manifest.outputs.run_docker_seed_e2e }}
|
||
docker_seed_lanes: ${{ steps.manifest.outputs.docker_seed_lanes }}
|
||
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_proof_tier: ${{ steps.manifest.outputs.run_proof_tier }}
|
||
run_browser_native_host: ${{ steps.manifest.outputs.run_browser_native_host }}
|
||
run_doctor_plugin_index: ${{ steps.manifest.outputs.run_doctor_plugin_index }}
|
||
run_discord_component_proof: ${{ steps.manifest.outputs.run_discord_component_proof }}
|
||
run_gateway_watch: ${{ steps.manifest.outputs.run_gateway_watch }}
|
||
run_tui_pty: ${{ steps.manifest.outputs.run_tui_pty }}
|
||
run_baseline_ratchets: ${{ steps.manifest.outputs.run_baseline_ratchets }}
|
||
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 }}
|
||
frozen_target: ${{ steps.manifest.outputs.frozen_target }}
|
||
run_qa_smoke_ci: ${{ steps.manifest.outputs.run_qa_smoke_ci }}
|
||
qa_smoke_ci_matrix: ${{ steps.manifest.outputs.qa_smoke_ci_matrix }}
|
||
run_prompt_snapshots: ${{ steps.manifest.outputs.run_prompt_snapshots }}
|
||
run_sqlite_session_lifecycle: ${{ steps.manifest.outputs.run_sqlite_session_lifecycle }}
|
||
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 }}
|
||
source_channel_test_env_json: ${{ steps.manifest.outputs.source_channel_test_env_json }}
|
||
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 }}
|
||
startup_corpus_node_revision: ${{ steps.manifest.outputs.startup_corpus_node_revision }}
|
||
startup_corpus_test_files_json: ${{ steps.manifest.outputs.startup_corpus_test_files_json }}
|
||
changed_core_test_paths_json: ${{ steps.manifest.outputs.changed_core_test_paths_json }}
|
||
narrow_check_paths_json: ${{ steps.manifest.outputs.narrow_check_paths_json }}
|
||
run_check_plan: ${{ steps.manifest.outputs.run_check_plan }}
|
||
check_plan_input_json: ${{ steps.manifest.outputs.check_plan_input_json }}
|
||
check_matrix: ${{ steps.manifest.outputs.check_matrix }}
|
||
core_type_matrix: ${{ steps.manifest.outputs.core_type_matrix }}
|
||
lint_core_matrix: ${{ steps.manifest.outputs.lint_core_matrix }}
|
||
lint_extension_matrix: ${{ steps.manifest.outputs.lint_extension_matrix }}
|
||
central_lint_selection_json: ${{ steps.manifest.outputs.central_lint_selection_json }}
|
||
run_lint_core: ${{ steps.manifest.outputs.run_lint_core }}
|
||
run_lint_extensions: ${{ steps.manifest.outputs.run_lint_extensions }}
|
||
run_changed_core_type_stripes: ${{ steps.manifest.outputs.run_changed_core_type_stripes }}
|
||
type_graph_boundary_owner: ${{ steps.manifest.outputs.type_graph_boundary_owner }}
|
||
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
|
||
check_additional_matrix: ${{ steps.manifest.outputs.check_additional_matrix }}
|
||
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
|
||
run_format_check: ${{ steps.manifest.outputs.run_format_check }}
|
||
compatibility_target: ${{ steps.manifest.outputs.compatibility_target }}
|
||
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
|
||
strict_control_ui_i18n: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.strict_control_ui_i18n }}
|
||
run_ui_tests: ${{ steps.manifest.outputs.run_ui_tests }}
|
||
ui_test_runtime_policy: ${{ steps.manifest.outputs.ui_test_runtime_policy }}
|
||
ui_test_matrix: ${{ steps.manifest.outputs.ui_test_matrix }}
|
||
ui_test_shard_count: ${{ steps.manifest.outputs.ui_test_shard_count }}
|
||
ui_test_groups_gzip_base64: ${{ steps.manifest.outputs.ui_test_groups_gzip_base64 }}
|
||
ui_e2e_test_groups_gzip_base64: ${{ steps.manifest.outputs.ui_e2e_test_groups_gzip_base64 }}
|
||
run_control_ui_performance: ${{ steps.manifest.outputs.run_control_ui_performance }}
|
||
run_ui_e2e: ${{ steps.manifest.outputs.run_ui_e2e }}
|
||
run_ui_real_gateway: ${{ steps.manifest.outputs.run_ui_real_gateway }}
|
||
ui_e2e_matrix: ${{ steps.manifest.outputs.ui_e2e_matrix }}
|
||
ui_real_gateway_matrix: ${{ steps.manifest.outputs.ui_real_gateway_matrix }}
|
||
run_native_i18n: ${{ steps.manifest.outputs.run_native_i18n }}
|
||
strict_native_i18n: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.strict_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_openclawkit_tests: ${{ steps.manifest.outputs.run_openclawkit_tests }}
|
||
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
|
||
run_ios_screenshots: ${{ steps.changed_scope.outputs.run_ios_screenshots }}
|
||
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
|
||
run_android_access_native: ${{ steps.manifest.outputs.run_android_access_native }}
|
||
use_compatible_android_ci: ${{ steps.manifest.outputs.use_compatible_android_ci }}
|
||
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:
|
||
HISTORICAL_TARGET_TAG: ${{ inputs.historical_target_tag }}
|
||
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||
TARGET_REF: ${{ inputs.target_ref }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
if [[ ! "$TARGET_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
||
echo "release_gate requires target_ref to be a full commit SHA" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if [[ ! "$PULL_REQUEST_NUMBER" =~ ^[1-9][0-9]*$ ]]; then
|
||
echo "release_gate requires pull_request_number" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if [[ "$GITHUB_SHA" != "$TARGET_REF" ]]; then
|
||
echo "release_gate must run from the branch at target_ref" >&2
|
||
exit 1
|
||
fi
|
||
|
||
if [[ -n "$HISTORICAL_TARGET_TAG" ]]; then
|
||
echo "release_gate cannot be combined with historical_target_tag" >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Checkout
|
||
shell: bash
|
||
env:
|
||
CHECKOUT_KIND: preflight
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
CHECKOUT_REPO: ${{ github.repository }}
|
||
CHECKOUT_TOKEN: ${{ github.token }}
|
||
CHECKOUT_REF: ${{ inputs.target_ref || github.sha }}
|
||
CHECKOUT_EVENT_REF: ${{ github.ref }}
|
||
CHECKOUT_FALLBACK_REF: ${{ github.sha }}
|
||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||
run: &owned_checkout_run |
|
||
set -euo pipefail
|
||
python_command=python3
|
||
if [ "$RUNNER_OS" = "Windows" ]; then
|
||
python_command=python
|
||
fi
|
||
run_owner() {
|
||
if [ "$RUNNER_OS" = "Linux" ] || [ "$RUNNER_OS" = "Windows" ]; then
|
||
# Keep trusted code outside the checkout and below Windows argv limits.
|
||
printf '%s' "$1" > "$RUNNER_TEMP/ci-git-owner.py"
|
||
exec "$python_command" -I -S "$RUNNER_TEMP/ci-git-owner.py"
|
||
fi
|
||
exec "$python_command" -I -S -c "$1"
|
||
}
|
||
# Generated from .github/actions/git-owner/owner.py; do not edit here.
|
||
run_owner 'import base64
|
||
import builtins
|
||
import json
|
||
import os
|
||
import re
|
||
import runpy
|
||
import shutil
|
||
import signal
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
from types import TracebackType
|
||
|
||
linux = os.environ.get("RUNNER_OS", sys.platform) in ("Linux", "linux")
|
||
fetch_timeout_seconds = 120 if linux else 90
|
||
cleanup_seconds = 10
|
||
cancelled = 0
|
||
closed = False
|
||
git = shutil.which("git")
|
||
checkout_environment = {}
|
||
|
||
|
||
def cancel(signum, _frame):
|
||
global cancelled
|
||
cancelled = signum
|
||
|
||
|
||
for signame in ("SIGINT", "SIGTERM", "SIGHUP", "SIGBREAK"):
|
||
if hasattr(signal, signame):
|
||
signal.signal(getattr(signal, signame), cancel)
|
||
|
||
|
||
def check_cancelled():
|
||
if cancelled:
|
||
raise SystemExit(128 + cancelled)
|
||
|
||
|
||
# The bootstrap inherits only this Job handle and stdio, joins before spawning Git,
|
||
# then closes its copy. Even owner death before assignment kills it on that close.
|
||
windows_api = '\'''\'''\''
|
||
import ctypes as c
|
||
from ctypes import wintypes as w
|
||
import os, subprocess, sys
|
||
kernel = c.WinDLL("kernel32", use_last_error=True)
|
||
def checked(value, function, arguments):
|
||
if not value:
|
||
raise c.WinError(c.get_last_error())
|
||
return value
|
||
def bind(name, result, *arguments):
|
||
function = getattr(kernel, name)
|
||
function.restype, function.argtypes = result, arguments
|
||
function.errcheck = checked
|
||
return function
|
||
close_handle = bind("CloseHandle", w.BOOL, w.HANDLE)
|
||
'\'''\'''\''
|
||
if os.name == "nt":
|
||
exec(windows_api)
|
||
|
||
class BasicLimits(c.Structure):
|
||
_fields_ = [
|
||
("PerProcessUserTimeLimit", c.c_int64), ("PerJobUserTimeLimit", c.c_int64),
|
||
("LimitFlags", w.DWORD), ("MinimumWorkingSetSize", c.c_size_t),
|
||
("MaximumWorkingSetSize", c.c_size_t), ("ActiveProcessLimit", w.DWORD),
|
||
("Affinity", c.c_size_t), ("PriorityClass", w.DWORD), ("SchedulingClass", w.DWORD),
|
||
]
|
||
|
||
class IoCounters(c.Structure):
|
||
_fields_ = [(name, c.c_uint64) for name in (
|
||
"ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
|
||
"ReadTransferCount", "WriteTransferCount", "OtherTransferCount",
|
||
)]
|
||
|
||
class ExtendedLimits(c.Structure):
|
||
_fields_ = [("BasicLimitInformation", BasicLimits), ("IoInfo", IoCounters)] + [
|
||
(name, c.c_size_t) for name in (
|
||
"ProcessMemoryLimit", "JobMemoryLimit", "PeakProcessMemoryUsed", "PeakJobMemoryUsed",
|
||
)
|
||
]
|
||
|
||
class Accounting(c.Structure):
|
||
_fields_ = [(name, c.c_int64) for name in (
|
||
"TotalUserTime", "TotalKernelTime", "ThisPeriodTotalUserTime", "ThisPeriodTotalKernelTime",
|
||
)] + [(name, w.DWORD) for name in (
|
||
"TotalPageFaultCount", "TotalProcesses", "ActiveProcesses", "TotalTerminatedProcesses",
|
||
)]
|
||
|
||
if (c.sizeof(BasicLimits), c.sizeof(ExtendedLimits), c.sizeof(Accounting), Accounting.ActiveProcesses.offset) != (64, 144, 48, 40):
|
||
raise RuntimeError("Unsupported Windows Job structure layout")
|
||
|
||
create_job = bind("CreateJobObjectW", w.HANDLE, c.c_void_p, w.LPCWSTR)
|
||
set_job = bind("SetInformationJobObject", w.BOOL, w.HANDLE, c.c_int, c.c_void_p, w.DWORD)
|
||
query_job = bind("QueryInformationJobObject", w.BOOL, w.HANDLE, c.c_int, c.c_void_p, w.DWORD, c.c_void_p)
|
||
terminate_job = bind("TerminateJobObject", w.BOOL, w.HANDLE, w.UINT)
|
||
open_process = bind("OpenProcess", w.HANDLE, w.DWORD, w.BOOL, w.DWORD)
|
||
in_job = bind("IsProcessInJob", w.BOOL, w.HANDLE, w.HANDLE, c.POINTER(w.BOOL))
|
||
wait_process = kernel.WaitForSingleObject
|
||
wait_process.argtypes, wait_process.restype = [w.HANDLE, w.DWORD], w.DWORD
|
||
|
||
def job_members(job, deadline):
|
||
# PIDs are discovery hints only. Hold query/synchronize handles and verify
|
||
# job membership before using them; never signal a process found by PID.
|
||
before = Accounting()
|
||
query_job(job, 1, c.byref(before), c.sizeof(before), None)
|
||
capacity = max(16, before.ActiveProcesses + 1)
|
||
while True:
|
||
if time.monotonic() >= deadline or capacity > 65536:
|
||
raise RuntimeError("Job member census did not complete")
|
||
class Members(c.Structure):
|
||
_fields_ = [("assigned", w.DWORD), ("count", w.DWORD),
|
||
("pids", c.c_size_t * capacity)]
|
||
members = Members()
|
||
try:
|
||
query_job(job, 3, c.byref(members), c.sizeof(members), None)
|
||
break
|
||
except OSError as error:
|
||
if error.winerror != 234: # ERROR_MORE_DATA: retry the census, not cleanup.
|
||
raise
|
||
capacity *= 2
|
||
handles = []
|
||
try:
|
||
for pid in members.pids[:members.count]:
|
||
handle = open_process(0x00100000 | 0x1000, False, pid)
|
||
handles.append(handle)
|
||
member = w.BOOL()
|
||
in_job(handle, job, c.byref(member))
|
||
if not member.value:
|
||
raise RuntimeError("Job member identity changed during census")
|
||
after = Accounting()
|
||
query_job(job, 1, c.byref(after), c.sizeof(after), None)
|
||
if after.TotalProcesses != before.TotalProcesses:
|
||
raise RuntimeError("Job membership grew during census")
|
||
return handles, after.TotalProcesses
|
||
except BaseException:
|
||
for handle in handles:
|
||
close_handle(handle)
|
||
raise
|
||
bootstrap = windows_api + '\'''\'''\''
|
||
job = int(sys.argv[1])
|
||
assign = bind("AssignProcessToJobObject", w.BOOL, w.HANDLE, w.HANDLE)
|
||
current = bind("GetCurrentProcess", w.HANDLE)
|
||
assign(job, current())
|
||
close_handle(job)
|
||
sys.exit(subprocess.call(sys.argv[2:], stdin=subprocess.DEVNULL))
|
||
'\'''\'''\''
|
||
|
||
|
||
def group_signal(pgid, signum, deadline):
|
||
try:
|
||
os.killpg(pgid, signum)
|
||
except ProcessLookupError:
|
||
return False
|
||
except PermissionError:
|
||
# Darwin can refuse signals while members are exiting but not yet zombies.
|
||
# Keep those members pending until drain proves termination; never accept a live denial.
|
||
states = group_states(pgid, deadline)
|
||
if any(not state.startswith("Z") and not (sys.platform == "darwin" and "E" in state)
|
||
for state in states):
|
||
raise
|
||
return any(not state.startswith("Z") for state in states)
|
||
return True
|
||
|
||
|
||
def group_alive(pgid, deadline):
|
||
return any(not state.startswith("Z") for state in group_states(pgid, deadline))
|
||
|
||
|
||
def group_states(pgid, deadline):
|
||
try:
|
||
os.killpg(pgid, 0)
|
||
except ProcessLookupError:
|
||
return []
|
||
except PermissionError:
|
||
pass # EPERM can mean zombie-only; the census must still prove extinction.
|
||
# Darwin -g selects a group; procps selects its session (a superset because
|
||
# run_git starts a new session). Pin Darwin'\''s standard, not legacy, -g syntax.
|
||
try:
|
||
result = subprocess.run(
|
||
["ps", "-o", "pgid=,stat=", "-g", str(pgid)], stdin=subprocess.DEVNULL,
|
||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||
env={**os.environ, "COMMAND_MODE": "unix2003"},
|
||
timeout=max(0.001, deadline - time.monotonic()),
|
||
)
|
||
except subprocess.TimeoutExpired as error:
|
||
print((error.stderr or b"").decode(errors="replace"), end="", file=sys.stderr)
|
||
raise
|
||
if result.stderr:
|
||
print(result.stderr, end="", file=sys.stderr)
|
||
states = []
|
||
if result.returncode != 1 or result.stdout or result.stderr:
|
||
result.check_returncode()
|
||
# Validate the complete census before ignoring zombies; Darwin ps can
|
||
# report sysctl errors on stderr with exit 0. Neither permits reuse.
|
||
if result.stderr or not re.fullmatch(
|
||
r"(?:[ \t]*[1-9][0-9]*[ \t]+[RSDTtXZxKWPIU?][<+NLlsEVWX]*[ \t]*\n)+", result.stdout
|
||
):
|
||
raise RuntimeError("Invalid process group census")
|
||
states = [state for group, state in (line.split() for line in result.stdout.splitlines())
|
||
if int(group) == pgid]
|
||
if states:
|
||
return states
|
||
# Empty selection (exit 1), or a session with only other groups, can race
|
||
# extinction. Require native ESRCH; a bare status 1 or EPERM proves nothing.
|
||
try:
|
||
os.killpg(pgid, 0)
|
||
except ProcessLookupError:
|
||
return []
|
||
raise RuntimeError("Process group census missed a present group")
|
||
|
||
|
||
def drain(child, job):
|
||
deadline = time.monotonic() + cleanup_seconds
|
||
if os.name == "nt":
|
||
# Stop/join even a pre-assignment bootstrap before terminating the Job:
|
||
# an empty Job alone cannot prove that no Git will start afterwards.
|
||
child.kill()
|
||
child.wait(timeout=max(0.001, deadline - time.monotonic()))
|
||
handles = []
|
||
terminated = False
|
||
try:
|
||
handles, total = job_members(job, deadline)
|
||
terminate_job(job, 1)
|
||
terminated = True
|
||
accounting = Accounting()
|
||
while True:
|
||
settled = True
|
||
for handle in handles:
|
||
status = wait_process(handle, 0)
|
||
if status == 258: # WAIT_TIMEOUT: accounting can reach zero first.
|
||
settled = False
|
||
elif status != 0:
|
||
raise c.WinError(c.get_last_error())
|
||
query_job(job, 1, c.byref(accounting), c.sizeof(accounting), None)
|
||
if accounting.TotalProcesses != total:
|
||
raise RuntimeError("Job membership grew during cleanup")
|
||
if accounting.ActiveProcesses == 0 and settled:
|
||
return
|
||
if time.monotonic() >= deadline:
|
||
raise RuntimeError("Job cleanup did not complete")
|
||
time.sleep(0.05)
|
||
finally:
|
||
try:
|
||
if not terminated:
|
||
terminate_job(job, 1)
|
||
finally:
|
||
for handle in handles:
|
||
close_handle(handle)
|
||
else:
|
||
# The group remains ours after leader exit. Reserve half the existing
|
||
# cleanup allowance for KILL and extinction verification after TERM.
|
||
try:
|
||
group_signal(child.pid, signal.SIGTERM, deadline)
|
||
kill_at = deadline - cleanup_seconds / 2
|
||
while True:
|
||
child.poll()
|
||
if not group_alive(child.pid, deadline):
|
||
child.wait(timeout=max(0.001, deadline - time.monotonic()))
|
||
return
|
||
if time.monotonic() >= kill_at:
|
||
group_signal(child.pid, signal.SIGKILL, deadline)
|
||
if time.monotonic() >= deadline:
|
||
raise RuntimeError("Process group cleanup did not complete")
|
||
time.sleep(0.05)
|
||
except Exception:
|
||
group_signal(child.pid, signal.SIGKILL, deadline)
|
||
# KILL queues termination; a leader wait cannot join descendants.
|
||
# Count zombies conservatively here, but never reset the allowance or retry.
|
||
while time.monotonic() < deadline:
|
||
child.poll()
|
||
if not group_signal(child.pid, 0, deadline):
|
||
break
|
||
time.sleep(0.05)
|
||
child.wait(timeout=max(0.001, deadline - time.monotonic()))
|
||
raise
|
||
|
||
|
||
class FetchTimeout(Exception):
|
||
pass
|
||
|
||
|
||
class GitFailure(Exception):
|
||
def __init__(self, code):
|
||
self.code = code
|
||
|
||
|
||
def git_lock_files(directory):
|
||
git_dir = os.path.join(os.path.realpath(directory), ".git")
|
||
if not os.path.lexists(git_dir):
|
||
return set()
|
||
if not os.path.isdir(git_dir) or os.path.realpath(git_dir) != git_dir:
|
||
raise RuntimeError("Checkout Git directory is not physical")
|
||
def scan_error(error):
|
||
raise error
|
||
locks = set()
|
||
for root, directories, files in os.walk(git_dir, onerror=scan_error):
|
||
directories[:] = [name for name in directories
|
||
if os.path.realpath(os.path.join(root, name)) == os.path.join(root, name)]
|
||
locks.update(os.path.join(root, name) for name in files if name.endswith(".lock"))
|
||
return locks
|
||
|
||
|
||
def git_auth_environment(remote, token):
|
||
# Git'\''s promisor fetch inherits this process-only config from checkout.
|
||
# Reject redirects: http.<url> matching does not re-scope redirected requests.
|
||
count = int(os.environ.get("GIT_CONFIG_COUNT", "0"))
|
||
if count < 0:
|
||
raise ValueError("Invalid Git environment configuration count")
|
||
header = f"http.{remote}.extraheader"
|
||
authorization = base64.b64encode(f"x-access-token:{token}".encode()).decode()
|
||
settings = [(header, ""), (header, f"AUTHORIZATION: basic {authorization}"),
|
||
(f"http.{remote}.followRedirects", "false")]
|
||
environment = {"GIT_CONFIG_COUNT": str(count + len(settings)), "GIT_TERMINAL_PROMPT": "0"}
|
||
for index, (key, value) in enumerate(settings, count):
|
||
environment[f"GIT_CONFIG_KEY_{index}"] = key
|
||
environment[f"GIT_CONFIG_VALUE_{index}"] = value
|
||
return environment
|
||
|
||
|
||
def run_git(directory, *arguments, timeout=None, stdout=None, stderr=None, env=None,
|
||
reclaim_locks=False):
|
||
global closed
|
||
if closed:
|
||
raise RuntimeError("Git owner is closed")
|
||
check_cancelled()
|
||
if git is None:
|
||
raise RuntimeError("Git unavailable")
|
||
# Process ownership alone does not grant metadata ownership: generic callers
|
||
# may use linked worktrees. Only exclusive checkout fetches reclaim locks.
|
||
previous_locks = git_lock_files(directory) if reclaim_locks else None
|
||
command = [git, "-C", directory, *arguments]
|
||
job = None
|
||
child = None
|
||
timed_out = False
|
||
deadline = time.monotonic() + timeout if timeout is not None else None
|
||
try:
|
||
environment = ({**os.environ, **checkout_environment, **(env or {})}
|
||
if checkout_environment or env is not None else None)
|
||
options = {"stdin": subprocess.DEVNULL, "stdout": stdout, "stderr": stderr,
|
||
"env": environment}
|
||
if os.name == "nt":
|
||
job = create_job(None, None)
|
||
limits = ExtendedLimits()
|
||
limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE; no breakaway.
|
||
set_job(job, 9, c.byref(limits), c.sizeof(limits))
|
||
os.set_handle_inheritable(job, True)
|
||
startup = subprocess.STARTUPINFO()
|
||
startup.lpAttributeList = {"handle_list": [job]}
|
||
options.update(startupinfo=startup, close_fds=True)
|
||
# The selected checkout must not inject Python startup code into its owner.
|
||
command = [sys.executable, "-I", "-S", "-c", bootstrap, str(job), *command]
|
||
else:
|
||
options["start_new_session"] = True
|
||
# Signal handlers only latch cancellation, so Popen cannot lose ownership
|
||
# between process creation and saving its handle/group for cleanup.
|
||
child = subprocess.Popen(command, **options)
|
||
if job is not None:
|
||
os.set_handle_inheritable(job, False)
|
||
while child.poll() is None and not cancelled:
|
||
if deadline is not None and time.monotonic() >= deadline:
|
||
timed_out = True
|
||
raise FetchTimeout()
|
||
time.sleep(0.05)
|
||
finally:
|
||
# Failed inspection/cleanup permanently fences this policy process. Only
|
||
# verified extinction permits another command, even after a caught error.
|
||
closed = True
|
||
try:
|
||
if child is not None:
|
||
drain(child, job)
|
||
if previous_locks is not None and (timed_out or cancelled or child.returncode):
|
||
# Forced termination skips Git'\''s lockfile cleanup. This checkout is exclusive;
|
||
# reclaim only newly created locks after tree extinction, never existing locks.
|
||
for lock in sorted(git_lock_files(directory) - previous_locks):
|
||
os.unlink(lock)
|
||
finally:
|
||
if job is not None:
|
||
close_handle(job)
|
||
closed = False
|
||
# Run even while a timeout is unwinding: cancellation received during
|
||
# draining outranks it, but failed cleanup above still outranks both.
|
||
check_cancelled()
|
||
if child.returncode:
|
||
raise GitFailure(child.returncode if child.returncode > 0 else 128 - child.returncode)
|
||
|
||
|
||
def backoff(seconds):
|
||
retry_at = time.monotonic() + seconds
|
||
while time.monotonic() < retry_at:
|
||
check_cancelled()
|
||
time.sleep(0.05)
|
||
|
||
|
||
def fetch(directory, *refs, prune=False, max_attempts=3, depth=1,
|
||
blobless=False, retry_failures=False, retry_codes=()):
|
||
for attempt in range(1, max_attempts + 1):
|
||
try:
|
||
run_git(directory, "-c", "protocol.version=2", "fetch", "--no-tags",
|
||
*(["--prune"] if prune else []), "--no-recurse-submodules", f"--depth={depth}",
|
||
*(["--filter=blob:none"] if blobless else []), "origin", *refs,
|
||
timeout=fetch_timeout_seconds, reclaim_locks=True)
|
||
return
|
||
except (FetchTimeout, GitFailure) as error:
|
||
check_cancelled()
|
||
retryable = isinstance(error, FetchTimeout) or retry_failures or error.code in retry_codes
|
||
if not retryable or attempt == max_attempts:
|
||
raise
|
||
print(f"::warning::checkout fetch failed on attempt {attempt}; retrying", flush=True)
|
||
backoff(5)
|
||
|
||
|
||
def git_output(directory, *arguments, timeout=None, env=None):
|
||
with tempfile.TemporaryFile() as output:
|
||
run_git(directory, *arguments, timeout=timeout, env=env, stdout=output)
|
||
output.seek(0)
|
||
return output.read().decode("utf-8", errors="surrogateescape")
|
||
|
||
|
||
def resolve_ref(ref):
|
||
return git_output(workspace, "rev-parse", ref).strip()
|
||
|
||
|
||
def checkout_selected_ref():
|
||
ref = os.environ["CHECKOUT_REF"]
|
||
fallback = os.environ["CHECKOUT_FALLBACK_REF"]
|
||
manual = os.environ["GITHUB_EVENT_NAME"] == "workflow_dispatch"
|
||
requested = ref if kind == "preflight" and re.fullmatch("[0-9a-f]{40}", ref) else None
|
||
# Prefer the event ref for an exact manual SHA, but detect a ref that moved in the queue.
|
||
if requested and manual and ref == fallback and os.environ.get("CHECKOUT_EVENT_REF"):
|
||
ref = os.environ["CHECKOUT_EVENT_REF"]
|
||
|
||
def fetch_ref(value):
|
||
fetch(workspace, f"+{value}:refs/remotes/origin/checkout", prune=True,
|
||
depth=1 if kind == "preflight" else 2, retry_codes=(124, 137))
|
||
|
||
try:
|
||
fetch_ref(ref)
|
||
except GitFailure as error:
|
||
if error.code in (124, 137) or not manual or os.environ["CHECKOUT_REF"] == fallback:
|
||
raise
|
||
print("::warning::workflow_dispatch target_ref is unavailable; falling back to head SHA", flush=True)
|
||
fetch_ref(fallback)
|
||
if requested:
|
||
resolved = resolve_ref("refs/remotes/origin/checkout")
|
||
if resolved != requested and ref != requested:
|
||
print("::notice::checkout ref moved; fetching requested SHA", flush=True)
|
||
fetch_ref(requested)
|
||
resolved = resolve_ref("refs/remotes/origin/checkout")
|
||
if resolved != requested:
|
||
print("::error::checkout ref did not resolve to the requested SHA", file=sys.stderr)
|
||
raise GitFailure(1)
|
||
if kind == "preflight":
|
||
# Diff-base callers need parent commits/trees, not their blobs.
|
||
try:
|
||
fetch(workspace, resolve_ref("refs/remotes/origin/checkout"), prune=True,
|
||
depth=2, blobless=True, retry_failures=True)
|
||
except (FetchTimeout, GitFailure):
|
||
raise GitFailure(1)
|
||
run_git(workspace, "checkout", "--detach", "refs/remotes/origin/checkout")
|
||
|
||
|
||
def checkout_harness(sha):
|
||
action = ".github/actions/setup-node-env/action.yml"
|
||
node_setup_scripts = ("scripts/lib/pnpm-lockfile-documents.mjs",)
|
||
evidence_scripts = ("scripts/ios-screenshot-evidence.mjs", "scripts/lib/direct-run.mjs", "scripts/ci-static-step.sh")
|
||
platform_scripts = ("scripts/lib/swift-toolchain.sh",)
|
||
upgrade_scripts = ("scripts/lib/release-upgrade-baseline.mjs", "scripts/lib/release-version.mjs")
|
||
npm_lock_scripts = (
|
||
"scripts/ci-npm-lock-admission.mjs",
|
||
"scripts/generate-npm-package-lock.mjs",
|
||
"scripts/generate-npm-package-lock.mts",
|
||
"scripts/changed-lanes.mts",
|
||
"scripts/lib/merge-head-diff-base.mjs",
|
||
)
|
||
if kind == "linux-node" and not os.path.isfile(os.path.join(workspace, action)):
|
||
raise GitFailure(1)
|
||
harness = os.path.join(workspace, ".ci-harness")
|
||
# This owner creates the harness, not candidate source. Keep strict source-status
|
||
# checks useful without hiding tracked edits or similarly named nested paths.
|
||
exclude = os.path.join(workspace, git_output(workspace, "rev-parse", "--git-path", "info/exclude").strip())
|
||
os.makedirs(os.path.dirname(exclude), exist_ok=True)
|
||
with open(exclude, "a+b") as output:
|
||
output.seek(0)
|
||
if output.read().splitlines()[-1:] != [b"/.ci-harness/"]:
|
||
output.write(b"\n/.ci-harness/\n")
|
||
os.makedirs(harness, exist_ok=True)
|
||
if sha == os.environ["WORKFLOW_SHA"]:
|
||
# Export the workflow revision from the freshly populated index, replacing
|
||
# retained harness files without updating the index or trusting later edits.
|
||
pathspecs = [".github/actions", *node_setup_scripts]
|
||
if kind in ("platform", "linux-node"):
|
||
pathspecs += evidence_scripts
|
||
elif kind == "preflight":
|
||
pathspecs += ["scripts/lib/release-context.mjs", "scripts/lib/release-version.mjs"]
|
||
if kind == "platform":
|
||
pathspecs += platform_scripts
|
||
if kind == "linux-node":
|
||
pathspecs += (*upgrade_scripts, *npm_lock_scripts)
|
||
paths = git_output(workspace, "ls-files", "-z", "--", *pathspecs).split("\0")[:-1]
|
||
run_git(workspace, "checkout-index", "--force", f"--prefix={harness}/", "--", *paths)
|
||
else:
|
||
run_git(harness, "init", harness)
|
||
run_git(harness, "remote", "add", "origin", remote)
|
||
sparse_paths = ["/.github/actions/", *(f"/{path}" for path in node_setup_scripts)]
|
||
if kind in ("platform", "linux-node"):
|
||
sparse_paths += [f"/{path}" for path in evidence_scripts]
|
||
if kind == "platform":
|
||
sparse_paths += [f"/{path}" for path in platform_scripts]
|
||
if kind == "linux-node":
|
||
sparse_paths += [f"/{path}" for path in (*upgrade_scripts, *npm_lock_scripts)]
|
||
# Rooted non-cone patterns keep the kind-owned workflow files exact.
|
||
# Sparse first, then blob-less avoids downloading a second repository snapshot.
|
||
run_git(harness, "sparse-checkout", "set", "--no-cone", *sparse_paths)
|
||
fetch(harness, f"+{os.environ['\''WORKFLOW_SHA'\'']}:refs/remotes/origin/ci-harness",
|
||
max_attempts=1, blobless=True)
|
||
# Checkout now materializes the sparse blobs over the network, so it carries the
|
||
# fetch deadline instead of running unbounded like a local checkout.
|
||
run_git(harness, "checkout", "--force", "--detach", os.environ["WORKFLOW_SHA"],
|
||
timeout=fetch_timeout_seconds)
|
||
if not os.path.isfile(os.path.join(harness, action)):
|
||
raise GitFailure(1)
|
||
check_cancelled()
|
||
|
||
|
||
def checkout():
|
||
check_cancelled()
|
||
prerequisites = json.loads(os.environ.get("CHECKOUT_GIT_COMMITS_JSON", "null")) if kind == "linux-node" else None
|
||
if prerequisites is None:
|
||
prerequisites = []
|
||
if not isinstance(prerequisites, list) or any(
|
||
not isinstance(commit, str) or not re.fullmatch("[0-9a-f]{40}", commit)
|
||
for commit in prerequisites
|
||
):
|
||
raise ValueError("Invalid immutable test prerequisite commits")
|
||
if reset:
|
||
os.makedirs(workspace, exist_ok=True)
|
||
# Every earlier Git group has been drained before deleting its workspace.
|
||
subprocess.run(["find", workspace, "-mindepth", "1", "-maxdepth", "1",
|
||
"-exec", "rm", "-rf", "{}", "+"], check=True)
|
||
run_git(workspace, "init", workspace)
|
||
if kind in ("linux-node", "android"):
|
||
run_git(workspace, "config", "--global", "--add", "safe.directory", workspace)
|
||
run_git(workspace, "config", "gc.auto", "0")
|
||
run_git(workspace, "remote", "add", "origin", remote)
|
||
if kind in ("preflight", "manual"):
|
||
checkout_selected_ref()
|
||
if kind == "preflight" and resolve_ref("HEAD") == os.environ["WORKFLOW_SHA"]:
|
||
checkout_harness(os.environ["WORKFLOW_SHA"])
|
||
return
|
||
target = "refs/remotes/origin/ci-target" if kind in ("linux-node", "android") else "refs/remotes/origin/checkout"
|
||
sha = "refs/heads/main" if kind == "clawhub" else os.environ["CHECKOUT_SHA"]
|
||
refs = [f"+{sha}:{target}"]
|
||
base = os.environ.get("CHECKOUT_BASE_SHA") if kind == "linux-node" else None
|
||
if base:
|
||
refs.append(f"+{base}:refs/remotes/origin/ci-ratchet-base")
|
||
# Fetch full reader objects with the authenticated checkout, before its
|
||
# credential scope ends and test workers create historical worktrees.
|
||
refs.extend(prerequisites)
|
||
fetch(workspace, *refs, prune=True, max_attempts=1 if reset else 3,
|
||
retry_codes=(124, 137) if kind == "skills" else ())
|
||
run_git(workspace, "checkout", *(["--force"] if reset else []), "--detach",
|
||
sha if kind in ("linux-node", "android") else target)
|
||
if kind == "android":
|
||
if not os.access(os.path.join(workspace, "apps/android/gradlew"), os.X_OK):
|
||
raise GitFailure(1)
|
||
return
|
||
if kind in ("clawhub", "skills"):
|
||
return
|
||
checkout_harness(sha)
|
||
|
||
|
||
def main():
|
||
global kind, workspace, remote, reset
|
||
if len(sys.argv) > 1:
|
||
if sys.argv[1] == "--policy":
|
||
# The caller supplies trusted policy bytes; imports share this exact
|
||
# owner and its terminal lifecycle state, never a second supervisor.
|
||
sys.modules["ci_git_owner"] = sys.modules[__name__]
|
||
try:
|
||
if sys.argv[2] == "-":
|
||
exec(compile(sys.stdin.read(), "<git-policy>", "exec"), {"__name__": "__main__"})
|
||
else:
|
||
runpy.run_path(sys.argv[2], run_name="__main__")
|
||
finally:
|
||
if closed:
|
||
raise RuntimeError("Git owner is closed")
|
||
check_cancelled()
|
||
return
|
||
if sys.argv[1] not in ("--git", "--checkout-git"):
|
||
raise ValueError("Unknown Git owner command")
|
||
run_git(os.getcwd(), *sys.argv[3:], timeout=float(sys.argv[2]) or None,
|
||
reclaim_locks=sys.argv[1] == "--checkout-git")
|
||
return
|
||
if git is None:
|
||
raise RuntimeError("Git unavailable")
|
||
kind = os.environ.get("CHECKOUT_KIND", "linux-node" if linux else "platform")
|
||
if kind == "prepare":
|
||
raise SystemExit(0)
|
||
workspace = os.environ["GITHUB_WORKSPACE"]
|
||
remote = f"https://github.com/{os.environ['\''CHECKOUT_REPO'\'']}.git"
|
||
# The workflow'\''s token is repository-bound; never lend it to a sibling checkout.
|
||
token = os.environ.pop("CHECKOUT_TOKEN", "")
|
||
if token and os.environ["CHECKOUT_REPO"] == os.environ.get("GITHUB_REPOSITORY"):
|
||
checkout_environment.update(git_auth_environment(remote, token))
|
||
del token
|
||
if kind == "clawhub":
|
||
workspace = os.path.join(workspace, "clawhub-source")
|
||
reset = kind in ("linux-node", "android", "clawhub")
|
||
label = "ClawHub checkout" if kind == "clawhub" else "checkout"
|
||
started_at = time.monotonic()
|
||
try:
|
||
for attempt in range(1, 6 if reset else 2):
|
||
try:
|
||
checkout()
|
||
if reset:
|
||
print(f"{label} attempt {attempt}/5 succeeded", flush=True)
|
||
if kind == "clawhub":
|
||
print(f"{label} completed in {int(time.monotonic() - started_at)}s", flush=True)
|
||
raise SystemExit(0)
|
||
except (FetchTimeout, GitFailure) as error:
|
||
# Only command failures are retryable. Ownership/inspection errors
|
||
# escape to the fail-closed boundary below, never workspace deletion.
|
||
check_cancelled()
|
||
if not reset:
|
||
raise SystemExit(124 if isinstance(error, FetchTimeout) else error.code)
|
||
print(f"::warning::{label} attempt {attempt}/5 failed", flush=True)
|
||
backoff(attempt * 5)
|
||
print(f"{label} failed after 5 attempts", file=sys.stderr)
|
||
raise SystemExit(1)
|
||
finally:
|
||
checkout_environment.clear()
|
||
|
||
|
||
def terminal_diagnostic(error, owner_code):
|
||
# Code identity, not a filename supplied by policy, proves source provenance.
|
||
codes = {id(owner_code): owner_code}
|
||
pending = [owner_code]
|
||
while pending:
|
||
for value in pending.pop().co_consts:
|
||
if type(value) is type(owner_code):
|
||
codes[id(value)] = value
|
||
pending.append(value)
|
||
names = {value: value.__name__ for value in vars(builtins).values()
|
||
if isinstance(value, type) and issubclass(value, BaseException)}
|
||
names.update({FetchTimeout: "FetchTimeout", GitFailure: "GitFailure"})
|
||
records, seen, via = [], set(), "terminal"
|
||
while error is not None and id(error) not in seen and len(records) < 4:
|
||
seen.add(id(error))
|
||
record = {"type": names.get(type(error), "unknown"), "via": via}
|
||
for field in ("errno", "winerror"):
|
||
value = getattr(error, field, None)
|
||
if type(value) is int and -(2 ** 31) <= value < 2 ** 32:
|
||
record[field] = value
|
||
frames, trace = [], error.__traceback__
|
||
# Bound traversal as well as output; malformed metadata cannot stall exit.
|
||
for _ in range(256):
|
||
if trace is None:
|
||
break
|
||
if type(trace) is not TracebackType:
|
||
raise TypeError
|
||
frame, code = trace.tb_frame, trace.tb_frame.f_code
|
||
if frame.f_globals is globals() and id(code) in codes and 0 < trace.tb_lineno < 2 ** 31:
|
||
frames.append({"function": code.co_name[:64], "line": trace.tb_lineno})
|
||
frames = frames[-6:]
|
||
trace = trace.tb_next
|
||
record["owner_frames"] = frames
|
||
if trace is not None:
|
||
record["traceback_truncated"] = 1
|
||
records.append(record)
|
||
cause = error.__cause__
|
||
error, via = (cause, "cause") if cause is not None else (error.__context__, "context")
|
||
return records
|
||
|
||
|
||
if __name__ == "__main__":
|
||
exit_code, terminal_error = 0, None
|
||
try:
|
||
main()
|
||
except FetchTimeout:
|
||
exit_code = 124
|
||
except GitFailure as error:
|
||
exit_code = error.code
|
||
except Exception as error:
|
||
exit_code, terminal_error = 125, error
|
||
# Leave the handler before diagnostics or exit can raise: older Python'\''s
|
||
# implicit exception chaining can loop on an already-cyclic context.
|
||
if terminal_error is not None:
|
||
name, diagnostic = "unknown", "unavailable"
|
||
try:
|
||
records = terminal_diagnostic(terminal_error, sys._getframe().f_code)
|
||
diagnostic = json.dumps(records, separators=(",", ":"))
|
||
name = records[0]["type"]
|
||
except BaseException:
|
||
pass # Diagnostics must never replace the authoritative terminal exit.
|
||
try:
|
||
print(f"::error::Git ownership/setup failed ({name}); refusing reuse or retry", file=sys.stderr)
|
||
print(f"[ci-git-owner] diagnostic={diagnostic}", file=sys.stderr)
|
||
except BaseException:
|
||
pass
|
||
raise SystemExit(exit_code)
|
||
'
|
||
# End generated CI Git owner.
|
||
|
||
- name: Resolve checkout SHA
|
||
id: checkout_ref
|
||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Checkout trusted CI harness
|
||
if: ${{ steps.checkout_ref.outputs.sha != github.workflow_sha }}
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
ref: ${{ github.workflow_sha }}
|
||
path: .ci-harness
|
||
sparse-checkout: |
|
||
/.github/actions/
|
||
/scripts/
|
||
/test/vitest/
|
||
/config/ci-test-timings.json
|
||
/packages/normalization-core/src/stable-stringify.ts
|
||
/src/infra/node-runtime-executable.ts
|
||
sparse-checkout-cone-mode: false
|
||
persist-credentials: false
|
||
|
||
- name: Validate CI qualification dispatch
|
||
id: qualification_dispatch
|
||
if: github.event_name == 'workflow_dispatch' && (inputs.runner_backend != 'default' || inputs.ci_shape == 'main')
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
RELEASE_GATE: ${{ inputs.release_gate }}
|
||
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||
TARGET_REF: ${{ inputs.target_ref }}
|
||
WORKFLOW_REVISION: ${{ github.workflow_sha }}
|
||
RELEASE_SCOPE: ${{ inputs.release_scope }}
|
||
CI_SHAPE: ${{ inputs.ci_shape }}
|
||
REQUESTED_RUNNER_PROFILE: ${{ inputs.runner_backend }}
|
||
HISTORICAL_TARGET_TAG: ${{ inputs.historical_target_tag }}
|
||
RELEASE_CANDIDATE_REF: ${{ inputs.release_candidate_ref }}
|
||
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
[[ "$GITHUB_REPOSITORY" == "openclaw/openclaw" && "$RELEASE_GATE" == "true" && "$TARGET_REF" == "$WORKFLOW_REVISION" && "$TARGET_REF" == "$GITHUB_SHA" ]]
|
||
[[ "$TARGET_REF" =~ ^[0-9a-f]{40}$ && "$PULL_REQUEST_NUMBER" =~ ^[1-9][0-9]*$ && "$RELEASE_SCOPE" == "full" ]]
|
||
[[ -z "$HISTORICAL_TARGET_TAG$RELEASE_CANDIDATE_REF$TARGET_CONTEXT_REF" ]]
|
||
case "$CI_SHAPE" in default | main) ;; *) exit 1 ;; esac
|
||
case "$REQUESTED_RUNNER_PROFILE" in default | hybrid | runson) ;; *) exit 1 ;; esac
|
||
permission="$(gh api "repos/$GITHUB_REPOSITORY/collaborators/$GITHUB_ACTOR/permission" --jq .permission)"
|
||
case "$permission" in admin | maintain | write) ;; *) echo "CI qualification requires a repository maintainer" >&2; exit 1 ;; esac
|
||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PULL_REQUEST_NUMBER" \
|
||
--jq '{state,head:.head.sha,branch:.head.ref,repository:.head.repo.full_name,base:.base.ref,baseRepository:.base.repo.full_name}' > "$RUNNER_TEMP/qualification-pr.json"
|
||
jq -e --arg sha "$TARGET_REF" --arg repo "$GITHUB_REPOSITORY" --arg branch "$GITHUB_REF_NAME" \
|
||
'.state == "open" and .head == $sha and .branch == $branch and .repository == $repo and .baseRepository == $repo and .base == "main"' "$RUNNER_TEMP/qualification-pr.json"
|
||
echo "eligible=true" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Resolve logical runner profile
|
||
id: runner_profile
|
||
env:
|
||
AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association || '' }}
|
||
CONFIGURED_RUNNER_PROFILE: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND }}
|
||
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name || '' }}
|
||
REQUESTED_RUNNER_PROFILE: ${{ inputs.runner_backend || 'default' }}
|
||
QUALIFICATION_DISPATCH: ${{ steps.qualification_dispatch.outputs.eligible || 'false' }}
|
||
CI_SHAPE: ${{ inputs.ci_shape || 'default' }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
runner_profile="${CONFIGURED_RUNNER_PROFILE:-blacksmith}"
|
||
case "$runner_profile" in
|
||
github | hybrid | blacksmith | runson) ;;
|
||
*) echo "OPENCLAW_CI_RUNNER_BACKEND must be github, hybrid, blacksmith, or runson" >&2; exit 1 ;;
|
||
esac
|
||
ci_qualification=false
|
||
ci_shape=default
|
||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && "${QUALIFICATION_DISPATCH:-false}" == "true" ]]; then
|
||
ci_qualification=true
|
||
ci_shape="${CI_SHAPE:-default}"
|
||
if [[ "${REQUESTED_RUNNER_PROFILE:-default}" != "default" ]]; then
|
||
runner_profile="$REQUESTED_RUNNER_PROFILE"
|
||
fi
|
||
elif [[ "${REQUESTED_RUNNER_PROFILE:-default}" != "default" || "${CI_SHAPE:-default}" != "default" ]]; then
|
||
echo "Runner and shape overrides require an admitted CI qualification" >&2
|
||
exit 1
|
||
fi
|
||
runson_requested=false
|
||
if [[ "$runner_profile" == "runson" || "${REQUESTED_RUNNER_PROFILE:-default}" == "runson" ]]; then
|
||
runson_requested=true
|
||
runner_profile=hybrid
|
||
fi
|
||
qualified_runner_profile="$runner_profile"
|
||
hosted_runner_profile_contract=false
|
||
grep -Fq "hosted-runner-profile-contract-v1" .github/workflows/ci.yml &&
|
||
hosted_runner_profile_contract=true
|
||
trusted_pull_request=false
|
||
case "$AUTHOR_ASSOCIATION" in
|
||
OWNER | MEMBER | COLLABORATOR | CONTRIBUTOR) trusted_pull_request=true ;;
|
||
esac
|
||
if [[
|
||
"$hosted_runner_profile_contract" == "true" &&
|
||
(
|
||
"$GITHUB_EVENT_NAME" == "workflow_dispatch" ||
|
||
"$GITHUB_REPOSITORY" != "openclaw/openclaw" ||
|
||
(
|
||
"$GITHUB_EVENT_NAME" == "pull_request" &&
|
||
("$HEAD_REPOSITORY" != "$GITHUB_REPOSITORY" || "$trusted_pull_request" != "true")
|
||
)
|
||
)
|
||
]]; then
|
||
runner_profile=github
|
||
fi
|
||
node_runner_backend="$runner_profile"
|
||
qualification_runner_backend=
|
||
if [[ "$ci_qualification" == "true" && "${GITHUB_RUN_ATTEMPT:-1}" == "1" ]]; then
|
||
runner_profile="$qualified_runner_profile"
|
||
node_runner_backend="$qualified_runner_profile"
|
||
qualification_runner_backend="$qualified_runner_profile"
|
||
fi
|
||
# Ordinary pushes retain hybrid; only admitted qualification may opt in on a main shape.
|
||
if [[ "$runson_requested" == "true" && "$GITHUB_REPOSITORY" == "openclaw/openclaw" && "${GITHUB_RUN_ATTEMPT:-1}" == "1" ]] &&
|
||
grep -Fq "runson-runner-profile-contract-v1" .github/workflows/ci.yml &&
|
||
[[ ( "$GITHUB_EVENT_NAME" == "pull_request" && "$HEAD_REPOSITORY" == "$GITHUB_REPOSITORY" && "$trusted_pull_request" == "true" ) || "$ci_qualification" == "true" ]]; then
|
||
runner_profile=hybrid
|
||
node_runner_backend=runson
|
||
fi
|
||
printf '%s\n' \
|
||
"hosted_runner_profile_contract=$hosted_runner_profile_contract" \
|
||
"ci_qualification=$ci_qualification" \
|
||
"ci_shape=$ci_shape" \
|
||
"qualification_runner_backend=$qualification_runner_backend" \
|
||
"runner_profile=$runner_profile" \
|
||
"node_runner_backend=$node_runner_backend" >> "$GITHUB_OUTPUT"
|
||
- name: Resolve exact diff base
|
||
id: diff_base
|
||
env:
|
||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||
EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha || '' }}
|
||
GH_TOKEN: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && github.token || '' }}
|
||
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||
RELEASE_GATE: ${{ inputs.release_gate }}
|
||
run: |
|
||
set -euo pipefail
|
||
base_sha="$EVENT_BASE_SHA"
|
||
head_sha="$(git rev-parse HEAD)"
|
||
# Full scheduled validation has no change range. Pin the checkout itself
|
||
# for consumers that require a protocol/lockfile comparison ref.
|
||
if [ "$GITHUB_EVENT_NAME" = "schedule" ]; then
|
||
base_sha="$head_sha"
|
||
fi
|
||
if [ "$GITHUB_EVENT_NAME" = "push" ] && [[ "$base_sha" =~ ^0+$ ]]; then
|
||
echo "::error title=ambiguous main push::github.event.before is zero; refusing to infer a diff base for a created or recreated main branch." >&2
|
||
exit 1
|
||
fi
|
||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||
# A long-lived PR event can retain an old base SHA. The tested merge
|
||
# commit's first parent is the exact target tree for this run.
|
||
base_sha="$(node scripts/lib/merge-head-diff-base.mjs \
|
||
--base "$base_sha" --head HEAD --prefer-first-parent)"
|
||
fi
|
||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$RELEASE_GATE" != "true" ]; then
|
||
encoded_ref="$(jq -rn --arg value "refs/heads/${DEFAULT_BRANCH}" '$value | @uri')"
|
||
default_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" --jq .sha)"
|
||
if [[ ! "$default_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||
echo "Could not resolve the default branch head for the manual target." >&2
|
||
exit 1
|
||
fi
|
||
echo "default_sha=$default_sha" >> "$GITHUB_OUTPUT"
|
||
base_sha="$(
|
||
gh api --method GET \
|
||
"repos/${GITHUB_REPOSITORY}/compare/${default_sha}...${head_sha}" \
|
||
--jq '.merge_base_commit.sha'
|
||
)"
|
||
fi
|
||
if [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$RELEASE_GATE" = "true" ]; then
|
||
merge_ref="refs/remotes/origin/release-gate-merge"
|
||
python3 -I -S "$RUNNER_TEMP/ci-git-owner.py" --checkout-git 120 fetch \
|
||
--no-tags --no-recurse-submodules --depth=2 origin \
|
||
"+refs/pull/${PULL_REQUEST_NUMBER}/merge:${merge_ref}"
|
||
release_gate_head="$(git rev-parse "${merge_ref}^2")"
|
||
target_head="$(git rev-parse HEAD)"
|
||
if [ "$release_gate_head" != "$target_head" ]; then
|
||
echo "release_gate pull request head ${release_gate_head} does not match target ${target_head}" >&2
|
||
exit 1
|
||
fi
|
||
base_sha="$(git rev-parse "${merge_ref}^1")"
|
||
head_sha="$(git rev-parse "$merge_ref")"
|
||
fi
|
||
if [[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||
echo "Could not resolve an exact diff base for ${GITHUB_EVENT_NAME}." >&2
|
||
exit 1
|
||
fi
|
||
echo "sha=$base_sha" >> "$GITHUB_OUTPUT"
|
||
echo "head_sha=$head_sha" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Validate historical release target
|
||
id: historical_target
|
||
if: inputs.historical_target_tag != ''
|
||
env:
|
||
EXPECTED_SHA: ${{ steps.checkout_ref.outputs.sha }}
|
||
GH_TOKEN: ${{ github.token }}
|
||
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]+)|(-[1-9][0-9]*))?$ ]]; then
|
||
echo "historical_target_tag must be a canonical OpenClaw release tag." >&2
|
||
exit 1
|
||
fi
|
||
encoded_ref="$(jq -rn --arg value "refs/tags/${HISTORICAL_TARGET_TAG}" '$value | @uri')"
|
||
tag_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" --jq .sha)"
|
||
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 }}
|
||
GH_TOKEN: ${{ github.token }}
|
||
RELEASE_CANDIDATE_REF: ${{ inputs.release_candidate_ref }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ ! "$RELEASE_CANDIDATE_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+(-[1-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
|
||
encoded_ref="$(jq -rn --arg value "refs/heads/${RELEASE_CANDIDATE_REF}" '$value | @uri')"
|
||
branch_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" --jq .sha)"
|
||
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: Validate target context
|
||
id: target_context_target
|
||
if: inputs.target_context_ref != ''
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
|
||
TARGET_REF: ${{ inputs.target_ref }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ ! "$TARGET_CONTEXT_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+(-[1-9][0-9]*)?|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then
|
||
echo "target_context_ref must be a canonical OpenClaw release branch." >&2
|
||
exit 1
|
||
fi
|
||
if [[ ! "$TARGET_REF" =~ ^[0-9a-f]{40}$ ]]; then
|
||
echo "target_context_ref requires target_ref to be a full commit SHA." >&2
|
||
exit 1
|
||
fi
|
||
encoded_ref="$(jq -rn --arg value "refs/heads/${TARGET_CONTEXT_REF}" '$value | @uri')"
|
||
branch_sha="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${encoded_ref}" --jq .sha)"
|
||
if [[ ! "$branch_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||
echo "target_context_ref branch ${TARGET_CONTEXT_REF} does not exist." >&2
|
||
exit 1
|
||
fi
|
||
comparison_status="$(
|
||
gh api "repos/${GITHUB_REPOSITORY}/compare/${TARGET_REF}...${branch_sha}" --jq .status
|
||
)"
|
||
if [[ "$comparison_status" != "ahead" && "$comparison_status" != "identical" ]]; then
|
||
echo "target_ref must be the declared release branch head or one of its ancestors." >&2
|
||
exit 1
|
||
fi
|
||
echo "eligible=true" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Setup manifest TypeScript runtime
|
||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||
with:
|
||
node-version: ${{ env.NODE_VERSION }}
|
||
|
||
- name: Resolve release correction base
|
||
id: release_correction_base
|
||
if: github.event_name == 'workflow_dispatch' && (inputs.release_scope == 'npm-beta' || inputs.release_scope == 'npm-stable')
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
TARGET_CONTEXT_REF: ${{ steps.target_context_target.outputs.eligible == 'true' && inputs.target_context_ref || steps.historical_target.outputs.eligible == 'true' && inputs.historical_target_tag || '' }}
|
||
run: |
|
||
# Keep the token outside the manifest step, which imports candidate-owned code.
|
||
node --input-type=module <<'NODE'
|
||
import { execFileSync } from 'node:child_process';
|
||
import { appendFileSync, readFileSync } from 'node:fs';
|
||
import { resolveReleaseContextIdentity } from './.ci-harness/scripts/lib/release-context.mjs';
|
||
const version = JSON.parse(readFileSync('package.json', 'utf8')).version;
|
||
const identity = resolveReleaseContextIdentity(process.env.TARGET_CONTEXT_REF, version);
|
||
if (identity?.baseTag) {
|
||
const ref = encodeURIComponent(`refs/tags/${identity.baseTag}`);
|
||
const sha = execFileSync('gh', ['api', `repos/${process.env.GITHUB_REPOSITORY}/commits/${ref}`, '--jq', '.sha'], { encoding: 'utf8' }).trim();
|
||
if (!/^[0-9a-f]{40}$/u.test(sha)) throw new Error(`Could not resolve correction base ${identity.baseTag}.`);
|
||
appendFileSync(process.env.GITHUB_OUTPUT, `sha=${sha}\n`);
|
||
}
|
||
NODE
|
||
|
||
- name: Classify candidate cache trust
|
||
id: candidate_trust
|
||
env:
|
||
CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
|
||
DEFAULT_SHA: ${{ steps.diff_base.outputs.default_sha }}
|
||
HISTORICAL_TARGET: ${{ steps.historical_target.outputs.eligible || 'false' }}
|
||
RELEASE_CANDIDATE_TARGET: ${{ steps.release_candidate_target.outputs.eligible || 'false' }}
|
||
RELEASE_GATE: ${{ inputs.release_gate && 'true' || 'false' }}
|
||
TARGET_CONTEXT_TARGET: ${{ steps.target_context_target.outputs.eligible || 'false' }}
|
||
TARGET_REF: ${{ inputs.target_ref }}
|
||
WORKFLOW_REVISION: ${{ github.workflow_sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
trust=untrusted
|
||
cache_mode=off
|
||
cache_write_allowed=false
|
||
|
||
if [[ ( "$GITHUB_EVENT_NAME" == "push" || "$GITHUB_EVENT_NAME" == "schedule" ) && "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||
trust=main
|
||
cache_mode=restore
|
||
cache_write_allowed=true
|
||
elif [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||
trust=pull-request
|
||
cache_mode=restore
|
||
elif [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" ]]; then
|
||
if [[ "$RELEASE_GATE" == "true" ]]; then
|
||
trust=pull-request
|
||
cache_mode=restore
|
||
elif [[
|
||
"$HISTORICAL_TARGET" == "true" ||
|
||
"$RELEASE_CANDIDATE_TARGET" == "true" ||
|
||
"$TARGET_CONTEXT_TARGET" == "true"
|
||
]]; then
|
||
trust=release
|
||
cache_mode=restore
|
||
cache_write_allowed=true
|
||
elif [[ -n "$DEFAULT_SHA" && "$CHECKOUT_REVISION" == "$DEFAULT_SHA" ]]; then
|
||
trust=main
|
||
cache_mode=restore
|
||
cache_write_allowed=true
|
||
elif [[ -z "$TARGET_REF" && "$CHECKOUT_REVISION" == "$WORKFLOW_REVISION" ]]; then
|
||
trust=workflow
|
||
cache_mode=restore
|
||
fi
|
||
fi
|
||
|
||
{
|
||
echo "trust=$trust"
|
||
echo "cache_mode=$cache_mode"
|
||
echo "cache_write_allowed=$cache_write_allowed"
|
||
} >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Ensure preflight base commit
|
||
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
|
||
uses: ./.ci-harness/.github/actions/ensure-base-commit
|
||
with:
|
||
base-sha: ${{ steps.diff_base.outputs.sha }}
|
||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||
|
||
- name: Detect docs-only changes
|
||
id: docs_scope
|
||
if: (github.event_name != 'workflow_dispatch' && github.event_name != 'schedule') || steps.runner_profile.outputs.ci_qualification == 'true'
|
||
uses: ./.ci-harness/.github/actions/detect-docs-changes
|
||
with:
|
||
base-sha: ${{ steps.diff_base.outputs.sha }}
|
||
|
||
- name: Detect changed scopes
|
||
id: changed_scope
|
||
if: (github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' && steps.docs_scope.outputs.docs_only != 'true') || (github.event_name == 'workflow_dispatch' && inputs.release_gate)
|
||
shell: bash
|
||
env:
|
||
OPENCLAW_ALLOW_RELEASE_GENERATED_MIX: ${{ github.event_name == 'push' || github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == github.repository }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
if [ "${{ github.event_name }}" = "push" ]; then
|
||
BASE="${{ steps.diff_base.outputs.sha }}"
|
||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||
elif [ "${{ github.event_name }}" = "pull_request" ]; then
|
||
BASE="${{ steps.diff_base.outputs.sha }}"
|
||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD --merge-head-first-parent
|
||
else
|
||
BASE="${{ steps.diff_base.outputs.sha }}"
|
||
HEAD_SHA="${{ steps.diff_base.outputs.head_sha }}"
|
||
node scripts/ci-changed-scope.mjs --base "$BASE" --head "$HEAD_SHA"
|
||
fi
|
||
|
||
- name: Setup manifest pnpm
|
||
if: github.event_name == 'workflow_dispatch' && steps.checkout_ref.outputs.sha != github.workflow_sha
|
||
uses: ./.ci-harness/.github/actions/setup-pnpm-store-cache
|
||
with:
|
||
cache-mode: ${{ steps.candidate_trust.outputs.cache_mode }}
|
||
node-version: ${{ env.NODE_VERSION }}
|
||
|
||
- name: Install manifest dependencies
|
||
if: github.event_name == 'workflow_dispatch' && steps.checkout_ref.outputs.sha != github.workflow_sha
|
||
run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts
|
||
|
||
- name: Check hybrid hosted assignment health
|
||
id: hosted_health
|
||
if: contains(fromJSON('["hybrid","runson"]'), (steps.runner_profile.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && steps.runner_profile.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && steps.changed_scope.outputs.run_node == 'true' && steps.changed_scope.outputs.run_node_fast_only != 'true' && (steps.qualification_dispatch.outputs.eligible == 'true' || (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && steps.changed_scope.outputs.run_windows == 'true' && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)))
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
run: |
|
||
node --input-type=module <<'EOF'
|
||
import { appendFileSync } from "node:fs";
|
||
// Current PRs and validated exact-head qualification share this admission owner.
|
||
import { inspectHybridHostedHealth } from "./scripts/lib/ci-hybrid-hosted-health.mts";
|
||
const health = await inspectHybridHostedHealth({
|
||
repository: process.env.GITHUB_REPOSITORY,
|
||
runId: process.env.GITHUB_RUN_ID,
|
||
token: process.env.GH_TOKEN,
|
||
});
|
||
appendFileSync(process.env.GITHUB_OUTPUT, `healthy=${health.healthy}\n`);
|
||
const summary = `Hybrid hosted assignment: ${health.reason}; ${health.sampledJobs} sampled jobs, maximum wait ${health.maxWaitSeconds}s.`;
|
||
console.log(health.healthy ? summary : `::warning::${summary} Retaining additional checks on Blacksmith.`);
|
||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n\n`);
|
||
EOF
|
||
|
||
- name: Build CI manifest
|
||
id: manifest
|
||
env:
|
||
OPENCLAW_CI_DOCS_ONLY: ${{ github.event_name == 'schedule' && 'false' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'false' || steps.docs_scope.outputs.docs_only }}
|
||
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.docs_scope.outputs.docs_changed }}
|
||
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
|
||
OPENCLAW_CI_NODE_TEST_DATA_ONLY: ${{ steps.changed_scope.outputs.node_test_data_only || 'false' }}
|
||
# release_gate retains the PR Apple scope.
|
||
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
|
||
OPENCLAW_CI_RUN_MACOS_NODE: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_macos_node || 'false' }}
|
||
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
|
||
OPENCLAW_CI_RUN_IOS_SCREENSHOTS: ${{ steps.changed_scope.outputs.run_ios_screenshots || 'false' }}
|
||
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && ((inputs.release_gate && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true')) || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
|
||
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
|
||
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
|
||
OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'false' || steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }}
|
||
OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }}
|
||
OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }}
|
||
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
|
||
OPENCLAW_CI_RUN_UI_TESTS: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }}
|
||
OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'schedule' && 'true' || github.event_name == 'workflow_dispatch' && (steps.runner_profile.outputs.node_runner_backend != 'runson' && steps.runner_profile.outputs.ci_qualification != 'true') && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }}
|
||
OPENCLAW_CI_CHANGED_PATHS_FILE: ${{ steps.changed_scope.outputs.changed_paths_file }}
|
||
OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_scope.outputs.changed_paths_json || 'null' }}
|
||
OPENCLAW_CI_CHANGED_BASE: ${{ steps.diff_base.outputs.sha }}
|
||
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
|
||
OPENCLAW_CI_CORRECTION_BASE_SHA: ${{ steps.release_correction_base.outputs.sha }}
|
||
OPENCLAW_CI_HISTORICAL_TARGET: ${{ steps.historical_target.outputs.eligible || 'false' }}
|
||
OPENCLAW_CI_RELEASE_GATE: ${{ inputs.release_gate && 'true' || 'false' }}
|
||
OPENCLAW_CI_RELEASE_FAST_LANE_LABEL: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'release-fast-lane') && 'true' || 'false' }}
|
||
OPENCLAW_CI_RELEASE_SCOPE: ${{ inputs.release_scope || 'full' }}
|
||
OPENCLAW_CI_VALIDATION_TIER: ${{ github.event_name == 'schedule' && 'main' || inputs.validation_tier || 'full' }}
|
||
OPENCLAW_CI_PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||
OPENCLAW_CI_TARGET_REF: ${{ inputs.target_ref }}
|
||
OPENCLAW_CI_TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }}
|
||
OPENCLAW_CI_HISTORICAL_TARGET_TAG: ${{ inputs.historical_target_tag }}
|
||
OPENCLAW_CI_RELEASE_CANDIDATE_TARGET: ${{ steps.release_candidate_target.outputs.eligible || 'false' }}
|
||
OPENCLAW_CI_TARGET_CONTEXT_TARGET: ${{ steps.target_context_target.outputs.eligible || 'false' }}
|
||
OPENCLAW_CI_WORKFLOW_REVISION: ${{ github.workflow_sha }}
|
||
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
|
||
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
|
||
OPENCLAW_CI_RUNNER_PROFILE: ${{ steps.runner_profile.outputs.runner_profile }}
|
||
OPENCLAW_CI_NODE_RUNNER_BACKEND: ${{ steps.runner_profile.outputs.node_runner_backend }}
|
||
OPENCLAW_CI_QUALIFICATION: ${{ steps.runner_profile.outputs.ci_qualification }}
|
||
OPENCLAW_CI_SHAPE: ${{ steps.runner_profile.outputs.ci_shape }}
|
||
OPENCLAW_CI_RUNNER_BACKEND: ${{ (steps.runner_profile.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && steps.runner_profile.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) }}
|
||
OPENCLAW_CI_HOSTED_HEALTHY: ${{ steps.hosted_health.outputs.healthy }}
|
||
OPENCLAW_CI_AUTHOR_ASSOCIATION: ${{ github.event.pull_request.author_association }}
|
||
OPENCLAW_CI_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
|
||
OPENCLAW_CI_PR_AUTHOR_TYPE: ${{ github.event.pull_request.user.type }}
|
||
OPENCLAW_CI_FULL: ${{ contains(github.event.pull_request.labels.*.name, 'ci:full') }}
|
||
run: |
|
||
manifest_node_args=()
|
||
if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] &&
|
||
[ "$OPENCLAW_CI_CHECKOUT_REVISION" != "$OPENCLAW_CI_WORKFLOW_REVISION" ]; then
|
||
manifest_node_args+=(--import tsx)
|
||
fi
|
||
node "${manifest_node_args[@]}" --input-type=module <<'EOF'
|
||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||
import { matchesGlob } from "node:path";
|
||
import { resolveTestGitCommits } from "./.ci-harness/.github/actions/git-owner/test-prerequisites.mjs";
|
||
import { resolveReleaseContextIdentity } from "./.ci-harness/scripts/lib/release-context.mjs";
|
||
import { classifyReleaseTrain, parseReleaseVersion } from "./.ci-harness/scripts/lib/release-version.mjs";
|
||
|
||
const workflowEventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
|
||
const ciQualification = workflowEventName === "workflow_dispatch" &&
|
||
process.env.OPENCLAW_CI_RELEASE_GATE === "true" &&
|
||
(process.env.OPENCLAW_CI_QUALIFICATION === "true" ||
|
||
process.env.OPENCLAW_CI_NODE_RUNNER_BACKEND === "runson");
|
||
const mainQualification = ciQualification && process.env.OPENCLAW_CI_SHAPE === "main";
|
||
const eventName = ciQualification ? mainQualification ? "push" : "pull_request" : workflowEventName;
|
||
const eventRef = mainQualification ? "refs/heads/main" : process.env.GITHUB_REF;
|
||
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 targetContextTarget =
|
||
eventName === "workflow_dispatch" &&
|
||
process.env.OPENCLAW_CI_TARGET_CONTEXT_TARGET === "true" &&
|
||
checkoutRevision !== workflowRevision;
|
||
const compatibilityTarget =
|
||
historicalTarget || releaseCandidateTarget || targetContextTarget;
|
||
const frozenTarget =
|
||
eventName === "workflow_dispatch" && checkoutRevision !== workflowRevision;
|
||
|
||
// Frozen releases use current trusted shard budgets while discovery
|
||
// and test execution keep the candidate checkout as their root.
|
||
const nodeTestPlanPath = frozenTarget
|
||
? "./.ci-harness/scripts/lib/ci-node-test-plan.mts"
|
||
: existsSync("./scripts/lib/ci-node-test-plan.mts")
|
||
? "./scripts/lib/ci-node-test-plan.mts"
|
||
: "./scripts/lib/ci-node-test-plan.mjs";
|
||
const nodeTestPlan = await import(nodeTestPlanPath);
|
||
const createNodeTestPlan =
|
||
typeof nodeTestPlan.createNodeTestShardBundles === "function"
|
||
? nodeTestPlan.createNodeTestShardBundles
|
||
: compatibilityTarget
|
||
? nodeTestPlan.createNodeTestShards
|
||
: undefined;
|
||
if (typeof createNodeTestPlan !== "function") {
|
||
throw new Error("CI target does not export a supported Node test shard planner");
|
||
}
|
||
let sourceChannelTestEnv = nodeTestPlan.SOURCE_CHANNEL_TEST_POLICY?.env;
|
||
if (!sourceChannelTestEnv) {
|
||
if (!frozenTarget || !compatibilityTarget) {
|
||
throw new Error("Current CI target does not export SOURCE_CHANNEL_TEST_POLICY");
|
||
}
|
||
// Named frozen targets retain the channel policy that predates this export.
|
||
sourceChannelTestEnv = {
|
||
NODE_OPTIONS: "--max-old-space-size=8192",
|
||
OPENCLAW_VITEST_MAX_WORKERS: "1",
|
||
};
|
||
}
|
||
|
||
const importTargetPlan = async (path) => {
|
||
if (existsSync(path)) {
|
||
return import(path);
|
||
}
|
||
if (!compatibilityTarget) {
|
||
throw new Error(`Current CI target does not provide ${path}`);
|
||
}
|
||
return {};
|
||
};
|
||
const changedNodeTestPlan = await importTargetPlan(
|
||
existsSync("./scripts/lib/ci-changed-node-test-plan.mts")
|
||
? "./scripts/lib/ci-changed-node-test-plan.mts"
|
||
: "./scripts/lib/ci-changed-node-test-plan.mjs",
|
||
);
|
||
const dockerSeedPlan = existsSync("./scripts/lib/ci-docker-seed-plan.mts")
|
||
? await import("./scripts/lib/ci-docker-seed-plan.mts")
|
||
: {};
|
||
const channelContractPlan = await importTargetPlan(
|
||
existsSync("./scripts/lib/channel-contract-test-plan.mts")
|
||
? "./scripts/lib/channel-contract-test-plan.mts"
|
||
: "./scripts/lib/channel-contract-test-plan.mjs",
|
||
);
|
||
const windowsTestPlan = existsSync("./scripts/lib/ci-windows-test-plan.mts")
|
||
? await import("./scripts/lib/ci-windows-test-plan.mts")
|
||
: null;
|
||
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(
|
||
existsSync("./scripts/lib/plugin-contract-test-plan.mts")
|
||
? "./scripts/lib/plugin-contract-test-plan.mts"
|
||
: "./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 });
|
||
// Share setup without combining the target planner's process envelopes.
|
||
const createContractMatrix = (shards, task) => createMatrix(
|
||
frozenTarget
|
||
? shards.map((shard) => ({ ...shard, task, groups: [shard] }))
|
||
: shards.length > 0 ? [{ checkName: `checks-fast-${task}`, task, groups: shards }] : [],
|
||
);
|
||
const outputPath = process.env.GITHUB_OUTPUT;
|
||
const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
|
||
const packageScripts = packageJson.scripts ?? {};
|
||
const hasPackageScript = (name) => typeof packageScripts[name] === "string";
|
||
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
|
||
const changedPaths = (() => {
|
||
try {
|
||
const manifestPath = process.env.OPENCLAW_CI_CHANGED_PATHS_FILE;
|
||
// Frozen target producers can predate the complete file transport.
|
||
const value = JSON.parse(manifestPath
|
||
? readFileSync(manifestPath, "utf8")
|
||
: process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "null");
|
||
return Array.isArray(value) && value.every((path) => typeof path === "string")
|
||
? value
|
||
: null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
})();
|
||
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
|
||
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
|
||
const releaseGate = parseBoolean(process.env.OPENCLAW_CI_RELEASE_GATE) && !ciQualification;
|
||
const compactPullRequest = isCanonicalRepository && eventName === "pull_request";
|
||
const runtimePullRequest = isCanonicalRepository && (compactPullRequest || releaseGate);
|
||
// Exact-head release gates substitute for PR CI. Ordinary manual CI
|
||
// is Full Release Validation's owner for the complete process proofs.
|
||
const runProofTier = eventName !== "pull_request" && !releaseGate;
|
||
const releaseScope = process.env.OPENCLAW_CI_RELEASE_SCOPE ?? "full";
|
||
const validationTier = process.env.OPENCLAW_CI_VALIDATION_TIER ?? "full";
|
||
const mainValidation = validationTier === "main";
|
||
if (validationTier !== "full" && !mainValidation) {
|
||
throw new Error("validation_tier must be full or main");
|
||
}
|
||
if (mainValidation && (
|
||
!["workflow_dispatch", "schedule"].includes(workflowEventName) || !isCanonicalRepository ||
|
||
frozenTarget || compatibilityTarget || ciQualification || releaseGate ||
|
||
releaseScope !== "full" || process.env.OPENCLAW_CI_PULL_REQUEST_NUMBER
|
||
)) {
|
||
throw new Error("The main validation tier requires canonical same-revision manual or scheduled validation with full scope");
|
||
}
|
||
const npmQualification = releaseScope === "npm-beta" || releaseScope === "npm-stable";
|
||
const fullNativeValidation =
|
||
eventName === "workflow_dispatch" && !mainValidation && !releaseGate && releaseScope === "full";
|
||
if (releaseScope !== "full" && !npmQualification) {
|
||
throw new Error("release_scope must be full, npm-beta, or npm-stable");
|
||
}
|
||
if (npmQualification) {
|
||
const packageVersion = String(packageJson.version ?? "");
|
||
const parsedVersion = parseReleaseVersion(packageVersion);
|
||
const expectedTrain = releaseScope === "npm-beta" ? "beta" : "stable";
|
||
const contextRef = process.env.OPENCLAW_CI_TARGET_CONTEXT_TARGET === "true"
|
||
? process.env.OPENCLAW_CI_TARGET_CONTEXT_REF
|
||
: historicalTargetApproved ? process.env.OPENCLAW_CI_HISTORICAL_TARGET_TAG : "";
|
||
if (
|
||
eventName !== "workflow_dispatch" || !isCanonicalRepository || releaseGate ||
|
||
process.env.OPENCLAW_CI_PULL_REQUEST_NUMBER ||
|
||
!/^[0-9a-f]{40}$/u.test(process.env.OPENCLAW_CI_TARGET_REF ?? "") ||
|
||
process.env.OPENCLAW_CI_TARGET_REF !== checkoutRevision ||
|
||
!parsedVersion || parsedVersion.version !== packageVersion ||
|
||
classifyReleaseTrain(parsedVersion) !== expectedTrain
|
||
) {
|
||
throw new Error(`release_scope ${releaseScope} requires an exact ${expectedTrain} target with validated matching release context and no PR release gate or pull_request_number`);
|
||
}
|
||
let identity;
|
||
try {
|
||
identity = resolveReleaseContextIdentity(contextRef ?? "", packageVersion);
|
||
} catch (error) {
|
||
throw new Error(`release_scope ${releaseScope}: ${error.message}`);
|
||
}
|
||
if (!identity || identity.kind === "extended-stable branch") {
|
||
throw new Error(`release_scope ${releaseScope} requires a matching regular release branch or tag`);
|
||
}
|
||
if (identity.baseTag) {
|
||
// Base-package corrections reuse the base tag's exact source; ancestry alone is insufficient.
|
||
if (process.env.OPENCLAW_CI_CORRECTION_BASE_SHA !== checkoutRevision) {
|
||
throw new Error(`release_scope ${releaseScope} correction base ${identity.baseTag} does not resolve to ${checkoutRevision}`);
|
||
}
|
||
}
|
||
}
|
||
const toolingOwnerChange =
|
||
eventName === "pull_request" && isCanonicalRepository && changedPaths !== null &&
|
||
!docsOnly &&
|
||
typeof nodeTestPlan.isToolingTestOwnerPath === "function" &&
|
||
changedPaths.some(nodeTestPlan.isToolingTestOwnerPath);
|
||
const nodeDataOnly = eventName === "pull_request" && parseBoolean(process.env.OPENCLAW_CI_NODE_TEST_DATA_ONLY);
|
||
const nativeGeneratedOnly = workflowEventName === "pull_request" &&
|
||
isCanonicalRepository && !parseBoolean(process.env.OPENCLAW_CI_FULL) &&
|
||
process.env.OPENCLAW_CI_HEAD_REPOSITORY === process.env.OPENCLAW_CI_REPOSITORY &&
|
||
process.env.OPENCLAW_CI_PR_AUTHOR_TYPE === "Bot" &&
|
||
(await import("./scripts/lib/ci-native-generated-scope.mjs")).isNativeGeneratedOnlyChange(changedPaths);
|
||
const runNode =
|
||
!nodeDataOnly && !nativeGeneratedOnly && ((parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly) || toolingOwnerChange);
|
||
const runNodeFastOnly =
|
||
runNode && !runtimePullRequest && !toolingOwnerChange && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
|
||
const runNodeFull = runNode && !runNodeFastOnly;
|
||
// release-fast-lane: label-admitted narrow gate for release tooling PRs.
|
||
// Canonical PR CI only; declined runs keep every ordinary decision.
|
||
const releaseFastLaneLabel = parseBoolean(process.env.OPENCLAW_CI_RELEASE_FAST_LANE_LABEL);
|
||
const releaseFastLaneScope = !releaseFastLaneLabel
|
||
? null
|
||
: !(eventName === "pull_request" && isCanonicalRepository && process.env.OPENCLAW_CI_HEAD_REPOSITORY === process.env.OPENCLAW_CI_REPOSITORY && runNodeFull && !frozenTarget && !compatibilityTarget && !releaseGate && !docsOnly)
|
||
? { eligible: false, reason: "applies only to same-repository pull request CI with full Node routing" }
|
||
: typeof changedNodeTestPlan.resolveReleaseFastLaneScope !== "function"
|
||
? { eligible: false, reason: "CI target lacks the release fast lane selector" }
|
||
: changedNodeTestPlan.resolveReleaseFastLaneScope(changedPaths);
|
||
const releaseFastLane = releaseFastLaneScope?.eligible === true;
|
||
const runCheck = runNodeFull || (!docsOnly && nodeDataOnly && (
|
||
changedPaths === null || changedPaths.some((path) => /\.[cm]?tsx?$/u.test(path))
|
||
));
|
||
const runnerProfile = process.env.OPENCLAW_CI_RUNNER_PROFILE ?? "blacksmith";
|
||
// Eligible paths share the consumer selector; hosted rows intersect
|
||
// those consumers with their canonical stripes.
|
||
let changedCoreTestPaths;
|
||
if (eventName === "pull_request" && runCheck && !frozenTarget && changedPaths &&
|
||
existsSync("scripts/changed-lanes.mts") &&
|
||
[
|
||
["scripts/run-tsgo-core-test-shards.mts", "--changed-paths-json"],
|
||
["scripts/run-additional-boundary-checks.mts", "--core-test-boundary-owner=test-types"],
|
||
].every(([file, capability]) => existsSync(file) && readFileSync(file, "utf8").includes(capability))) {
|
||
const lanes = await import("./scripts/changed-lanes.mts");
|
||
if (typeof lanes.getChangedCoreTestPaths === "function") {
|
||
const coreTestPaths = lanes.getChangedCoreTestPaths(lanes.detectChangedLanes(changedPaths));
|
||
// Deleted leaves need the full plan.
|
||
if (coreTestPaths?.length && coreTestPaths.every((path) => existsSync(path))) {
|
||
changedCoreTestPaths = coreTestPaths;
|
||
}
|
||
}
|
||
}
|
||
|
||
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 proposedCheckScope = runtimePullRequest &&
|
||
(!frozenTarget || releaseGate) && !compatibilityTarget && changedPaths?.length &&
|
||
existsSync("scripts/lib/ci-check-family-scope.mts")
|
||
? (await import("./scripts/lib/ci-check-family-scope.mts")).resolveCiCheckFamilyScope(changedPaths)
|
||
: null;
|
||
const pluginContractShards = !runtimePullRequest && ((runNodeFull && !releaseFastLane) || runNodeFastPluginContracts)
|
||
? createPluginContractTestShards() : [];
|
||
const channelContractShards = !runtimePullRequest && runNodeFull && !releaseFastLane
|
||
? createChannelContractTestShards() : [];
|
||
const runMacos =
|
||
!nativeGeneratedOnly && parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository && !releaseFastLane;
|
||
// Older selected checkouts report only run_macos; retain their native Node coverage.
|
||
const runMacosNode = runMacos ||
|
||
(!nativeGeneratedOnly && parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS_NODE) && !docsOnly && isCanonicalRepository && !releaseFastLane);
|
||
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 =
|
||
!nativeGeneratedOnly &&
|
||
parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) &&
|
||
!releaseFastLane &&
|
||
!npmQualification &&
|
||
!docsOnly &&
|
||
isCanonicalRepository &&
|
||
(!frozenTarget ||
|
||
supportsCurrentIosCi ||
|
||
(releaseCandidateTarget && supportsIosBuild));
|
||
const runAndroid =
|
||
!nativeGeneratedOnly && parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !npmQualification &&
|
||
!docsOnly && isCanonicalRepository && !releaseFastLane;
|
||
// Frozen targets may predate this class; current source must still fail
|
||
// native validation if the required test disappears.
|
||
const runAndroidAccessNative = runAndroid && !compatibilityTarget &&
|
||
(!frozenTarget || existsSync(
|
||
"apps/android/app/src/androidTest/java/ai/openclaw/app/gateway/CloudflareAccessNativeTest.kt",
|
||
));
|
||
let runWindows =
|
||
parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) &&
|
||
!releaseFastLane &&
|
||
!docsOnly &&
|
||
!runNodeFastOnly &&
|
||
isCanonicalRepository;
|
||
const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly && !releaseFastLane;
|
||
const runControlUiI18n =
|
||
parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly && !releaseFastLane;
|
||
let runUiTests = parseBoolean(process.env.OPENCLAW_CI_RUN_UI_TESTS) && !docsOnly && !nodeDataOnly && !releaseFastLane;
|
||
// The UI owner graph also covers protocol and Gateway-served inputs
|
||
// outside ui/. Ordinary UI test leaves retain their cheaper unit owner.
|
||
const selectUiE2eFamily = (family) => !docsOnly && !nodeDataOnly && !releaseFastLane && (
|
||
runtimePullRequest && (!frozenTarget || releaseGate) && !compatibilityTarget &&
|
||
changedPaths !== null && typeof changedNodeTestPlan.hasUiE2eAffectingChange === "function"
|
||
? changedNodeTestPlan.hasUiE2eAffectingChange(changedPaths, { family })
|
||
: runUiTests
|
||
);
|
||
let runControlUiE2e = selectUiE2eFamily("control-ui");
|
||
let runBrowserExtensionE2e = selectUiE2eFamily("browser-extension");
|
||
let runUiRealGateway = selectUiE2eFamily("real-gateway");
|
||
let runUiE2e = runControlUiE2e || runBrowserExtensionE2e;
|
||
const nodeRunnerBackend = process.env.OPENCLAW_CI_NODE_RUNNER_BACKEND || runnerProfile;
|
||
const usesHostedRunnerProfile =
|
||
runnerProfile === "github" || runnerProfile === "hybrid";
|
||
const supportsUiE2eProjects =
|
||
existsSync("test/vitest/vitest.ui-e2e.config.ts") &&
|
||
readFileSync("test/vitest/vitest.ui-e2e.config.ts", "utf8")
|
||
.includes("ui-e2e-projects-contract-v1");
|
||
const includeReleaseOnlyUiTests =
|
||
!mainValidation && ((eventName === "workflow_dispatch" && !releaseGate) ||
|
||
!isCanonicalRepository || compatibilityTarget || changedPaths === null);
|
||
// Ordinary CI shares eight 16-class browser jobs; full inventories retain
|
||
// twelve. Private source-server projects keep their own one-worker limit.
|
||
const compactUiE2e = supportsUiE2eProjects && !includeReleaseOnlyUiTests;
|
||
let uiE2eJobCount = compactUiE2e ? 9 : supportsUiE2eProjects
|
||
? 13
|
||
: usesHostedRunnerProfile ? 14 : 4;
|
||
// The logical profile already owns contributor routing. Reuse the
|
||
// four-part plan only for normal hybrid first attempts on Blacksmith.
|
||
const compactHybridQaSmoke = runnerProfile === "hybrid" &&
|
||
isCanonicalRepository &&
|
||
(eventName === "push" || eventName === "pull_request" || mainValidation) &&
|
||
process.env.GITHUB_RUN_ATTEMPT === "1";
|
||
const qaSmokeCiPartCount = usesHostedRunnerProfile && !compactHybridQaSmoke ? 6 : 4;
|
||
const supportsNativeI18n =
|
||
hasPackageScript("native:i18n:check") &&
|
||
hasPackageScript("android:i18n:check") &&
|
||
hasPackageScript("apple:i18n:check");
|
||
const runNativeI18n =
|
||
parseBoolean(process.env.OPENCLAW_CI_RUN_NATIVE_I18N) &&
|
||
!releaseFastLane &&
|
||
!npmQualification &&
|
||
!docsOnly &&
|
||
(!frozenTarget || supportsNativeI18n);
|
||
const targetWorkflow = existsSync(".github/workflows/ci.yml")
|
||
? readFileSync(".github/workflows/ci.yml", "utf8")
|
||
: "";
|
||
const supportsOpenClawKitTests = targetWorkflow.includes("openclawkit-tests-contract-v1");
|
||
const supportsCurrentAndroidCi = targetWorkflow.includes("android-ci-contract-v2");
|
||
const supportsDockerSeedE2e = targetWorkflow.includes("docker-seed-e2e-contract-v1");
|
||
const useCompatibleAndroidCi = compatibilityTarget && !supportsCurrentAndroidCi;
|
||
const androidTestTier = !fullNativeValidation && !useCompatibleAndroidCi;
|
||
// Unit tests do not compile the benchmark. Keep its build when inputs
|
||
// change, or when the existing changed-path manifest cannot narrow scope.
|
||
const androidBenchmarkChanged = !changedPaths?.length || changedPaths.some((path) =>
|
||
!path.trim() ||
|
||
matchesGlob(path, "apps/android/{benchmark,buildSrc,build-logic,gradle,Config}/**") ||
|
||
matchesGlob(path, "apps/android/**/*.gradle{,.kts}") ||
|
||
matchesGlob(path, "apps/android/**/gradle.properties") ||
|
||
matchesGlob(path, "apps/android/gradlew{,.bat}"),
|
||
);
|
||
const supportsFormatCheck =
|
||
targetWorkflow.split("pnpm format:check").length - 1 >= 2;
|
||
const runFormatCheck = !frozenTarget || supportsFormatCheck;
|
||
let runBaselineRatchets = runNode && !frozenTarget && !releaseFastLane;
|
||
const checksFastCoreTasks =
|
||
runBaselineRatchets
|
||
? [
|
||
{
|
||
check_name: "checks-fast-startup-corpus",
|
||
runtime: "node",
|
||
task: "startup-corpus",
|
||
},
|
||
{
|
||
check_name: "checks-fast-coercion-helpers",
|
||
runtime: "node",
|
||
task: "coercion-helpers",
|
||
},
|
||
]
|
||
: [];
|
||
if (runNodeFull && !releaseFastLane) {
|
||
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 && !releaseFastLane) {
|
||
checksFastCoreTasks.push({
|
||
check_name: "checks-fast-ci-routing",
|
||
runtime: "node",
|
||
task: "ci-routing",
|
||
});
|
||
}
|
||
}
|
||
if (releaseGate) {
|
||
checksFastCoreTasks.push(
|
||
...Array.from({ length: 5 }, (_, index) => {
|
||
const stripe = index + 1;
|
||
return {
|
||
check_name: `checks-fast-release-lint-core-${stripe}`,
|
||
runtime: "node",
|
||
stripe,
|
||
task: `release-lint-core-${stripe}`,
|
||
};
|
||
}),
|
||
{
|
||
check_name: "checks-fast-release-lint-extensions",
|
||
runtime: "node",
|
||
task: "release-lint-extensions",
|
||
},
|
||
);
|
||
}
|
||
|
||
const includeReleaseOnlyRuntimeTests =
|
||
!mainValidation && (!isCanonicalRepository || (!runtimePullRequest && eventName !== "push"));
|
||
const includePrExemptRuntimeTests = !runtimePullRequest;
|
||
let selectedTestTargets;
|
||
if (runtimePullRequest && runNodeFull) {
|
||
if (changedPaths === null) {
|
||
throw new Error("Current PR CI requires complete changed paths for Node test planning");
|
||
}
|
||
if (typeof changedNodeTestPlan.resolveChangedNodeTestTargets !== "function") {
|
||
throw new Error("Current PR CI requires a bounded changed-owner target selector");
|
||
}
|
||
selectedTestTargets = changedNodeTestPlan.resolveChangedNodeTestTargets(changedPaths, {
|
||
baseRef: process.env.OPENCLAW_CI_CHANGED_BASE,
|
||
includeReleaseOnlyRuntimeTests,
|
||
includePrExemptRuntimeTests,
|
||
});
|
||
}
|
||
const uiOwnerScope = {
|
||
unit: runUiTests,
|
||
mocked: runControlUiE2e,
|
||
browser: runBrowserExtensionE2e,
|
||
realGateway: runUiRealGateway,
|
||
};
|
||
let uiTestGroups = (runUiTests || runUiE2e || runUiRealGateway || selectedTestTargets) && !compatibilityTarget &&
|
||
(!frozenTarget || releaseGate) && typeof nodeTestPlan.createUiTestShardGroups === "function"
|
||
? nodeTestPlan.createUiTestShardGroups({
|
||
// Preserve the ordinary owner-family inventory. Protected or directly
|
||
// selected files opt into the existing release-only UI tier below.
|
||
includeReleaseOnlyTests: includeReleaseOnlyUiTests,
|
||
includePrExemptRuntimeTests: selectedTestTargets ? true : includePrExemptRuntimeTests,
|
||
changedPaths: selectedTestTargets ?? changedPaths ?? [],
|
||
})
|
||
: null;
|
||
let uiTestShardCount = compatibilityTarget ? 1 : 3;
|
||
if (selectedTestTargets) {
|
||
if (!uiTestGroups) throw new Error("Current PR CI requires UI target groups");
|
||
const selected = new Set(selectedTestTargets);
|
||
const { controlUiE2eTestGlobs, isUiTestTarget, uiE2eRealGatewayTestFiles } =
|
||
await import("./test/vitest/vitest.ui-paths.mjs");
|
||
const realGatewayTargets = new Set(uiE2eRealGatewayTestFiles);
|
||
const narrowGroups = (groups, ownsFile, retainsOwner) => groups.map((group) => ({
|
||
...group,
|
||
includePatterns: (group.includePatterns ?? selectedTestTargets.filter(ownsFile))
|
||
.filter((file) => retainsOwner(file) || selected.has(file)),
|
||
})).filter((group) => group.includePatterns.length > 0);
|
||
uiTestGroups = {
|
||
ui: narrowGroups(uiTestGroups.ui, isUiTestTarget, () => uiOwnerScope.unit),
|
||
e2e: narrowGroups(uiTestGroups.e2e, (file) => realGatewayTargets.has(file) ||
|
||
controlUiE2eTestGlobs.some((pattern) => matchesGlob(file, pattern)),
|
||
(file) => realGatewayTargets.has(file) ? uiOwnerScope.realGateway : uiOwnerScope.mocked),
|
||
};
|
||
const uiTargets = uiTestGroups.ui.flatMap((group) => group.includePatterns);
|
||
const e2eTargets = uiTestGroups.e2e.flatMap((group) => group.includePatterns);
|
||
const controlTargets = e2eTargets.filter((file) => !realGatewayTargets.has(file));
|
||
runUiTests = uiTargets.length > 0;
|
||
runControlUiE2e = controlTargets.length > 0;
|
||
runBrowserExtensionE2e = uiOwnerScope.browser ||
|
||
selected.has("extensions/browser/chrome-extension/bootstrap.chromium.test.ts");
|
||
runUiRealGateway = e2eTargets.some((file) => realGatewayTargets.has(file));
|
||
runUiE2e = runControlUiE2e || runBrowserExtensionE2e;
|
||
uiTestShardCount = Math.min(3, uiTargets.length);
|
||
// Keep the existing worker/row cap; omit empty file partitions.
|
||
uiE2eJobCount = Math.min(uiE2eJobCount - 1, controlTargets.length) + 1;
|
||
}
|
||
if (selectedTestTargets && runWindows && !windowsTestPlan) {
|
||
throw new Error("Current PR CI requires a target-owned Windows planner");
|
||
}
|
||
const plannedWindowsShards = runWindows && windowsTestPlan
|
||
? windowsTestPlan.createWindowsTestShards(packageScripts, {
|
||
includePrExemptRuntimeTests: selectedTestTargets ? true : includePrExemptRuntimeTests,
|
||
...(runtimePullRequest && changedPaths ? { changedPaths: selectedTestTargets ?? changedPaths } : {}),
|
||
})
|
||
: null;
|
||
const windowsShards = plannedWindowsShards?.map((shard) => ({
|
||
...shard,
|
||
runtime: "node",
|
||
task: "test",
|
||
})).filter((shard) => shard.targets.length > 0) ?? (runWindows ? [1, 2].map((part) => ({
|
||
check_name: `checks-windows-node-test-${part}`,
|
||
runtime: "node",
|
||
task: `test-${part}`,
|
||
})) : []);
|
||
if (selectedTestTargets) runWindows = windowsShards.length > 0;
|
||
const startupCorpusTestFiles =
|
||
typeof nodeTestPlan.resolveStartupCorpusTestFiles === "function"
|
||
? nodeTestPlan.resolveStartupCorpusTestFiles({
|
||
includeReleaseOnlyRuntimeTests: selectedTestTargets ? true : includeReleaseOnlyRuntimeTests,
|
||
includePrExemptRuntimeTests: selectedTestTargets ? true : includePrExemptRuntimeTests,
|
||
...(runtimePullRequest && changedPaths ? { changedPaths } : {}),
|
||
}).filter((file) => !selectedTestTargets || selectedTestTargets.includes(file))
|
||
: undefined;
|
||
const ownerPathEvent = isCanonicalRepository &&
|
||
eventName === "push" && eventRef === "refs/heads/main";
|
||
if (runtimePullRequest && supportsDockerSeedE2e &&
|
||
typeof dockerSeedPlan.resolveChangedDockerSeedLanes !== "function") {
|
||
throw new Error("Current PR CI requires the Docker owner selector");
|
||
}
|
||
const dockerSeedLanes = isCanonicalRepository && supportsDockerSeedE2e
|
||
? runtimePullRequest
|
||
? dockerSeedPlan.resolveChangedDockerSeedLanes(changedPaths ?? [])
|
||
: runProofTier && (ownerPathEvent || eventName === "workflow_dispatch" || mainValidation)
|
||
? typeof dockerSeedPlan.resolveDockerSeedLanes === "function"
|
||
? dockerSeedPlan.resolveDockerSeedLanes({ includeReleaseOnly: eventName === "workflow_dispatch" && !mainValidation })
|
||
: ["published-upgrade-survivor"]
|
||
: []
|
||
: [];
|
||
// Canonical pushes also use compact bins: 80+ single-group jobs
|
||
// drain the runner pool for minutes, and per-shard check names on
|
||
// main have no branch-protection consumers. Dispatch (release
|
||
// validation) keeps the full named matrix.
|
||
const compactPlanMode = !isCanonicalRepository
|
||
? undefined
|
||
: runtimePullRequest
|
||
? "pull-request"
|
||
: eventName === "push" || mainValidation
|
||
? "push"
|
||
: undefined;
|
||
const nodeMatrixLimit = mainValidation ? 77 : compactPlanMode === "pull-request" ? 130 : 70;
|
||
let changedNodeTestShards = null;
|
||
let changedNodeTestFallbackReason;
|
||
if (runtimePullRequest && runNodeFull) {
|
||
// PRs admit only concrete owner plans; missing selection is a planner failure.
|
||
for (const name of ["createChangedNodeTestShards"]) {
|
||
if (typeof changedNodeTestPlan[name] !== "function") {
|
||
throw new Error(`Current PR CI target does not export ${name}`);
|
||
}
|
||
}
|
||
changedNodeTestShards = changedNodeTestPlan.createChangedNodeTestShards(changedPaths, {
|
||
baseRef: process.env.OPENCLAW_CI_CHANGED_BASE,
|
||
compactNodeJobCap: compactPlanMode ? nodeMatrixLimit : undefined,
|
||
selectedTestTargets,
|
||
// Changed compiler plans validate every consuming graph, with full fallback on ambiguity.
|
||
dedicatedCoreTypeChecks: runNodeFull,
|
||
dedicatedBuildArtifacts: false,
|
||
dedicatedNativeChecks: { macos: runMacos, ios: runIosBuild, android: runAndroid },
|
||
includeReleaseOnlyToolingShards: false,
|
||
includeReleaseOnlyRuntimeTests,
|
||
includePrExemptRuntimeTests,
|
||
runnerBackend: nodeRunnerBackend,
|
||
releaseFastLane,
|
||
onFallback: (reason) => {
|
||
changedNodeTestFallbackReason = reason;
|
||
console.log(`Node test plan owner selection: ${reason}`);
|
||
},
|
||
dedicatedContractShards: [...pluginContractShards, ...channelContractShards],
|
||
dedicatedUiE2e: (runUiE2e || runUiRealGateway) && !compatibilityTarget,
|
||
dedicatedUiTests: runUiTests,
|
||
dedicatedMaxLinesRatchet: runBaselineRatchets &&
|
||
(!proposedCheckScope || proposedCheckScope.baselineRatchets),
|
||
});
|
||
if (changedNodeTestShards === null) {
|
||
throw new Error(`Current PR CI requires a bounded changed-owner Node plan: ${changedNodeTestFallbackReason ?? "selector returned no plan"}`);
|
||
}
|
||
console.log(`Node test plan changed-set: ${changedNodeTestShards.filter((shard) => !shard.requiresDist).length} rows`);
|
||
}
|
||
// A Node-targeting fallback does not invalidate independently resolved
|
||
// check families or their compiler/lint consumer graphs.
|
||
const narrowCheckScope = proposedCheckScope?.mode === "scoped" ? proposedCheckScope : null;
|
||
const runCheckPlan = Boolean(runCheck && narrowCheckScope);
|
||
let typeGraphBoundaryOwner = "";
|
||
if (runCheckPlan && narrowCheckScope.types) {
|
||
const { resolveChangedCiTsgoInputs } = await import("./scripts/lib/tsgo-core-test-shards.mts");
|
||
typeGraphBoundaryOwner = runNodeFull && !releaseFastLane &&
|
||
narrowCheckScope.additionalGroups.includes("boundaries") &&
|
||
!resolveChangedCiTsgoInputs(changedPaths, existsSync)
|
||
? "additional-checks" : "check-plan";
|
||
}
|
||
const fullCoreLintStripes = runnerProfile === "hybrid" && !frozenTarget &&
|
||
((!releaseGate && ["push", "pull_request"].includes(eventName)) ||
|
||
nodeRunnerBackend === "runson" || ciQualification) ? [1, 2] : [1, 2, 3, 4, 5];
|
||
const coreLintRows = fullCoreLintStripes.map((stripe) => ({ stripe }));
|
||
const compactExtensionLint = isCanonicalRepository && runnerProfile === "hybrid" &&
|
||
!frozenTarget && !compatibilityTarget && !releaseGate && !runCheckPlan;
|
||
const extensionLintRows = compactExtensionLint
|
||
? [1, 2, 3].map((stripe) => ({ stripe, stripe_count: 3 }))
|
||
: [1, 2, 3, 4, 5, 6].map((stripe) => ({ stripe }));
|
||
const coreTypeRows = (frozenTarget ? [1, 2] : [1, 2, 3, 4, 5]).map((stripe) => ({ stripe }));
|
||
if (proposedCheckScope) {
|
||
runBaselineRatchets &&= proposedCheckScope.baselineRatchets;
|
||
for (let index = checksFastCoreTasks.length - 1; index >= 0; index--) {
|
||
if (!proposedCheckScope.fastTasks.includes(checksFastCoreTasks[index].task) &&
|
||
!checksFastCoreTasks[index].task.startsWith("release-lint-")) {
|
||
checksFastCoreTasks.splice(index, 1);
|
||
}
|
||
}
|
||
}
|
||
// Heavy packaging lanes run only when the diff touches surfaces they
|
||
// exist to prove: built-artifact tests need dist even on test-only diffs, and QA
|
||
// smoke only sees changes on its scenario surface or inside the
|
||
// packaged CLI's import graph. QA gating is diff-based, so it also
|
||
// applies when test targeting fell back to the full compact suite.
|
||
const selectedOwnerTest = (file) => selectedTestTargets?.includes(file) === true;
|
||
const selectedBuildOwner = [
|
||
"test/scripts/build-all.test.ts",
|
||
"test/scripts/tsdown-build.test.ts",
|
||
"test/scripts/dist-artifact-ownership.test.ts",
|
||
"test/scripts/write-plugin-sdk-entry-dts.test.ts",
|
||
"test/scripts/write-unified-entry-dts.test.ts",
|
||
"test/scripts/check-openclaw-package-tarball.test.ts",
|
||
].some(selectedOwnerTest);
|
||
const runBrowserNativeHost = runNodeFull && (runProofTier ||
|
||
selectedOwnerTest("extensions/browser/src/browser/extension-install.native-host.e2e.test.ts"));
|
||
const runDoctorPluginIndex = runNodeFull && (runProofTier ||
|
||
selectedOwnerTest("test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts"));
|
||
const runDiscordComponentProof = runNodeFull && (runProofTier ||
|
||
selectedOwnerTest("test/e2e/qa-lab/plugins/discord-show-widget-contextual-presenter.e2e.test.ts"));
|
||
const runGatewayWatch = runNodeFull && ((runProofTier && !releaseFastLane) ||
|
||
selectedOwnerTest("test/scripts/check-gateway-watch-regression.test.ts"));
|
||
const selectedTuiPty = selectedOwnerTest("src/tui/tui-pty-local.e2e.test.ts");
|
||
const changedScopeHasBuildImpact = runtimePullRequest
|
||
? selectedBuildOwner || changedNodeTestShards?.some((shard) => shard.requiresDist) === true
|
||
: changedNodeTestShards === null ||
|
||
changedNodeTestShards.some((shard) => shard.requiresDist) ||
|
||
typeof changedNodeTestPlan.hasBuildArtifactAffectingChange !== "function" ||
|
||
changedNodeTestPlan.hasBuildArtifactAffectingChange(changedPaths);
|
||
const changedScopeHasQaImpact =
|
||
changedPaths === null ||
|
||
(eventName === "workflow_dispatch" && !releaseGate) ||
|
||
typeof changedNodeTestPlan.hasQaSmokeAffectingChange !== "function" ||
|
||
changedNodeTestPlan.hasQaSmokeAffectingChange(changedPaths);
|
||
// Prompt snapshots only change when the generator's import graph or
|
||
// its fixtures do; unaffected PR diffs skip the regeneration lane.
|
||
const changedScopeHasPromptSnapshotImpact =
|
||
changedPaths === null ||
|
||
eventName !== "pull_request" ||
|
||
typeof changedNodeTestPlan.hasPromptSnapshotAffectingChange !== "function" ||
|
||
changedNodeTestPlan.hasPromptSnapshotAffectingChange(changedPaths);
|
||
const supportsSqliteSessionLifecycleProof = existsSync(
|
||
"test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts",
|
||
);
|
||
const changedScopeHasSqliteSessionLifecycleImpact =
|
||
changedPaths === null ||
|
||
(eventName === "workflow_dispatch" && !releaseGate) ||
|
||
typeof changedNodeTestPlan.hasSqliteSessionLifecycleAffectingChange !== "function" ||
|
||
changedNodeTestPlan.hasSqliteSessionLifecycleAffectingChange(changedPaths);
|
||
const runSqliteSessionLifecycle =
|
||
runNodeFull &&
|
||
supportsSqliteSessionLifecycleProof &&
|
||
(changedScopeHasSqliteSessionLifecycleImpact ||
|
||
selectedOwnerTest("test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts"));
|
||
const runBuildArtifacts =
|
||
runNodeFull && (changedScopeHasBuildImpact || runSqliteSessionLifecycle ||
|
||
runBrowserNativeHost || runDoctorPluginIndex || runDiscordComponentProof ||
|
||
runGatewayWatch || selectedTuiPty);
|
||
const runControlUiPerformance = !releaseFastLane && (runNodeFull || runUiTests) && (
|
||
eventName === "workflow_dispatch" || changedPaths === null ||
|
||
typeof changedNodeTestPlan.hasControlUiPerformanceAffectingChange !== "function" ||
|
||
changedNodeTestPlan.hasControlUiPerformanceAffectingChange(changedPaths)
|
||
);
|
||
const runQaSmokeCi =
|
||
runNodeFull &&
|
||
changedScopeHasQaImpact &&
|
||
(!frozenTarget || existsSync("extensions/qa-lab/src/ci-smoke-plan.ts"));
|
||
const rawNodeTestShards = runNodeFull
|
||
? changedNodeTestShards
|
||
? changedNodeTestShards
|
||
: [
|
||
...createNodeTestPlan({
|
||
includeProofTests: runProofTier,
|
||
includeReleaseOnlyPluginShards: false,
|
||
includeReleaseOnlyToolingShards: mainValidation || eventName === "workflow_dispatch" || !isCanonicalRepository,
|
||
includeReleaseOnlyRuntimeTests,
|
||
includePrExemptRuntimeTests,
|
||
...(runtimePullRequest && changedPaths ? { changedPaths } : {}),
|
||
// Keep the legacy boolean for historical targets. Hourly main uses
|
||
// the complete compact inventory within its 77-row main-tier cap.
|
||
compact: compactPlanMode !== undefined,
|
||
compactMode: mainValidation ? "pull-request" : compactPlanMode,
|
||
compactNodeJobCap: nodeMatrixLimit,
|
||
runnerBackend: nodeRunnerBackend,
|
||
}),
|
||
]
|
||
: [];
|
||
if (ciQualification && !mainQualification && process.env.OPENCLAW_CI_NODE_RUNNER_BACKEND === "runson") {
|
||
const cron = rawNodeTestShards.find((shard) => shard.runner === "runson-c8i-8xlarge");
|
||
if (!cron) throw new Error("RunsOn qualification requires selected cron tests");
|
||
// One dispatch compares the same child contracts and worker ceiling.
|
||
for (const [provider, runner] of [
|
||
["blacksmith", "blacksmith-32vcpu-ubuntu-2404"],
|
||
["github", "ubuntu-24.04"],
|
||
]) {
|
||
rawNodeTestShards.push({
|
||
...cron,
|
||
checkName: `checks-node-runson-cron-${provider}-control`,
|
||
shardName: `runson-cron-${provider}-control`,
|
||
runner,
|
||
});
|
||
}
|
||
}
|
||
// The trusted planner may name owners added after a frozen checkout.
|
||
// Project them out here so the runner never receives impossible work.
|
||
const projectFrozenNodeTestPlan = (plan) => {
|
||
const configs = plan.configs?.filter((config) => existsSync(config));
|
||
if (plan.configs?.length && !configs?.length) {
|
||
return null;
|
||
}
|
||
return { ...plan, configs };
|
||
};
|
||
const targetNodeTestShards = compatibilityTarget
|
||
? rawNodeTestShards.flatMap((shard) => {
|
||
const groups = shard.groups
|
||
?.map(projectFrozenNodeTestPlan)
|
||
.filter((group) => group !== null);
|
||
if (shard.groups?.length && !groups?.length) return [];
|
||
const projected = projectFrozenNodeTestPlan({ ...shard, groups });
|
||
return projected ? [projected] : [];
|
||
})
|
||
: rawNodeTestShards;
|
||
// Node rows list every striped test file. Current targets pack the
|
||
// projected runner contract; older targets keep their flat fields or
|
||
// projected legacy groups because their shard runner predates the codec.
|
||
const nodeTestGroupsCodecPath = "./scripts/lib/ci-node-test-groups-codec.mts";
|
||
const nodeTestGroupsCodec =
|
||
(targetNodeTestShards.length > 0 || uiTestGroups !== null) &&
|
||
existsSync(nodeTestGroupsCodecPath)
|
||
? await importTargetPlan(nodeTestGroupsCodecPath)
|
||
: null;
|
||
const encodeNodeTestGroups = (groups) => {
|
||
if (typeof nodeTestGroupsCodec?.encodeNodeTestGroups !== "function") {
|
||
throw new Error("CI target emits grouped Node test rows without scripts/lib/ci-node-test-groups-codec.mts");
|
||
}
|
||
return nodeTestGroupsCodec.encodeNodeTestGroups(groups);
|
||
};
|
||
if (selectedTestTargets && runUiRealGateway && typeof nodeTestPlan.createUiRealGatewayTestShards !== "function") {
|
||
throw new Error("Current PR CI requires target-owned real-Gateway groups");
|
||
}
|
||
const plannedUiRealGatewayShards = uiTestGroups && (!frozenTarget || releaseGate) &&
|
||
typeof nodeTestPlan.createUiRealGatewayTestShards === "function"
|
||
? nodeTestPlan.createUiRealGatewayTestShards(uiTestGroups.e2e)
|
||
: selectedTestTargets ? [] : [{ shard: 1, shard_count: 1, run_desktop: true, groups: uiTestGroups?.e2e }];
|
||
const uiRealGatewayShards = selectedTestTargets
|
||
? plannedUiRealGatewayShards.map((shard) => ({
|
||
...shard,
|
||
groups: shard.groups?.filter((group) => group.includePatterns?.length > 0),
|
||
})).filter((shard) => shard.run_desktop || shard.groups?.length > 0)
|
||
: plannedUiRealGatewayShards;
|
||
const projectNodeTestGroup = ({
|
||
configs,
|
||
env,
|
||
fallbackMaxWorkers,
|
||
includePatterns,
|
||
minTotalMemoryBytes,
|
||
shard_name,
|
||
timing_key,
|
||
}) => ({ configs, env, fallbackMaxWorkers, includePatterns, minTotalMemoryBytes, shard_name, timing_key });
|
||
const testRuntimePolicyPath = "./scripts/lib/ci-test-runtime.mts";
|
||
const testRuntimePolicy = existsSync(testRuntimePolicyPath)
|
||
? await importTargetPlan(testRuntimePolicyPath)
|
||
: null;
|
||
const testRuntimeMode = testRuntimePolicy
|
||
? eventName === "pull_request" || releaseGate
|
||
? "bun-compatible"
|
||
: eventName === "workflow_dispatch" && !mainValidation
|
||
? "dual"
|
||
: "node"
|
||
: "node";
|
||
const uiTestRuntimePolicy = !compatibilityTarget && testRuntimePolicy?.ciTestShardRequiresBun({
|
||
configs: ["ui/vitest.config.ts"],
|
||
vitestArgs: [
|
||
"--maxWorkers", "3",
|
||
"--reporter=verbose",
|
||
"--reporter=github-actions",
|
||
"--reporter=./scripts/lib/vitest-resource-reporter.mts",
|
||
...(compatibilityTarget ? [] : ["--shard=1/3"]),
|
||
],
|
||
}, testRuntimeMode) ? testRuntimeMode : "node";
|
||
const goToolingConfig = "test/vitest/vitest.tooling.config.ts";
|
||
const knownToolingConfigs = new Set([
|
||
goToolingConfig,
|
||
"test/vitest/vitest.tooling-isolated.config.ts",
|
||
"test/vitest/vitest.tooling-docker.config.ts",
|
||
]);
|
||
// The same capped matrix owns compact and plugin work; admit its longest rows first.
|
||
const nodeTestShards = targetNodeTestShards
|
||
.toSorted((a, b) =>
|
||
Number(b.runner === "runson-c8i-8xlarge") - Number(a.runner === "runson-c8i-8xlarge") ||
|
||
(b.predictedSeconds ?? 0) - (a.predictedSeconds ?? 0))
|
||
.map((shard) => {
|
||
const groups = shard.groups?.map(projectNodeTestGroup) ?? (
|
||
nodeTestGroupsCodec && shard.configs?.length && !shard.targets?.length
|
||
? [{
|
||
configs: shard.configs,
|
||
env: shard.env,
|
||
includePatterns: shard.includePatterns,
|
||
shard_name: shard.shardName,
|
||
timing_key: shard.timing_key,
|
||
}]
|
||
: undefined
|
||
);
|
||
const packedGroups = groups?.length && nodeTestGroupsCodec
|
||
? encodeNodeTestGroups(groups)
|
||
: undefined;
|
||
return {
|
||
check_name: shard.checkName,
|
||
test_runtime_policy: testRuntimeMode,
|
||
requires_bun: !shard.requiresDist && Boolean(testRuntimePolicy?.ciTestShardRequiresBun(shard, testRuntimeMode)),
|
||
shard_name: shard.shardName,
|
||
groups_gzip_base64: packedGroups,
|
||
groups: groups && !nodeTestGroupsCodec ? groups : undefined,
|
||
configs: packedGroups ? undefined : shard.configs,
|
||
env: shard.env,
|
||
includePatterns: packedGroups ? undefined : shard.includePatterns,
|
||
pretest_build_mode: shard.pretestBuildMode,
|
||
git_commits: resolveTestGitCommits(shard),
|
||
requires_dist: shard.requiresDist,
|
||
runner: shard.runner,
|
||
timeout_minutes: shard.timeoutMinutes,
|
||
plan_concurrency: shard.planConcurrency,
|
||
predicted_seconds: shard.predictedTestSeconds ?? shard.predictedSeconds,
|
||
targets: shard.targets,
|
||
requires_go: (shard.groups ?? [shard]).some((plan) => {
|
||
const patterns = plan.targets ?? plan.includePatterns;
|
||
if (patterns) {
|
||
return patterns.some((pattern) => matchesGlob("test/scripts/docs-i18n.test.ts", pattern));
|
||
}
|
||
if (plan.configs?.length && plan.configs.every((config) => knownToolingConfigs.has(config))) {
|
||
return plan.configs.includes(goToolingConfig);
|
||
}
|
||
// Unknown historical configs retain their original Go setup;
|
||
// current isolated and Docker catalogs exclude the Go owner.
|
||
return (plan.shard_name ?? plan.shardName).startsWith("core-tooling");
|
||
}),
|
||
requires_ripgrep: (shard.groups ?? [shard]).some((plan) => {
|
||
const patterns = plan.targets ?? plan.includePatterns;
|
||
if (patterns) {
|
||
return patterns.some((pattern) => [
|
||
"src/agents/sessions/agent-session-runtime-projection.test.ts",
|
||
"src/agents/sessions/tools/index.test.ts",
|
||
"src/agents/sessions/tools/grep.byte-path.test.ts",
|
||
"src/agents/filesystem-tools-output-contract.test.ts",
|
||
].some((test) => matchesGlob(test, pattern)));
|
||
}
|
||
return ["agentic-agents-support", "agentic-agents-core-runtime"].includes(
|
||
plan.shard_name ?? plan.shardName,
|
||
);
|
||
}),
|
||
requires_sandbox_image: (shard.groups ?? [shard]).some((plan) => {
|
||
const patterns = plan.targets ?? plan.includePatterns;
|
||
if (patterns) {
|
||
return patterns.some((pattern) => [
|
||
"test/e2e/qa-lab/runtime/agent-sandboxed-exec-behavior.e2e.test.ts",
|
||
"test/e2e/qa-lab/runtime/openclaw-sandbox-workspace-isolation.e2e.test.ts",
|
||
].some((test) => matchesGlob(test, pattern)));
|
||
}
|
||
return plan.configs?.includes("test/vitest/vitest.e2e.config.ts") ?? false;
|
||
}),
|
||
};
|
||
});
|
||
const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist);
|
||
// Bound the final matrix: precise plans and appended plugin rows can bypass compact caps.
|
||
if (compactPlanMode && nodeTestNonDistShards.length > nodeMatrixLimit) {
|
||
throw new Error(
|
||
`Canonical ${eventName} Node matrix has ${nodeTestNonDistShards.length} jobs, exceeding limit ${nodeMatrixLimit}`,
|
||
);
|
||
}
|
||
const nodeTestDistShards = nodeTestShards.filter((shard) => shard.requires_dist);
|
||
// The required Node matrix can own the selected PR corpus on this
|
||
// exact tree; frozen/release targets retain their independent corpus step.
|
||
const startupCorpusNodeRevision =
|
||
isCanonicalRepository && eventName === "pull_request" && runNodeFull &&
|
||
!frozenTarget && !compatibilityTarget && !releaseGate &&
|
||
/^[0-9a-f]{40}$/u.test(checkoutRevision) && checkoutRevision === workflowRevision &&
|
||
typeof nodeTestPlan.hasCompleteStartupCorpusCoverage === "function" &&
|
||
nodeTestPlan.hasCompleteStartupCorpusCoverage(targetNodeTestShards, startupCorpusTestFiles)
|
||
? checkoutRevision : "";
|
||
if (startupCorpusNodeRevision) {
|
||
// The exact-tree Node receipt makes this row's only test step a no-op.
|
||
const startupTask = checksFastCoreTasks.findIndex(({ task }) => task === "startup-corpus");
|
||
if (startupTask >= 0) checksFastCoreTasks.splice(startupTask, 1);
|
||
}
|
||
// Targeted PRs keep source boundary guards in their Node plan. Only
|
||
// an actual dist descriptor transfers that owner to build-artifacts.
|
||
const runNodeCoreDist = nodeTestDistShards.length > 0;
|
||
const runTuiPty = runNodeFull && ((runProofTier && runNodeCoreDist) || selectedTuiPty);
|
||
const protocolCoverageRequested = runNode || runIosBuild || runAndroid;
|
||
const runProtocolEventCoverage =
|
||
protocolCoverageRequested &&
|
||
(!frozenTarget || existsSync("scripts/check-protocol-event-coverage.mjs"));
|
||
|
||
const additionalChecks = [
|
||
{ check_name: "check-additional-boundaries", group: "boundaries", runner: "blacksmith-8vcpu-ubuntu-2404" },
|
||
// Prompt regeneration loads the full tool/prompt import graph independently.
|
||
{ check_name: "check-prompt-snapshots", group: "prompt-snapshots", runner: "blacksmith-8vcpu-ubuntu-2404" },
|
||
// Frozen targets retain their original rows and command boundaries.
|
||
...(frozenTarget ? [
|
||
{ check_name: "check-export-name-collisions", group: "export-name-collisions", runner: "blacksmith-4vcpu-ubuntu-2404" },
|
||
{ check_name: "check-session-accessor-boundary", group: "session-accessor-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-source-contracts", group: "source-contracts", runner: "blacksmith-4vcpu-ubuntu-2404" },
|
||
]),
|
||
// Only dispatches execute this report; scheduled tips have no change range.
|
||
...(eventName === "workflow_dispatch" ? [
|
||
// Diff generation took 209–246s on smaller hosts; keep its 8GiB heap isolated.
|
||
{ check_name: "report-plugin-sdk-api-diff", group: "plugin-sdk-api-diff", runner: "blacksmith-8vcpu-ubuntu-2404" },
|
||
] : []),
|
||
// Keep these scans on available capacity; their resource guards remain authoritative.
|
||
{ check_name: "check-additional-extension-package-boundary", group: "extension-package-boundary", runner: "blacksmith-32vcpu-ubuntu-2404" },
|
||
{ check_name: "check-additional-runtime-topology-architecture", group: "runtime-topology-architecture", runner: "blacksmith-16vcpu-ubuntu-2404" },
|
||
].filter(({ group }) => !narrowCheckScope ||
|
||
(group === "prompt-snapshots" ? changedScopeHasPromptSnapshotImpact :
|
||
narrowCheckScope.additionalGroups.includes(group)));
|
||
const checkTasks = [
|
||
{ check_name: "check-guards", task: "guards", runner: "blacksmith-4vcpu-ubuntu-2404" },
|
||
{ check_name: "check-npm-lock", task: "npm-lock", 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-16vcpu-ubuntu-2404" },
|
||
{ check_name: "check-test-types", task: "test-types", runner: "blacksmith-16vcpu-ubuntu-2404" },
|
||
].filter((row) => {
|
||
if (!narrowCheckScope) return true;
|
||
if (row.task === "prod-types" || row.task === "test-types") return narrowCheckScope.types;
|
||
return row.task === "lint" ? narrowCheckScope.lint : narrowCheckScope.checkTasks.includes(row.task);
|
||
});
|
||
|
||
// The selected guards row owns the same coercion scan; fast-only plans retain its row.
|
||
if (!frozenTarget && !compatibilityTarget && runCheck && checkTasks.some(({ task }) => task === "guards")) {
|
||
const coercionTask = checksFastCoreTasks.findIndex(({ task }) => task === "coercion-helpers");
|
||
if (coercionTask >= 0) checksFastCoreTasks.splice(coercionTask, 1);
|
||
}
|
||
|
||
const manifest = {
|
||
release_scope: releaseScope,
|
||
release_fast_lane: releaseFastLane,
|
||
validation_tier: validationTier,
|
||
docs_only: docsOnly,
|
||
docs_changed: docsChanged,
|
||
run_node: runNode,
|
||
run_docker_seed_e2e: dockerSeedLanes.length > 0,
|
||
docker_seed_lanes: dockerSeedLanes.join(" "),
|
||
run_macos: runMacos,
|
||
run_android: runAndroid,
|
||
run_skills_python: runSkillsPython,
|
||
run_windows: runWindows,
|
||
run_build_artifacts: runBuildArtifacts,
|
||
run_proof_tier: runProofTier,
|
||
run_browser_native_host: runBrowserNativeHost,
|
||
run_doctor_plugin_index: runDoctorPluginIndex,
|
||
run_discord_component_proof: runDiscordComponentProof,
|
||
run_gateway_watch: runGatewayWatch,
|
||
run_tui_pty: runTuiPty,
|
||
run_baseline_ratchets: runBaselineRatchets,
|
||
run_checks_fast_core: checksFastCoreTasks.length > 0,
|
||
run_checks_fast: runNodeFull,
|
||
historical_target: historicalTarget,
|
||
frozen_target: frozenTarget,
|
||
compatibility_target: compatibilityTarget,
|
||
run_qa_smoke_ci: runQaSmokeCi,
|
||
qa_smoke_ci_matrix: createMatrix(
|
||
Array.from({ length: qaSmokeCiPartCount }, (_, index) => {
|
||
const part = index + 1;
|
||
return {
|
||
name: `profile ${part}/${qaSmokeCiPartCount}`,
|
||
lane: `profile-${part}`,
|
||
slug: `profile-${part}-of-${qaSmokeCiPartCount}`,
|
||
part_count: qaSmokeCiPartCount,
|
||
};
|
||
}),
|
||
),
|
||
run_prompt_snapshots: runNodeFull && !releaseFastLane && changedScopeHasPromptSnapshotImpact,
|
||
run_sqlite_session_lifecycle: runSqliteSessionLifecycle,
|
||
checks_fast_core_matrix: createMatrix(checksFastCoreTasks),
|
||
run_plugin_contracts_shards: pluginContractShards.length > 0,
|
||
plugin_contracts_matrix: createContractMatrix(
|
||
pluginContractShards,
|
||
"contracts-plugins",
|
||
),
|
||
run_channel_contracts_shards: channelContractShards.length > 0,
|
||
channel_contracts_matrix: createContractMatrix(channelContractShards, "contracts-channels"),
|
||
run_checks: runNodeFull,
|
||
source_channel_test_env_json: JSON.stringify(sourceChannelTestEnv),
|
||
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
|
||
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
|
||
run_checks_node_core_dist: runNodeCoreDist,
|
||
run_check: runCheck,
|
||
narrow_check_paths_json: narrowCheckScope ? JSON.stringify(changedPaths) : "",
|
||
run_check_plan: runCheckPlan,
|
||
check_plan_input_json: runCheckPlan ? JSON.stringify({
|
||
typeGraphBoundaryOwner,
|
||
changedPaths,
|
||
changedCoreTestPaths: changedCoreTestPaths ?? null,
|
||
runnerProfile,
|
||
checkMatrix: createMatrix(checkTasks),
|
||
coreTypeMatrix: createMatrix(coreTypeRows),
|
||
lintCoreMatrix: createMatrix(coreLintRows),
|
||
lintExtensionMatrix: createMatrix(extensionLintRows),
|
||
}) : "",
|
||
check_matrix: createMatrix(runCheck ? checkTasks : []),
|
||
core_type_matrix: createMatrix(coreTypeRows),
|
||
lint_core_matrix: createMatrix(coreLintRows),
|
||
lint_extension_matrix: createMatrix(extensionLintRows),
|
||
central_lint_selection_json: "",
|
||
run_lint_core: runCheck && coreLintRows.length > 0,
|
||
run_lint_extensions: runCheck && extensionLintRows.length > 0,
|
||
run_changed_core_type_stripes: Boolean(narrowCheckScope?.types && usesHostedRunnerProfile),
|
||
type_graph_boundary_owner: typeGraphBoundaryOwner,
|
||
startup_corpus_node_revision: startupCorpusNodeRevision,
|
||
startup_corpus_test_files_json: startupCorpusTestFiles ? JSON.stringify(startupCorpusTestFiles) : "",
|
||
changed_core_test_paths_json: changedCoreTestPaths ? JSON.stringify(changedCoreTestPaths) : "",
|
||
run_check_additional: runNodeFull && !releaseFastLane && additionalChecks.length > 0,
|
||
check_additional_matrix: createMatrix(runNodeFull && !releaseFastLane ? additionalChecks : []),
|
||
run_check_docs: docsChanged && eventName !== "push",
|
||
run_format_check: runFormatCheck,
|
||
run_control_ui_i18n: runControlUiI18n,
|
||
run_ui_tests: runUiTests,
|
||
ui_test_runtime_policy: uiTestRuntimePolicy,
|
||
ui_test_shard_count: uiTestShardCount,
|
||
ui_test_matrix: createMatrix(Array.from({ length: runUiTests ? uiTestShardCount : 0 }, (_, index) => ({ shard: index + 1 }))),
|
||
ui_test_groups_gzip_base64: uiTestGroups ? encodeNodeTestGroups(uiTestGroups.ui) : "",
|
||
ui_e2e_test_groups_gzip_base64: uiTestGroups ? encodeNodeTestGroups(uiTestGroups.e2e) : "",
|
||
ui_real_gateway_matrix: createMatrix(uiRealGatewayShards.map(({ groups, ...row }) => ({
|
||
...row,
|
||
run_tests: !groups || groups.some((group) => group.includePatterns === undefined || group.includePatterns.length > 0),
|
||
test_groups_gzip_base64: groups ? encodeNodeTestGroups(groups) : "",
|
||
}))),
|
||
run_control_ui_performance: runControlUiPerformance,
|
||
run_ui_e2e: runUiE2e,
|
||
run_ui_real_gateway: runUiRealGateway,
|
||
ui_e2e_matrix: createMatrix(Array.from({ length: uiE2eJobCount }, (_, index) => {
|
||
const shard = index + 1;
|
||
return {
|
||
shard,
|
||
shard_count: uiE2eJobCount,
|
||
task: shard === uiE2eJobCount ? "browser-extension" : "control-ui",
|
||
vitest_shard_count: uiE2eJobCount - 1,
|
||
vitest_max_workers: compactUiE2e ? 3 : 2,
|
||
};
|
||
}).filter((row) => row.task === "browser-extension" ? runBrowserExtensionE2e : runControlUiE2e)),
|
||
run_native_i18n: runNativeI18n,
|
||
run_skills_python_job: runSkillsPython,
|
||
run_checks_windows: runWindows,
|
||
// Current targets balance the complete Windows inventory by measured
|
||
// file cost; historical targets retain their original package commands.
|
||
checks_windows_matrix: createMatrix(windowsShards),
|
||
run_macos_node: runMacosNode,
|
||
macos_node_matrix: createMatrix(
|
||
runMacosNode
|
||
? [1, 2, 3].every((part) => hasPackageScript(`test:macos:ci:${part}`))
|
||
? [1, 2, 3].map((part) => ({
|
||
check_name: `macos-node-${part}`,
|
||
runtime: "node",
|
||
task: `test-${part}`,
|
||
}))
|
||
// Historical targets keep their complete native suite in one job.
|
||
: [{ check_name: "macos-node", runtime: "node", task: "test" }]
|
||
: [],
|
||
),
|
||
run_macos_swift:
|
||
runMacos && !npmQualification &&
|
||
(!frozenTarget || compatibilityTarget || supportsCurrentMacosSwiftCi),
|
||
run_openclawkit_tests: runMacos && !npmQualification && supportsOpenClawKitTests,
|
||
run_ios_build: runIosBuild,
|
||
run_android_job: runAndroid,
|
||
run_android_access_native: runAndroidAccessNative,
|
||
use_compatible_android_ci: useCompatibleAndroidCi,
|
||
run_protocol_event_coverage: runProtocolEventCoverage,
|
||
android_matrix: createMatrix(
|
||
runAndroid
|
||
? [
|
||
// android-ci-contract-v3: phone variants, Wear modules, Android lint, benchmark, and ktlint.
|
||
{
|
||
check_name: "android-test-play",
|
||
task: useCompatibleAndroidCi ? "test-play-compat" : "test-play",
|
||
},
|
||
{
|
||
check_name: "android-test-third-party",
|
||
task: "test-third-party",
|
||
...(androidTestTier ? { app_lint: "third-party" } : {}),
|
||
},
|
||
...(!useCompatibleAndroidCi
|
||
? [{
|
||
check_name: "android-test-wear",
|
||
task: "test-wear",
|
||
...(androidTestTier ? { lint: true } : {}),
|
||
}]
|
||
: []),
|
||
...(!androidTestTier
|
||
? [{
|
||
check_name: "android-build-play",
|
||
task: useCompatibleAndroidCi ? "build-play-compat" : "build-play",
|
||
}]
|
||
: []),
|
||
...(!useCompatibleAndroidCi
|
||
? [
|
||
...(!androidTestTier ? [{ check_name: "android-build-wear", task: "build-wear" }] : []),
|
||
{
|
||
check_name: "android-ktlint",
|
||
task: "ktlint",
|
||
...(androidTestTier ? { app_lint: "play" } : {}),
|
||
...(androidTestTier && androidBenchmarkChanged ? { build_benchmark: true } : {}),
|
||
},
|
||
]
|
||
: []),
|
||
]
|
||
: [],
|
||
),
|
||
};
|
||
|
||
// Routing belongs to this workflow, not the frozen target's test planners.
|
||
// Only automatic hybrid first attempts can add optional hosted rows.
|
||
const HYBRID_HOSTED_ROW_LIMIT = 45;
|
||
const HYBRID_HOSTED_BASE_ROW_LIMIT = 40;
|
||
const hybridHostedEligible = !frozenTarget && isCanonicalRepository &&
|
||
["hybrid", "runson"].includes(process.env.OPENCLAW_CI_RUNNER_BACKEND ?? "") &&
|
||
process.env.GITHUB_RUN_ATTEMPT === "1" &&
|
||
(eventName === "push" || ciQualification || (eventName === "pull_request" &&
|
||
["OWNER", "MEMBER", "COLLABORATOR", "CONTRIBUTOR"].includes(
|
||
process.env.OPENCLAW_CI_AUTHOR_ASSOCIATION ?? "",
|
||
)));
|
||
let hybridHostedBaseRows = 0;
|
||
let hybridHostedOffloadRows = 0;
|
||
if (hybridHostedEligible) {
|
||
const count = (selected, rows = 1) => selected ? rows : 0;
|
||
const hostedNodeRows = manifest.checks_node_core_nondist_matrix.include.filter(
|
||
(row) => row.runner === "ubuntu-24.04",
|
||
).length;
|
||
const hostedAdditionalRows = manifest.check_additional_matrix.include.filter(
|
||
(row) => !["extension-package-boundary", "runtime-topology-architecture", "plugin-sdk-api-diff"].includes(row.group) ||
|
||
!row.runner.startsWith("blacksmith-"),
|
||
).length;
|
||
const hostedControlJobs = process.env.OPENCLAW_CI_RUNNER_BACKEND === "runson" ||
|
||
nodeRunnerBackend === "runson" ||
|
||
(workflowEventName === "pull_request" &&
|
||
process.env.OPENCLAW_CI_HEAD_REPOSITORY !== process.env.OPENCLAW_CI_REPOSITORY);
|
||
// Include control jobs, every emitted matrix row and native hosted jobs.
|
||
// Narrow PRs reserve full lint/type templates; only hybrid moves critical controls.
|
||
// The guard independently expands the workflow to catch inventory drift.
|
||
// Qualification authenticates on hosted preflight before paid admission.
|
||
hybridHostedBaseRows = Object.values({
|
||
"preflight": count(ciQualification),
|
||
"check-plan": count(hostedControlJobs && runCheckPlan),
|
||
"pr-fail-fast": count(workflowEventName === "pull_request" && manifest.run_checks_node_core_nondist &&
|
||
process.env.OPENCLAW_CI_HEAD_REPOSITORY !== process.env.OPENCLAW_CI_REPOSITORY),
|
||
"control-ui-performance": count(manifest.run_control_ui_performance),
|
||
"native-i18n": count(manifest.run_native_i18n),
|
||
"control-ui-i18n": count(manifest.run_control_ui_i18n),
|
||
"checks-baseline-ratchets": count(hostedControlJobs && manifest.run_baseline_ratchets),
|
||
"checks-fast-core": count(manifest.run_checks_fast_core, manifest.checks_fast_core_matrix.include.length),
|
||
"checks-fast-plugin-contracts-shard": count(manifest.run_plugin_contracts_shards, manifest.plugin_contracts_matrix.include.length),
|
||
"checks-fast-channel-contracts-shard": count(manifest.run_channel_contracts_shards, manifest.channel_contracts_matrix.include.length),
|
||
"checks-node-core-test-nondist-shard": count(manifest.run_checks_node_core_nondist, hostedNodeRows),
|
||
"check-shard": count(manifest.run_check, checkTasks.filter(({ task }) => !["lint", "test-types", "dependencies"].includes(task)).length),
|
||
"check-lint-hosted-extension-shard": count(manifest.run_check && runnerProfile === "hybrid" && !releaseGate, extensionLintRows.length),
|
||
"check-additional-shard": count(manifest.run_check_additional, hostedAdditionalRows),
|
||
"check-docs": count(manifest.run_check_docs),
|
||
"skills-python": count(manifest.run_skills_python_job),
|
||
"macos-node": count(manifest.run_macos_node, manifest.macos_node_matrix.include.length),
|
||
"macos-swift": count(manifest.run_macos_swift, 2),
|
||
"ios-build": count(manifest.run_ios_build),
|
||
"ios-screenshot-shard": count(parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_SCREENSHOTS), 2),
|
||
"ios-screenshot-evidence": count(parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_SCREENSHOTS)),
|
||
"android-access-native": count(manifest.run_android_access_native, 2),
|
||
"docker-seed-e2e": count(manifest.run_docker_seed_e2e && workflowEventName === "pull_request" &&
|
||
process.env.OPENCLAW_CI_HEAD_REPOSITORY !== process.env.OPENCLAW_CI_REPOSITORY),
|
||
}).reduce((total, rows) => total + rows, 0);
|
||
hybridHostedOffloadRows = 1 + count(manifest.run_ui_tests, 3) +
|
||
count(manifest.run_ui_e2e && !compatibilityTarget,
|
||
manifest.ui_e2e_matrix.include.filter((row) => row.task === "browser-extension").length);
|
||
}
|
||
if (hybridHostedEligible && hybridHostedBaseRows > HYBRID_HOSTED_ROW_LIMIT) {
|
||
console.warn(`::warning::Hybrid base manifest has ${hybridHostedBaseRows} hosted jobs, above the ${HYBRID_HOSTED_ROW_LIMIT}-row offload budget; keeping optional offloads on Blacksmith.`);
|
||
}
|
||
const hybridHostedOffload = hybridHostedEligible &&
|
||
hybridHostedBaseRows <= HYBRID_HOSTED_BASE_ROW_LIMIT &&
|
||
hybridHostedBaseRows + hybridHostedOffloadRows <= HYBRID_HOSTED_ROW_LIMIT;
|
||
// The measured check rows consume only remaining hosted capacity.
|
||
// Keep the original UI/security decision and all test-runner labels intact.
|
||
const hybridHostedCheckRows = (manifest.run_check && checkTasks.some(({ task }) => task === "dependencies") ? 1 : 0) +
|
||
(manifest.run_check && checkTasks.some(({ task }) => task === "test-types") && usesHostedRunnerProfile ? coreTypeRows.length : 0) +
|
||
(manifest.run_check_additional ? manifest.check_additional_matrix.include.filter(
|
||
(row) => ["extension-package-boundary", "runtime-topology-architecture"].includes(row.group),
|
||
).length : 0);
|
||
const hybridHostedExistingRows = hybridHostedBaseRows +
|
||
(hybridHostedOffload ? hybridHostedOffloadRows : 0);
|
||
// R1's slowest admitted hosted check took 496s including setup. A full
|
||
// compact plan must retain at least 500s of serial Node work plus setup;
|
||
// aggregate two-slot estimates and the shortened Windows shards are not a floor.
|
||
const hybridHostedPullRequestHasSlack = changedNodeTestShards === null &&
|
||
rawNodeTestShards.some((shard) => !shard.requiresDist &&
|
||
shard.planConcurrency === 1 && (shard.predictedSeconds ?? 0) >= 500);
|
||
const hybridHostedChecks = hybridHostedEligible &&
|
||
((eventName === "push" && eventRef === "refs/heads/main") ||
|
||
(eventName === "pull_request" && manifest.run_checks_windows && hybridHostedPullRequestHasSlack)) &&
|
||
process.env.OPENCLAW_CI_HOSTED_HEALTHY === "true" &&
|
||
hybridHostedExistingRows + hybridHostedCheckRows <= HYBRID_HOSTED_ROW_LIMIT;
|
||
const hybridHostedRowsWithChecks = hybridHostedExistingRows +
|
||
(hybridHostedChecks ? hybridHostedCheckRows : 0);
|
||
// Main can spend remaining hosted capacity on independent four-CPU checks.
|
||
// PRs retain their Blacksmith placement regardless of spare row capacity.
|
||
const hybridHostedMainCheckRows = manifest.run_check ? 2 : 0;
|
||
const hybridHostedMainChecks = hybridHostedChecks && eventName === "push" &&
|
||
eventRef === "refs/heads/main" &&
|
||
hybridHostedRowsWithChecks + hybridHostedMainCheckRows <= HYBRID_HOSTED_ROW_LIMIT;
|
||
Object.assign(manifest, {
|
||
hybrid_hosted_offload: hybridHostedOffload,
|
||
hybrid_hosted_checks: hybridHostedChecks,
|
||
hybrid_hosted_main_checks: hybridHostedMainChecks,
|
||
hybrid_hosted_base_rows: hybridHostedBaseRows,
|
||
hybrid_hosted_total_rows: hybridHostedRowsWithChecks +
|
||
(hybridHostedMainChecks ? hybridHostedMainCheckRows : 0),
|
||
});
|
||
if (runCheckPlan) {
|
||
console.log("Hosted admission reserves full lint/type template bounds and any hosted check-plan job; late selection cannot expand that inventory.");
|
||
}
|
||
if (hybridHostedEligible) {
|
||
console.log(`Hybrid hosted rows: ${manifest.hybrid_hosted_base_rows} base, ${manifest.hybrid_hosted_total_rows} total; optional offload ${hybridHostedOffload ? "admitted" : "retained on Blacksmith"}; measured checks ${hybridHostedChecks ? "admitted" : "retained on Blacksmith"}; main checks ${hybridHostedMainChecks ? "admitted" : "retained on Blacksmith"}`);
|
||
}
|
||
|
||
// The PR monitor must see the whole selected graph before declaring it
|
||
// complete; an early jobs response can omit not-yet-expanded matrices.
|
||
const countPrJobs = (selected, rows = 1) => selected ? rows : 0;
|
||
manifest.pr_check_job_count = workflowEventName !== "pull_request" ? 0 :
|
||
countPrJobs(manifest.run_check, manifest.check_matrix.include.length) +
|
||
countPrJobs(manifest.run_check && manifest.run_lint_core && usesHostedRunnerProfile,
|
||
manifest.lint_core_matrix.include.length) +
|
||
countPrJobs(manifest.run_check && manifest.run_lint_extensions && runnerProfile === "hybrid",
|
||
manifest.lint_extension_matrix.include.length) +
|
||
countPrJobs(manifest.run_check && usesHostedRunnerProfile &&
|
||
(!manifest.narrow_check_paths_json || manifest.run_changed_core_type_stripes),
|
||
manifest.core_type_matrix.include.length);
|
||
manifest.pr_job_count = workflowEventName !== "pull_request" ? 0 : 2 +
|
||
countPrJobs(manifest.run_check_plan) + manifest.pr_check_job_count +
|
||
["run_build_artifacts", "run_control_ui_performance", "run_native_i18n",
|
||
"run_control_ui_i18n", "run_baseline_ratchets", "run_check_docs", "run_skills_python_job", "run_docker_seed_e2e"]
|
||
.reduce((sum, key) => sum + countPrJobs(manifest[key]), 0) +
|
||
[["run_checks_fast_core", "checks_fast_core_matrix"],
|
||
["run_plugin_contracts_shards", "plugin_contracts_matrix"],
|
||
["run_channel_contracts_shards", "channel_contracts_matrix"],
|
||
["run_checks_node_core_nondist", "checks_node_core_nondist_matrix"],
|
||
["run_check_additional", "check_additional_matrix"],
|
||
["run_checks_windows", "checks_windows_matrix"],
|
||
["run_macos_node", "macos_node_matrix"],
|
||
["run_android_job", "android_matrix"],
|
||
["run_qa_smoke_ci", "qa_smoke_ci_matrix"],
|
||
["run_ui_e2e", "ui_e2e_matrix"]]
|
||
.reduce((sum, [selected, matrix]) => sum + countPrJobs(manifest[selected], manifest[matrix].include.length), 0) +
|
||
countPrJobs(manifest.run_ui_tests, uiTestShardCount) + countPrJobs(manifest.run_macos_swift, 2) +
|
||
countPrJobs(manifest.run_ios_build) + (manifest.run_ui_real_gateway ? uiRealGatewayShards.length : 0) +
|
||
countPrJobs(manifest.run_android_access_native, 2) +
|
||
countPrJobs(parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_SCREENSHOTS), 3);
|
||
|
||
for (const [key, value] of Object.entries(manifest)) {
|
||
appendFileSync(
|
||
outputPath,
|
||
`${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`,
|
||
"utf8",
|
||
);
|
||
}
|
||
console.log(`CI release scope: ${releaseScope}`);
|
||
if (releaseFastLane) {
|
||
const running = `security-fast, check-shard (lint, prod/test types, guards, dependencies), check-docs when docs changed, and ${nodeTestShards.length} changed Node rows${runBuildArtifacts ? ", build-artifacts for selected owners" : ""}${dockerSeedLanes.length ? ", owner-selected Docker seed" : ""}${runQaSmokeCi ? ", owner-selected QA Smoke" : ""}`;
|
||
console.log(`::notice title=Release fast lane::Admitted by label release-fast-lane for release tooling paths. Running: ${running}.`);
|
||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
|
||
"### Release fast lane\n\n" +
|
||
"- Admitted by label `release-fast-lane` for release tooling paths.\n" +
|
||
`- Running: ${running}.\n` +
|
||
"- Skipped: contracts, baseline ratchets, bundled protocol, Bun launcher, additional checks, Control UI, Windows, macOS, iOS, Android, native and Control UI i18n, skills-python.\n" +
|
||
(changedNodeTestFallbackReason ? `- Node plan: bounded owner selection (${changedNodeTestFallbackReason}).\n` : "") + "\n");
|
||
}
|
||
} else if (releaseFastLaneLabel) {
|
||
const reason = releaseFastLaneScope.reason;
|
||
console.warn(`::warning title=Release fast lane declined::${reason}`);
|
||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
|
||
`### Release fast lane\n\n- Declined: ${reason}. Ordinary CI selection applies.\n\n`);
|
||
}
|
||
}
|
||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||
appendFileSync(process.env.GITHUB_STEP_SUMMARY,
|
||
`### CI release qualification\n\n- Scope: \`${releaseScope}\`\n- Target: \`${checkoutRevision}\`\n` +
|
||
(npmQualification ? "- Native app qualification: deferred; Linux, macOS, and Windows Node coverage retained.\n" : ""));
|
||
}
|
||
EOF
|
||
|
||
- name: Check mobile protocol event coverage
|
||
if: steps.manifest.outputs.run_protocol_event_coverage == 'true'
|
||
env:
|
||
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
|
||
OPENCLAW_CI_WORKFLOW_REVISION: ${{ github.workflow_sha }}
|
||
run: |
|
||
# Different-revision dispatches may need their candidate-owned tsx
|
||
# shim; current workflow source runs the dependency-free .mts owner.
|
||
if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ] &&
|
||
[ "$OPENCLAW_CI_CHECKOUT_REVISION" != "$OPENCLAW_CI_WORKFLOW_REVISION" ]; then
|
||
node scripts/check-protocol-event-coverage.mjs
|
||
else
|
||
node scripts/check-protocol-event-coverage.mts
|
||
fi
|
||
|
||
- name: Restore exact dependency cache
|
||
if: vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && !contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && github.repository == 'openclaw/openclaw' && steps.manifest.outputs.run_node == 'true' && ((github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository))
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: ${{ steps.candidate_trust.outputs.cache_mode }}
|
||
dependency-cache: "true"
|
||
install-bun: "false"
|
||
|
||
security-fast:
|
||
permissions:
|
||
contents: read
|
||
needs: [preflight]
|
||
if: ${{ (github.event_name != 'schedule' || (github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main')) && (!cancelled() && (github.event_name != 'pull_request' || !github.event.pull_request.draft)) }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON(contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && needs.preflight.outputs.hybrid_hosted_offload != 'true' && github.run_attempt == 1 && (github.event_name != 'workflow_dispatch' || ((needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true') && github.run_attempt == 1)) && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04'))) || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && needs.preflight.outputs.hybrid_hosted_offload != 'true' && github.run_attempt == 1 && (github.event_name != 'workflow_dispatch' || ((needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true') && github.run_attempt == 1)) && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||
timeout-minutes: 20
|
||
env:
|
||
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
|
||
steps:
|
||
- name: Checkout
|
||
if: github.event_name != 'workflow_dispatch' || inputs.target_ref == ''
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
fetch-depth: 2
|
||
persist-credentials: false
|
||
|
||
- name: Prepare Git owner
|
||
shell: bash
|
||
env:
|
||
CHECKOUT_KIND: prepare
|
||
run: *owned_checkout_run
|
||
|
||
- name: Checkout manual target
|
||
if: github.event_name == 'workflow_dispatch' && inputs.target_ref != ''
|
||
shell: bash
|
||
env:
|
||
CHECKOUT_KIND: manual
|
||
CHECKOUT_REPO: ${{ github.repository }}
|
||
CHECKOUT_TOKEN: ${{ github.token }}
|
||
CHECKOUT_REF: ${{ inputs.target_ref }}
|
||
CHECKOUT_FALLBACK_REF: ${{ github.sha }}
|
||
run: *owned_checkout_run
|
||
|
||
- name: Checkout trusted CI harness
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
ref: ${{ github.workflow_sha }}
|
||
path: .ci-harness
|
||
sparse-checkout: ".github/actions\n.github/zizmor.yml\nscripts/detect-private-keys.mts\nscripts/ci-production-audit.mjs"
|
||
persist-credentials: false
|
||
|
||
- name: Resolve security diff base
|
||
id: diff_base
|
||
env:
|
||
EVENT_BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha || '' }}
|
||
run: |
|
||
set -euo pipefail
|
||
base_sha="$EVENT_BASE_SHA"
|
||
if [ "$GITHUB_EVENT_NAME" = "push" ] && [[ "$base_sha" =~ ^0+$ ]]; then
|
||
echo "::error title=ambiguous main push::github.event.before is zero; refusing to infer a diff base for a created or recreated main branch." >&2
|
||
exit 1
|
||
fi
|
||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||
# Do not execute a helper from the untrusted PR tree before trusted
|
||
# pre-commit configuration is selected.
|
||
read -r head_sha first_parent second_parent extra <<< \
|
||
"$(git rev-list --parents -n 1 HEAD)"
|
||
if [[
|
||
"$head_sha" = "$(git rev-parse HEAD)" &&
|
||
"$first_parent" =~ ^[0-9a-f]{40}$ &&
|
||
"$second_parent" =~ ^[0-9a-f]{40}$ &&
|
||
-z "${extra:-}"
|
||
]]; then
|
||
base_sha="$first_parent"
|
||
fi
|
||
fi
|
||
echo "sha=$base_sha" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Ensure security base commit
|
||
if: github.event_name != 'workflow_dispatch' && github.event_name != 'schedule'
|
||
uses: ./.ci-harness/.github/actions/ensure-base-commit
|
||
with:
|
||
base-sha: ${{ steps.diff_base.outputs.sha }}
|
||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||
|
||
- name: Prepare trusted scanner config
|
||
env:
|
||
BASE_SHA: ${{ steps.diff_base.outputs.sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
trusted_policy="$RUNNER_TEMP/zizmor.yml"
|
||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||
if ! git show "${BASE_SHA}:.github/zizmor.yml" > "$trusted_policy" 2>/dev/null; then
|
||
echo "::error title=trusted zizmor policy unavailable::Could not read .github/zizmor.yml from exact base ${BASE_SHA}."
|
||
exit 1
|
||
fi
|
||
else
|
||
cp .ci-harness/.github/zizmor.yml "$trusted_policy"
|
||
fi
|
||
# The key scanner is repo code, so pull requests run the exact-base
|
||
# copy: a candidate must not be able to neuter the guard that scans it.
|
||
trusted_scanner="$RUNNER_TEMP/detect-private-keys.mts"
|
||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
|
||
if ! git show "${BASE_SHA}:scripts/detect-private-keys.mts" > "$trusted_scanner" 2>/dev/null; then
|
||
rm -f "$trusted_scanner"
|
||
echo "::error title=trusted private-key scanner unavailable::Could not read scripts/detect-private-keys.mts from exact base ${BASE_SHA}; land the scanner on the base branch first."
|
||
exit 1
|
||
fi
|
||
else
|
||
cp .ci-harness/scripts/detect-private-keys.mts "$trusted_scanner"
|
||
fi
|
||
echo "PRIVATE_KEY_SCANNER_PATH=$trusted_scanner" >> "$GITHUB_ENV"
|
||
cat > "$RUNNER_TEMP/security-fast-pre-commit.yaml" <<EOF
|
||
repos:
|
||
- repo: local
|
||
hooks:
|
||
- id: zizmor
|
||
name: zizmor
|
||
entry: zizmor
|
||
language: system
|
||
types: [yaml]
|
||
files: '(\.github/(workflows/.*|dependabot.ya?ml))|(action\.ya?ml)$'
|
||
require_serial: true
|
||
args: [--config, "$trusted_policy", --persona=regular, --min-severity=medium, --min-confidence=medium]
|
||
exclude: '^(vendor/|apps/swabble/)'
|
||
EOF
|
||
echo "PRE_COMMIT_CONFIG_PATH=$RUNNER_TEMP/security-fast-pre-commit.yaml" >> "$GITHUB_ENV"
|
||
|
||
- name: Setup Node.js
|
||
env:
|
||
REQUESTED_NODE_VERSION: "24.x"
|
||
run: &ensure_node_run |
|
||
set -euo pipefail
|
||
source .ci-harness/.github/actions/setup-pnpm-store-cache/ensure-node.sh
|
||
openclaw_ensure_node "$REQUESTED_NODE_VERSION"
|
||
|
||
- name: Detect committed private keys
|
||
run: node "$PRIVATE_KEY_SCANNER_PATH"
|
||
|
||
- name: Detect changed GitHub workflows
|
||
id: workflow_scope
|
||
env:
|
||
BASE_SHA: ${{ steps.diff_base.outputs.sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then
|
||
echo "No usable base SHA detected; skipping zizmor."
|
||
exit 0
|
||
fi
|
||
|
||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||
echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor."
|
||
exit 0
|
||
fi
|
||
|
||
git diff --name-only --diff-filter=ACMR "${BASE_SHA}" HEAD -- \
|
||
'.github/workflows/*.yml' '.github/workflows/*.yaml' > "$RUNNER_TEMP/security-workflow-files"
|
||
if [ ! -s "$RUNNER_TEMP/security-workflow-files" ]; then
|
||
echo "No workflow changes detected; skipping zizmor."
|
||
exit 0
|
||
fi
|
||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Install security scanners
|
||
if: steps.workflow_scope.outputs.changed == 'true'
|
||
run: python3 --version && python3 -m pip install --disable-pip-version-check pre-commit==4.6.2 zizmor==1.30.1
|
||
|
||
- name: Audit changed GitHub workflows with zizmor
|
||
if: steps.workflow_scope.outputs.changed == 'true'
|
||
run: |
|
||
set -euo pipefail
|
||
mapfile -t workflow_files < "$RUNNER_TEMP/security-workflow-files"
|
||
|
||
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
|
||
GIT_ALLOW_PROTOCOL=file GIT_CONFIG_COUNT=0 \
|
||
pre-commit run --config "$PRE_COMMIT_CONFIG_PATH" zizmor --files "${workflow_files[@]}"
|
||
|
||
- name: Audit production dependencies
|
||
run: node .ci-harness/scripts/ci-production-audit.mjs
|
||
|
||
- name: Setup differential guard dependencies
|
||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.OPENCLAW_CI_ON_PUSH != 'true'
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: restore
|
||
install-bun: "false"
|
||
|
||
- name: Check main push ratchets and protocol additions
|
||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' && vars.OPENCLAW_CI_ON_PUSH != 'true'
|
||
env:
|
||
BASE_SHA: ${{ steps.diff_base.outputs.sha }}
|
||
PROTOCOL_SINCE_BASE_SHA: ${{ steps.diff_base.outputs.sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
pnpm check:max-lines-ratchet --base "$BASE_SHA"
|
||
pnpm check:assertion-safety --base "$BASE_SHA"
|
||
node --import ./scripts/tsx.mjs scripts/check-protocol-since.mts
|
||
|
||
build-artifacts:
|
||
permissions:
|
||
contents: read
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_build_artifacts == 'true'
|
||
# The measured hosted tail reached 898s before preflight and gate overhead.
|
||
runs-on: &shared_16vcpu_linux_runner ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04')))) || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04')) }}
|
||
# Hosted rows (github backend, hybrid retries, full-release dispatches, fork PRs) run slower.
|
||
timeout-minutes: &hosted_budget_timeout_minutes ${{ ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) || (github.event_name == 'workflow_dispatch' && needs.preflight.outputs.ci_shape != 'main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository)) && 35 || 20 }}
|
||
steps:
|
||
- &linux_node_checkout_step
|
||
name: Checkout
|
||
shell: bash
|
||
env:
|
||
CHECKOUT_REPO: ${{ github.repository }}
|
||
CHECKOUT_TOKEN: ${{ github.token }}
|
||
CHECKOUT_SHA: &checkout_sha ${{ needs.preflight.outputs.checkout_revision }}
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: *owned_checkout_run
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: &cache_mode ${{ needs.preflight.outputs.cache_mode }}
|
||
install-bun: "true"
|
||
node-compile-cache: "true"
|
||
build-all-cache-scope: full
|
||
# Use the physical runner to keep hosted retries store-only.
|
||
dependency-cache: &trusted_dependency_cache ${{ (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) != 'github' && runner.environment == 'self-hosted' && (github.event_name != 'workflow_dispatch' || ((needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true') && github.run_attempt == 1)) && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
||
|
||
- name: Restore dist build cache
|
||
id: dist_build_cache
|
||
if: needs.preflight.outputs.cache_mode != 'off'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
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 bundled plugin generated assets
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
if node --input-type=module <<'NODE'
|
||
import { readFileSync } from "node:fs";
|
||
|
||
const packageJson = JSON.parse(readFileSync("package.json", "utf8"));
|
||
process.exit(packageJson.scripts?.["plugins:assets:check"] ? 0 : 1);
|
||
NODE
|
||
then
|
||
pnpm plugins:assets:check
|
||
else
|
||
# Frozen release candidates predate this generated-asset contract.
|
||
# Their own build remains the available asset validation surface.
|
||
echo "Selected release candidate predates plugins:assets:check; skipping unavailable check."
|
||
fi
|
||
|
||
- 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 CLI with Bun
|
||
if: ${{ needs.preflight.outputs.frozen_target != 'true' }}
|
||
run: |
|
||
bun openclaw.mjs --help
|
||
bun openclaw.mjs status --json --timeout 1
|
||
|
||
- name: Verify built browser native host
|
||
if: ${{ needs.preflight.outputs.run_browser_native_host == 'true' && (needs.preflight.outputs.frozen_target != 'true' || hashFiles('extensions/browser/src/browser/extension-install.native-host.e2e.test.ts') != '') }}
|
||
env:
|
||
FROZEN_TARGET: &frozen_target ${{ needs.preflight.outputs.frozen_target }}
|
||
OPENCLAW_E2E_USE_PREBUILT_DIST: "1"
|
||
OPENCLAW_VITEST_MAX_WORKERS: "1"
|
||
run: |
|
||
set -euo pipefail
|
||
rm -f "${RUNNER_TEMP}/browser-native-host.json"
|
||
node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts \
|
||
extensions/browser/src/browser/extension-install.native-host.e2e.test.ts \
|
||
--reporter=default --reporter=json \
|
||
--outputFile.json "${RUNNER_TEMP}/browser-native-host.json"
|
||
node --input-type=module <<'BROWSER_PROOF_REPORT'
|
||
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import path from "node:path";
|
||
|
||
const report = JSON.parse(readFileSync(path.join(process.env.RUNNER_TEMP, "browser-native-host.json"), "utf8"));
|
||
assert.equal(report.success, true);
|
||
const currentNames = [
|
||
"does not inspect or migrate configuration before rejecting a malformed native request",
|
||
"rejects an unauthorized bootstrap caller before config, keys or database creation",
|
||
"rejects an unauthorized ensure_relay caller before config, keys or database creation",
|
||
...["status", "setup", "pair"].map((command) =>
|
||
`preserves invalid-config diagnostics for ordinary extension command ${JSON.stringify(command)}`),
|
||
...["launcher", "cli"].map((entry) =>
|
||
`launches ${entry} with the exact custom installation context when Chrome has no selectors`),
|
||
].map((name) => `native host registration ${name}`).sort();
|
||
const frozenNames = [
|
||
"native host registration launches with the exact custom installation context when Chrome has no selectors",
|
||
];
|
||
const file = report.testResults[0];
|
||
const observedNames = file?.assertionResults?.map((entry) => entry.fullName).sort() ?? [];
|
||
const frozenInventories = [frozenNames, currentNames];
|
||
const expectedNames = process.env.FROZEN_TARGET === "true"
|
||
? frozenInventories.find((inventory) =>
|
||
inventory.length === observedNames.length &&
|
||
inventory.every((name, index) => name === observedNames[index]))
|
||
: currentNames;
|
||
assert.ok(expectedNames, "native-host proof used an unknown frozen test inventory");
|
||
assert.equal(report.numTotalTests, expectedNames.length);
|
||
assert.equal(report.numPassedTests, expectedNames.length);
|
||
for (const key of ["numFailedTestSuites", "numPendingTestSuites", "numFailedTests", "numPendingTests", "numTodoTests"]) {
|
||
assert.equal(report[key], 0, key);
|
||
}
|
||
assert.equal(report.testResults.length, 1);
|
||
assert.equal(file.name, path.resolve("extensions/browser/src/browser/extension-install.native-host.e2e.test.ts"));
|
||
assert.equal(file.status, "passed");
|
||
assert.deepEqual(file.assertionResults.map((entry) => entry.fullName).sort(), expectedNames);
|
||
for (const entry of file.assertionResults) {
|
||
assert.equal(entry.status, "passed", entry.fullName);
|
||
}
|
||
console.log(JSON.stringify({ proof: "browser-native-host", fullNames: expectedNames, passed: expectedNames.length, failed: 0, pending: 0 }));
|
||
BROWSER_PROOF_REPORT
|
||
|
||
- name: Run built artifact checks
|
||
id: built_artifact_checks
|
||
env:
|
||
PARALLEL_GATEWAY_WATCH: ${{ runner.environment != 'github-hosted' && 'true' || 'false' }}
|
||
PARALLEL_BUILT_VERIFIERS: ${{ runner.environment != 'github-hosted' && 'true' || 'false' }}
|
||
# Hosted Linux has a higher RSS baseline; keep Blacksmith tighter.
|
||
OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB: ${{ runner.environment == 'github-hosted' && '425' || '400' }}
|
||
RUN_CHANNELS: ${{ needs.preflight.outputs.run_proof_tier == 'true' && needs.preflight.outputs.run_checks == 'true' }}
|
||
CHANNEL_NODE_OPTIONS: ${{ fromJSON(needs.preflight.outputs.source_channel_test_env_json).NODE_OPTIONS }}
|
||
CHANNEL_MAX_WORKERS: ${{ fromJSON(needs.preflight.outputs.source_channel_test_env_json).OPENCLAW_VITEST_MAX_WORKERS }}
|
||
RUN_DOCTOR_PLUGIN_INDEX: ${{ needs.preflight.outputs.run_doctor_plugin_index }}
|
||
RUN_DISCORD_COMPONENT_PROOF: ${{ needs.preflight.outputs.run_discord_component_proof }}
|
||
RUN_CORE_SUPPORT_BOUNDARY: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
|
||
RUN_GATEWAY_WATCH: ${{ needs.preflight.outputs.run_gateway_watch }}
|
||
RUN_SQLITE_SESSION_LIFECYCLE: ${{ needs.preflight.outputs.run_sqlite_session_lifecycle }}
|
||
RUN_TUI_PTY: ${{ needs.preflight.outputs.run_tui_pty }}
|
||
FROZEN_TARGET: *frozen_target
|
||
shell: bash
|
||
run: |
|
||
set -uo pipefail
|
||
|
||
names=()
|
||
pids=()
|
||
logs=()
|
||
declare -A results=(
|
||
["channels"]="skipped"
|
||
["core-support-boundary"]="skipped"
|
||
["discord-component-attachments"]="skipped"
|
||
["doctor-plugin-index"]="skipped"
|
||
["gateway-watch"]="skipped"
|
||
["plugin-singleton"]="skipped"
|
||
["sqlite-session-lifecycle"]="skipped"
|
||
["startup-memory"]="skipped"
|
||
["tui-pty"]="skipped"
|
||
)
|
||
|
||
start_check() {
|
||
local name="$1"
|
||
shift
|
||
local log="${RUNNER_TEMP}/${name}.log"
|
||
names+=("$name")
|
||
logs+=("$log")
|
||
echo "starting ${name}: $*"
|
||
# Concurrent verifiers must not invalidate one shared Vitest module cache.
|
||
OPENCLAW_VITEST_FS_MODULE_CACHE_PATH="${RUNNER_TEMP}/vitest-module-cache/${name}" \
|
||
"$@" >"$log" 2>&1 &
|
||
pids+=("$!")
|
||
}
|
||
|
||
wait_checks() {
|
||
local index name log pid result
|
||
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
|
||
names=()
|
||
pids=()
|
||
logs=()
|
||
}
|
||
|
||
run_doctor_plugin_index() {
|
||
if [[ -f test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts ]]; then
|
||
# Cold hosted runners can spend over five minutes in E2E setup before
|
||
# this proof's own bounded test can report a result.
|
||
env OPENCLAW_E2E_USE_PREBUILT_DIST=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=660000 node scripts/run-vitest.mjs run \
|
||
--config test/vitest/vitest.e2e.config.ts \
|
||
test/scripts/doctor-config-preflight-plugin-index.built-cli.e2e.test.ts
|
||
else
|
||
echo "Selected target predates the built Doctor plugin index persistence proof."
|
||
fi
|
||
}
|
||
|
||
run_discord_component_attachments() {
|
||
local test_file="test/e2e/qa-lab/plugins/discord-show-widget-contextual-presenter.e2e.test.ts"
|
||
if [[ "$FROZEN_TARGET" = "true" && ! -f "$test_file" ]]; then
|
||
echo "[skip] Frozen target predates the Discord component attachment Gateway proof."
|
||
return 0
|
||
fi
|
||
rm -f "${RUNNER_TEMP}/discord-component-attachments.json" || return
|
||
env OPENCLAW_E2E_USE_PREBUILT_DIST=1 OPENCLAW_E2E_WORKERS=1 OPENCLAW_E2E_VERBOSE=1 OPENCLAW_VITEST_MAX_WORKERS=1 \
|
||
node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts \
|
||
"$test_file" \
|
||
--testNamePattern "preserves component attachment filenames through the public Gateway message action" \
|
||
--reporter=default --reporter=json \
|
||
--outputFile.json "${RUNNER_TEMP}/discord-component-attachments.json" || return
|
||
node --input-type=module <<'DISCORD_PROOF_REPORT'
|
||
import assert from "node:assert/strict";
|
||
import { readFileSync } from "node:fs";
|
||
import path from "node:path";
|
||
|
||
const report = JSON.parse(readFileSync(path.join(process.env.RUNNER_TEMP, "discord-component-attachments.json"), "utf8"));
|
||
assert.equal(report.success, true);
|
||
assert.equal(report.numFailedTestSuites, 0);
|
||
assert.equal(report.numFailedTests, 0);
|
||
assert.equal(report.testResults.length, 1);
|
||
const file = report.testResults[0];
|
||
assert.equal(file.name, path.resolve("test/e2e/qa-lab/plugins/discord-show-widget-contextual-presenter.e2e.test.ts"));
|
||
assert.equal(file.status, "passed");
|
||
const fullName = "Discord show_widget contextual presenter process proof preserves component attachment filenames through the public Gateway message action";
|
||
const matches = file.assertionResults.filter((entry) => entry.fullName === fullName);
|
||
if (matches.length === 0 && process.env.FROZEN_TARGET === "true") {
|
||
assert.equal(report.numPassedTests, 0);
|
||
console.log("[skip] Frozen target predates the named Discord component attachment Gateway proof.");
|
||
} else {
|
||
assert.equal(matches.length, 1, "The named Discord attachment proof must be present.");
|
||
assert.equal(matches[0].status, "passed");
|
||
assert.equal(report.numPassedTests, 1);
|
||
console.log(JSON.stringify({ proof: "discord-component-attachments", fullName, passed: 1, failed: 0 }));
|
||
}
|
||
DISCORD_PROOF_REPORT
|
||
}
|
||
|
||
# A missing startup asset rebuild must finish before any verifier forks
|
||
# so concurrent readers never observe dist mid-write.
|
||
startup_assets=success
|
||
startup_builder=(node --import tsx scripts/ensure-cli-startup-build.mts)
|
||
if [[ ! -f scripts/ensure-cli-startup-build.mts ]]; then
|
||
startup_builder=(node scripts/ensure-cli-startup-build.mjs)
|
||
fi
|
||
"${startup_builder[@]}" || startup_assets=failure
|
||
|
||
# Hosted runners keep the remaining verifiers serial; Blacksmith
|
||
# overlaps them with the selected checks below.
|
||
run_verifier() {
|
||
local name="$1"
|
||
shift
|
||
start_check "$name" "$@"
|
||
[ "$PARALLEL_BUILT_VERIFIERS" = "true" ] || wait_checks
|
||
}
|
||
|
||
# Concurrent checks perturb RSS even on Blacksmith. Complete this
|
||
# measurement before starting other verifiers without relaxing its ceiling.
|
||
run_verifier "startup-memory" node scripts/check-cli-startup-memory.mjs
|
||
wait_checks
|
||
|
||
# This verifier creates a synthetic dist plugin and rebuilds the full
|
||
# dist-runtime plugin overlay. Complete it before Gateway watch snapshots
|
||
# that tree or any artifact reader starts.
|
||
run_verifier "plugin-singleton" pnpm test:build:singleton
|
||
wait_checks
|
||
|
||
# The skip-build watch proof still refreshes dist build receipts.
|
||
# On Blacksmith, settle those writes before the parallel reader wave.
|
||
if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" = "true" ]; then
|
||
start_check "gateway-watch" \
|
||
pnpm test:gateway:watch-regression -- --skip-build
|
||
wait_checks
|
||
fi
|
||
|
||
if [ "$RUN_DOCTOR_PLUGIN_INDEX" = "true" ]; then
|
||
run_verifier "doctor-plugin-index" run_doctor_plugin_index
|
||
fi
|
||
|
||
if [ "$RUN_SQLITE_SESSION_LIFECYCLE" = "true" ]; then
|
||
run_verifier "sqlite-session-lifecycle" env \
|
||
OPENCLAW_E2E_USE_PREBUILT_DIST=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=660000 \
|
||
node scripts/run-vitest.mjs run --config test/vitest/vitest.e2e.config.ts \
|
||
test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts
|
||
fi
|
||
|
||
if [ "$RUN_CHANNELS" = "true" ]; then
|
||
start_check "channels" env \
|
||
NODE_OPTIONS="$CHANNEL_NODE_OPTIONS" \
|
||
OPENCLAW_VITEST_MAX_WORKERS="$CHANNEL_MAX_WORKERS" \
|
||
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
|
||
|
||
if [ "$RUN_DISCORD_COMPONENT_PROOF" = "true" ] && [ "$PARALLEL_BUILT_VERIFIERS" = "true" ]; then
|
||
start_check "discord-component-attachments" run_discord_component_attachments
|
||
fi
|
||
|
||
wait_checks
|
||
|
||
# Preserve the low-core hosted path; concurrent Vitest can otherwise
|
||
# starve the Gateway readiness deadline used by this regression gate.
|
||
if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" != "true" ]; then
|
||
start_check "gateway-watch" \
|
||
pnpm test:gateway:watch-regression -- --skip-build
|
||
wait_checks
|
||
fi
|
||
|
||
# Preserve the low-core hosted path: its real Gateway send stays serial.
|
||
if [ "$RUN_DISCORD_COMPONENT_PROOF" = "true" ] && [ "$PARALLEL_BUILT_VERIFIERS" != "true" ]; then
|
||
start_check "discord-component-attachments" run_discord_component_attachments
|
||
wait_checks
|
||
fi
|
||
|
||
# These canaries verify the built CLI's local roundtrip and Gateway connection.
|
||
# Full plans retain the PTY descriptor, but CI consumes it only as this selection flag.
|
||
if [ "$RUN_TUI_PTY" = "true" ]; then
|
||
start_check "tui-pty" env \
|
||
NODE_OPTIONS=--max-old-space-size=8192 \
|
||
OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1 \
|
||
OPENCLAW_TUI_PTY_USE_BUILT_CLI=1 \
|
||
OPENCLAW_VITEST_MAX_WORKERS=2 \
|
||
node scripts/run-vitest.mjs run \
|
||
--config test/vitest/vitest.tui-pty.config.ts \
|
||
src/tui/tui-pty-local.e2e.test.ts \
|
||
--testNamePattern "launches openclaw (chat as local mode|tui against a real Gateway) through a real PTY"
|
||
wait_checks
|
||
fi
|
||
|
||
if [[ -f .artifacts/startup-memory/summary.md ]]; then
|
||
cat .artifacts/startup-memory/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||
fi
|
||
|
||
failures=0
|
||
if [ "$startup_assets" = "failure" ]; then
|
||
echo "::error title=startup assets failed::cli startup asset build failed"
|
||
failures=1
|
||
fi
|
||
for name in channels core-support-boundary discord-component-attachments doctor-plugin-index gateway-watch plugin-singleton sqlite-session-lifecycle startup-memory tui-pty; do
|
||
if [ "${results[$name]}" = "failure" ]; then
|
||
echo "::error title=${name} failed::${name} failed"
|
||
failures=1
|
||
fi
|
||
done
|
||
exit "$failures"
|
||
|
||
- name: Upload Discord component attachment proof
|
||
if: >-
|
||
always() && needs.preflight.outputs.run_discord_component_proof == 'true' &&
|
||
(steps.built_artifact_checks.outcome == 'success' || steps.built_artifact_checks.outcome == 'failure')
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: discord-component-attachments
|
||
path: |
|
||
${{ runner.temp }}/discord-component-attachments.json
|
||
${{ runner.temp }}/discord-component-attachments.log
|
||
if-no-files-found: error
|
||
retention-days: 7
|
||
|
||
- 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: Upload gateway watch regression artifacts
|
||
if: always() && needs.preflight.outputs.run_gateway_watch == 'true'
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: gateway-watch-regression
|
||
path: .local/gateway-watch-regression/
|
||
retention-days: 7
|
||
|
||
control-ui-performance:
|
||
permissions:
|
||
contents: read
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_control_ui_performance == 'true'
|
||
runs-on: &shared_small_linux_runner ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON(((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04')))) || (((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04')) }}
|
||
timeout-minutes: 15
|
||
env:
|
||
CHECKOUT_BASE_SHA: ${{ needs.preflight.outputs.diff_base_revision }}
|
||
COMPATIBILITY_TARGET: &historical_target ${{ needs.preflight.outputs.compatibility_target }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- &linux_node_setup_step
|
||
name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
dependency-cache: *trusted_dependency_cache
|
||
|
||
- name: Check Control UI performance against base
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
if node -e 'process.exit(require("./package.json").scripts?.["ui:check-performance:base"] ? 0 : 1)'; then
|
||
pnpm ui:check-performance:base "$CHECKOUT_BASE_SHA"
|
||
elif [[ "$COMPATIBILITY_TARGET" == "true" ]]; then
|
||
echo "Skipping separate Control UI performance check: unavailable on the selected compatibility target; its artifact build retains its historical policy." >> "$GITHUB_STEP_SUMMARY"
|
||
else
|
||
echo "ui:check-performance:base is required for non-compatibility targets." >&2
|
||
exit 1
|
||
fi
|
||
|
||
native-i18n:
|
||
permissions:
|
||
contents: read
|
||
needs: [preflight]
|
||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_native_i18n == 'true' }}
|
||
runs-on: *shared_small_linux_runner
|
||
timeout-minutes: 10
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
|
||
- *linux_node_setup_step
|
||
|
||
- name: Verify native app i18n source
|
||
run: |
|
||
if node -e 'const scripts = require("./package.json").scripts ?? {}; process.exit(scripts["native:i18n:verify"] ? 0 : 1)'; then
|
||
pnpm native:i18n:verify
|
||
else
|
||
# Historical release targets predate the source/generated split.
|
||
pnpm native:i18n:check
|
||
pnpm android:i18n:check
|
||
pnpm apple:i18n:check
|
||
fi
|
||
|
||
- name: Check native app generated locale parity
|
||
if: ${{ needs.preflight.outputs.strict_native_i18n == 'true' }}
|
||
run: |
|
||
if node -e 'const scripts = require("./package.json").scripts ?? {}; process.exit(scripts["native:i18n:verify"] ? 0 : 1)'; then
|
||
pnpm native:i18n:check
|
||
else
|
||
echo "Historical target was validated by the legacy native checks."
|
||
fi
|
||
|
||
checks-ui:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ needs.preflight.outputs.compatibility_target == 'true' && 'checks-ui' || format('checks-ui ({0}/{1})', matrix.shard, needs.preflight.outputs.ui_test_shard_count) }}
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_ui_tests == 'true'
|
||
# Keep three workers per row; admit hosted hybrid rows only within budget.
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && needs.preflight.outputs.hybrid_hosted_offload == 'true') && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-8vcpu-ubuntu-2404' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04'))))) || ((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && needs.preflight.outputs.hybrid_hosted_offload == 'true') && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-8vcpu-ubuntu-2404' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04'))) }}
|
||
# Hosted full-release shards measured ~15-20 min; grant the hosted budget.
|
||
timeout-minutes: *hosted_budget_timeout_minutes
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 3
|
||
matrix: ${{ fromJSON(needs.preflight.outputs.ui_test_matrix) }}
|
||
env:
|
||
COMPATIBILITY_TARGET: *historical_target
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
node-version: "24.x"
|
||
install-bun: "false"
|
||
dependency-cache: *trusted_dependency_cache
|
||
restore-test-caches: &restore_test_caches ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 'true' || 'false' }}
|
||
|
||
- name: Setup pinned Bun test runtime
|
||
if: needs.preflight.outputs.ui_test_runtime_policy == 'bun-compatible' || needs.preflight.outputs.ui_test_runtime_policy == 'dual'
|
||
uses: ./.ci-harness/.github/actions/setup-test-bun
|
||
|
||
- &cache_playwright_chromium
|
||
name: Cache Playwright Chromium
|
||
if: needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.compatibility_target != 'true'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: ~/.cache/ms-playwright
|
||
key: ${{ runner.os }}-playwright-chromium-1.63.0
|
||
|
||
- &install_playwright_chromium
|
||
name: Install Playwright Chromium
|
||
env:
|
||
FROZEN_TARGET: *frozen_target
|
||
run: |
|
||
if [[ "${COMPATIBILITY_TARGET:-false}" == "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
|
||
elif [[ -f scripts/ensure-playwright-chromium.mts ]]; then
|
||
# A cache miss must not substitute the runner image's unrelated Chromium revision.
|
||
node --import tsx scripts/ensure-playwright-chromium.mts --require-playwright-chromium
|
||
elif [[ "$FROZEN_TARGET" == "true" && -f scripts/ensure-playwright-chromium.mjs ]]; then
|
||
node scripts/ensure-playwright-chromium.mjs
|
||
else
|
||
echo "Target does not provide a supported Playwright Chromium installer." >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Lint Control UI window.open usage
|
||
if: ${{ matrix.shard == 1 }}
|
||
run: pnpm lint:ui:no-raw-window-open
|
||
|
||
- name: Test Control UI
|
||
env:
|
||
OPENCLAW_CI_TEST_RUNTIME_POLICY: ${{ needs.preflight.outputs.ui_test_runtime_policy }}
|
||
OPENCLAW_UI_E2E_DIAGNOSTIC_DIR: ${{ github.workspace }}/.artifacts/control-ui-e2e-timeouts/ui-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
|
||
OPENCLAW_NODE_TEST_CONFIGS_JSON: '["ui/vitest.config.ts"]'
|
||
OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64: ${{ needs.preflight.outputs.ui_test_groups_gzip_base64 }}
|
||
OPENCLAW_NODE_TEST_PLAN_CONCURRENCY: "1"
|
||
OPENCLAW_NODE_TEST_VITEST_ARGS_JSON: ${{ format('["--maxWorkers", "3", "--reporter=verbose", "--reporter=github-actions", "--reporter=./scripts/lib/vitest-resource-reporter.mts"{0}]', needs.preflight.outputs.compatibility_target != 'true' && format(', "--shard={0}/{1}"', matrix.shard, needs.preflight.outputs.ui_test_shard_count) || '') }}
|
||
run: |
|
||
if [[ "$COMPATIBILITY_TARGET" == "true" ]]; then
|
||
# Frozen targets can contain timing-sensitive tests fixed on current main.
|
||
# Give legacy browser fixtures enough headroom on shared hosted runners.
|
||
# Isolate files because older suites can still leak module mocks between tests.
|
||
# Do not retry whole files: several rely on one-shot mocked browser globals.
|
||
pnpm --dir ui test --testTimeout=30000 --isolate
|
||
else
|
||
# Three workers deliberately exercise stable non-default file packing so
|
||
# isolate:false mock-registry leaks fail close to the introducing change.
|
||
DEBUG=vitest:browser:*,pw:browser node --import tsx scripts/ci-run-node-test-shard.mts
|
||
fi
|
||
|
||
- name: Upload Control UI timeout diagnostics
|
||
if: failure()
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: control-ui-test-timeout-${{ matrix.shard }}-${{ github.run_attempt }}
|
||
path: ${{ github.workspace }}/.artifacts/control-ui-e2e-timeouts/ui-shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}/failure-*/failure.public.json
|
||
include-hidden-files: true
|
||
if-no-files-found: ignore
|
||
retention-days: 7
|
||
|
||
checks-ui-e2e:
|
||
permissions:
|
||
contents: read
|
||
name: checks-ui-e2e (${{ matrix.shard }}/${{ matrix.shard_count }})
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_ui_e2e == 'true' && needs.preflight.outputs.compatibility_target != 'true'
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && needs.preflight.outputs.hybrid_hosted_offload == 'true' && matrix.task == 'browser-extension') && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.task == 'control-ui' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04')))) || ((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && needs.preflight.outputs.hybrid_hosted_offload == 'true' && matrix.task == 'browser-extension') && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.task == 'control-ui' && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04')) }}
|
||
# Runtime-budget Chromium projects stay serial.
|
||
timeout-minutes: 25
|
||
env:
|
||
OPENCLAW_UI_E2E_SKIP_REAL_GATEWAY: "1"
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 14
|
||
matrix: ${{ fromJson(needs.preflight.outputs.ui_e2e_matrix) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
node-version: "24.x"
|
||
install-bun: "false"
|
||
dependency-cache: &trusted_first_attempt_dependency_cache ${{ ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || runner.environment != 'self-hosted' || (github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'false' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false') }}
|
||
restore-test-caches: *restore_test_caches
|
||
|
||
- *cache_playwright_chromium
|
||
- *install_playwright_chromium
|
||
|
||
- name: Test Control UI end-to-end
|
||
if: matrix.task == 'control-ui'
|
||
env:
|
||
OPENCLAW_UI_E2E_DIAGNOSTIC_DIR: .artifacts/control-ui-e2e-timeouts/shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}
|
||
VITEST_SHARD_INDEX: ${{ matrix.shard }}
|
||
VITEST_SHARD_COUNT: ${{ matrix.vitest_shard_count }}
|
||
OPENCLAW_VITEST_MAX_WORKERS: ${{ matrix.vitest_max_workers || 2 }}
|
||
OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64: ${{ needs.preflight.outputs.ui_e2e_test_groups_gzip_base64 }}
|
||
run: |
|
||
if [[ -n "${OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64:-}" ]]; then
|
||
OPENCLAW_VITEST_INCLUDE_FILE="$(node --import tsx --input-type=module <<'NODE'
|
||
import { writeFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { decodeNodeTestGroups } from "./scripts/lib/ci-node-test-groups-codec.mts";
|
||
const [group] = decodeNodeTestGroups(process.env.OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64);
|
||
if (group.includePatterns) {
|
||
const includeFile = join(process.env.RUNNER_TEMP, "ui-e2e-include.json");
|
||
writeFileSync(includeFile, JSON.stringify(group.includePatterns));
|
||
process.stdout.write(includeFile);
|
||
}
|
||
NODE
|
||
)"
|
||
export OPENCLAW_VITEST_INCLUDE_FILE
|
||
fi
|
||
node scripts/run-vitest.mjs run \
|
||
--config test/vitest/vitest.ui-e2e.config.ts \
|
||
--configLoader runner \
|
||
--maxWorkers "${OPENCLAW_VITEST_MAX_WORKERS:-2}" \
|
||
--shard "$VITEST_SHARD_INDEX/$VITEST_SHARD_COUNT"
|
||
|
||
- name: Upload Control UI E2E timeout diagnostics
|
||
if: failure() && matrix.task == 'control-ui'
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: control-ui-e2e-timeout-${{ matrix.shard }}-${{ github.run_attempt }}
|
||
path: .artifacts/control-ui-e2e-timeouts/shard-${{ matrix.shard }}-attempt-${{ github.run_attempt }}/failure-*/failure.public.json
|
||
if-no-files-found: ignore
|
||
retention-days: 7
|
||
|
||
- name: Upload synthetic desktop Picture-in-Picture proof
|
||
if: always() && matrix.task == 'control-ui' && hashFiles('.artifacts/control-ui-e2e/desktop-picture-in-picture-*/native-pip-after-tab-switch.png') != ''
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: desktop-pip-proof-${{ strategy.job-index }}-${{ github.run_attempt }}
|
||
path: |
|
||
.artifacts/control-ui-e2e/desktop-picture-in-picture-*/desktop-before-pip.png
|
||
.artifacts/control-ui-e2e/desktop-picture-in-picture-*/native-pip-after-tab-switch.png
|
||
if-no-files-found: error
|
||
retention-days: 7
|
||
|
||
- name: Upload synthetic widget prompt failure evidence
|
||
if: failure() && hashFiles('.artifacts/control-ui-e2e/control-ui-authenticated-widget-sandbox-*/widget-prompt-failure.json') != ''
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: widget-sandbox-ci-${{ strategy.job-index }}-${{ github.run_attempt }}
|
||
path: |
|
||
.artifacts/control-ui-e2e/control-ui-authenticated-widget-sandbox-*/widget-prompt-failure.json
|
||
.artifacts/control-ui-e2e/control-ui-authenticated-widget-sandbox-*/*.png
|
||
.artifacts/control-ui-e2e/control-ui-authenticated-widget-sandbox-*/*.webm
|
||
if-no-files-found: error
|
||
retention-days: 7
|
||
|
||
- name: Test browser extension bootstrap end-to-end
|
||
if: matrix.task == 'browser-extension'
|
||
run: pnpm test:e2e:browser-extension
|
||
|
||
checks-ui-e2e-real-gateway:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ matrix.shard_count == 1 && 'checks-ui-e2e-real-gateway' || format('checks-ui-e2e-real-gateway ({0}/{1})', matrix.shard, matrix.shard_count) }}
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_ui_real_gateway == 'true' && needs.preflight.outputs.compatibility_target != 'true'
|
||
# Trust gates Blacksmith; available memory gates SDK overlap.
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04')))) || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-24.04')) }}
|
||
timeout-minutes: ${{ ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) || (github.event_name == 'workflow_dispatch' && needs.preflight.outputs.ci_shape != 'main') || (github.event_name == 'pull_request' && github.run_attempt > 1) || github.repository != 'openclaw/openclaw' || (github.event_name == 'pull_request' && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association))) && 40 || 20 }}
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 2
|
||
matrix: ${{ fromJson(needs.preflight.outputs.ui_real_gateway_matrix) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
node-version: "24.x"
|
||
install-bun: "false"
|
||
dependency-cache: *trusted_first_attempt_dependency_cache
|
||
|
||
- *cache_playwright_chromium
|
||
- *install_playwright_chromium
|
||
|
||
- name: Build runtime and Control UI artifacts for real-Gateway tests
|
||
env:
|
||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||
# Only build-artifacts owns SDK declarations.
|
||
OPENCLAW_RUN_NODE_SKIP_DTS_BUILD: "1"
|
||
run: pnpm build
|
||
|
||
- name: Prove desktop resize over node and SSH
|
||
if: matrix.run_desktop
|
||
env:
|
||
FROZEN_TARGET: *frozen_target
|
||
DESKTOP_PROOF_CHECKOUT_SHA: *checkout_sha
|
||
DESKTOP_PROOF_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||
DESKTOP_PROOF_PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
DESKTOP_PROOF_WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: |
|
||
bootstrap=scripts/test-desktop-resize-real.mts
|
||
if [[ ! -f "$bootstrap" ]]; then
|
||
if [[ "$FROZEN_TARGET" == "true" ]]; then
|
||
echo "::notice::Frozen target has no desktop resize fixture bootstrap; no desktop resize proof produced"
|
||
exit 0
|
||
fi
|
||
echo "::error::Current target is missing $bootstrap" >&2
|
||
exit 1
|
||
fi
|
||
node --import tsx "$bootstrap"
|
||
|
||
- name: Test Control UI suites with a real Gateway
|
||
if: matrix.run_tests
|
||
env:
|
||
FROZEN_TARGET: *frozen_target
|
||
OPENCLAW_CAPTURE_UI_PROOF: ${{ github.event_name == 'workflow_dispatch' && inputs.capture_ui_proof && '1' || '0' }}
|
||
OPENCLAW_UI_E2E_ARTIFACT_DIR: .artifacts/control-ui-e2e/real-gateway
|
||
OPENCLAW_UI_E2E_DIAGNOSTIC_DIR: .artifacts/control-ui-e2e-timeouts/real-gateway-attempt-${{ github.run_attempt }}
|
||
OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64: ${{ matrix.test_groups_gzip_base64 }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ -n "${OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64:-}" ]]; then
|
||
OPENCLAW_VITEST_INCLUDE_FILE="$(node --import tsx --input-type=module <<'NODE'
|
||
import { writeFileSync } from "node:fs";
|
||
import { join } from "node:path";
|
||
import { decodeNodeTestGroups } from "./scripts/lib/ci-node-test-groups-codec.mts";
|
||
const [group] = decodeNodeTestGroups(process.env.OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64);
|
||
if (group.includePatterns) {
|
||
const includeFile = join(process.env.RUNNER_TEMP, "ui-real-gateway-include.json");
|
||
writeFileSync(includeFile, JSON.stringify(group.includePatterns));
|
||
process.stdout.write(includeFile);
|
||
}
|
||
NODE
|
||
)"
|
||
export OPENCLAW_VITEST_INCLUDE_FILE
|
||
fi
|
||
config=test/vitest/vitest.ui-e2e-prebuilt.config.ts
|
||
# Old frozen targets retain their own serial config and complete test list.
|
||
# A present config's readiness failure must fail this invocation.
|
||
if [[ ! -f "$config" ]]; then
|
||
if [[ "$FROZEN_TARGET" != "true" ]]; then
|
||
echo "::error::Current target is missing $config" >&2
|
||
exit 1
|
||
fi
|
||
config=test/vitest/vitest.ui-e2e.config.ts
|
||
fi
|
||
# Frozen targets retain their own reporter implementation and defaults.
|
||
reporter_args=()
|
||
if [[ "$FROZEN_TARGET" != "true" ]]; then
|
||
reporter_args=(--reporter verbose --reporter github-actions --reporter default --reporter ./scripts/lib/vitest-resource-reporter.mts)
|
||
fi
|
||
if [[ "$config" == test/vitest/vitest.ui-e2e-prebuilt.config.ts ]]; then
|
||
# The canonical config owns this inventory; desktop transport proof runs above.
|
||
node scripts/run-vitest.mjs run \
|
||
--config "$config" \
|
||
--configLoader runner \
|
||
"${reporter_args[@]}" \
|
||
--exclude ui/src/e2e/desktop-resize.real-gateway.e2e.test.ts
|
||
exit 0
|
||
fi
|
||
node scripts/run-vitest.mjs run \
|
||
--config "$config" \
|
||
--configLoader runner \
|
||
"${reporter_args[@]}" \
|
||
ui/src/e2e/mcp-app-conformance.e2e.test.ts \
|
||
ui/src/e2e/control-ui-auth-transports.e2e.test.ts \
|
||
ui/src/e2e/usage-sessions-owner-attribution.e2e.test.ts \
|
||
ui/src/e2e/profile-page.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/quota-reset-status.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/logs-lifecycle.e2e.test.ts \
|
||
ui/src/e2e/agent-file-lifecycle.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-collaborator-scroll.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-composer-websearch-kill-switch.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-flow.catalog-bootstrap.e2e.test.ts \
|
||
ui/src/e2e/chat-agent-avatar.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-loading-performance.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-project-media.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-stop-finished-run.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-thinking-metadata.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-tts-supplement.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/chat-widget-sandbox.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/command-palette-catalog.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/command-palette-search.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/cron-duration-save.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/model-api-keys.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/model-catalog-partial-refresh.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/model-picker-search.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/provider-browser-login.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/device-alias-rename.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/device-platform-family.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/session-roster-request-rate.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/session-pr-reader-lifetime.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/session-progress-hovercard.real-gateway.e2e.test.ts \
|
||
ui/src/e2e/worker-initial-setup.real-gateway.e2e.test.ts \
|
||
extensions/qa-lab/src/control-ui-media-transcript.real-gateway.e2e.test.ts \
|
||
extensions/qa-lab/src/session-host-command-state.real-gateway.e2e.test.ts \
|
||
extensions/qa-lab/src/control-ui-openclaw-delegation.real-gateway.e2e.test.ts \
|
||
extensions/qa-lab/src/control-ui-automation-management.real-gateway.e2e.test.ts
|
||
|
||
- name: Upload Control UI real-Gateway failure diagnostics
|
||
if: failure()
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: control-ui-real-gateway-timeout-${{ github.run_attempt }}-${{ matrix.shard }}
|
||
path: |
|
||
.artifacts/control-ui-e2e-timeouts/real-gateway-attempt-${{ github.run_attempt }}/failure-*/failure.public.json
|
||
if-no-files-found: ignore
|
||
retention-days: 7
|
||
|
||
- name: Upload quota auth and transport diagnostics
|
||
if: always()
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: control-ui-quota-diagnostics-${{ github.run_attempt }}-${{ matrix.shard }}
|
||
path: .artifacts/control-ui-e2e/real-gateway/quota-refresh-*/quota.public.json
|
||
if-no-files-found: ignore
|
||
retention-days: 7
|
||
|
||
- name: Upload sanitized desktop resize proof
|
||
if: always() && matrix.run_desktop
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: desktop-resize-proof-${{ github.run_id }}-${{ github.run_attempt }}
|
||
path: .artifacts/control-ui-e2e/real-gateway/desktop-resize
|
||
if-no-files-found: warn
|
||
retention-days: 14
|
||
|
||
- name: Upload model picker public observations
|
||
if: always()
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: model-picker-public-proof-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard }}
|
||
path: .artifacts/control-ui-e2e/real-gateway/model-picker-public-*
|
||
if-no-files-found: warn
|
||
retention-days: 14
|
||
|
||
- name: Upload sanitized Control UI real-Gateway proof
|
||
if: always() && github.event_name == 'workflow_dispatch' && inputs.capture_ui_proof
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: control-ui-real-gateway-proof-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard }}
|
||
path: .artifacts/control-ui-e2e/real-gateway
|
||
if-no-files-found: error
|
||
retention-days: 14
|
||
|
||
control-ui-i18n:
|
||
permissions:
|
||
contents: read
|
||
name: control-ui-i18n
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
|
||
runs-on: *shared_small_linux_runner
|
||
timeout-minutes: 10
|
||
env:
|
||
COMPATIBILITY_TARGET: *historical_target
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
node-version: "24.x"
|
||
install-bun: "false"
|
||
dependency-cache: *trusted_dependency_cache
|
||
|
||
- name: Verify Control UI i18n source
|
||
run: |
|
||
if node -e 'process.exit(require("./package.json").scripts?.["ui:i18n:verify"] ? 0 : 1)'; then
|
||
pnpm ui:i18n:verify
|
||
elif [[ "$COMPATIBILITY_TARGET" == "true" ]]; then
|
||
echo "Skipping ui:i18n:verify: unavailable on the selected compatibility target." >> "$GITHUB_STEP_SUMMARY"
|
||
else
|
||
echo "ui:i18n:verify is required for non-compatibility targets." >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Check Control UI locale parity
|
||
continue-on-error: ${{ needs.preflight.outputs.strict_control_ui_i18n != 'true' }}
|
||
run: pnpm ui:i18n:check
|
||
|
||
checks-baseline-ratchets:
|
||
permissions:
|
||
contents: read
|
||
pull-requests: read
|
||
name: checks-fast-baseline-ratchets
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_baseline_ratchets == 'true'
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON(((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'runson' || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'hybrid' && (github.run_attempt != 1 || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || needs.preflight.outputs.node_runner_backend == 'runson' || (github.event_name == 'workflow_dispatch' && needs.preflight.outputs.ci_qualification != 'true') || needs.preflight.outputs.frozen_target == 'true')))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04')))) || (((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'runson' || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'hybrid' && (github.run_attempt != 1 || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) || needs.preflight.outputs.node_runner_backend == 'runson' || (github.event_name == 'workflow_dispatch' && needs.preflight.outputs.ci_qualification != 'true') || needs.preflight.outputs.frozen_target == 'true')))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04')) }}
|
||
timeout-minutes: 60
|
||
env:
|
||
CHECKOUT_BASE_SHA: ${{ needs.preflight.outputs.diff_base_revision }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Prepare release-gate ratchet merge tree
|
||
if: github.event_name == 'workflow_dispatch' && inputs.release_gate
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||
TARGET_SHA: ${{ inputs.target_ref }}
|
||
shell: bash
|
||
run: &prepare_ratchet_merge |
|
||
set -euo pipefail
|
||
pr_head="$(
|
||
gh api --method GET "repos/${GITHUB_REPOSITORY}/pulls/${PULL_REQUEST_NUMBER}" |
|
||
jq -r --arg repo "$GITHUB_REPOSITORY" --arg target "$TARGET_SHA" '
|
||
select(.state == "open" and .head.sha == $target and .base.repo.full_name == $repo)
|
||
| .head.sha
|
||
'
|
||
)"
|
||
if [[ "$pr_head" != "$TARGET_SHA" ]]; then
|
||
echo "release-gate pull request must be open and match the target head" >&2
|
||
exit 1
|
||
fi
|
||
# Freeze GitHub's canonical merge snapshot once it contains the exact head.
|
||
# Base freshness belongs to the landing gate; chasing moving main here can never converge.
|
||
prepared=false
|
||
for attempt in {1..6}; do
|
||
if python3 -I -S "$RUNNER_TEMP/ci-git-owner.py" --checkout-git 120 fetch --no-tags --depth=2 origin \
|
||
"+refs/pull/${PULL_REQUEST_NUMBER}/merge:refs/remotes/origin/ci-ratchet-merge"; then
|
||
merge_sha="$(git rev-parse refs/remotes/origin/ci-ratchet-merge)"
|
||
read -r frozen_base_sha merge_head extra_parent <<<"$(git show -s --format=%P "$merge_sha")"
|
||
if [[ "$merge_head" == "$TARGET_SHA" && -z "$extra_parent" ]]; then
|
||
prepared=true
|
||
break
|
||
fi
|
||
else
|
||
fetch_status="$?"
|
||
# Cleanup uncertainty and cancellation never authorize another merge attempt.
|
||
case "$fetch_status" in 125|129|130|143) exit "$fetch_status" ;; esac
|
||
fi
|
||
if [[ "$attempt" != "6" ]]; then
|
||
echo "::warning::GitHub merge ref is not ready for the selected head; retrying acquisition ($attempt/6)." >&2
|
||
sleep 5
|
||
fi
|
||
done
|
||
if [[ "$prepared" != "true" ]]; then
|
||
echo "release-gate merge tree did not refresh to the target head" >&2
|
||
exit 1
|
||
fi
|
||
python3 -I -S "$RUNNER_TEMP/ci-git-owner.py" --git 0 checkout --detach "$merge_sha"
|
||
echo "RATCHET_BASE_REF=${frozen_base_sha}" >> "$GITHUB_ENV"
|
||
|
||
- *linux_node_setup_step
|
||
|
||
- name: Run baseline ratchets
|
||
env:
|
||
RATCHET_PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
has_package_script() {
|
||
node -e '
|
||
const scripts = require("./package.json").scripts ?? {};
|
||
process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1);
|
||
' "$1"
|
||
}
|
||
for required_script in check:max-lines-ratchet check:assertion-safety config:docs:check plugins:inventory:check; do
|
||
if ! has_package_script "$required_script"; then
|
||
echo "Current CI targets must provide ${required_script}." >&2
|
||
exit 1
|
||
fi
|
||
done
|
||
base_ref="${RATCHET_BASE_REF:-refs/remotes/origin/ci-ratchet-base}"
|
||
if ! git cat-file -e "${base_ref}^{commit}" 2>/dev/null; then
|
||
echo "Prepared ratchet base ${base_ref} is unavailable." >&2
|
||
exit 1
|
||
fi
|
||
if [[ -n "${RATCHET_PR_HEAD_SHA:-}" ]]; then
|
||
mapfile -t merge_parents < <(git cat-file -p HEAD | sed -n 's/^parent //p')
|
||
if [[ "${#merge_parents[@]}" != "2" || "${merge_parents[1]:-}" != "$RATCHET_PR_HEAD_SHA" ]]; then
|
||
echo "Pull request checkout is not the expected two-parent merge tree." >&2
|
||
exit 1
|
||
fi
|
||
prepared_base="$(git rev-parse "$base_ref")"
|
||
if [[ "${merge_parents[0]}" != "$prepared_base" ]]; then
|
||
echo "Pull request merge base does not match the prepared preflight base." >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
pnpm check:max-lines-ratchet --base "$base_ref"
|
||
if [[ -n "${RATCHET_PR_HEAD_SHA:-}" ]]; then
|
||
pnpm check:line-cap-ratchet --base "$base_ref"
|
||
fi
|
||
pnpm check:assertion-safety --base "$base_ref"
|
||
pnpm config:docs:check
|
||
pnpm plugins:inventory:check
|
||
|
||
checks-fast-core:
|
||
permissions:
|
||
contents: read
|
||
pull-requests: read
|
||
name: ${{ matrix.check_name || 'checks-fast-core' }}
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_checks_fast_core == 'true'
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON(((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')))) || (((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (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) }}
|
||
env:
|
||
CHECKOUT_BASE_SHA: ${{ (matrix.task == 'startup-corpus' || matrix.task == 'bundled-protocol' || startsWith(matrix.task, 'release-lint-')) && needs.preflight.outputs.diff_base_revision || '' }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Smoke test trusted Git owner action
|
||
id: git_owner_smoke
|
||
if: matrix.task == 'bundled-protocol' || matrix.task == 'ci-routing' || matrix.task == 'contracts-plugins-ci-routing'
|
||
uses: ./.ci-harness/.github/actions/git-owner
|
||
- name: Verify trusted Git owner bootstrap
|
||
if: matrix.task == 'bundled-protocol' || matrix.task == 'ci-routing' || matrix.task == 'contracts-plugins-ci-routing'
|
||
env:
|
||
OWNER_PATH: ${{ steps.git_owner_smoke.outputs.owner-path }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
test "$OWNER_PATH" = "$CI_GIT_OWNER"
|
||
cmp "$OWNER_PATH" .ci-harness/.github/actions/git-owner/owner.py
|
||
python3 -I -S "$OWNER_PATH" --git 0 --version
|
||
- name: Prepare release-gate ratchet merge tree
|
||
if: (matrix.task == 'startup-corpus' || startsWith(matrix.task, 'release-lint-')) && github.event_name == 'workflow_dispatch' && inputs.release_gate
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
PULL_REQUEST_NUMBER: ${{ inputs.pull_request_number }}
|
||
TARGET_SHA: ${{ inputs.target_ref }}
|
||
shell: bash
|
||
run: *prepare_ratchet_merge
|
||
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
semantic-checks: "true"
|
||
cache-mode: *cache_mode
|
||
install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || 'false' }}
|
||
dependency-cache: *trusted_dependency_cache
|
||
restore-test-caches: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && (matrix.task == 'bundled-protocol' || matrix.task == 'contracts-plugins-ci-routing' || matrix.task == 'ci-routing' || matrix.task == 'bun-launcher') && 'true' || 'false' }}
|
||
|
||
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
|
||
env:
|
||
OPENCLAW_TEST_PROJECTS_PARALLEL: 3
|
||
PROTOCOL_SINCE_BASE_SHA: ${{ needs.preflight.outputs.diff_base_revision }}
|
||
TASK: ${{ matrix.task }}
|
||
RUN_BUNDLED_TESTS: ${{ needs.preflight.outputs.run_proof_tier }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
case "$TASK" in
|
||
bundled-protocol)
|
||
if [[ "$RUN_BUNDLED_TESTS" == "true" ]]; then
|
||
pnpm test:bundled
|
||
fi
|
||
test "$(git rev-parse refs/remotes/origin/ci-ratchet-base^{commit})" = "$PROTOCOL_SINCE_BASE_SHA"
|
||
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/changed-path-facts.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-changed-node-test-plan.config-fallback.test.ts test/scripts/ci-changed-node-test-plan.dependency-inputs.test.ts test/scripts/ci-changed-node-test-plan.dependency-hubs.test.ts test/scripts/ci-changed-node-test-plan.policy.test.ts test/scripts/ci-changed-node-test-plan.process-owners.test.ts test/scripts/ci-changed-node-test-plan.source-owners.test.ts test/scripts/ci-docker-seed-plan.test.ts test/scripts/ci-run-node-test-shard.test.ts test/scripts/ci-hourly.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/ci-workflow-planning.test.ts test/scripts/ci-workflow-evidence.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/changed-path-facts.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-changed-node-test-plan.config-fallback.test.ts test/scripts/ci-changed-node-test-plan.dependency-inputs.test.ts test/scripts/ci-changed-node-test-plan.dependency-hubs.test.ts test/scripts/ci-changed-node-test-plan.policy.test.ts test/scripts/ci-changed-node-test-plan.process-owners.test.ts test/scripts/ci-changed-node-test-plan.source-owners.test.ts test/scripts/ci-docker-seed-plan.test.ts test/scripts/ci-run-node-test-shard.test.ts test/scripts/ci-hourly.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/ci-workflow-planning.test.ts test/scripts/ci-workflow-evidence.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts
|
||
;;
|
||
coercion-helpers)
|
||
pnpm check:coercion-helpers
|
||
;;
|
||
startup-corpus)
|
||
# The following step owns startup coverage; policy runs in the dedicated ratchet job.
|
||
;;
|
||
release-lint-core-*)
|
||
stripe="${TASK#release-lint-core-}"
|
||
node --import tsx scripts/run-oxlint-shards.mts \
|
||
--only=core --split-core --core-stripe="${stripe}/5" --threads=1
|
||
;;
|
||
release-lint-extensions)
|
||
node --import tsx scripts/run-oxlint-shards.mts --only=extensions --threads=1
|
||
;;
|
||
bun-launcher)
|
||
OPENCLAW_E2E_SKIP_BUILD=1 OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test test/openclaw-launcher.e2e.test.ts
|
||
if [[ -f src/plugins/plugin-module-generation.bun.test.ts ]]; then
|
||
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test src/plugins/plugin-module-generation.bun.test.ts
|
||
elif [[ -f src/plugins/plugin-module-generation.test.ts ]]; then
|
||
OPENCLAW_TEST_BUN_LAUNCHER=1 pnpm test src/plugins/plugin-module-generation.test.ts --testNamePattern Bun
|
||
elif [[ "${{ needs.preflight.outputs.frozen_target }}" != "true" ]]; then
|
||
echo "Current CI targets must provide src/plugins/plugin-module-generation.test.ts." >&2
|
||
exit 1
|
||
fi
|
||
;;
|
||
*)
|
||
echo "Unsupported checks-fast task: $TASK" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
- name: Check startup corpus
|
||
if: matrix.task == 'startup-corpus' && !(github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main' && needs.preflight.outputs.run_check == 'true') && !(github.repository == 'openclaw/openclaw' && github.event_name == 'pull_request' && needs.preflight.outputs.startup_corpus_node_revision && needs.preflight.outputs.startup_corpus_node_revision == needs.preflight.outputs.checkout_revision)
|
||
env:
|
||
OPENCLAW_CI_STARTUP_CORPUS_TEST_FILES_JSON: ${{ needs.preflight.outputs.startup_corpus_test_files_json }}
|
||
run: |
|
||
set -euo pipefail
|
||
# Prepare once before Vitest workers consume the runtime.
|
||
pnpm build qaRuntime
|
||
run_startup_corpus() {
|
||
if [[ "${{ needs.preflight.outputs.frozen_target }}" == "true" ]]; then
|
||
node scripts/run-vitest.mjs run --config test/vitest/vitest.runtime-config.config.ts "$@"
|
||
else
|
||
node scripts/run-vitest.mjs run --config test/vitest/vitest.runtime-config.config.ts \
|
||
--reporter verbose --reporter github-actions --reporter ./scripts/lib/vitest-resource-reporter.mts "$@"
|
||
fi
|
||
}
|
||
if [[ -n "${OPENCLAW_CI_STARTUP_CORPUS_TEST_FILES_JSON:-}" ]]; then
|
||
# Use the manifest's inventory for both Node receipts and this fallback.
|
||
startup_files_text="$(node -p 'JSON.parse(process.env.OPENCLAW_CI_STARTUP_CORPUS_TEST_FILES_JSON).join(" ")')"
|
||
read -r -a startup_files <<< "$startup_files_text"
|
||
startup_workers="$(node -p 'Math.min(4, require("node:os").availableParallelism())')"
|
||
run_startup_corpus --maxWorkers="$startup_workers" "${startup_files[@]}"
|
||
exit 0
|
||
elif [[ -f test/vitest/vitest.startup-corpus-paths.mjs ]]; then
|
||
# Runner labels can advertise more CPUs than this process can use.
|
||
startup_workers="$(node -p 'Math.min(4, require("node:os").availableParallelism())')"
|
||
run_startup_corpus --maxWorkers="$startup_workers" src/config/config-startup-corpus.test.ts \
|
||
src/config/state-startup-corpus*.test.ts
|
||
exit 0
|
||
elif [[ "${{ needs.preflight.outputs.frozen_target }}" != "true" ]]; then
|
||
echo "Current CI targets must provide the startup corpus file inventory." >&2
|
||
exit 1
|
||
fi
|
||
# Frozen targets before the file split retain their complete legacy matrix.
|
||
cores="$(node -p 'require("node:os").availableParallelism()')"
|
||
# Worker compilation also uses CPU: reserve four cores per invocation.
|
||
# The 4-vCPU ratchets label can deliver only two usable CPUs.
|
||
concurrency=$((cores / 4))
|
||
if (( concurrency < 1 )); then concurrency=1; fi
|
||
if (( concurrency > 5 )); then concurrency=5; fi
|
||
echo "[corpus:resources] logicalCpuCount=$cores admitted runs=$concurrency"
|
||
pids=()
|
||
labels=()
|
||
result=0
|
||
wait_corpus_batch() {
|
||
for index in "${!pids[@]}"; do
|
||
if ! wait "${pids[$index]}"; then
|
||
echo "::error::${labels[$index]} failed"
|
||
result=1
|
||
fi
|
||
done
|
||
pids=()
|
||
labels=()
|
||
}
|
||
for shard in {0..4}; do
|
||
if (( shard == 0 )); then
|
||
run_startup_corpus src/config/config-startup-corpus.test.ts &
|
||
labels+=("config corpus")
|
||
else
|
||
OPENCLAW_TEST_STARTUP_CORPUS_SHARD="${shard}/4" \
|
||
run_startup_corpus src/config/state-startup-corpus.test.ts &
|
||
labels+=("state corpus ${shard}/4")
|
||
fi
|
||
pids+=("$!")
|
||
if (( ${#pids[@]} == concurrency )); then
|
||
wait_corpus_batch
|
||
fi
|
||
done
|
||
wait_corpus_batch
|
||
exit "$result"
|
||
|
||
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: *shared_16vcpu_linux_runner
|
||
timeout-minutes: 60
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 6 || 4 }}
|
||
matrix: ${{ fromJson(needs.preflight.outputs.qa_smoke_ci_matrix) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
dependency-cache: *trusted_dependency_cache
|
||
node-compile-cache: "true"
|
||
|
||
- name: Build QA smoke runtime
|
||
env:
|
||
NODE_OPTIONS: --max-old-space-size=8192
|
||
run: |
|
||
# The smoke coverage set contains no docker-lane or Control UI
|
||
# scenarios (the run step fails closed if one returns), so the
|
||
# public pack and ui:build are skipped: one private overlay build
|
||
# halves this fixed cost on every part. Never pack dist after a
|
||
# private build; the overlay must not leak into public artifacts.
|
||
OPENCLAW_BUILD_PRIVATE_QA=1 pnpm build qaRuntime
|
||
|
||
- name: Run smoke profile part
|
||
env:
|
||
PROFILE_PART: ${{ matrix.lane }}
|
||
PROFILE_PART_COUNT: ${{ matrix.part_count }}
|
||
PROFILE_PART_SLUG: ${{ matrix.slug }}
|
||
OPENCLAW_QA_SUITE_WORKER_START_STAGGER_MS: ${{ needs.preflight.outputs.runner_profile == 'blacksmith' && '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
|
||
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 ?? "";
|
||
const partCount = Number(process.env.PROFILE_PART_COUNT ?? "4");
|
||
let runs;
|
||
if (typeof smokePlan.createQaSmokeCiPart === "function") {
|
||
try {
|
||
runs = smokePlan.createQaSmokeCiPart(partId, partCount).runs;
|
||
} catch (error) {
|
||
// Frozen/compat targets ship older planners that declare fewer
|
||
// profile parts; the declared parts still cover every scenario.
|
||
if (/unknown QA smoke CI profile part/.test(String(error)) && partId !== "profile-1") {
|
||
// stdout is the TSV contract consumed below. Keep diagnostics on stderr
|
||
// so an empty compatibility shard remains empty instead of becoming a run.
|
||
console.error(`[skip] ${partId} is not declared by this checkout's smoke plan`);
|
||
process.exit(0);
|
||
}
|
||
throw error;
|
||
}
|
||
} else if (typeof smokePlan.createQaSmokeCiMatrix === "function") {
|
||
// Legacy planners select the entire profile and can mix long-lived
|
||
// execution kinds. Reuse the current bounded smoke contract and
|
||
// isolate each scenario so one invocation cannot pin a profile part.
|
||
const compatibilityScenarioIds = new Set([
|
||
"system-agent-ring-zero-setup",
|
||
"gateway-smoke",
|
||
"group-visible-reply-tool",
|
||
"long-running-release-audit",
|
||
"luna-thinking-visibility-switch",
|
||
"matrix-restart-resume",
|
||
"personal-task-followthrough-status",
|
||
"plugin-lifecycle-hot-reload",
|
||
"subagent-completion-direct-fallback",
|
||
"telegram-commands-command",
|
||
]);
|
||
const partIndex = partId === "profile-1" ? 0 : partId === "profile-2" ? 1 : -1;
|
||
if (partIndex < 0) {
|
||
console.error(`[skip] ${partId} is not declared by this checkout's legacy smoke plan`);
|
||
process.exit(0);
|
||
}
|
||
const scenarioCatalog = await import("./extensions/qa-lab/src/scenario-catalog.ts");
|
||
const scenarioKindById = new Map(
|
||
scenarioCatalog
|
||
.readQaScenarioPack()
|
||
.scenarios.map((scenario) => [scenario.id, scenario.execution.kind]),
|
||
);
|
||
const legacyRuns = smokePlan
|
||
.createQaSmokeCiMatrix()
|
||
.include.filter((_, index) => index % 2 === partIndex);
|
||
runs = legacyRuns.flatMap((run) =>
|
||
run.scenario_ids.flatMap((scenarioId) => {
|
||
if (!compatibilityScenarioIds.has(scenarioId)) {
|
||
return [];
|
||
}
|
||
const kind = scenarioKindById.get(scenarioId);
|
||
if (!kind) {
|
||
throw new Error(`legacy QA smoke scenario not found: ${scenarioId}`);
|
||
}
|
||
return [
|
||
{
|
||
...run,
|
||
slug: `${run.slug}-${kind}-${scenarioId}`,
|
||
scenario_ids: [scenarioId],
|
||
},
|
||
];
|
||
}),
|
||
);
|
||
} else {
|
||
throw new Error("QA smoke plan does not expose a supported CI planner.");
|
||
}
|
||
// The build step skips the public pack because no smoke scenario
|
||
// uses the docker lane; fail closed instead of running one without
|
||
// its packaged tgz.
|
||
const catalog = await import("./extensions/qa-lab/src/scenario-catalog.ts");
|
||
const scenarioById = new Map(
|
||
catalog.readQaScenarioPack().scenarios.map((scenario) => [scenario.id, scenario]),
|
||
);
|
||
for (const run of runs) {
|
||
for (const scenarioId of run.scenario_ids) {
|
||
const executionPath = scenarioById.get(scenarioId)?.execution?.path ?? "";
|
||
if (executionPath.includes("docker")) {
|
||
throw new Error(
|
||
`smoke scenario ${scenarioId} needs the docker lane; restore the public pack step in ci.yml before selecting it`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
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: *shared_small_linux_runner
|
||
timeout-minutes: 60
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 12
|
||
matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- &contract_node_setup_step
|
||
name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
dependency-cache: *trusted_dependency_cache
|
||
restore-test-caches: *restore_test_caches
|
||
|
||
- name: Run plugin contract shard
|
||
env:
|
||
OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix) }}
|
||
shell: bash
|
||
run: &run_contract_groups |
|
||
set -euo pipefail
|
||
include_list="$RUNNER_TEMP/contract-includes.list"
|
||
INCLUDE_FILE="$include_list" node --input-type=module <<'EOF'
|
||
import { writeFileSync } from "node:fs";
|
||
|
||
const { task, groups } = JSON.parse(process.env.OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON ?? "{}");
|
||
const script = {
|
||
"contracts-plugins": "test:contracts:plugins",
|
||
"contracts-channels": "test:contracts:channels",
|
||
}[task];
|
||
if (!script || !Array.isArray(groups) || groups.length === 0) {
|
||
throw new Error("Missing contract task or process groups");
|
||
}
|
||
const entries = groups.map(({ checkName, includePatterns }, index) => {
|
||
if (!Array.isArray(includePatterns) || includePatterns.length === 0) {
|
||
throw new Error("Missing contract include patterns");
|
||
}
|
||
const includeFile = `${process.env.INCLUDE_FILE}.${index}.json`;
|
||
writeFileSync(includeFile, JSON.stringify(includePatterns), "utf8");
|
||
return `${includeFile}\t${script}\t${checkName}`;
|
||
});
|
||
writeFileSync(process.env.INCLUDE_FILE, `${entries.join("\n")}\n`, "utf8");
|
||
EOF
|
||
# A nonzero exit can mean unverified cleanup; do not admit another envelope.
|
||
while IFS=$'\t' read -r include_file contract_script contract_name; do
|
||
echo "::group::${contract_name}"
|
||
contract_exit=0
|
||
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm "$contract_script" || contract_exit=$?
|
||
echo "::endgroup::"
|
||
if [[ "$contract_exit" -ne 0 ]]; then
|
||
exit "$contract_exit"
|
||
fi
|
||
done < "$include_list"
|
||
|
||
checks-fast-channel-contracts-shard:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ matrix.checkName }}
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_channel_contracts_shards == 'true'
|
||
runs-on: *shared_small_linux_runner
|
||
timeout-minutes: 60
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 12
|
||
matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- *contract_node_setup_step
|
||
|
||
- name: Run channel contract shard
|
||
env:
|
||
OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix) }}
|
||
OPENCLAW_TEST_PROJECTS_PARALLEL: "4"
|
||
shell: bash
|
||
run: *run_contract_groups
|
||
|
||
checks-node-compat:
|
||
permissions:
|
||
contents: read
|
||
name: checks-node-compat-node24
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch' && needs.preflight.outputs.validation_tier != 'main' && (needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true')
|
||
runs-on: *shared_small_linux_runner
|
||
timeout-minutes: 60
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
semantic-checks: "true"
|
||
cache-mode: *cache_mode
|
||
node-version: "24.16.0"
|
||
install-bun: "false"
|
||
build-all-cache-scope: full
|
||
|
||
- name: Configure Node test resources
|
||
run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV"
|
||
|
||
- name: Run Node 24 minimum 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
|
||
pnpm test src/config/sessions/session-accessor.test.ts src/config/sessions/store-writer.test.ts src/config/sessions/sessions.test.ts
|
||
|
||
checks-node-core-test-nondist-shard:
|
||
permissions:
|
||
contents: read
|
||
env:
|
||
CHECKOUT_GIT_COMMITS_JSON: ${{ toJson(matrix.git_commits) }}
|
||
name: ${{ matrix.check_name || 'checks-node-core-test-nondist-shard' }}
|
||
needs: [preflight]
|
||
if: ${{ !cancelled() && needs.preflight.result == 'success' && needs.preflight.outputs.run_checks_node_core_nondist == 'true' }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON(needs.preflight.outputs.node_runner_backend == 'runson' && matrix.runner == 'runson-c8i-8xlarge' && github.run_attempt == 1 && format('runs-on={0}-{1}/family=c8i.8xlarge/cpu=32/ram=64/spot=true/retry=false/image=ubuntu24-full-x64/volume=80gb', github.run_id, matrix.check_name) || matrix.runner == 'runson-c8i-8xlarge' && 'ubuntu-24.04' || (needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true') && github.run_attempt == 1 && ((matrix.runner == 'blacksmith-8vcpu-ubuntu-2404' && contains(fromJSON('["checks-node-compact-large-9","checks-node-compact-large-5"]'), matrix.check_name)) && 'blacksmith-16vcpu-ubuntu-2404' || matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && ((matrix.runner == 'blacksmith-8vcpu-ubuntu-2404' && contains(fromJSON('["checks-node-compact-large-9","checks-node-compact-large-5"]'), matrix.check_name)) && 'blacksmith-16vcpu-ubuntu-2404' || matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')))) || (needs.preflight.outputs.node_runner_backend == 'runson' && matrix.runner == 'runson-c8i-8xlarge' && github.run_attempt == 1 && format('runs-on={0}-{1}/family=c8i.8xlarge/cpu=32/ram=64/spot=true/retry=false/image=ubuntu24-full-x64/volume=80gb', github.run_id, matrix.check_name) || matrix.runner == 'runson-c8i-8xlarge' && 'ubuntu-24.04' || (needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true') && github.run_attempt == 1 && ((matrix.runner == 'blacksmith-8vcpu-ubuntu-2404' && contains(fromJSON('["checks-node-compact-large-9","checks-node-compact-large-5"]'), matrix.check_name)) && 'blacksmith-16vcpu-ubuntu-2404' || matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && ((matrix.runner == 'blacksmith-8vcpu-ubuntu-2404' && contains(fromJSON('["checks-node-compact-large-9","checks-node-compact-large-5"]'), matrix.check_name)) && 'blacksmith-16vcpu-ubuntu-2404' || matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')) }}
|
||
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
|
||
strategy:
|
||
fail-fast: ${{ github.event_name == 'pull_request' && (github.run_attempt != 1 || github.repository != 'openclaw/openclaw') }}
|
||
max-parallel: ${{ github.event_name == 'pull_request' && github.repository == 'openclaw/openclaw' && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association) && github.run_attempt == 1 && needs.preflight.outputs.frozen_target != 'true' && needs.preflight.outputs.runner_profile != 'github' && needs.preflight.outputs.node_runner_backend != 'runson' && contains(fromJSON('["","blacksmith","hybrid"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && 130 || 96 }}
|
||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Initialize RunsOn
|
||
if: matrix.runner == 'runson-c8i-8xlarge' && runner.environment == 'self-hosted'
|
||
uses: runs-on/action@efac073ea2507ec18797de3a81704201ade11d9d # v2
|
||
|
||
- name: Record RunsOn allocation
|
||
if: matrix.runner == 'runson-c8i-8xlarge' && runner.environment == 'self-hosted'
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
imds_token="$(curl -fsS --connect-timeout 2 --max-time 5 -X PUT -H 'X-aws-ec2-metadata-token-ttl-seconds: 60' http://169.254.169.254/latest/api/token)"
|
||
for fact in instance-type instance-life-cycle placement/region; do
|
||
value="$(curl -fsS --connect-timeout 2 --max-time 5 -H "X-aws-ec2-metadata-token: $imds_token" "http://169.254.169.254/latest/meta-data/$fact")"
|
||
echo "RUNSON_ALLOCATION $fact=$value"
|
||
case "$fact" in
|
||
instance-type) test "$value" = c8i.8xlarge ;;
|
||
instance-life-cycle) [[ "$value" == spot || "$value" == on-demand ]] ;;
|
||
placement/region) test "$value" = us-east-1 ;;
|
||
esac
|
||
done
|
||
echo "RUNSON_ALLOCATION launched_at=${RUNS_ON_INSTANCE_LAUNCHED_AT:-unknown}"
|
||
# c8i uses EBS; do not claim the pilot's unmeasured c8id NVMe path.
|
||
findmnt -T /tmp -o TARGET,SOURCE,FSTYPE
|
||
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
semantic-checks: "true"
|
||
cache-mode: *cache_mode
|
||
node-version: "${{ (matrix.runner == 'runson-c8i-8xlarge' || startsWith(matrix.check_name, 'checks-node-runson-cron-')) && env.NODE_VERSION || matrix.node_version || '24.x' }}"
|
||
install-bun: "false"
|
||
# Only Node 24 consumes the trusted compile seed.
|
||
dependency-cache: ${{ matrix.runner != 'runson-c8i-8xlarge' && (matrix.node_version == null || matrix.node_version == '24.x') && (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) != 'github' && runner.environment == 'self-hosted' && (github.event_name != 'workflow_dispatch' || ((needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true') && github.run_attempt == 1)) && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }}
|
||
vitest-fs-cache: "true"
|
||
vitest-worker-cache: ${{ needs.preflight.outputs.frozen_target != 'true' && matrix.pretest_build_mode == null && (matrix.node_version == null || matrix.node_version == '24.x') && 'true' || 'false' }}
|
||
node-compile-cache: "true"
|
||
|
||
- name: Apply frozen Node test compatibility
|
||
if: needs.preflight.outputs.checkout_revision == 'f773aa06a1a93b36b050f1a3f4b57d3d91311541'
|
||
uses: ./.ci-harness/.github/actions/frozen-node-test-compat
|
||
with:
|
||
target-sha: *checkout_sha
|
||
|
||
- name: Setup pinned Bun test runtime
|
||
if: matrix.requires_bun == true
|
||
uses: ./.ci-harness/.github/actions/setup-test-bun
|
||
|
||
- name: Setup Go for docs i18n
|
||
if: matrix.requires_go == true
|
||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
|
||
with:
|
||
go-version: "1.27.1"
|
||
cache: false
|
||
|
||
- name: Resolve docs i18n Go cache
|
||
id: docs-i18n-go-cache-key
|
||
if: matrix.requires_go == true && needs.preflight.outputs.cache_mode != 'off'
|
||
env:
|
||
DEPENDENCY_HASH: ${{ hashFiles('scripts/docs-i18n/go.sum') }}
|
||
run: |
|
||
set -euo pipefail
|
||
arch="$(node -p process.arch)"
|
||
image_prefix=""
|
||
if [[ "$RUNNER_OS" == "Linux" ]]; then
|
||
image_prefix="${ImageOS-undefined}-"
|
||
fi
|
||
version="$(go env GOVERSION)"
|
||
{
|
||
echo "key=setup-go-${RUNNER_OS}-${arch}-${image_prefix}go-${version#go}-${DEPENDENCY_HASH}"
|
||
echo "paths<<EOF"
|
||
go env GOMODCACHE
|
||
go env GOCACHE
|
||
echo "EOF"
|
||
} >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Restore docs i18n Go cache
|
||
id: docs-i18n-go-cache
|
||
if: matrix.requires_go == true && needs.preflight.outputs.cache_mode != 'off'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: ${{ steps.docs-i18n-go-cache-key.outputs.paths }}
|
||
key: ${{ steps.docs-i18n-go-cache-key.outputs.key }}
|
||
|
||
- name: Verify docs i18n Go toolchain
|
||
if: matrix.requires_go == true
|
||
run: test "$(go env GOVERSION)" = "go1.27.1"
|
||
|
||
- name: Checkout trusted Node shard runner
|
||
if: ${{ hashFiles('scripts/ci-run-node-test-shard.mts') == '' }}
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
ref: ${{ github.workflow_sha }}
|
||
path: .ci-workflow
|
||
sparse-checkout: |
|
||
scripts/ci-run-node-test-shard.mts
|
||
scripts/lib/ci-node-test-groups-codec.mts
|
||
scripts/lib/direct-run.mjs
|
||
scripts/lib/local-check-runtime.mts
|
||
scripts/lib/numeric-options.mjs
|
||
scripts/lib/vitest-plan-scheduling.mts
|
||
scripts/lib/vitest-local-scheduling.mts
|
||
scripts/lib/arg-utils.mts
|
||
scripts/lib/arg-utils.runtime.mjs
|
||
sparse-checkout-cone-mode: false
|
||
persist-credentials: false
|
||
|
||
- name: Build Node test runtime
|
||
id: node-test-runtime
|
||
if: matrix.pretest_build_mode != null
|
||
env:
|
||
NODE_OPTIONS: --max-old-space-size=8192
|
||
OPENCLAW_BUILD_PRIVATE_QA: ${{ matrix.pretest_build_mode == 'private-qa' && '1' || '0' }}
|
||
VITEST: "1"
|
||
run: pnpm build qaRuntime
|
||
|
||
- name: Install ripgrep for native grep tests
|
||
if: matrix.requires_ripgrep == true && runner.os == 'Linux'
|
||
shell: bash
|
||
run: |
|
||
if command -v rg >/dev/null 2>&1; then
|
||
exit 0
|
||
fi
|
||
sudo apt-get update -qq
|
||
sudo apt-get install -y --no-install-recommends ripgrep
|
||
|
||
- name: Prepare Docker sandbox image
|
||
if: matrix.requires_sandbox_image == true && runner.os == 'Linux'
|
||
shell: bash
|
||
run: |
|
||
if ! docker image inspect openclaw-sandbox:bookworm-slim >/dev/null 2>&1; then
|
||
scripts/sandbox-setup.sh
|
||
fi
|
||
|
||
- name: Configure Node test resources
|
||
env:
|
||
PREDICTED_TEST_SECONDS: ${{ matrix.predicted_seconds || '' }}
|
||
SHARD_PLAN_CONCURRENCY: ${{ matrix.plan_concurrency || '' }}
|
||
FROZEN_TARGET: *frozen_target
|
||
RUNNER_ENVIRONMENT: ${{ runner.environment }}
|
||
RUNSON_JOB: ${{ (matrix.runner == 'runson-c8i-8xlarge' || startsWith(matrix.check_name, 'checks-node-runson-cron-')) && 'true' || 'false' }}
|
||
run: |
|
||
cores="$(nproc)"
|
||
if [ "$RUNSON_JOB" = "true" ]; then
|
||
workers=2
|
||
elif [ "$SHARD_PLAN_CONCURRENCY" != "1" ]; then
|
||
workers=2
|
||
elif [ "$cores" -ge 12 ]; then
|
||
workers=6
|
||
elif [ "$cores" -ge 6 ]; then
|
||
workers=4
|
||
else
|
||
workers=3
|
||
fi
|
||
if [[ "$RUNSON_JOB" != "true" && "$RUNNER_ENVIRONMENT" == "self-hosted" && "$FROZEN_TARGET" != "true" && "$SHARD_PLAN_CONCURRENCY" == "1" ]]; then
|
||
workers="$(CI_LEGACY_WORKERS="$workers" node --input-type=module <<'JS'
|
||
import { isConstrainedCiCheckHost } from './scripts/lib/local-check-runtime.mts';
|
||
import { detectVitestHostInfo, resolveLocalVitestScheduling } from './scripts/lib/vitest-local-scheduling.mts';
|
||
const host = detectVitestHostInfo();
|
||
const constrained = isConstrainedCiCheckHost({
|
||
logicalCpuCount: host.cpuCount,
|
||
totalMemoryBytes: host.totalMemoryBytes,
|
||
});
|
||
console.log(constrained ? process.env.CI_LEGACY_WORKERS : resolveLocalVitestScheduling(process.env, host).maxWorkers);
|
||
JS
|
||
)"
|
||
fi
|
||
if [ "$workers" -gt "$cores" ]; then
|
||
workers="$cores"
|
||
fi
|
||
echo "detected cores=$cores plan_concurrency=${SHARD_PLAN_CONCURRENCY:-default} predicted_test_seconds=${PREDICTED_TEST_SECONDS:-unknown} -> workers=$workers"
|
||
echo "OPENCLAW_VITEST_MAX_WORKERS=$workers" >> "$GITHUB_ENV"
|
||
|
||
- name: Run Node test shard
|
||
env:
|
||
NODE_OPTIONS: --max-old-space-size=8192
|
||
OPENCLAW_E2E_USE_PREBUILT_DIST: ${{ steps.node-test-runtime.outcome == 'success' && matrix.pretest_build_mode == 'private-qa' && '1' || '' }}
|
||
OPENCLAW_CI_TEST_RUNTIME_POLICY: ${{ matrix.test_runtime_policy || 'node' }}
|
||
FROZEN_TARGET: *frozen_target
|
||
RUNNER_ENVIRONMENT: ${{ runner.environment }}
|
||
OPENCLAW_NODE_TEST_GROUPS_GZIP_BASE64: ${{ matrix.groups_gzip_base64 || '' }}
|
||
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ matrix.groups && toJson(matrix.groups) || '' }}
|
||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
|
||
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
|
||
OPENCLAW_NODE_TEST_TARGETS_JSON: ${{ toJson(matrix.targets) }}
|
||
# Keep the watchdog above Vitest so frozen hook diagnostics finish.
|
||
OPENCLAW_NODE_TEST_VITEST_ARGS_JSON: ${{ needs.preflight.outputs.compatibility_target == 'true' && '["--hookTimeout=600000"]' || '[]' }}
|
||
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
|
||
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: ${{ needs.preflight.outputs.compatibility_target == 'true' && '660000' || '300000' }}
|
||
OPENCLAW_NODE_TEST_PLAN_CONCURRENCY: ${{ matrix.plan_concurrency }}
|
||
OPENCLAW_NODE_TEST_PLAN_CONTINUE_ON_FAILURE: ${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && '1' || '0' }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
runner="scripts/ci-run-node-test-shard.mts"
|
||
if [[ ! -f "$runner" ]]; then
|
||
runner=".ci-workflow/${runner}"
|
||
[[ -f "$runner" ]]
|
||
fi
|
||
time -p node --import tsx "$runner"
|
||
|
||
- &runner_memory_peak_step
|
||
name: Record runner memory peak
|
||
if: always()
|
||
shell: bash
|
||
run: |
|
||
# This includes setup and every child process, not just the test step.
|
||
if peak_memory="$(cat /sys/fs/cgroup/memory.peak 2>/dev/null)"; then
|
||
echo "runner cgroup peak memory bytes=$peak_memory"
|
||
fi
|
||
|
||
- name: Save docs i18n Go cache
|
||
if: always() && matrix.requires_go == true && needs.preflight.outputs.cache_write_allowed == 'true' && steps.docs-i18n-go-cache.outputs.cache-hit != 'true'
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: ${{ steps.docs-i18n-go-cache-key.outputs.paths }}
|
||
key: ${{ steps.docs-i18n-go-cache.outputs.cache-primary-key }}
|
||
|
||
check-plan:
|
||
permissions:
|
||
contents: read
|
||
name: check-plan
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_check_plan == 'true'
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'hybrid' && needs.preflight.outputs.node_runner_backend != 'runson' && github.run_attempt == 1 && (github.event_name != 'workflow_dispatch' || needs.preflight.outputs.ci_qualification == 'true') && needs.preflight.outputs.frozen_target != 'true' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association))) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04'))) || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'hybrid' && needs.preflight.outputs.node_runner_backend != 'runson' && github.run_attempt == 1 && (github.event_name != 'workflow_dispatch' || needs.preflight.outputs.ci_qualification == 'true') && needs.preflight.outputs.frozen_target != 'true' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association))) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||
timeout-minutes: 20
|
||
outputs:
|
||
check_matrix: ${{ steps.plan.outputs.check_matrix }}
|
||
core_type_matrix: ${{ steps.plan.outputs.core_type_matrix }}
|
||
lint_core_matrix: ${{ steps.plan.outputs.lint_core_matrix }}
|
||
lint_extension_matrix: ${{ steps.plan.outputs.lint_extension_matrix }}
|
||
central_lint_selection_json: ${{ steps.plan.outputs.central_lint_selection_json }}
|
||
run_lint_core: ${{ steps.plan.outputs.run_lint_core }}
|
||
run_lint_extensions: ${{ steps.plan.outputs.run_lint_extensions }}
|
||
run_changed_core_type_stripes: ${{ steps.plan.outputs.run_changed_core_type_stripes }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
semantic-checks: "true"
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
dependency-cache: ${{ runner.environment == 'self-hosted' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'true' || 'false' }}
|
||
- name: Check narrow PR global guards
|
||
run: |
|
||
set -euo pipefail
|
||
pnpm check:no-conflict-markers
|
||
pnpm check:doctor-deprecation-registry
|
||
- name: Materialize check plan
|
||
id: plan
|
||
env:
|
||
OPENCLAW_CI_CHECK_PLAN_INPUT_JSON: ${{ needs.preflight.outputs.check_plan_input_json }}
|
||
run: node scripts/ci-check-plan.mts
|
||
# The failure observer depends on this step name.
|
||
- name: "CI check job count v1: ${{ steps.plan.outputs.check_job_count }}"
|
||
run: ":"
|
||
|
||
# Types, lint, and format check shards.
|
||
check-shard:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ matrix.check_name || 'check-shard' }}
|
||
needs: [preflight, check-plan]
|
||
if: ${{ !cancelled() && always() && (needs.preflight.outputs.run_check_plan != 'true' || needs.check-plan.result == 'success') && needs.preflight.outputs.run_check == 'true' }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && ((needs.preflight.outputs.hybrid_hosted_checks == 'true' && matrix.task == 'dependencies') || (needs.preflight.outputs.hybrid_hosted_main_checks == 'true' && (matrix.task == 'lint' || matrix.task == 'test-types')))) && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.repository == 'openclaw/openclaw' && (github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) && !inputs.release_gate && startsWith(inputs.dispatch_id, 'full-release-validation-') && needs.preflight.outputs.frozen_target == 'true' && matrix.task == 'lint') && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || github.run_attempt > 1 || (matrix.task != 'lint' && matrix.task != 'test-types' && matrix.task != 'dependencies'))) && 'ubuntu-24.04' || (((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) && inputs.release_gate) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')))) || ((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && ((needs.preflight.outputs.hybrid_hosted_checks == 'true' && matrix.task == 'dependencies') || (needs.preflight.outputs.hybrid_hosted_main_checks == 'true' && (matrix.task == 'lint' || matrix.task == 'test-types')))) && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.repository == 'openclaw/openclaw' && (github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) && !inputs.release_gate && startsWith(inputs.dispatch_id, 'full-release-validation-') && needs.preflight.outputs.frozen_target == 'true' && matrix.task == 'lint') && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || github.run_attempt > 1 || (matrix.task != 'lint' && matrix.task != 'test-types' && matrix.task != 'dependencies'))) && 'ubuntu-24.04' || (((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) && inputs.release_gate) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')) }}
|
||
timeout-minutes: 20
|
||
env:
|
||
CHECKOUT_BASE_SHA: ${{ ((matrix.task == 'guards' && (github.event_name == 'pull_request' || (needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true'))) || (matrix.task == 'npm-lock' && (github.event_name != 'workflow_dispatch' || (needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true')))) && needs.preflight.outputs.diff_base_revision || '' }}
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 12
|
||
matrix: ${{ fromJSON((needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.check_matrix || needs.preflight.outputs.check_matrix)) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Check npm lock scope before setup
|
||
id: npm-lock-scope
|
||
if: matrix.task == 'npm-lock'
|
||
env:
|
||
HISTORICAL_TARGET: *historical_target
|
||
shell: bash
|
||
run: |
|
||
# A missing runtime or unfamiliar target must keep the existing check.
|
||
if decision="$(node .ci-harness/scripts/ci-npm-lock-admission.mjs)"; then
|
||
echo "$decision" >> "$GITHUB_OUTPUT"
|
||
else
|
||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||
fi
|
||
|
||
- name: Setup Node environment
|
||
if: steps.npm-lock-scope.outputs.skip != 'true'
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with: &semantic_node_inputs
|
||
semantic-checks: "true"
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
dependency-cache: *trusted_dependency_cache
|
||
|
||
- name: Restore test-type incremental state
|
||
id: test-type-cache
|
||
if: matrix.task == 'test-types' && needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.frozen_target != 'true'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ runner.os }}-${{ runner.arch }}-tsgo-v1-${{ matrix.task }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-${{ github.sha }}
|
||
restore-keys: |
|
||
${{ runner.os }}-${{ runner.arch }}-tsgo-v1-${{ matrix.task }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-
|
||
|
||
- name: Restore changed core test-type stripe 1
|
||
if: &changed_core_test_type_cache_gate matrix.task == 'test-types' && needs.preflight.outputs.changed_core_test_paths_json != '' && needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.frozen_target != 'true'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-1-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-${{ github.sha }}
|
||
restore-keys: |
|
||
${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-1-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-
|
||
|
||
- name: Restore changed core test-type stripe 2
|
||
if: *changed_core_test_type_cache_gate
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-2-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-${{ github.sha }}
|
||
restore-keys: |
|
||
${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-2-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-
|
||
|
||
- name: Restore changed core test-type stripe 3
|
||
if: *changed_core_test_type_cache_gate
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-3-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-${{ github.sha }}
|
||
restore-keys: |
|
||
${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-3-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-
|
||
|
||
- name: Restore changed core test-type stripe 4
|
||
if: *changed_core_test_type_cache_gate
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-4-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-${{ github.sha }}
|
||
restore-keys: |
|
||
${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-4-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-
|
||
|
||
- name: Restore changed core test-type stripe 5
|
||
if: *changed_core_test_type_cache_gate
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-5-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-${{ github.sha }}
|
||
restore-keys: |
|
||
${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-5-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-
|
||
|
||
- name: Compute extension boundary input fingerprint
|
||
id: extension-boundary-inputs
|
||
if: matrix.task == 'lint' && (needs.preflight.outputs.runner_profile != 'hybrid' || needs.preflight.outputs.frozen_target == 'true')
|
||
shell: bash
|
||
run: &extension_boundary_fingerprint_run |
|
||
set -euo pipefail
|
||
if [[ -f scripts/prepare-extension-package-boundary-artifacts.mts ]]; then
|
||
fingerprint="$(git rev-parse HEAD)"
|
||
echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT"
|
||
echo "enabled=true" >> "$GITHUB_OUTPUT"
|
||
else
|
||
# Historical targets without this preparer retain their own
|
||
# declaration setup and do not consume the shared archive.
|
||
echo "enabled=false" >> "$GITHUB_OUTPUT"
|
||
fi
|
||
|
||
- name: Cache extension package boundary artifacts for hosted lint
|
||
if: needs.preflight.outputs.cache_mode != 'off' && matrix.task == 'lint' && steps.extension-boundary-inputs.outputs.enabled == 'true' && (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid')
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with: &extension_boundary_cache_with
|
||
path: |
|
||
packages/plugin-sdk/dist
|
||
.artifacts/extension-package-boundary/plugins
|
||
.artifacts/extension-package-boundary/*.json
|
||
.artifacts/extension-package-boundary/compile
|
||
key: ${{ runner.os }}-extension-package-boundary-v4-${{ steps.extension-boundary-inputs.outputs.fingerprint }}
|
||
restore-keys: |
|
||
${{ runner.os }}-extension-package-boundary-v4-
|
||
|
||
- name: Mount extension boundary sticky disk
|
||
if: &lint_boundary_sticky_disk_gate matrix.task == 'lint' && steps.extension-boundary-inputs.outputs.enabled == 'true' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && !contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
||
uses: useblacksmith/stickydisk@94697d49e77d0dd78b77deb85ad3de63a28b4b8a # v1.7.1
|
||
with:
|
||
# One stable disk; in-job markers validate snapshots without exhausting disk quota.
|
||
key: ${{ github.repository }}-ext-boundary-v2
|
||
path: /var/tmp/openclaw-ext-boundary
|
||
commit: "false"
|
||
|
||
- name: Restore extension boundary artifacts from sticky disk
|
||
if: *lint_boundary_sticky_disk_gate
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
sticky_root=/var/tmp/openclaw-ext-boundary
|
||
if [ ! -f "$sticky_root/.snapshot-ready" ]; then
|
||
echo "boundary artifact snapshot not seeded yet; lint prepares cold"
|
||
exit 0
|
||
fi
|
||
# The commit keys transport reuse only. The preparer validates each
|
||
# owner against its consumed input bytes and complete native inventory.
|
||
current_fingerprint="${{ steps.extension-boundary-inputs.outputs.fingerprint }}"
|
||
if [ ! -f "$sticky_root/.source-fingerprint" ] || [ "$current_fingerprint" != "$(cat "$sticky_root/.source-fingerprint")" ]; then
|
||
echo "boundary source trees changed since snapshot; lint prepares cold"
|
||
exit 0
|
||
fi
|
||
for payload in packages .artifacts; do
|
||
if [ -d "$sticky_root/$payload" ]; then
|
||
rsync -a "$sticky_root/$payload/" "$payload/"
|
||
fi
|
||
done
|
||
|
||
- name: Run changed lint
|
||
if: matrix.task == 'lint' && (needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.central_lint_selection_json || needs.preflight.outputs.central_lint_selection_json)
|
||
env:
|
||
NARROW_CHECK_PATHS_JSON: ${{ needs.preflight.outputs.narrow_check_paths_json }}
|
||
CI_LINT_SELECTION_JSON: ${{ (needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.central_lint_selection_json || needs.preflight.outputs.central_lint_selection_json) }}
|
||
RUNNER_PROFILE: ${{ needs.preflight.outputs.runner_profile }}
|
||
OPENCLAW_LOCAL_CHECK: "0"
|
||
shell: bash
|
||
run: &run_changed_lint |
|
||
set -euo pipefail
|
||
node --import ./scripts/tsx.mjs --input-type=module <<'CHANGED_LINT'
|
||
import { availableParallelism } from "node:os";
|
||
import { detectChangedLanes } from "./scripts/changed-lanes.mts";
|
||
import { runChangedCheck } from "./scripts/check-changed.mts";
|
||
const paths = JSON.parse(process.env.NARROW_CHECK_PATHS_JSON);
|
||
const lintSelection = JSON.parse(process.env.CI_LINT_SELECTION_JSON);
|
||
const lintThreads = lintSelection.central && !["github", "hybrid"].includes(process.env.RUNNER_PROFILE) &&
|
||
availableParallelism() >= 8 ? 8 : 1;
|
||
process.exitCode = await runChangedCheck(detectChangedLanes(paths), { lintOnly: true, lintSelection, lintThreads });
|
||
CHANGED_LINT
|
||
|
||
- name: Run check shard
|
||
if: matrix.task != 'lint' || !(needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.central_lint_selection_json || needs.preflight.outputs.central_lint_selection_json)
|
||
env:
|
||
SKIP_NPM_LOCK: ${{ steps.npm-lock-scope.outputs.skip }}
|
||
FROZEN_TARGET: *frozen_target
|
||
HISTORICAL_TARGET: *historical_target
|
||
FORMAT_CHECK: ${{ needs.preflight.outputs.run_format_check }}
|
||
RELEASE_GATE: &release_gate ${{ inputs.release_gate && (needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') && 'true' || 'false' }}
|
||
RUN_CONTROL_UI_I18N: ${{ needs.preflight.outputs.run_control_ui_i18n }}
|
||
RUN_UI_TESTS: ${{ needs.preflight.outputs.run_ui_tests }}
|
||
HOSTED_RUNNER_STRIPES: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && (needs.preflight.outputs.frozen_target != 'true' || needs.preflight.outputs.hosted_runner_profile_contract == 'true') && 'true' || 'false' }}
|
||
RUNNER_PROFILE: ${{ needs.preflight.outputs.runner_profile }}
|
||
OPENCLAW_LOCAL_CHECK: "0"
|
||
CHANGED_CORE_TEST_PATHS_JSON: ${{ needs.preflight.outputs.changed_core_test_paths_json }}
|
||
NARROW_CHECK_PATHS_JSON: ${{ needs.preflight.outputs.narrow_check_paths_json }}
|
||
CI_TYPE_GRAPHS_JSON: ${{ matrix.type_graph_names_json }}
|
||
CI_CORE_TYPE_GRAPHS_JSON: ${{ matrix.core_type_graph_names_json }}
|
||
CI_CORE_TYPE_CONCURRENCY: ${{ matrix.core_type_concurrency }}
|
||
OPENCLAW_CI_STATIC_EVIDENCE: &static_evidence ${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && needs.preflight.outputs.frozen_target != 'true' && '1' || '0' }}
|
||
TASK: ${{ matrix.task }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
has_package_script() {
|
||
node -e '
|
||
const scripts = require("./package.json").scripts ?? {};
|
||
process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1);
|
||
' "$1"
|
||
}
|
||
if [ -n "${NARROW_CHECK_PATHS_JSON:-}" ] && { [ "$TASK" = "test-types" ] || [ "$TASK" = "prod-types" ]; }; then
|
||
source .ci-harness/scripts/ci-static-step.sh tsgo
|
||
if [ -n "${CI_CORE_TYPE_GRAPHS_JSON:-}" ] && [ "$CI_CORE_TYPE_GRAPHS_JSON" != "[]" ]; then
|
||
core_type_command=(node scripts/run-tsgo-core-test-shards.mjs)
|
||
if [ "$CI_CORE_TYPE_CONCURRENCY" = "2" ]; then
|
||
core_type_command=(env -u OPENCLAW_LOCAL_CHECK "${core_type_command[@]}")
|
||
fi
|
||
run_static_check "${core_type_command[@]}" \
|
||
--ci-graphs-json "$CI_CORE_TYPE_GRAPHS_JSON" --concurrency "$CI_CORE_TYPE_CONCURRENCY"
|
||
fi
|
||
if [ -n "${CI_TYPE_GRAPHS_JSON:-}" ] && [ "$CI_TYPE_GRAPHS_JSON" != "[]" ]; then
|
||
run_static_check node scripts/run-tsgo-core-test-shards.mjs --ci-graphs-json "$CI_TYPE_GRAPHS_JSON"
|
||
fi
|
||
finish_static_checks
|
||
fi
|
||
case "$TASK" in
|
||
guards)
|
||
if [ -z "${NARROW_CHECK_PATHS_JSON:-}" ]; then
|
||
pnpm check:no-conflict-markers
|
||
if has_package_script "check:doctor-deprecation-registry"; then
|
||
pnpm check:doctor-deprecation-registry
|
||
elif [[ "$FROZEN_TARGET" == "true" ]]; then
|
||
echo "[skip] frozen target predates the wall-clock doctor deprecation registry guard"
|
||
else
|
||
echo "Current CI targets must provide the check:doctor-deprecation-registry package script." >&2
|
||
exit 1
|
||
fi
|
||
fi
|
||
pnpm tool-display:check
|
||
pnpm check:host-env-policy:swift
|
||
if has_package_script "check:browser-inspect-script:swift"; then
|
||
pnpm check:browser-inspect-script:swift
|
||
elif [[ "$FROZEN_TARGET" == "true" ]]; then
|
||
echo "[skip] frozen target predates the browser inspect Swift script guard"
|
||
else
|
||
echo "Current CI targets must provide the check:browser-inspect-script:swift package script." >&2
|
||
exit 1
|
||
fi
|
||
if has_package_script "check:temp-path-guardrails"; then
|
||
pnpm check:temp-path-guardrails
|
||
elif [[ "$FROZEN_TARGET" == "true" ]]; then
|
||
echo "[skip] frozen target predates the temp path guardrails"
|
||
else
|
||
echo "Current CI targets must provide the check:temp-path-guardrails package script." >&2
|
||
exit 1
|
||
fi
|
||
if [[ "$FROZEN_TARGET" == "true" ]]; then
|
||
pnpm dup:check:coverage
|
||
else
|
||
pnpm dup:check
|
||
fi
|
||
if has_package_script "check:coercion-helpers"; then
|
||
pnpm check:coercion-helpers
|
||
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
||
echo "[skip] historical target predates the coercion-helper declaration guard"
|
||
else
|
||
echo "Current CI targets must provide the check:coercion-helpers package script." >&2
|
||
exit 1
|
||
fi
|
||
if [ -n "$CHECKOUT_BASE_SHA" ]; then
|
||
test "$(git rev-parse refs/remotes/origin/ci-ratchet-base^{commit})" = "$CHECKOUT_BASE_SHA"
|
||
node scripts/report-test-temp-creations.mjs --base "$CHECKOUT_BASE_SHA" --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
|
||
;;
|
||
npm-lock)
|
||
if [[ "$SKIP_NPM_LOCK" == "true" ]]; then
|
||
echo "No npm-lock package changes detected; dependency setup skipped."
|
||
exit 0
|
||
fi
|
||
# The --all sweep resolves ~94 npm graphs against the registry
|
||
# (~110s). Push/PR runs use the reviewed --changed scoping (zero
|
||
# dependency-surface changes resolve nothing); dispatches and
|
||
# release validation keep the full sweep, which also covers
|
||
# registry-side drift on unchanged lockfiles.
|
||
if [[ -n "$CHECKOUT_BASE_SHA" ]] &&
|
||
has_package_script "deps:npm-lock:check:changed"; then
|
||
test "$(git rev-parse refs/remotes/origin/ci-ratchet-base^{commit})" = "$CHECKOUT_BASE_SHA"
|
||
pnpm deps:npm-lock:check:changed --base "$CHECKOUT_BASE_SHA" --head HEAD
|
||
elif has_package_script "deps:npm-lock:check"; then
|
||
pnpm deps:npm-lock:check
|
||
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
|
||
echo "Current CI targets must provide the deps:npm-lock:check package script." >&2
|
||
exit 1
|
||
else
|
||
echo "[skip] historical target predates the transient npm lock contract"
|
||
fi
|
||
;;
|
||
bundled-channel-config-metadata)
|
||
pnpm check:bundled-channel-config-metadata
|
||
;;
|
||
prod-types)
|
||
pnpm tsgo:prod
|
||
;;
|
||
lint)
|
||
# The i18n verify covers keys extracted from every ui/ source,
|
||
# not just ui/src/i18n; any ui-touching diff (run_ui_tests) or
|
||
# i18n-tooling diff must run it. oxlint always runs.
|
||
lint_args=(--threads=8)
|
||
hosted_extension_stripe=""
|
||
if [ "$HOSTED_RUNNER_STRIPES" = "true" ]; then
|
||
# Five dedicated hosted jobs own the aggregated core Programs.
|
||
# Frozen targets keep their original extension ownership.
|
||
lint_args=(--only=extensions --only=scripts --threads=1)
|
||
if [ "$RELEASE_GATE" = "true" ]; then
|
||
lint_args=(--only=scripts --threads=1)
|
||
elif [ "$RUNNER_PROFILE" = "hybrid" ] && [ "$FROZEN_TARGET" != "true" ]; then
|
||
# Independent hosted rows own the complete extension inventory.
|
||
lint_args=(--only=scripts --threads=1)
|
||
elif [ "$RUNNER_PROFILE" = "github" ] &&
|
||
grep -q -- '--extension-stripe' scripts/run-oxlint-shards.mts 2>/dev/null; then
|
||
# Six existing hosted lint jobs divide the independently bounded
|
||
# extension Programs without running two Programs concurrently.
|
||
hosted_extension_stripe="6/6"
|
||
lint_args=(--only=scripts --threads=1)
|
||
fi
|
||
elif [ "$(nproc)" -lt 8 ]; then
|
||
# Fork PRs build each semantic Program once on small runners.
|
||
lint_args=(--threads=1)
|
||
fi
|
||
if [ "$FROZEN_TARGET" = "true" ] &&
|
||
{ [ "$HOSTED_RUNNER_STRIPES" = "true" ] || [ "$(nproc)" -lt 8 ]; }; then
|
||
# Frozen wrappers can predate native resource policy. Current
|
||
# wrappers limit lint without throttling declaration preparation.
|
||
export GOMAXPROCS=2
|
||
export GOGC=30 GOMEMLIMIT=3GiB
|
||
fi
|
||
if [[ ! -f scripts/run-oxlint-shards.mts ]]; then
|
||
# The candidate's older shard runner owns all three source
|
||
# groups; do not split core stripes it cannot represent.
|
||
pnpm lint
|
||
else
|
||
if [ -n "$hosted_extension_stripe" ]; then
|
||
node --import tsx scripts/run-oxlint-shards.mts \
|
||
--only=extensions --extension-stripe="$hosted_extension_stripe" --threads=1
|
||
fi
|
||
if [ "$RUN_CONTROL_UI_I18N" = "true" ] || [ "$RUN_UI_TESTS" = "true" ]; then
|
||
pnpm lint "${lint_args[@]}"
|
||
else
|
||
echo "[skip] changed scope cannot affect control-UI i18n catalogs"
|
||
node --import tsx scripts/run-oxlint-shards.mts "${lint_args[@]}"
|
||
fi
|
||
fi
|
||
if [ "$FORMAT_CHECK" = "true" ]; then
|
||
pnpm format:check
|
||
fi
|
||
;;
|
||
dependencies)
|
||
# The beta.1 release commit backported this workflow-only helper after its
|
||
# Knip config was frozen. Register that exact executable root without
|
||
# weakening the target's remaining dead-code scan.
|
||
release_evidence_entry='"scripts/generate-dependency-release-evidence.mts!"'
|
||
if [[
|
||
"$FROZEN_TARGET" == "true" &&
|
||
-f scripts/generate-dependency-release-evidence.mts &&
|
||
-f config/knip.config.ts
|
||
]] && ! grep -Fq "$release_evidence_entry" config/knip.config.ts; then
|
||
RELEASE_EVIDENCE_ENTRY="$release_evidence_entry" node --input-type=module -e '
|
||
import { readFileSync, writeFileSync } from "node:fs";
|
||
const configPath = "config/knip.config.ts";
|
||
const marker = "const repositoryScriptEntries = [";
|
||
const source = readFileSync(configPath, "utf8");
|
||
// Pre-refactor frozen configs ignore scripts/** wholesale, so
|
||
// they need no synthetic executable-root registration.
|
||
if (!source.includes(marker)) process.exit(0);
|
||
writeFileSync(configPath, source.replace(
|
||
marker,
|
||
`${marker}\n ${process.env.RELEASE_EVIDENCE_ENTRY},`,
|
||
));
|
||
'
|
||
fi
|
||
if has_package_script "deadcode:dependencies" &&
|
||
has_package_script "deadcode:unused-files"; then
|
||
# The three deadcode scripts spawn independent Knip scans over
|
||
# separate configs; run them concurrently (with buffered logs
|
||
# so output stays readable) instead of paying ~3 minutes of
|
||
# serial scanning.
|
||
dc_scripts=(deadcode:dependencies deadcode:unused-files)
|
||
if has_package_script "deadcode:exports"; then
|
||
dc_scripts+=(deadcode:exports)
|
||
elif [[ "$HISTORICAL_TARGET" != "true" ]]; then
|
||
echo "Current CI targets must provide the deadcode:exports package script." >&2
|
||
exit 1
|
||
fi
|
||
if [ "$(nproc)" -lt 8 ]; then
|
||
# Hosted/dispatch runners are too small for up to seven
|
||
# concurrent Knip processes; keep the serial path there.
|
||
for dc in "${dc_scripts[@]}"; do
|
||
pnpm "$dc"
|
||
done
|
||
else
|
||
dc_pids=()
|
||
dc_logs=()
|
||
for dc in "${dc_scripts[@]}"; do
|
||
dc_log="$(mktemp -t deadcode-log.XXXXXX)"
|
||
dc_logs+=("$dc_log")
|
||
pnpm "$dc" >"$dc_log" 2>&1 &
|
||
dc_pids+=($!)
|
||
done
|
||
dc_failures=0
|
||
for i in "${!dc_scripts[@]}"; do
|
||
if wait "${dc_pids[$i]}"; then
|
||
echo "[ok] ${dc_scripts[$i]}"
|
||
else
|
||
echo "::error title=${dc_scripts[$i]} failed::${dc_scripts[$i]} failed"
|
||
dc_failures=1
|
||
fi
|
||
cat "${dc_logs[$i]}"
|
||
done
|
||
if [ "$dc_failures" -ne 0 ]; then
|
||
exit 1
|
||
fi
|
||
fi
|
||
elif [[ "$HISTORICAL_TARGET" == "true" ]] && has_package_script "deadcode:ci"; then
|
||
pnpm deadcode:ci
|
||
else
|
||
echo "Target does not provide a supported deadcode check." >&2
|
||
exit 1
|
||
fi
|
||
;;
|
||
test-types)
|
||
# Current github/hybrid jobs own all five core-test stripes.
|
||
# Frozen targets retain their paired stripes and central fifth;
|
||
# targets without stripe support keep the whole lane here.
|
||
root_covered=false
|
||
if [ "$HOSTED_RUNNER_STRIPES" = "true" ] &&
|
||
grep -q -- '--stripe' scripts/run-tsgo-core-test-shards.mts 2>/dev/null; then
|
||
if [ "$FROZEN_TARGET" = "true" ]; then
|
||
env -u OPENCLAW_LOCAL_CHECK node scripts/run-tsgo-core-test-shards.mjs --stripe "5/5" --concurrency 2
|
||
fi
|
||
pnpm tsgo:extensions:test
|
||
elif [ -n "${CHANGED_CORE_TEST_PATHS_JSON:-}" ]; then
|
||
# Targets without hosted stripes retain the same consumer check.
|
||
env -u OPENCLAW_LOCAL_CHECK node scripts/run-tsgo-core-test-shards.mjs --changed-paths-json "$CHANGED_CORE_TEST_PATHS_JSON" --concurrency 2
|
||
pnpm tsgo:extensions:test
|
||
else
|
||
pnpm check:test-types
|
||
# check:test-types (pnpm tsgo:test) already runs tsgo:test:root
|
||
# on current targets; rerunning it below would double ~25s of
|
||
# wall on this slowest static lane.
|
||
if node -e '
|
||
const scripts = require("./package.json").scripts ?? {};
|
||
process.exit(/tsgo:test:root/.test(scripts["tsgo:test"] ?? "") ? 0 : 1);
|
||
'; then
|
||
root_covered=true
|
||
fi
|
||
fi
|
||
if has_package_script "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 [ "$root_covered" != "true" ]; then
|
||
if has_package_script "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
|
||
fi
|
||
;;
|
||
*)
|
||
echo "Unsupported check task: $TASK" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
- name: Save test-type incremental state
|
||
if: success() && matrix.task == 'test-types' && needs.preflight.outputs.cache_write_allowed == 'true' && needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.frozen_target != 'true' && steps.test-type-cache.outputs.cache-hit != 'true'
|
||
continue-on-error: true
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ steps.test-type-cache.outputs.cache-primary-key }}
|
||
|
||
check-lint-hosted-core-shard:
|
||
permissions:
|
||
contents: read
|
||
name: check-lint-core-${{ matrix.stripe }}
|
||
needs: [preflight, check-plan]
|
||
if: ${{ !cancelled() && !failure() && (needs.preflight.outputs.run_check_plan != 'true' || needs.check-plan.result == 'success') && (needs.preflight.outputs.run_check == 'true' && (needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.run_lint_core || needs.preflight.outputs.run_lint_core) == 'true' && (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && (needs.preflight.outputs.frozen_target != 'true' || needs.preflight.outputs.hosted_runner_profile_contract == 'true')) }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON(contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && needs.preflight.outputs.qualification_runner_backend || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (github.event_name != 'workflow_dispatch' || needs.preflight.outputs.ci_qualification == 'true' || needs.preflight.outputs.node_runner_backend == 'runson') && needs.preflight.outputs.frozen_target != 'true' && (needs.preflight.outputs.runner_profile == 'hybrid' && (matrix.stripe == 1 || (needs.preflight.outputs.ci_qualification == 'true' && needs.preflight.outputs.qualification_runner_backend || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'hybrid') && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04'))) || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && needs.preflight.outputs.qualification_runner_backend || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (github.event_name != 'workflow_dispatch' || needs.preflight.outputs.ci_qualification == 'true' || needs.preflight.outputs.node_runner_backend == 'runson') && needs.preflight.outputs.frozen_target != 'true' && (needs.preflight.outputs.runner_profile == 'hybrid' && (matrix.stripe == 1 || (needs.preflight.outputs.ci_qualification == 'true' && needs.preflight.outputs.qualification_runner_backend || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'hybrid') && 'blacksmith-16vcpu-ubuntu-2404' || 'blacksmith-8vcpu-ubuntu-2404') || 'ubuntu-24.04') }}
|
||
timeout-minutes: 15
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 5
|
||
matrix: ${{ fromJSON((needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.lint_core_matrix || needs.preflight.outputs.lint_core_matrix)) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- &semantic_node_setup_step
|
||
name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with: *semantic_node_inputs
|
||
|
||
- name: Compute extension boundary input fingerprint
|
||
id: extension-boundary-inputs
|
||
if: needs.preflight.outputs.runner_profile == 'github' && !inputs.release_gate
|
||
shell: bash
|
||
run: *extension_boundary_fingerprint_run
|
||
|
||
- name: Cache extension package boundary artifacts for hosted core lint
|
||
id: sdk-boundary-cache
|
||
if: needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.runner_profile == 'github' && !inputs.release_gate && steps.extension-boundary-inputs.outputs.enabled == 'true'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with: *extension_boundary_cache_with
|
||
|
||
- &changed_lint_step
|
||
name: Run changed lint
|
||
if: matrix.lint_selection_json
|
||
env:
|
||
NARROW_CHECK_PATHS_JSON: ${{ needs.preflight.outputs.narrow_check_paths_json }}
|
||
CI_LINT_SELECTION_JSON: ${{ matrix.lint_selection_json || '' }}
|
||
OPENCLAW_LOCAL_CHECK: "0"
|
||
shell: bash
|
||
run: *run_changed_lint
|
||
|
||
- name: Run hosted core lint stripe
|
||
if: ${{ !matrix.lint_selection_json }}
|
||
env:
|
||
CORE_STRIPE: ${{ matrix.stripe }}
|
||
FROZEN_TARGET: *frozen_target
|
||
OPENCLAW_LOCAL_CHECK: "0"
|
||
RELEASE_GATE: *release_gate
|
||
RUNNER_PROFILE: ${{ needs.preflight.outputs.runner_profile }}
|
||
OPENCLAW_CI_STATIC_EVIDENCE: *static_evidence
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/ci-static-step.sh oxlint
|
||
if [ "$FROZEN_TARGET" = "true" ]; then
|
||
# Older wrappers lack the current constrained-host Go policy.
|
||
export GOMAXPROCS=2
|
||
fi
|
||
# Older candidates run their complete lint set in check-lint; their
|
||
# runner predates core-stripe, so these five new-only jobs are redundant.
|
||
if [[ ! -f scripts/run-oxlint-shards.mts ]]; then
|
||
echo "[skip] target does not support core lint stripes"
|
||
exit 0
|
||
fi
|
||
stripes=("$CORE_STRIPE")
|
||
if ${{ needs.preflight.outputs.runner_profile == 'hybrid' && needs.preflight.outputs.frozen_target != 'true' && ((!inputs.release_gate && (github.event_name == 'push' || github.event_name == 'pull_request')) || (needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true')) }}; then
|
||
if [ "$CORE_STRIPE" = "1" ]; then
|
||
stripes=(1 2)
|
||
else
|
||
stripes=(3 4 5)
|
||
fi
|
||
fi
|
||
for stripe in "${stripes[@]}"; do
|
||
run_static_check node --import tsx scripts/run-oxlint-shards.mts \
|
||
--only=core --split-core --core-stripe="$stripe/5" --threads=1
|
||
if [ "$RUNNER_PROFILE" = "github" ] && [ "$RELEASE_GATE" != "true" ] &&
|
||
grep -q -- '--extension-stripe' scripts/run-oxlint-shards.mts 2>/dev/null; then
|
||
run_static_check node --import tsx scripts/run-oxlint-shards.mts \
|
||
--only=extensions --extension-stripe="$stripe/6" --threads=1
|
||
fi
|
||
done
|
||
finish_static_checks
|
||
|
||
- &save_hosted_sdk_step
|
||
name: Save hosted SDK boundary cache
|
||
if: ${{ success() && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main' && needs.preflight.outputs.candidate_trust == 'main' && needs.preflight.outputs.cache_write_allowed == 'true' && needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.frozen_target != 'true' && needs.preflight.outputs.compatibility_target != 'true' && !inputs.release_gate && runner.environment == 'github-hosted' && matrix.stripe == 1 && !matrix.lint_selection_json && steps.extension-boundary-inputs.outputs.enabled == 'true' && steps.sdk-boundary-cache.outputs.cache-hit != 'true' }}
|
||
continue-on-error: true
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
packages/plugin-sdk/dist
|
||
.artifacts/extension-package-boundary/plugins
|
||
.artifacts/extension-package-boundary/*.json
|
||
.artifacts/extension-package-boundary/compile
|
||
key: ${{ steps.sdk-boundary-cache.outputs.cache-primary-key }}
|
||
|
||
check-lint-hosted-extension-shard:
|
||
permissions:
|
||
contents: read
|
||
name: check-lint-extensions-${{ matrix.stripe }}
|
||
needs: [preflight, check-plan]
|
||
if: ${{ !cancelled() && !failure() && (needs.preflight.outputs.run_check_plan != 'true' || needs.check-plan.result == 'success') && (needs.preflight.outputs.run_check == 'true' && (needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.run_lint_extensions || needs.preflight.outputs.run_lint_extensions) == 'true' && needs.preflight.outputs.runner_profile == 'hybrid' && needs.preflight.outputs.frozen_target != 'true' && (!inputs.release_gate || needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true')) }}
|
||
runs-on: &hosted_linux_runner ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON('ubuntu-24.04'))) || ('ubuntu-24.04') }}
|
||
timeout-minutes: 15
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 6
|
||
matrix: ${{ fromJSON((needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.lint_extension_matrix || needs.preflight.outputs.lint_extension_matrix)) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- &plain_node_setup_step
|
||
name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
semantic-checks: "true"
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
|
||
- name: Compute extension boundary input fingerprint
|
||
id: extension-boundary-inputs
|
||
shell: bash
|
||
run: *extension_boundary_fingerprint_run
|
||
|
||
- name: Restore extension package boundary artifacts
|
||
id: sdk-boundary-cache
|
||
if: needs.preflight.outputs.cache_mode != 'off'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with: *extension_boundary_cache_with
|
||
|
||
- *changed_lint_step
|
||
|
||
- name: Run hosted extension lint stripe
|
||
if: ${{ !matrix.lint_selection_json }}
|
||
env:
|
||
EXTENSION_STRIPE: ${{ matrix.stripe }}
|
||
EXTENSION_STRIPE_COUNT: ${{ matrix.stripe_count || 6 }}
|
||
OPENCLAW_LOCAL_CHECK: "0"
|
||
OPENCLAW_CI_STATIC_EVIDENCE: ${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && '1' || '0' }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/ci-static-step.sh oxlint
|
||
run_static_check node --import tsx scripts/run-oxlint-shards.mts \
|
||
--only=extensions --extension-stripe="$EXTENSION_STRIPE/$EXTENSION_STRIPE_COUNT" --threads=1
|
||
finish_static_checks
|
||
|
||
- *save_hosted_sdk_step
|
||
|
||
check-test-types-hosted-core-shard:
|
||
permissions:
|
||
contents: read
|
||
name: check-test-types-core-${{ matrix.stripe }}
|
||
needs: [preflight, check-plan]
|
||
if: ${{ !cancelled() && !failure() && (needs.preflight.outputs.run_check_plan != 'true' || needs.check-plan.result == 'success') && (needs.preflight.outputs.run_check == 'true' && (!needs.preflight.outputs.narrow_check_paths_json || (needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.run_changed_core_type_stripes || needs.preflight.outputs.run_changed_core_type_stripes) == 'true') && (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && (needs.preflight.outputs.frozen_target != 'true' || needs.preflight.outputs.hosted_runner_profile_contract == 'true')) }}
|
||
# Healthy, budgeted hybrid checks use hosted capacity with unchanged compiler children.
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && needs.preflight.outputs.hybrid_hosted_checks == 'true') && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04')))) || ((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && needs.preflight.outputs.hybrid_hosted_checks == 'true') && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04')) }}
|
||
timeout-minutes: 15
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 5
|
||
matrix: ${{ fromJSON((needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.core_type_matrix || needs.preflight.outputs.core_type_matrix)) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- *semantic_node_setup_step
|
||
|
||
- name: Restore core test-type incremental state
|
||
id: test-type-cache
|
||
if: needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.frozen_target != 'true'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-${{ matrix.stripe }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-${{ github.sha }}
|
||
restore-keys: |
|
||
${{ runner.os }}-${{ runner.arch }}-tsgo-v1-core-${{ matrix.stripe }}-${{ hashFiles('pnpm-lock.yaml', 'tsconfig*.json', 'test/tsconfig/*.json', 'scripts/run-tsgo*', 'scripts/lib/local-check-runtime.mts') }}-
|
||
|
||
- name: Run hosted core test-types stripe
|
||
env:
|
||
CORE_STRIPE: ${{ matrix.stripe }}
|
||
CI_TYPE_GRAPHS_JSON: ${{ matrix.type_graph_names_json }}
|
||
FROZEN_TARGET: *frozen_target
|
||
CHANGED_CORE_TEST_PATHS_JSON: ${{ needs.preflight.outputs.changed_core_test_paths_json }}
|
||
OPENCLAW_CI_STATIC_EVIDENCE: *static_evidence
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/ci-static-step.sh tsgo
|
||
if [ -n "${CI_TYPE_GRAPHS_JSON:-}" ]; then
|
||
run_static_check node scripts/run-tsgo-core-test-shards.mjs --ci-graphs-json "$CI_TYPE_GRAPHS_JSON" --concurrency 2
|
||
finish_static_checks
|
||
fi
|
||
# Frozen/historical targets predate stripe support; their whole
|
||
# test-types lane already runs inside the check-test-types row.
|
||
if ! grep -q -- '--stripe' scripts/run-tsgo-core-test-shards.mts 2>/dev/null; then
|
||
echo "[skip] target does not support core test-type stripes"
|
||
exit 0
|
||
fi
|
||
if [ "$FROZEN_TARGET" = "true" ]; then
|
||
first="$((2 * CORE_STRIPE - 1))"
|
||
last="$((2 * CORE_STRIPE))"
|
||
for stripe in "$first" "$last"; do
|
||
node scripts/run-tsgo-core-test-shards.mjs --stripe "$stripe/5" --concurrency 2
|
||
done
|
||
else
|
||
args=(--stripe "$CORE_STRIPE/5" --concurrency 2)
|
||
if [ -n "${CHANGED_CORE_TEST_PATHS_JSON:-}" ]; then
|
||
args+=(--changed-paths-json "$CHANGED_CORE_TEST_PATHS_JSON")
|
||
fi
|
||
run_static_check node scripts/run-tsgo-core-test-shards.mjs "${args[@]}"
|
||
finish_static_checks
|
||
fi
|
||
|
||
- name: Save core test-type incremental state
|
||
if: success() && needs.preflight.outputs.cache_write_allowed == 'true' && needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.frozen_target != 'true' && steps.test-type-cache.outputs.cache-hit != 'true'
|
||
continue-on-error: true
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: .artifacts/tsgo-cache
|
||
key: ${{ steps.test-type-cache.outputs.cache-primary-key }}
|
||
|
||
check-additional-shard:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ matrix.check_name || 'check-additional-shard' }}
|
||
needs: [preflight]
|
||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && needs.preflight.outputs.hybrid_hosted_checks == 'true' && (matrix.group == 'extension-package-boundary' || matrix.group == 'runtime-topology-architecture')) && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && (github.run_attempt > 1 || (matrix.group != 'extension-package-boundary' && matrix.group != 'runtime-topology-architecture' && matrix.group != 'plugin-sdk-api-diff'))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')))) || ((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && needs.preflight.outputs.hybrid_hosted_checks == 'true' && (matrix.group == 'extension-package-boundary' || matrix.group == 'runtime-topology-architecture')) && 'ubuntu-24.04' || (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && (github.run_attempt > 1 || (matrix.group != 'extension-package-boundary' && matrix.group != 'runtime-topology-architecture' && matrix.group != 'plugin-sdk-api-diff'))) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || 'ubuntu-24.04')) }}
|
||
# Cold package validation exceeds 20 min on 4-CPU hosted runners.
|
||
timeout-minutes: ${{ matrix.group == 'extension-package-boundary' && 30 || 20 }}
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 12
|
||
matrix: ${{ fromJSON(needs.preflight.outputs.check_additional_matrix) }}
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Ensure Plugin SDK API diff base commit
|
||
if: matrix.group == 'plugin-sdk-api-diff' && github.event_name == 'workflow_dispatch'
|
||
uses: ./.ci-harness/.github/actions/ensure-base-commit
|
||
with:
|
||
base-sha: ${{ needs.preflight.outputs.diff_base_revision }}
|
||
fetch-ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
|
||
- name: Ensure Plugin SDK API diff head commit
|
||
if: matrix.group == 'plugin-sdk-api-diff' && github.event_name == 'workflow_dispatch'
|
||
uses: ./.ci-harness/.github/actions/ensure-base-commit
|
||
with:
|
||
base-sha: ${{ needs.preflight.outputs.diff_head_revision }}
|
||
fetch-ref: ${{ github.event_name == 'workflow_dispatch' && inputs.release_gate && format('refs/pull/{0}/merge', inputs.pull_request_number) || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
|
||
- *semantic_node_setup_step
|
||
|
||
- name: Compute extension boundary input fingerprint
|
||
id: extension-boundary-inputs
|
||
if: matrix.group == 'extension-package-boundary'
|
||
shell: bash
|
||
run: *extension_boundary_fingerprint_run
|
||
|
||
- name: Mount extension boundary sticky disk
|
||
if: &additional_boundary_sticky_disk_gate matrix.group == 'extension-package-boundary' && steps.extension-boundary-inputs.outputs.enabled == 'true' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && !contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
||
uses: useblacksmith/stickydisk@94697d49e77d0dd78b77deb85ad3de63a28b4b8a # v1.7.1
|
||
with:
|
||
# Stable disk keys avoid quota exhaustion; markers detect source/toolchain changes.
|
||
key: ${{ github.repository }}-ext-boundary-v2
|
||
path: /var/tmp/openclaw-ext-boundary
|
||
# Only protected successful pushes publish. Explicit commit:true refreshes same-size changes.
|
||
commit: ${{ github.event_name != 'pull_request' && 'true' || 'false' }}
|
||
|
||
- name: Restore extension boundary artifacts from sticky disk
|
||
id: boundary-sticky-restore
|
||
if: *additional_boundary_sticky_disk_gate
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
sticky_root=/var/tmp/openclaw-ext-boundary
|
||
if [ ! -f "$sticky_root/.snapshot-ready" ]; then
|
||
echo "restored=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
# Restore an exact-commit transport snapshot here. The preparer still
|
||
# validates per-owner content, topology, toolchain and output inventories.
|
||
current_fingerprint="${{ steps.extension-boundary-inputs.outputs.fingerprint }}"
|
||
if [ ! -f "$sticky_root/.source-fingerprint" ] || [ "$current_fingerprint" != "$(cat "$sticky_root/.source-fingerprint")" ]; then
|
||
echo "boundary source trees changed since snapshot; building cold"
|
||
echo "restored=false" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
fi
|
||
for payload in packages .artifacts; do
|
||
if [ -d "$sticky_root/$payload" ]; then
|
||
rsync -a "$sticky_root/$payload/" "$payload/"
|
||
fi
|
||
done
|
||
echo "restored=true" >> "$GITHUB_OUTPUT"
|
||
|
||
- name: Cache extension package boundary artifacts
|
||
id: extension-package-boundary-cache
|
||
if: needs.preflight.outputs.cache_mode != 'off' && matrix.group == 'extension-package-boundary' && steps.extension-boundary-inputs.outputs.enabled == 'true'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
packages/plugin-sdk/dist
|
||
.artifacts/extension-package-boundary/plugins
|
||
.artifacts/extension-package-boundary/*.json
|
||
.artifacts/extension-package-boundary/compile
|
||
key: ${{ runner.os }}-${{ runner.arch }}-${{ runner.environment }}-extension-package-boundary-compiled-v1-${{ steps.extension-boundary-inputs.outputs.fingerprint }}
|
||
restore-keys: |
|
||
${{ runner.os }}-${{ runner.arch }}-${{ runner.environment }}-extension-package-boundary-compiled-v1-
|
||
${{ runner.os }}-extension-package-boundary-v4-
|
||
|
||
- name: Run additional check shard
|
||
env:
|
||
ADDITIONAL_CHECK_GROUP: ${{ matrix.group }}
|
||
TYPE_GRAPH_BOUNDARY_OWNER: ${{ needs.preflight.outputs.type_graph_boundary_owner }}
|
||
CHANGED_CORE_TEST_PATHS_JSON: ${{ needs.preflight.outputs.changed_core_test_paths_json }}
|
||
COMPATIBILITY_TARGET: *historical_target
|
||
RUN_PROMPT_SNAPSHOTS: ${{ needs.preflight.outputs.run_prompt_snapshots }}
|
||
OPENCLAW_ADDITIONAL_BOUNDARY_SHARD: ""
|
||
OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY: 4
|
||
# Runner labels are not CPU counts; preserve the CI worker budget.
|
||
OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 16
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
|
||
failures=0
|
||
|
||
run_check() {
|
||
local label="$1"
|
||
shift
|
||
|
||
echo "::group::${label}"
|
||
if "$@"; then
|
||
echo "[ok] ${label}"
|
||
else
|
||
echo "::error title=${label} failed::${label} failed"
|
||
failures=1
|
||
fi
|
||
echo "::endgroup::"
|
||
}
|
||
|
||
check_groups=("$ADDITIONAL_CHECK_GROUP")
|
||
if [[ "$ADDITIONAL_CHECK_GROUP" == "source-contracts" ]]; then
|
||
check_groups=(export-name-collisions session-accessor-boundary sqlite-session-schema-baseline)
|
||
fi
|
||
# Keep fresh, serial child processes and collect every command failure.
|
||
for check_group in "${check_groups[@]}"; do
|
||
case "$check_group" in
|
||
boundaries)
|
||
boundary_runner=(node --import tsx scripts/run-additional-boundary-checks.mts)
|
||
if [[ ! -f scripts/run-additional-boundary-checks.mts ]]; then
|
||
boundary_runner=(node scripts/run-additional-boundary-checks.mjs)
|
||
fi
|
||
if [[ "$TYPE_GRAPH_BOUNDARY_OWNER" == "check-plan" || ( -z "$TYPE_GRAPH_BOUNDARY_OWNER" && -n "${CHANGED_CORE_TEST_PATHS_JSON:-}" ) ]]; then
|
||
boundary_runner+=(--core-test-boundary-owner=test-types)
|
||
fi
|
||
"${boundary_runner[@]}"
|
||
;;
|
||
prompt-snapshots)
|
||
# No presence fallback: the boundary runner previously invoked
|
||
# this unconditionally, and silent success would drop snapshot
|
||
# drift coverage. The manifest gates the lane on the generator's
|
||
# import graph and fixtures; diffs outside both cannot change
|
||
# generated snapshots.
|
||
if [ "$RUN_PROMPT_SNAPSHOTS" != "true" ]; then
|
||
echo "[skip] changed scope cannot affect generated prompt snapshots"
|
||
else
|
||
run_check "prompt:snapshots:check" pnpm prompt:snapshots:check
|
||
fi
|
||
;;
|
||
export-name-collisions)
|
||
if [ ! -f scripts/check-export-name-collisions.mts ]; then
|
||
echo "[skip] export name collision check is not present in this checkout"
|
||
elif ! node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["lint:tmp:export-name-collisions"] ? 0 : 1);'; then
|
||
echo "[skip] export name collision script is not present in package.json"
|
||
else
|
||
run_check "lint:tmp:export-name-collisions" pnpm run lint:tmp:export-name-collisions
|
||
fi
|
||
;;
|
||
session-accessor-boundary)
|
||
if ! 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 ! 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
|
||
if ! 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
|
||
;;
|
||
plugin-sdk-api-diff)
|
||
# Pure reporting: no caller passes --require-acknowledgement, so
|
||
# this can only surface an artifact/summary. Keep it off the
|
||
# push/PR critical path; dispatch (incl. release validation)
|
||
# still produces the report.
|
||
if [[ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" ]]; then
|
||
echo "[skip] plugin SDK API diff reports on manual and release dispatches only"
|
||
elif node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["plugin-sdk:api:diff"] ? 0 : 1);'; then
|
||
mkdir -p .artifacts
|
||
run_check "plugin-sdk:api:diff" pnpm run plugin-sdk:api:diff -- \
|
||
--base "${{ needs.preflight.outputs.diff_base_revision }}" \
|
||
--head "${{ needs.preflight.outputs.diff_head_revision }}" \
|
||
--json .artifacts/plugin-sdk-api-diff.json \
|
||
--summary "$GITHUB_STEP_SUMMARY"
|
||
elif [[ "$COMPATIBILITY_TARGET" == "true" ]] && node -e 'const pkg = require("./package.json"); process.exit(pkg.scripts?.["plugin-sdk:api:check"] ? 0 : 1);'; then
|
||
run_check "plugin-sdk:api:check (historical compatibility)" pnpm run plugin-sdk:api:check
|
||
elif [[ "$COMPATIBILITY_TARGET" == "true" ]]; then
|
||
echo "::error title=Plugin SDK API check unavailable::Compatibility target provides neither plugin-sdk:api:diff nor plugin-sdk:api:check."
|
||
failures=1
|
||
else
|
||
echo "::error title=Plugin SDK API diff unavailable::Current CI targets must provide plugin-sdk:api:diff."
|
||
failures=1
|
||
fi
|
||
;;
|
||
extension-package-boundary)
|
||
run_check "test:extensions:package-boundary:compile" pnpm run test:extensions:package-boundary:compile
|
||
run_check "test:extensions:package-boundary:canary" pnpm run test:extensions:package-boundary:canary
|
||
;;
|
||
runtime-topology-architecture)
|
||
GOGC="${GOGC:-30}" GOMEMLIMIT="${GOMEMLIMIT:-3GiB}" \
|
||
run_check "check:architecture" pnpm check:architecture
|
||
;;
|
||
*)
|
||
echo "Unsupported additional check group: $ADDITIONAL_CHECK_GROUP" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
done
|
||
|
||
exit "$failures"
|
||
|
||
- name: Save compiled extension package boundary artifacts
|
||
if: ${{ success() && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main' && contains(fromJSON('["push","schedule","workflow_dispatch"]'), github.event_name) && !inputs.release_gate && needs.preflight.outputs.candidate_trust == 'main' && needs.preflight.outputs.cache_write_allowed == 'true' && needs.preflight.outputs.cache_mode != 'off' && needs.preflight.outputs.frozen_target != 'true' && needs.preflight.outputs.compatibility_target != 'true' && matrix.group == 'extension-package-boundary' && steps.extension-boundary-inputs.outputs.enabled == 'true' && steps.extension-package-boundary-cache.outputs.cache-hit != 'true' }}
|
||
continue-on-error: true
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: |
|
||
packages/plugin-sdk/dist
|
||
.artifacts/extension-package-boundary/plugins
|
||
.artifacts/extension-package-boundary/*.json
|
||
.artifacts/extension-package-boundary/compile
|
||
key: ${{ steps.extension-package-boundary-cache.outputs.cache-primary-key }}
|
||
|
||
- name: Upload Plugin SDK API diff
|
||
if: always() && matrix.group == 'plugin-sdk-api-diff' && hashFiles('.artifacts/plugin-sdk-api-diff.json') != ''
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: plugin-sdk-api-diff-${{ github.run_id }}-${{ github.run_attempt }}
|
||
path: .artifacts/plugin-sdk-api-diff.json
|
||
retention-days: 14
|
||
|
||
- name: Seed extension boundary sticky disk
|
||
if: success() && steps.extension-boundary-inputs.outputs.enabled == 'true' && steps.boundary-sticky-restore.outputs.restored == 'false' && matrix.group == 'extension-package-boundary' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && !contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && github.event_name != 'workflow_dispatch' && github.event_name != 'pull_request' && github.repository == 'openclaw/openclaw'
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
sticky_root=/var/tmp/openclaw-ext-boundary
|
||
# A stale marker must not survive a mid-seed failure: drop readiness
|
||
# first so a partial payload can never be restored as valid.
|
||
rm -rf "$sticky_root/.snapshot-ready" "$sticky_root/.source-trees" "$sticky_root/.source-fingerprint" "$sticky_root/dist" "$sticky_root/packages" "$sticky_root/extensions" "$sticky_root/.artifacts"
|
||
rsync -aR packages/plugin-sdk/dist "$sticky_root/"
|
||
rsync -aR --exclude='*.lock*' .artifacts/extension-package-boundary "$sticky_root/"
|
||
echo "${{ steps.extension-boundary-inputs.outputs.fingerprint }}" > "$sticky_root/.source-fingerprint"
|
||
touch "$sticky_root/.snapshot-ready"
|
||
|
||
check-docs:
|
||
permissions:
|
||
contents: read
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_check_docs == 'true'
|
||
# Never lend the repository token to the ClawHub mirror.
|
||
runs-on: *hosted_linux_runner
|
||
timeout-minutes: 20
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- *linux_node_setup_step
|
||
|
||
- name: Check formatting
|
||
if: needs.preflight.outputs.run_format_check == 'true'
|
||
run: pnpm format:check
|
||
|
||
- name: Check config docs baseline
|
||
run: pnpm config:docs:check
|
||
|
||
- name: Check plugin inventory
|
||
run: pnpm plugins:inventory:check
|
||
|
||
- name: Checkout ClawHub docs source
|
||
shell: bash
|
||
env:
|
||
CHECKOUT_KIND: clawhub
|
||
CHECKOUT_REPO: openclaw/clawhub
|
||
run: *owned_checkout_run
|
||
|
||
- 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: *shared_small_linux_runner
|
||
timeout-minutes: 20
|
||
steps:
|
||
- name: Checkout
|
||
shell: bash
|
||
env:
|
||
CHECKOUT_KIND: skills
|
||
CHECKOUT_REPO: ${{ github.repository }}
|
||
CHECKOUT_TOKEN: ${{ github.token }}
|
||
CHECKOUT_SHA: *checkout_sha
|
||
run: *owned_checkout_run
|
||
|
||
- name: Setup Python
|
||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||
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 || 'checks-windows' }}
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_checks_windows == 'true'
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'windows-2025' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'windows-2025' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'windows-2025' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-windows-2025' || 'windows-2025')))) || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'windows-2025' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'windows-2025' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'windows-2025' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-windows-2025' || 'windows-2025')) }}
|
||
timeout-minutes: 60
|
||
env:
|
||
# Node 24.20 fixes Windows scheduler shutdown (nodejs/node#61999).
|
||
NODE_VERSION: "24.21.0"
|
||
NODE_OPTIONS: --max-old-space-size=8192
|
||
OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD: 1
|
||
defaults:
|
||
run:
|
||
shell: bash
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 5
|
||
matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }}
|
||
steps:
|
||
- &platform_checkout_step
|
||
name: Checkout
|
||
env:
|
||
CHECKOUT_REPO: ${{ github.repository }}
|
||
CHECKOUT_TOKEN: ${{ github.token }}
|
||
CHECKOUT_SHA: *checkout_sha
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: *owned_checkout_run
|
||
|
||
- 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: "${{ env.NODE_VERSION }}"
|
||
run: *ensure_node_run
|
||
|
||
- name: Setup pnpm
|
||
uses: ./.ci-harness/.github/actions/setup-pnpm-store-cache
|
||
with:
|
||
cache-mode: *cache_mode
|
||
node-version: ${{ env.NODE_VERSION }}
|
||
|
||
- name: Runtime versions
|
||
run: |
|
||
node -v
|
||
npm -v
|
||
pnpm -v
|
||
# OS-visible resources at setup, not enforced job limits or peak memory.
|
||
node -e 'const os = require("node:os"); console.log("Native OS resources: " + JSON.stringify({ logicalCpuCount: os.cpus().length, availableParallelism: os.availableParallelism(), osTotalMemoryBytes: os.totalmem(), osFreeMemoryBytes: os.freemem() }));'
|
||
|
||
- 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
|
||
# Reuse native postinstall outputs within this job's pnpm store.
|
||
# Windows setup does not restore or publish a shared dependency cache.
|
||
pnpm install --frozen-lockfile --prefer-offline --config.ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true --config.side-effects-cache=true || pnpm install --frozen-lockfile --prefer-offline --config.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 }}
|
||
# The 16-class supplies four CPUs; hosted fallback stays serial.
|
||
OPENCLAW_VITEST_MAX_WORKERS: ${{ runner.environment == 'self-hosted' && 4 || 1 }}
|
||
WINDOWS_TARGETS_JSON: ${{ toJSON(matrix.targets || null) }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
# Allocation can undershoot labels; run complete matrix files serially per machine.
|
||
export OPENCLAW_TEST_PROJECTS_PARALLEL=1
|
||
case "$TASK" in
|
||
test)
|
||
node --input-type=module <<'WINDOWS_TESTS'
|
||
import { spawnSync } from "node:child_process";
|
||
const targets = JSON.parse(process.env.WINDOWS_TARGETS_JSON);
|
||
if (!Array.isArray(targets) || targets.length === 0 ||
|
||
targets.some((file) => typeof file !== "string" || !/^[\w./-]+\.test\.tsx?$/u.test(file))) {
|
||
throw new Error("Windows test row requires explicit test files");
|
||
}
|
||
const result = spawnSync(process.execPath, [
|
||
"--import", "./scripts/tsx.mjs", "scripts/test-projects.mts", ...targets, "--fileParallelism",
|
||
], { stdio: "inherit" });
|
||
if (result.error) throw result.error;
|
||
process.exit(result.status ?? 1);
|
||
WINDOWS_TESTS
|
||
;;
|
||
# Linux owns full coverage; Windows targets native process/path wrappers.
|
||
test-1)
|
||
if node -e 'process.exit(require("./package.json").scripts?.["test:windows:ci:1"] ? 0 : 1)'; then
|
||
pnpm test:windows:ci:1 -- --fileParallelism
|
||
else
|
||
pnpm test:windows:ci
|
||
fi
|
||
;;
|
||
test-2)
|
||
if node -e 'process.exit(require("./package.json").scripts?.["test:windows:ci:2"] ? 0 : 1)'; then
|
||
pnpm test:windows:ci:2 -- --fileParallelism
|
||
else
|
||
echo "[skip] target's combined Windows suite ran in test-1"
|
||
fi
|
||
;;
|
||
*)
|
||
echo "Unsupported Windows checks task: $TASK" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
macos-node:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ matrix.check_name || 'macos-node' }}
|
||
needs: [preflight]
|
||
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON(((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND))) && 'macos-15' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || github.run_attempt > 1) && 'macos-15' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-6vcpu-macos-15' || 'macos-15')))) || (((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND))) && 'macos-15' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || github.run_attempt > 1) && 'macos-15' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-6vcpu-macos-15' || 'macos-15')) }}
|
||
timeout-minutes: 20
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 3
|
||
matrix: ${{ fromJson(needs.preflight.outputs.macos_node_matrix) }}
|
||
steps:
|
||
- *platform_checkout_step
|
||
|
||
- *plain_node_setup_step
|
||
|
||
- 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
|
||
node -e 'const os = require("node:os"); console.log("Native OS resources: " + JSON.stringify({ logicalCpuCount: os.cpus().length, availableParallelism: os.availableParallelism(), osTotalMemoryBytes: os.totalmem(), osFreeMemoryBytes: os.freemem() }));'
|
||
case "$TASK" in
|
||
test-[123])
|
||
pnpm "test:macos:ci:${TASK#test-}"
|
||
;;
|
||
test)
|
||
# Linux owns full coverage; macOS targets launchd/Homebrew, paths, and process groups.
|
||
pnpm test:macos:ci
|
||
;;
|
||
*)
|
||
echo "Unsupported macOS node task: $TASK" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
macos-swift:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ format('macos-swift ({0})', matrix.phase) }}
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_macos_swift == 'true'
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 2
|
||
matrix:
|
||
phase: ${{ fromJSON(github.event_name == 'workflow_dispatch' && needs.preflight.outputs.validation_tier != 'main' && !inputs.release_gate && needs.preflight.outputs.release_scope == 'full' && '["release","tests","packages"]' || '["tests","packages"]') }}
|
||
runs-on: &hosted_xcode_runner ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON('xcode-27'))) || ('xcode-27') }}
|
||
timeout-minutes: 30
|
||
env:
|
||
HISTORICAL_TARGET: *historical_target
|
||
MACOS_PRIMARY_PHASE: ${{ github.event_name == 'workflow_dispatch' && needs.preflight.outputs.validation_tier != 'main' && !inputs.release_gate && needs.preflight.outputs.release_scope == 'full' && 'release' || 'tests' }}
|
||
OPENCLAWKIT_TEST_EXECUTION: ${{ (github.event_name == 'schedule' || (github.event_name == 'workflow_dispatch' && needs.preflight.outputs.ci_shape != 'main') || github.run_attempt > 1) && 'serial' || 'parallel' }}
|
||
steps:
|
||
- name: Start Swift cache clock
|
||
id: swift-cache-clock
|
||
continue-on-error: true
|
||
timeout-minutes: 1
|
||
run: |
|
||
python3 -I -S - <<'PYTHON'
|
||
import os
|
||
import time
|
||
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
|
||
print(f"started={time.monotonic()}", file=output)
|
||
PYTHON
|
||
|
||
- *platform_checkout_step
|
||
|
||
- &select_xcode_step
|
||
name: Select Xcode
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||
select_xcode_toolchain
|
||
|
||
- name: Setup Node environment for Apple assets
|
||
if: hashFiles('scripts/prepare-apple-mermaid.mjs') != ''
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
node-version: ${{ env.NODE_VERSION }}
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
install-deps: "false"
|
||
|
||
- name: Install Mermaid renderer dependencies
|
||
if: hashFiles('scripts/prepare-apple-mermaid.mjs') != ''
|
||
env:
|
||
CI: "true"
|
||
run: &mermaid_renderer_install_run >-
|
||
pnpm install --frozen-lockfile --prefer-offline --optional
|
||
--filter '@openclaw/mermaid-renderer...'
|
||
--config.ignore-scripts=false
|
||
--config.engine-strict=false
|
||
--config.enable-pre-post-scripts=true
|
||
--config.side-effects-cache=true
|
||
|
||
- name: Setup Node.js for native test tooling
|
||
if: hashFiles('scripts/prepare-apple-mermaid.mjs') == ''
|
||
env:
|
||
REQUESTED_NODE_VERSION: ${{ env.NODE_VERSION }}
|
||
run: *ensure_node_run
|
||
|
||
- name: Prepare Apple Mermaid assets
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ -f scripts/prepare-apple-mermaid.mjs ]]; then
|
||
node scripts/prepare-apple-mermaid.mjs
|
||
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
||
echo "Frozen target predates bundled Mermaid assets."
|
||
else
|
||
echo "Current CI targets must provide scripts/prepare-apple-mermaid.mjs." >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||
if: matrix.phase == env.MACOS_PRIMARY_PHASE
|
||
run: |
|
||
if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then
|
||
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
|
||
./scripts/install-xcodegen.sh "$swift_tools_dir"
|
||
./scripts/install-swift-tools.sh "$swift_tools_dir"
|
||
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
||
"$swift_tools_dir/xcodegen" --version
|
||
"$swift_tools_dir/swiftformat" --version
|
||
"$swift_tools_dir/swiftlint" version
|
||
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
||
# Frozen release targets before the pinned installer used one of these
|
||
# reviewed formatter contracts. Fail closed for any unknown minimum.
|
||
brew update
|
||
brew install xcodegen swiftlint
|
||
swiftformat_min_version="$(awk '$1 == "--min-version" { print $2; exit }' config/swiftformat)"
|
||
case "$swiftformat_min_version" in
|
||
""|0.61.1)
|
||
swiftformat_version="0.61.1"
|
||
swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"
|
||
;;
|
||
0.62.1)
|
||
swiftformat_version="0.62.1"
|
||
swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a"
|
||
;;
|
||
*)
|
||
echo "Unsupported frozen-target SwiftFormat minimum: $swiftformat_min_version" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
swiftformat_archive="$RUNNER_TEMP/swiftformat-$swiftformat_version.zip"
|
||
swift_tools_dir="$RUNNER_TEMP/openclaw-legacy-swift-tools"
|
||
curl --fail --location --no-progress-meter --show-error \
|
||
--connect-timeout 10 --max-time 120 \
|
||
--retry 3 --retry-max-time 120 \
|
||
--output "$swiftformat_archive" \
|
||
"https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip" \
|
||
2> >(sed 's/^Warning: /::warning::/' >&2)
|
||
if [[ "$(shasum -a 256 "$swiftformat_archive" | awk '{print $1}')" != "$swiftformat_checksum" ]]; then
|
||
echo "SwiftFormat $swiftformat_version archive checksum mismatch" >&2
|
||
exit 1
|
||
fi
|
||
mkdir -p "$swift_tools_dir"
|
||
unzip -q "$swiftformat_archive" -d "$swift_tools_dir"
|
||
chmod +x "$swift_tools_dir/swiftformat"
|
||
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
||
[[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]]
|
||
else
|
||
echo "Current CI targets must provide scripts/install-xcodegen.sh and scripts/install-swift-tools.sh." >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Detect Swift toolchain cache key
|
||
id: swift-toolchain
|
||
if: matrix.phase != 'packages'
|
||
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: Restore SwiftPM cache
|
||
id: swiftpm-cache
|
||
if: needs.preflight.outputs.cache_mode != 'off'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: ~/Library/Caches/org.swift.swiftpm
|
||
key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-swiftpm-
|
||
|
||
- name: Restore Swift build directory cache
|
||
id: swift-build-cache
|
||
if: matrix.phase != 'packages' && needs.preflight.outputs.cache_mode != 'off'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: apps/macos/.build
|
||
# Cached SwiftPM traits and phase products need separate archives.
|
||
key: ${{ runner.os }}-swift-build-${{ matrix.phase == 'tests' && 'v7' || 'v6' }}-${{ matrix.phase }}-${{ hashFiles('scripts/swift-build-cache-metadata.py') }}-graph-${{ steps.swift-toolchain.outputs.key }}-${{ hashFiles('apps/macos/Package*.swift', 'apps/macos/Package.resolved', 'apps/shared/**/Package*.swift', 'apps/shared/**/Package.resolved', 'apps/swabble/Package*.swift', 'apps/swabble/Package.resolved') }}-${{ hashFiles('apps/macos/Sources/**', 'apps/macos/Tests/**', 'apps/shared/**/Sources/**', 'apps/swabble/Sources/**') }}
|
||
restore-keys: |
|
||
${{ runner.os }}-swift-build-${{ matrix.phase == 'tests' && 'v7' || 'v6' }}-${{ matrix.phase }}-${{ hashFiles('scripts/swift-build-cache-metadata.py') }}-graph-${{ steps.swift-toolchain.outputs.key }}-${{ hashFiles('apps/macos/Package*.swift', 'apps/macos/Package.resolved', 'apps/shared/**/Package*.swift', 'apps/shared/**/Package.resolved', 'apps/swabble/Package*.swift', 'apps/swabble/Package.resolved') }}-
|
||
|
||
- name: Validate Swift build cache
|
||
id: validate-swift-build-cache
|
||
if: matrix.phase != 'packages'
|
||
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: Restore Swift build input timestamps
|
||
if: steps.validate-swift-build-cache.outputs.cache-valid == 'true' && env.HISTORICAL_TARGET != 'true'
|
||
run: python3 -I -S scripts/swift-build-cache-metadata.py restore
|
||
|
||
- name: Show toolchain
|
||
run: |
|
||
sw_vers
|
||
xcodebuild -version
|
||
swift --version
|
||
|
||
- name: Native state schema version contract
|
||
if: matrix.phase == env.MACOS_PRIMARY_PHASE
|
||
run: |
|
||
if [[ -f scripts/check-native-state-schema-version.mjs ]]; then
|
||
node scripts/check-native-state-schema-version.mjs
|
||
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
||
echo "[skip] native state schema version guard is not present in this historical target"
|
||
else
|
||
echo "Current CI targets must provide scripts/check-native-state-schema-version.mjs." >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Swift lint
|
||
if: matrix.phase == env.MACOS_PRIMARY_PHASE
|
||
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,**/BrowserInspectScript.generated.swift'
|
||
else
|
||
echo "Current CI targets must provide the Swift lint and format wrappers." >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Swift build (release)
|
||
if: matrix.phase == 'release'
|
||
run: |
|
||
set -euo pipefail
|
||
# 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
|
||
|
||
sparkle_framework="apps/macos/.build/artifacts/sparkle/Sparkle/Sparkle.xcframework"
|
||
[[ -d "$sparkle_framework" && ! -f "$sparkle_framework/Info.plist" ]] || exit 1
|
||
echo "::warning::SwiftPM did not produce complete Sparkle metadata; resetting once before retry."
|
||
swift package --package-path apps/macos reset
|
||
swift build --package-path apps/macos --product OpenClaw --configuration release
|
||
|
||
- name: OpenClawKit Talk-trait opt-out (no ElevenLabsKit when default traits disabled)
|
||
if: matrix.phase == 'packages'
|
||
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 --disable-index-store -Xswiftc -gline-tables-only
|
||
|
||
# openclawkit-tests-contract-v1: the target owns an independently runnable package suite.
|
||
- name: OpenClawKit tests
|
||
if: matrix.phase == 'packages' && needs.preflight.outputs.run_openclawkit_tests == 'true'
|
||
run: |
|
||
set -euo pipefail
|
||
openclawkit_scratch="$(mktemp -d "$RUNNER_TEMP/openclawkit.XXXXXX")"
|
||
trap 'rm -rf "$openclawkit_scratch"' EXIT
|
||
openclawkit_test_args=(
|
||
--package-path apps/shared/OpenClawKit
|
||
--scratch-path "$openclawkit_scratch"
|
||
--disable-index-store -Xswiftc -gline-tables-only
|
||
)
|
||
if [[ "$OPENCLAWKIT_TEST_EXECUTION" == "parallel" ]]; then
|
||
openclawkit_test_args+=(--parallel)
|
||
else
|
||
openclawkit_test_args+=(--no-parallel)
|
||
fi
|
||
swift test "${openclawkit_test_args[@]}"
|
||
|
||
- name: Swabble tests
|
||
if: matrix.phase == 'packages' && env.HISTORICAL_TARGET != 'true'
|
||
run: |
|
||
set -euo pipefail
|
||
swabble_scratch="$(mktemp -d "$RUNNER_TEMP/swabble.XXXXXX")"
|
||
trap 'rm -rf "$swabble_scratch"' EXIT
|
||
swift test --package-path apps/swabble --scratch-path "$swabble_scratch" --build-system native --disable-index-store -Xswiftc -gline-tables-only --no-parallel
|
||
|
||
- name: Swift test
|
||
id: swift-test
|
||
if: matrix.phase == 'tests'
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||
native_test_log_dir="$RUNNER_TEMP/openclaw-native-test-logs"
|
||
native_test_log_id="$(node -e 'process.stdout.write(require("node:crypto").randomUUID())')"
|
||
# SwiftPM's swiftbuild backend can omit the test-bundle rpath for Sparkle.
|
||
# Remove once runner toolchains pass with swiftlang/swift-package-manager#10394.
|
||
# Keep the release build above on the default backend.
|
||
# CI needs coverage and line backtraces, not IDE indexes or debugger type metadata.
|
||
swift_test_args=(--package-path apps/macos --build-system native --enable-code-coverage --disable-index-store -Xswiftc -gline-tables-only)
|
||
swift build "${swift_test_args[@]}" --build-tests
|
||
echo "debug-tests-built=true" >> "$GITHUB_OUTPUT"
|
||
swift_test_args+=(--skip-build)
|
||
if [[ -f scripts/test-macos-native.mts ]]; then
|
||
logical_cpu="$(sysctl -n hw.logicalcpu)"
|
||
if [[ ! "$logical_cpu" =~ ^[1-9][0-9]*$ ]]; then
|
||
echo "Invalid macOS logical CPU count: $logical_cpu" >&2
|
||
exit 1
|
||
fi
|
||
swift_test_width=$(( logical_cpu < 12 ? logical_cpu : 12 ))
|
||
echo "[macos-swift] Swift Testing parallelization width: $swift_test_width"
|
||
swift_test_args+=(--experimental-maximum-parallelization-width "$swift_test_width")
|
||
# Quick Chat keeps XCTest process isolation: parallel suites take key status and dismiss its
|
||
# panel. Frozen targets without the suite skip the lane.
|
||
if [[ "$HISTORICAL_TARGET" != "true" || -f apps/macos/Tests/OpenClawIPCTests/QuickChatCatalogPresentationTests.swift ]]; then
|
||
run_apple_command_logged "$native_test_log_dir/default-$native_test_log_id.log" node scripts/test-macos-native.mts default "${swift_test_args[@]}" --skip "AppStateIsolationTests|ProfileChatPreferencesTests|QuickChatCatalogPresentationTests"
|
||
run_apple_command_logged "$native_test_log_dir/default-rendered-$native_test_log_id.log" node scripts/test-macos-native.mts default "${swift_test_args[@]}" --filter "QuickChatCatalogPresentationTests"
|
||
else
|
||
run_apple_command_logged "$native_test_log_dir/default-$native_test_log_id.log" node scripts/test-macos-native.mts default "${swift_test_args[@]}" --skip "AppStateIsolationTests|ProfileChatPreferencesTests"
|
||
fi
|
||
run_apple_command_logged "$native_test_log_dir/named-$native_test_log_id.log" node scripts/test-macos-native.mts named "${swift_test_args[@]}" --filter "AppStateIsolationTests|ProfileChatPreferencesTests"
|
||
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
||
# Frozen release targets predate the resource-owner script and its tests.
|
||
# Their original suite still runs only on this disposable macOS worker.
|
||
swift_test_args+=(--no-parallel)
|
||
run_apple_command_logged "$native_test_log_dir/default-$native_test_log_id.log" swift test "${swift_test_args[@]}"
|
||
else
|
||
echo "Current CI targets must provide scripts/test-macos-native.mts." >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Upload macOS native test logs
|
||
if: ${{ always() && matrix.phase == 'tests' }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: macos-native-test-logs
|
||
path: ${{ runner.temp }}/openclaw-native-test-logs/*.log
|
||
if-no-files-found: ignore
|
||
retention-days: 7
|
||
|
||
- name: Upload default-profile chat menu captures
|
||
if: ${{ always() && steps.swift-test.outputs.menu-default-artifact-path != '' }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: macos-chat-menu-captures-default
|
||
path: |
|
||
${{ runner.temp }}/openclaw-menu-default-*/*-window.png
|
||
${{ runner.temp }}/openclaw-menu-default-*/*-menu-*.png
|
||
${{ runner.temp }}/openclaw-menu-default-*/*-capture-status.json
|
||
${{ runner.temp }}/openclaw-menu-default-*/capture-export.json
|
||
if-no-files-found: error
|
||
retention-days: 7
|
||
|
||
- name: Upload named-profile chat menu captures
|
||
if: ${{ always() && steps.swift-test.outputs.menu-named-artifact-path != '' }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: macos-chat-menu-captures-named
|
||
path: |
|
||
${{ steps.swift-test.outputs.menu-named-artifact-path }}/*-window.png
|
||
${{ steps.swift-test.outputs.menu-named-artifact-path }}/*-menu-*.png
|
||
${{ steps.swift-test.outputs.menu-named-artifact-path }}/*-capture-status.json
|
||
${{ steps.swift-test.outputs.menu-named-artifact-path }}/capture-export.json
|
||
if-no-files-found: error
|
||
retention-days: 7
|
||
|
||
- name: Render isolated macOS health fixtures
|
||
id: health-render
|
||
if: ${{ !cancelled() && github.event_name == 'workflow_dispatch' && needs.preflight.outputs.validation_tier != 'main' && !inputs.release_gate && needs.preflight.outputs.release_scope == 'full' && steps.swift-test.outputs.debug-tests-built == 'true' && hashFiles('scripts/test-macos-health-render.sh') != '' }}
|
||
run: bash scripts/test-macos-health-render.sh
|
||
|
||
- name: Upload macOS health component renders
|
||
if: ${{ always() && steps.health-render.outputs.artifact-path != '' }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: macos-health-component-renders
|
||
path: |
|
||
${{ steps.health-render.outputs.artifact-path }}/ready.png
|
||
${{ steps.health-render.outputs.artifact-path }}/startup-grace.png
|
||
${{ steps.health-render.outputs.artifact-path }}/stale-socket.png
|
||
${{ steps.health-render.outputs.artifact-path }}/disabled.png
|
||
${{ steps.health-render.outputs.artifact-path }}/probe-permission.png
|
||
if-no-files-found: error
|
||
retention-days: 7
|
||
|
||
- name: Check Swift cache save budget
|
||
id: swift-cache-budget
|
||
if: matrix.phase != 'packages' && needs.preflight.outputs.cache_write_allowed == 'true'
|
||
continue-on-error: true
|
||
timeout-minutes: 1
|
||
env:
|
||
CACHE_STARTED: ${{ steps.swift-cache-clock.outputs.started }}
|
||
run: |
|
||
python3 -I -S - <<'PYTHON'
|
||
import os
|
||
import time
|
||
try:
|
||
elapsed = time.monotonic() - float(os.environ.get("CACHE_STARTED", ""))
|
||
allowed = 0 <= elapsed < 20 * 60
|
||
except ValueError:
|
||
allowed = False
|
||
with open(os.environ["GITHUB_OUTPUT"], "a") as output:
|
||
print(f"allowed={str(allowed).lower()}", file=output)
|
||
if not allowed:
|
||
print("::notice::Skipping optional Swift cache saves: insufficient or unknown job budget.")
|
||
PYTHON
|
||
|
||
- name: Save SwiftPM cache
|
||
continue-on-error: true
|
||
timeout-minutes: 1
|
||
if: matrix.phase == env.MACOS_PRIMARY_PHASE && needs.preflight.outputs.cache_write_allowed == 'true' && steps.swiftpm-cache.outputs.cache-hit != 'true' && steps.swift-cache-budget.outputs.allowed == 'true'
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: ~/Library/Caches/org.swift.swiftpm
|
||
key: ${{ steps.swiftpm-cache.outputs.cache-primary-key }}
|
||
|
||
- name: Record Swift build input timestamps
|
||
id: record-swift-build-cache-metadata
|
||
continue-on-error: true
|
||
timeout-minutes: 1
|
||
if: needs.preflight.outputs.cache_write_allowed == 'true' && steps.swift-build-cache.outputs.cache-hit != 'true' && steps.swift-cache-budget.outputs.allowed == 'true' && env.HISTORICAL_TARGET != 'true'
|
||
run: python3 -I -S scripts/swift-build-cache-metadata.py record
|
||
|
||
- name: Save Swift build directory cache
|
||
continue-on-error: true
|
||
timeout-minutes: 2
|
||
if: needs.preflight.outputs.cache_write_allowed == 'true' && steps.swift-build-cache.outputs.cache-hit != 'true' && steps.swift-cache-budget.outputs.allowed == 'true' && (env.HISTORICAL_TARGET == 'true' || steps.record-swift-build-cache-metadata.outcome == 'success')
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: apps/macos/.build
|
||
key: ${{ steps.swift-build-cache.outputs.cache-primary-key }}
|
||
|
||
ios-build:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ format('ios-build ({0})', matrix.phase) }}
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_ios_build == 'true'
|
||
concurrency:
|
||
group: ${{ github.event_name == 'schedule' && format('{0}-hourly-ios-v1-{1}', github.workflow, matrix.phase) || format('{0}-ios-v1-{1}-{2}', github.workflow, github.run_id, matrix.phase) }}
|
||
cancel-in-progress: false
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 2
|
||
matrix:
|
||
phase: ${{ fromJSON(github.event_name == 'schedule' && '["tests"]' || github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.release_scope == 'full' && ((needs.preflight.outputs.validation_tier == 'main' || needs.preflight.outputs.compatibility_target == 'true') && '["tests"]' || '["release","tests"]') || '["smoke"]') }}
|
||
runs-on: *hosted_xcode_runner
|
||
timeout-minutes: 150
|
||
env:
|
||
HISTORICAL_TARGET: *historical_target
|
||
IOS_CI_PHASE: ${{ matrix.phase }}
|
||
IOS_MAIN_TIER: ${{ needs.preflight.outputs.validation_tier == 'main' }}
|
||
steps:
|
||
- *platform_checkout_step
|
||
|
||
- *select_xcode_step
|
||
|
||
- *plain_node_setup_step
|
||
|
||
- &install_watch_rust_toolchain
|
||
name: Install Watch Rust toolchain
|
||
if: env.HISTORICAL_TARGET != 'true' || hashFiles('apps/shared/OpenClawWatchRTC/Cargo.toml') != ''
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$HOME/.cargo/bin:$PATH"
|
||
watch_toolchain="$(awk -F '"' '/^channel =/ { print $2; exit }' apps/shared/OpenClawWatchRTC/rust-toolchain.toml)"
|
||
test -n "$watch_toolchain"
|
||
rustup toolchain install "$watch_toolchain" --profile minimal --component rust-src
|
||
|
||
- name: Test Watch RTC engine
|
||
if: matrix.phase == 'tests' && (env.HISTORICAL_TARGET != 'true' || hashFiles('apps/shared/OpenClawWatchRTC/Cargo.toml') != '')
|
||
working-directory: apps/shared/OpenClawWatchRTC
|
||
run: |
|
||
set -euo pipefail
|
||
export PATH="$HOME/.cargo/bin:$PATH"
|
||
SDKROOT="$(xcrun --sdk macosx --show-sdk-path)" \
|
||
AWS_LC_SYS_NO_ASM=0 AWS_LC_SYS_CMAKE_BUILDER=0 \
|
||
cargo test --locked --lib -j 2 -- --test-threads=1
|
||
|
||
- name: Install iOS Swift tooling
|
||
run: |
|
||
if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then
|
||
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
|
||
./scripts/install-xcodegen.sh "$swift_tools_dir"
|
||
./scripts/install-swift-tools.sh "$swift_tools_dir"
|
||
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
||
"$swift_tools_dir/xcodegen" --version
|
||
"$swift_tools_dir/swiftformat" --version
|
||
"$swift_tools_dir/swiftlint" version
|
||
elif [[ "$HISTORICAL_TARGET" == "true" ]]; then
|
||
# The generated Xcode project runs SwiftFormat during the build, so
|
||
# frozen targets must keep the formatter contract they were authored for.
|
||
brew update
|
||
brew install xcodegen swiftlint
|
||
swiftformat_min_version="$(awk '$1 == "--min-version" { print $2; exit }' config/swiftformat)"
|
||
case "$swiftformat_min_version" in
|
||
""|0.61.1)
|
||
swiftformat_version="0.61.1"
|
||
swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584"
|
||
;;
|
||
0.62.1)
|
||
swiftformat_version="0.62.1"
|
||
swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a"
|
||
;;
|
||
*)
|
||
echo "Unsupported frozen-target SwiftFormat minimum: $swiftformat_min_version" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
swiftformat_archive="$RUNNER_TEMP/swiftformat-$swiftformat_version.zip"
|
||
swift_tools_dir="$RUNNER_TEMP/openclaw-legacy-swift-tools"
|
||
curl --fail --location --no-progress-meter --show-error \
|
||
--connect-timeout 10 --max-time 120 \
|
||
--retry 3 --retry-max-time 120 \
|
||
--output "$swiftformat_archive" \
|
||
"https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip" \
|
||
2> >(sed 's/^Warning: /::warning::/' >&2)
|
||
if [[ "$(shasum -a 256 "$swiftformat_archive" | awk '{print $1}')" != "$swiftformat_checksum" ]]; then
|
||
echo "SwiftFormat $swiftformat_version archive checksum mismatch" >&2
|
||
exit 1
|
||
fi
|
||
mkdir -p "$swift_tools_dir"
|
||
unzip -q "$swiftformat_archive" -d "$swift_tools_dir"
|
||
chmod +x "$swift_tools_dir/swiftformat"
|
||
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
||
[[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]]
|
||
# Legacy generated Xcode phases prepend Homebrew ahead of GITHUB_PATH.
|
||
# Point that lookup at the verified binary or the build can bypass the pin.
|
||
swiftformat_link="$(brew --prefix)/bin/swiftformat"
|
||
ln -sfn "$swift_tools_dir/swiftformat" "$swiftformat_link"
|
||
[[ "$("$swiftformat_link" --version)" == "$swiftformat_version" ]]
|
||
else
|
||
echo "Current CI targets must provide scripts/install-xcodegen.sh and scripts/install-swift-tools.sh." >&2
|
||
exit 1
|
||
fi
|
||
|
||
- name: Swift lint
|
||
if: matrix.phase == 'smoke' || matrix.phase == 'tests'
|
||
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: Prepare iOS simulator
|
||
if: (matrix.phase == 'smoke' || matrix.phase == 'tests') && needs.preflight.outputs.compatibility_target != 'true'
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||
simulator_id="$(prepare_ios_test_simulator)"
|
||
if [[ -x ./scripts/install-simslim.sh && -x ./scripts/ios-simulator-prepare.sh ]]; then
|
||
simslim_dir="$RUNNER_TEMP/openclaw-simslim"
|
||
./scripts/install-simslim.sh "$simslim_dir"
|
||
OPENCLAW_CI_SIMSLIM_BINARY="$simslim_dir/simslim" \
|
||
./scripts/ios-simulator-prepare.sh "$simulator_id"
|
||
fi
|
||
echo "IOS_SIMULATOR_ID=$simulator_id" >> "$GITHUB_ENV"
|
||
if [[ "$IOS_CI_PHASE" == "smoke" || "$IOS_MAIN_TIER" == "true" ]]; then
|
||
# Apply to the embedded Watch build and the later XCTest build too.
|
||
smoke_settings="$RUNNER_TEMP/openclaw-ios-smoke.xcconfig"
|
||
printf 'ARCHS = %s\nCOMPILER_INDEX_STORE_ENABLE = NO\n' "$(uname -m)" > "$smoke_settings"
|
||
echo "XCODE_XCCONFIG_FILE=$smoke_settings" >> "$GITHUB_ENV"
|
||
fi
|
||
|
||
- name: Build iOS app
|
||
if: matrix.phase == 'smoke' || matrix.phase == 'tests'
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ ( "$IOS_CI_PHASE" == "smoke" || "$IOS_MAIN_TIER" == "true" ) && "$HISTORICAL_TARGET" != "true" ]]; then
|
||
# Full manual validation retains the universal simulator build.
|
||
export IOS_DEST="platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?}"
|
||
fi
|
||
pnpm ios:build
|
||
|
||
- name: Build iOS app (Release)
|
||
if: matrix.phase == 'release'
|
||
run: |
|
||
set -euo pipefail
|
||
./scripts/ios-configure-signing.sh
|
||
./scripts/ios-write-version-xcconfig.sh
|
||
node scripts/ios-write-swift-filelist.mjs
|
||
xcodegen generate --spec apps/ios/project.yml --project apps/ios
|
||
xcodebuild \
|
||
-project apps/ios/OpenClaw.xcodeproj \
|
||
-scheme OpenClaw \
|
||
-configuration Release \
|
||
-destination "generic/platform=iOS" \
|
||
CODE_SIGNING_ALLOWED=NO \
|
||
build
|
||
|
||
- name: Prove native managed document download and export
|
||
id: ios_attachment_tests
|
||
if: matrix.phase == 'tests' && needs.preflight.outputs.validation_tier != 'main' && needs.preflight.outputs.compatibility_target != 'true'
|
||
env:
|
||
BASELINE_SHA: ${{ github.event.pull_request.base.sha }}
|
||
run: /bin/bash scripts/test-ios-chat-attachments.sh "$BASELINE_SHA"
|
||
|
||
- name: Run focused iOS voice cleanup simulator tests
|
||
id: ios_voice_cleanup_tests
|
||
if: (matrix.phase == 'smoke' || matrix.phase == 'tests') && needs.preflight.outputs.compatibility_target != 'true'
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||
mkdir -p apps/ios/build/LifecycleTestResults
|
||
diagnostic_collection=on-failure
|
||
if [[ "$IOS_CI_PHASE" == "smoke" || "$IOS_MAIN_TIER" == "true" ]]; then
|
||
# Xcode 27's verbose simulator diagnostics can stall for 600s after passing tests.
|
||
# Keep xcresult/log evidence here and verbose diagnostics in full manual validation.
|
||
diagnostic_collection=never
|
||
fi
|
||
run_apple_command_logged apps/ios/build/LifecycleTestResults/OpenClawVoiceCleanupTests.log xcodebuild \
|
||
-project apps/ios/OpenClaw.xcodeproj \
|
||
-scheme OpenClaw \
|
||
-configuration Debug \
|
||
-destination "platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?}" \
|
||
-resultBundlePath apps/ios/build/LifecycleTestResults/OpenClawVoiceCleanupTests.xcresult \
|
||
-parallel-testing-enabled NO \
|
||
-collect-test-diagnostics "$diagnostic_collection" \
|
||
-only-testing:OpenClawTests/TalkRealtimeVoiceSessionCleanupTests \
|
||
-only-testing:OpenClawTests/TalkRealtimeConsultCancellationTests \
|
||
-only-testing:OpenClawTests/TalkRealtimeTranscriptWriteQueueTests \
|
||
-only-testing:OpenClawTests/TalkModeManagerTests \
|
||
-only-testing:OpenClawTests/ManagedDocumentEnvelopeTests \
|
||
-only-testing:OpenClawTests/IOSMediaArtifactLoaderTests \
|
||
-only-testing:OpenClawTests/OpenClawTypographyTests \
|
||
test
|
||
|
||
- name: Run focused iOS lifecycle simulator tests
|
||
id: ios_lifecycle_tests
|
||
if: (matrix.phase == 'smoke' || matrix.phase == 'tests') && needs.preflight.outputs.compatibility_target != 'true'
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||
result_bundle="apps/ios/build/LifecycleTestResults/OpenClawLifecycleTests.xcresult"
|
||
mkdir -p "apps/ios/build/LifecycleTestResults"
|
||
test_args=(
|
||
-only-testing:OpenClawTests/CloudflareAccessClientTests
|
||
-only-testing:OpenClawTests/CloudflareAccessTransferTests
|
||
-only-testing:OpenClawTests/CloudflareAccessSessionStoreTests
|
||
-only-testing:OpenClawTests/ChatTypingFocusTests
|
||
-only-testing:OpenClawTests/ChatSendHydrationTests
|
||
)
|
||
if [[ "$IOS_CI_PHASE" == "tests" ]]; then
|
||
test_args+=(
|
||
-only-testing:OpenClawLogicTests/WatchVoiceTurnTrackerTests
|
||
-only-testing:OpenClawTests/DelayedActionGateTests
|
||
-only-testing:OpenClawTests/IOSGatewayChatTransportTests
|
||
-only-testing:OpenClawTests/LocationServiceCallbackTests
|
||
-only-testing:OpenClawTests/LocationServiceOrderingTests
|
||
-only-testing:OpenClawTests/NodeAppModelInvokeTests
|
||
-only-testing:OpenClawTests/OpenClawAppDelegateTests
|
||
-only-testing:OpenClawTests/WatchMessagingInboundTransportTests
|
||
-only-testing:OpenClawTests/WatchSessionActivationGateTests
|
||
-only-testing:OpenClawTests/OpenClawTypographyTests
|
||
-only-testing:OpenClawTests/NotificationServingPreferenceTests
|
||
-only-testing:OpenClawTests/RootTabsSourceGuardTests
|
||
-only-testing:OpenClawTests/TraceHeadingVisualProofTests
|
||
)
|
||
fi
|
||
diagnostic_collection=on-failure
|
||
if [[ "$IOS_CI_PHASE" == "smoke" || "$IOS_MAIN_TIER" == "true" ]]; then
|
||
diagnostic_collection=never
|
||
fi
|
||
run_apple_command_logged "${result_bundle%.xcresult}.log" xcodebuild \
|
||
-project apps/ios/OpenClaw.xcodeproj \
|
||
-scheme OpenClaw \
|
||
-configuration Debug \
|
||
-destination "platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?}" \
|
||
-resultBundlePath "$result_bundle" \
|
||
-parallel-testing-enabled NO \
|
||
-collect-test-diagnostics "$diagnostic_collection" \
|
||
"${test_args[@]}" \
|
||
test
|
||
if [[ "$IOS_CI_PHASE" == "tests" && "$IOS_MAIN_TIER" != "true" ]]; then
|
||
run_apple_command_logged apps/ios/build/LifecycleTestResults/OpenClawWatchDeliveryUITests.log xcodebuild \
|
||
-project apps/ios/OpenClaw.xcodeproj \
|
||
-scheme OpenClawUITests \
|
||
-configuration Debug \
|
||
-destination "platform=iOS Simulator,id=${IOS_SIMULATOR_ID:?}" \
|
||
-resultBundlePath apps/ios/build/LifecycleTestResults/OpenClawWatchDeliveryUITests.xcresult \
|
||
-parallel-testing-enabled NO \
|
||
-only-testing:OpenClawUITests/OpenClawSnapshotUITests/testWatchMessageDeliveryIsReachableFromSettings \
|
||
-only-testing:OpenClawUITests/BootstrapSetupFailureUITests \
|
||
test
|
||
fi
|
||
|
||
- name: Run focused Apple Watch operation simulator tests
|
||
if: matrix.phase == 'tests' && needs.preflight.outputs.validation_tier != 'main' && needs.preflight.outputs.compatibility_target != 'true'
|
||
run: |
|
||
set -euo pipefail
|
||
source .ci-harness/scripts/lib/swift-toolchain.sh
|
||
simulator_id="$(
|
||
xcrun simctl list devices available --json | node --input-type=module -e '
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||
const runtimes = JSON.parse(Buffer.concat(chunks).toString("utf8")).devices;
|
||
const simulator = Object.values(runtimes)
|
||
.flat()
|
||
.find((device) => device.isAvailable && device.name.startsWith("Apple Watch"));
|
||
if (!simulator) {
|
||
console.error("No available Apple Watch simulator for operation lifecycle tests");
|
||
process.exit(1);
|
||
}
|
||
process.stdout.write(simulator.udid);
|
||
'
|
||
)"
|
||
# Reuse the Watch products already compiled by the generic iOS build.
|
||
# Resolve the selected target product from Xcode, not its DerivedData naming.
|
||
xcodebuild_args=(
|
||
-project apps/ios/OpenClaw.xcodeproj
|
||
-scheme OpenClawWatchApp
|
||
-configuration Debug
|
||
-destination "platform=watchOS Simulator,id=${simulator_id}"
|
||
CODE_SIGNING_ALLOWED=NO
|
||
)
|
||
test_args=(
|
||
-parallel-testing-enabled NO
|
||
-only-testing:OpenClawWatchTests/WatchInboxStoreOperationTests
|
||
-only-testing:OpenClawWatchTests/WatchSpeechPlaybackTests
|
||
-only-testing:OpenClawWatchTests/WatchRealtimeMediaTests
|
||
-only-testing:OpenClawWatchTests/WatchGatewayConfigurationTests
|
||
)
|
||
run_apple_command_logged apps/ios/build/LifecycleTestResults/OpenClawWatchBuild.log xcodebuild \
|
||
"${xcodebuild_args[@]}" "${test_args[@]}" build-for-testing
|
||
app_path="$(
|
||
xcodebuild "${xcodebuild_args[@]}" -showBuildSettings -json |
|
||
node --input-type=module -e '
|
||
import path from "node:path";
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||
const targets = JSON.parse(Buffer.concat(chunks).toString("utf8"))
|
||
.filter((target) => target.target === "OpenClawWatchApp");
|
||
if (targets.length !== 1) throw new Error("Expected one Watch app target from Xcode");
|
||
const { TARGET_BUILD_DIR, FULL_PRODUCT_NAME } = targets[0].buildSettings;
|
||
if (!path.isAbsolute(TARGET_BUILD_DIR) || !FULL_PRODUCT_NAME) {
|
||
throw new Error("Expected an absolute Watch app product from Xcode");
|
||
}
|
||
process.stdout.write(path.join(TARGET_BUILD_DIR, FULL_PRODUCT_NAME));
|
||
'
|
||
)"
|
||
companion_id="$(
|
||
xcrun simctl list pairs --json | node --input-type=module -e '
|
||
const chunks = [];
|
||
for await (const chunk of process.stdin) chunks.push(chunk);
|
||
const pairs = Object.values(JSON.parse(Buffer.concat(chunks).toString("utf8")).pairs);
|
||
const pair = pairs.find((entry) => entry.watch.udid === process.argv[1]);
|
||
process.stdout.write(pair?.phone.udid ?? "");
|
||
' "$simulator_id"
|
||
)"
|
||
# XCTest connects through the paired phone, which can differ from IOS_SIMULATOR_ID.
|
||
if [[ -n "$companion_id" ]]; then
|
||
xcrun simctl bootstatus "$companion_id" -b
|
||
fi
|
||
xcrun simctl boot "$simulator_id" 2>/dev/null || true
|
||
xcrun simctl bootstatus "$simulator_id" -b
|
||
xcrun simctl install "$simulator_id" "$app_path"
|
||
run_apple_command_logged apps/ios/build/LifecycleTestResults/OpenClawWatchOperationTests.log xcodebuild \
|
||
"${xcodebuild_args[@]}" "${test_args[@]}" \
|
||
-resultBundlePath apps/ios/build/LifecycleTestResults/OpenClawWatchOperationTests.xcresult \
|
||
test-without-building
|
||
|
||
- name: Upload iOS lifecycle simulator evidence
|
||
if: ${{ always() && ((steps.ios_lifecycle_tests.outcome != '' && steps.ios_lifecycle_tests.outcome != 'skipped') || (steps.ios_voice_cleanup_tests.outcome != '' && steps.ios_voice_cleanup_tests.outcome != 'skipped') || (steps.ios_attachment_tests.outcome != '' && steps.ios_attachment_tests.outcome != 'skipped')) }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: ios-lifecycle-tests-${{ needs.preflight.outputs.checkout_revision }}
|
||
path: |
|
||
apps/ios/build/LifecycleTestResults/*.xcresult
|
||
apps/ios/build/LifecycleTestResults/*.log
|
||
apps/ios/build/LifecycleTestResults/Attachment-*
|
||
apps/ios/build/LifecycleTestResults/attachments-*
|
||
if-no-files-found: warn
|
||
retention-days: 14
|
||
|
||
ios-release-e2e:
|
||
permissions:
|
||
contents: read
|
||
needs: [preflight]
|
||
if: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.validation_tier == 'full' && needs.preflight.outputs.release_scope == 'full' && needs.preflight.outputs.checkout_revision == github.sha && needs.preflight.outputs.compatibility_target != 'true' && needs.preflight.outputs.run_ios_build == 'true' }}
|
||
uses: ./.github/workflows/ios-release-e2e.yml
|
||
with:
|
||
target_sha: *checkout_sha
|
||
mode: stock
|
||
|
||
ios-screenshot-shard:
|
||
permissions:
|
||
contents: read
|
||
name: "ios-screenshots-${{ matrix.device_family }}"
|
||
needs: [preflight]
|
||
if: &ios_screenshot_capture_gate ${{ needs.preflight.outputs.validation_tier != 'main' && needs.preflight.outputs.release_scope == 'full' && needs.preflight.outputs.compatibility_target != 'true' && ((github.event_name == 'workflow_dispatch' && !inputs.release_gate) || needs.preflight.outputs.run_ios_screenshots == 'true') }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON('xcode-27-xlarge'))) || ('xcode-27-xlarge') }}
|
||
timeout-minutes: 90
|
||
env:
|
||
BUNDLE_DEPLOYMENT: "true"
|
||
BUNDLE_GEMFILE: ${{ github.workspace }}/apps/ios/Gemfile
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: 2
|
||
matrix:
|
||
device_family: [iphone, ipad-13]
|
||
steps:
|
||
- *platform_checkout_step
|
||
|
||
- name: Setup Ruby
|
||
uses: ruby/setup-ruby@a0102e0972be65f351c307e2d64b9314a57c8073 # v1.324.0
|
||
with:
|
||
ruby-version: "3.4.10"
|
||
bundler: "4.0.21"
|
||
bundler-cache: false
|
||
working-directory: apps/ios
|
||
|
||
- name: Install locked Fastlane bundle
|
||
working-directory: apps/ios
|
||
run: |
|
||
bundle _4.0.21_ install --jobs 4 --retry 3
|
||
bundle _4.0.21_ check
|
||
bundle _4.0.21_ exec fastlane --version
|
||
|
||
- *select_xcode_step
|
||
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
node-version: ${{ env.IOS_SCREENSHOT_NODE_VERSION }}
|
||
|
||
- name: Install iOS screenshot tooling
|
||
run: |
|
||
swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools"
|
||
./scripts/install-xcodegen.sh "$swift_tools_dir"
|
||
./scripts/install-swift-tools.sh "$swift_tools_dir"
|
||
echo "$swift_tools_dir" >> "$GITHUB_PATH"
|
||
"$swift_tools_dir/xcodegen" --version
|
||
"$swift_tools_dir/swiftformat" --version
|
||
"$swift_tools_dir/swiftlint" version
|
||
|
||
- *install_watch_rust_toolchain
|
||
|
||
- name: Capture iOS device screenshot shard
|
||
id: device_screenshots
|
||
env:
|
||
OPENCLAW_SNAPSHOT_DEVICE_FAMILY: ${{ matrix.device_family }}
|
||
OPENCLAW_SNAPSHOT_SKIP_WATCH: ${{ matrix.device_family == 'iphone' && '1' || '0' }}
|
||
OPENCLAW_SNAPSHOT_DIAGNOSTICS: "1"
|
||
run: pnpm ios:screenshots
|
||
|
||
- name: Package iOS screenshot shard evidence
|
||
id: package_screenshot_evidence
|
||
env:
|
||
DEVICE_FAMILY: ${{ matrix.device_family }}
|
||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||
RUN_ID: ${{ github.run_id }}
|
||
TARGET_SHA: *checkout_sha
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: |
|
||
set -euo pipefail
|
||
fastlane_version="$(
|
||
source scripts/lib/ios-fastlane.sh
|
||
run_ios_fastlane --version 2>&1 | awk 'match($0, /^fastlane [0-9]+\.[0-9]+\.[0-9]+$/) { print substr($0, RSTART + 9, RLENGTH - 9); exit }'
|
||
)"
|
||
xcode_version="$(xcodebuild -version | paste -sd ' ' -)"
|
||
test "$xcode_version" = "$IOS_SCREENSHOT_XCODE_VERSION"
|
||
collect_args=(
|
||
collect
|
||
--family "$DEVICE_FAMILY"
|
||
--screenshots apps/ios/fastlane/screenshots/en-US
|
||
--xcresults apps/ios/build/SnapshotTestResults
|
||
--output apps/ios/build/ScreenshotEvidenceShard
|
||
--target-sha "$TARGET_SHA"
|
||
--workflow-sha "$WORKFLOW_SHA"
|
||
--run-id "$RUN_ID"
|
||
--run-attempt "$RUN_ATTEMPT"
|
||
--xcode-version "$xcode_version"
|
||
--fastlane-version "$fastlane_version"
|
||
--node-version "$(node --version)"
|
||
)
|
||
node .ci-harness/scripts/ios-screenshot-evidence.mjs "${collect_args[@]}"
|
||
if [[ "$DEVICE_FAMILY" == "ipad-13" ]]; then
|
||
collect_args[2]="watch"
|
||
node .ci-harness/scripts/ios-screenshot-evidence.mjs "${collect_args[@]}"
|
||
fi
|
||
|
||
- name: Upload iOS screenshot shard evidence
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: ios-release-screenshot-shard-${{ matrix.device_family }}-${{ needs.preflight.outputs.checkout_revision }}
|
||
path: apps/ios/build/ScreenshotEvidenceShard/
|
||
if-no-files-found: error
|
||
retention-days: 14
|
||
|
||
- name: Upload failed iOS screenshot attempt evidence
|
||
if: ${{ always() && (steps.device_screenshots.outcome == 'failure' || steps.package_screenshot_evidence.outcome == 'failure') }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: ios-release-screenshot-failure-${{ matrix.device_family }}-${{ needs.preflight.outputs.checkout_revision }}
|
||
path: |
|
||
apps/ios/fastlane/screenshots/en-US/*.png
|
||
apps/ios/build/SnapshotTestResults/capture-attempts.json
|
||
apps/ios/build/SnapshotTestResults/*.xcresult
|
||
apps/ios/build/SnapshotLogs/*.log
|
||
apps/ios/build/screenshot-diagnostics.json
|
||
if-no-files-found: warn
|
||
retention-days: 14
|
||
|
||
ios-screenshot-evidence:
|
||
permissions:
|
||
contents: read
|
||
name: "ios-screenshot-evidence"
|
||
needs: [preflight, ios-screenshot-shard]
|
||
if: *ios_screenshot_capture_gate
|
||
runs-on: *hosted_linux_runner
|
||
timeout-minutes: 10
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
|
||
- name: Setup screenshot evidence Node
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
install-deps: "false"
|
||
node-version: ${{ env.IOS_SCREENSHOT_NODE_VERSION }}
|
||
|
||
- name: Download iOS screenshot shard evidence
|
||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||
with:
|
||
pattern: ios-release-screenshot-shard-*-${{ needs.preflight.outputs.checkout_revision }}
|
||
path: apps/ios/build/ScreenshotEvidenceInputs
|
||
merge-multiple: false
|
||
|
||
- name: Reduce iOS screenshot evidence
|
||
id: reduce_screenshot_evidence
|
||
env:
|
||
RUN_ATTEMPT: ${{ github.run_attempt }}
|
||
RUN_ID: ${{ github.run_id }}
|
||
TARGET_SHA: *checkout_sha
|
||
WORKFLOW_SHA: ${{ github.workflow_sha }}
|
||
run: |
|
||
node .ci-harness/scripts/ios-screenshot-evidence.mjs reduce \
|
||
--input apps/ios/build/ScreenshotEvidenceInputs \
|
||
--output . \
|
||
--target-sha "$TARGET_SHA" \
|
||
--workflow-sha "$WORKFLOW_SHA" \
|
||
--run-id "$RUN_ID" \
|
||
--run-attempt "$RUN_ATTEMPT" \
|
||
--xcode-version "$IOS_SCREENSHOT_XCODE_VERSION" \
|
||
--fastlane-version "$IOS_SCREENSHOT_FASTLANE_VERSION" \
|
||
--node-version "$(node --version)"
|
||
|
||
- name: Upload failed iOS screenshot reducer evidence
|
||
if: ${{ always() && steps.reduce_screenshot_evidence.outcome == 'failure' }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: ios-release-screenshot-reducer-failure-${{ needs.preflight.outputs.checkout_revision }}
|
||
path: |
|
||
apps/ios/build/ScreenshotEvidenceInputs/**/screenshots/*.png
|
||
apps/ios/build/ScreenshotEvidenceInputs/**/xcresults/*.xcresult
|
||
apps/ios/build/ScreenshotEvidenceInputs/**/manifest.json
|
||
if-no-files-found: warn
|
||
retention-days: 14
|
||
|
||
- name: Upload iOS release screenshot evidence
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: ios-release-screenshots-${{ needs.preflight.outputs.checkout_revision }}
|
||
path: |
|
||
apps/ios/fastlane/screenshots/en-US/*.png
|
||
apps/ios/build/SnapshotTestResults/*.xcresult
|
||
apps/ios/build/ScreenshotEvidence/manifest.json
|
||
if-no-files-found: error
|
||
retention-days: 14
|
||
|
||
android:
|
||
permissions:
|
||
contents: read
|
||
name: ${{ matrix.check_name || 'android' }}
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_android_job == 'true'
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04')))) || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || ((github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04')) }}
|
||
timeout-minutes: ${{ matrix.task == 'build-play' && ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) || (github.event_name == 'workflow_dispatch' && needs.preflight.outputs.ci_shape != 'main') || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository && github.run_attempt > 1) || github.repository != 'openclaw/openclaw' || (github.event_name == 'pull_request' && !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association))) && 35 || 20 }}
|
||
strategy:
|
||
fail-fast: false
|
||
max-parallel: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && github.event_name != 'workflow_dispatch' && github.event_name != 'schedule' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || (github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association))) && github.run_attempt == 1 && 4 || 2 }}
|
||
matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }}
|
||
steps:
|
||
- &android_checkout_step
|
||
name: Checkout
|
||
shell: bash
|
||
env:
|
||
CHECKOUT_KIND: android
|
||
CHECKOUT_REPO: ${{ github.repository }}
|
||
CHECKOUT_TOKEN: ${{ github.token }}
|
||
CHECKOUT_SHA: *checkout_sha
|
||
run: *owned_checkout_run
|
||
|
||
- &android_toolchain_checkout_step
|
||
name: Checkout CI Android toolchain action
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
ref: ${{ github.workflow_sha }}
|
||
path: .ci-harness
|
||
sparse-checkout: .github/actions
|
||
persist-credentials: false
|
||
|
||
- name: Setup Android toolchain
|
||
id: android-toolchain
|
||
uses: ./.ci-harness/.github/actions/setup-android-toolchain
|
||
with:
|
||
cache-mode: ${{ needs.preflight.outputs.cache_write_allowed == 'true' && 'read-write' || needs.preflight.outputs.cache_mode }}
|
||
|
||
- name: Setup Node environment for native resources
|
||
if: needs.preflight.outputs.use_compatible_android_ci != 'true'
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
install-deps: "false"
|
||
|
||
- name: Install Mermaid renderer dependencies
|
||
if: needs.preflight.outputs.use_compatible_android_ci != 'true'
|
||
env:
|
||
CI: "true"
|
||
run: *mermaid_renderer_install_run
|
||
|
||
- name: Mount Gradle sticky disk
|
||
if: &android_gradle_sticky_disk_gate vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && !(contains(fromJSON('["hybrid","runson"]'), vars.OPENCLAW_CI_RUNNER_BACKEND) && github.run_attempt > 1) && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw')
|
||
uses: useblacksmith/stickydisk@94697d49e77d0dd78b77deb85ad3de63a28b4b8a # v1.7.1
|
||
with:
|
||
# One disk per matrix task keeps thin dependency sets off heavier lanes. Gradle owns invalidation.
|
||
key: ${{ github.repository }}-gradle-v2-${{ matrix.task }}
|
||
path: /var/tmp/openclaw-gradle
|
||
# Only successful protected pushes publish. Explicit commit:true refreshes same-size changes.
|
||
commit: ${{ github.event_name != 'pull_request' && 'true' || 'false' }}
|
||
|
||
- name: Point Gradle at the sticky disk
|
||
if: *android_gradle_sticky_disk_gate
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
sticky_root=/var/tmp/openclaw-gradle
|
||
mkdir -p "$sticky_root/gradle-user-home"
|
||
echo "GRADLE_USER_HOME=$sticky_root/gradle-user-home" >> "$GITHUB_ENV"
|
||
|
||
- name: Restore Robolectric Maven cache
|
||
id: robolectric-cache
|
||
if: startsWith(matrix.task, 'test-') && needs.preflight.outputs.cache_mode != 'off'
|
||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: /var/tmp/openclaw-robolectric-m2
|
||
key: ${{ github.repository }}-robolectric-m2-v1-${{ runner.os }}-${{ runner.arch }}-${{ matrix.task }}-${{ hashFiles('apps/android/**/*.gradle*', 'apps/android/**/gradle-wrapper.properties', 'apps/android/gradle/libs.versions.toml', 'apps/android/**/src/test*/**') }}
|
||
restore-keys: |
|
||
${{ github.repository }}-robolectric-m2-v1-${{ runner.os }}-${{ runner.arch }}-${{ matrix.task }}-
|
||
|
||
- name: Configure Robolectric Maven cache
|
||
if: startsWith(matrix.task, 'test-')
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
maven_repo=/var/tmp/openclaw-robolectric-m2
|
||
init_script="$RUNNER_TEMP/openclaw-robolectric-init.gradle"
|
||
mkdir -p "$maven_repo"
|
||
cat >"$init_script" <<'GROOVY'
|
||
println "Gradle JVM: runtime=${System.getProperty('java.runtime.version')} vendor=${System.getProperty('java.vendor')} home=${System.getProperty('java.home')}"
|
||
allprojects {
|
||
tasks.withType(org.gradle.api.tasks.testing.Test).configureEach {
|
||
systemProperty "maven.repo.local", System.getenv("OPENCLAW_ROBOLECTRIC_M2")
|
||
// Record the executing launcher; cached results do not prove this JVM ran tests.
|
||
doFirst {
|
||
def metadata = javaLauncher.get().metadata
|
||
logger.lifecycle("Test JVM ${path}: runtime=${metadata.javaRuntimeVersion} vendor=${metadata.vendor} home=${metadata.installationPath.asFile} forks=${maxParallelForks}")
|
||
}
|
||
}
|
||
}
|
||
GROOVY
|
||
echo "OPENCLAW_ROBOLECTRIC_M2=$maven_repo" >> "$GITHUB_ENV"
|
||
echo "OPENCLAW_ROBOLECTRIC_INIT=$init_script" >> "$GITHUB_ENV"
|
||
|
||
- name: Run Android ${{ matrix.task }}
|
||
working-directory: apps/android
|
||
env:
|
||
JAVA_HOME: ${{ steps.android-toolchain.outputs.gradle-java-home }}
|
||
CI_RUNNER_BACKEND: ${{ ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) || (github.event_name == 'workflow_dispatch' && needs.preflight.outputs.ci_shape != 'main') || github.repository != 'openclaw/openclaw' || (github.event_name == 'pull_request' && (github.event.pull_request.head.repo.full_name != 'openclaw/openclaw' || !contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)))) && 'github' || 'blacksmith' }}
|
||
CI_MAIN_QUALIFICATION: ${{ needs.preflight.outputs.ci_shape == 'main' && 'true' || 'false' }}
|
||
TASK: ${{ matrix.task }}
|
||
RUN_LINT: ${{ matrix.lint && 'true' || 'false' }}
|
||
APP_LINT: ${{ matrix.app_lint || '' }}
|
||
BUILD_BENCHMARK: ${{ matrix.build_benchmark && 'true' || 'false' }}
|
||
shell: bash
|
||
run: |
|
||
set -euo pipefail
|
||
android_build_metadata=()
|
||
if [ "$RUN_LINT" = "true" ] || [ -n "$APP_LINT" ]; then
|
||
# Repeated native invocations in one row must not invalidate BuildConfig by time alone.
|
||
android_build_timestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||
android_build_metadata=("-PopenclawBuildTimestamp=$android_build_timestamp")
|
||
fi
|
||
case "$TASK" in
|
||
test-play)
|
||
./gradlew "${android_build_metadata[@]}" --no-daemon --build-cache --init-script "$OPENCLAW_ROBOLECTRIC_INIT" \
|
||
:app:testPlayDebugUnitTest \
|
||
:wear-shared:testDebugUnitTest
|
||
;;
|
||
test-play-compat)
|
||
# Frozen targets predate the Wear shared project. Keep their app-owned
|
||
# Play unit tests without importing current Android modules.
|
||
./gradlew --no-daemon --build-cache --init-script "$OPENCLAW_ROBOLECTRIC_INIT" \
|
||
:app:testPlayDebugUnitTest
|
||
;;
|
||
test-third-party)
|
||
./gradlew "${android_build_metadata[@]}" --no-daemon --build-cache --init-script "$OPENCLAW_ROBOLECTRIC_INIT" \
|
||
:app:testThirdPartyDebugUnitTest
|
||
;;
|
||
test-wear)
|
||
./gradlew "${android_build_metadata[@]}" --no-daemon --build-cache --init-script "$OPENCLAW_ROBOLECTRIC_INIT" \
|
||
:wear:testDebugUnitTest
|
||
if [ "$RUN_LINT" = "true" ]; then
|
||
./gradlew "${android_build_metadata[@]}" --no-daemon --build-cache :wear:lintDebug
|
||
fi
|
||
;;
|
||
build-play)
|
||
if [ "$CI_RUNNER_BACKEND" = "github" ] || { [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ] && [ "$CI_MAIN_QUALIFICATION" != "true" ]; }; then
|
||
# GitHub-hosted runners have less memory headroom. Separate Gradle
|
||
# processes release each variant's build state before the next starts.
|
||
./gradlew --no-daemon --build-cache \
|
||
:app:assemblePlayDebug \
|
||
:app:lintPlayDebug
|
||
./gradlew --no-daemon --build-cache \
|
||
:app:assembleThirdPartyDebug \
|
||
:app:lintThirdPartyDebug
|
||
./gradlew --no-daemon --build-cache \
|
||
:benchmark:assembleDebug \
|
||
:wear-shared:assembleDebug \
|
||
:wear-shared:lintDebug
|
||
else
|
||
./gradlew --no-daemon --build-cache \
|
||
:app:assemblePlayDebug \
|
||
:app:assembleThirdPartyDebug \
|
||
:app:lintPlayDebug \
|
||
:app:lintThirdPartyDebug \
|
||
:benchmark:assembleDebug \
|
||
:wear-shared:assembleDebug \
|
||
:wear-shared:lintDebug
|
||
fi
|
||
;;
|
||
build-wear)
|
||
./gradlew --no-daemon --build-cache \
|
||
:wear:assembleDebug \
|
||
:wear:lintDebug
|
||
;;
|
||
build-play-compat)
|
||
# Frozen targets keep their target-owned Android build contract. New lint rules
|
||
# must not retroactively reject a previously validated release branch.
|
||
./gradlew --no-daemon --build-cache :app:assemblePlayDebug
|
||
;;
|
||
ktlint)
|
||
# Mirrors `pnpm android:lint` to keep formatting drift out of main.
|
||
./gradlew "${android_build_metadata[@]}" --no-daemon --build-cache \
|
||
:app:ktlintCheck \
|
||
:benchmark:ktlintCheck \
|
||
:wear:ktlintCheck \
|
||
:wear-shared:ktlintCheck
|
||
if [ "$BUILD_BENCHMARK" = "true" ]; then
|
||
./gradlew "${android_build_metadata[@]}" --no-daemon --build-cache :benchmark:assembleDebug
|
||
fi
|
||
;;
|
||
*)
|
||
echo "Unsupported Android task: $TASK" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
# Third-party lint reuses its phone test compilation and build metadata.
|
||
case "$APP_LINT" in
|
||
third-party)
|
||
./gradlew "${android_build_metadata[@]}" --no-daemon --build-cache :app:lintThirdPartyDebug
|
||
;;
|
||
play)
|
||
./gradlew "${android_build_metadata[@]}" --no-daemon --build-cache :app:lintPlayDebug :wear-shared:lintDebug
|
||
;;
|
||
"") ;;
|
||
*)
|
||
echo "Unsupported Android app lint flavor: $APP_LINT" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
- name: Upload Android test reports
|
||
if: ${{ !cancelled() && startsWith(matrix.task, 'test-') }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: android-test-reports-${{ matrix.task }}-${{ needs.preflight.outputs.checkout_revision }}-${{ github.run_attempt }}
|
||
path: |
|
||
apps/android/app/build/test-results/test*UnitTest/TEST-*.xml
|
||
apps/android/wear/build/test-results/test*UnitTest/TEST-*.xml
|
||
apps/android/wear-shared/build/test-results/test*UnitTest/TEST-*.xml
|
||
apps/android/**/hs_err_pid*.log
|
||
apps/android/**/replay_pid*.log
|
||
if-no-files-found: warn
|
||
retention-days: 14
|
||
|
||
- name: Save Robolectric Maven cache
|
||
if: success() && startsWith(matrix.task, 'test-') && needs.preflight.outputs.cache_write_allowed == 'true' && steps.robolectric-cache.outputs.cache-hit != 'true'
|
||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||
with:
|
||
path: /var/tmp/openclaw-robolectric-m2
|
||
key: ${{ steps.robolectric-cache.outputs.cache-primary-key }}
|
||
|
||
android-access-native:
|
||
permissions:
|
||
contents: read
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_android_access_native == 'true'
|
||
runs-on: *hosted_linux_runner
|
||
timeout-minutes: 30
|
||
strategy:
|
||
fail-fast: false
|
||
matrix:
|
||
include:
|
||
- page-size: 4096
|
||
image: google_apis
|
||
- page-size: 16384
|
||
image: google_apis_ps16k
|
||
steps:
|
||
- *android_checkout_step
|
||
|
||
- *android_toolchain_checkout_step
|
||
|
||
- name: Setup Android toolchain
|
||
uses: ./.ci-harness/.github/actions/setup-android-toolchain
|
||
with:
|
||
cache-mode: *cache_mode
|
||
|
||
- name: Setup Node environment for native resources
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
cache-mode: *cache_mode
|
||
install-bun: "false"
|
||
|
||
- name: Run packaged Access crypto on Android
|
||
shell: bash
|
||
env:
|
||
EXPECTED_PAGE_SIZE: ${{ matrix.page-size }}
|
||
SYSTEM_IMAGE: system-images;android-36;${{ matrix.image }};x86_64
|
||
run: |
|
||
set -euo pipefail
|
||
test "$RUNNER_OS/$RUNNER_ARCH" = Linux/X64
|
||
test -c /dev/kvm
|
||
/usr/bin/sudo /usr/bin/setfacl -m "u:$(id -un):rw" /dev/kvm
|
||
test -r /dev/kvm && test -w /dev/kvm
|
||
sdkmanager --sdk_root="$ANDROID_SDK_ROOT" --install emulator "$SYSTEM_IMAGE"
|
||
acceleration="$(/usr/bin/timeout --signal=TERM --kill-after=2s 15s emulator -accel-check 2>&1)"
|
||
printf '%s\n' "$acceleration"
|
||
grep -Eiq '\bKVM\b.*\b(available|usable)\b' <<<"$acceleration"
|
||
printf 'no\n' | avdmanager create avd --name openclaw-access --package "$SYSTEM_IMAGE" --device pixel_6
|
||
emulator -avd openclaw-access -port 5554 -no-window -no-audio -no-boot-anim -no-snapshot -gpu swiftshader_indirect >"$RUNNER_TEMP/access-emulator.log" 2>&1 &
|
||
emulator_pid=$!
|
||
trap 'adb -s emulator-5554 emu kill >/dev/null 2>&1 || true; wait "$emulator_pid" || true' EXIT
|
||
/usr/bin/timeout --signal=TERM --kill-after=2s 180s bash -c '
|
||
adb -s emulator-5554 wait-for-device
|
||
until [[ "$(adb -s emulator-5554 shell getprop sys.boot_completed | tr -d "\r")" == 1 ]]; do sleep 2; done
|
||
'
|
||
test "$(adb -s emulator-5554 shell getconf PAGE_SIZE | tr -d '\r')" = "$EXPECTED_PAGE_SIZE"
|
||
if [[ "$EXPECTED_PAGE_SIZE" == 16384 ]]; then
|
||
# ps16k simulates 16 KiB pages on x86_64; compat mode must not hide loader failures.
|
||
adb -s emulator-5554 shell setprop bionic.linker.16kb.app_compat.enabled false
|
||
adb -s emulator-5554 shell setprop pm.16kb.app_compat.disabled true
|
||
test "$(adb -s emulator-5554 shell getprop bionic.linker.16kb.app_compat.enabled | tr -d '\r')" = false
|
||
test "$(adb -s emulator-5554 shell getprop pm.16kb.app_compat.disabled | tr -d '\r')" = true
|
||
fi
|
||
node --import ./scripts/tsx.mjs scripts/run-android-gradle.mts \
|
||
:app:connectedPlayDebugAndroidTest \
|
||
-Pandroid.testInstrumentationRunnerArguments.expectedPageSize="$EXPECTED_PAGE_SIZE" \
|
||
-Pandroid.testInstrumentationRunnerArguments.class=ai.openclaw.app.gateway.CloudflareAccessNativeTest \
|
||
-PopenclawBuildTimestamp="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||
apk="$(python3 - <<'PY'
|
||
from pathlib import Path
|
||
import json
|
||
import xml.etree.ElementTree as ET
|
||
import zipfile
|
||
reports = Path('apps/android/app/build/outputs/androidTest-results/connected').rglob('*.xml')
|
||
cases = [case for report in reports for case in ET.parse(report).getroot().iter('testcase')
|
||
if case.get('classname') == 'ai.openclaw.app.gateway.CloudflareAccessNativeTest']
|
||
assert len(cases) == 1, 'The packaged native test must execute exactly once'
|
||
assert all(case.find(tag) is None for case in cases for tag in ('failure', 'error', 'skipped')), 'The packaged native test failed or skipped'
|
||
directory = Path('apps/android/app/build/outputs/apk/play/debug')
|
||
metadata = json.loads((directory / 'output-metadata.json').read_text())
|
||
assert metadata['variantName'] == 'playDebug' and metadata['artifactType']['type'] == 'APK'
|
||
assert len(metadata['elements']) == 1, 'Expected one universal playDebug APK'
|
||
element = metadata['elements'][0]
|
||
filename = element['outputFile']
|
||
assert not element['filters'] and Path(filename).name == filename and filename.endswith('.apk')
|
||
selected = directory / filename
|
||
with zipfile.ZipFile(selected) as archive:
|
||
libraries = {name for name in archive.namelist() if name.endswith('/libsodium.so')}
|
||
assert libraries == {f'lib/{abi}/libsodium.so' for abi in ('armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64')}
|
||
print(selected)
|
||
PY
|
||
)"
|
||
"$ANDROID_SDK_ROOT/build-tools/36.0.0/zipalign" -c -P 16 -v 4 "$apk"
|
||
|
||
- name: Upload Access native test reports
|
||
if: ${{ !cancelled() }}
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: android-access-native-${{ matrix.page-size }}-${{ needs.preflight.outputs.checkout_revision }}-${{ github.run_attempt }}
|
||
path: |
|
||
apps/android/app/build/outputs/androidTest-results/connected/**
|
||
apps/android/app/build/reports/androidTests/connected/**
|
||
if-no-files-found: error
|
||
retention-days: 14
|
||
|
||
docker-seed-e2e:
|
||
permissions:
|
||
contents: read
|
||
name: docker-seed-e2e
|
||
needs: [preflight]
|
||
if: needs.preflight.outputs.run_docker_seed_e2e == 'true'
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) && 'ubuntu-24.04' || (github.event_name == 'pull_request' && (github.run_attempt > 1 || github.event.pull_request.head.repo.full_name != github.repository)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04')))) || ((needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) == 'github' && 'ubuntu-24.04' || (contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt > 1) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' && ((needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') || github.run_attempt != 1)) && 'ubuntu-24.04' || (github.event_name == 'pull_request' && (github.run_attempt > 1 || github.event.pull_request.head.repo.full_name != github.repository)) && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04')) }}
|
||
# Six serial lanes, hosted 4 vCPU: (120s setup + 1032s package + 1668s other) x 1.5
|
||
# + 2580s survivor = 6810s => 115m (CI 36506671071); 60m cancelled CI 36486786689.
|
||
timeout-minutes: 115
|
||
steps:
|
||
- *linux_node_checkout_step
|
||
- name: Setup Node environment
|
||
uses: ./.ci-harness/.github/actions/setup-node-env
|
||
with:
|
||
build-all-cache-scope: full
|
||
cache-mode: *cache_mode
|
||
node-version: "24.x"
|
||
install-bun: "false"
|
||
dependency-cache: *trusted_first_attempt_dependency_cache
|
||
|
||
- name: Report runner resources
|
||
run: |
|
||
node --input-type=module -e 'import os from "node:os"; console.log(JSON.stringify({ logicalCpuCount: os.availableParallelism(), totalMemoryBytes: os.totalmem() }));'
|
||
|
||
- name: Resolve published Docker seed upgrade baseline
|
||
if: contains(format(' {0} ', needs.preflight.outputs.docker_seed_lanes), ' published-upgrade-survivor ')
|
||
env:
|
||
TARGET_CONTEXT_REF: ${{ inputs.target_context_ref || github.base_ref || github.ref_name }}
|
||
run: |
|
||
set -euo pipefail
|
||
candidate_version="$(node -p "require('./package.json').version")"
|
||
baseline="$(node .ci-harness/scripts/lib/release-upgrade-baseline.mjs \
|
||
--candidate-version "$candidate_version" \
|
||
--target-context-ref "$TARGET_CONTEXT_REF")"
|
||
printf 'OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC=%s\n' "$baseline" >> "$GITHUB_ENV"
|
||
|
||
# The update tripwire and release validation require complete SDK declarations.
|
||
- name: Prepare main Docker smoke package
|
||
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (needs.preflight.outputs.ci_qualification == 'true' && needs.preflight.outputs.ci_shape == 'main') || needs.preflight.outputs.validation_tier == 'main'
|
||
run: |
|
||
set -euo pipefail
|
||
pnpm clean:dist
|
||
pnpm build:ci-artifacts
|
||
package_dir="${RUNNER_TEMP}/docker-seed-package"
|
||
node scripts/package-openclaw-for-docker.mjs --skip-build \
|
||
--allow-unreleased-changelog --output-dir "$package_dir" \
|
||
--output-name openclaw-current.tgz
|
||
printf 'OPENCLAW_CURRENT_PACKAGE_TGZ=%s/openclaw-current.tgz\n' "$package_dir" >> "$GITHUB_ENV"
|
||
|
||
- name: Run Docker seed tier
|
||
env:
|
||
OPENCLAW_DOCKER_ALL_LANES: ${{ needs.preflight.outputs.docker_seed_lanes }}
|
||
OPENCLAW_DOCKER_ALL_LIVE_MODE: skip
|
||
OPENCLAW_DOCKER_E2E_ALLOW_UNRELEASED_CHANGELOG: "1"
|
||
OPENCLAW_UPGRADE_SURVIVOR_SCENARIOS: ${{ needs.preflight.outputs.frozen_target == 'true' && 'base' || 'legacy-operator-state' }}
|
||
OPENCLAW_UPGRADE_SURVIVOR_UPDATE_RESTART_MODE: auto-auth
|
||
OPENCLAW_DOCKER_ALL_PARALLELISM: &docker_parallelism ${{ (needs.preflight.outputs.ci_qualification == 'true' && (github.run_attempt == 1 && needs.preflight.outputs.qualification_runner_backend || 'github') || vars.OPENCLAW_CI_RUNNER_BACKEND) != 'github' && github.event_name == 'pull_request' && github.run_attempt == 1 && github.event.pull_request.head.repo.full_name == github.repository && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association) && 3 || 1 }}
|
||
OPENCLAW_DOCKER_ALL_TAIL_PARALLELISM: *docker_parallelism
|
||
run: pnpm test:docker:all
|
||
|
||
- name: Upload Fleet Docker proof
|
||
if: always() && contains(format(' {0} ', needs.preflight.outputs.docker_seed_lanes), ' fleet-cache ')
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: fleet-cache-docker-proof
|
||
path: .artifacts/docker-tests/**/fleet-cache.log
|
||
include-hidden-files: true
|
||
if-no-files-found: warn
|
||
|
||
- name: Upload sanitized upgrade survivor proof
|
||
if: always() && contains(format(' {0} ', needs.preflight.outputs.docker_seed_lanes), ' published-upgrade-survivor ')
|
||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||
with:
|
||
name: docker-seed-upgrade-survivor-proof
|
||
path: |
|
||
.artifacts/docker-tests/????????T??????Z/summary.json
|
||
.artifacts/docker-tests/????????T??????Z/failures.json
|
||
.artifacts/docker-tests/upgrade-survivor-*/summary.json
|
||
.artifacts/docker-tests/upgrade-survivor-*/failure.json
|
||
include-hidden-files: true
|
||
if-no-files-found: warn
|
||
|
||
- *runner_memory_peak_step
|
||
|
||
pr-fail-fast:
|
||
permissions:
|
||
contents: read
|
||
actions: write
|
||
pull-requests: read
|
||
needs: [preflight]
|
||
if: ${{ always() && github.event_name == 'pull_request' && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && !github.event.pull_request.draft && needs.preflight.result == 'success' && needs.preflight.outputs.run_checks_node_core_nondist == 'true' }}
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' || github.run_attempt > 1 || github.repository != 'openclaw/openclaw' || github.event.pull_request.head.repo.full_name != github.repository || needs.preflight.outputs.runner_profile == 'github') && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
|
||
timeout-minutes: 60
|
||
outputs:
|
||
failure_job_id: ${{ steps.monitor.outputs.failure_job_id }}
|
||
failure_job_name: ${{ steps.monitor.outputs.failure_job_name }}
|
||
failure_run_attempt: ${{ steps.monitor.outputs.failure_run_attempt }}
|
||
known_main_red_attempt: ${{ steps.monitor.outputs.known_main_red_attempt }}
|
||
steps:
|
||
- name: Checkout CI monitor
|
||
if: always()
|
||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||
with:
|
||
ref: ${{ github.event.pull_request.base.sha }}
|
||
persist-credentials: false
|
||
sparse-checkout: |
|
||
scripts/ci-pr-fail-fast.mjs
|
||
scripts/ci-known-main-red.mjs
|
||
scripts/lib/ci-node-test-evidence.mjs
|
||
scripts/lib/ci-static-check-evidence.mjs
|
||
sparse-checkout-cone-mode: false
|
||
- name: Classify PR failures and cancel eligible same-repository work
|
||
id: monitor
|
||
if: always()
|
||
env:
|
||
GITHUB_TOKEN: ${{ github.token }}
|
||
OPENCLAW_CI_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||
OPENCLAW_CI_PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||
OPENCLAW_CI_PR_HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
|
||
OPENCLAW_CI_EXPECTED_JOBS: ${{ needs.preflight.outputs.pr_job_count }}
|
||
OPENCLAW_CI_PREFLIGHT_CHECK_JOBS: ${{ needs.preflight.outputs.pr_check_job_count }}
|
||
OPENCLAW_CI_CHECK_PLAN_EXPECTED: ${{ needs.preflight.outputs.run_check_plan }}
|
||
run: node scripts/ci-pr-fail-fast.mjs
|
||
|
||
ci-gate:
|
||
permissions:
|
||
contents: read
|
||
name: openclaw/ci-gate
|
||
needs:
|
||
- preflight
|
||
- security-fast
|
||
- check-plan
|
||
- build-artifacts
|
||
- control-ui-performance
|
||
- native-i18n
|
||
- checks-ui
|
||
- checks-ui-e2e
|
||
- checks-ui-e2e-real-gateway
|
||
- control-ui-i18n
|
||
- checks-baseline-ratchets
|
||
- checks-fast-core
|
||
- qa-smoke-ci-profile
|
||
- checks-fast-plugin-contracts-shard
|
||
- checks-fast-channel-contracts-shard
|
||
- checks-node-compat
|
||
- checks-node-core-test-nondist-shard
|
||
- check-shard
|
||
- check-lint-hosted-core-shard
|
||
- check-lint-hosted-extension-shard
|
||
- check-test-types-hosted-core-shard
|
||
- check-additional-shard
|
||
- check-docs
|
||
- skills-python
|
||
- checks-windows
|
||
- macos-node
|
||
- macos-swift
|
||
- ios-build
|
||
- ios-release-e2e
|
||
- ios-screenshot-shard
|
||
- ios-screenshot-evidence
|
||
- android
|
||
- android-access-native
|
||
- docker-seed-e2e
|
||
- pr-fail-fast
|
||
if: ${{ (github.event_name != 'schedule' || (github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main')) && ((github.event_name != 'push' || github.ref != 'refs/heads/main' || vars.OPENCLAW_CI_ON_PUSH == 'true') && always() && (github.event_name == 'schedule' || !cancelled() || (needs.pr-fail-fast.outputs.failure_run_attempt == format('{0}', github.run_attempt) && needs.pr-fail-fast.outputs.failure_job_id != '') || (github.run_attempt == 1 && needs.pr-fail-fast.result == 'failure')) && (github.event_name != 'pull_request' || !github.event.pull_request.draft)) }}
|
||
# Blacksmith PR failures need critical-path routing within cancellation grace.
|
||
runs-on: ${{ (github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '') != '' && fromJSON(format('{{"group":{0},"labels":{1}}}', toJSON((github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') && vars.OPENCLAW_RELEASE_RUNNER_GROUP || '')), toJSON((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && needs.preflight.outputs.qualification_runner_backend || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (github.event_name != 'workflow_dispatch' || needs.preflight.outputs.ci_qualification == 'true' || needs.preflight.outputs.node_runner_backend == 'runson') || (github.event_name == 'pull_request' && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && github.event.pull_request.head.repo.full_name == github.repository && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && needs.preflight.outputs.runner_profile == 'blacksmith' && ((needs.pr-fail-fast.outputs.failure_run_attempt == '1' && needs.pr-fail-fast.outputs.failure_job_id != '') || needs.pr-fail-fast.result == 'failure'))) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04'))) || ((contains(fromJSON('["hybrid","runson"]'), (needs.preflight.outputs.ci_qualification == 'true' && needs.preflight.outputs.qualification_runner_backend || vars.OPENCLAW_CI_RUNNER_BACKEND)) && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && (github.event_name != 'workflow_dispatch' || needs.preflight.outputs.ci_qualification == 'true' || needs.preflight.outputs.node_runner_backend == 'runson') || (github.event_name == 'pull_request' && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && github.event.pull_request.head.repo.full_name == github.repository && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && needs.preflight.outputs.runner_profile == 'blacksmith' && ((needs.pr-fail-fast.outputs.failure_run_attempt == '1' && needs.pr-fail-fast.outputs.failure_job_id != '') || needs.pr-fail-fast.result == 'failure'))) && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
|
||
timeout-minutes: 5
|
||
steps:
|
||
- name: Report originating PR failure
|
||
if: always() && needs.pr-fail-fast.outputs.failure_run_attempt == format('{0}', github.run_attempt) && needs.pr-fail-fast.outputs.failure_job_id != ''
|
||
env:
|
||
FAILURE_JOB_ID: ${{ needs.pr-fail-fast.outputs.failure_job_id }}
|
||
FAILURE_JOB_NAME: ${{ needs.pr-fail-fast.outputs.failure_job_name }}
|
||
run: |
|
||
node --input-type=module <<'EOF'
|
||
import { appendFileSync } from "node:fs";
|
||
const name = process.env.FAILURE_JOB_NAME.replace(/[\r\n`]/g, " ");
|
||
const url = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}/job/${process.env.FAILURE_JOB_ID}`;
|
||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `PR CI stopped after **${name}** failed.\n\n[Originating job](${url}). Remaining jobs were cancelled to avoid wasted compute.\n`);
|
||
process.exitCode = 1;
|
||
EOF
|
||
- name: Verify selected CI lanes
|
||
if: always()
|
||
shell: bash
|
||
env:
|
||
RELEASE_FAST_LANE: ${{ needs.preflight.outputs.release_fast_lane }}
|
||
FRV_WINDOWS_NODE_ADVISORY: ${{ github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') }}
|
||
ALLOW_COALESCED_IOS: ${{ github.event_name == 'schedule' && github.repository == 'openclaw/openclaw' && github.ref == 'refs/heads/main' }}
|
||
ALLOW_KNOWN_MAIN_RED: ${{ github.event_name == 'pull_request' && github.run_attempt == 1 && needs.pr-fail-fast.result == 'success' && needs.pr-fail-fast.outputs.known_main_red_attempt == format('{0}', github.run_attempt) }}
|
||
JOB_RESULTS: |
|
||
preflight=${{ needs.preflight.result }}|true
|
||
security-fast=${{ needs.security-fast.result }}|true
|
||
check-plan=${{ needs.check-plan.result }}|${{ needs.preflight.outputs.run_check_plan }}
|
||
build-artifacts=${{ needs.build-artifacts.result }}|${{ needs.preflight.outputs.run_build_artifacts }}
|
||
control-ui-performance=${{ needs.control-ui-performance.result }}|${{ needs.preflight.outputs.run_control_ui_performance }}
|
||
native-i18n=${{ needs.native-i18n.result }}|${{ needs.preflight.outputs.run_native_i18n }}
|
||
checks-ui=${{ needs.checks-ui.result }}|${{ needs.preflight.outputs.run_ui_tests }}
|
||
checks-ui-e2e=${{ needs.checks-ui-e2e.result }}|${{ needs.preflight.outputs.run_ui_e2e == 'true' && needs.preflight.outputs.compatibility_target != 'true' }}
|
||
checks-ui-e2e-real-gateway=${{ needs.checks-ui-e2e-real-gateway.result }}|${{ needs.preflight.outputs.run_ui_real_gateway == 'true' && needs.preflight.outputs.compatibility_target != 'true' }}
|
||
control-ui-i18n=${{ needs.control-ui-i18n.result }}|${{ needs.preflight.outputs.run_control_ui_i18n }}
|
||
checks-baseline-ratchets=${{ needs.checks-baseline-ratchets.result }}|${{ needs.preflight.outputs.run_baseline_ratchets }}
|
||
checks-fast-core=${{ needs.checks-fast-core.result }}|${{ needs.preflight.outputs.run_checks_fast_core }}
|
||
qa-smoke-ci-profile=${{ needs.qa-smoke-ci-profile.result }}|${{ needs.preflight.outputs.run_qa_smoke_ci }}
|
||
checks-fast-plugin-contracts-shard=${{ needs.checks-fast-plugin-contracts-shard.result }}|${{ needs.preflight.outputs.run_plugin_contracts_shards }}
|
||
checks-fast-channel-contracts-shard=${{ needs.checks-fast-channel-contracts-shard.result }}|${{ needs.preflight.outputs.run_channel_contracts_shards }}
|
||
checks-node-compat=${{ needs.checks-node-compat.result }}|${{ needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch' && needs.preflight.outputs.validation_tier != 'main' && (needs.preflight.outputs.node_runner_backend != 'runson' && needs.preflight.outputs.ci_qualification != 'true') }}
|
||
checks-node-core-test-nondist-shard=${{ needs.checks-node-core-test-nondist-shard.result }}|${{ needs.preflight.outputs.run_checks_node_core_nondist }}
|
||
check-shard=${{ needs.check-shard.result }}|${{ needs.preflight.outputs.run_check }}
|
||
check-lint-hosted-core-shard=${{ needs.check-lint-hosted-core-shard.result }}|${{ needs.preflight.outputs.run_check == 'true' && (needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.run_lint_core || needs.preflight.outputs.run_lint_core) == 'true' && (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && (needs.preflight.outputs.frozen_target != 'true' || needs.preflight.outputs.hosted_runner_profile_contract == 'true') }}
|
||
check-lint-hosted-extension-shard=${{ needs.check-lint-hosted-extension-shard.result }}|${{ needs.preflight.outputs.run_check == 'true' && (needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.run_lint_extensions || needs.preflight.outputs.run_lint_extensions) == 'true' && needs.preflight.outputs.runner_profile == 'hybrid' && needs.preflight.outputs.frozen_target != 'true' && (!inputs.release_gate || needs.preflight.outputs.node_runner_backend == 'runson' || needs.preflight.outputs.ci_qualification == 'true') }}
|
||
check-test-types-hosted-core-shard=${{ needs.check-test-types-hosted-core-shard.result }}|${{ needs.preflight.outputs.run_check == 'true' && (!needs.preflight.outputs.narrow_check_paths_json || (needs.preflight.outputs.run_check_plan == 'true' && needs.check-plan.outputs.run_changed_core_type_stripes || needs.preflight.outputs.run_changed_core_type_stripes) == 'true') && (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && (needs.preflight.outputs.frozen_target != 'true' || needs.preflight.outputs.hosted_runner_profile_contract == 'true') }}
|
||
check-additional-shard=${{ needs.check-additional-shard.result }}|${{ needs.preflight.outputs.run_check_additional }}
|
||
check-docs=${{ needs.check-docs.result }}|${{ needs.preflight.outputs.run_check_docs }}
|
||
skills-python=${{ needs.skills-python.result }}|${{ needs.preflight.outputs.run_skills_python_job }}
|
||
checks-windows=${{ needs.checks-windows.result }}|${{ needs.preflight.outputs.run_checks_windows }}
|
||
macos-node=${{ needs.macos-node.result }}|${{ needs.preflight.outputs.run_macos_node }}
|
||
macos-swift=${{ needs.macos-swift.result }}|${{ needs.preflight.outputs.run_macos_swift }}
|
||
ios-build=${{ needs.ios-build.result }}|${{ needs.preflight.outputs.run_ios_build }}
|
||
ios-release-e2e=${{ needs.ios-release-e2e.result }}|${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && needs.preflight.outputs.validation_tier == 'full' && needs.preflight.outputs.release_scope == 'full' && needs.preflight.outputs.checkout_revision == github.sha && needs.preflight.outputs.compatibility_target != 'true' && needs.preflight.outputs.run_ios_build == 'true' }}
|
||
ios-screenshot-shard=${{ needs.ios-screenshot-shard.result }}|${{ needs.preflight.outputs.validation_tier != 'main' && needs.preflight.outputs.release_scope == 'full' && needs.preflight.outputs.compatibility_target != 'true' && ((github.event_name == 'workflow_dispatch' && !inputs.release_gate) || needs.preflight.outputs.run_ios_screenshots == 'true') }}
|
||
ios-screenshot-evidence=${{ needs.ios-screenshot-evidence.result }}|${{ needs.preflight.outputs.validation_tier != 'main' && needs.preflight.outputs.release_scope == 'full' && needs.preflight.outputs.compatibility_target != 'true' && ((github.event_name == 'workflow_dispatch' && !inputs.release_gate) || needs.preflight.outputs.run_ios_screenshots == 'true') }}
|
||
android=${{ needs.android.result }}|${{ needs.preflight.outputs.run_android_job }}
|
||
android-access-native=${{ needs.android-access-native.result }}|${{ needs.preflight.outputs.run_android_access_native }}
|
||
docker-seed-e2e=${{ needs.docker-seed-e2e.result }}|${{ needs.preflight.outputs.run_docker_seed_e2e }}
|
||
pr-fail-fast=${{ github.run_attempt != 1 && 'skipped' || needs.pr-fail-fast.result }}|${{ github.event_name == 'pull_request' && github.run_attempt == 1 && github.repository == 'openclaw/openclaw' && needs.preflight.outputs.run_checks_node_core_nondist == 'true' }}
|
||
run: |
|
||
set -euo pipefail
|
||
echo "release fast lane: ${RELEASE_FAST_LANE:-false}"
|
||
|
||
# Preserve raw fields; IFS can hide invalid trailing delimiters.
|
||
failures=0
|
||
while IFS= read -r entry; do
|
||
[[ -n "$entry" ]] || continue
|
||
name="${entry%%=*}"
|
||
result="${entry#*=}"
|
||
selected="${result#*|}"
|
||
result="${result%%|*}"
|
||
echo "${name}: ${result} (selected=${selected:-missing})"
|
||
# Workflow tests pin WINDOWS_NODE_CI_ADVISORY.
|
||
if [[ "${FRV_WINDOWS_NODE_ADVISORY:-false}" == "true" && "$name:$selected:$result" == "checks-windows:true:failure" ]]; then
|
||
echo "::notice title=windows-node-ci advisory::Windows Node unit-test shards failed; Full Release Validation records these results without blocking release."
|
||
echo 'Advisory class `windows-node-ci`: Windows Node unit-test shards failed. Individual shard results remain visible and are recorded in Full Release Validation evidence.' >> "$GITHUB_STEP_SUMMARY"
|
||
continue
|
||
fi
|
||
if [[ "${ALLOW_KNOWN_MAIN_RED:-false}" == "true" && "$selected:$result" == "true:failure" ]]; then
|
||
case "$name" in
|
||
checks-node-core-test-nondist-shard|check-shard|check-test-types-hosted-core-shard|check-lint-hosted-core-shard|check-lint-hosted-extension-shard)
|
||
echo "::notice title=known main red, owned by main::Exact failures match hourly main; failing files and direct subjects are unchanged."
|
||
continue
|
||
;;
|
||
esac
|
||
fi
|
||
# Only scheduled iOS pending-job replacement delegates proof to the next hour.
|
||
if [[ "${ALLOW_COALESCED_IOS:-false}" == "true" && "$name:$selected:$result" == "ios-build:true:cancelled" ]]; then
|
||
echo "::notice title=Hourly iOS proof coalesced::A later scheduled iOS job owns simulator proof."
|
||
continue
|
||
fi
|
||
case "$selected:$result" in
|
||
true:success | false:success | false:skipped) ;;
|
||
*)
|
||
echo "::error title=CI job did not succeed::${name} finished with ${result} (selected=${selected:-missing})"
|
||
failures=1
|
||
;;
|
||
esac
|
||
done <<< "$JOB_RESULTS"
|
||
|
||
exit "$failures"
|
||
|
||
# Writes require this exact-attempt receipt; skipped CI proves nothing.
|
||
- name: Confirm validated workflow revision
|
||
if: success() && needs.preflight.outputs.validation_tier == 'full' && needs.preflight.outputs.checkout_revision == github.sha && (github.event_name != 'workflow_dispatch' || (!startsWith(inputs.dispatch_id, 'full-release-validation-') && !inputs.release_gate && inputs.target_ref == '' && inputs.release_scope == 'full' && inputs.include_android))
|
||
run: echo "Full CI validated the immutable workflow revision."
|
||
seal_release_child_evidence:
|
||
name: Seal full release child evidence
|
||
needs:
|
||
- preflight
|
||
- check-plan
|
||
- security-fast
|
||
- build-artifacts
|
||
- control-ui-performance
|
||
- native-i18n
|
||
- checks-ui
|
||
- checks-ui-e2e
|
||
- checks-ui-e2e-real-gateway
|
||
- control-ui-i18n
|
||
- checks-fast-core
|
||
- checks-baseline-ratchets
|
||
- qa-smoke-ci-profile
|
||
- checks-fast-plugin-contracts-shard
|
||
- checks-fast-channel-contracts-shard
|
||
- checks-node-compat
|
||
- checks-node-core-test-nondist-shard
|
||
- check-shard
|
||
- check-lint-hosted-core-shard
|
||
- check-lint-hosted-extension-shard
|
||
- check-test-types-hosted-core-shard
|
||
- check-additional-shard
|
||
- check-docs
|
||
- skills-python
|
||
- checks-windows
|
||
- macos-node
|
||
- macos-swift
|
||
- ios-build
|
||
- ios-release-e2e
|
||
- ios-screenshot-shard
|
||
- ios-screenshot-evidence
|
||
- android
|
||
- android-access-native
|
||
- docker-seed-e2e
|
||
- pr-fail-fast
|
||
- ci-gate
|
||
if: ${{ always() && !cancelled() && github.event_name == 'workflow_dispatch' && startsWith(inputs.dispatch_id, 'full-release-validation-') }}
|
||
permissions:
|
||
contents: read
|
||
actions: read
|
||
uses: ./.github/workflows/full-release-child-evidence.yml
|
||
with:
|
||
role: normalCi
|
||
target_sha: *checkout_sha
|