CI #11
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| workflow_dispatch: | |
| inputs: | |
| target_ref: | |
| description: Optional branch, tag, or full commit SHA to validate instead of the workflow ref | |
| required: false | |
| default: "" | |
| type: string | |
| 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 | |
| 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: | |
| # Canonical main uses run-number parity for two non-canceling slots. Each | |
| # slot keeps GitHub's single coalesced pending tip while its active run finishes. | |
| group: ${{ github.event_name == 'workflow_dispatch' && format('{0}-manual-v1-{1}', github.workflow, github.run_id) || (github.event_name == 'pull_request' && format('{0}-v7-{1}', github.workflow, github.event.pull_request.number) || (github.repository == 'openclaw/openclaw' && 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.238.0" | |
| IOS_SCREENSHOT_NODE_VERSION: "24.16.0" | |
| IOS_SCREENSHOT_XCODE_VERSION: "Xcode 26.6 Build version 17F113" | |
| NODE_VERSION: "24.x" | |
| jobs: | |
| # Preflight: establish routing truth and job matrices once, then let real | |
| # work fan out from a single source of truth. | |
| preflight: | |
| permissions: | |
| contents: read | |
| if: github.event_name != 'pull_request' || !github.event.pull_request.draft | |
| runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: 20 | |
| outputs: | |
| checkout_revision: ${{ steps.checkout_ref.outputs.sha }} | |
| 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 | |
| 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 }} | |
| 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_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 }} | |
| run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }} | |
| checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }} | |
| run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }} | |
| run_check: ${{ steps.manifest.outputs.run_check }} | |
| run_check_additional: ${{ steps.manifest.outputs.run_check_additional }} | |
| run_check_docs: ${{ steps.manifest.outputs.run_check_docs }} | |
| run_format_check: ${{ steps.manifest.outputs.run_format_check }} | |
| compatibility_target: ${{ steps.manifest.outputs.compatibility_target }} | |
| run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }} | |
| strict_control_ui_i18n: ${{ steps.changed_scope.outputs.strict_control_ui_i18n }} | |
| run_ui_tests: ${{ steps.manifest.outputs.run_ui_tests }} | |
| ui_e2e_matrix: ${{ steps.manifest.outputs.ui_e2e_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 }} | |
| 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: Checkout trusted CI harness | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| ref: ${{ github.workflow_sha }} | |
| path: .ci-harness | |
| sparse-checkout: .github/actions | |
| persist-credentials: false | |
| - 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 | |
| CHECKOUT_REPO: ${{ github.repository }} | |
| CHECKOUT_REF: ${{ inputs.target_ref || github.sha }} | |
| CHECKOUT_EVENT_REF: ${{ github.ref }} | |
| CHECKOUT_FALLBACK_REF: ${{ github.sha }} | |
| GITHUB_EVENT_NAME: ${{ github.event_name }} | |
| run: &owned_checkout_run | | |
| set -euo pipefail | |
| python_command=python3 | |
| if [ "$RUNNER_OS" = "Windows" ]; then | |
| python_command=python | |
| fi | |
| run_owner() { | |
| if [ "$RUNNER_OS" = "Linux" ]; then | |
| # Later fetch steps reuse workflow code outside the selected/deletable checkout. | |
| cat > "$RUNNER_TEMP/ci-git-owner.py" | |
| exec "$python_command" -I -S "$RUNNER_TEMP/ci-git-owner.py" | |
| fi | |
| exec "$python_command" -I -S - | |
| } | |
| run_owner <<'PYTHON' | |
| import os | |
| import re | |
| import shutil | |
| import signal | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import time | |
| linux = os.environ["RUNNER_OS"] == "Linux" | |
| fetch_timeout_seconds = 120 if linux else 90 | |
| cleanup_seconds = 10 | |
| cancelled = 0 | |
| 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) | |
| 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 report EPERM for a zombie-only group. Only a checked | |
| # census proving no live members can authorize continuing. | |
| if group_alive(pgid, deadline): | |
| raise | |
| return False | |
| return True | |
| def group_alive(pgid, deadline): | |
| try: | |
| os.killpg(pgid, 0) | |
| except ProcessLookupError: | |
| return False | |
| except PermissionError: | |
| pass # EPERM can mean zombie-only; the census must still prove extinction. | |
| # Zombies are terminated, not writers. A failed/ambiguous inspection | |
| # never authorizes checkout reuse, including after a denied signal probe. | |
| result = subprocess.run( | |
| ["ps", "-axo", "pgid=,stat="], stdin=subprocess.DEVNULL, | |
| stdout=subprocess.PIPE, text=True, check=True, | |
| timeout=max(0.001, deadline - time.monotonic()), | |
| ) | |
| return any(int(group) == pgid and not state.startswith("Z") | |
| for group, state in (line.split() for line in result.stdout.splitlines())) | |
| 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())) | |
| terminate_job(job, 1) | |
| accounting = Accounting() | |
| while True: | |
| query_job(job, 1, c.byref(accounting), c.sizeof(accounting), None) | |
| if accounting.ActiveProcesses == 0: | |
| return | |
| if time.monotonic() >= deadline: | |
| raise RuntimeError("Job cleanup did not complete") | |
| time.sleep(0.05) | |
| 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 run_git(directory, *arguments, timeout=None, stdout=None): | |
| check_cancelled() | |
| command = [git, "-C", directory, *arguments] | |
| job = None | |
| child = None | |
| deadline = time.monotonic() + timeout if timeout is not None else None | |
| try: | |
| options = {"stdin": subprocess.DEVNULL, "stdout": stdout} | |
| 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: | |
| raise FetchTimeout() | |
| time.sleep(0.05) | |
| finally: | |
| try: | |
| if child is not None: | |
| drain(child, job) | |
| finally: | |
| if job is not None: | |
| close_handle(job) | |
| # After verified cleanup, cancellation must supersede a pending timeout. | |
| 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) | |
| 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 resolve_ref(ref): | |
| with tempfile.TemporaryFile(mode="w+") as output: | |
| run_git(workspace, "rev-parse", ref, stdout=output) | |
| output.seek(0) | |
| return output.read().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(): | |
| check_cancelled() | |
| 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() | |
| 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(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 | |
| action = ".github/actions/setup-node-env/action.yml" | |
| if kind == "linux-node" and not os.path.isfile(os.path.join(workspace, action)): | |
| raise GitFailure(1) | |
| harness = os.path.join(workspace, ".ci-harness") | |
| os.makedirs(harness, exist_ok=True) | |
| run_git(harness, "init", harness) | |
| run_git(harness, "remote", "add", "origin", remote) | |
| fetch(harness, f"+{os.environ['WORKFLOW_SHA']}:refs/remotes/origin/ci-harness", max_attempts=1) | |
| run_git(harness, "sparse-checkout", "set", ".github/actions") | |
| run_git(harness, "checkout", "--force", "--detach", os.environ["WORKFLOW_SHA"]) | |
| if not os.path.isfile(os.path.join(harness, action)): | |
| raise GitFailure(1) | |
| check_cancelled() | |
| try: | |
| git = shutil.which("git") | |
| if git is None: | |
| raise RuntimeError("Git unavailable") | |
| if len(sys.argv) > 1: | |
| if sys.argv[1] != "--git": | |
| raise ValueError("Unknown Git owner command") | |
| try: | |
| run_git(os.getcwd(), *sys.argv[3:], timeout=float(sys.argv[2]) or None) | |
| except FetchTimeout: | |
| raise SystemExit(124) | |
| except GitFailure as error: | |
| raise SystemExit(error.code) | |
| raise SystemExit(0) | |
| 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" | |
| 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() | |
| 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"{label} attempt {attempt}/5 failed", flush=True) | |
| backoff(attempt * 5) | |
| print(f"{label} failed after 5 attempts", file=sys.stderr) | |
| raise SystemExit(1) | |
| except Exception as error: | |
| # Do not print command arguments or environment: Git may carry credentials. | |
| print(f"::error::checkout ownership/setup failed ({type(error).__name__}); refusing reuse or retry", file=sys.stderr) | |
| raise SystemExit(125) | |
| PYTHON | |
| - name: Resolve checkout SHA | |
| id: checkout_ref | |
| run: echo "sha=$(git rev-parse HEAD)" >> "$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 || '' }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| runner_profile="${CONFIGURED_RUNNER_PROFILE:-blacksmith}" | |
| case "$runner_profile" in | |
| github | hybrid | blacksmith) ;; | |
| *) echo "OPENCLAW_CI_RUNNER_BACKEND must be github, hybrid, or blacksmith" >&2; exit 1 ;; | |
| esac | |
| 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 | |
| printf '%s\n' \ | |
| "hosted_runner_profile_contract=$hosted_runner_profile_contract" \ | |
| "runner_profile=$runner_profile" >> "$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)" | |
| 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 | |
| default_sha="$(git ls-remote origin "refs/heads/${DEFAULT_BRANCH}" | awk 'NR == 1 { print $1}')" | |
| if [[ ! "$default_sha" =~ ^[0-9a-f]{40}$ ]]; then | |
| echo "Could not resolve the default branch head for the manual target." >&2 | |
| exit 1 | |
| fi | |
| 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" --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 }} | |
| HISTORICAL_TARGET_TAG: ${{ inputs.historical_target_tag }} | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "$HISTORICAL_TARGET_TAG" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then | |
| echo "historical_target_tag must be a canonical OpenClaw release tag." >&2 | |
| exit 1 | |
| fi | |
| tag_ref="refs/tags/${HISTORICAL_TARGET_TAG}" | |
| remote="$(git remote get-url origin)" | |
| tag_sha="$(git ls-remote --tags "$remote" "${tag_ref}^{}" | awk 'NR == 1 { print $1 }')" | |
| if [[ -z "$tag_sha" ]]; then | |
| tag_sha="$(git ls-remote --tags "$remote" "$tag_ref" | awk 'NR == 1 { print $1 }')" | |
| fi | |
| if [[ "$tag_sha" != "$EXPECTED_SHA" ]]; then | |
| echo "Historical release tag ${HISTORICAL_TARGET_TAG} does not resolve to ${EXPECTED_SHA}." >&2 | |
| exit 1 | |
| fi | |
| echo "eligible=true" >> "$GITHUB_OUTPUT" | |
| - name: Validate release candidate target | |
| id: release_candidate_target | |
| if: inputs.release_candidate_ref != '' | |
| env: | |
| EXPECTED_SHA: ${{ steps.checkout_ref.outputs.sha }} | |
| RELEASE_CANDIDATE_REF: ${{ inputs.release_candidate_ref }} | |
| run: | | |
| set -euo pipefail | |
| if [[ ! "$RELEASE_CANDIDATE_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then | |
| echo "release_candidate_ref must be a canonical OpenClaw release branch." >&2 | |
| exit 1 | |
| fi | |
| remote="$(git remote get-url origin)" | |
| branch_sha="$(git ls-remote --heads "$remote" "refs/heads/${RELEASE_CANDIDATE_REF}" | awk 'NR == 1 { print $1 }')" | |
| if [[ "$branch_sha" != "$EXPECTED_SHA" ]]; then | |
| echo "Release candidate branch ${RELEASE_CANDIDATE_REF} does not resolve to ${EXPECTED_SHA}." >&2 | |
| exit 1 | |
| fi | |
| echo "eligible=true" >> "$GITHUB_OUTPUT" | |
| - name: 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]+|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 | |
| branch_sha="$(git ls-remote --heads origin "refs/heads/${TARGET_CONTEXT_REF}" | awk 'NR == 1 { print $1 }')" | |
| 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: Classify candidate cache trust | |
| id: candidate_trust | |
| env: | |
| CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }} | |
| DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} | |
| 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_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 | |
| default_sha="$(git ls-remote origin "refs/heads/${DEFAULT_BRANCH}" | awk 'NR == 1 { print $1 }')" | |
| 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' | |
| 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' | |
| 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' && 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 TypeScript runtime | |
| uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 | |
| with: | |
| node-version: ${{ env.NODE_VERSION }} | |
| # Push/PR preflight is dependency-free: the manifest planner closure and | |
| # the protocol coverage script import only node builtins and relative | |
| # files, and Node strips their types natively. Only manual dispatches | |
| # (which may check out frozen targets with older planner syntax) still | |
| # install and run through tsx. | |
| - name: Setup manifest pnpm | |
| if: github.event_name == 'workflow_dispatch' | |
| 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' | |
| run: pnpm install --frozen-lockfile --prefer-offline --ignore-scripts | |
| - name: Build CI manifest | |
| id: manifest | |
| env: | |
| OPENCLAW_CI_DOCS_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.docs_scope.outputs.docs_only }} | |
| OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }} | |
| OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }} | |
| # Ordinary manual/release validation stays full. A release_gate is a | |
| # hosted substitute for PR CI, so it must retain the exact Apple scope. | |
| OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_macos || 'false' }} | |
| OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && !inputs.release_gate && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }} | |
| OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }} | |
| OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }} | |
| OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }} | |
| OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_plugin_contracts || 'false' }} | |
| OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_ci_routing || 'false' }} | |
| OPENCLAW_CI_RUN_SKILLS_PYTHON: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_skills_python || 'false' }} | |
| OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }} | |
| OPENCLAW_CI_RUN_UI_TESTS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ui_tests || 'false' }} | |
| OPENCLAW_CI_RUN_NATIVE_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_native_i18n || 'false' }} | |
| OPENCLAW_CI_CHANGED_PATHS_JSON: ${{ steps.changed_scope.outputs.changed_paths_json || 'null' }} | |
| OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }} | |
| OPENCLAW_CI_HISTORICAL_TARGET: ${{ steps.historical_target.outputs.eligible || 'false' }} | |
| OPENCLAW_CI_RELEASE_GATE: ${{ inputs.release_gate && 'true' || 'false' }} | |
| 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.sha }} | |
| OPENCLAW_CI_REPOSITORY: ${{ github.repository }} | |
| OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }} | |
| OPENCLAW_CI_RUNNER_PROFILE: ${{ steps.runner_profile.outputs.runner_profile }} | |
| run: | | |
| manifest_node_args=() | |
| if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ]; then | |
| manifest_node_args+=(--import tsx) | |
| fi | |
| node "${manifest_node_args[@]}" --input-type=module <<'EOF' | |
| import { appendFileSync, existsSync, readFileSync } from "node:fs"; | |
| const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? ""; | |
| const checkoutRevision = process.env.OPENCLAW_CI_CHECKOUT_REVISION ?? ""; | |
| const workflowRevision = process.env.OPENCLAW_CI_WORKFLOW_REVISION ?? ""; | |
| const historicalTargetApproved = process.env.OPENCLAW_CI_HISTORICAL_TARGET === "true"; | |
| const historicalTarget = | |
| eventName === "workflow_dispatch" && | |
| historicalTargetApproved && | |
| checkoutRevision !== workflowRevision; | |
| const releaseCandidateTarget = | |
| eventName === "workflow_dispatch" && | |
| process.env.OPENCLAW_CI_RELEASE_CANDIDATE_TARGET === "true" && | |
| checkoutRevision !== workflowRevision; | |
| const 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; | |
| const nodeTestPlanPath = 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 changedNodeTestPlan = {}; | |
| const changedNodeTestPlanPath = 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"; | |
| if (existsSync(changedNodeTestPlanPath)) { | |
| try { | |
| changedNodeTestPlan = await import(changedNodeTestPlanPath); | |
| } catch (error) { | |
| console.warn(`Changed Node test planner import failed; using compact full suite: ${error}`); | |
| } | |
| } | |
| const importTargetPlan = async (path) => { | |
| if (existsSync(path)) { | |
| return import(path); | |
| } | |
| if (!compatibilityTarget) { | |
| throw new Error(`Current CI target does not provide ${path}`); | |
| } | |
| return {}; | |
| }; | |
| const channelContractPlan = await importTargetPlan( | |
| existsSync("./scripts/lib/channel-contract-test-plan.mts") | |
| ? "./scripts/lib/channel-contract-test-plan.mts" | |
| : "./scripts/lib/channel-contract-test-plan.mjs", | |
| ); | |
| const createChannelContractTestShards = | |
| typeof channelContractPlan.createChannelContractTestShards === "function" | |
| ? channelContractPlan.createChannelContractTestShards | |
| : () => []; | |
| const parseBoolean = (value, fallback = false) => { | |
| if (value === undefined) return fallback; | |
| const normalized = value.trim().toLowerCase(); | |
| if (normalized === "true" || normalized === "1") return true; | |
| if (normalized === "false" || normalized === "0" || normalized === "") return false; | |
| return fallback; | |
| }; | |
| const pluginContractPlan = await importTargetPlan( | |
| 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 }); | |
| const outputPath = process.env.GITHUB_OUTPUT; | |
| const packageScripts = JSON.parse(readFileSync("package.json", "utf8")).scripts ?? {}; | |
| const hasPackageScript = (name) => typeof packageScripts[name] === "string"; | |
| const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw"; | |
| const changedPaths = (() => { | |
| try { | |
| const value = JSON.parse(process.env.OPENCLAW_CI_CHANGED_PATHS_JSON ?? "null"); | |
| return Array.isArray(value) && value.every((path) => typeof path === "string") | |
| ? value | |
| : null; | |
| } catch { | |
| return null; | |
| } | |
| })(); | |
| const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY); | |
| const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED); | |
| const releaseGate = parseBoolean(process.env.OPENCLAW_CI_RELEASE_GATE); | |
| const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly; | |
| const runNodeFastOnly = | |
| runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY); | |
| const runNodeFull = runNode && !runNodeFastOnly; | |
| const runNodeFastPluginContracts = | |
| runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_PLUGIN_CONTRACTS); | |
| const runNodeFastCiRouting = | |
| runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_CI_ROUTING); | |
| const runPluginContractShards = runNodeFull || runNodeFastPluginContracts; | |
| const runMacos = | |
| parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository; | |
| const supportsCurrentMacosSwiftCi = | |
| existsSync("scripts/install-swift-tools.sh") && | |
| existsSync("scripts/lint-swift.sh") && | |
| existsSync("scripts/format-swift.sh"); | |
| const supportsIosBuild = hasPackageScript("ios:build"); | |
| const supportsCurrentIosCi = supportsIosBuild && supportsCurrentMacosSwiftCi; | |
| const runIosBuild = | |
| parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && | |
| !docsOnly && | |
| isCanonicalRepository && | |
| (!frozenTarget || | |
| supportsCurrentIosCi || | |
| (releaseCandidateTarget && supportsIosBuild)); | |
| const runAndroid = | |
| parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository; | |
| const runWindows = | |
| parseBoolean(process.env.OPENCLAW_CI_RUN_WINDOWS) && | |
| !docsOnly && | |
| !runNodeFastOnly && | |
| isCanonicalRepository; | |
| const runSkillsPython = parseBoolean(process.env.OPENCLAW_CI_RUN_SKILLS_PYTHON) && !docsOnly; | |
| const runControlUiI18n = | |
| parseBoolean(process.env.OPENCLAW_CI_RUN_CONTROL_UI_I18N) && !docsOnly; | |
| const runUiTests = parseBoolean(process.env.OPENCLAW_CI_RUN_UI_TESTS) && !docsOnly; | |
| const runnerProfile = process.env.OPENCLAW_CI_RUNNER_PROFILE ?? "blacksmith"; | |
| const usesHostedRunnerProfile = | |
| runnerProfile === "github" || runnerProfile === "hybrid"; | |
| // Control UI E2E is serial Chromium work: 2114s of measured body plus a | |
| // ~116s median per-shard floor (checkout, pnpm install, Vitest transform) | |
| // that every added row pays again. Sharding is the only lever on the | |
| // tallest row -- duration-weighted packing already lands within ~10% of | |
| // ideal -- so the count is set from that floor, not from packing slack. | |
| const uiE2eShardCount = usesHostedRunnerProfile ? 14 : 4; | |
| const qaSmokeCiPartCount = usesHostedRunnerProfile ? 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) && | |
| !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 supportsFormatCheck = | |
| targetWorkflow.split("pnpm format:check").length - 1 >= 2; | |
| const runFormatCheck = !frozenTarget || supportsFormatCheck; | |
| const checksFastCoreTasks = | |
| runNode && !frozenTarget | |
| ? [ | |
| { | |
| check_name: "checks-fast-baseline-ratchets", | |
| runtime: "node", | |
| task: "baseline-ratchets", | |
| }, | |
| { | |
| check_name: "checks-fast-coercion-helpers", | |
| runtime: "node", | |
| task: "coercion-helpers", | |
| }, | |
| ] | |
| : []; | |
| if (runNodeFull) { | |
| checksFastCoreTasks.push( | |
| { check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" }, | |
| { check_name: "checks-fast-bun-launcher", runtime: "bun", task: "bun-launcher" }, | |
| ); | |
| } else { | |
| if (runNodeFastCiRouting) { | |
| checksFastCoreTasks.push({ | |
| check_name: "checks-fast-ci-routing", | |
| runtime: "node", | |
| task: "ci-routing", | |
| }); | |
| } | |
| } | |
| 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 compactPullRequest = isCanonicalRepository && eventName === "pull_request"; | |
| const dockerSeedLanes = | |
| compactPullRequest && | |
| changedPaths && | |
| supportsDockerSeedE2e && | |
| typeof changedNodeTestPlan.resolveChangedDockerSeedLanes === "function" | |
| ? changedNodeTestPlan.resolveChangedDockerSeedLanes(changedPaths) | |
| : []; | |
| // 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 | |
| : eventName === "pull_request" | |
| ? "pull-request" | |
| : eventName === "push" | |
| ? "push" | |
| : undefined; | |
| let changedNodeTestShards = null; | |
| let changedExtensionFallbackShards = []; | |
| if ( | |
| compactPullRequest && | |
| changedPaths && | |
| typeof changedNodeTestPlan.createChangedNodeTestShards === "function" | |
| ) { | |
| try { | |
| changedNodeTestShards = changedNodeTestPlan.createChangedNodeTestShards(changedPaths); | |
| } catch (error) { | |
| console.warn(`Changed Node test planning failed; using compact full suite: ${error}`); | |
| } | |
| if ( | |
| changedNodeTestShards === null && | |
| typeof changedNodeTestPlan.createChangedExtensionFallbackShards === "function" | |
| ) { | |
| try { | |
| changedExtensionFallbackShards = | |
| changedNodeTestPlan.createChangedExtensionFallbackShards(changedPaths); | |
| } catch (error) { | |
| console.warn(`Changed extension fallback planning failed; using compact full suite: ${error}`); | |
| changedExtensionFallbackShards = []; | |
| } | |
| } | |
| } | |
| // 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 changedScopeHasBuildImpact = | |
| changedNodeTestShards === null || | |
| typeof changedNodeTestPlan.hasBuildArtifactAffectingChange !== "function" || | |
| changedNodeTestPlan.hasBuildArtifactAffectingChange(changedPaths); | |
| const changedScopeHasQaImpact = | |
| changedPaths === null || | |
| eventName !== "pull_request" || | |
| typeof changedNodeTestPlan.hasQaSmokeAffectingChange !== "function" || | |
| changedNodeTestPlan.hasQaSmokeAffectingChange(changedPaths); | |
| // Prompt snapshots only change when the generator's import graph or | |
| // its fixtures do; unaffected PR diffs skip the regeneration lane. | |
| const changedScopeHasPromptSnapshotImpact = | |
| changedPaths === null || | |
| eventName !== "pull_request" || | |
| typeof changedNodeTestPlan.hasPromptSnapshotAffectingChange !== "function" || | |
| changedNodeTestPlan.hasPromptSnapshotAffectingChange(changedPaths); | |
| const supportsSqliteSessionLifecycleProof = existsSync( | |
| "test/scripts/sqlite-sessions-transcripts-flip-proof.built-cli.e2e.test.ts", | |
| ); | |
| const changedScopeHasSqliteSessionLifecycleImpact = | |
| changedPaths === null || | |
| eventName === "workflow_dispatch" || | |
| typeof changedNodeTestPlan.hasSqliteSessionLifecycleAffectingChange !== "function" || | |
| changedNodeTestPlan.hasSqliteSessionLifecycleAffectingChange(changedPaths); | |
| const runSqliteSessionLifecycle = | |
| runNodeFull && | |
| supportsSqliteSessionLifecycleProof && | |
| changedScopeHasSqliteSessionLifecycleImpact; | |
| const runBuildArtifacts = | |
| runNodeFull && (changedScopeHasBuildImpact || runSqliteSessionLifecycle); | |
| const runQaSmokeCi = | |
| runNodeFull && | |
| changedScopeHasQaImpact && | |
| (!frozenTarget || existsSync("extensions/qa-lab/src/ci-smoke-plan.ts")); | |
| const rawNodeTestShards = runNodeFull | |
| ? changedNodeTestShards | |
| ? changedNodeTestShards | |
| : [ | |
| ...createNodeTestPlan({ | |
| includeReleaseOnlyPluginShards: false, | |
| // Keep the legacy boolean for historical target planners; | |
| // current planners use the event-specific mode. | |
| compact: compactPlanMode !== undefined, | |
| compactMode: compactPlanMode, | |
| runnerBackend: runnerProfile, | |
| }), | |
| ...changedExtensionFallbackShards, | |
| ] | |
| : []; | |
| const nodeTestShards = rawNodeTestShards.map((shard) => ({ | |
| check_name: shard.checkName, | |
| runtime: "node", | |
| task: "test-shard", | |
| shard_name: shard.shardName, | |
| groups: shard.groups, | |
| configs: shard.configs, | |
| env: shard.env, | |
| includePatterns: shard.includePatterns, | |
| pretest_build_mode: shard.pretestBuildMode, | |
| requires_dist: shard.requiresDist, | |
| runner: shard.runner, | |
| timeout_minutes: shard.timeoutMinutes, | |
| plan_concurrency: shard.planConcurrency, | |
| predicted_seconds: shard.predictedSeconds, | |
| targets: shard.targets, | |
| requires_go: | |
| shard.shardName.startsWith("core-tooling") || | |
| shard.groups?.some((group) => group.shard_name.startsWith("core-tooling")), | |
| })); | |
| const nodeTestNonDistShards = nodeTestShards.filter((shard) => !shard.requires_dist); | |
| const nodeTestDistShards = nodeTestShards.filter((shard) => shard.requires_dist); | |
| // Targeted jobs cannot discover repository-scanning boundary tests | |
| // through imports. Keep the full boundary gate inside build-artifacts | |
| // when that lane runs; test-only targeted plans carry their own | |
| // nondist changed-boundary shard instead. | |
| const runNodeCoreDist = | |
| (changedNodeTestShards !== null && runBuildArtifacts) || | |
| nodeTestDistShards.length > 0; | |
| const channelContractShards = runNodeFull ? createChannelContractTestShards() : []; | |
| const protocolCoverageRequested = runNode || runIosBuild || runAndroid; | |
| const runProtocolEventCoverage = | |
| protocolCoverageRequested && | |
| (!frozenTarget || existsSync("scripts/check-protocol-event-coverage.mjs")); | |
| const manifest = { | |
| docs_only: docsOnly, | |
| docs_changed: docsChanged, | |
| run_node: runNode, | |
| run_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_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 && changedScopeHasPromptSnapshotImpact, | |
| run_sqlite_session_lifecycle: runSqliteSessionLifecycle, | |
| checks_fast_core_matrix: createMatrix(checksFastCoreTasks), | |
| run_plugin_contracts_shards: runPluginContractShards, | |
| plugin_contracts_matrix: createMatrix( | |
| runPluginContractShards ? createPluginContractTestShards() : [], | |
| ), | |
| run_channel_contracts_shards: channelContractShards.length > 0, | |
| channel_contracts_matrix: createMatrix(channelContractShards), | |
| run_checks: runNodeFull, | |
| run_checks_node_core_nondist: nodeTestNonDistShards.length > 0, | |
| checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards), | |
| run_checks_node_core_dist: runNodeCoreDist, | |
| run_check: runNodeFull, | |
| run_check_additional: runNodeFull, | |
| run_check_docs: docsChanged && eventName !== "push", | |
| run_format_check: runFormatCheck, | |
| run_control_ui_i18n: runControlUiI18n, | |
| run_ui_tests: runUiTests, | |
| ui_e2e_matrix: createMatrix( | |
| Array.from({ length: uiE2eShardCount }, (_, index) => { | |
| const shard = index + 1; | |
| return { | |
| shard, | |
| shard_count: uiE2eShardCount, | |
| task: shard === uiE2eShardCount ? "browser-extension" : "control-ui", | |
| vitest_shard_count: uiE2eShardCount - 1, | |
| }; | |
| }), | |
| ), | |
| run_native_i18n: runNativeI18n, | |
| run_skills_python_job: runSkillsPython, | |
| run_checks_windows: runWindows, | |
| // Blacksmith's Windows runner class admits exactly 2 concurrent jobs | |
| // (measured on run 31865243804), so the split width is pinned to 2 on | |
| // every backend. A 3rd part queues behind a finished one there, and a | |
| // single lane serializes the whole 226s body onto the wall. | |
| checks_windows_matrix: createMatrix( | |
| runWindows | |
| ? [1, 2].map((part) => ({ | |
| check_name: `checks-windows-node-test-${part}`, | |
| runtime: "node", | |
| task: `test-${part}`, | |
| })) | |
| : [], | |
| ), | |
| run_macos_node: runMacos, | |
| macos_node_matrix: createMatrix( | |
| runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [], | |
| ), | |
| run_macos_swift: | |
| runMacos && (!frozenTarget || compatibilityTarget || supportsCurrentMacosSwiftCi), | |
| run_openclawkit_tests: runMacos && supportsOpenClawKitTests, | |
| run_ios_build: runIosBuild, | |
| run_android_job: runAndroid, | |
| 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" }, | |
| ...(!useCompatibleAndroidCi | |
| ? [{ check_name: "android-test-wear", task: "test-wear" }] | |
| : []), | |
| { | |
| check_name: "android-build-play", | |
| task: useCompatibleAndroidCi ? "build-play-compat" : "build-play", | |
| }, | |
| ...(!useCompatibleAndroidCi | |
| ? [ | |
| { check_name: "android-build-wear", task: "build-wear" }, | |
| { check_name: "android-ktlint", task: "ktlint" }, | |
| ] | |
| : []), | |
| ] | |
| : [], | |
| ), | |
| }; | |
| for (const [key, value] of Object.entries(manifest)) { | |
| appendFileSync( | |
| outputPath, | |
| `${key}=${typeof value === "string" ? value : JSON.stringify(value)}\n`, | |
| "utf8", | |
| ); | |
| } | |
| EOF | |
| - name: Check mobile protocol event coverage | |
| if: steps.manifest.outputs.run_protocol_event_coverage == 'true' | |
| run: | | |
| # Dispatches may target frozen checkouts whose script needs the tsx | |
| # shim; push/PR checkouts run the dependency-free .mts natively. | |
| if [ "${GITHUB_EVENT_NAME:-}" = "workflow_dispatch" ]; then | |
| node scripts/check-protocol-event-coverage.mjs | |
| else | |
| node scripts/check-protocol-event-coverage.mts | |
| fi | |
| # Validate and consume the immutable dependency archive before same-repo | |
| # Blacksmith jobs fan out. Cache publication belongs to the trusted warmer. | |
| - name: Restore exact dependency cache | |
| if: vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && 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" | |
| # Run dependency-free security checks on a hosted runner in parallel with | |
| # scope detection. No downstream job waits for Python/pre-commit setup. | |
| security-fast: | |
| permissions: | |
| contents: read | |
| if: github.event_name != 'pull_request' || !github.event.pull_request.draft | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 20 | |
| env: | |
| PRE_COMMIT_HOME: .cache/pre-commit-security-fast | |
| steps: | |
| - name: Checkout | |
| if: github.event_name != 'workflow_dispatch' || inputs.target_ref == '' | |
| uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 | |
| with: | |
| fetch-depth: 2 | |
| persist-credentials: false | |
| # Manual target validation has a distinct fallback contract: a missing branch/tag | |
| # falls back to the workflow SHA, while transport timeouts still fail closed. | |
| - 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_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 | |
| 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' | |
| 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: Fetch pull request scan history | |
| if: github.event_name == 'pull_request' | |
| env: | |
| PR_COMMIT_COUNT: ${{ github.event.pull_request.commits }} | |
| PR_MERGE_SHA: ${{ github.sha }} | |
| run: | | |
| set -euo pipefail | |
| if ! [[ "$PR_COMMIT_COUNT" =~ ^[0-9]+$ ]]; then | |
| echo "::error::Invalid pull request commit count: $PR_COMMIT_COUNT" | |
| exit 2 | |
| fi | |
| # Include the synthetic merge, every pull request commit, and one | |
| # ancestor so TruffleHog can clone and resolve the bounded range. | |
| fetch_depth=$((PR_COMMIT_COUNT + 2)) | |
| python3 -I -S "$RUNNER_TEMP/ci-git-owner.py" --git 120 \ | |
| -c protocol.version=2 \ | |
| fetch --no-tags --no-recurse-submodules --depth="$fetch_depth" origin "$PR_MERGE_SHA" | |
| - name: Scan pull request for leaked credentials | |
| if: github.event_name == 'pull_request' | |
| uses: trufflesecurity/trufflehog@bcfcf73aaf4759d4dadc2783177c245a02792318 # v3.97.0 | |
| with: | |
| base: ${{ steps.diff_base.outputs.sha }} | |
| head: ${{ github.sha }} | |
| version: "3.97.0@sha256:ff4c95e9df7d645daf2140e3ca1039031c63106268d5fbb25feb43ceca1bcc33" | |
| extra_args: --results=verified,unknown --fail-on-scan-errors | |
| - name: Prepare trusted pre-commit config | |
| if: github.event_name == 'pull_request' | |
| env: | |
| BASE_SHA: ${{ steps.diff_base.outputs.sha }} | |
| BASE_REF: ${{ github.event.pull_request.base.ref }} | |
| run: | | |
| set -euo pipefail | |
| trusted_config="$RUNNER_TEMP/pre-commit-base.yaml" | |
| if git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null && | |
| git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then | |
| git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config" | |
| elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \ | |
| > "$trusted_config" 2>/dev/null; then | |
| echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead." | |
| else | |
| echo "::warning title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}; falling back to the checked-out config." | |
| rm -f "$trusted_config" | |
| exit 0 | |
| fi | |
| echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV" | |
| - name: Resolve Python runtime | |
| id: setup-python | |
| run: | | |
| set -euo pipefail | |
| python3 --version | |
| version="$(python3 - <<'PY' | |
| import platform | |
| print(platform.python_version()) | |
| PY | |
| )" | |
| echo "python-version=${version}" >> "$GITHUB_OUTPUT" | |
| - name: Install pre-commit | |
| run: python3 -m pip install --disable-pip-version-check pre-commit==4.6.2 | |
| - name: Detect committed private keys | |
| run: pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" --all-files detect-private-key | |
| - name: Audit changed GitHub workflows with zizmor | |
| env: | |
| BASE_SHA: ${{ steps.diff_base.outputs.sha }} | |
| run: | | |
| set -euo pipefail | |
| if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then | |
| echo "No usable base SHA detected; skipping zizmor." | |
| exit 0 | |
| fi | |
| if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then | |
| echo "Base SHA ${BASE_SHA} is unavailable; skipping zizmor." | |
| exit 0 | |
| fi | |
| mapfile -t workflow_files < <( | |
| git diff --name-only "${BASE_SHA}" HEAD -- '.github/workflows/*.yml' '.github/workflows/*.yaml' | |
| ) | |
| if [ "${#workflow_files[@]}" -eq 0 ]; then | |
| echo "No workflow changes detected; skipping zizmor." | |
| exit 0 | |
| fi | |
| printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}" | |
| pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}" | |
| - name: Setup Node.js | |
| env: | |
| REQUESTED_NODE_VERSION: "24.x" | |
| run: | | |
| set -euo pipefail | |
| source .ci-harness/.github/actions/setup-pnpm-store-cache/ensure-node.sh | |
| openclaw_ensure_node "$REQUESTED_NODE_VERSION" | |
| - name: Audit production dependencies | |
| run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high | |
| # Prime the lockfile- and pnpm-pinned store for fork PRs, manual runs, the | |
| # GitHub backend, and docs-only same-repo PRs. This job restores only; the | |
| # trusted cache warmer owns publication. | |
| pnpm-store-warmup: | |
| permissions: | |
| contents: read | |
| needs: [preflight] | |
| if: ${{ (needs.preflight.outputs.run_node == 'true' || needs.preflight.outputs.run_check_docs == 'true') && (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid' || (!(github.repository == 'openclaw/openclaw' && github.event_name == 'push' && github.ref == 'refs/heads/main') && !(github.repository == 'openclaw/openclaw' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && needs.preflight.outputs.run_node == 'true'))) }} | |
| runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: 20 | |
| steps: | |
| - &linux_node_checkout_step | |
| name: Checkout | |
| shell: bash | |
| env: | |
| CHECKOUT_REPO: ${{ github.repository }} | |
| 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: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # Build dist once for Node-relevant changes and share it with downstream jobs. | |
| # Keep this overlapping with the fast correctness lanes so green PRs get heavy | |
| # test/build feedback sooner instead of waiting behind a full `check` pass. | |
| build-artifacts: | |
| permissions: | |
| contents: read | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_build_artifacts == 'true' | |
| # 32 vCPU: the dist build sits on the light-run critical path alongside | |
| # check-lint/check-dependencies; tsdown parallelizes across the extra | |
| # cores for roughly the same billed core-minutes. | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: ${{ (vars.OPENCLAW_CI_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)) && 35 || 20 }} | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Ensure secrets base commit (PR fast path) | |
| if: github.event_name == 'pull_request' | |
| uses: ./.ci-harness/.github/actions/ensure-base-commit | |
| with: | |
| base-sha: ${{ needs.preflight.outputs.diff_base_revision }} | |
| fetch-ref: ${{ github.event.pull_request.base.ref }} | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "true" | |
| node-compile-cache: "true" | |
| node-compile-cache-scope: "build" | |
| # Same-repo Blacksmith runs restore the dependency cache published by | |
| # preflight; hosted paths use the pnpm store cache instead. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| - name: Restore build-all step cache | |
| if: needs.preflight.outputs.cache_mode != 'off' | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: .artifacts/build-all-cache | |
| key: ${{ runner.os }}-build-all-v4-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'scripts/build-all.mts', 'scripts/runtime-postbuild.mjs', 'scripts/runtime-postbuild.mts', 'scripts/lib/tsx-cli-shim.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-private-local-only-subpaths.json', 'scripts/lib/plugin-sdk-deprecated-public-subpaths.json', 'scripts/lib/plugin-sdk-deprecated-barrel-subpaths.json', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/**', 'packages/**', '!src/**/dist/**', '!src/**/node_modules/**', '!packages/**/dist/**', '!packages/**/node_modules/**') }} | |
| restore-keys: | | |
| ${{ runner.os }}-build-all-v4- | |
| - name: Restore dist build cache | |
| id: dist_build_cache | |
| 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 | |
| # Reruns the (warm, seconds-cheap) asset hooks and fails on drift so | |
| # packages/-only PRs cannot land stale committed plugin bundles; the | |
| # extension byte-equality suites do not run for those diffs. | |
| - 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: Pack built runtime artifacts | |
| run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime packages/*/dist | |
| - name: Upload built runtime artifacts | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: dist-runtime-build | |
| path: dist-runtime-build.tar.zst | |
| retention-days: 1 | |
| - name: Upload bundled plugin asset artifacts | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: bundled-plugin-assets | |
| path: | | |
| extensions/*/src/host/**/.bundle.hash | |
| extensions/*/src/host/**/*.bundle.js | |
| include-hidden-files: true | |
| retention-days: 1 | |
| - name: Smoke test CLI launcher help | |
| run: node openclaw.mjs --help | |
| - name: Smoke test CLI launcher status json | |
| run: node openclaw.mjs status --json --timeout 1 | |
| - name: Smoke test built CLI with Bun | |
| run: | | |
| bun openclaw.mjs --help | |
| bun openclaw.mjs status --json --timeout 1 | |
| - name: Verify built browser native host | |
| # Frozen candidates may predate this source proof; a skipped step is | |
| # unavailable historical coverage, never a successful native-host test. | |
| if: ${{ needs.preflight.outputs.frozen_target != 'true' || hashFiles('extensions/browser/src/browser/extension-install.native-host.e2e.test.ts') != '' }} | |
| env: | |
| 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); | |
| assert.equal(report.numTotalTests, 1); | |
| assert.equal(report.numPassedTests, 1); | |
| for (const key of ["numFailedTestSuites", "numPendingTestSuites", "numFailedTests", "numPendingTests", "numTodoTests"]) { | |
| assert.equal(report[key], 0, key); | |
| } | |
| assert.equal(report.testResults.length, 1); | |
| const file = report.testResults[0]; | |
| assert.equal(file.name, path.resolve("extensions/browser/src/browser/extension-install.native-host.e2e.test.ts")); | |
| assert.equal(file.status, "passed"); | |
| assert.equal(file.assertionResults.length, 1); | |
| const fullName = "native host registration launches with the exact custom installation context when Chrome has no selectors"; | |
| assert.equal(file.assertionResults[0].fullName, fullName); | |
| assert.equal(file.assertionResults[0].status, "passed"); | |
| console.log(JSON.stringify({ proof: "browser-native-host", fullName, passed: 1, failed: 0, pending: 0 })); | |
| BROWSER_PROOF_REPORT | |
| # Startup RSS is measured alone on every runner before independent dist | |
| # readers share the artifact-check wave. The step is unconditional because | |
| # the verifiers always run; each selected check self-gates. | |
| - name: Run built artifact checks | |
| env: | |
| PARALLEL_GATEWAY_WATCH: ${{ runner.environment != 'github-hosted' && 'true' || 'false' }} | |
| PARALLEL_BUILT_VERIFIERS: ${{ runner.environment != 'github-hosted' && 'true' || 'false' }} | |
| # GitHub-hosted Linux reports a higher RSS baseline than Blacksmith for | |
| # the same built CLI. Keep the tighter Blacksmith regression ceiling. | |
| OPENCLAW_STARTUP_MEMORY_PLUGINS_LIST_MB: ${{ runner.environment == 'github-hosted' && '425' || '400' }} | |
| RUN_CHANNELS: ${{ needs.preflight.outputs.run_checks }} | |
| RUN_CORE_SUPPORT_BOUNDARY: ${{ needs.preflight.outputs.run_checks_node_core_dist }} | |
| RUN_GATEWAY_WATCH: ${{ needs.preflight.outputs.run_check_additional }} | |
| RUN_TUI_PTY: ${{ needs.preflight.outputs.run_checks_node_core_dist }} | |
| FROZEN_TARGET: ${{ needs.preflight.outputs.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" | |
| ["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}: $*" | |
| "$@" >"$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 | |
| run_verifier "doctor-plugin-index" run_doctor_plugin_index | |
| run_verifier "plugin-singleton" pnpm test:build:singleton | |
| if [ "$RUN_CHANNELS" = "true" ]; then | |
| start_check "channels" env \ | |
| NODE_OPTIONS=--max-old-space-size=8192 \ | |
| OPENCLAW_VITEST_MAX_WORKERS=1 \ | |
| pnpm test:channels | |
| fi | |
| if [ "$RUN_CORE_SUPPORT_BOUNDARY" = "true" ]; then | |
| start_check "core-support-boundary" env \ | |
| NODE_OPTIONS=--max-old-space-size=8192 \ | |
| OPENCLAW_VITEST_MAX_WORKERS=2 \ | |
| node scripts/run-vitest.mjs run --config test/vitest/vitest.full-core-support-boundary.config.ts | |
| fi | |
| if [ "$RUN_GATEWAY_WATCH" = "true" ] && [ "$PARALLEL_GATEWAY_WATCH" = "true" ]; then | |
| start_check "gateway-watch" \ | |
| pnpm test:gateway:watch-regression -- --skip-build | |
| 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 | |
| # Run the real Gateway send alone, after other artifact readers finish. | |
| if [ "$RUN_CHANNELS" = "true" ]; then | |
| start_check "discord-component-attachments" run_discord_component_attachments | |
| wait_checks | |
| fi | |
| # The dedicated Node shard owns the full built-CLI PTY suite. Keep | |
| # only the local roundtrip and Gateway-connect artifact canaries here. | |
| 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 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_checks == 'true' | |
| 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_check_additional == 'true' | |
| uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 | |
| with: | |
| name: gateway-watch-regression | |
| path: .local/gateway-watch-regression/ | |
| retention-days: 7 | |
| sqlite-session-lifecycle: | |
| permissions: | |
| contents: read | |
| name: check-sqlite-session-lifecycle | |
| needs: [preflight, build-artifacts] | |
| if: ${{ !cancelled() && always() && needs.preflight.outputs.run_sqlite_session_lifecycle == 'true' && needs.build-artifacts.result == 'success' }} | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: 20 | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| restore-test-caches: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 'true' || 'false' }} | |
| - name: Download exact-run built runtime | |
| uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 | |
| with: | |
| name: dist-runtime-build | |
| path: .artifacts/dist-runtime-build | |
| - name: Extract built runtime | |
| run: tar --extract --file .artifacts/dist-runtime-build/dist-runtime-build.tar.zst --use-compress-program unzstd | |
| - name: Verify SQLite session lifecycle | |
| env: | |
| OPENCLAW_E2E_USE_PREBUILT_DIST: "1" | |
| OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "660000" | |
| run: 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 | |
| native-i18n: | |
| permissions: | |
| contents: read | |
| needs: [preflight] | |
| if: ${{ !cancelled() && always() && needs.preflight.outputs.run_native_i18n == 'true' }} | |
| runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: 10 | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # Same-repo Blacksmith runs restore the dependency cache published by | |
| # preflight; hosted paths use the pnpm store cache instead. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| - 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: checks-ui | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_ui_tests == 'true' | |
| # Measured 177s body on hosted 4-core (run 31861511624); hybrid attempt 1 | |
| # rides the 8 vCPU class like the ui-e2e shards, retries stay hosted. | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && 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')) }} | |
| timeout-minutes: 20 | |
| env: | |
| COMPATIBILITY_TARGET: ${{ needs.preflight.outputs.compatibility_target }} | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| node-version: "24.x" | |
| install-bun: "false" | |
| # Same-repo Blacksmith runs restore the dependency cache published by | |
| # preflight; hosted paths use the pnpm store cache instead. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| restore-test-caches: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 'true' || 'false' }} | |
| - &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.62.1 | |
| - &install_playwright_chromium | |
| name: Install Playwright Chromium | |
| env: | |
| FROZEN_TARGET: ${{ needs.preflight.outputs.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 | |
| node --import tsx scripts/ensure-playwright-chromium.mts | |
| 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 | |
| run: pnpm lint:ui:no-raw-window-open | |
| - name: Test Control UI | |
| run: | | |
| if [[ "$COMPATIBILITY_TARGET" == "true" ]]; then | |
| # Frozen targets can contain timing-sensitive tests fixed on current main. | |
| # Give legacy browser fixtures enough headroom on shared hosted runners. | |
| # Isolate files because older suites can still leak module mocks between tests. | |
| # Do not retry whole files: several rely on one-shot mocked browser globals. | |
| pnpm --dir ui test --testTimeout=30000 --isolate | |
| else | |
| # Three workers deliberately exercise stable non-default file packing so | |
| # isolate:false mock-registry leaks fail close to the introducing change. | |
| pnpm --dir ui test --maxWorkers 3 | |
| fi | |
| checks-ui-e2e: | |
| permissions: | |
| contents: read | |
| name: checks-ui-e2e (${{ matrix.shard }}/${{ matrix.shard_count }}) | |
| needs: [preflight] | |
| # Compatibility targets pin a frozen Control UI whose e2e expectations track | |
| # that release, not current main. | |
| if: needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true' | |
| # Hybrid attempt 1 uses Blacksmith; github mode, hybrid retries, manual | |
| # dispatches, forks, and same-repo PR retries use GitHub-hosted capacity. | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' || (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-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }} | |
| # Keep Chromium ownership serial within each independently required shard. | |
| timeout-minutes: 25 | |
| env: | |
| OPENCLAW_UI_E2E_SKIP_REAL_GATEWAY: "1" | |
| strategy: | |
| fail-fast: false | |
| max-parallel: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 14 || 4 }} | |
| 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: ${{ needs.preflight.outputs.cache_mode }} | |
| node-version: "24.x" | |
| install-bun: "false" | |
| # The github/hybrid planner profile uses the Actions pnpm-store cache | |
| # on either runner backend; all-Blacksmith mode restores preflight's tree. | |
| dependency-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (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: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 'true' || 'false' }} | |
| - *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 }} | |
| SHARD_INDEX: ${{ matrix.shard }} | |
| VITEST_SHARD_COUNT: ${{ matrix.vitest_shard_count }} | |
| run: >- | |
| node scripts/run-vitest.mjs run | |
| --config test/vitest/vitest.ui-e2e.config.ts | |
| --configLoader runner | |
| --shard "$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 }} | |
| if-no-files-found: ignore | |
| 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: checks-ui-e2e-real-gateway | |
| needs: [preflight] | |
| # Compatibility targets pin a frozen Control UI whose e2e expectations track | |
| # that release, not current main. | |
| if: needs.preflight.outputs.run_ui_tests == 'true' && needs.preflight.outputs.compatibility_target != 'true' | |
| # First-attempt same-repo runs use Blacksmith unless the backend breaker is | |
| # set; manual dispatches, forks, and same-repo PR retries also use GitHub-hosted capacity. | |
| # This lane owns the same serial Chromium workload as checks-ui-e2e, so it | |
| # follows that job's hybrid routing: Blacksmith on attempt 1, hosted on rerun. | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' || (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-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }} | |
| timeout-minutes: 20 | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| node-version: "24.x" | |
| install-bun: "false" | |
| # The github/hybrid planner profile uses the Actions pnpm-store cache | |
| # on either runner backend; all-Blacksmith mode restores preflight's tree. | |
| dependency-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (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') }} | |
| - *cache_playwright_chromium | |
| - *install_playwright_chromium | |
| - name: Build Control UI bundle for real-Gateway tests | |
| run: pnpm ui:build | |
| - name: Test MCP app conformance with a real Gateway | |
| run: >- | |
| node scripts/run-vitest.mjs run | |
| --config test/vitest/vitest.ui-e2e.config.ts | |
| --configLoader runner | |
| ui/src/e2e/mcp-app-conformance.e2e.test.ts | |
| - name: Test Control UI auth transports with a real Gateway | |
| env: | |
| 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 | |
| run: >- | |
| node scripts/run-vitest.mjs run | |
| --config test/vitest/vitest.ui-e2e.config.ts | |
| --configLoader runner | |
| ui/src/e2e/control-ui-auth-transports.e2e.test.ts | |
| - name: Test Control UI usage sessions owner attribution with a real Gateway | |
| env: | |
| 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 | |
| run: >- | |
| node scripts/run-vitest.mjs run | |
| --config test/vitest/vitest.ui-e2e.config.ts | |
| --configLoader runner | |
| ui/src/e2e/usage-sessions-owner-attribution.e2e.test.ts | |
| - 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 }} | |
| path: .artifacts/control-ui-e2e/real-gateway | |
| if-no-files-found: error | |
| retention-days: 14 | |
| - name: Test Control UI Logs lifecycle with a real Gateway | |
| run: >- | |
| node scripts/run-vitest.mjs run | |
| --config test/vitest/vitest.ui-e2e.config.ts | |
| --configLoader runner | |
| ui/src/e2e/logs-lifecycle.e2e.test.ts | |
| control-ui-i18n: | |
| permissions: | |
| contents: read | |
| name: control-ui-i18n | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_control_ui_i18n == 'true' | |
| runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: 10 | |
| env: | |
| COMPATIBILITY_TARGET: ${{ needs.preflight.outputs.compatibility_target }} | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| node-version: "24.x" | |
| install-bun: "false" | |
| # Same-repo Blacksmith runs restore the dependency cache published by | |
| # preflight; hosted paths use the pnpm store cache instead. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| - 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 | |
| # Source-only drift stays advisory because the post-merge bot owns | |
| # repair. Generated locale changes and full release CI remain strict. | |
| continue-on-error: ${{ needs.preflight.outputs.strict_control_ui_i18n != 'true' }} | |
| run: pnpm ui:i18n:check | |
| checks-fast-core: | |
| permissions: | |
| contents: read | |
| pull-requests: read | |
| name: ${{ matrix.check_name }} | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_checks_fast_core == 'true' | |
| runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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 == 'baseline-ratchets' || startsWith(matrix.task, 'release-lint-')) && needs.preflight.outputs.diff_base_revision || '' }} | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Prepare release-gate ratchet merge tree | |
| if: (matrix.task == 'baseline-ratchets' || 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: | | |
| 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" --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 | |
| 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" | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: ${{ matrix.task == 'bun-launcher' && 'true' || 'false' }} | |
| # Same-repo Blacksmith runs restore the dependency cache published by | |
| # preflight; hosted paths use the pnpm store cache instead. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| 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 }} | |
| RATCHET_PR_HEAD_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || '' }} | |
| TASK: ${{ matrix.task }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| has_package_script() { | |
| node -e ' | |
| const scripts = require("./package.json").scripts ?? {}; | |
| process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1); | |
| ' "$1" | |
| } | |
| case "$TASK" in | |
| bundled-protocol) | |
| pnpm test:bundled | |
| python3 -I -S "$RUNNER_TEMP/ci-git-owner.py" --git 0 fetch --no-tags --no-recurse-submodules --depth=1 origin \ | |
| "+${PROTOCOL_SINCE_BASE_SHA}:refs/remotes/origin/protocol-since-base" | |
| 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-run-node-test-shard.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts | |
| ;; | |
| ci-routing) | |
| pnpm test src/commands/status.scan-result.test.ts src/scripts/ci-changed-scope*.test.ts test/scripts/changed-lanes.test.ts test/scripts/changed-path-facts.test.ts test/scripts/ci-changed-node-test-plan.test.ts test/scripts/ci-run-node-test-shard.test.ts test/scripts/ci-workflow-guards.test.ts test/scripts/run-vitest.test.ts test/scripts/test-projects.test.ts | |
| ;; | |
| coercion-helpers) | |
| pnpm check:coercion-helpers | |
| ;; | |
| baseline-ratchets) | |
| for required_script in check:max-lines-ratchet check:assertion-safety; 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" | |
| pnpm check:assertion-safety --base "$base_ref" | |
| ;; | |
| 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 | |
| ;; | |
| *) | |
| echo "Unsupported checks-fast task: $TASK" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| qa-smoke-ci-profile: | |
| permissions: | |
| contents: read | |
| name: QA Smoke CI (${{ matrix.name }}) | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_qa_smoke_ci == 'true' | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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') }} | |
| 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: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # The github/hybrid planner profile uses the Actions pnpm-store cache | |
| # on either runner backend; all-Blacksmith mode restores preflight's tree. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| # Reader of build-artifacts' protected daily archive: warms tsdown | |
| # tooling plus the repeated openclaw.mjs boots in the scenario loop. | |
| node-compile-cache: "true" | |
| node-compile-cache-scope: build | |
| - 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([ | |
| "control-ui-chat-flow-playwright", | |
| "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) { | |
| throw new Error(`unknown QA smoke CI profile part: ${partId}`); | |
| } | |
| const scenarioCatalog = await import("./extensions/qa-lab/src/scenario-catalog.ts"); | |
| const scenarioKindById = new Map( | |
| scenarioCatalog | |
| .readQaScenarioPack() | |
| .scenarios.map((scenario) => [scenario.id, scenario.execution.kind]), | |
| ); | |
| const legacyRuns = smokePlan | |
| .createQaSmokeCiMatrix() | |
| .include.filter((_, index) => index % 2 === partIndex); | |
| runs = legacyRuns.flatMap((run) => | |
| run.scenario_ids.flatMap((scenarioId) => { | |
| if (!compatibilityScenarioIds.has(scenarioId)) { | |
| return []; | |
| } | |
| const kind = scenarioKindById.get(scenarioId); | |
| if (!kind) { | |
| throw new Error(`legacy QA smoke scenario not found: ${scenarioId}`); | |
| } | |
| return [ | |
| { | |
| ...run, | |
| slug: `${run.slug}-${kind}-${scenarioId}`, | |
| scenario_ids: [scenarioId], | |
| }, | |
| ]; | |
| }), | |
| ); | |
| } else { | |
| throw new Error("QA smoke plan does not expose a supported CI planner."); | |
| } | |
| // 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: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 12 | |
| matrix: ${{ fromJson(needs.preflight.outputs.plugin_contracts_matrix) }} | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # Same-repo Blacksmith runs restore the dependency cache published by | |
| # preflight; hosted paths use the pnpm store cache instead. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| restore-test-caches: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 'true' || 'false' }} | |
| - name: Run plugin contract shard | |
| env: | |
| OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| include_file="$RUNNER_TEMP/plugin-contract-include.json" | |
| INCLUDE_FILE="$include_file" node --input-type=module <<'EOF' | |
| import { writeFileSync } from "node:fs"; | |
| const includePatterns = JSON.parse(process.env.OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON ?? "[]"); | |
| if (!Array.isArray(includePatterns) || includePatterns.length === 0) { | |
| console.error("Missing plugin contract include patterns"); | |
| process.exit(1); | |
| } | |
| writeFileSync(process.env.INCLUDE_FILE, JSON.stringify(includePatterns), "utf8"); | |
| EOF | |
| OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:plugins | |
| checks-fast-channel-contracts-shard: | |
| permissions: | |
| contents: read | |
| name: ${{ matrix.checkName }} | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_channel_contracts_shards == 'true' | |
| runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 12 | |
| matrix: ${{ fromJson(needs.preflight.outputs.channel_contracts_matrix) }} | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # Same-repo Blacksmith runs restore the dependency cache published by | |
| # preflight; hosted paths use the pnpm store cache instead. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| restore-test-caches: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 'true' || 'false' }} | |
| - name: Run channel contract shard | |
| env: | |
| OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }} | |
| OPENCLAW_TEST_PROJECTS_PARALLEL: "4" | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| include_file="$RUNNER_TEMP/channel-contract-include.json" | |
| INCLUDE_FILE="$include_file" node --input-type=module <<'EOF' | |
| import { writeFileSync } from "node:fs"; | |
| const includePatterns = JSON.parse(process.env.OPENCLAW_CONTRACT_INCLUDE_PATTERNS_JSON ?? "[]"); | |
| if (!Array.isArray(includePatterns) || includePatterns.length === 0) { | |
| console.error("Missing channel contract include patterns"); | |
| process.exit(1); | |
| } | |
| writeFileSync(process.env.INCLUDE_FILE, JSON.stringify(includePatterns), "utf8"); | |
| EOF | |
| OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels | |
| checks-node-compat: | |
| permissions: | |
| contents: read | |
| name: checks-node-compat-node22 | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch' | |
| runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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 | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| node-version: "22.23.2" | |
| install-bun: "false" | |
| build-all-cache-scope: full | |
| - name: Configure Node test resources | |
| run: echo "OPENCLAW_VITEST_MAX_WORKERS=2" >> "$GITHUB_ENV" | |
| - name: Run Node 22 compatibility | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=8192 | |
| run: | | |
| pnpm build | |
| pnpm ui:build | |
| node openclaw.mjs --help | |
| node openclaw.mjs status --json --timeout 1 | |
| pnpm test:build:singleton | |
| checks-node-core-test-nondist-shard: | |
| permissions: | |
| contents: read | |
| name: ${{ matrix.check_name }} | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_checks_node_core_nondist == 'true' | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: ${{ matrix.timeout_minutes || 60 }} | |
| strategy: | |
| fail-fast: false | |
| # Canonical main admits only one complete run at a time, so widen this | |
| # matrix within the current runner-registration budget. | |
| max-parallel: ${{ (needs.preflight.outputs.runner_profile == 'github' || needs.preflight.outputs.runner_profile == 'hybrid') && 96 || 28 }} | |
| matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }} | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| node-version: "${{ matrix.node_version || '24.x' }}" | |
| install-bun: "false" | |
| # Blacksmith shards restore the exact dependency archive; | |
| # github/hybrid use the pnpm store. | |
| dependency-cache: ${{ (matrix.node_version == null || matrix.node_version == '24.x') && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| vitest-fs-cache: "true" | |
| node-compile-cache: "true" | |
| node-compile-cache-scope: "test" | |
| - name: Setup Go for docs i18n | |
| if: matrix.requires_go == true | |
| # The current workflow validates frozen targets whose go.mod may predate this toolchain pin. | |
| # Keep the runner toolchain owned by the workflow; cache publication is gated separately. | |
| uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 | |
| with: | |
| go-version: "1.27.0" | |
| 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.0" | |
| - name: Configure Node test resources | |
| # Scale the in-process Vitest worker budget with the cores the job | |
| # actually received: runner labels over-promise under fleet load | |
| # (observed: a 16 vCPU label delivering a 4-CPU cgroup) and dispatch | |
| # runs fall back to hosted runners regardless of matrix.runner. | |
| # Serial-plan bins (planConcurrency 1) get the full budget; anything | |
| # that can overlap Vitest processes keeps the proven 2-worker cap. | |
| # Timing-sensitive groups stay pinned to 2 via plan-level env. | |
| env: | |
| PREDICTED_TEST_SECONDS: ${{ matrix.predicted_seconds || '' }} | |
| SHARD_PLAN_CONCURRENCY: ${{ matrix.plan_concurrency || '' }} | |
| run: | | |
| cores="$(nproc)" | |
| if [ "$SHARD_PLAN_CONCURRENCY" != "1" ]; then | |
| workers=2 | |
| elif [ "$cores" -ge 12 ]; then | |
| workers=6 | |
| elif [ "$cores" -ge 6 ]; then | |
| workers=4 | |
| else | |
| workers=3 | |
| fi | |
| 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: Checkout trusted Node shard runner | |
| # Frozen release targets can predate the workflow-owned shard runner. | |
| # Keep its implementation pinned to this workflow revision, while tests | |
| # continue to run against the checked-out candidate. | |
| 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/direct-run.mjs | |
| scripts/lib/local-check-runtime.mts | |
| sparse-checkout-cone-mode: false | |
| persist-credentials: false | |
| # A few real-CLI E2E tests need a current dist tree. Build it before | |
| # Vitest starts so the shard's worker processes cannot consume the | |
| # cgroup memory that the build admission check must reserve. Reuse the | |
| # CI artifact profile: these runtime readers do not need global declarations. | |
| - name: Build 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:ci-artifacts | |
| - name: Run Node test shard | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=8192 | |
| OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }} | |
| OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }} | |
| OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }} | |
| OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }} | |
| OPENCLAW_NODE_TEST_TARGETS_JSON: ${{ toJson(matrix.targets) }} | |
| # Frozen targets can carry integration hooks whose cold setup exceeds | |
| # the current defaults on shared release runners. Keep the no-output | |
| # watchdog above Vitest so it cannot preempt hook diagnostics. | |
| 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_VITEST_NO_OUTPUT_RETRY: "1" | |
| OPENCLAW_NODE_TEST_PLAN_CONCURRENCY: ${{ matrix.plan_concurrency }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| runner="scripts/ci-run-node-test-shard.mts" | |
| if [[ ! -f "$runner" ]]; then | |
| runner=".ci-workflow/${runner}" | |
| [[ -f "$runner" ]] | |
| fi | |
| node --import tsx "$runner" | |
| - 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 }} | |
| # Types, lint, and format check shards. | |
| check-shard: | |
| permissions: | |
| contents: read | |
| name: ${{ matrix.check_name }} | |
| needs: [preflight] | |
| if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }} | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.repository == 'openclaw/openclaw' && github.event_name == 'workflow_dispatch' && !inputs.release_gate && startsWith(inputs.dispatch_id, 'full-release-validation-') && inputs.target_context_ref != '' && needs.preflight.outputs.frozen_target == 'true' && matrix.task == 'lint') && (matrix.runner || 'blacksmith-4vcpu-ubuntu-2404') || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && (github.event_name == 'workflow_dispatch' || github.run_attempt > 1 || (matrix.task != 'lint' && matrix.task != 'test-types' && matrix.task != 'dependencies'))) && 'ubuntu-24.04' || (github.event_name == 'workflow_dispatch' && inputs.release_gate) && '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 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 12 | |
| matrix: | |
| include: | |
| - 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 | |
| # Top light-run pole (~186s of shard work); oxlint shard | |
| # concurrency scales with cores at similar billed core-minutes. | |
| runner: blacksmith-32vcpu-ubuntu-2404 | |
| - check_name: check-dependencies | |
| task: dependencies | |
| # Concurrent Knip scans need cores and memory headroom; the wall | |
| # clock roughly halves for similar billed core-minutes. | |
| runner: blacksmith-32vcpu-ubuntu-2404 | |
| - check_name: check-test-types | |
| task: test-types | |
| # Slowest static lane (~3.5min on 4 vCPU); extra cores shorten the | |
| # PR critical path for roughly the same billed core-minutes. | |
| runner: blacksmith-16vcpu-ubuntu-2404 | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # The github/hybrid planner profile uses the Actions pnpm-store cache | |
| # on either runner backend; all-Blacksmith mode restores preflight's tree. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| - name: Compute extension boundary input fingerprint | |
| id: extension-boundary-inputs | |
| if: matrix.task == 'lint' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ -f scripts/prepare-extension-package-boundary-artifacts.mts ]]; then | |
| fingerprint="$(node --import tsx scripts/prepare-extension-package-boundary-artifacts.mts --print-input-fingerprint)" | |
| echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| else | |
| # Older targets build this boundary through their candidate-owned | |
| # runner, but cannot produce the v2 cache fingerprint safely. | |
| 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: | |
| path: | | |
| dist/plugin-sdk | |
| packages/plugin-sdk/dist | |
| extensions/*/dist/.boundary-tsc.tsbuildinfo | |
| extensions/*/dist/.boundary-tsc.stamp | |
| key: ${{ runner.os }}-extension-package-boundary-v2-${{ steps.extension-boundary-inputs.outputs.fingerprint }} | |
| restore-keys: | | |
| ${{ runner.os }}-extension-package-boundary-v2- | |
| # check-lint's shard runner rebuilds the same plugin-sdk boundary | |
| # artifacts the boundary lane snapshots (~72s cold); restore them from | |
| # the shared sticky. Strictly read-only (commit: false): only the | |
| # protected boundary lane may publish this repository-global snapshot. | |
| - name: Mount extension boundary sticky disk | |
| if: matrix.task == 'lint' && steps.extension-boundary-inputs.outputs.enabled == 'true' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && 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@35ba2e331a80056a42af053ee54968511de2b7c7 # v1.5.0 | |
| with: | |
| # One stable disk for the whole repository. The v1 per-PR/per-config | |
| # keys minted a new backing disk for every PR and toolchain change | |
| # and helped saturate Blacksmith's installation-wide sticky-disk | |
| # budget, 429-failing every mount. Snapshot validity lives in the | |
| # in-job marker checked below instead of the key. | |
| key: ${{ github.repository }}-ext-boundary-v2 | |
| path: /var/tmp/openclaw-ext-boundary | |
| commit: "false" | |
| - name: Restore extension boundary artifacts from sticky disk | |
| if: matrix.task == 'lint' && steps.extension-boundary-inputs.outputs.enabled == 'true' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') | |
| shell: bash | |
| 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 same canonical fingerprint keys the hosted cache and boundary | |
| # lane, so no consumer can restore a snapshot from stale SDK inputs. | |
| 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 dist packages extensions; do | |
| if [ -d "$sticky_root/$payload" ]; then | |
| rsync -a "$sticky_root/$payload/" "$payload/" | |
| fi | |
| done | |
| - name: Run check shard | |
| env: | |
| FROZEN_TARGET: ${{ needs.preflight.outputs.frozen_target }} | |
| HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }} | |
| FORMAT_CHECK: ${{ needs.preflight.outputs.run_format_check }} | |
| RELEASE_GATE: ${{ inputs.release_gate && '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" | |
| TASK: ${{ matrix.task }} | |
| PR_BASE_SHA: ${{ github.event_name == 'pull_request' && needs.preflight.outputs.diff_base_revision || '' }} | |
| DIFF_BASE_SHA: ${{ needs.preflight.outputs.diff_base_revision }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| has_package_script() { | |
| node -e ' | |
| const scripts = require("./package.json").scripts ?? {}; | |
| process.exit(Object.hasOwn(scripts, process.argv[1]) ? 0 : 1); | |
| ' "$1" | |
| } | |
| case "$TASK" in | |
| guards) | |
| pnpm check:no-conflict-markers | |
| 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 | |
| pnpm tool-display:check | |
| pnpm check:host-env-policy:swift | |
| 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 "$PR_BASE_SHA" ]; then | |
| python3 -I -S "$RUNNER_TEMP/ci-git-owner.py" --git 120 fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/ci-base" | |
| node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/ci-base --head HEAD --no-merge-base | |
| fi | |
| pnpm deps:patches:check | |
| pnpm lint:webhook:no-low-level-body-read | |
| pnpm lint:auth:no-pairing-store-group | |
| pnpm lint:auth:pairing-account-scope | |
| pnpm check:import-cycles | |
| ;; | |
| npm-lock) | |
| # 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. | |
| npm_lock_base="" | |
| if [[ "${GITHUB_EVENT_NAME:-}" != "workflow_dispatch" && -n "$DIFF_BASE_SHA" ]] && | |
| has_package_script "deps:npm-lock:check:changed"; then | |
| if python3 -I -S "$RUNNER_TEMP/ci-git-owner.py" --git 120 fetch --no-tags --depth=1 origin "+${DIFF_BASE_SHA}:refs/remotes/origin/npm-lock-base"; then | |
| npm_lock_base="refs/remotes/origin/npm-lock-base" | |
| else | |
| fetch_status="$?" | |
| # A full sweep also reuses this workspace; require verified cleanup first. | |
| case "$fetch_status" in 125|129|130|143) exit "$fetch_status" ;; esac | |
| echo "::warning::npm-lock base fetch failed; falling back to the full sweep" | |
| fi | |
| fi | |
| if [[ -n "$npm_lock_base" ]]; then | |
| pnpm deps:npm-lock:check:changed --base "$npm_lock_base" --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) | |
| if [ "$HOSTED_RUNNER_STRIPES" = "true" ]; then | |
| # Five dedicated hosted jobs own the aggregated core Programs. Keep | |
| # extension preparation in this sixth lane except when the exact | |
| # merge-tree release lint job already owns it. | |
| lint_args=(--only=extensions --only=scripts --threads=1) | |
| if [ "$RELEASE_GATE" = "true" ]; then | |
| lint_args=(--only=scripts --threads=1) | |
| fi | |
| export GOMAXPROCS=2 | |
| elif [ "$(nproc)" -lt 8 ]; then | |
| # Fork PRs build each semantic Program once. Bound both oxlint | |
| # and its Go helper within the 4-core/16GB hosted runner. | |
| lint_args=(--threads=1) | |
| export GOMAXPROCS=2 | |
| 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 | |
| elif [ "$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 | |
| 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"); | |
| if (!source.includes(marker)) throw new Error(`${configPath} is missing ${marker}`); | |
| 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) | |
| # The github/hybrid planner profile moves the 14 serial core-test | |
| # graphs (~70% of this lane) into the dedicated hosted stripes | |
| # job; this row keeps the extensions/root/scripts tail. Targets | |
| # without stripe support run 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 | |
| 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 pnpm run --silent 2>/dev/null | grep -q '^ tsgo:scripts$'; then | |
| pnpm tsgo:scripts | |
| elif [[ "$HISTORICAL_TARGET" != "true" ]]; then | |
| echo "Current CI targets must provide the tsgo:scripts package script." >&2 | |
| exit 1 | |
| fi | |
| if [ "$root_covered" != "true" ]; then | |
| if pnpm run --silent 2>/dev/null | grep -q '^ tsgo:test:root$'; then | |
| pnpm tsgo:test:root | |
| elif [[ "$HISTORICAL_TARGET" != "true" ]]; then | |
| echo "Current CI targets must provide the tsgo:test:root package script." >&2 | |
| exit 1 | |
| fi | |
| fi | |
| ;; | |
| *) | |
| echo "Unsupported check task: $TASK" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| # The github/hybrid planner profile aggregates core targets into five Programs | |
| # across five hosted jobs. All-Blacksmith mode keeps one lint job. | |
| check-lint-hosted-core-shard: | |
| permissions: | |
| contents: read | |
| name: check-lint-core-${{ matrix.stripe }} | |
| needs: [preflight] | |
| if: ${{ needs.preflight.outputs.run_check == '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: ubuntu-24.04 | |
| timeout-minutes: 15 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 5 | |
| matrix: | |
| stripe: [1, 2, 3, 4, 5] | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| - name: Run hosted core lint stripe | |
| env: | |
| GOMAXPROCS: "2" | |
| OPENCLAW_LOCAL_CHECK: "0" | |
| run: | | |
| set -euo pipefail | |
| # 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 | |
| node --import tsx scripts/run-oxlint-shards.mts \ | |
| --only=core --split-core --core-stripe=${{ matrix.stripe }}/5 --threads=1 | |
| # The github/hybrid planner profile stripes the 14 serial core test-type | |
| # graphs (~34-42s per graph on either runner class; tsgo saturates a machine | |
| # per graph) across four jobs; the check-test-types row keeps the | |
| # extensions/root/scripts tail. All-Blacksmith mode keeps the single | |
| # 16-vCPU test-types job. | |
| check-test-types-hosted-core-shard: | |
| permissions: | |
| contents: read | |
| name: check-test-types-core-${{ matrix.stripe }} | |
| needs: [preflight] | |
| if: ${{ needs.preflight.outputs.run_check == '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') }} | |
| # Measured 232-269s per stripe on hosted 4-core; hybrid attempt 1 rides | |
| # the 8 vCPU class like the compact large rows, retries stay hosted. | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && 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: 15 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 5 | |
| matrix: | |
| stripe: [1, 2, 3, 4, 5] | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| - name: Run hosted core test-types stripe | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| # 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 | |
| node scripts/run-tsgo-core-test-shards.mjs --stripe "${{ matrix.stripe }}/5" --concurrency 2 | |
| check-additional-shard: | |
| permissions: | |
| contents: read | |
| name: ${{ matrix.check_name }} | |
| needs: [preflight] | |
| if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }} | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && (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' && '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 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 12 | |
| matrix: | |
| include: | |
| - check_name: check-additional-boundaries-a | |
| group: boundaries | |
| boundary_shard: 1/4 | |
| runner: blacksmith-8vcpu-ubuntu-2404 | |
| - check_name: check-additional-boundaries-bcd | |
| group: boundaries | |
| boundary_shard: 2/4,3/4,4/4 | |
| runner: blacksmith-8vcpu-ubuntu-2404 | |
| # Prompt snapshot regeneration evaluates the full agent tool/prompt | |
| # import graph and used to own the boundaries-a wall clock; keep it | |
| # in its own lane. | |
| - check_name: check-prompt-snapshots | |
| group: prompt-snapshots | |
| runner: blacksmith-8vcpu-ubuntu-2404 | |
| - check_name: check-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-session-transcript-reader-boundary | |
| group: session-transcript-reader-boundary | |
| runner: blacksmith-4vcpu-ubuntu-2404 | |
| - check_name: check-sqlite-session-schema-baseline | |
| group: sqlite-session-schema-baseline | |
| runner: blacksmith-4vcpu-ubuntu-2404 | |
| - check_name: report-plugin-sdk-api-diff | |
| group: plugin-sdk-api-diff | |
| # Measured 209s hosted and 246s on 4 vCPU; diff generation is | |
| # CPU-bound and the 8 vCPU class keeps it off the critical path. | |
| runner: blacksmith-8vcpu-ubuntu-2404 | |
| - check_name: check-additional-extension-package-boundary | |
| group: extension-package-boundary | |
| # Light-run critical-path pole: cold runs spend ~100s in nine | |
| # parallel plugin-sdk dts builds plus ~58s compiling 122 plugins | |
| # at concurrency 6 on 8 vCPUs. Both phases scale with cores at | |
| # similar billed core-minutes. | |
| runner: blacksmith-32vcpu-ubuntu-2404 | |
| - check_name: check-additional-runtime-topology-architecture | |
| group: runtime-topology-architecture | |
| # Measured 274s on hosted 4-core and ~170s on 4 vCPU; the 8 vCPU | |
| # class keeps this off the hybrid critical path. | |
| runner: blacksmith-8vcpu-ubuntu-2404 | |
| steps: | |
| - *linux_node_checkout_step | |
| # The report is dispatch-only; fetching its inputs can otherwise fail skipped PR work. | |
| - 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 }} | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # The github/hybrid planner profile uses the Actions pnpm-store cache | |
| # on either runner backend; all-Blacksmith mode restores preflight's tree. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| - name: Compute extension boundary input fingerprint | |
| id: extension-boundary-inputs | |
| if: matrix.group == 'extension-package-boundary' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [[ -f scripts/prepare-extension-package-boundary-artifacts.mts ]]; then | |
| fingerprint="$(node --import tsx scripts/prepare-extension-package-boundary-artifacts.mts --print-input-fingerprint)" | |
| echo "fingerprint=$fingerprint" >> "$GITHUB_OUTPUT" | |
| echo "enabled=true" >> "$GITHUB_OUTPUT" | |
| else | |
| # Older targets build this boundary through their candidate-owned | |
| # runner, but cannot produce the v2 cache fingerprint safely. | |
| echo "enabled=false" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Same-repo runs carry boundary artifacts on a Blacksmith sticky disk: | |
| # the GitHub cache below is evicted so quickly under the repo quota that | |
| # its prefix restore never hits, leaving every run to rebuild ~113s of | |
| # plugin-sdk declarations. Fork PRs must never produce writable | |
| # repository-global snapshots, so they keep the GitHub cache path. | |
| - name: Mount extension boundary sticky disk | |
| if: matrix.group == 'extension-package-boundary' && steps.extension-boundary-inputs.outputs.enabled == 'true' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && 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@35ba2e331a80056a42af053ee54968511de2b7c7 # v1.5.0 | |
| with: | |
| # One stable disk for the whole repository. The v1 per-PR/per-config | |
| # keys minted a new backing disk for every PR and toolchain change | |
| # and helped saturate Blacksmith's installation-wide sticky-disk | |
| # budget, 429-failing every mount. Snapshot validity lives in the | |
| # in-job marker checked below instead of the key, so source and | |
| # toolchain changes refresh this disk in place. | |
| key: ${{ github.repository }}-ext-boundary-v2 | |
| path: /var/tmp/openclaw-ext-boundary | |
| # Single semantic writer: only protected pushes commit, so | |
| # pull_request clones stay read-only and the snapshot tracks main. | |
| # Explicit true (not on-change/if-missing) because the allocated-byte | |
| # heuristic can miss a same-size refresh, permanently stranding | |
| # consumers on a stale marker. v1.4.0 skips commit after | |
| # failed/cancelled steps, so a broken build cannot poison this key. | |
| commit: ${{ github.event_name != 'pull_request' && 'true' || 'false' }} | |
| - name: Restore extension boundary artifacts from sticky disk | |
| id: boundary-sticky-restore | |
| if: matrix.group == 'extension-package-boundary' && steps.extension-boundary-inputs.outputs.enabled == 'true' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| sticky_root=/var/tmp/openclaw-ext-boundary | |
| if [ ! -f "$sticky_root/.snapshot-ready" ]; then | |
| echo "restored=false" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| # Restore only when the canonical generative-input fingerprint | |
| # matches the snapshotting run. | |
| # Restored artifacts are valid by construction: no clock-skew mtime | |
| # race can false-skip a rebuild, and deleted/renamed sources or a | |
| # toolchain bump build cold. | |
| current_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 dist packages extensions; do | |
| if [ -d "$sticky_root/$payload" ]; then | |
| rsync -a "$sticky_root/$payload/" "$payload/" | |
| fi | |
| done | |
| echo "restored=true" >> "$GITHUB_OUTPUT" | |
| - name: Cache extension package boundary artifacts | |
| id: extension-package-boundary-cache | |
| if: 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: | | |
| dist/plugin-sdk | |
| packages/plugin-sdk/dist | |
| extensions/*/dist/.boundary-tsc.tsbuildinfo | |
| extensions/*/dist/.boundary-tsc.stamp | |
| key: ${{ runner.os }}-extension-package-boundary-v2-${{ steps.extension-boundary-inputs.outputs.fingerprint }} | |
| restore-keys: | | |
| ${{ runner.os }}-extension-package-boundary-v2- | |
| - name: Preserve extension package boundary cache hit | |
| if: matrix.group == 'extension-package-boundary' && steps.extension-boundary-inputs.outputs.enabled == 'true' && steps.extension-package-boundary-cache.outputs.cache-hit == 'true' | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| find extensions \ | |
| -path '*/dist' -prune -o \ | |
| -path '*/node_modules' -prune -o \ | |
| -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \ | |
| -exec touch -t 200001010000 {} + | |
| find src \ | |
| -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \ | |
| -exec touch -t 200001010000 {} + | |
| if [ -d packages/llm-core/src ]; then | |
| find packages/llm-core/src \ | |
| -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \ | |
| -exec touch -t 200001010000 {} + | |
| fi | |
| if [ -d packages/model-catalog-core/src ]; then | |
| find packages/model-catalog-core/src \ | |
| -type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \ | |
| -exec touch -t 200001010000 {} + | |
| fi | |
| - name: Run additional check shard | |
| env: | |
| ADDITIONAL_CHECK_GROUP: ${{ matrix.group }} | |
| COMPATIBILITY_TARGET: ${{ needs.preflight.outputs.compatibility_target }} | |
| RUN_PROMPT_SNAPSHOTS: ${{ needs.preflight.outputs.run_prompt_snapshots }} | |
| OPENCLAW_ADDITIONAL_BOUNDARY_SHARD: ${{ matrix.boundary_shard || '' }} | |
| OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY: 4 | |
| # Matches the boundary lane's 32 vCPU runner (cores/2); the | |
| # script's default caps at 6 to protect laptops. | |
| OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY: 16 | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| failures=0 | |
| run_check() { | |
| local label="$1" | |
| shift | |
| echo "::group::${label}" | |
| if "$@"; then | |
| echo "[ok] ${label}" | |
| else | |
| echo "::error title=${label} failed::${label} failed" | |
| failures=1 | |
| fi | |
| echo "::endgroup::" | |
| } | |
| case "$ADDITIONAL_CHECK_GROUP" in | |
| boundaries) | |
| 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 | |
| "${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 | |
| ;; | |
| session-transcript-reader-boundary) | |
| 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) | |
| run_check "check:architecture" pnpm check:architecture | |
| ;; | |
| *) | |
| echo "Unsupported additional check group: $ADDITIONAL_CHECK_GROUP" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| exit "$failures" | |
| - 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 | |
| # Only the protected writer refreshes the snapshot (pull_request mounts | |
| # never commit, so seeding there would burn wall clock on a discarded | |
| # clone). Seed the payload before the sticky post-action flushes. | |
| # rsync -aR mirrors the repo-relative layout the restore step replays. | |
| - name: Seed extension boundary sticky disk | |
| if: success() && steps.extension-boundary-inputs.outputs.enabled == 'true' && steps.boundary-sticky-restore.outputs.restored == 'false' && matrix.group == 'extension-package-boundary' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && 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" | |
| if [ -d dist/plugin-sdk ]; then | |
| rsync -aR dist/plugin-sdk "$sticky_root/" | |
| fi | |
| if [ -d packages/plugin-sdk/dist ]; then | |
| rsync -aR packages/plugin-sdk/dist "$sticky_root/" | |
| fi | |
| find extensions -maxdepth 3 \( -name '.boundary-tsc.tsbuildinfo' -o -name '.boundary-tsc.stamp' \) \ | |
| -exec rsync -aR {} "$sticky_root/" \; | |
| echo "${{ steps.extension-boundary-inputs.outputs.fingerprint }}" > "$sticky_root/.source-fingerprint" | |
| touch "$sticky_root/.snapshot-ready" | |
| # Validate docs (format, lint, broken links) only when docs files changed. | |
| check-docs: | |
| permissions: | |
| contents: read | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_check_docs == 'true' | |
| runs-on: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: 20 | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # Same-repo Blacksmith runs restore the dependency cache published by | |
| # preflight; hosted paths use the pnpm store cache instead. | |
| dependency-cache: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && vars.OPENCLAW_CI_RUNNER_BACKEND != 'hybrid' && github.event_name != 'workflow_dispatch' && github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == 'openclaw/openclaw') && 'true' || 'false' }} | |
| - name: Check formatting | |
| if: needs.preflight.outputs.run_format_check == 'true' | |
| run: pnpm format: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: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid') && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: 20 | |
| steps: | |
| - name: Checkout | |
| shell: bash | |
| env: | |
| CHECKOUT_KIND: skills | |
| CHECKOUT_REPO: ${{ github.repository }} | |
| CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }} | |
| 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 }} | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_checks_windows == 'true' | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'windows-2025' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'windows-2025' || github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-8vcpu-windows-2025' || 'windows-2025') }} | |
| timeout-minutes: 60 | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=8192 | |
| # Keep total concurrency predictable on the smaller Windows runner. | |
| OPENCLAW_VITEST_MAX_WORKERS: 1 | |
| OPENCLAW_TEST_SKIP_FULL_EXTENSIONS_SHARD: 1 | |
| defaults: | |
| run: | |
| shell: bash | |
| strategy: | |
| fail-fast: false | |
| # Both parts must run concurrently or the split gains nothing; Blacksmith's | |
| # Windows class admits exactly 2, so this is the width, not a cap. | |
| max-parallel: 2 | |
| matrix: ${{ fromJson(needs.preflight.outputs.checks_windows_matrix) }} | |
| steps: | |
| - &platform_checkout_step | |
| name: Checkout | |
| env: | |
| CHECKOUT_REPO: ${{ github.repository }} | |
| CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }} | |
| 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: "22.x" | |
| run: | | |
| set -euo pipefail | |
| source .ci-harness/.github/actions/setup-pnpm-store-cache/ensure-node.sh | |
| openclaw_ensure_node "$REQUESTED_NODE_VERSION" | |
| - name: Setup pnpm | |
| uses: ./.ci-harness/.github/actions/setup-pnpm-store-cache | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| node-version: 22.x | |
| - name: Runtime versions | |
| run: | | |
| node -v | |
| npm -v | |
| pnpm -v | |
| - name: Capture node path | |
| run: | | |
| node_bin="$(dirname "$(node -p 'process.execPath')")" | |
| if command -v cygpath >/dev/null 2>&1; then | |
| node_bin="$(cygpath -u "$node_bin")" | |
| fi | |
| echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV" | |
| - name: Install dependencies | |
| env: | |
| CI: true | |
| run: | | |
| export PATH="$NODE_BIN:$PATH" | |
| which node | |
| node -v | |
| pnpm -v | |
| # Persist Windows-native postinstall outputs in the pnpm store so restored | |
| # caches can skip repeated rebuild/download work on later shards/runs. | |
| pnpm install --frozen-lockfile --prefer-offline --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 }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| case "$TASK" in | |
| # Linux owns the full repo test suite. Keep the Windows runner focused on | |
| # Windows-native process/path wrappers so platform regressions fail fast. | |
| test-1) | |
| if node -e 'process.exit(require("./package.json").scripts?.["test:windows:ci:1"] ? 0 : 1)'; then | |
| pnpm test:windows:ci:1 | |
| 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 | |
| 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 }} | |
| needs: [preflight] | |
| if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }} | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'macos-15' || (github.event_name == 'workflow_dispatch' || 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 | |
| matrix: ${{ fromJson(needs.preflight.outputs.macos_node_matrix) }} | |
| steps: | |
| - *platform_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| - name: TS tests (macOS) | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| OPENCLAW_VITEST_MAX_WORKERS: 2 | |
| TASK: ${{ matrix.task }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| case "$TASK" in | |
| test) | |
| # Linux owns the full repo test suite. Keep macOS CI focused on | |
| # launchd/Homebrew/runtime path coverage and the process-group wrapper. | |
| pnpm test:macos:ci | |
| ;; | |
| *) | |
| echo "Unsupported macOS node task: $TASK" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| macos-swift: | |
| permissions: | |
| contents: read | |
| name: "macos-swift" | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_macos_swift == 'true' | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'macos-26' || (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} | |
| # Hosted paths include untrusted first-attempt PRs, so their timeout must | |
| # follow the runner rather than inheriting Blacksmith's tighter budget. | |
| timeout-minutes: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || github.event_name == 'workflow_dispatch' || 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))) && 30 || 20 }} | |
| env: | |
| HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }} | |
| SWIFT_TEST_EXECUTION: ${{ (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'serial' || 'parallel' }} | |
| steps: | |
| - *platform_checkout_step | |
| - name: Install XcodeGen / SwiftLint / SwiftFormat | |
| run: | | |
| if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then | |
| swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools" | |
| ./scripts/install-xcodegen.sh "$swift_tools_dir" | |
| ./scripts/install-swift-tools.sh "$swift_tools_dir" | |
| echo "$swift_tools_dir" >> "$GITHUB_PATH" | |
| "$swift_tools_dir/xcodegen" --version | |
| "$swift_tools_dir/swiftformat" --version | |
| "$swift_tools_dir/swiftlint" version | |
| elif [[ "$HISTORICAL_TARGET" == "true" ]]; then | |
| # Frozen release targets before the pinned installer used one of these | |
| # reviewed formatter contracts. Fail closed for any unknown minimum. | |
| brew update | |
| brew install xcodegen swiftlint | |
| swiftformat_min_version="$(awk '$1 == "--min-version" { print $2; exit }' config/swiftformat)" | |
| case "$swiftformat_min_version" in | |
| ""|0.61.1) | |
| swiftformat_version="0.61.1" | |
| swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584" | |
| ;; | |
| 0.62.1) | |
| swiftformat_version="0.62.1" | |
| swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a" | |
| ;; | |
| *) | |
| echo "Unsupported frozen-target SwiftFormat minimum: $swiftformat_min_version" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| swiftformat_archive="$RUNNER_TEMP/swiftformat-$swiftformat_version.zip" | |
| swift_tools_dir="$RUNNER_TEMP/openclaw-legacy-swift-tools" | |
| curl --fail --location --silent --show-error \ | |
| --connect-timeout 10 --max-time 120 \ | |
| --retry 3 --retry-max-time 120 \ | |
| --output "$swiftformat_archive" \ | |
| "https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip" | |
| if [[ "$(shasum -a 256 "$swiftformat_archive" | awk '{print $1}')" != "$swiftformat_checksum" ]]; then | |
| echo "SwiftFormat $swiftformat_version archive checksum mismatch" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p "$swift_tools_dir" | |
| unzip -q "$swiftformat_archive" -d "$swift_tools_dir" | |
| chmod +x "$swift_tools_dir/swiftformat" | |
| echo "$swift_tools_dir" >> "$GITHUB_PATH" | |
| [[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]] | |
| else | |
| echo "Current CI targets must provide scripts/install-xcodegen.sh and scripts/install-swift-tools.sh." >&2 | |
| exit 1 | |
| fi | |
| - name: Detect Swift toolchain cache key | |
| id: swift-toolchain | |
| run: | | |
| set -euo pipefail | |
| xcode_version="$(xcodebuild -version | tr '\n' ' ' | sed 's/ */ /g; s/ $//')" | |
| swift_version="$(swift --version | head -n 1)" | |
| toolchain_key="$(printf '%s\n%s\n' "$xcode_version" "$swift_version" | shasum -a 256 | awk '{print $1}')" | |
| echo "key=$toolchain_key" >> "$GITHUB_OUTPUT" | |
| - name: 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: needs.preflight.outputs.cache_mode != 'off' | |
| uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 | |
| with: | |
| path: apps/macos/.build | |
| # Exact hits are immutable; exclude pre-native products from both restore paths. | |
| key: ${{ runner.os }}-swift-build-v4-native-tests-${{ steps.swift-toolchain.outputs.key }}-${{ hashFiles('apps/macos/Package.swift', 'apps/macos/Package.resolved', 'apps/macos/Sources/**', 'apps/macos/Tests/**', 'apps/shared/OpenClawKit/Package.swift', 'apps/shared/OpenClawKit/Sources/**', 'apps/swabble/Package.swift', 'apps/swabble/Sources/**') }} | |
| restore-keys: | | |
| ${{ runner.os }}-swift-build-v4-native-tests-${{ steps.swift-toolchain.outputs.key }}- | |
| - name: Validate Swift build cache | |
| id: validate-swift-build-cache | |
| run: | | |
| set -euo pipefail | |
| cache_valid=true | |
| sparkle_info="apps/macos/.build/artifacts/sparkle/Sparkle/Sparkle.xcframework/Info.plist" | |
| if [[ -d apps/macos/.build && ! -f "$sparkle_info" ]]; then | |
| echo "::warning::Swift build cache is missing Sparkle; resetting the local SwiftPM build directory." | |
| swift package --package-path apps/macos reset | |
| cache_valid=false | |
| fi | |
| echo "cache-valid=$cache_valid" >> "$GITHUB_OUTPUT" | |
| - name: Preserve Swift build cache hit | |
| if: steps.swift-build-cache.outputs.cache-hit == 'true' && steps.validate-swift-build-cache.outputs.cache-valid == 'true' | |
| run: | | |
| set -euo pipefail | |
| # Exact source-hash cache hits already match these inputs; checkout | |
| # mtimes are the only reason SwiftPM rebuilds cached products. | |
| find apps/macos/Sources apps/macos/Tests apps/shared/OpenClawKit/Sources apps/swabble/Sources apps/macos/.build/checkouts \ | |
| -type f -exec touch -t 200001010000 {} + | |
| touch -t 200001010000 \ | |
| apps/macos/Package.swift \ | |
| apps/macos/Package.resolved \ | |
| apps/shared/OpenClawKit/Package.swift \ | |
| apps/swabble/Package.swift | |
| - name: Show toolchain | |
| run: | | |
| sw_vers | |
| xcodebuild -version | |
| swift --version | |
| - name: Native state schema version contract | |
| 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 | |
| run: | | |
| if [[ -x ./scripts/lint-swift.sh && -x ./scripts/format-swift.sh ]]; then | |
| ./scripts/lint-swift.sh macos | |
| ./scripts/format-swift.sh macos | |
| elif [[ "$HISTORICAL_TARGET" == "true" ]]; then | |
| # Frozen release targets before the shared wrappers used these commands directly. | |
| swiftlint lint --config config/swiftlint.yml | |
| swiftformat --lint apps/macos/Sources --config config/swiftformat --exclude '**/OpenClawProtocol,**/HostEnvSecurityPolicy.generated.swift' | |
| else | |
| echo "Current CI targets must provide the Swift lint and format wrappers." >&2 | |
| exit 1 | |
| fi | |
| - name: Swift build (release) | |
| run: | | |
| set -euo pipefail | |
| # 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) | |
| 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 | |
| # openclawkit-tests-contract-v1: the target owns an independently runnable package suite. | |
| - name: OpenClawKit tests | |
| if: 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 | |
| swift test \ | |
| --package-path apps/shared/OpenClawKit \ | |
| --scratch-path "$openclawkit_scratch" \ | |
| --parallel | |
| - name: Swift test | |
| id: swift-test | |
| run: | | |
| set -euo pipefail | |
| # 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. | |
| swift_test_args=(--package-path apps/macos --build-system native --enable-code-coverage) | |
| swift build "${swift_test_args[@]}" --build-tests | |
| echo "debug-tests-built=true" >> "$GITHUB_OUTPUT" | |
| swift_test_args+=(--skip-build) | |
| if [[ "$SWIFT_TEST_EXECUTION" == "parallel" ]]; then | |
| swift_test_args+=(--parallel) | |
| else | |
| swift_test_args+=(--no-parallel) | |
| fi | |
| swift test "${swift_test_args[@]}" | |
| - name: Render isolated macOS health fixtures | |
| id: health-render | |
| if: ${{ !cancelled() && 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: Save SwiftPM cache | |
| if: needs.preflight.outputs.cache_write_allowed == 'true' && steps.swiftpm-cache.outputs.cache-hit != '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: Save Swift build directory cache | |
| if: needs.preflight.outputs.cache_write_allowed == 'true' && steps.swift-build-cache.outputs.cache-hit != 'true' | |
| 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: "ios-build" | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_ios_build == 'true' | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'macos-26' || (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} | |
| timeout-minutes: 150 | |
| env: | |
| HISTORICAL_TARGET: ${{ needs.preflight.outputs.compatibility_target }} | |
| steps: | |
| - *platform_checkout_step | |
| - name: Select Xcode 26 | |
| run: | | |
| set -euo pipefail | |
| for xcode_app in /Applications/Xcode_26.6.app /Applications/Xcode-26.6.0.app; do | |
| if [ -d "$xcode_app/Contents/Developer" ]; then | |
| sudo xcode-select -s "$xcode_app/Contents/Developer" | |
| break | |
| fi | |
| done | |
| xcodebuild -version | |
| xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')" | |
| if [[ "$xcode_version" != 26.6* ]]; then | |
| echo "error: expected Xcode 26.6, got $xcode_version" >&2 | |
| exit 1 | |
| fi | |
| swift --version | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| - name: Install iOS Swift tooling | |
| run: | | |
| if [[ -x ./scripts/install-xcodegen.sh && -x ./scripts/install-swift-tools.sh ]]; then | |
| swift_tools_dir="$RUNNER_TEMP/openclaw-swift-tools" | |
| ./scripts/install-xcodegen.sh "$swift_tools_dir" | |
| ./scripts/install-swift-tools.sh "$swift_tools_dir" | |
| echo "$swift_tools_dir" >> "$GITHUB_PATH" | |
| "$swift_tools_dir/xcodegen" --version | |
| "$swift_tools_dir/swiftformat" --version | |
| "$swift_tools_dir/swiftlint" version | |
| elif [[ "$HISTORICAL_TARGET" == "true" ]]; then | |
| # The generated Xcode project runs SwiftFormat during the build, so | |
| # frozen targets must keep the formatter contract they were authored for. | |
| brew update | |
| brew install xcodegen swiftlint | |
| swiftformat_min_version="$(awk '$1 == "--min-version" { print $2; exit }' config/swiftformat)" | |
| case "$swiftformat_min_version" in | |
| ""|0.61.1) | |
| swiftformat_version="0.61.1" | |
| swiftformat_checksum="b990400779aceb7d7020796eb9ba814d4480543f671d38fc0ff48cb72f04c584" | |
| ;; | |
| 0.62.1) | |
| swiftformat_version="0.62.1" | |
| swiftformat_checksum="7cb1cb1fae04932047c7015441c543848e8e60e1572d808d080e0a1f1661114a" | |
| ;; | |
| *) | |
| echo "Unsupported frozen-target SwiftFormat minimum: $swiftformat_min_version" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| swiftformat_archive="$RUNNER_TEMP/swiftformat-$swiftformat_version.zip" | |
| swift_tools_dir="$RUNNER_TEMP/openclaw-legacy-swift-tools" | |
| curl --fail --location --silent --show-error \ | |
| --connect-timeout 10 --max-time 120 \ | |
| --retry 3 --retry-max-time 120 \ | |
| --output "$swiftformat_archive" \ | |
| "https://github.com/nicklockwood/SwiftFormat/releases/download/$swiftformat_version/swiftformat.zip" | |
| if [[ "$(shasum -a 256 "$swiftformat_archive" | awk '{print $1}')" != "$swiftformat_checksum" ]]; then | |
| echo "SwiftFormat $swiftformat_version archive checksum mismatch" >&2 | |
| exit 1 | |
| fi | |
| mkdir -p "$swift_tools_dir" | |
| unzip -q "$swiftformat_archive" -d "$swift_tools_dir" | |
| chmod +x "$swift_tools_dir/swiftformat" | |
| echo "$swift_tools_dir" >> "$GITHUB_PATH" | |
| [[ "$("$swift_tools_dir/swiftformat" --version)" == "$swiftformat_version" ]] | |
| # Legacy generated Xcode phases prepend Homebrew ahead of GITHUB_PATH. | |
| # Point that lookup at the verified binary or the build can bypass the pin. | |
| swiftformat_link="$(brew --prefix)/bin/swiftformat" | |
| ln -sfn "$swift_tools_dir/swiftformat" "$swiftformat_link" | |
| [[ "$("$swiftformat_link" --version)" == "$swiftformat_version" ]] | |
| else | |
| echo "Current CI targets must provide scripts/install-xcodegen.sh and scripts/install-swift-tools.sh." >&2 | |
| exit 1 | |
| fi | |
| - name: Swift lint | |
| run: | | |
| if [[ -x ./scripts/lint-swift.sh && -x ./scripts/format-swift.sh ]]; then | |
| ./scripts/lint-swift.sh ios | |
| ./scripts/format-swift.sh ios | |
| else | |
| # Frozen release targets before the iOS lint lane have no equivalent target-owned step. | |
| echo "Swift lint wrappers are absent; skipping iOS lint for this frozen target." | |
| fi | |
| - name: Build iOS app | |
| run: pnpm ios:build | |
| # Debug's incremental compilation can miss actor-isolation diagnostics | |
| # that Swift's optimized whole-module Release build enforces. | |
| - name: Build iOS app (Release) | |
| if: env.HISTORICAL_TARGET != 'true' | |
| run: | | |
| xcodebuild \ | |
| -project apps/ios/OpenClaw.xcodeproj \ | |
| -scheme OpenClaw \ | |
| -configuration Release \ | |
| -destination "generic/platform=iOS" \ | |
| CODE_SIGNING_ALLOWED=NO \ | |
| build | |
| # App compilation and screenshots do not execute approval or notification | |
| # lifecycles. Exercise both owners on the already provisioned simulator. | |
| - name: Run focused iOS lifecycle simulator tests | |
| id: ios_lifecycle_tests | |
| if: env.HISTORICAL_TARGET != 'true' | |
| run: | | |
| set -euo pipefail | |
| 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("iPhone")); | |
| if (!simulator) { | |
| console.error("No available iPhone simulator for iOS lifecycle tests"); | |
| process.exit(1); | |
| } | |
| process.stdout.write(simulator.udid); | |
| ' | |
| )" | |
| result_bundle="apps/ios/build/LifecycleTestResults/OpenClawLifecycleTests.xcresult" | |
| mkdir -p "apps/ios/build/LifecycleTestResults" | |
| xcodebuild \ | |
| -project apps/ios/OpenClaw.xcodeproj \ | |
| -scheme OpenClaw \ | |
| -configuration Debug \ | |
| -destination "platform=iOS Simulator,id=${simulator_id}" \ | |
| -resultBundlePath "$result_bundle" \ | |
| -parallel-testing-enabled NO \ | |
| -only-testing:OpenClawTests/DelayedActionGateTests \ | |
| -only-testing:OpenClawTests/NodeAppModelInvokeTests \ | |
| -only-testing:OpenClawTests/NotificationServingPreferenceTests \ | |
| -only-testing:OpenClawTests/RootTabsSourceGuardTests \ | |
| -only-testing:OpenClawTests/TraceHeadingVisualProofTests \ | |
| test | |
| - name: Run focused Apple Watch operation simulator tests | |
| if: env.HISTORICAL_TARGET != 'true' | |
| run: | | |
| set -euo pipefail | |
| 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); | |
| ' | |
| )" | |
| derived_data_path="$RUNNER_TEMP/openclaw-watch-lifecycle" | |
| xcodebuild \ | |
| -project apps/ios/OpenClaw.xcodeproj \ | |
| -scheme OpenClawWatchApp \ | |
| -configuration Debug \ | |
| -destination "platform=watchOS Simulator,id=${simulator_id}" \ | |
| -derivedDataPath "$derived_data_path" \ | |
| -parallel-testing-enabled NO \ | |
| -only-testing:OpenClawWatchTests/WatchInboxStoreOperationTests \ | |
| CODE_SIGNING_ALLOWED=NO \ | |
| build-for-testing | |
| xcrun simctl boot "$simulator_id" 2>/dev/null || true | |
| xcrun simctl bootstatus "$simulator_id" -b | |
| xcrun simctl install \ | |
| "$simulator_id" \ | |
| "$derived_data_path/Build/Products/Debug-watchsimulator/OpenClawWatchApp.app" | |
| xcodebuild \ | |
| -project apps/ios/OpenClaw.xcodeproj \ | |
| -scheme OpenClawWatchApp \ | |
| -configuration Debug \ | |
| -destination "platform=watchOS Simulator,id=${simulator_id}" \ | |
| -derivedDataPath "$derived_data_path" \ | |
| -resultBundlePath apps/ios/build/LifecycleTestResults/OpenClawWatchOperationTests.xcresult \ | |
| -parallel-testing-enabled NO \ | |
| -only-testing:OpenClawWatchTests/WatchInboxStoreOperationTests \ | |
| CODE_SIGNING_ALLOWED=NO \ | |
| test-without-building | |
| - name: Upload iOS lifecycle simulator evidence | |
| if: ${{ always() && steps.ios_lifecycle_tests.outcome != '' && steps.ios_lifecycle_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 | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| ios-screenshot-shard: | |
| permissions: | |
| contents: read | |
| name: "ios-screenshots-${{ matrix.device_family }}" | |
| # Each shard builds its own simulator app; ios-build produces no input here. | |
| # The final CI gate still requires both build and screenshot evidence. | |
| needs: [preflight] | |
| # Full manual/release validation always captures. PRs and their exact-head | |
| # release-gate substitutes use the same conservative screenshot-risk scope. | |
| if: ${{ ((github.event_name == 'workflow_dispatch' && (!inputs.release_gate || needs.preflight.outputs.run_ios_screenshots == 'true')) || (github.event_name == 'pull_request' && needs.preflight.outputs.run_ios_screenshots == 'true')) && needs.preflight.outputs.compatibility_target != 'true' }} | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'macos-26' || (github.event_name == 'workflow_dispatch' || github.run_attempt > 1) && 'macos-26' || (github.repository == 'openclaw/openclaw' && (github.event_name != 'pull_request' || contains(fromJSON('["OWNER","MEMBER","COLLABORATOR","CONTRIBUTOR"]'), github.event.pull_request.author_association)) && 'blacksmith-12vcpu-macos-26' || 'macos-26') }} | |
| 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@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1 | |
| with: | |
| ruby-version: "3.4.10" | |
| bundler: "2.6.9" | |
| bundler-cache: false | |
| working-directory: apps/ios | |
| - name: Install locked Fastlane bundle | |
| working-directory: apps/ios | |
| run: | | |
| bundle _2.6.9_ install --jobs 4 --retry 3 | |
| bundle _2.6.9_ check | |
| bundle _2.6.9_ exec fastlane --version | |
| - name: Select Xcode 26 | |
| run: | | |
| set -euo pipefail | |
| for xcode_app in /Applications/Xcode_26.6.app /Applications/Xcode-26.6.0.app; do | |
| if [ -d "$xcode_app/Contents/Developer" ]; then | |
| sudo xcode-select -s "$xcode_app/Contents/Developer" | |
| break | |
| fi | |
| done | |
| xcodebuild -version | |
| xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')" | |
| if [[ "$xcode_version" != 26.6* ]]; then | |
| echo "error: expected Xcode 26.6, got $xcode_version" >&2 | |
| exit 1 | |
| fi | |
| swift --version | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.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 | |
| - name: Capture iOS device screenshot shard | |
| id: device_screenshots | |
| env: | |
| OPENCLAW_SNAPSHOT_DEVICE_FAMILY: ${{ matrix.device_family }} | |
| OPENCLAW_SNAPSHOT_SKIP_WATCH: "1" | |
| run: pnpm ios:screenshots | |
| - name: Capture Apple Watch screenshot | |
| id: watch_screenshot | |
| if: matrix.device_family == 'iphone' | |
| run: | | |
| source scripts/lib/ios-fastlane.sh | |
| ( | |
| cd apps/ios | |
| run_ios_fastlane ios watch_screenshot | |
| ) | |
| - 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: ${{ needs.preflight.outputs.checkout_revision }} | |
| 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 scripts/ios-screenshot-evidence.mjs "${collect_args[@]}" | |
| if [[ "$DEVICE_FAMILY" == "iphone" ]]; then | |
| collect_args[2]="watch" | |
| node 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.watch_screenshot.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 | |
| if-no-files-found: warn | |
| retention-days: 14 | |
| ios-screenshot-evidence: | |
| permissions: | |
| contents: read | |
| name: "ios-screenshot-evidence" | |
| needs: [preflight, ios-screenshot-shard] | |
| if: ${{ ((github.event_name == 'workflow_dispatch' && (!inputs.release_gate || needs.preflight.outputs.run_ios_screenshots == 'true')) || (github.event_name == 'pull_request' && needs.preflight.outputs.run_ios_screenshots == 'true')) && needs.preflight.outputs.compatibility_target != 'true' }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 10 | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup screenshot evidence Node | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.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: ${{ needs.preflight.outputs.checkout_revision }} | |
| WORKFLOW_SHA: ${{ github.workflow_sha }} | |
| run: | | |
| node 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 }} | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_android_job == 'true' | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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: 20 | |
| strategy: | |
| fail-fast: false | |
| max-parallel: 2 | |
| matrix: ${{ fromJson(needs.preflight.outputs.android_matrix) }} | |
| steps: | |
| - name: Checkout | |
| shell: bash | |
| env: | |
| CHECKOUT_KIND: android | |
| CHECKOUT_REPO: ${{ github.repository }} | |
| CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }} | |
| run: *owned_checkout_run | |
| - 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 | |
| # Frozen targets keep their Gradle task contract, but CI toolchain pins remain | |
| # workflow-owned as before. Load them from the workflow revision so old targets work. | |
| uses: ./.ci-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: ${{ needs.preflight.outputs.cache_mode }} | |
| install-bun: "false" | |
| # Same-repo runs carry the Gradle user home (dependency, wrapper, and | |
| # build caches) on a Blacksmith sticky disk: the setup-java gradle cache | |
| # above is evicted so quickly under the repo quota that it rarely | |
| # restores, leaving every run to re-resolve the full dependency graph. | |
| # Fork PRs, manual dispatches, the GitHub backend, and hosted hybrid retries must never produce writable | |
| # repository-global snapshots, so they keep the GitHub cache path (their | |
| # runs-on already selects non-Blacksmith ubuntu-24.04 runners). | |
| - name: Mount Gradle sticky disk | |
| if: vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && !(vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && 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@35ba2e331a80056a42af053ee54968511de2b7c7 # v1.5.0 | |
| with: | |
| # One stable disk per matrix task. The v1 per-PR/per-dependency-hash | |
| # keys minted a new backing disk for every PR and dependency bump | |
| # and helped saturate Blacksmith's installation-wide sticky-disk | |
| # budget, 429-failing every mount. Task scope stays in the key: each | |
| # matrix task resolves a different dependency set, and a shared key | |
| # would let a light task (ktlint) pin a thin snapshot the heavy | |
| # build lanes can never repair. Dependency-file changes live in the | |
| # runtime fingerprint marker below instead of the key. | |
| key: ${{ github.repository }}-gradle-v2-${{ matrix.task }} | |
| path: /var/tmp/openclaw-gradle | |
| # Single semantic writer: only protected pushes commit, so | |
| # pull_request clones stay read-only and the snapshot tracks main. | |
| # Explicit true (not on-change) because the allocated-byte heuristic | |
| # can miss a same-size refresh and strand the fingerprint marker; | |
| # v1.4.0 still skips commit after failed/cancelled steps, so a | |
| # broken build cannot poison this key. | |
| commit: ${{ github.event_name != 'pull_request' && 'true' || 'false' }} | |
| - name: Point Gradle at the sticky disk | |
| if: vars.OPENCLAW_CI_RUNNER_BACKEND != 'github' && !(vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && 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') | |
| shell: bash | |
| env: | |
| # Gradle caches are content-addressed, so a stale snapshot is safe | |
| # to restore (obsolete entries are simply ignored). The fingerprint | |
| # only bounds disk growth: when dependency inputs change, the writer | |
| # rebuilds its snapshot from scratch so retired dependency artifacts | |
| # do not accumulate on the O(1) key forever. | |
| GRADLE_DEPS_FINGERPRINT: ${{ hashFiles('apps/android/**/*.gradle*', 'apps/android/**/gradle-wrapper.properties', 'apps/android/gradle/libs.versions.toml') }} | |
| STICKY_WRITER: ${{ github.event_name != 'pull_request' && 'true' || 'false' }} | |
| run: | | |
| set -euo pipefail | |
| sticky_root=/var/tmp/openclaw-gradle | |
| marker="$sticky_root/.openclaw-gradle-deps-fingerprint" | |
| disk_fingerprint="" | |
| if [ -f "$marker" ]; then | |
| disk_fingerprint="$(<"$marker")" | |
| fi | |
| if [ "$disk_fingerprint" != "$GRADLE_DEPS_FINGERPRINT" ]; then | |
| if [ "$STICKY_WRITER" = "true" ]; then | |
| echo "Gradle dependency inputs changed; rebuilding snapshot cold to drop retired artifacts" | |
| rm -rf "$sticky_root/gradle-user-home" | |
| printf '%s\n' "$GRADLE_DEPS_FINGERPRINT" > "$marker" | |
| else | |
| echo "Gradle dependency inputs changed since snapshot; content-addressed caches stay safe to reuse" | |
| fi | |
| fi | |
| mkdir -p "$sticky_root/gradle-user-home" | |
| echo "GRADLE_USER_HOME=$sticky_root/gradle-user-home" >> "$GITHUB_ENV" | |
| - name: 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' | |
| allprojects { | |
| tasks.withType(org.gradle.api.tasks.testing.Test).configureEach { | |
| systemProperty "maven.repo.local", System.getenv("OPENCLAW_ROBOLECTRIC_M2") | |
| } | |
| } | |
| 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: | |
| CI_RUNNER_BACKEND: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && 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')) && 'github' || 'blacksmith' }} | |
| TASK: ${{ matrix.task }} | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| case "$TASK" in | |
| test-play) | |
| ./gradlew --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 --no-daemon --build-cache --init-script "$OPENCLAW_ROBOLECTRIC_INIT" \ | |
| :app:testThirdPartyDebugUnitTest | |
| ;; | |
| test-wear) | |
| ./gradlew --no-daemon --build-cache --init-script "$OPENCLAW_ROBOLECTRIC_INIT" \ | |
| :wear:testDebugUnitTest | |
| ;; | |
| build-play) | |
| if [ "$CI_RUNNER_BACKEND" = "github" ] || [ "$GITHUB_EVENT_NAME" = "workflow_dispatch" ]; 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`; keeps formatting drift out of main (see PR #100304 sweep). | |
| ./gradlew --no-daemon --build-cache \ | |
| :app:ktlintCheck \ | |
| :benchmark:ktlintCheck \ | |
| :wear:ktlintCheck \ | |
| :wear-shared:ktlintCheck | |
| ;; | |
| *) | |
| echo "Unsupported Android task: $TASK" >&2 | |
| exit 1 | |
| ;; | |
| esac | |
| - 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 }} | |
| docker-seed-e2e: | |
| permissions: | |
| contents: read | |
| name: docker-seed-e2e | |
| needs: [preflight] | |
| if: needs.preflight.outputs.run_docker_seed_e2e == 'true' | |
| runs-on: ${{ vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' && 'ubuntu-24.04' || (vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' && github.run_attempt > 1) && 'ubuntu-24.04' || github.event_name == 'workflow_dispatch' && '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') }} | |
| timeout-minutes: 60 | |
| steps: | |
| - *linux_node_checkout_step | |
| - name: Setup Node environment | |
| uses: ./.ci-harness/.github/actions/setup-node-env | |
| with: | |
| cache-mode: ${{ needs.preflight.outputs.cache_mode }} | |
| node-version: "24.x" | |
| install-bun: "false" | |
| dependency-cache: ${{ (vars.OPENCLAW_CI_RUNNER_BACKEND == 'github' || vars.OPENCLAW_CI_RUNNER_BACKEND == 'hybrid' || github.event_name == 'workflow_dispatch' || (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') }} | |
| - name: Run changed Docker seed owner lanes | |
| 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_DOCKER_ALL_PARALLELISM: ${{ 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: ${{ 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 }} | |
| run: pnpm test:docker:all | |
| ci-gate: | |
| permissions: | |
| contents: read | |
| name: openclaw/ci-gate | |
| needs: | |
| - preflight | |
| - security-fast | |
| - pnpm-store-warmup | |
| - build-artifacts | |
| - sqlite-session-lifecycle | |
| - native-i18n | |
| - checks-ui | |
| - checks-ui-e2e | |
| - checks-ui-e2e-real-gateway | |
| - control-ui-i18n | |
| - checks-fast-core | |
| - qa-smoke-ci-profile | |
| - checks-fast-plugin-contracts-shard | |
| - checks-fast-channel-contracts-shard | |
| - checks-node-compat | |
| - checks-node-core-test-nondist-shard | |
| - check-shard | |
| - check-lint-hosted-core-shard | |
| - check-test-types-hosted-core-shard | |
| - check-additional-shard | |
| - check-docs | |
| - skills-python | |
| - checks-windows | |
| - macos-node | |
| - macos-swift | |
| - ios-build | |
| - ios-screenshot-shard | |
| - ios-screenshot-evidence | |
| - android | |
| - docker-seed-e2e | |
| if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }} | |
| runs-on: ubuntu-24.04 | |
| timeout-minutes: 5 | |
| steps: | |
| - name: Verify selected CI lanes | |
| shell: bash | |
| env: | |
| REQUIRED_RESULTS: | | |
| preflight=${{ needs.preflight.result }} | |
| security-fast=${{ needs.security-fast.result }} | |
| SELECTED_RESULTS: | | |
| pnpm-store-warmup=${{ needs.pnpm-store-warmup.result }} | |
| build-artifacts=${{ needs.build-artifacts.result }} | |
| sqlite-session-lifecycle=${{ needs.sqlite-session-lifecycle.result }} | |
| native-i18n=${{ needs.native-i18n.result }} | |
| checks-ui=${{ needs.checks-ui.result }} | |
| checks-ui-e2e=${{ needs.checks-ui-e2e.result }} | |
| checks-ui-e2e-real-gateway=${{ needs.checks-ui-e2e-real-gateway.result }} | |
| control-ui-i18n=${{ needs.control-ui-i18n.result }} | |
| checks-fast-core=${{ needs.checks-fast-core.result }} | |
| qa-smoke-ci-profile=${{ needs.qa-smoke-ci-profile.result }} | |
| checks-fast-plugin-contracts-shard=${{ needs.checks-fast-plugin-contracts-shard.result }} | |
| checks-fast-channel-contracts-shard=${{ needs.checks-fast-channel-contracts-shard.result }} | |
| checks-node-compat=${{ needs.checks-node-compat.result }} | |
| checks-node-core-test-nondist-shard=${{ needs.checks-node-core-test-nondist-shard.result }} | |
| check-shard=${{ needs.check-shard.result }} | |
| check-lint-hosted-core-shard=${{ needs.check-lint-hosted-core-shard.result }} | |
| check-test-types-hosted-core-shard=${{ needs.check-test-types-hosted-core-shard.result }} | |
| check-additional-shard=${{ needs.check-additional-shard.result }} | |
| check-docs=${{ needs.check-docs.result }} | |
| skills-python=${{ needs.skills-python.result }} | |
| checks-windows=${{ needs.checks-windows.result }} | |
| macos-node=${{ needs.macos-node.result }} | |
| macos-swift=${{ needs.macos-swift.result }} | |
| ios-build=${{ needs.ios-build.result }} | |
| ios-screenshot-shard=${{ needs.ios-screenshot-shard.result }} | |
| ios-screenshot-evidence=${{ needs.ios-screenshot-evidence.result }} | |
| android=${{ needs.android.result }} | |
| docker-seed-e2e=${{ needs.docker-seed-e2e.result }} | |
| run: | | |
| set -euo pipefail | |
| failures=0 | |
| while IFS= read -r entry; do | |
| [[ -n "$entry" ]] || continue | |
| name="${entry%%=*}" | |
| result="${entry#*=}" | |
| echo "${name}: ${result}" | |
| if [[ "$result" != "success" ]]; then | |
| echo "::error title=Required CI job did not succeed::${name} finished with ${result}" | |
| failures=1 | |
| fi | |
| done <<< "$REQUIRED_RESULTS" | |
| while IFS= read -r entry; do | |
| [[ -n "$entry" ]] || continue | |
| name="${entry%%=*}" | |
| result="${entry#*=}" | |
| echo "${name}: ${result}" | |
| case "$result" in | |
| success | skipped) ;; | |
| *) | |
| echo "::error title=Selected CI job did not succeed::${name} finished with ${result}" | |
| failures=1 | |
| ;; | |
| esac | |
| done <<< "$SELECTED_RESULTS" | |
| exit "$failures" |