openclaw/.github/workflows/ci.yml
Peter Steinberger 56f616e437
fix(release): dependency advisories no longer fail or delay a release (#161463)
* 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.
2026-09-30 01:11:12 +00:00

7509 lines
469 KiB
YAML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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