Compare commits

..

1 Commits

Author SHA1 Message Date
Sarah Fortune
074f699c54 fix(onboard): skip config-handling prompt when no key settings detected 2026-05-12 17:16:28 -07:00
5756 changed files with 67001 additions and 407132 deletions

View File

@@ -1,146 +0,0 @@
---
name: autoreview
description: "Autoreview closeout: local dirty changes, PR branch vs main, parallel tests."
---
# Autoreview
Run Codex's built-in code review as a closeout check. This is code review (`codex review`), not Guardian `auto_review` approval routing.
Codex native review mode performs best and is recommended. Non-Codex reviewers are fallback/second-opinion paths that receive a generated diff prompt, not the full Codex review-mode runtime.
Use when:
- user asks for Codex review / autoreview / second-model review
- after non-trivial code edits, before final/commit/ship
- reviewing a local branch or PR branch after fixes
## Contract
- Treat review output as advisory. Never blindly apply it.
- Verify every finding by reading the real code path and adjacent files.
- Read dependency docs/source/types when the finding depends on external behavior.
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
- Keep going until the selected review path returns no accepted/actionable findings.
- If a review-triggered fix changes code, rerun focused tests and rerun the review helper.
- Default to Codex review with no fallback. Prefer Codex for final closeout because it uses native review mode; non-Codex reviewers use a Codex-inspired generated diff prompt. Use `--fallback-reviewer auto|claude|pi|opencode|droid|copilot` only when a second-model fallback is explicitly wanted and authenticated. The helper runs nested Codex review in yolo/full-access mode by default; use `--no-yolo` only when intentionally testing sandbox behavior.
- Stop as soon as the review command/helper exits 0 with no accepted/actionable findings. Do not run an extra direct `codex review` just to get a nicer "clean" line, a second opinion, or clearer closeout wording.
- Treat the helper's successful exit plus absence of actionable findings as the clean review result, even if the underlying Codex CLI output is terse.
- If rejecting a finding as intentional/not worth fixing, add a brief inline code comment only when it explains a real invariant or ownership decision that future reviewers should know.
- If creating or updating a PR while rejecting any autoreview finding, record the rejected finding and reason in the PR description so later reviewers can distinguish intentional design decisions from missed review output.
- Do not push just to review. Push only when the user requested push/ship/PR update.
- For OpenClaw maintainers, keep autoreview validation Crabbox/Testbox-aware when maintainer validation mode is enabled (`OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`). A review pass may inspect files and run cheap non-Node probes, but it must not start local `pnpm`, Vitest, `tsgo`, `npm test`, or `node scripts/run-vitest.mjs` from a Codex/worktree review unless the operator explicitly requested local proof. For runtime proof, use existing evidence or route through Crabbox/Testbox and report the id. Do not apply this rule to ordinary contributors who do not have maintainer Testbox access.
## Pick Target
Dirty local work:
```bash
codex review --uncommitted
```
Use this only when the patch is actually unstaged/staged/untracked in the
current checkout. For committed, pushed, or PR work, point Codex at the commit
or branch diff instead; do not force `--mode local` / `--uncommitted` just
because the helper docs mention dirty work first. A clean `--uncommitted` review
only proves there is no local patch.
Branch/PR work:
```bash
git fetch origin
codex review --base origin/main
```
Do not pass any prompt with `--base`, `--commit`, or `--uncommitted`. Codex CLI
review targets and custom review prompts are mutually exclusive: target modes
generate their own review prompt internally. Use plain target review for native
Codex closeout, or use custom prompt review (`codex review -`) only when you
intentionally want a generated diff prompt instead of native target review.
If an open PR exists, use its actual base:
```bash
base=$(gh pr view --json baseRefName --jq .baseRefName)
codex review --base "origin/$base"
```
Committed single change:
```bash
codex review --commit HEAD
```
or with the helper:
```bash
.agents/skills/autoreview/scripts/autoreview --mode commit --commit HEAD
```
Use commit review for already-landed or already-pushed work on `main`. Reviewing
clean `main` against `origin/main` is usually an empty diff after push. For a
small stack, review each commit explicitly or review the branch before merging
with `--base`.
## Parallel Closeout
Format first if formatting can change line locations. Then it is OK to run tests and review in parallel:
```bash
.agents/skills/autoreview/scripts/autoreview --parallel-tests "<focused test command>"
```
Tradeoff: tests may force code changes that stale the review. If tests or review lead to code edits, rerun the affected tests and rerun review until no accepted/actionable findings remain. Once that rerun exits cleanly, stop; do not spend another long review cycle on redundant confirmation.
## Context Efficiency
Codex review is usually noisy. Default to a subagent filter when subagents are available. Ask it to run the review and return only:
- actionable findings it accepts
- findings it rejects, with one-line reason
- exact files/tests to rerun
Run inline only for tiny changes or when subagents are unavailable.
## Helper
Bundled helper:
```bash
.agents/skills/autoreview/scripts/autoreview --help
```
The helper:
- chooses dirty `--uncommitted` first
- otherwise uses current PR base if `gh pr view` works
- otherwise uses `origin/main` for non-main branches
- auto-runs `PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check` in parallel when a repo has `package.json`, `pnpm-lock.yaml`, `node_modules`, and a `check` script; disable with `AUTOREVIEW_AUTO_TESTS=0`
- use `--mode commit --commit <ref>` for already-committed work, especially clean `main` after landing
- should be left in `--mode auto` or forced to `--mode branch` for PR/branch work; do not force `--mode local` after committing
- supports `--reviewer codex|claude|pi|opencode|droid|copilot|auto`; `auto` means Codex first
- supports `--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none`; default is `none`
- falls back only when Codex is unavailable or exits nonzero, not when Codex reports findings
- writes only to stdout unless `--output` or `AUTOREVIEW_OUTPUT` is set
- supports `--dry-run`, `--parallel-tests`, and commit refs
- runs nested review with `--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access` by default
- with `OPENCLAW_TESTBOX=1` or `AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1`, disables auto local `pnpm run check` and routes Codex through generated prompt review (`codex review -`) so the no-local-heavy-tests policy is included; native Codex target review cannot accept extra prompt text
- non-Codex reviewers receive the generated diff prompt and maintainer validation policy text when maintainer validation is active
- keeps accepting `--full-access`; use `--no-yolo` or `AUTOREVIEW_YOLO=0` to opt out
- still accepts legacy `CODEX_REVIEW_*` env vars when the matching `AUTOREVIEW_*` var is unset
- prints `autoreview clean: no accepted/actionable findings reported` when the selected review command exits 0
## Final Report
Include:
- review command used
- tests/proof run
- findings accepted/rejected, briefly why
- the clean review result from the final helper/review run, or why a remaining finding was consciously rejected
Do not run another Codex review solely to improve the final report wording. If the final helper run exited 0 and produced no accepted/actionable findings, report that exact run as clean.
## PR / CI Closeout
- Prefer direct run/job APIs after CI starts: `gh run view <run-id> --json jobs`; use PR rollup only for final mergeability.
- After rebase, compare `origin/main..HEAD`; drop CI-fix commits already upstream before pushing.
- For prompt snapshot CI failures, prove/generate with Linux Node 24 before rerunning the failed job.
- Update PR body once near the final head unless proof labels are missing or stale enough to block CI.

View File

@@ -1,698 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: autoreview [options]
Options:
--mode auto|local|branch|commit
Target selection. Default: auto.
--base REF Base ref for branch review. Default: PR base or origin/main.
--commit REF Commit ref for commit review. Default: HEAD.
--reviewer codex|claude|pi|opencode|droid|copilot|auto
Review engine. Default: Codex.
--fallback-reviewer auto|claude|pi|opencode|droid|copilot|none
Fallback when Codex is unavailable or exits nonzero. Default: none.
--codex-bin PATH Codex binary. Default: codex.
--claude-bin PATH Claude binary. Default: claude.
--pi-bin PATH Pi binary. Default: pi.
--opencode-bin PATH OpenCode binary. Default: opencode.
--droid-bin PATH Droid binary. Default: droid.
--copilot-bin PATH GitHub Copilot binary. Default: copilot.
--full-access Keep yolo/full-access mode enabled. Default.
--no-yolo Run nested Codex review with normal sandbox/approval prompts.
--output FILE Also save output to file.
--parallel-tests CMD Run review and test command concurrently.
Default: PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check when available.
--dry-run Print selected commands, do not run.
-h, --help Show help.
Environment:
OPENCLAW_TESTBOX=1 or AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION=1
Enable maintainer-only OpenClaw Crabbox/Testbox validation policy.
Modes:
local codex review --uncommitted
branch codex review --base <base>
commit codex review --commit <commit>
auto dirty tree -> local, else PR/current branch -> branch
EOF
}
mode=auto
base_ref=
commit_ref=HEAD
reviewer=${AUTOREVIEW_REVIEWER:-${CODEX_REVIEW_REVIEWER:-auto}}
fallback_reviewer=${AUTOREVIEW_FALLBACK_REVIEWER:-${CODEX_REVIEW_FALLBACK_REVIEWER:-none}}
codex_bin=${CODEX_BIN:-codex}
claude_bin=${CLAUDE_BIN:-claude}
pi_bin=${PI_BIN:-pi}
opencode_bin=${OPENCODE_BIN:-opencode}
droid_bin=${DROID_BIN:-droid}
copilot_bin=${COPILOT_BIN:-copilot}
codex_args=()
yolo=${AUTOREVIEW_YOLO:-${CODEX_REVIEW_YOLO:-1}}
output=${AUTOREVIEW_OUTPUT:-${CODEX_REVIEW_OUTPUT:-}}
parallel_tests=
parallel_tests_auto=false
dry_run=false
codex_review_prompt=
codex_review_prompt_file=false
openclaw_maintainer_validation=${AUTOREVIEW_OPENCLAW_MAINTAINER_VALIDATION:-${OPENCLAW_TESTBOX:-0}}
while [[ $# -gt 0 ]]; do
case "$1" in
--mode)
mode=${2:-}
shift 2
;;
--base)
base_ref=${2:-}
shift 2
;;
--commit)
commit_ref=${2:-}
shift 2
;;
--reviewer)
reviewer=${2:-}
shift 2
;;
--fallback-reviewer)
fallback_reviewer=${2:-}
shift 2
;;
--codex-bin)
codex_bin=${2:-}
shift 2
;;
--claude-bin)
claude_bin=${2:-}
shift 2
;;
--pi-bin)
pi_bin=${2:-}
shift 2
;;
--opencode-bin)
opencode_bin=${2:-}
shift 2
;;
--droid-bin)
droid_bin=${2:-}
shift 2
;;
--copilot-bin)
copilot_bin=${2:-}
shift 2
;;
--full-access)
yolo=1
shift
;;
--no-yolo)
yolo=0
shift
;;
--output)
output=${2:-}
shift 2
;;
--parallel-tests)
parallel_tests=${2:-}
shift 2
;;
--dry-run)
dry_run=true
shift
;;
-h|--help)
usage
exit 0
;;
*)
usage >&2
exit 2
;;
esac
done
case "$yolo" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) ;;
*) codex_args+=(--dangerously-bypass-approvals-and-sandbox --sandbox danger-full-access) ;;
esac
case "$mode" in
auto|local|branch|commit) ;;
*)
echo "invalid --mode: $mode" >&2
exit 2
;;
esac
case "$reviewer" in
auto|codex|claude|pi|opencode|droid|copilot) ;;
*)
echo "invalid --reviewer: $reviewer" >&2
exit 2
;;
esac
case "$fallback_reviewer" in
auto|claude|pi|opencode|droid|copilot|none) ;;
*)
echo "invalid --fallback-reviewer: $fallback_reviewer" >&2
exit 2
;;
esac
repo_root=$(git rev-parse --show-toplevel)
printf -v quoted_repo_root '%q' "$repo_root"
has_package_check_script() {
command -v node >/dev/null 2>&1 || return 1
node -e 'const { readFileSync } = require("node:fs"); const p = JSON.parse(readFileSync(process.argv[1], "utf8")); process.exit(p.scripts?.check ? 0 : 1)' \
"$repo_root/package.json" \
>/dev/null 2>&1
}
auto_tests_disabled() {
case "${AUTOREVIEW_AUTO_TESTS:-${CODEX_REVIEW_AUTO_TESTS:-1}}" in
0|false|False|FALSE|no|No|NO|off|Off|OFF) return 0 ;;
*) return 1 ;;
esac
}
current_branch=$(git branch --show-current 2>/dev/null || true)
dirty=false
if [[ -n "$(git status --porcelain)" ]]; then
dirty=true
fi
pr_url=
if [[ -z "$base_ref" && "$mode" != local ]] && command -v gh >/dev/null 2>&1; then
if pr_lines=$(gh pr view --json baseRefName,url --jq '[.baseRefName, .url] | @tsv' 2>/dev/null); then
base_name=${pr_lines%%$'\t'*}
pr_url=${pr_lines#*$'\t'}
if [[ -n "$base_name" ]]; then
base_ref="origin/$base_name"
fi
fi
fi
if [[ -z "$base_ref" ]]; then
base_ref=origin/main
fi
review_kind=
if [[ "$mode" == local || ( "$mode" == auto && "$dirty" == true ) ]]; then
review_kind=local
elif [[ "$mode" == commit ]]; then
review_kind=commit
elif [[ "$mode" == branch || ( "$mode" == auto && -n "$current_branch" && "$current_branch" != "main" ) ]]; then
review_kind=branch
else
echo "no review target: clean main checkout and no forced mode" >&2
exit 1
fi
if [[ "$review_kind" == local ]]; then
review_cmd=("$codex_bin" "${codex_args[@]}" review --uncommitted)
elif [[ "$review_kind" == commit ]]; then
review_cmd=("$codex_bin" "${codex_args[@]}" review --commit "$commit_ref")
else
review_cmd=("$codex_bin" "${codex_args[@]}" review --base "$base_ref")
fi
repo_url=$(git -C "$repo_root" config --get remote.origin.url 2>/dev/null || true)
case "$openclaw_maintainer_validation" in
1|true|True|TRUE|yes|Yes|YES|on|On|ON) openclaw_maintainer_validation=1 ;;
*) openclaw_maintainer_validation=0 ;;
esac
if [[ -z "$parallel_tests" && "$openclaw_maintainer_validation" != 1 ]] &&
! auto_tests_disabled; then
if [[ -f "$repo_root/package.json" && -f "$repo_root/pnpm-lock.yaml" && -d "$repo_root/node_modules" ]] &&
command -v pnpm >/dev/null 2>&1 &&
has_package_check_script; then
parallel_tests="cd $quoted_repo_root && PNPM_CONFIG_PM_ON_FAIL=ignore PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN=false PNPM_CONFIG_OFFLINE=true pnpm run check"
parallel_tests_auto=true
fi
fi
if [[ "$repo_url" == *"openclaw/openclaw"* && "$openclaw_maintainer_validation" == 1 ]]; then
codex_review_prompt=$(cat <<'EOF'
OpenClaw maintainer autoreview validation policy:
- Review the diff by reading code, tests, and dependency contracts.
- Do not run local memory-heavy Node validation from review mode. This includes local pnpm checks/tests, Vitest, tsgo, npm test, and node scripts/run-vitest.mjs.
- If runtime proof is needed, use existing proof or route validation through Crabbox / Blacksmith Testbox and report the exact provider and id.
- If remote validation is not necessary for the finding, state the targeted proof that should be run instead of starting local tests.
EOF
)
review_cmd=("$codex_bin" "${codex_args[@]}" review -)
codex_review_prompt_file=true
fi
printf 'autoreview target: %s\n' "$review_kind"
printf 'branch: %s\n' "${current_branch:-detached}"
if [[ -n "$pr_url" ]]; then
printf 'pr: %s\n' "$pr_url"
fi
if [[ "$reviewer" == auto ]]; then
printf 'reviewer: codex\n'
else
printf 'reviewer: %s\n' "$reviewer"
fi
case "$reviewer" in
codex|auto) ;;
*)
printf 'note: Codex native review mode is the recommended and best-supported review path; %s uses a generated diff prompt.\n' "$reviewer"
;;
esac
if [[ "$reviewer" == auto || "$reviewer" == codex ]]; then
printf 'review:'
printf ' %q' "${review_cmd[@]}"
printf '\n'
if [[ "$codex_review_prompt_file" == true ]]; then
printf 'review policy: OpenClaw maintainer validation active; using generated prompt review because Codex target review cannot accept extra prompt text\n'
fi
else
printf 'review: %s prompt review\n' "$reviewer"
fi
if [[ -n "$parallel_tests" ]]; then
printf 'tests: %s' "$parallel_tests"
if [[ "$parallel_tests_auto" == true ]]; then
printf ' (auto)'
fi
printf '\n'
fi
if [[ "$review_kind" == branch ]]; then
printf 'fetch: git fetch origin --quiet\n'
fi
if [[ -n "$output" ]]; then
printf 'output: %s\n' "$output"
fi
if [[ "$dry_run" == true ]]; then
exit 0
fi
if [[ "$review_kind" == branch ]]; then
git fetch origin --quiet || {
echo "warning: git fetch origin failed; reviewing with existing refs" >&2
}
fi
review_output=$output
review_output_is_temp=false
if [[ -z "$review_output" ]]; then
review_output=$(mktemp)
review_output_is_temp=true
fi
mkdir -p "$(dirname "$review_output")"
: > "$review_output"
cleanup() {
if [[ "${review_output_is_temp:-false}" == true && -n "${review_output:-}" ]]; then
rm -f "$review_output"
fi
if [[ -n "${prompt_file:-}" ]]; then
rm -f "$prompt_file"
fi
}
trap cleanup EXIT
run_review() {
local status=0
mkdir -p "$(dirname "$review_output")"
if [[ "$codex_review_prompt_file" == true ]]; then
build_prompt_file || return
"${review_cmd[@]}" < "$prompt_file" 2>&1 | tee "$review_output"
status=${PIPESTATUS[0]}
rm -f "$prompt_file"
prompt_file=
return "$status"
fi
"${review_cmd[@]}" 2>&1 | tee "$review_output"
}
diff_for_review() {
case "$review_kind" in
local)
git -C "$repo_root" diff --stat
git -C "$repo_root" diff --cached --stat
git -C "$repo_root" diff --find-renames
git -C "$repo_root" diff --cached --find-renames
while IFS= read -r untracked_file; do
[[ -n "$untracked_file" ]] || continue
git -C "$repo_root" diff --no-index -- /dev/null "$untracked_file" || true
done < <(git -C "$repo_root" ls-files --others --exclude-standard)
;;
commit)
git -C "$repo_root" show --find-renames --stat --format=fuller "$commit_ref"
git -C "$repo_root" show --find-renames --format=medium "$commit_ref"
;;
branch)
git -C "$repo_root" diff --find-renames --stat "$base_ref"...HEAD
git -C "$repo_root" diff --find-renames "$base_ref"...HEAD
;;
esac
}
build_prompt_file() {
prompt_file=$(mktemp)
{
cat <<EOF
You are performing a closeout code review for the current repository.
Review target: $review_kind
Branch: ${current_branch:-detached}
Base: ${base_ref:-}
Commit: ${commit_ref:-}
Rules:
- Review the proposed code change as a closeout reviewer.
- Focus on the diff below. If your CLI exposes read-only repository tools, inspect surrounding code and tests to verify findings; never modify files.
- Do not modify files.
${codex_review_prompt}
- Report only discrete, actionable issues introduced by this change.
- Prioritize correctness, regressions, security, data loss, performance cliffs, and missing tests that would catch a real bug.
- Do not report pre-existing issues, speculative risks, broad rewrites, style nits, changelog gaps, or findings that depend on unstated assumptions.
- Identify the concrete scenario where the issue appears, and keep the line reference as small as possible.
- A finding should overlap changed code or clearly cite changed code as the cause.
- For each accepted/actionable finding, use exactly this format:
[P<0-3>] Short title
File: path:line
Why: one sentence
Fix: one sentence
- If no accepted/actionable findings, output exactly:
autoreview clean: no accepted/actionable findings reported
Diff:
EOF
diff_for_review
} > "$prompt_file" || return
}
reviewer_output_has_clean_marker() {
local path=$1
grep -Eq '^[^[:alnum:]]*autoreview clean: no accepted/actionable findings reported[[:space:]]*$' "$path"
}
run_prompt_reviewer() {
local selected=$1
local copilot_prompt=
local prompt_bytes=0
local reviewer_output
local status=0
if ! build_prompt_file; then
rm -f "${prompt_file:-}"
prompt_file=
return 1
fi
reviewer_output=$(mktemp)
mkdir -p "$(dirname "$review_output")"
case "$selected" in
claude)
if ! command -v "$claude_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $claude_bin" >&2
status=127
elif printf 'fallback: claude -p\n' | tee -a "$review_output"; then
"$claude_bin" --tools "" --no-session-persistence -p < "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
pi)
if ! command -v "$pi_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $pi_bin" >&2
status=127
elif printf 'fallback: pi -p\n' | tee -a "$review_output"; then
"$pi_bin" --no-tools --no-session -p < "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
opencode)
if ! command -v "$opencode_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $opencode_bin" >&2
status=127
elif printf 'fallback: opencode run\n' | tee -a "$review_output"; then
"$opencode_bin" run --pure --dir "$repo_root" \
"Review the attached prompt file. Do not modify files." \
--file "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
droid)
if ! command -v "$droid_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $droid_bin" >&2
status=127
elif printf 'fallback: droid exec\n' | tee -a "$review_output"; then
"$droid_bin" exec --cwd "$repo_root" -f "$prompt_file" 2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
else
status=$?
fi
;;
copilot)
if ! command -v "$copilot_bin" >/dev/null 2>&1; then
echo "fallback reviewer unavailable: $copilot_bin" >&2
status=127
elif printf 'fallback: copilot\n' | tee -a "$review_output"; then
prompt_bytes=$(wc -c < "$prompt_file" | tr -d '[:space:]')
if (( prompt_bytes > 120000 )); then
echo "copilot reviewer unavailable: generated prompt is too large for copilot -p; use codex, droid, or another file/stdin-capable reviewer" \
2>&1 | tee -a "$review_output" "$reviewer_output"
status=1
else
copilot_prompt=$(< "$prompt_file")
"$copilot_bin" -C "$repo_root" --available-tools=none --stream off --output-format text --silent \
-p "$copilot_prompt" \
2>&1 | tee -a "$review_output" "$reviewer_output"
status=$?
fi
else
status=$?
fi
;;
*)
echo "unsupported prompt reviewer: $selected" >&2
status=2
;;
esac
if [[ "$status" == 0 ]]; then
if grep -Eq '\[P[0-3]\]' "$reviewer_output"; then
status=1
elif ! grep -q '[^[:space:]]' "$reviewer_output"; then
status=1
elif ! reviewer_output_has_clean_marker "$reviewer_output"; then
status=1
fi
fi
rm -f "$reviewer_output"
rm -f "$prompt_file"
prompt_file=
return "$status"
}
run_selected_review() {
local selected=$1
case "$selected" in
codex)
if ! command -v "$codex_bin" >/dev/null 2>&1; then
echo "codex reviewer unavailable: $codex_bin" >&2
return 127
fi
run_review
;;
claude|pi|opencode|droid|copilot)
run_prompt_reviewer "$selected"
;;
*)
echo "unsupported reviewer: $selected" >&2
return 2
;;
esac
}
fallback_reviewer_is_available() {
local selected=$1
case "$selected" in
claude) command -v "$claude_bin" >/dev/null 2>&1 ;;
pi) command -v "$pi_bin" >/dev/null 2>&1 ;;
opencode) command -v "$opencode_bin" >/dev/null 2>&1 ;;
droid) command -v "$droid_bin" >/dev/null 2>&1 ;;
copilot) command -v "$copilot_bin" >/dev/null 2>&1 ;;
*) return 1 ;;
esac
}
run_auto_fallback_review() {
local selected
if [[ "$fallback_reviewer" != auto ]]; then
run_selected_review "$fallback_reviewer"
return $?
fi
for selected in claude pi opencode droid copilot; do
if fallback_reviewer_is_available "$selected"; then
run_selected_review "$selected"
return $?
fi
done
echo "fallback reviewer unavailable: no configured fallback CLI found" >&2
return 127
}
run_auto_review() {
run_selected_review codex
local status=$?
if [[ "$status" == 0 ]]; then
return 0
fi
if (( status > 128 && status < 192 )); then
return "$status"
fi
if review_output_has_findings; then
return "$status"
fi
if [[ "$fallback_reviewer" == none ]]; then
return "$status"
fi
if [[ "$fallback_reviewer" == auto ]]; then
printf 'autoreview warning: codex exited %s; trying configured fallback reviewers\n' "$status" >&2
else
printf 'autoreview warning: codex exited %s; falling back to %s\n' "$status" "$fallback_reviewer" >&2
fi
run_auto_fallback_review
}
elapsed_since() {
local started_at=$1
local finished_at
finished_at=$(date +%s)
printf '%s\n' "$((finished_at - started_at))"
}
format_elapsed() {
local seconds=$1
if (( seconds < 60 )); then
printf '%ss\n' "$seconds"
else
printf '%sm%ss\n' "$((seconds / 60))" "$((seconds % 60))"
fi
}
review_output_empty() {
[[ ! -s "$review_output" ]] || ! grep -q '[^[:space:]]' "$review_output"
}
review_findings_text() {
if grep -Fxq 'codex' "$review_output"; then
awk '
$0 == "codex" {
capture = 1
output = $0 ORS
next
}
capture {
output = output $0 ORS
}
END {
printf "%s", output
}
' "$review_output"
return
fi
cat "$review_output"
}
review_output_has_findings() {
review_findings_text | grep -Eq '\[P[0-3]\]'
}
report_clean_review_or_fail() {
local elapsed_text
elapsed_text=$(format_elapsed "${review_elapsed_seconds:-0}")
if review_output_has_findings; then
printf 'autoreview complete after %s\n' "$elapsed_text"
printf 'autoreview findings: accepted/actionable findings reported\n'
return 1
fi
if review_output_empty; then
printf 'autoreview complete after %s; no output\n' "$elapsed_text"
return 1
fi
printf 'autoreview complete after %s\n' "$elapsed_text"
printf 'autoreview clean: no accepted/actionable findings reported\n'
}
if [[ -z "$parallel_tests" ]]; then
review_started_at=$(date +%s)
set +e
if [[ "$reviewer" == auto ]]; then
run_auto_review
else
run_selected_review "$reviewer"
fi
review_status=$?
review_elapsed_seconds=$(elapsed_since "$review_started_at")
set -e
if [[ "$review_status" == 0 ]]; then
report_clean_review_or_fail
exit $?
fi
exit "$review_status"
fi
review_status_file=$(mktemp)
review_elapsed_file=$(mktemp)
tests_status_file=$(mktemp)
(
set +e
review_started_at=$(date +%s)
if [[ "$reviewer" == auto ]]; then
run_auto_review
else
run_selected_review "$reviewer"
fi
status=$?
elapsed=$(elapsed_since "$review_started_at")
printf '%s\n' "$status" > "$review_status_file"
printf '%s\n' "$elapsed" > "$review_elapsed_file"
) &
review_pid=$!
(
set +e
bash -lc "$parallel_tests"
status=$?
printf '%s\n' "$status" > "$tests_status_file"
) &
tests_pid=$!
wait "$review_pid" || true
wait "$tests_pid" || true
review_status=$(cat "$review_status_file")
review_elapsed_seconds=$(cat "$review_elapsed_file")
tests_status=$(cat "$tests_status_file")
rm -f "$review_status_file" "$review_elapsed_file" "$tests_status_file"
printf 'autoreview exit: %s\n' "$review_status"
printf 'tests exit: %s\n' "$tests_status"
if [[ "$review_status" != 0 || "$tests_status" != 0 ]]; then
exit 1
fi
report_clean_review_or_fail

View File

@@ -1,44 +0,0 @@
---
name: channel-message-flows
description: "Use when previewing local channel message flow fixtures."
---
# Channel Message Flows
Use this from the OpenClaw repo root to send canned channel preview flows while iterating on message UX. These are real sends/edits/deletes against the configured channel target.
## Telegram
Native Telegram `sendMessageDraft` tool progress, then a final answer:
```bash
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow working-final \
--duration-ms 20000
```
Thinking preview, then a final answer:
```bash
node --import tsx scripts/dev/channel-message-flows.ts \
--channel telegram \
--target <telegram-chat-id> \
--flow thinking-final
```
## Options
- `--account <accountId>`: Telegram account id when not using the default.
- `--thread-id <id>`: Telegram forum topic/message thread id.
- `--delay-ms <ms>`: Override preview update cadence.
- `--duration-ms <ms>`: Simulated working duration for `working-final`.
- `--final-text <text>`: Override the durable final message.
## Notes
- `--target` is the numeric Telegram chat id.
- `working-final` exercises native Telegram `sendMessageDraft` with static `Working` status and sample tool progress.
- `thinking-final` exercises formatted `Thinking` reasoning preview clearing before the final answer.
- Only `--channel telegram` is implemented for now.

View File

@@ -1,159 +0,0 @@
---
name: clawdtributor
description: "Use for OpenClaw clawtributors PR/issue triage: Discrawl discovery, live-open rechecks, deep review, topic grouping, and compact @handle/LOC/type/blast/verification summaries."
---
# Clawdtributor
Use for the `#clawtributors` queue: Discord-discovered OpenClaw PRs/issues that need live GitHub status plus maintainer-quality review.
## Compose with other skills
- `$discrawl`: local Discord archive sync/search.
- `$openclaw-pr-maintainer`: live GitHub PR/issue review, duplicate search, close/land rules.
- `$gitcrawl`: related issue/PR and current-main/stale-proof search.
- `$openclaw-testing` / `$crabbox`: proof choice when a candidate needs real validation.
## Archive flow
Local archive first; verify freshness for current questions.
```bash
discrawl status --json
discrawl sync
```
Resolve channel if needed:
```bash
sqlite3 "$HOME/.discrawl/discrawl.db" \
"select id,name from channels where name like '%clawtributor%' order by name;"
```
Current known channel id from prior work: `1458141495701012561`. Re-resolve if it stops matching.
Extract recent refs:
```bash
sqlite3 "$HOME/.discrawl/discrawl.db" "
select m.created_at, coalesce(nullif(mm.username,''), m.author_id), m.content
from messages m
left join members mm on mm.guild_id=m.guild_id and mm.user_id=m.author_id
where m.channel_id='1458141495701012561'
and m.created_at >= '<ISO cutoff>'
order by m.created_at desc;" |
perl -nE 'while(m{github\.com/openclaw/openclaw/(pull|issues)/(\d+)}g){say "$1\t$2\t$_"}'
```
Map a PR/issue back to the Discord handle:
```bash
sqlite3 -separator $'\t' "$HOME/.discrawl/discrawl.db" "
select m.created_at,
coalesce(nullif(mm.username,''), nullif(mm.global_name,''), m.author_id)
from messages m
left join members mm on mm.guild_id=m.guild_id and mm.user_id=m.author_id
where m.channel_id='1458141495701012561'
and m.content like '%github.com/openclaw/openclaw/<pull-or-issues>/<number>%'
order by m.created_at desc
limit 1;"
```
Show only `@handle` in the final list. Do not write the word Discord unless the user asks for source details.
## Live GitHub recheck
Always recheck live state before listing, closing, or saying "open".
```bash
GITHUB_TOKEN= GITHUB_TOKEN_NODIFF= GH_TOKEN= \
gh api repos/openclaw/openclaw/pulls/<number> \
--jq '. | {number,title,state,merged,mergeable,draft,author:.user.login,url:.html_url,updatedAt:.updated_at,additions,deletions,changedFiles:.changed_files}'
```
For issues:
```bash
GITHUB_TOKEN= GITHUB_TOKEN_NODIFF= GH_TOKEN= \
gh api repos/openclaw/openclaw/issues/<number> \
--jq '. | {number,title,state,author:.user.login,url:.html_url,updatedAt:.updated_at,pull_request}'
```
If `gh` says bad credentials, clear env vars with empty assignments as above. Use `--jq '. | {...}'` for object projections.
## Review depth
For each open item, inspect enough to classify risk:
- PR body, linked issue, comments, files, additions/deletions, checks.
- Current `origin/main` code path and adjacent tests.
- Related threads with `gitcrawl neighbors/search`.
- Whether main already fixed it, the PR is obsolete, or the idea is invalid.
- Blast radius: touched runtime surfaces, config/schema, plugin/core boundary, user-visible behavior, release/package surface.
- Verification: say if local unit/docs proof is enough, live/provider proof is needed, or it is not directly verifiable.
Do not close from title alone. If closing as done on main or nonsensical, prove it against current main and comment first when mutation is requested. Bulk close/reopen above 5 requires explicit scope.
## Candidate selection
When asked for `5 new`, exclude refs already surfaced in the session and refill from the archive until there are 5 live-open candidates. If fewer than 5 remain open, list all open ones and say how many short.
When asked to `update`, `refresh`, `recheck`, `check again`, or similar, return an updated live-open candidate list. Do not fill the main list with items that merely merged/closed since the last pass; put those numbers in a short bottom line.
Prefer:
- Fresh, open, external contributor work.
- Small, high-confidence bugfixes.
- Clear repro, tests, or obvious code-path proof.
Demote:
- Broad product/features without owner decision.
- Large rewrites with unclear contract.
- PRs already in progress, merged, closed, duplicate, or fixed on main.
## Topic grouping
Group only when useful or requested:
- Agents/tooling
- Providers/auth/models
- Channels/messaging
- UI/web
- Gateway/protocol/runtime
- Config/memory/cache
- Docker/install/release
- Docs/tests/chore
- Closed/obsolete
Infer topic from labels, touched files, title/body, and actual code path.
## Output format
No Markdown tables. Compact bullets. Use color/risk markers:
- 🟢 low/narrow
- 🟡 medium or needs targeted proof
- 🔴 broad/high runtime risk
- 🟣 security/policy/owner-boundary slow review
- ✅ merged
- ⚪ closed unmerged
Required line shape:
```markdown
- **PR #81244** `@whatsskill.` `+118/-1` `bug` 🟢 verifiable: yes. This prevents chat action buttons from overlapping short assistant replies. Blast: web chat rendering, low.
- **Issue #81245** `@alice` `LOC n/a` `bug` 🟡 verifiable: partial. This reports duplicate Telegram replies when reconnecting after gateway restart. Blast: Telegram channel runtime, medium.
```
Rules:
- Bold the `PR #n` or `Issue #n` marker.
- Use `@handle`, not author bio text.
- PR LOC is `+additions/-deletions`; issue LOC is `LOC n/a`.
- Type: `bug`, `feature`, `perf`, `security`, `docs`, `test`, `chore`, or `refactor`.
- Write a full sentence for what it does.
- Always include blast radius in one phrase.
- Always include `verifiable: yes|partial|no` plus the shortest proof hint when helpful.
- If status is not open, still show it only when the user asked for all surfaced refs; use ✅ or ⚪ and state merged/closed.
- For refresh-style asks, bottom line: `Merged/closed since last pass: #81016 merged, #81026 closed.` Omit if none.

View File

@@ -1,32 +1,17 @@
---
name: crabbox
description: Use the Crabbox wrapper for OpenClaw remote validation across Linux, macOS, Windows, and WSL2, including delegated Blacksmith Testbox proof. Report the actual provider and id.
description: Use Crabbox for OpenClaw remote Linux validation. Default to Blacksmith Testbox; includes direct Blacksmith and owned AWS/Hetzner fallback notes when Crabbox fails.
---
# Crabbox
Use the Crabbox wrapper when OpenClaw needs remote Linux proof for broad tests,
CI-parity checks, secrets, hosted services, Docker/E2E/package lanes, warmed
reusable boxes, sync timing, logs/results, cache inspection, or lease cleanup.
Use Crabbox when OpenClaw needs remote Linux proof for broad tests, CI-parity
checks, secrets, hosted services, Docker/E2E/package lanes, warmed reusable
boxes, sync timing, logs/results, cache inspection, or lease cleanup.
Crabbox is the transport/orchestration surface. The actual backend can be:
- brokered AWS Crabbox: direct provider, `provider=aws`, lease ids like
`cbx_...`, `syncDelegated=false`
- Blacksmith Testbox through Crabbox: delegated provider,
`provider=blacksmith-testbox`, ids like `tbx_...`, `syncDelegated=true`
For OpenClaw maintainer broad `pnpm` gates, Blacksmith Testbox through the
Crabbox wrapper is acceptable and often preferred when the standing Testbox
rules apply. Do not describe those runs as "AWS Crabbox"; report them as
Testbox-through-Crabbox with the `tbx_...` id and Actions run.
Use the repo `.crabbox.yaml` brokered AWS path when the task specifically needs
direct AWS Crabbox behavior, persistent direct-provider leases, `--fresh-pr`,
`--full-resync`, environment forwarding, capture/download support, or provider
comparison. Use `--provider blacksmith-testbox` when the task needs OpenClaw
maintainer Testbox proof, prepared CI environment, broad/heavy pnpm gates, or
the user asks for Testbox/Blacksmith.
Default backend: `blacksmith-testbox`. The separate `blacksmith-testbox` skill
has been removed; this skill owns both the normal Crabbox path and the direct
Blacksmith fallback playbook.
## First Checks
@@ -43,29 +28,16 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
- OpenClaw scripts prefer `../crabbox/bin/crabbox` when present. The user PATH
shim can be stale.
- Check `.crabbox.yaml` for direct-provider defaults. Omitting `--provider`
means brokered AWS today.
- The brokered AWS default is a Linux developer image in `eu-west-1`; the repo
config pins hot `eu-west-1a/b/c` placement so Fast Snapshot Restore can apply.
If warmup drifts well past the minute-scale path, verify image promotion,
region/AZ placement, and FSR state before blaming OpenClaw.
- For broad OpenClaw maintainer `pnpm` gates, prefer the repo wrapper with
`--provider blacksmith-testbox` or the repo Testbox helpers when the standing
Testbox policy applies.
- Always report the actual provider and id. `cbx_...` means AWS Crabbox;
`tbx_...` means Blacksmith Testbox through Crabbox. If the output only says
`blacksmith testbox list`, use `blacksmith testbox list --all` before
concluding no box exists.
- If a warm direct-provider lease smells stale, retry with `--full-resync`
(alias `--fresh-sync`) before replacing the lease. This resets the remote
workdir, skips the fingerprint fast path, reseeds Git when possible, and
uploads the checkout from scratch.
- For live/provider bugs, use the configured secret workflow before downgrading
to mocks. Copy only the exact needed key into the remote process environment
for that one command. Do not print it, do not sync it as a repo file, and do
not leave it in remote shell history or logs. If no secret-safe injection path
is available, say true live provider auth is blocked instead of silently using
a fake key.
- Check `.crabbox.yaml` for repo defaults, but override provider explicitly.
Even if config still says AWS, maintainer validation should normally pass
`--provider blacksmith-testbox`.
- For live/provider bugs, check keys on the local Mac before downgrading to
mocks: source local `~/.profile` and test only presence/length. If Crabbox
does not already have the key, copy only the exact needed key into the remote
process environment for that one command. Do not print it, do not sync it as a
repo file, and do not leave it in remote shell history or logs. If no
secret-safe injection path is available, say true live provider auth is
blocked instead of silently using a fake key.
- Prefer local targeted tests for tight edit loops. Broad gates belong remote.
- Do not treat inherited shell env as operator intent. In particular,
`OPENCLAW_LOCAL_CHECK_MODE=throttled` from the local shell is not permission
@@ -79,43 +51,7 @@ pnpm crabbox:run -- --help | sed -n '1,120p'
## macOS And Windows Targets
Use these only when the task needs an existing non-Linux host. OpenClaw broad
Linux validation uses the repo Crabbox config unless a provider is explicitly
requested.
Native brokered Windows is available for Windows-specific proof. Use the AWS
developer image in `us-west-2` on demand; it has the expected OpenClaw developer
toolchain and Docker image cache. Keep broad Linux gates on Linux/Testbox unless
the bug is Windows-specific:
```sh
../crabbox/bin/crabbox warmup \
--provider aws \
--target windows \
--windows-mode normal \
--region us-west-2 \
--market on-demand \
--timing-json
```
The hydrate workflow assumes Docker should already be baked into Linux images
and only installs it as a fallback. Do not add per-run Docker installs to proof
commands unless the image probe shows Docker is actually missing.
When the user explicitly asks for brokered macOS runners, use Crabbox AWS
macOS only after confirming the deployed coordinator supports EC2 Mac host
lifecycle/image routes and the operator has AWS EC2 Mac Dedicated Host quota
and IAM. Prefer `CRABBOX_HOST_ID` for a known Crabbox-managed Dedicated Host,
or run the no-spend preflight first:
```sh
crabbox admin hosts quota --provider aws --target macos --region eu-west-1 --type mac2.metal --json
crabbox admin hosts allocate --provider aws --target macos --region eu-west-1 --type mac2.metal --dry-run --json
CRABBOX_MACOS_TYPES=all scripts/macos-host-region-preflight.sh
```
Do not silently substitute AWS macOS for normal OpenClaw Linux proof. Report
paid-host blockers as quota, IAM, coordinator deployment, or host availability
instead of falling back to local macOS.
validation still defaults to `blacksmith-testbox`.
Crabbox supports static SSH targets:
@@ -128,23 +64,27 @@ Crabbox supports static SSH targets:
- `target=macos` and `target=windows --windows-mode wsl2` use the POSIX SSH,
bash, Git, rsync, and tar contract.
- Native Windows uses OpenSSH, PowerShell, Git, and tar; sync is manifest tar
archive transfer into `static.workRoot`. Direct native Windows runs support
`--script*`, `--env-from-profile`, `--preflight`, and PowerShell `--shell`.
archive transfer into `static.workRoot`.
- `crabbox actions hydrate/register` are Linux-only today; use plain
`crabbox run` loops for static macOS and Windows hosts.
- Live proof needs a reachable, operator-managed SSH host. Without one, verify
with `../crabbox/bin/crabbox run --help`, config/flag tests, and the Crabbox
Go test suite.
## Direct Brokered AWS Backend
## Default Blacksmith Backend
Use this when the task needs direct AWS Crabbox semantics rather than the
prepared Blacksmith Testbox CI environment.
Use this for `pnpm check`, `pnpm check:changed`, `pnpm test`,
`pnpm test:changed`, Docker/E2E/live/package gates, or anything likely to fan
out across many Vitest projects.
Changed gate:
```sh
pnpm crabbox:run -- \
pnpm crabbox:run -- --provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
@@ -155,7 +95,11 @@ pnpm crabbox:run -- \
Full suite:
```sh
pnpm crabbox:run -- \
pnpm crabbox:run -- --provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
@@ -166,7 +110,11 @@ pnpm crabbox:run -- \
Focused rerun:
```sh
pnpm crabbox:run -- \
pnpm crabbox:run -- --provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
@@ -176,53 +124,19 @@ pnpm crabbox:run -- \
Read the JSON summary. Useful fields:
- `provider`: `aws`
- `leaseId`: `cbx_...`
- `syncDelegated`: `false`
- `provider`: should be `blacksmith-testbox`
- `leaseId`: `tbx_...`
- `syncDelegated`: should be `true`
- `commandPhases`: populated when the command prints `CRABBOX_PHASE:<name>`
- `commandMs` / `totalMs`
- `exitCode`
Crabbox should stop one-shot AWS leases automatically after the run. Verify
cleanup when a run fails, is interrupted, or the command output is unclear:
Crabbox should stop one-shot Blacksmith Testboxes automatically after the run.
Verify cleanup when a run fails, is interrupted, or the command output is
unclear:
```sh
../crabbox/bin/crabbox list --provider aws
```
## Blacksmith Testbox Through Crabbox
Use this for OpenClaw maintainer broad/heavy `pnpm` gates when the prepared CI
environment is the right proof surface:
```sh
node scripts/crabbox-wrapper.mjs run \
--provider blacksmith-testbox \
--blacksmith-org openclaw \
--blacksmith-workflow .github/workflows/ci-check-testbox.yml \
--blacksmith-job check \
--blacksmith-ref main \
--idle-timeout 90m \
--ttl 240m \
--timing-json \
-- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 OPENCLAW_TESTBOX=1 OPENCLAW_TESTBOX_REMOTE_RUN=1 pnpm check:changed
```
Read the JSON summary and the Testbox line. Useful fields:
- `provider`: `blacksmith-testbox`
- `leaseId`: `tbx_...`
- `syncDelegated`: `true`
- `syncPhases`: delegated/skipped because Blacksmith owns checkout/sync
- Actions run URL/id from the Testbox output
- `exitCode`
`blacksmith testbox list` may hide hydrating or ready boxes. Use:
```sh
blacksmith testbox list --all
blacksmith testbox status <tbx_id>
blacksmith testbox list
```
## Observability Flags
@@ -230,16 +144,8 @@ blacksmith testbox status <tbx_id>
Use these on debugging runs before inventing ad hoc logging:
- `--preflight`: prints run context, workspace mode, SSH target, remote user/cwd,
and target-specific tool probes. Defaults cover `git`, `tar`, `node`, `npm`,
`corepack`, `pnpm`, `yarn`, `bun`, `docker`, plus POSIX
`sudo`/`apt`/`bubblewrap` and native Windows
`powershell`/`execution_policy`/`longpaths`/`temp`/`pwsh`. Add
`--preflight-tools node,bun,docker`, `CRABBOX_PREFLIGHT_TOOLS`, or repo
`run.preflightTools` to replace the list. `default` expands built-ins; `none`
prints only the workspace summary. Preflight is diagnostic only; install
toolchains through Actions hydration, images, devcontainer/Nix/mise/asdf, or
the run script. On `blacksmith-testbox`, this prints a delegated-unsupported
note because the workflow owns setup.
sudo/apt, Node, pnpm, Docker, and bubblewrap. On `blacksmith-testbox`, this
prints a delegated-unsupported note because the workflow owns setup.
- `CRABBOX_ENV_ALLOW=NAME,...`: forwards only listed local env vars for direct
providers and prints `set len=N secret=true` style summaries. On
`blacksmith-testbox`, env forwarding is unsupported; put secrets in the
@@ -248,36 +154,21 @@ Use these on debugging runs before inventing ad hoc logging:
`export NAME=value` / `NAME=value` lines from a local profile without
executing it, then forwards only allowlisted names. `--allow-env` is
repeatable and comma-separated. Profile values override ambient allowlisted
env values for that run. Direct POSIX, WSL2, and native Windows runs are
supported; delegated providers are not. Crabbox probes the uploaded profile
remotely and prints redacted presence/length metadata before the command.
- `--env-helper <name>`: with `--env-from-profile` on POSIX SSH targets,
persists `.crabbox/env/<name>` and `.crabbox/env/<name>.env` so follow-up
commands on the same lease can run through `./.crabbox/env/<name> <command>`.
Use only on leases you control; the profile stays until cleanup, lease reset,
or `--full-resync`.
env values for that run.
- `--script <file>` / `--script-stdin`: upload a local script into
`.crabbox/scripts/` and execute it on the remote box. Shebang scripts execute
directly on POSIX; scripts without a shebang run through `bash`. Native
Windows uploads run through Windows PowerShell, and Crabbox appends `.ps1`
when needed. Arguments after `--` become script args.
directly; scripts without a shebang run through `bash`. Arguments after `--`
become script args.
- `--fresh-pr owner/repo#123|URL|number`: skip dirty local sync and create a
fresh remote checkout of the GitHub PR. Bare numbers use the current repo's
GitHub origin. Add `--apply-local-patch` only when the current local
`git diff --binary HEAD` should be applied on top of that PR checkout.
- `--full-resync` / `--fresh-sync`: reset a stale direct-provider workdir
before syncing. Use after sync fingerprints look wrong, SSH times out before
sync, or rsync watchdog output suggests it. It is redundant with
`--fresh-pr`, incompatible with `--no-sync`, and unsupported by delegated
providers.
- `--capture-stdout <path>` / `--capture-stderr <path>`: write remote streams to
local files and keep binary/noisy output out of retained logs. Parent
directories must already exist. These are direct-provider only.
- `--capture-on-fail`: on non-zero direct-provider exits, downloads
`.crabbox/captures/*.tar.gz` with `test-results`, `playwright-report`,
`coverage`, JUnit XML, and nearby logs. Treat as secret-bearing until reviewed.
- `--keep-on-failure`: leave a failed one-shot lease alive for live debugging
until idle/TTL expiry. Useful on direct providers and delegated one-shots.
- `--timing-json`: final machine-readable timing. Add
`echo CRABBOX_PHASE:install`, `CRABBOX_PHASE:test`, etc. in long shell
commands; direct providers and Blacksmith Testbox both report them as
@@ -289,6 +180,7 @@ Live-provider debug template for direct AWS/Hetzner leases:
mkdir -p .crabbox/logs
pnpm crabbox:run -- --provider aws \
--preflight \
--env-from-profile ~/.profile \
--allow-env OPENAI_API_KEY,OPENAI_BASE_URL \
--timing-json \
--capture-stdout .crabbox/logs/live-provider.stdout.log \
@@ -299,10 +191,9 @@ pnpm crabbox:run -- --provider aws \
```
Do not pass `--capture-*`, `--download`, `--checksum`, `--force-sync-large`, or
`--sync-only` to delegated providers. Also do not pass `--script*`,
`--fresh-pr`, `--full-resync`, or `--env-helper` there. Crabbox rejects these
because the provider owns sync or command transport. `--keep-on-failure` is OK
for delegated one-shots when you need to inspect a failed lease.
`--sync-only` to delegated providers. Also do not pass `--script*` or
`--fresh-pr` there. Crabbox rejects these because the provider owns sync or
command transport.
## Efficient Bug E2E Verification
@@ -310,20 +201,13 @@ Use the smallest Crabbox lane that proves the reported user path, not just the
touched code. Aim for one after-fix E2E proof before commenting, closing, or
opening a PR for a user-visible bug.
When the user says "test in Crabbox", do not simply copy tests to the remote
box and run them there. Crabbox is for remote real-scenario proof: copy or
install OpenClaw as the user would, run the same setup/update/CLI/Gateway/API
call that failed, and capture behavior from that entrypoint. For regressions or
bug reports, prove the broken state first when feasible, then run the same
scenario after the fix.
Pick the lane by symptom:
- Docker/setup/install bug: build a package tarball and run the matching
`scripts/e2e/*-docker.sh` or package script. This proves npm packaging,
install paths, runtime deps, config writes, and container behavior.
- Provider/model/auth bug: prefer true live E2E. Use the configured secret
workflow, then inject the single needed key into Crabbox if needed. Scrub
- Provider/model/auth bug: prefer true live E2E. First source local Mac
`~/.profile`, then inject the single needed key into Crabbox if needed. Scrub
unrelated provider env vars in the child command so interactive defaults do
not drift to another provider. If only a dummy key is used, label the proof
narrowly, e.g. "UI/install path only; live provider auth not exercised."
@@ -338,9 +222,8 @@ Pick the lane by symptom:
Efficient flow:
1. Reproduce or prove the pre-fix symptom from the real user-facing entrypoint
when feasible. If the issue cannot be reproduced, capture the exact command
and observed behavior instead.
1. Reproduce or prove the pre-fix symptom when feasible. If the issue cannot be
reproduced, capture the exact command and observed behavior instead.
2. Patch locally and run narrow local tests for edit speed.
3. Run one Crabbox E2E command that starts from the user-facing entrypoint:
package install, Docker setup, onboarding, channel add, gateway start, or
@@ -358,8 +241,6 @@ Keep it efficient:
- Use `--fresh-pr <pr>` when validating an upstream PR in isolation from the
local dirty tree. Add `--apply-local-patch` only when testing a local fixup on
top of that PR.
- Use `--full-resync` before replacing a warmed direct-provider lease when the
remote workdir or sync fingerprint appears stale.
- Use one-shot Crabbox for a single proof; use a reusable Testbox only when
several commands must share built images, installed packages, or live state.
- Prefer `OPENCLAW_CURRENT_PACKAGE_TGZ` with Docker/package lanes when testing a
@@ -421,13 +302,13 @@ Interactive CLI/onboarding:
## Reuse And Keepalive
For most Crabbox calls, one-shot is enough. Use reuse only when you need
multiple manual commands on the same hydrated box.
For most Blacksmith-backed Crabbox calls, one-shot is enough. Use reuse only
when you need multiple manual commands on the same hydrated box.
If Crabbox returns a reusable id or you intentionally keep a lease:
```sh
pnpm crabbox:run -- --id <cbx_id-or-slug> --no-sync --timing-json --shell -- "pnpm test <path>"
pnpm crabbox:run -- --provider blacksmith-testbox --id <tbx_id> --no-sync --timing-json --shell -- "pnpm test <path>"
```
Stop boxes you created before handoff:
@@ -448,70 +329,35 @@ Common desktop flow:
```sh
../crabbox/bin/crabbox warmup --provider hetzner --desktop --browser --class standard --idle-timeout 60m --ttl 240m
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open --take-control
../crabbox/bin/crabbox desktop launch --provider hetzner --id <cbx_id-or-slug> --browser --url https://example.com --webvnc --open
```
Useful WebVNC commands:
```sh
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox webvnc daemon start --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox webvnc daemon status --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc daemon stop --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc status --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox webvnc reset --provider hetzner --id <cbx_id-or-slug> --open --take-control
../crabbox/bin/crabbox desktop doctor --provider hetzner --id <cbx_id-or-slug>
../crabbox/bin/crabbox desktop click --provider hetzner --id <cbx_id-or-slug> --x 640 --y 420
../crabbox/bin/crabbox desktop paste --provider hetzner --id <cbx_id-or-slug> --text "user@example.com"
../crabbox/bin/crabbox desktop key --provider hetzner --id <cbx_id-or-slug> ctrl+l
../crabbox/bin/crabbox artifacts collect --id <cbx_id-or-slug> --all --output artifacts/<slug>
../crabbox/bin/crabbox artifacts publish --dir artifacts/<slug> --pr <number>
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --open
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --daemon --open
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --status
../crabbox/bin/crabbox webvnc --provider hetzner --id <cbx_id-or-slug> --stop
../crabbox/bin/crabbox screenshot --provider hetzner --id <cbx_id-or-slug> --output desktop.png
```
`desktop launch --webvnc --open` is usually the nicest one-shot: it starts the
browser/app inside the visible session, bridges the lease into the authenticated
WebVNC portal, and opens the portal. Keep browsers windowed for human QA; use
`--fullscreen` only for capture/video workflows.
For human handoff, include `--take-control` so the opened portal viewer gets
keyboard/mouse control automatically instead of landing as an observer.
Human handoff preflight:
- Do not assume a visible desktop or launched browser means the repo CLI/app is
installed, built, or on the interactive terminal's `PATH`.
- Before handing WebVNC to a human tester, prove the expected command from the
same kept lease and from a neutral directory such as `~`.
- If the handoff needs repo-local code, sync/build/link it explicitly on that
lease. Source-tree CLIs often need build output before a symlink works.
- Prefer a real `command -v <expected-command> && <expected-command> --version`
check over a repo-root-only `pnpm ...` command.
Generic handoff repair pattern:
```sh
../crabbox/bin/crabbox run --id <cbx_id-or-slug> --full-resync --shell -- \
"set -euo pipefail
pnpm install --frozen-lockfile
pnpm build
sudo ln -sf \"\$PWD/<cli-entry>\" /usr/local/bin/<expected-command>
cd ~
command -v <expected-command>
<expected-command> --version"
```
## If Crabbox Fails
Keep the fallback narrow. First decide whether the failure is Crabbox itself,
the brokered AWS lease, Blacksmith/Testbox, repo hydration, sync, or the test
command.
Blacksmith/Testbox, repo hydration, sync, or the test command.
Fast checks:
```sh
command -v crabbox
../crabbox/bin/crabbox --version
pnpm crabbox:run -- --help | sed -n '1,140p'
../crabbox/bin/crabbox doctor
crabbox run --provider blacksmith-testbox --help | sed -n '1,140p'
command -v blacksmith
blacksmith --version
blacksmith testbox list
@@ -521,36 +367,34 @@ Common Crabbox-only failures:
- Provider missing or old CLI: use `../crabbox/bin/crabbox` from the sibling
repo, or update/install Crabbox before retrying.
- Bad local config: inspect `.crabbox.yaml`, `crabbox config show`, and
`crabbox whoami`; normal OpenClaw proof should use brokered AWS without
asking for cloud keys.
- Slug/claim confusion: use the raw `cbx_...` / `tbx_...` id, or run one-shot
without `--id`.
- Bad local config: pass `--provider blacksmith-testbox` plus explicit
`--blacksmith-*` flags instead of relying on `.crabbox.yaml`.
- Slug/claim confusion: use the raw `tbx_...` id, or run one-shot without
`--id`.
- Sync/timing bug: add `--debug --timing-json`; capture the final JSON and the
printed Actions URL. Large sync warnings now include top source directories
by file count and a hint to update `.crabboxignore` / `sync.exclude`; inspect
those before reaching for `--force-sync-large`. Quiet rsync watchdogs and SSH
timeouts now print `next_action=` hints; follow them, usually `--full-resync`
first and a fresh lease second.
- Cleanup uncertainty: run `crabbox list --provider aws`; for explicit
Blacksmith runs, use `blacksmith testbox list` and stop only boxes you
those before reaching for `--force-sync-large`.
- Cleanup uncertainty: run `blacksmith testbox list` and stop only boxes you
created.
- Testbox queued/capacity pressure: do not retry Blacksmith repeatedly. Rerun
once without `--provider` so `.crabbox.yaml` routes to brokered AWS, or report
the Blacksmith blocker if Testbox itself is the requested proof.
- Testbox queued/capacity pressure: do not convert a broad changed gate or full
suite into local `OPENCLAW_LOCAL_CHECK_MODE=throttled pnpm ...`. Leave the
remote lane queued, switch to a narrower targeted local check, or stop and
report the capacity blocker.
If brokered AWS cannot dispatch, sync, attach, or stop, retry once with
`--debug` and `--timing-json`:
If Crabbox cannot dispatch, sync, attach, or stop but Blacksmith itself works,
first try the same command through the repo wrapper with `--debug` and
`--timing-json`:
```sh
pnpm crabbox:run -- --debug --timing-json -- \
pnpm crabbox:run -- --provider blacksmith-testbox --debug --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed
```
Full suite:
```sh
pnpm crabbox:run -- --debug --timing-json -- \
pnpm crabbox:run -- --provider blacksmith-testbox --debug --timing-json -- \
CI=1 NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test
```
@@ -569,10 +413,9 @@ Raw Blacksmith footguns:
- Treat `blacksmith testbox list` as cleanup diagnostics, not a shared reusable
queue.
Use Blacksmith only when the task is specifically about Testbox, brokered AWS
is unavailable, or an explicit comparison is needed. If Blacksmith is down or
quota-limited, do not keep probing it; stay on brokered AWS and note the
delegated-provider outage.
Escalate to owned AWS/Hetzner only when Blacksmith is down, quota-limited,
missing the needed environment, or owned capacity is the explicit goal. Use the
Owned Cloud Fallback section below.
## Blacksmith Backend Notes
@@ -608,14 +451,13 @@ Important Blacksmith footguns:
blacksmith auth login --non-interactive --organization openclaw
```
## Brokered AWS
## Owned Cloud Fallback
Use AWS for normal OpenClaw remote proof. The repo `.crabbox.yaml` already
selects brokered AWS, so omit `--provider` unless you are testing a different
provider deliberately.
Use AWS/Hetzner only when Blacksmith is down, quota-limited, missing the needed
environment, or owned capacity is explicitly the goal.
```sh
pnpm crabbox:warmup -- --class beast --market on-demand --idle-timeout 90m
pnpm crabbox:warmup -- --provider aws --class beast --market on-demand --idle-timeout 90m
pnpm crabbox:hydrate -- --id <cbx_id-or-slug>
pnpm crabbox:run -- --id <cbx_id-or-slug> --timing-json --shell -- "env NODE_OPTIONS=--max-old-space-size=4096 OPENCLAW_TEST_PROJECTS_PARALLEL=6 OPENCLAW_VITEST_MAX_WORKERS=1 OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS=900000 pnpm test:changed"
pnpm crabbox:stop -- <cbx_id-or-slug>
@@ -639,8 +481,8 @@ crabbox whoami
- If broker auth is missing, run `crabbox login --url https://crabbox.openclaw.ai --provider aws`.
- If the CLI asks for `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, or AWS
profile setup during normal OpenClaw validation, assume the agent selected
the wrong path. Use brokered `crabbox login` or an existing brokered lease
before asking the user for cloud credentials.
the wrong path. Use brokered `crabbox login`, `--provider blacksmith-testbox`,
or an existing brokered lease before asking the user for cloud credentials.
- Ask for AWS keys only for explicit direct-provider/account administration,
not for normal brokered OpenClaw proof.
- Trusted automation may still use
@@ -653,7 +495,8 @@ macOS config lives at:
```
It should include `broker.url`, `broker.token`, and usually `provider: aws`
for OpenClaw lanes. Let that config drive normal validation.
for owned-cloud lanes. Do not let that config override the OpenClaw default
when Blacksmith proof is requested; pass `--provider blacksmith-testbox`.
### Interactive Desktop / WebVNC
@@ -673,10 +516,7 @@ crabbox run --id <lease> --shell -- 'DISPLAY=:99 xdotool search --onlyvisible --
crabbox status --id <id-or-slug> --wait
crabbox inspect --id <id-or-slug> --json
crabbox sync-plan
crabbox history --limit 20
crabbox history --lease <id-or-slug>
crabbox attach <run_id>
crabbox events <run_id> --json
crabbox logs <run_id>
crabbox results <run_id>
crabbox cache stats --id <id-or-slug>
@@ -691,15 +531,14 @@ Use `--market spot|on-demand` only on AWS warmup/one-shot runs.
## Failure Triage
- Crabbox cannot find provider: verify `../crabbox/bin/crabbox --help` lists
the provider selected by `.crabbox.yaml`; update Crabbox before falling back.
`blacksmith-testbox`; update Crabbox before falling back.
- Hydration stuck or failed: open the printed GitHub Actions run URL and inspect
the hydration step.
- Sync failed: rerun with `--debug`; check changed-file count and whether the
checkout is dirty.
- Command failed: rerun only the failing shard/file first. Do not rerun a full
suite until the focused failure is understood.
- Cleanup uncertain: `crabbox list --provider aws`; for explicit Blacksmith
runs, use `blacksmith testbox list` and stop owned `tbx_...` leases you
- Cleanup uncertain: `blacksmith testbox list`; stop owned `tbx_...` leases you
created.
- Crabbox broken but Blacksmith works: use the direct Blacksmith fallback above,
then file/fix the Crabbox issue.

View File

@@ -1,44 +0,0 @@
---
name: discrawl
description: "Discord archive: search, sync freshness, DMs, channel slices, SQL counts, and Discrawl repo work."
metadata:
openclaw:
homepage: https://github.com/openclaw/discrawl
requires:
bins:
- discrawl
install:
- kind: go
module: github.com/openclaw/discrawl/cmd/discrawl@latest
bins:
- discrawl
---
# Discrawl
Use local Discord archive data before live Discord APIs. Check freshness for recent/current questions:
```bash
discrawl status --json
discrawl doctor
```
Refresh only when stale or asked:
```bash
discrawl sync --source wiretap
discrawl sync
```
Query with bounded slices:
```bash
DISCRAWL_NO_AUTO_UPDATE=1 discrawl search --limit 20 "query"
discrawl messages --channel '#maintainers' --days 7 --all
discrawl dms --last 20
DISCRAWL_NO_AUTO_UPDATE=1 discrawl --json sql "select count(*) from messages;"
```
Report absolute date spans, channel/DM names, counts, and known gaps. Use read-only SQL for exact counts/rankings. Never use `--unsafe --confirm` unless the user explicitly requests a reviewed DB mutation.
Boundaries: bot sync needs configured Discord bot credentials. Wiretap reads local Discord Desktop artifacts only; do not extract user tokens, call Discord as the user, or write to Discord storage. Git-share snapshots must not include secrets or `@me` DM rows.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Discrawl"
short_description: "Search local Discord archives and freshness"
default_prompt: "Use $discrawl to search local Discord archives, check freshness, inspect DMs or channel slices, and report exact date spans and source gaps."

View File

@@ -1,50 +1,68 @@
---
name: gitcrawl
description: "GitHub archive: issue/PR search, sync freshness, duplicate clusters, gh-shim PR status, and Gitcrawl repo work."
description: Use gitcrawl for OpenClaw issue and PR archive search, duplicate discovery, related-thread clustering, and local GitHub mirror freshness checks.
metadata:
openclaw:
homepage: https://github.com/openclaw/gitcrawl
requires:
bins:
- gitcrawl
install:
- kind: go
module: github.com/openclaw/gitcrawl/cmd/gitcrawl@latest
bins:
- gitcrawl
---
# Gitcrawl
Use local GitHub issue/PR archives before live GitHub search. Check freshness first:
Use this skill before live GitHub search when triaging OpenClaw issues or PRs.
`gitcrawl` is the local candidate-discovery layer. It is fast, includes open and closed threads, and can surface duplicate attempts, related issues, and already-landed fixes. It is not the final source of truth for comments, labels, merges, closes, or current CI.
## Default Flow
1. Check local state:
```bash
gitcrawl doctor --json
```
Find candidates:
2. Read the target from the local archive:
```bash
gitcrawl threads openclaw/openclaw --numbers <issue-or-pr-number> --include-closed --json
gitcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 12 --json
gitcrawl search issues "query" -R openclaw/openclaw --state open --json number,title,url
gitcrawl clusters openclaw/openclaw --sort size --min-size 5
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id>
```
For PR triage, start cached and go live only before mutation/merge decisions:
3. Find related candidates:
```bash
gitcrawl gh pr status <number-or-url> -R openclaw/openclaw --compact
gitcrawl gh pr view <number-or-url> -R openclaw/openclaw --json number,title,state,url,isDraft,headRef,headSha
gitcrawl gh --live pr status <number-or-url> -R openclaw/openclaw --compact
gitcrawl neighbors openclaw/openclaw --number <issue-or-pr-number> --limit 12 --json
gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hybrid --limit 20 --json
```
Use live `gh` plus checkout proof before commenting, labeling, closing, reopening, merging, or filing a PR review:
4. Inspect relevant clusters:
```bash
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
```
5. Verify anything actionable with live GitHub and the checkout:
```bash
gh pr view <number> --json number,title,state,mergedAt,body,files,comments,reviews,statusCheckRollup
gh issue view <number> --json number,title,state,body,comments,closedAt
```
Report absolute dates, repo names, issue/PR numbers, cluster ids, and source gaps. Do not close/label from similarity alone; require matching intent plus live verification.
## Freshness Rules
- Treat `gitcrawl` as stale if `doctor` shows no target thread, an old `last_sync_at`, missing embeddings for neighbor/search commands, or a clearly wrong open/closed state.
- If stale data blocks the decision, refresh the portable store first:
```bash
gitcrawl init --portable-store git@github.com:openclaw/gitcrawl-store.git --json
```
- Run expensive update commands such as `gitcrawl sync --include-comments` only when the user asked to update the local store or stale data is blocking the decision.
- The sync default is all GitHub thread states; pass `--state open`, `--state closed`, or `--state all` only when a task requires a narrower or explicit scope.
## Boundaries
- Use `gitcrawl` for candidates, clusters, and historical context.
- Use `gh`, `gh api`, and the current checkout for live state before commenting, labeling, closing, reopening, merging, or filing a PR review.
- Do not close or label based only on `gitcrawl` similarity. Require matching problem intent plus live verification.
- If `gitcrawl` is unavailable, say so and fall back to targeted `gh search` rather than blocking normal maintainer work.

View File

@@ -1,44 +0,0 @@
---
name: graincrawl
description: "Granola archive: search, sync freshness, notes, transcripts, panels, SQL counts, and Graincrawl repo work."
metadata:
openclaw:
homepage: https://github.com/openclaw/graincrawl
requires:
bins:
- graincrawl
install:
- kind: go
module: github.com/vincentkoc/graincrawl/cmd/graincrawl@latest
bins:
- graincrawl
---
# Graincrawl
Use local Granola archive data first. Check freshness for recent/current questions:
```bash
graincrawl doctor --json
graincrawl status --json
```
Refresh only when stale or asked:
```bash
graincrawl sync --source private-api
graincrawl sync --source desktop-cache
```
Query with bounded reads:
```bash
graincrawl search "query"
graincrawl notes --json
graincrawl note get <id>
graincrawl transcripts get <id>
graincrawl panels get <id>
graincrawl --json sql "select count(*) as notes from notes;"
```
Report absolute date spans, note titles, source gaps, and transcript/panel availability. Use read-only SQL for exact counts/rankings. Before encrypted source debugging, run explicit unlock/secrets checks; do not surprise-prompt Keychain.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Graincrawl"
short_description: "Search local Granola notes and transcripts"
default_prompt: "Use $graincrawl to search local Granola notes, transcripts, and panels, check freshness, and report exact date spans and source gaps."

View File

@@ -1,42 +0,0 @@
---
name: notcrawl
description: "Notion archive: search, sync freshness, pages/databases, Markdown exports, SQL counts, and Notcrawl repo work."
metadata:
openclaw:
homepage: https://github.com/openclaw/notcrawl
requires:
bins:
- notcrawl
install:
- kind: go
module: github.com/vincentkoc/notcrawl/cmd/notcrawl@latest
bins:
- notcrawl
---
# Notcrawl
Use local Notion archive data before browsing or live Notion API calls. Check freshness for recent/current questions:
```bash
notcrawl doctor
notcrawl status --json
```
Refresh only when stale or asked:
```bash
notcrawl sync --source desktop
notcrawl sync --source api
```
Query with bounded reads:
```bash
notcrawl search "query"
notcrawl databases
notcrawl report
notcrawl sql "select count(*) from pages;"
```
Report workspace/teamspace, page/database titles, absolute date spans, counts, and known gaps. Use read-only SQL only; never mutate the archive. API mode requires `NOTION_TOKEN`; do not assume token availability.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Notcrawl"
short_description: "Search local Notion archives and freshness"
default_prompt: "Use $notcrawl to search local Notion pages and databases, check freshness, inspect exports, and report exact date spans and source gaps."

View File

@@ -73,9 +73,8 @@ openclaw logs --follow
tool execution.
- **Worker/dist:** run `pnpm build` when touching workers, dynamic imports,
package exports, lazy runtime boundaries, or published paths.
- **Live keys:** use the configured secret workflow for missing provider keys
before saying live proof is blocked. Env checks are presence-only; never print
secrets.
- **Live keys:** check local `~/.profile` for key presence/length before saying
live proof is blocked. Never print secrets.
## Code Pointers

View File

@@ -1,64 +0,0 @@
---
name: openclaw-docker-e2e-authoring
description: "Author OpenClaw Docker E2E and live provider Docker lanes."
---
# OpenClaw Docker E2E Authoring
Use this when adding or changing Docker E2E lanes, release-path Docker tests,
or live-provider Docker proof.
## Lane Choice
- Deterministic Docker: fake the dependency/server and assert the exact runtime
contract crossing the boundary.
- Live Docker: use real provider credentials/model only when user-visible
behavior needs the real service.
- Prefer both when they prove different risks: deterministic for byte/payload
routing, live for actual provider behavior.
## Authoring Rules
- Test-only helpers live in `test/helpers` or `scripts/e2e/lib/<lane>/`, not
`src/**`, unless production imports them.
- Package-installed app runs from `/app`; mount only explicit harness/helper
paths read-only.
- Fake servers should log boundary requests as JSONL and clients should assert
the real dependency payload, not just process success.
- Add the package script and `scripts/lib/docker-e2e-scenarios.mjs` lane in the
same change.
- If a lane installs a plugin from npm, default the spec via env so published
and local override paths are both testable.
## Media And Vision
- Expected answer must exist only in pixels or provider output being tested.
- Use neutral filenames, neutral prompts, and no metadata leaks.
- Random bitmap/OCR tokens reuse the repo OCR-safe alphabet `24567ACEF` unless
the test owns a stronger glyph set.
- Make the expected answer unique per run when proving real image
understanding.
## `chat.send` E2E
- Require `chat.send` to return `status: "started"` and a string `runId`.
- Wait for completion with `agent.wait`.
- Assert final user-visible text via `chat.history` when event ordering is not
the behavior under test.
- Keep originating channel/account metadata only when the bug path needs queued
inbound/channel context.
## Verification
Run the smallest proof that covers the touched lane:
```bash
pnpm exec oxfmt --write <changed files>
node --check <new .mjs files>
bash -n <new .sh files>
node scripts/run-vitest.mjs test/scripts/docker-e2e-plan.test.ts
OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:<lane>
```
For real-provider lanes, run the matching live Docker script after deterministic
Docker is green. Finish with `$autoreview` before commit/PR.

View File

@@ -42,20 +42,16 @@ Choose the page type before writing:
Use this default topic page structure:
1. Title: name the major entity or surface.
2. Opening overview: start with a few unheaded sentences that explain what it
is, what it owns, and what it does not own. Do not add a `## Overview`
heading unless the page is itself an overview index.
2. Overview: explain what it is, what it owns, and what it does not own.
3. Requirements: include only when setup needs specific accounts, versions,
permissions, plugins, operating systems, or credentials.
4. Quickstart: show the recommended setup path and smallest reliable verification.
5. Configuration: show the minimum configuration needed to use the surface,
common variants users must choose between, and where each option is set:
CLI, config file, environment variable, plugin manifest, dashboard, or API.
6. Major subtopics: organize the entity's major concepts, workflows, and
decisions by reader intent. Put each major subtopic under its own heading;
do not wrap them in a generic `## Subtopics` section.
7. Troubleshooting: diagnose common observable failures under an explicit
`## Troubleshooting` heading.
6. Subtopics: organize the entity's major concepts, workflows, and decisions by
reader intent.
7. Troubleshooting: diagnose common observable failures.
8. Related: link to guides, references, commands, concepts, and adjacent topics.
Topic pages may be longer than quickstarts, but they should not become exhaustive

View File

@@ -1,95 +0,0 @@
---
name: openclaw-mac-release
description: "Run or recover OpenClaw macOS release signing, notarization, appcast, and asset promotion."
---
# OpenClaw Mac Release
Use with `$openclaw-release-maintainer`, `$openclaw-release-ci`, and `$one-password` when stable macOS assets, private mac preflight, notarization, appcast promotion, or mac release recovery is involved.
## Credentials
- Canonical ASC item: vault `Molty`, title `API Key - App Store Connect - Personal - Release`.
- Fields: `private_key_p8`, `key_id`, `issuer_id`.
- Current known good key id: `AKVLXW849T`.
- Legacy mirror: vault `Private`, title `API Key - App Store Connect - Personal`; keep it synced for older refs.
- Stale/revoked key symptom: `xcrun notarytool submit` fails with `HTTP status code: 401. Unauthenticated`.
- Validate candidate ASC credentials with `xcrun notarytool history` before setting GitHub secrets.
## 1Password
- Use `$one-password`: all `op` work inside one persistent tmux session, no secret output.
- Prefer `OP_SERVICE_ACCOUNT_TOKEN` from `~/.profile` for Molty reads.
- Do not assume `MOLTY_OP_SERVICE_ACCOUNT_TOKEN` is alive; it has previously pointed at a deleted service account.
- If a service token fails, run status-only checks: token present/length and `op whoami`; never print token values.
- If desktop app auth is needed but Touch ID is unavailable, set `OP_BIOMETRIC_UNLOCK_ENABLED=false` for the manual `op account add --signin` path.
## GitHub Secrets
Target private repo environment: `openclaw/releases-private`, env `mac-release`.
Set only after local notary auth validation:
- `APP_STORE_CONNECT_API_KEY_P8`
- `APP_STORE_CONNECT_KEY_ID`
- `APP_STORE_CONNECT_ISSUER_ID`
Do not update these from mixed sources. All three ASC fields must come from the same 1Password item.
## Workflow Shape
- Public release branch may carry mac-only packaging fixes after the stable tag/npm are already live.
- Use `source_ref=release/YYYY.M.D` for private mac preflight/validation when building that branch variation.
- Keep `tag=vYYYY.M.D` pointing at the original stable release commit.
- Real mac publish must reuse:
- a successful private mac preflight run for the same tag/source SHA
- a successful private mac validation run for the same tag/source SHA
- If preflight source SHA differs from tag SHA, validation must also use the same `source_ref`; promotion rejects mismatched proof.
## Notarization
- OpenClaw uses `scripts/notarize-mac-artifact.sh`.
- `xcrun notarytool submit` should use `--no-s3-acceleration`; accelerated upload can surface misleading 401s even when `notarytool history` succeeds.
- If signing succeeds but notarization fails immediately with 401, check ASC key freshness first.
- If notarization stays in progress for several minutes after key-file write, that is normal Apple wait time; do not edit blindly.
## Dispatch
Private preflight:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D \
-f preflight_only=true \
-f smoke_test_only=false \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
```
Private validation for a branch-variation preflight:
```bash
gh workflow run openclaw-macos-validate.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f source_ref=release/YYYY.M.D
```
Real publish:
```bash
gh workflow run openclaw-macos-publish.yml --repo openclaw/releases-private --ref main \
-f tag=vYYYY.M.D \
-f preflight_only=false \
-f smoke_test_only=false \
-f preflight_run_id=<successful-preflight-run> \
-f validate_run_id=<successful-validation-run> \
-f allow_late_calver_recovery=false \
-f public_release_branch=release/YYYY.M.D
```
## Verify
- `gh release view vYYYY.M.D --repo openclaw/openclaw` shows zip, dmg, dSYM zip, not draft, not prerelease.
- Public `main` `appcast.xml` points at `OpenClaw-YYYY.M.D.zip`.
- Appcast entry has `sparkle:version`, `sparkle:shortVersionString`, length, and `sparkle:edSignature`.

View File

@@ -56,7 +56,7 @@ Use this skill for Parallels guest workflows and smoke interpretation. Do not lo
- For unpublished targets, pack the candidate on the host, serve the `.tgz` over the harness HTTP server, and point the guest updater at that served package. Prefer `openclaw update --tag http://<host-ip>:<port>/openclaw-<version>.tgz --yes --json`; when channel persistence also matters, pass `--channel <stable|beta>` and set `OPENCLAW_UPDATE_PACKAGE_SPEC` to the same served URL in the guest update environment. The command under test must still be `openclaw update`, not direct npm.
- For unpublished local-fix validation, remember the old baseline updater code still controls the first hop. A fix that lives only in the new updater code cannot change that already-running old process; the served candidate must either keep package/plugin metadata compatible with the baseline host or the baseline itself must include the updater fix.
- For beta/stable verification, resolve the tag immediately before the run (`npm view openclaw@beta version dist.tarball` or `npm view openclaw@latest ...`). Tags can move while a long VM matrix is already running; restart the matrix when the intended prerelease appears after an earlier registry 404/tag-lag check.
- Use the configured secret workflow to inject only the provider keys needed by OpenAI/Anthropic lanes. Do not print secrets or env dumps; pass provider secrets through the guest exec environment.
- Source Peter's profile in the host shell (`set -a; source "$HOME/.profile"; set +a`) before OpenAI/Anthropic lanes. Do not print profile contents or env dumps; pass provider secrets through the guest exec environment.
- Same-guest update verification should set the default model explicitly to `openai/gpt-5.4` before the agent turn and use a fresh explicit `--session-id` so old session model state does not leak into the check.
- The aggregate npm-update wrapper must resolve the Linux VM with the same Ubuntu fallback policy as `parallels-linux-smoke.sh` before both fresh and update lanes. Treat any Ubuntu guest with major version `>= 24` as acceptable when the exact default VM is missing, preferring the closest version match. On Peter's current host today, missing `Ubuntu 24.04.3 ARM64` should fall back to `Ubuntu 25.10`.
- On macOS same-guest update checks, restart the gateway after the npm upgrade before `gateway status` / `agent`; launchd can otherwise report a loaded service while the old process has exited and the fresh process is not RPC-ready yet.

View File

@@ -24,36 +24,6 @@ gitcrawl search openclaw/openclaw --query "<scope or title keywords>" --mode hyb
gitcrawl cluster-detail openclaw/openclaw --id <cluster-id> --member-limit 20 --body-chars 280 --json
```
## Claim specific review targets
When a maintainer asks Codex to review, triage, fix, or land a specific OpenClaw issue/PR, check assignment before deep work.
- Identify the requesting maintainer's GitHub login. In this environment, default Peter to `steipete`; if another maintainer is clearly the requester, use that maintainer's bare login.
- Read current assignees with live `gh issue view` / `gh pr view`; `gitcrawl` is not enough for assignment state.
- If unassigned, assign the requester before deep review. This is allowed for specific requested targets; do not auto-assign broad discovery candidates or shortlists.
- If assigned to someone else, say so clearly before analysis and include assignment age:
- fresh: assigned within 6h; treat as actively owned unless user explicitly asks to continue or reassign
- stale: assigned 6h+ ago; treat as ownership hint, not a hard block; continue only with that caveat
- If assigned to requester plus others, mention co-assignees and continue.
- If assignment event time is unavailable, say `assigned, time unknown`; treat as assigned, not stale.
- Never remove or replace assignees unless explicitly asked.
Assignment time proof:
```bash
gh api "repos/openclaw/openclaw/issues/<number>/timeline" --paginate \
-H "Accept: application/vnd.github+json" \
--jq '[.[] | select(.event=="assigned") | {assignee:.assignee.login, assigner:.assigner.login, actor:.actor.login, created_at}]'
```
Use the newest `assigned` event for each current assignee. Issue timeline events expose `created_at`; GitHub GraphQL `AssignedEvent.createdAt` is also valid when REST pagination is awkward.
Claim command for issues or PRs:
```bash
gh api -X POST "repos/openclaw/openclaw/issues/<number>/assignees" -f 'assignees[]=<login>' >/dev/null
```
## Surface opener identity
- For every reviewed, triaged, closed, or landed issue/PR, show the opener's human name when available, GitHub login, and account age.
@@ -168,9 +138,7 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
- Start every PR review with 1-3 plain sentences explaining what the change does and why it matters. Put this before `Findings`.
- Then list findings first. If none, say `No blocking findings` or `No findings`.
- Always answer: bug/behavior being fixed, PR/issue URL and affected surface, provenance for regressions when traceable, and best-fix verdict.
- For bug/regression fixes, include a compact `Provenance:` line after cause/root-cause when a bounded history pass can identify it. Use `git log -S/-G`, `git blame`, linked PRs/issues, and tests; separate author, committer/merger, and current PR author when they differ.
- Phrase provenance as `introduced by`, `made visible by`, or `carried forward by`, with confidence (`clear`, `likely`, `unknown`). If unclear, say what evidence is missing instead of guessing. For features, docs, and refactors, use `Provenance: N/A` or omit it when no broken behavior is being fixed.
- Always answer: bug/behavior being fixed, PR/issue URL and affected surface, and best-fix verdict.
- Keep summaries compact, but include enough proof that the verdict is auditable without rereading the PR.
## Read beyond the diff
@@ -192,9 +160,8 @@ Output only qualifying candidates, with: ref, surface, proof, cause, fix sketch,
- Before landing, require:
1. symptom evidence such as a repro, logs, or a failing test
2. a verified root cause in code with file/line
3. provenance for regressions when traceable by bounded git/PR history
4. a fix that touches the implicated code path
5. a regression test when feasible, or explicit manual verification plus a reason no test was added
3. a fix that touches the implicated code path
4. a regression test when feasible, or explicit manual verification plus a reason no test was added
- If the claim is unsubstantiated or likely wrong, request evidence or changes instead of merging.
- If the linked issue appears outdated or incorrect, correct triage first. Do not merge a speculative fix.
- If Crabbox/E2E proof is blocked, say exactly why and use the closest available
@@ -247,7 +214,6 @@ gh search issues --repo openclaw/openclaw --match title,body --limit 50 \
not correctness findings.
- If bot review conversations exist on your PR, address them and resolve them yourself once fixed.
- Leave a review conversation unresolved only when reviewer or maintainer judgment is still needed.
- Before landing any PR with non-trivial code changes, run `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already covered it, the change is trivial/docs-only, or the user opts out.
- When landing or merging any PR, follow the global `/landpr` process.
- Use `scripts/committer "<msg>" <file...>` for scoped commits instead of manual `git add` and `git commit`.
- Keep commit messages concise and action-oriented.

View File

@@ -227,9 +227,7 @@ pnpm openclaw qa manual \
- Treat the concrete Codex model name as user/config input; do not hardcode it in source, docs examples, or scenarios.
- Live QA preserves `CODEX_HOME` so Codex CLI auth/config works while keeping `HOME` and `OPENCLAW_HOME` sandboxed.
- Mock QA should scrub `CODEX_HOME`.
- If Codex returns fallback/auth text every turn, first check `CODEX_HOME`,
relevant secret-backed auth, and gateway child logs before changing
scenario assertions.
- If Codex returns fallback/auth text every turn, first check `CODEX_HOME`, `~/.profile`, and gateway child logs before changing scenario assertions.
- For model comparison, include `codex-cli/<codex-model>` as another candidate in `qa character-eval`; the report should label it as an opaque model name.
## Repo facts

View File

@@ -1,93 +0,0 @@
---
name: openclaw-release-ci
description: "Run, watch, debug, and summarize OpenClaw full release CI, release checks, live provider gates, install/update proofs, and release-secret preflights."
---
# OpenClaw Release CI
Use this with `$openclaw-release-maintainer` and `$openclaw-testing` when a release candidate needs full validation, install/update proof, live provider checks, or CI recovery.
## Guardrails
- No version bump, tag, npm publish, GitHub release, or release promotion without explicit operator approval.
- Validate provider secrets before dispatching expensive full release matrices.
- Do not set GitHub secrets from unvalidated 1Password candidates. If a candidate returns 401/403, leave the existing secret alone and report the exact missing provider.
- Use `$one-password` for secret reads/writes: one persistent tmux session, targeted items only, no secret output.
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
## Preflight
Before full release validation:
```bash
node .agents/skills/openclaw-release-ci/scripts/verify-provider-secrets.mjs --required openai,anthropic,fireworks
gh api rate_limit --jq '.resources.core'
git status --short --branch
git rev-parse HEAD
```
1Password service-account values are the first source for release provider
preflight. Inject those exact targeted keys first, then run the verifier; use
ambient env only when it was already intentionally injected for this release.
The script prints only provider status and HTTP class, never tokens.
## Dispatch
Prefer the trusted workflow on `main`, target the exact release SHA:
```bash
gh workflow run full-release-validation.yml \
--repo openclaw/openclaw \
--ref main \
-f ref=<release-sha> \
-f provider=openai \
-f mode=both \
-f release_profile=full \
-f rerun_group=all
```
Use `release_profile=stable` unless the operator explicitly asks for the broad advisory provider/media matrix. Use narrow `rerun_group` after focused fixes.
## Watch
Use the summary helper instead of repeated raw polling:
```bash
node .agents/skills/openclaw-release-ci/scripts/release-ci-summary.mjs <full-release-run-id>
```
Then watch only when useful:
```bash
gh run watch <full-release-run-id> --repo openclaw/openclaw --exit-status
```
Stop watchers before ending the turn or switching strategy.
## Failure Triage
1. Confirm parent SHA and child run IDs.
2. List failed jobs only:
```bash
gh run view <child-run-id> --repo openclaw/openclaw --json jobs \
--jq '.jobs[] | select(.conclusion=="failure" or .conclusion=="timed_out" or .conclusion=="cancelled") | [.databaseId,.name,.conclusion,.url] | @tsv'
```
3. Fetch one failed job log. If rate-limited, note reset time and avoid more REST calls.
4. For secret-looking failures, validate the provider endpoint from the same secret source before editing code.
5. For live-cache failures, inspect whether it is missing/invalid key, empty text, provider refusal, timeout, or baseline miss. Do not weaken release gates without clear provider evidence.
6. Fix narrowly, run local/changed proof, commit, push, rerun the smallest matching group.
## Evidence
Record:
- release SHA
- full parent run URL
- child run IDs and conclusions: CI, Release Checks, Plugin Prerelease, NPM Telegram
- targeted local proof commands
- provider-secret preflight result
- known gaps or unrelated failures
For lessons and recovery patterns, read `references/release-ci-notes.md`.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "OpenClaw Release CI"
short_description: "Verify and debug OpenClaw release validation runs"
default_prompt: "Use $openclaw-release-ci to preflight provider secrets, watch full release validation, summarize child runs, and triage only failing release lanes."

View File

@@ -1,41 +0,0 @@
# Release CI Notes
## What Went Wrong
- Full validation was started before all provider keys were proven valid.
- GitHub secret presence was confused with key validity.
- Repeated `gh run view` and log fetches exhausted REST quota.
- Parent run state was less useful than child run evidence.
- Live-cache failures needed structured classification: invalid key, empty provider output, timeout, or real cache regression.
- Background watchers accumulated and made interruption recovery harder.
## Better Defaults
- Run provider-secret preflight first. Require real `/models` or equivalent endpoint checks for release-blocking providers.
- Keep one watcher open. Use child summaries every few minutes, not every few seconds.
- Fetch failed-job logs only after a job reaches a terminal failing state.
- Prefer narrow `rerun_group` recovery after a focused fix.
- Leave bad secrets unset. A 401 candidate from 1Password should not overwrite GitHub.
- Make the final release evidence note durable: parent URL, child run URLs, SHA, command proof, and gaps.
## Secret Handling Pattern
- Use `$one-password`; never run broad env dumps.
- Search exact item titles or known ids.
- Validate candidates without printing values.
- Set GitHub secrets only after endpoint validation succeeds.
- After setting, verify metadata with `gh secret list`, not value output.
## Live Cache Pattern
- Empty text with token usage is a provider/output issue until proven otherwise.
- Retry lane-level mismatches once with a fresh session id.
- Keep cache baselines strict, but log enough structured usage to distinguish cache miss from response mismatch.
- If a provider key validates locally but fails in Actions, inspect whether the workflow reads the expected secret name.
## Quota-Safe GitHub Pattern
- Check `gh api rate_limit --jq '.resources.core'` before log-heavy work.
- Use one child-run listing call, then inspect failed jobs only.
- If remaining quota is low, pause until reset; do not keep polling.
- Prefer GraphQL only for metadata when REST is exhausted; logs still need REST.

View File

@@ -1,79 +0,0 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import process from "node:process";
const runId = process.argv[2];
const repo = process.env.OPENCLAW_RELEASE_REPO || "openclaw/openclaw";
if (!runId) {
console.error("usage: release-ci-summary.mjs <full-release-run-id>");
process.exit(2);
}
function gh(args) {
return execFileSync("gh", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function jsonGh(args) {
return JSON.parse(gh(args));
}
function rate() {
try {
return jsonGh(["api", "rate_limit"]).resources.core;
} catch {
return undefined;
}
}
const core = rate();
if (core) {
const reset = new Date(core.reset * 1000).toISOString();
console.log(`rate: remaining=${core.remaining}/${core.limit} reset=${reset}`);
if (core.remaining < 20) {
console.error("rate too low for CI summary; wait for reset before polling");
process.exit(3);
}
}
const parent = jsonGh([
"run",
"view",
runId,
"--repo",
repo,
"--json",
"status,conclusion,createdAt,headSha,url,jobs",
]);
console.log(`parent: ${runId} ${parent.status}/${parent.conclusion || "none"}`);
console.log(`sha: ${parent.headSha}`);
console.log(`url: ${parent.url}`);
for (const job of parent.jobs ?? []) {
const marker = job.conclusion || job.status;
console.log(`parent-job: ${marker} ${job.name}`);
}
const since = parent.createdAt;
const runList = gh([
"api",
`repos/${repo}/actions/runs?per_page=100`,
"--jq",
`.workflow_runs[] | select(.created_at >= "${since}") | select(.name=="CI" or .name=="OpenClaw Release Checks" or .name=="Plugin Prerelease" or .name=="NPM Telegram Beta E2E" or .name=="Full Release Validation") | [.id,.name,.status,.conclusion,.head_sha,.html_url] | @tsv`,
]).trim();
if (!runList) {
console.log("children: none found yet");
process.exit(0);
}
console.log("children:");
for (const line of runList.split("\n")) {
const [id, name, status, conclusion, sha, url] = line.split("\t");
console.log(`child: ${id} ${name} ${status}/${conclusion || "none"} sha=${sha}`);
console.log(`child-url: ${url}`);
}

View File

@@ -1,113 +0,0 @@
#!/usr/bin/env node
import process from "node:process";
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const arg = process.argv[index];
if (!arg.startsWith("--")) continue;
const [key, inlineValue] = arg.slice(2).split("=", 2);
const value = inlineValue ?? process.argv[index + 1];
if (inlineValue === undefined) index += 1;
args.set(key, value);
}
const requiredInput = String(args.get("required") ?? "openai,anthropic").trim();
const required = new Set(
(requiredInput.toLowerCase() === "none" ? "" : requiredInput)
.split(",")
.map((entry) => entry.trim().toLowerCase())
.filter(Boolean),
);
const timeoutMs = Number(args.get("timeout-ms") ?? 10_000);
function envFirst(names) {
for (const name of names) {
const value = process.env[name]?.trim();
if (value) return { name, value };
}
return undefined;
}
async function checkProvider(id, config) {
const secret = envFirst(config.env);
if (!secret) {
return { id, ok: false, status: "missing", env: config.env.join("|") };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const headers = config.headers(secret.value);
const response = await fetch(config.url, {
headers,
signal: controller.signal,
});
return {
id,
ok: response.ok,
status: response.ok ? "ok" : `http_${response.status}`,
env: secret.name,
};
} catch (error) {
return {
id,
ok: false,
status: error?.name === "AbortError" ? "timeout" : "error",
env: secret.name,
};
} finally {
clearTimeout(timer);
}
}
const providers = {
openai: {
env: ["OPENAI_API_KEY"],
url: "https://api.openai.com/v1/models",
headers: (token) => ({ authorization: `Bearer ${token}` }),
},
anthropic: {
env: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
url: "https://api.anthropic.com/v1/models",
headers: (token) => ({
"anthropic-version": "2023-06-01",
"x-api-key": token,
}),
},
fireworks: {
env: ["FIREWORKS_API_KEY"],
url: "https://api.fireworks.ai/inference/v1/models",
headers: (token) => ({ authorization: `Bearer ${token}` }),
},
openrouter: {
env: ["OPENROUTER_API_KEY"],
url: "https://openrouter.ai/api/v1/models",
headers: (token) => ({ authorization: `Bearer ${token}` }),
},
};
const unknown = [...required].filter((id) => !providers[id]);
if (unknown.length > 0) {
console.error(`unknown providers: ${unknown.join(",")}`);
process.exit(2);
}
const results = [];
for (const id of Object.keys(providers)) {
if (required.has(id) || envFirst(providers[id].env)) {
results.push(await checkProvider(id, providers[id]));
}
}
let failed = false;
for (const result of results) {
const requiredLabel = required.has(result.id) ? "required" : "optional";
console.log(`${result.id}: ${result.status} env=${result.env} ${requiredLabel}`);
if (required.has(result.id) && !result.ok) failed = true;
}
if (failed) {
console.error("release provider secret preflight failed");
process.exit(1);
}

View File

@@ -65,8 +65,8 @@ Use this skill for release and publish-time workflow. Keep ordinary development
stable base version section, for example `v2026.4.20-beta.1` uses
`## 2026.4.20` release notes.
- When any beta or stable release is live, make a best-effort Discord
announcement using the configured secret workflow; do not block or roll back
the release if the announcement fails.
announcement using Peter's bot token from `.profile`; do not block or roll
back the release if the announcement fails.
- When asked to announce on X, use `~/Projects/bird/bird` and follow the
release tweet style below.
@@ -170,13 +170,6 @@ live`; keep it clearly beta and avoid implying stable promotion.
CI, validation, or internal release mechanics unless the release is explicitly
about those. Peter prefers concrete user wins: features, integrations,
workflow improvements, and practical reliability fixes.
- Do not feature QA parity, test coverage, release gates, or validation lanes in
user-facing launch tweets. Keep them for release notes or maintainer proof
unless the operator explicitly asks for validation-focused copy.
- Do not feature plugin-author or developer tooling such as SDK helpers,
tool-plugin scaffolding, build/validate/init commands, or internal CLI
plumbing in general user-facing launch tweets unless the operator explicitly
asks for developer-focused copy.
- Tone: high-signal, slightly cheeky, confident, not corporate. One joke is
enough. Avoid punching down, insulting users, or promising what was not
verified.
@@ -295,11 +288,13 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
## Check all relevant release builds
- Always validate the OpenClaw npm release path before creating the tag.
- Use the configured secret workflow before live release validation so OpenAI
and Anthropic credentials are available without printing secrets.
- Source Peter's profile before live release validation so OpenAI and Anthropic
credentials are available without printing secrets:
`set -a; source "$HOME/.profile"; set +a`.
- Parallels validation and any local live model QA for this train must use both
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY`. If either cannot be injected, stop
before starting those local long lanes and report the missing key.
`OPENAI_API_KEY` and `ANTHROPIC_API_KEY`. If either is missing after sourcing
`.profile`, stop before starting those local long lanes and report the
missing key.
- Live credentialed channel QA is the GitHub Actions workflow
`QA-Lab - All Lanes` (`.github/workflows/qa-live-telegram-convex.yml`), not a
local substitute. Dispatch it from Actions against the release tag and wait
@@ -597,7 +592,8 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
If a pre-npm lane fails before any tag/package leaves the machine, fix and
rerun the same intended beta attempt. Repeat up to the operator's
authorized beta-attempt limit, normally 4.
24. Announce the beta/stable release on Discord best-effort using the configured secret workflow.
24. Announce the beta/stable release on Discord best-effort using Peter's bot
token from `.profile`.
25. If the operator requested beta only, stop after beta verification and the
announcement.
26. If the stable release was published to `beta`, use the light stable

View File

@@ -34,10 +34,10 @@ Supports single or multiple alerts. For multiple alerts, process in ascending or
For each alert:
1. **Identify**`fetch-alert` + `fetch-content` to get metadata and body
2. **Decide** — Agent reads the body file, identifies whether plaintext secrets remain, and produces a redacted version only when needed
3. **Redact**`redact-body-if-needed` for issue/PR body; skip for comments (delete directly)
2. **Decide** — Agent reads the body file, identifies all secrets, produces redacted version
3. **Redact**`redact-body` for issue/PR body; skip for comments (delete directly)
4. **Purge**`delete-comment` + `recreate-comment` for comments; cannot purge body history
5. **Notify**`notify` posts the right template per location type, unless the current issue/PR body is already redacted
5. **Notify**`notify` posts the right template per location type
6. **Resolve**`resolve` closes the alert
7. **Summary**`summary` prints formatted results
@@ -81,20 +81,11 @@ The `fetch-content` output includes:
The agent reads the body file from `fetch-content` output and:
1. Identifies ALL secrets in the content (there may be more than the alert flagged)
2. Determines whether any plaintext credential remains in the current body
3. Replaces each remaining secret with `[REDACTED <secret_type>]`**no partial values, no prefix/suffix**
4. Saves the redacted content to a new temp file
2. Replaces each secret with `[REDACTED <secret_type>]`**no partial values, no prefix/suffix**
3. Saves the redacted content to a new temp file
This is the only step that requires semantic understanding. Everything else is mechanical.
For `issue_body` and `pull_request_body`: if the current body has already been redacted by the author and no plaintext credential remains, **do not post a public notification comment**. Resolve the alert with a maintainer-only resolution comment such as:
```bash
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Current issue/PR body is already redacted; no public notification posted."
```
This avoids creating a fresh public pointer to historical sensitive content.
## Step 3: Redact
### For comments (issue_comment / PR comments)
@@ -104,11 +95,9 @@ This avoids creating a fresh public pointer to historical sensitive content.
### For issue_body / pull_request_body
```bash
node secret-scanning.mjs redact-body-if-needed <issue|pr> <NUMBER> <current-body-file> <redacted-body-file> <result-file>
node secret-scanning.mjs redact-body <issue|pr> <NUMBER> <redacted-body-file>
```
Use the `body_file` from `fetch-content` as `<current-body-file>`. The command writes `notify_required` to `<result-file>` and only PATCHes the body when the redacted file differs from the current body.
## Step 4: Purge Edit History
### Comments — Delete and Recreate
@@ -145,12 +134,10 @@ The recreated comment should follow this format:
<redacted original content>
```
### issue_body / pull_request_body — Cannot Purge Edit History
### issue_body / pull_request_body — Cannot Purge
Editing creates an edit history revision with the pre-edit plaintext. This cannot be cleared via API.
Do not advise authors publicly to delete/recreate issues or close/reopen PRs. That can draw attention to historical content. Keep purge guidance maintainer-only.
**Output to maintainer terminal only (never in public comments):**
```
@@ -168,13 +155,12 @@ Cannot clean. Notify author to delete branch or force-push (for unmerged PRs).
## Step 5: Notify
```bash
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID|BODY_REDACTION_RESULT_FILE]
node secret-scanning.mjs notify <TARGET> <AUTHOR> <LOCATION_TYPE> <SECRET_TYPES> [REPLY_TO_NODE_ID]
```
- For non-discussion types, `<TARGET>` is the issue/PR number.
- For `discussion_comment`, `<TARGET>` is the `discussion_node_id` returned by `fetch-content`.
- For reply-style `discussion_comment` locations, pass the optional `reply_to_node_id` from `fetch-content` so the notification stays in the same thread.
- For `issue_body` and `pull_request_body`, pass the `<result-file>` from `redact-body-if-needed`. The script skips notification when `notify_required` is `false` and refuses body notifications without this file.
Secret types are comma-separated: `"Discord Bot Token,Feishu App Secret"`
@@ -184,8 +170,6 @@ The script picks the right template:
- **body types**: "your issue/PR description … redacted in place"
- **commit**: "code you committed"
For `issue_body` and `pull_request_body`, only notify when the current body still contained plaintext and maintainers redacted it. If the user already redacted the current body, skip this step and resolve silently.
## Step 6: Resolve
```bash
@@ -194,7 +178,7 @@ node secret-scanning.mjs resolve <ALERT_NUMBER>
node secret-scanning.mjs resolve <ALERT_NUMBER> revoked "Custom comment"
```
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to remove current plaintext exposure and notify only when public notification is useful. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
Resolution is `revoked` by default. As maintainers we cannot control whether users rotate — our responsibility is to redact + notify. The `revoked` means "this secret should be considered leaked", not "I confirmed it was revoked".
## Step 7: Summary

View File

@@ -7,7 +7,6 @@ import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
const REPO = "openclaw/openclaw";
const REPO_URL = `https://github.com/${REPO}`;
@@ -51,34 +50,6 @@ function ghGraphQL(query, options = {}) {
return gh(["api", "graphql", "-f", `query=${query}`], options);
}
function isBodyLocationType(locationType) {
return locationType === "issue_body" || locationType === "pull_request_body";
}
export function decideBodyRedaction(currentBody, redactedBody) {
const bodyChanged = String(currentBody) !== String(redactedBody);
return {
body_changed: bodyChanged,
notify_required: bodyChanged,
};
}
export function loadBodyRedactionResult(locationType, resultFile) {
if (!isBodyLocationType(locationType)) {
return { notify_required: true };
}
if (!resultFile) {
fail("Body notifications require a redaction result file from redact-body-if-needed");
}
if (!fs.existsSync(resultFile)) fail(`File not found: ${resultFile}`);
const result = JSON.parse(fs.readFileSync(resultFile, "utf8"));
if (typeof result.notify_required !== "boolean") {
fail(`Invalid redaction result file: missing boolean notify_required in ${resultFile}`);
}
return result;
}
function failOnGraphQLFailure(result, message) {
if (result?.gh_failed) {
const details = (
@@ -499,43 +470,6 @@ function cmdRedactBody(kind, number, bodyFile) {
console.log(JSON.stringify({ ok: true, kind, number: Number(number) }));
}
/**
* redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>
* PATCH only when the agent-produced redacted body differs from the current body.
*/
function cmdRedactBodyIfNeeded(kind, number, currentBodyFile, redactedBodyFile, resultFile) {
if (!kind || !number || !currentBodyFile || !redactedBodyFile || !resultFile) {
fail(
"Usage: redact-body-if-needed <issue|pr> <number> <current-body-file> <redacted-body-file> <result-file>",
);
}
if (!fs.existsSync(currentBodyFile)) fail(`File not found: ${currentBodyFile}`);
if (!fs.existsSync(redactedBodyFile)) fail(`File not found: ${redactedBodyFile}`);
const currentBody = fs.readFileSync(currentBodyFile, "utf8");
const redactedBody = fs.readFileSync(redactedBodyFile, "utf8");
const decision = decideBodyRedaction(currentBody, redactedBody);
const result = {
ok: true,
kind,
number: Number(number),
...decision,
};
if (decision.body_changed) {
const endpoint =
kind === "pr" ? `repos/${REPO}/pulls/${number}` : `repos/${REPO}/issues/${number}`;
gh(["api", endpoint, "-X", "PATCH", "-F", `body=@${redactedBodyFile}`]);
result.redacted = true;
} else {
result.redacted = false;
result.reason = "current_body_already_redacted";
}
fs.writeFileSync(resultFile, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
console.log(JSON.stringify(result));
}
/**
* delete-comment <comment-id>
* Delete a comment (and all its edit history).
@@ -621,17 +555,6 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
const types = secretTypes.split(",").map((s) => s.trim());
const typeList = types.map((t, i) => `${i + 1}. **${t}**`).join("\n");
const redactionResult = loadBodyRedactionResult(locationType, replyToNodeId);
if (isBodyLocationType(locationType) && !redactionResult.notify_required) {
console.log(
JSON.stringify({
ok: true,
skipped: true,
reason: "current_body_already_redacted",
}),
);
return;
}
let locationDesc;
let actionDesc;
@@ -658,8 +581,6 @@ function cmdNotify(target, author, locationType, secretTypes, replyToNodeId) {
}
const body = [
`> **Note:** This is an automated message sent by the OpenClaw maintainer team. **NO_REPLY.**`,
"",
`@${author} :warning: **Security Notice: Secret Leakage Detected**`,
"",
`GitHub Secret Scanning detected the following exposed secret types in ${locationDesc}:`,
@@ -835,13 +756,12 @@ function cmdSummary(jsonFile) {
// ─── Dispatch ───────────────────────────────────────────────────────────────
const args = [];
const [command, ...args] = process.argv.slice(2);
export const commands = {
const commands = {
"fetch-alert": () => cmdFetchAlert(args[0]),
"fetch-content": () => cmdFetchContent(args[0]),
"redact-body": () => cmdRedactBody(args[0], args[1], args[2]),
"redact-body-if-needed": () => cmdRedactBodyIfNeeded(args[0], args[1], args[2], args[3], args[4]),
"delete-comment": () => cmdDeleteComment(args[0]),
"delete-discussion-comment": () => cmdDeleteDiscussionComment(args[0]),
"recreate-comment": () => cmdRecreateComment(args[0], args[1]),
@@ -852,37 +772,26 @@ export const commands = {
summary: () => cmdSummary(args[0]),
};
function main(argv = process.argv.slice(2)) {
const [command, ...commandArgs] = argv;
args.length = 0;
args.push(...commandArgs);
if (!command || !commands[command]) {
console.error(
[
"Usage: node secret-scanning.mjs <command> [args]",
"",
"Commands:",
" fetch-alert <number> Fetch alert metadata + locations",
" fetch-content '<location-json>' Fetch content for a location",
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
" redact-body-if-needed <issue|pr> <n> <current-file> <redacted-file> <result-file> PATCH body only if redaction changed it",
" delete-comment <comment-id> Delete a comment",
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
" recreate-comment <issue-n> <file> Create replacement comment",
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
" notify <target> <author> <type> <types> [reply-to-node-id|body-result-file] Post notification",
" resolve <n> [resolution] [comment] Close alert",
" list-open List open alerts",
" summary <json-file> Print formatted summary",
].join("\n"),
);
process.exit(1);
}
commands[command]();
if (!command || !commands[command]) {
console.error(
[
"Usage: node secret-scanning.mjs <command> [args]",
"",
"Commands:",
" fetch-alert <number> Fetch alert metadata + locations",
" fetch-content '<location-json>' Fetch content for a location",
" redact-body <issue|pr> <n> <file> PATCH body with redacted file",
" delete-comment <comment-id> Delete a comment",
" delete-discussion-comment <node-id> Delete a discussion comment (GraphQL)",
" recreate-comment <issue-n> <file> Create replacement comment",
" recreate-discussion-comment <disc-node-id> <file> [reply-to-node-id] Create discussion comment (GraphQL)",
" notify <target> <author> <type> <types> [reply-to-node-id] Post notification",
" resolve <n> [resolution] [comment] Close alert",
" list-open List open alerts",
" summary <json-file> Print formatted summary",
].join("\n"),
);
process.exit(1);
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main();
}
commands[command]();

View File

@@ -19,16 +19,9 @@ or validating a change without wasting hours.
Prove the touched surface first. Do not reflexively run the whole suite.
1. Inspect the diff and classify the touched surface:
- normal source checkout, source change: `pnpm changed:lanes --json`, then `pnpm check:changed`
- normal source checkout, tests only: `pnpm test:changed`
- normal source checkout, one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
- Codex worktree or linked/sparse checkout, one/few explicit files: `node scripts/run-vitest.mjs <path-or-filter>`
- Codex worktree or linked/sparse checkout, changed gates or anything broad:
use the Crabbox wrapper with the provider that matches the proof surface.
For maintainer heavy `pnpm` gates, that is usually delegated Blacksmith
Testbox through Crabbox, e.g. `node scripts/crabbox-wrapper.mjs run
--provider blacksmith-testbox ... -- pnpm check:changed`. For direct AWS
Crabbox proof, omit `--provider` and let `.crabbox.yaml` choose AWS.
- source: `pnpm changed:lanes --json`, then `pnpm check:changed`
- tests only: `pnpm test:changed`
- one failing file: `pnpm test <path-or-filter> -- --reporter=verbose`
- workflow-only: `git diff --check`, workflow syntax/lint (`actionlint` when available)
- docs-only: `pnpm docs:list`, docs formatter/lint only if docs tooling changed or requested
2. Reproduce narrowly before fixing.
@@ -43,24 +36,11 @@ Prove the touched surface first. Do not reflexively run the whole suite.
- Prefer GitHub Actions for release/Docker proof when the workflow already has the prepared image and secrets.
- Use `scripts/committer "<msg>" <paths...>` when committing; stage only your files.
- If deps are missing, run `pnpm install`, retry once, then report the first actionable error.
- In a Codex worktree or linked/sparse checkout, do not run direct local
`pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, or `scripts/committer` until
you have verified pnpm will not reconcile or reinstall dependencies. Use
`node scripts/run-vitest.mjs` for tiny local proof, `node
scripts/crabbox-wrapper.mjs` for Testbox, and `git commit --no-verify` only
after the relevant remote or node-wrapper proof is already clean.
- For remote proof, use the Crabbox wrapper first, but name the actual backend.
Direct AWS Crabbox uses `provider=aws` and `cbx_...` ids. Delegated
Blacksmith Testbox through Crabbox uses `provider=blacksmith-testbox`,
`syncDelegated=true`, and `tbx_...` ids. Both satisfy "remote proof" when the
requested proof surface allows either.
- Do not infer "no Testbox is running" from plain `blacksmith testbox list`.
Use `blacksmith testbox list --all` or `blacksmith testbox status <tbx_id>`
before reporting cloud state.
- Reuse only an id/slug created in this operator session unless explicitly
coordinating with another lane. If Testbox queues, fails capacity, or cannot
allocate, report the blocker or switch to direct AWS Crabbox only when that
still proves the requested surface.
- For Blacksmith Testbox proof, use Crabbox first. `pnpm crabbox:run -- --provider
blacksmith-testbox --timing-json -- <command...>` warms, claims, syncs, runs,
reports, and cleans up one-shot boxes. Reuse only an id/slug created in this
operator session; `blacksmith testbox list` is diagnostics only, not a shared
work queue.
## Local Test Shortcuts
@@ -75,14 +55,6 @@ OPENCLAW_VITEST_MAX_WORKERS=1 pnpm test <path-or-filter>
Use targeted file paths whenever possible. Avoid raw `vitest`; use the repo
`pnpm test` wrapper so project routing, workers, and setup stay correct.
When the checkout is a Codex worktree, prefer the direct node harness instead:
```bash
node scripts/run-vitest.mjs <path-or-filter>
```
That keeps the test scoped without giving pnpm a chance to run dependency
status checks or install reconciliation in a linked worktree.
## Command Semantics
@@ -131,8 +103,6 @@ gh run view <run-id> --job <job-id> --log
- Check exact SHA. Ignore newer unrelated `main` unless asked.
- For cancelled same-branch runs, confirm whether a newer run superseded it.
- Fetch full logs only for failed or relevant jobs.
- Prefer `gh run view <run-id> --json jobs` over PR rollup while debugging; rollup can be stale/noisy.
- For `prompt:snapshots:check` failures, treat Linux Node 24 as CI truth. If macOS passes but CI drifts, reproduce in a Linux Node 24 container or Testbox, commit that generated output, then rerun.
## GitHub Release Workflows

View File

@@ -1,41 +0,0 @@
---
name: slacrawl
description: "Slack archive: search, sync freshness, threads/DMs, SQL counts, and Slacrawl repo work."
metadata:
openclaw:
homepage: https://github.com/openclaw/slacrawl
requires:
bins:
- slacrawl
install:
- kind: go
module: github.com/vincentkoc/slacrawl/cmd/slacrawl@latest
bins:
- slacrawl
---
# Slacrawl
Use local Slack archive data first. Check freshness for recent/current questions:
```bash
slacrawl doctor
slacrawl status --json
```
Refresh only when stale or asked:
```bash
slacrawl sync --source desktop
slacrawl sync --source api --latest-only
```
Query with bounded slices:
```bash
slacrawl search --limit 20 "query"
slacrawl messages --since 7d --limit 50
slacrawl sql "select count(*) from messages;"
```
Report workspace/channel names, absolute date spans, counts, and token/source limits. Use read-only SQL for exact counts/rankings. API sync and full thread/DM hydration require Slack tokens; do not assume they exist.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Slacrawl"
short_description: "Search local Slack archives and freshness"
default_prompt: "Use $slacrawl to search local Slack archives, check freshness, inspect channel or DM slices, and report exact date spans and token/source limits."

View File

@@ -17,8 +17,7 @@ artifact bundle. The runner leases the shared burner account from Convex.
Run from the OpenClaw repo and branch under test:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" start \
pnpm qa:telegram-user:crabbox -- start \
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
```
@@ -40,8 +39,7 @@ For deterministic visual repros, put the exact mock-model reply in a file and
pass it to `start`:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" start \
pnpm qa:telegram-user:crabbox -- start \
--tdlib-url http://artifacts.openclaw.ai/tdlib-v1.8.0-linux-x64.tgz \
--mock-response-file .artifacts/qa-e2e/telegram-user-crabbox/reply.txt \
--output-dir .artifacts/qa-e2e/telegram-user-crabbox/pr-review
@@ -57,16 +55,15 @@ For visual proof, first send or identify a bottom marker message, then open the
group/topic directly by message id:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" view \
pnpm qa:telegram-user:crabbox -- view \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--message-id <message-id>
```
This uses Telegram Desktop directly with `tg://privatepost`, not `xdg-open`.
It also resizes Telegram to `650x1000` at the tested desktop position so
the crop can isolate the chat pane even if Telegram keeps a split/sidebar
layout. Do not press Escape after this; Escape can close the selected chat.
Telegram switches to single-chat mode with no left chat list or right info
pane. Do not press Escape after this; Escape can close the selected chat.
Bottom behavior matters:
@@ -74,14 +71,13 @@ Bottom behavior matters:
later messages appear live in the recording
- deep-linking to an older message does not auto-scroll to new arrivals; link
again to the newest/final marker instead of clicking the down-arrow
- the cropped GIF intentionally uses the chat pane, not the whole desktop or
whole Telegram window
- `650px` is the largest tested clean width; `660px` switches Telegram back to
split/sidebar layout
Send as the real Telegram user:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" send \
pnpm qa:telegram-user:crabbox -- send \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--text /status
```
@@ -91,8 +87,7 @@ For slash commands, omit the bot username; the runner targets the SUT bot.
Run arbitrary commands on the Crabbox:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" run \
pnpm qa:telegram-user:crabbox -- run \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
-- bash -lc 'source /tmp/openclaw-telegram-user-crabbox/env.sh && python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py transcript --limit 20 --json'
```
@@ -111,16 +106,14 @@ python3 /tmp/openclaw-telegram-user-crabbox/user-driver.py probe --text '@{sut}
Capture the current desktop without ending the session:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" screenshot \
pnpm qa:telegram-user:crabbox -- screenshot \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
```
Check lease state and get the WebVNC command:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" status \
pnpm qa:telegram-user:crabbox -- status \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json
```
@@ -129,8 +122,7 @@ proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-pr
Always finish or explicitly keep the box:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" finish \
pnpm qa:telegram-user:crabbox -- finish \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--preview-crop telegram-window
```
@@ -158,8 +150,7 @@ Attach only the useful visual artifact to the PR unless logs are needed. The
runner is GIF-only by default:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" publish \
pnpm qa:telegram-user:crabbox -- publish \
--session .artifacts/qa-e2e/telegram-user-crabbox/pr-review/session.json \
--pr <pr-number> \
--summary 'Telegram real-user Crabbox session motion GIF'
@@ -198,8 +189,7 @@ experiments unless those artifacts are explicitly needed.
For a fast one-shot check, use:
```bash
proof_cmd="${OPENCLAW_TELEGRAM_USER_PROOF_CMD:-openclaw-telegram-user-crabbox-proof}"
"$proof_cmd" --text /status
pnpm qa:telegram-user:crabbox -- --text /status
```
This is a start/send/finish shortcut. Prefer the held session for PR review,

View File

@@ -6,10 +6,6 @@ capacity:
strategy: most-available
fallback: on-demand-after-120s
hints: true
availabilityZones:
- eu-west-1a
- eu-west-1b
- eu-west-1c
regions:
- eu-west-1
- eu-west-2

2
.github/CODEOWNERS vendored
View File

@@ -11,8 +11,6 @@
/.github/workflows/codeql.yml @openclaw/openclaw-secops
/.github/workflows/codeql-android-critical-security.yml @openclaw/openclaw-secops
/.github/workflows/codeql-critical-quality.yml @openclaw/openclaw-secops
/.github/workflows/dependency-change-awareness.yml @openclaw/openclaw-secops
/test/scripts/dependency-change-awareness-workflow.test.ts @openclaw/openclaw-secops
/src/security/ @openclaw/openclaw-secops
/src/secrets/ @openclaw/openclaw-secops
/src/config/*secret*.ts @openclaw/openclaw-secops

View File

@@ -40,7 +40,6 @@ runs:
id: pnpm-cache
uses: ./.github/actions/setup-pnpm-store-cache
with:
node-version: ${{ inputs.node-version }}
pnpm-version: ${{ inputs.pnpm-version }}
cache-key-suffix: ${{ inputs.cache-key-suffix }}
@@ -59,15 +58,14 @@ runs:
if command -v bun &>/dev/null; then bun -v; fi
- name: Capture node path
if: inputs.install-deps == 'true'
shell: bash
run: |
node_bin="$(dirname "$(node -p 'process.execPath')")"
if command -v cygpath >/dev/null 2>&1; then
node_bin="$(cygpath -u "$node_bin")"
fi
# zizmor: ignore[github-env] node_bin comes from trusted actions/setup-node output in this composite action.
echo "NODE_BIN=$node_bin" >> "$GITHUB_ENV"
echo "$node_bin" >> "$GITHUB_PATH"
- name: Install dependencies
if: inputs.install-deps == 'true'

View File

@@ -5,10 +5,6 @@ inputs:
description: pnpm version to activate via corepack.
required: false
default: "11.0.8"
node-version:
description: Expected Node.js version already installed by actions/setup-node.
required: false
default: "24.x"
cache-key-suffix:
description: Suffix appended to the cache key.
required: false
@@ -45,85 +41,12 @@ runs:
env:
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0"
PNPM_VERSION: ${{ inputs.pnpm-version }}
REQUESTED_NODE_VERSION: ${{ inputs.node-version }}
run: |
set -euo pipefail
if [[ ! "$PNPM_VERSION" =~ ^[0-9]+(\.[0-9]+){1,2}([.-][0-9A-Za-z.-]+)?$ ]]; then
echo "::error::Invalid pnpm-version input: '$PNPM_VERSION'"
exit 2
fi
requested_node="${REQUESTED_NODE_VERSION:-${NODE_VERSION:-}}"
requested_node="${requested_node#v}"
node_version_matches() {
local actual="$1"
local requested="$2"
if [[ -z "$requested" ]]; then
return 0
fi
case "$requested" in
*x)
[[ "${actual%%.*}" == "${requested%%.*}" ]]
;;
*.*.*)
[[ "$actual" == "$requested" ]]
;;
*.*)
[[ "$actual" == "$requested".* ]]
;;
*)
[[ "${actual%%.*}" == "$requested" ]]
;;
esac
}
active_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
if ! node_version_matches "$active_node_version" "$requested_node"; then
node_roots=()
for root in \
"${RUNNER_TOOL_CACHE:-}" \
"${AGENT_TOOLSDIRECTORY:-}" \
"${ACTIONS_RUNNER_TOOL_CACHE:-}" \
"/opt/hostedtoolcache" \
"/home/runner/_work/_tool" \
"/Users/runner/hostedtoolcache" \
"/c/hostedtoolcache/windows"
do
if [[ -d "$root/node" ]]; then
node_roots+=("$root/node")
elif [[ "$(basename "$root")" == "node" && -d "$root" ]]; then
node_roots+=("$root")
fi
done
node_bin=""
for node_root in "${node_roots[@]}"; do
while IFS= read -r candidate; do
candidate_version="$("$candidate" -p 'process.versions.node' 2>/dev/null || true)"
if node_version_matches "$candidate_version" "$requested_node"; then
node_bin="$candidate"
break 2
fi
done < <(find "$node_root" \( -name node -o -name node.exe \) -type f 2>/dev/null | sort -r)
done
if [[ -n "$node_bin" ]]; then
echo "Using Node $("$node_bin" -p 'process.versions.node') from $node_bin"
export PATH="$(dirname "$node_bin"):$PATH"
hash -r
fi
fi
active_node_version="$(node -p 'process.versions.node' 2>/dev/null || true)"
if ! node_version_matches "$active_node_version" "$requested_node"; then
echo "::error::Expected Node '${requested_node}', but active node is '${active_node_version:-missing}' at $(command -v node || true)"
exit 1
fi
node -v
command -v node
command -v corepack
corepack enable
for attempt in 1 2 3; do
if corepack prepare "pnpm@$PNPM_VERSION" --activate; then

View File

@@ -2,9 +2,10 @@
You are Mantis running native Telegram Desktop visual proof for an OpenClaw PR.
Goal: inspect the pull request, decide whether it has an honest
Telegram-visible before/after behavior, then either run native Telegram Desktop
proof or leave a no-visual-proof manifest for the workflow to publish.
Goal: inspect the pull request, decide the best Telegram-visible behavior to
prove, run before/after native Telegram Desktop sessions, iterate until the GIFs
are visually good, and leave a Mantis evidence manifest for the workflow to
publish.
Hard limits:
@@ -15,16 +16,6 @@ Hard limits:
- Do not use fixed `/status` proof unless it genuinely proves the PR.
- Do not finish with tiny, cropped-wrong, off-bottom, or sidebar-heavy GIFs.
- Do not invent a generic proof. The proof must match the PR behavior.
- Do not force GIFs for internal-only, workflow-only, test-only, docs-only, or
otherwise non-visual PRs. A no-visual-proof manifest is a successful workflow
outcome when GIFs would be misleading, but it is not proof that the PR passed.
- Do not skip Telegram-visible PRs just because the proof needs a specific
message, mock response, media attachment, command, button, reaction, stop
timing, approval prompt, or progress/final delivery sequence. First write a
concrete proof plan and try the standard harness path.
- Keep public-facing manifest summaries short and user-domain. Do not mention
harness internals, mock-provider limits, secret/trust boundaries, local paths,
transcript seeding, or workflow implementation details in the summary.
Inputs are provided as environment variables:
@@ -45,63 +36,10 @@ Required workflow:
1. Read `.agents/skills/telegram-crabbox-e2e-proof/SKILL.md`.
2. Inspect the PR with `gh pr view "$MANTIS_PR_NUMBER"` and
`gh pr diff "$MANTIS_PR_NUMBER"`.
3. Decide whether the PR has a visibly reproducible Telegram Desktop
before/after. Treat these as visible until proven otherwise: message text
formatting/content, progress drafts, native drafts, final delivery, media or
document delivery, inline buttons, approval prompts, stop/abort behavior,
reactions/status indicators, guest/inline responses, TTS/voice/audio
delivery, and routing changes whose result is visible in the chat. For those
PRs, define the exact Telegram stimulus and expected main/PR visual delta
before deciding to skip.
If the PR does not have a Telegram-visible before/after, write
`${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with `comparison.pass: true`, no
artifacts, and a summary that starts with
`Mantis did not generate before/after GIFs because`. Include a short
public reason, such as `the PR changes internal session bookkeeping rather
than Telegram-visible behavior`. Use this manifest shape and do not create
worktrees or start Crabbox for this case:
```json
{
"schemaVersion": 1,
"id": "telegram-desktop-proof",
"title": "Mantis Telegram Desktop Proof",
"summary": "Mantis did not generate before/after GIFs because <reason>.",
"scenario": "telegram-desktop-proof",
"comparison": {
"baseline": {
"ref": "<BASELINE_REF>",
"sha": "<BASELINE_SHA>",
"expected": "no visible Telegram Desktop delta",
"status": "skipped"
},
"candidate": {
"ref": "<CANDIDATE_REF>",
"sha": "<CANDIDATE_SHA>",
"expected": "no visible Telegram Desktop delta",
"status": "skipped",
"fixed": true
},
"pass": true
},
"artifacts": []
}
```
If the PR appears visual but proof is blocked by Telegram Desktop session
state, authorization, credentials, Crabbox, missing Telegram client support,
unavailable media/provider setup, or another capture-infrastructure issue,
do not describe it as a no-visual PR. Write a manifest with
`comparison.pass: false`, skipped lanes, no artifacts, and a summary that
starts with `Mantis could not capture Telegram Desktop proof because`. The
publisher will keep that out of PR comments so the failure stays in the
workflow logs and artifacts.
4. Decide what Telegram message, mock model response, command, callback, button,
3. Decide what Telegram message, mock model response, command, callback, button,
media, or sequence best proves the PR. Use `MANTIS_INSTRUCTIONS` as extra
maintainer guidance, not as a replacement for reading the PR.
5. Create detached worktrees under
4. Create detached worktrees under
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/baseline` and
`.artifacts/qa-e2e/mantis/telegram-desktop-proof-worktrees/candidate`, then
install and build each worktree with the repo's normal `pnpm` commands.
@@ -111,7 +49,7 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
runtime commands. The candidate SUT may receive only the proof runner's
short-lived Telegram bot token, generated local config/state paths, and mock
model key needed for this isolated proof.
6. In each worktree, run the real-user Telegram Crabbox proof flow from the
5. In each worktree, run the real-user Telegram Crabbox proof flow from the
skill with `$OPENCLAW_TELEGRAM_USER_PROOF_CMD`; do not run
`pnpm qa:telegram-user:crabbox` directly. The proof command comes from the
trusted workflow checkout while the current directory controls which
@@ -119,15 +57,13 @@ than Telegram-visible behavior`. Use this manifest shape and do not create
`$OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT`, the workflow-provided `crabbox`
binary, and the workflow-provided local `ffmpeg`/`ffprobe`; do not generate,
install, or patch replacement proof tooling during the run. Use the same
proof idea for baseline and candidate. Let `start` return or fail on its
own; do not kill it while Crabbox is still waiting for bootstrap. Use a long
command timeout for `start`, `send`, `view`, and `finish`. You may iterate
and rerun if the visual result is not convincing.
7. Open Telegram Desktop directly to the newest relevant message with the
proof idea for baseline and candidate. You may iterate and rerun if the
visual result is not convincing.
6. Open Telegram Desktop directly to the newest relevant message with the
runner `view` command before finishing each recording. Keep the chat scrolled
to the bottom so new proof messages appear in-frame.
8. Finish each session with `--preview-crop telegram-window`.
9. Build `${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with:
7. Finish each session with `--preview-crop telegram-window`.
8. Build `${MANTIS_OUTPUT_DIR}/mantis-evidence.json` with:
```bash
node scripts/mantis/build-telegram-desktop-proof-evidence.mjs \
@@ -157,10 +93,6 @@ Visual acceptance:
Expected final state:
- `${MANTIS_OUTPUT_DIR}/mantis-evidence.json` exists.
- Visual proof manifests contain paired `motionPreview` artifacts labeled
`Main` and `This PR`.
- No-visual-proof manifests contain no artifacts and have `comparison.pass:
true`.
- Capture-infrastructure failure manifests contain no artifacts and have
`comparison.pass: false`.
- The manifest contains paired `motionPreview` artifacts labeled `Main` and
`This PR`.
- The worktree can be dirty only under `.artifacts/`.

11
.github/labeler.yml vendored
View File

@@ -101,9 +101,7 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/qa-lab/**"
- "qa/scenarios/**"
- "docs/concepts/qa-e2e-automation.md"
- "docs/concepts/personal-agent-benchmark-pack.md"
- "docs/channels/qa-channel.md"
"channel: signal":
- changed-files:
@@ -246,10 +244,6 @@
- "docs/gateway/security.md"
- "security/**"
"extensions: admin-http-rpc":
- changed-files:
- any-glob-to-any-file:
- "extensions/admin-http-rpc/**"
"extensions: copilot-proxy":
- changed-files:
- any-glob-to-any-file:
@@ -286,11 +280,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/oc-path/**"
"extensions: policy":
- changed-files:
- any-glob-to-any-file:
- "extensions/policy/**"
- "docs/cli/policy.md"
"extensions: open-prose":
- changed-files:
- any-glob-to-any-file:

View File

@@ -5,16 +5,10 @@ Describe the problem and fix in 25 bullets:
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
- Problem:
- Solution:
- Why it matters:
- What changed:
- What did NOT change (scope boundary):
## Motivation
Explain why this change should exist now. Link it to the user pain, failure mode, maintainer need, or product goal. If this is purely mechanical, write `N/A`.
-
## Change Type (select all)
- [ ] Bug fix

View File

@@ -124,6 +124,5 @@ jobs:
- name: Run Testbox
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
if: always()
continue-on-error: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"

View File

@@ -20,8 +20,6 @@ on:
- "docs/**"
pull_request:
types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
paths-ignore:
- "CHANGELOG.md"
permissions:
contents: read
@@ -40,7 +38,7 @@ jobs:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
checkout_revision: ${{ steps.checkout_ref.outputs.sha }}
@@ -60,11 +58,14 @@ jobs:
plugin_contracts_matrix: ${{ steps.manifest.outputs.plugin_contracts_matrix }}
channel_contracts_matrix: ${{ steps.manifest.outputs.channel_contracts_matrix }}
run_checks: ${{ steps.manifest.outputs.run_checks }}
checks_matrix: ${{ steps.manifest.outputs.checks_matrix }}
run_checks_node_core_nondist: ${{ steps.manifest.outputs.run_checks_node_core_nondist }}
checks_node_core_nondist_matrix: ${{ steps.manifest.outputs.checks_node_core_nondist_matrix }}
run_checks_node_core_dist: ${{ steps.manifest.outputs.run_checks_node_core_dist }}
checks_node_core_dist_matrix: ${{ steps.manifest.outputs.checks_node_core_dist_matrix }}
run_check: ${{ steps.manifest.outputs.run_check }}
run_check_additional: ${{ steps.manifest.outputs.run_check_additional }}
run_build_smoke: ${{ steps.manifest.outputs.run_build_smoke }}
run_check_docs: ${{ steps.manifest.outputs.run_check_docs }}
run_control_ui_i18n: ${{ steps.manifest.outputs.run_control_ui_i18n }}
run_checks_windows: ${{ steps.manifest.outputs.run_checks_windows }}
@@ -131,7 +132,6 @@ jobs:
OPENCLAW_CI_RUN_CONTROL_UI_I18N: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_control_ui_i18n || 'false' }}
OPENCLAW_CI_CHECKOUT_REVISION: ${{ steps.checkout_ref.outputs.sha }}
OPENCLAW_CI_REPOSITORY: ${{ github.repository }}
OPENCLAW_CI_EVENT_NAME: ${{ github.event_name }}
run: |
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
@@ -173,7 +173,6 @@ jobs:
const isCanonicalRepository = process.env.OPENCLAW_CI_REPOSITORY === "openclaw/openclaw";
const docsOnly = parseBoolean(process.env.OPENCLAW_CI_DOCS_ONLY);
const docsChanged = parseBoolean(process.env.OPENCLAW_CI_DOCS_CHANGED);
const eventName = process.env.OPENCLAW_CI_EVENT_NAME ?? "";
const runNode = parseBoolean(process.env.OPENCLAW_CI_RUN_NODE) && !docsOnly;
const runNodeFastOnly =
runNode && parseBoolean(process.env.OPENCLAW_CI_RUN_NODE_FAST_ONLY);
@@ -198,7 +197,7 @@ jobs:
const checksFastCoreTasks = [];
if (runNodeFull) {
checksFastCoreTasks.push(
{ check_name: "checks-fast-bundled-protocol", runtime: "node", task: "bundled-protocol" },
{ check_name: "checks-fast-bundled", runtime: "node", task: "bundled" },
);
} else {
if (runNodeFastCiRouting) {
@@ -247,12 +246,21 @@ jobs:
runNodeFull ? createChannelContractTestShards() : [],
),
run_checks: runNodeFull,
checks_matrix: createMatrix(
runNodeFull
? [
{ check_name: "checks-node-channels", runtime: "node", task: "channels" },
]
: [],
),
run_checks_node_core_nondist: nodeTestNonDistShards.length > 0,
checks_node_core_nondist_matrix: createMatrix(nodeTestNonDistShards),
run_checks_node_core_dist: nodeTestDistShards.length > 0,
checks_node_core_dist_matrix: createMatrix(nodeTestDistShards),
run_check: runNodeFull,
run_check_additional: runNodeFull,
run_check_docs: docsChanged && eventName !== "push",
run_build_smoke: runNodeFull,
run_check_docs: docsChanged,
run_control_ui_i18n: runControlUiI18n,
run_skills_python_job: runSkillsPython,
run_checks_windows: runWindows,
@@ -287,13 +295,13 @@ jobs:
}
EOF
# Run dependency-free security checks in parallel with scope detection so the
# Run the fast security/SCM checks in parallel with scope detection so the
# main Node jobs do not have to wait for Python/pre-commit setup.
security-fast:
security-scm-fast:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
env:
PRE_COMMIT_HOME: .cache/pre-commit-security-fast
@@ -382,6 +390,22 @@ jobs:
printf 'Auditing workflow files:\n%s\n' "${workflow_files[@]}"
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
security-dependency-audit:
permissions:
contents: read
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v6
with:
ref: ${{ inputs.target_ref || github.sha }}
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Setup Node.js
uses: actions/setup-node@v6
with:
@@ -391,6 +415,35 @@ jobs:
- name: Audit production dependencies
run: node scripts/pre-commit/pnpm-audit-prod.mjs --audit-level=high
security-fast:
permissions: {}
needs: [security-scm-fast, security-dependency-audit]
if: ${{ !cancelled() && always() && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify fast security jobs
env:
DEPENDENCY_AUDIT_RESULT: ${{ needs.security-dependency-audit.result }}
SCM_RESULT: ${{ needs.security-scm-fast.result }}
run: |
set -euo pipefail
failed=0
for result in \
"security-scm-fast=${SCM_RESULT}" \
"security-dependency-audit=${DEPENDENCY_AUDIT_RESULT}"
do
job="${result%%=*}"
status="${result#*=}"
if [ "$status" != "success" ]; then
echo "::error::${job} ended with ${status}"
failed=1
fi
done
exit "$failed"
# Build dist once for Node-relevant changes and share it with downstream jobs.
# Keep this overlapping with the fast correctness lanes so green PRs get heavy
# test/build feedback sooner instead of waiting behind a full `check` pass.
@@ -399,7 +452,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
outputs:
channels-result: ${{ steps.built_artifact_checks.outputs['channels-result'] }}
@@ -588,15 +641,6 @@ jobs:
echo "${name}-result=${results[$name]}" >> "$GITHUB_OUTPUT"
done
failures=0
for name in channels core-support-boundary gateway-watch; do
if [ "${results[$name]}" = "failure" ]; then
echo "::error title=${name} failed::${name} failed"
failures=1
fi
done
exit "$failures"
- name: Upload gateway watch regression artifacts
if: always() && needs.preflight.outputs.run_check_additional == 'true'
uses: actions/upload-artifact@v7
@@ -611,7 +655,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast_core == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -678,9 +722,14 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
bundled-protocol)
bundled)
pnpm test:bundled
pnpm protocol:check
;;
contracts-channels)
pnpm test:contracts:channels
;;
contracts-plugins)
pnpm test:contracts:plugins
;;
contracts-plugins-ci-routing)
pnpm test:contracts:plugins
@@ -701,7 +750,7 @@ jobs:
name: ${{ matrix.checkName }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_contracts_shards == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -779,13 +828,35 @@ jobs:
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:plugins
checks-fast-plugin-contracts:
permissions:
contents: read
name: checks-fast-contracts-plugins
needs: [preflight, checks-fast-plugin-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_plugin_contracts_shards == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify plugin contract shards
env:
SHARD_RESULT: ${{ needs.checks-fast-plugin-contracts-shard.result }}
run: |
if [ "$SHARD_RESULT" = "cancelled" ]; then
echo "Plugin contract shards were cancelled, usually because a newer commit superseded this run." >&2
exit 1
fi
if [ "$SHARD_RESULT" != "success" ]; then
echo "Plugin contract shards failed: $SHARD_RESULT" >&2
exit 1
fi
checks-fast-channel-contracts-shard:
permissions:
contents: read
name: ${{ matrix.checkName }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -863,13 +934,132 @@ jobs:
EOF
OPENCLAW_VITEST_INCLUDE_FILE="$include_file" pnpm test:contracts:channels
checks-fast-channel-contracts:
permissions:
contents: read
name: checks-fast-contracts-channels
needs: [preflight, checks-fast-channel-contracts-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_fast == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify channel contract shards
env:
SHARD_RESULT: ${{ needs.checks-fast-channel-contracts-shard.result }}
run: |
if [ "$SHARD_RESULT" = "cancelled" ]; then
echo "Channel contract shards were cancelled, usually because a newer commit superseded this run." >&2
exit 1
fi
if [ "$SHARD_RESULT" != "success" ]; then
echo "Channel contract shards failed: $SHARD_RESULT" >&2
exit 1
fi
checks-fast-protocol:
permissions:
contents: read
name: "checks-fast-protocol"
needs: [preflight]
if: needs.preflight.outputs.run_checks_fast == 'true'
runs-on: ubuntu-24.04
timeout-minutes: 30
steps:
- name: Checkout
shell: bash
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
CHECKOUT_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
workdir="$GITHUB_WORKSPACE"
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
reset_checkout_dir() {
mkdir -p "$workdir"
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
}
checkout_attempt() {
local attempt="$1"
reset_checkout_dir
git init "$workdir" >/dev/null
git config --global --add safe.directory "$workdir"
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
git -C "$workdir" config gc.auto 0
timeout --signal=TERM 30s git -C "$workdir" \
-c protocol.version=2 \
-c "http.https://github.com/.extraheader=AUTHORIZATION: basic ${auth_header}" \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
echo "checkout attempt ${attempt}/5 succeeded"
}
for attempt in 1 2 3 4 5; do
if checkout_attempt "$attempt"; then
exit 0
fi
echo "checkout attempt ${attempt}/5 failed"
sleep $((attempt * 5))
done
echo "checkout failed after 5 attempts" >&2
exit 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run protocol check
run: pnpm protocol:check
checks:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_matrix) }}
steps:
- name: Verify ${{ matrix.task }} (${{ matrix.runtime }})
env:
TASK: ${{ matrix.task }}
CHANNELS_RESULT: ${{ needs.build-artifacts.outputs['channels-result'] }}
shell: bash
run: |
set -euo pipefail
case "$TASK" in
channels)
if [ "$CHANNELS_RESULT" != "success" ]; then
echo "Channel tests failed in build-artifacts: $CHANNELS_RESULT" >&2
exit 1
fi
;;
*)
echo "Unsupported checks task: $TASK" >&2
exit 1
;;
esac
checks-node-compat:
permissions:
contents: read
name: checks-node-compat-node22
needs: [preflight]
if: needs.preflight.outputs.run_build_artifacts == 'true' && github.event_name == 'workflow_dispatch'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 60
steps:
- name: Checkout
@@ -923,7 +1113,7 @@ jobs:
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: "22.19.0"
node-version: "22.18.0"
cache-key-suffix: "node22-pnpm11"
install-bun: "false"
@@ -946,7 +1136,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_node_core_nondist == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && (matrix.runner || 'ubuntu-24.04') || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -1050,6 +1240,63 @@ jobs:
}
EOF
checks-node-core-test-dist-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks_node_core_dist == 'true' && needs.build-artifacts.result == 'success' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_dist_matrix) }}
steps:
- name: Verify Node test shard
env:
CORE_SUPPORT_BOUNDARY_RESULT: ${{ needs.build-artifacts.outputs['core-support-boundary-result'] }}
SHARD_NAME: ${{ matrix.shard_name }}
shell: bash
run: |
set -euo pipefail
case "$SHARD_NAME" in
core-support-boundary)
if [ "$CORE_SUPPORT_BOUNDARY_RESULT" != "success" ]; then
echo "Core support boundary shard failed in build-artifacts: $CORE_SUPPORT_BOUNDARY_RESULT" >&2
exit 1
fi
;;
*)
echo "Unsupported built-artifact shard: $SHARD_NAME" >&2
exit 1
;;
esac
checks-node-core-test:
permissions:
contents: read
name: checks-node-core
needs: [preflight, checks-node-core-test-nondist-shard, checks-node-core-test-dist-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_checks == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify node test shards
env:
DIST_SHARD_RESULT: ${{ needs.checks-node-core-test-dist-shard.result }}
NONDIST_SHARD_RESULT: ${{ needs.checks-node-core-test-nondist-shard.result }}
RUN_DIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_dist }}
RUN_NONDIST_SHARDS: ${{ needs.preflight.outputs.run_checks_node_core_nondist }}
run: |
if [ "$RUN_NONDIST_SHARDS" = "true" ] && [ "$NONDIST_SHARD_RESULT" != "success" ]; then
echo "Node non-dist test shards failed: $NONDIST_SHARD_RESULT" >&2
exit 1
fi
if [ "$RUN_DIST_SHARDS" = "true" ] && [ "$DIST_SHARD_RESULT" != "success" ]; then
echo "Node dist test shards failed: $DIST_SHARD_RESULT" >&2
exit 1
fi
# Types, lint, and format check shards.
check-shard:
permissions:
@@ -1057,15 +1304,15 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && matrix.runner || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
include:
- check_name: check-guards
task: guards
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-preflight-guards
task: preflight-guards
runner: ubuntu-24.04
- check_name: check-prod-types
task: prod-types
runner: blacksmith-4vcpu-ubuntu-2404
@@ -1074,10 +1321,16 @@ jobs:
runner: blacksmith-16vcpu-ubuntu-2404
- check_name: check-dependencies
task: dependencies
runner: blacksmith-8vcpu-ubuntu-2404
runner: ubuntu-24.04
- check_name: check-policy-guards
task: policy-guards
runner: ubuntu-24.04
- check_name: check-test-types
task: test-types
runner: blacksmith-4vcpu-ubuntu-2404
- check_name: check-strict-smoke
task: strict-smoke
runner: ubuntu-24.04
steps:
- name: Checkout
shell: bash
@@ -1140,18 +1393,11 @@ jobs:
run: |
set -euo pipefail
case "$TASK" in
guards)
preflight-guards)
pnpm check:no-conflict-markers
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
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
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
prod-types)
pnpm tsgo:prod
@@ -1168,9 +1414,19 @@ jobs:
pnpm deadcode:ci
fi
;;
policy-guards)
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group
pnpm lint:auth:pairing-account-scope
pnpm check:import-cycles
;;
test-types)
pnpm check:test-types
;;
strict-smoke)
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
pnpm build:plugin-sdk:strict-smoke
;;
*)
echo "Unsupported check task: $TASK" >&2
exit 1
@@ -1185,13 +1441,31 @@ jobs:
path: .artifacts/deadcode
if-no-files-found: ignore
check:
permissions:
contents: read
name: "check"
needs: [preflight, check-shard]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify check shards
env:
SHARD_RESULT: ${{ needs.check-shard.result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Check shards failed: $SHARD_RESULT" >&2
exit 1
fi
check-additional-shard:
permissions:
contents: read
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1200,9 +1474,15 @@ jobs:
- check_name: check-additional-boundaries-a
group: boundaries
boundary_shard: 1/4
- check_name: check-additional-boundaries-bcd
- check_name: check-additional-boundaries-b
group: boundaries
boundary_shard: 2/4,3/4,4/4
boundary_shard: 2/4
- check_name: check-additional-boundaries-c
group: boundaries
boundary_shard: 3/4
- check_name: check-additional-boundaries-d
group: boundaries
boundary_shard: 4/4
- check_name: check-additional-extension-channels
group: extension-channels
- check_name: check-additional-extension-bundled
@@ -1356,13 +1636,59 @@ jobs:
exit "$failures"
check-additional:
permissions:
contents: read
name: "check-additional"
needs: [preflight, check-additional-shard, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_check_additional == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify additional check shards
env:
SHARD_RESULT: ${{ needs.check-additional-shard.result }}
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
GATEWAY_RESULT: ${{ needs.build-artifacts.outputs.gateway-watch-result }}
run: |
if [ "$SHARD_RESULT" != "success" ]; then
echo "Additional check shards failed: $SHARD_RESULT" >&2
exit 1
fi
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build artifact job failed: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
if [ "$GATEWAY_RESULT" != "success" ]; then
echo "Gateway topology check failed: $GATEWAY_RESULT" >&2
exit 1
fi
build-smoke:
permissions:
contents: read
name: "build-smoke"
needs: [preflight, build-artifacts]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_build_smoke == 'true' && (github.event_name != 'push' || needs.build-artifacts.result == 'success') }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Verify build smoke
env:
BUILD_ARTIFACTS_RESULT: ${{ needs.build-artifacts.result }}
run: |
if [ "$BUILD_ARTIFACTS_RESULT" != "success" ]; then
echo "Build smoke checks failed in build-artifacts: $BUILD_ARTIFACTS_RESULT" >&2
exit 1
fi
# Validate docs (format, lint, broken links) only when docs files changed.
check-docs:
permissions:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_check_docs == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -1436,7 +1762,7 @@ jobs:
contents: read
needs: [preflight]
if: needs.preflight.outputs.run_skills_python_job == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-4vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ubuntu-24.04
timeout-minutes: 20
steps:
- name: Checkout
@@ -1468,7 +1794,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_checks_windows == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'windows-2025' || (github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-16vcpu-windows-2025' || 'windows-2025' }}
timeout-minutes: 60
env:
NODE_OPTIONS: --max-old-space-size=8192
@@ -1581,7 +1907,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: ${{ !cancelled() && always() && needs.preflight.outputs.run_macos_node == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-latest' || (github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-6vcpu-macos-latest' || 'macos-latest' }}
timeout-minutes: 20
strategy:
fail-fast: false
@@ -1625,7 +1951,7 @@ jobs:
name: "macos-swift"
needs: [preflight]
if: needs.preflight.outputs.run_macos_swift == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-26') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-latest' || 'macos-latest' }}
timeout-minutes: 20
steps:
- name: Checkout
@@ -1722,7 +2048,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_android_job == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04') }}
runs-on: ${{ github.repository == 'openclaw/openclaw' && 'blacksmith-8vcpu-ubuntu-2404' || 'ubuntu-24.04' }}
timeout-minutes: 20
strategy:
fail-fast: false

View File

@@ -137,10 +137,8 @@ jobs:
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_DOCS_I18N_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENCLAW_CONTROL_UI_I18N_PROVIDER: ${{ secrets.ANTHROPIC_API_KEY != '' && 'anthropic' || 'openai' }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ secrets.ANTHROPIC_API_KEY != '' && 'claude-opus-4-7' || vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
OPENCLAW_CONTROL_UI_I18N_MODEL: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
OPENCLAW_CONTROL_UI_I18N_THINKING: low
OPENCLAW_CONTROL_UI_I18N_AUTH_OPTIONAL: "1"
LOCALE: ${{ matrix.locale }}
run: node --import tsx scripts/control-ui-i18n.ts sync --locale "${LOCALE}" --write

View File

@@ -62,20 +62,17 @@ jobs:
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo ln -sf "$pnpm_bin" /usr/local/bin/pnpm
- name: Ensure Docker is running
- name: Ensure Docker is available
shell: bash
run: |
set -euo pipefail
if ! command -v docker >/dev/null 2>&1; then
echo "docker not found; installing fallback engine"
curl -fsSL https://get.docker.com | sudo sh
fi
if command -v systemctl >/dev/null 2>&1; then
sudo systemctl start docker || true
elif command -v service >/dev/null 2>&1; then
sudo service docker start || true
sudo systemctl start docker
fi
if [ -S /var/run/docker.sock ]; then
@@ -85,10 +82,6 @@ jobs:
sudo chmod 666 /var/run/docker.sock
fi
docker version
docker buildx version || true
docker compose version || true
- name: Hydrate provider env helper
shell: bash
env:

View File

@@ -1,171 +0,0 @@
name: Dependency Change Awareness
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] metadata-only workflow; no checkout or untrusted code execution
types: [opened, reopened, synchronize, ready_for_review]
permissions:
pull-requests: write
issues: write
concurrency:
group: dependency-change-awareness-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
dependency-change-awareness:
if: ${{ !github.event.pull_request.draft }}
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Label and comment on dependency changes
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const marker = "<!-- openclaw:dependency-change-awareness -->";
const labelName = "dependencies-changed";
const maxListedFiles = 25;
const pullRequest = context.payload.pull_request;
if (!pullRequest) {
core.info("No pull_request payload found; skipping.");
return;
}
const isDependencyFile = (filename) =>
filename === "package.json" ||
filename === "pnpm-lock.yaml" ||
filename === "pnpm-workspace.yaml" ||
filename === "ui/package.json" ||
filename.startsWith("patches/") ||
/^packages\/[^/]+\/package\.json$/u.test(filename) ||
/^extensions\/[^/]+\/package\.json$/u.test(filename);
const sanitizeDisplayValue = (value) =>
String(value)
.replace(/[\u0000-\u001f\u007f]/gu, "?")
.slice(0, 240);
const markdownCode = (value) =>
`\`${sanitizeDisplayValue(value).replaceAll("`", "\\`")}\``;
const ignoreUnavailableWritePermission = (action) => (error) => {
if (error?.status === 403) {
core.warning(
`Skipping dependency change ${action}; token does not have issue write permission.`,
);
return;
}
if (error?.status === 404 || error?.status === 422) {
core.warning(`Dependency change ${action} is unavailable.`);
return;
}
throw error;
};
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullRequest.number,
per_page: 100,
});
const dependencyFiles = files
.map((file) => file.filename)
.filter((filename) => typeof filename === "string" && isDependencyFile(filename))
.sort((left, right) => left.localeCompare(right));
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const existingComment = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" && comment.body?.includes(marker),
);
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
per_page: 100,
});
const hasLabel = labels.some((label) => label.name === labelName);
if (dependencyFiles.length === 0) {
if (hasLabel) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
name: labelName,
}).catch(ignoreUnavailableWritePermission("label removal"));
}
if (existingComment) {
await github.rest.issues.deleteComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
}).catch(ignoreUnavailableWritePermission("comment deletion"));
}
await core.summary
.addHeading("Dependency Change Awareness")
.addRaw("No dependency-related file changes detected.")
.write();
core.info("No dependency-related file changes detected.");
return;
}
if (!hasLabel) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
labels: [labelName],
}).catch(ignoreUnavailableWritePermission(`label "${labelName}" update`));
}
const listedFiles = dependencyFiles.slice(0, maxListedFiles);
const omittedCount = dependencyFiles.length - listedFiles.length;
const fileLines = listedFiles.map((filename) => `- ${markdownCode(filename)}`);
if (omittedCount > 0) {
fileLines.push(`- ${omittedCount} additional dependency-related files not shown`);
}
const body = [
marker,
"",
"### Dependency Changes Detected",
"",
"This PR changes dependency-related files. Maintainers should confirm these changes are intentional.",
"",
"Changed files:",
...fileLines,
"",
"Maintainer follow-up:",
"- Review whether the dependency changes are intentional.",
"- Inspect resolved package deltas when lockfile or workspace dependency policy changes are present.",
"- Run `pnpm deps:changes:report -- --base-ref origin/main --markdown /tmp/dependency-changes.md --json /tmp/dependency-changes.json` locally for detailed release-style evidence.",
].join("\n");
if (existingComment) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existingComment.id,
body,
}).catch(ignoreUnavailableWritePermission("comment update"));
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullRequest.number,
body,
}).catch(ignoreUnavailableWritePermission("comment creation"));
}
await core.summary
.addHeading("Dependency Change Awareness")
.addRaw(`Detected ${dependencyFiles.length} dependency-related file change(s).`)
.addList(dependencyFiles.map((filename) => markdownCode(filename)))
.write();
core.notice(`Detected ${dependencyFiles.length} dependency-related file change(s).`);

View File

@@ -155,7 +155,7 @@ jobs:
cache-from: type=gha,scope=docker-release-amd64
cache-to: type=gha,mode=max,scope=docker-release-amd64
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
OPENCLAW_EXTENSIONS=diagnostics-otel
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
sbom: true
@@ -253,7 +253,7 @@ jobs:
cache-from: type=gha,scope=docker-release-arm64
cache-to: type=gha,mode=max,scope=docker-release-arm64
build-args: |
OPENCLAW_EXTENSIONS=diagnostics-otel,codex
OPENCLAW_EXTENSIONS=diagnostics-otel
tags: ${{ steps.tags.outputs.value }}
labels: ${{ steps.labels.outputs.value }}
sbom: true

View File

@@ -16,37 +16,29 @@ permissions:
jobs:
sync-publish-repo:
runs-on: ubuntu-latest
env:
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
steps:
- name: Skip publish sync without token
if: env.OPENCLAW_DOCS_SYNC_TOKEN == ''
run: echo "OPENCLAW_DOCS_SYNC_TOKEN is not configured; skipping docs publish repo sync."
- name: Checkout source repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Checkout ClawHub docs source
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/checkout@v6
with:
repository: openclaw/clawhub
path: clawhub-source
fetch-depth: 1
persist-credentials: false
token: ${{ env.OPENCLAW_DOCS_SYNC_TOKEN || github.token }}
token: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN || github.token }}
- name: Setup Node
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
uses: actions/setup-node@v6
with:
node-version: "24.x"
node-version: "22.18.0"
- name: Clone publish repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
env:
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
run: |
set -euo pipefail
for attempt in 1 2 3 4 5; do
@@ -64,7 +56,6 @@ jobs:
exit 1
- name: Sync docs into publish repo
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
run: |
clawhub_sha="$(git -C "$GITHUB_WORKSPACE/clawhub-source" rev-parse HEAD)"
node scripts/docs-sync-publish.mjs \
@@ -76,16 +67,13 @@ jobs:
--clawhub-source-sha "$clawhub_sha"
- name: Install docs MDX checker dependency
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
working-directory: publish
run: npm install --no-save --package-lock=false @mdx-js/mdx@3.1.1
- name: Check publish docs MDX
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
run: node "$GITHUB_WORKSPACE/publish/.openclaw-sync/check-docs-mdx.mjs" "$GITHUB_WORKSPACE/publish/docs"
- name: Commit publish repo sync
if: env.OPENCLAW_DOCS_SYNC_TOKEN != ''
working-directory: publish
run: |
set -euo pipefail

View File

@@ -6,7 +6,6 @@ on:
paths:
- "**/*.md"
- "docs/**"
- "!CHANGELOG.md"
permissions:
contents: read
@@ -36,15 +35,5 @@ jobs:
with:
install-bun: "false"
- name: Checkout ClawHub docs source
uses: actions/checkout@v6
with:
repository: openclaw/clawhub
path: clawhub-source
fetch-depth: 1
persist-credentials: false
- name: Check docs
env:
OPENCLAW_DOCS_SYNC_CLAWHUB_REPO: ${{ github.workspace }}/clawhub-source
run: pnpm check:docs

View File

@@ -88,11 +88,6 @@ on:
required: false
default: ""
type: string
codex_plugin_spec:
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
required: false
default: ""
type: string
npm_telegram_provider_mode:
description: Provider mode for the package Telegram E2E lane
required: false
@@ -113,7 +108,7 @@ permissions:
concurrency:
group: full-release-validation-${{ inputs.ref }}-${{ inputs.rerun_group }}
cancel-in-progress: ${{ (inputs.ref == 'main' && inputs.rerun_group == 'all') || startsWith(inputs.ref, 'tideclaw/alpha/') }}
cancel-in-progress: ${{ inputs.ref == 'main' && inputs.rerun_group == 'all' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -156,7 +151,6 @@ jobs:
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
EVIDENCE_PACKAGE_SPEC: ${{ inputs.evidence_package_spec }}
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }}
RERUN_GROUP: ${{ inputs.rerun_group }}
@@ -214,43 +208,14 @@ jobs:
else
echo "- Package Acceptance package spec: SHA-built release artifact"
fi
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
docker_runtime_assets_preflight:
name: Verify Docker runtime-assets prune path
needs: [resolve_target]
if: inputs.rerun_group == 'all'
runs-on: ubuntu-24.04
timeout-minutes: 45
permissions:
contents: read
steps:
- name: Checkout target SHA
uses: actions/checkout@v6
with:
ref: ${{ needs.resolve_target.outputs.sha }}
fetch-depth: 1
persist-credentials: false
- name: Verify Docker runtime-assets prune path
env:
DOCKER_BUILDKIT: "1"
run: |
set -euo pipefail
timeout --foreground --kill-after=30s 35m docker build \
--target runtime-assets \
--build-arg OPENCLAW_EXTENSIONS="matrix" \
.
normal_ci:
name: Run normal full CI
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","ci"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
needs: [resolve_target]
if: contains(fromJSON('["all","ci"]'), inputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -332,7 +297,6 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -347,10 +311,10 @@ jobs:
plugin_prerelease:
name: Run plugin prerelease validation
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
needs: [resolve_target]
if: contains(fromJSON('["all","plugin-prerelease"]'), inputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || inputs.release_profile == 'stable' && 240 || 60 }}
timeout-minutes: ${{ inputs.release_profile == 'full' && 300 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -432,7 +396,6 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -447,10 +410,10 @@ jobs:
release_checks:
name: Run release/live/Docker/QA validation
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
needs: [resolve_target]
if: contains(fromJSON('["all","release-checks","install-smoke","cross-os","live-e2e","package","qa","qa-parity","qa-live"]'), inputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile != 'minimum' && 240 || 60 }}
timeout-minutes: ${{ inputs.release_profile == 'full' && 240 || 60 }}
outputs:
run_id: ${{ steps.dispatch.outputs.run_id }}
url: ${{ steps.dispatch.outputs.url }}
@@ -472,7 +435,6 @@ jobs:
CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
run: |
set -euo pipefail
@@ -542,7 +504,6 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
}
@@ -568,9 +529,6 @@ jobs:
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`"
fi
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
child_rerun_group="$RERUN_GROUP"
@@ -599,16 +557,13 @@ jobs:
if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then
args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC")
fi
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC")
fi
dispatch_and_wait openclaw-release-checks.yml "${args[@]}"
prepare_release_package:
name: Prepare release package artifact
needs: [resolve_target, docker_runtime_assets_preflight]
if: ${{ always() && needs.resolve_target.result == 'success' && inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' && needs.docker_runtime_assets_preflight.result == 'success' }}
needs: [resolve_target]
if: ${{ inputs.npm_telegram_package_spec == '' && inputs.release_package_spec == '' && inputs.rerun_group == 'all' && inputs.release_profile == 'full' }}
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
@@ -680,7 +635,6 @@ jobs:
name: Run package Telegram E2E
needs: [resolve_target, prepare_release_package]
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
runs-on: ubuntu-24.04
timeout-minutes: ${{ inputs.release_profile == 'full' && 120 || 60 }}
outputs:
@@ -772,16 +726,71 @@ jobs:
echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT"
if [[ "$conclusion" != "success" ]]; then
gh run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
exit 1
fi
summary:
name: Verify full validation
needs: [resolve_target, docker_runtime_assets_preflight, normal_ci, plugin_prerelease, release_checks, npm_telegram]
needs: [resolve_target, normal_ci, plugin_prerelease, release_checks, npm_telegram]
if: always()
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Request private evidence update
env:
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
exit 0
fi
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
exit 0
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$PACKAGE_SPEC" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
if [[ -z "$release_id" ]]; then
echo "::error::Could not derive release evidence id from target ref '${TARGET_REF}'."
exit 1
fi
payload="$(
jq -cn \
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$PACKAGE_SPEC" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
'{
event_type: "openclaw_full_release_validation_completed",
client_payload: {
full_validation_run_id: $full_validation_run_id,
release_id: $release_id,
release_ref: $release_ref,
package_spec: $package_spec,
notes: $notes
}
}'
)"
curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"
- name: Verify child workflow results
env:
GH_TOKEN: ${{ github.token }}
@@ -793,8 +802,6 @@ jobs:
PLUGIN_PRERELEASE_RESULT: ${{ needs.plugin_prerelease.result }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
NPM_TELEGRAM_RESULT: ${{ needs.npm_telegram.result }}
DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT: ${{ needs.docker_runtime_assets_preflight.result }}
RERUN_GROUP: ${{ inputs.rerun_group }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
run: |
@@ -842,7 +849,6 @@ jobs:
echo
echo "| Child | Result | Minutes | Head SHA | Run |"
echo "| --- | --- | ---: | --- | --- |"
echo "| \`docker_runtime_assets_preflight\` | \`${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}\` | | current workflow | |"
} >> "$GITHUB_STEP_SUMMARY"
append_child_row() {
@@ -929,103 +935,30 @@ jobs:
} >> "$GITHUB_STEP_SUMMARY"
}
summarize_failed_child() {
local label="$1"
local run_id="$2"
if [[ -z "${run_id// }" ]]; then
return 0
fi
local run_json status conclusion artifacts_json
run_json="$(gh run view "$run_id" --json status,conclusion,url,jobs)"
status="$(jq -r '.status' <<< "$run_json")"
conclusion="$(jq -r '.conclusion' <<< "$run_json")"
if [[ "$status" == "completed" && "$conclusion" == "success" ]]; then
return 0
fi
{
echo
echo "### Failed child detail: ${label}"
echo
jq -r '
"- Run: " + (.url // ""),
"- Result: `" + (.status // "") + "/" + (.conclusion // "") + "`",
"",
"Failed jobs:",
(.jobs[]
| select(.conclusion != "success" and .conclusion != "skipped")
| "- `" + (.name | gsub("`"; "\\`")) + "`: `" + ((.conclusion // .status // "") | tostring) + "` " + (.url // ""))
' <<< "$run_json" || true
echo
echo "Artifacts:"
artifacts_json="$(
gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts?per_page=100" 2>/dev/null || true
)"
if [[ -n "${artifacts_json// }" ]]; then
jq -r '
if ((.artifacts // []) | length) == 0 then
"- none"
else
(.artifacts[]
| "- `" + (.name | gsub("`"; "\\`")) + "` (" + ((.size_in_bytes // 0) | tostring) + " bytes)")
end
' <<< "$artifacts_json" || echo "- unable to list artifacts"
else
echo "- unable to list artifacts"
fi
} >> "$GITHUB_STEP_SUMMARY"
}
failed=0
normal_ci_required=0
plugin_prerelease_required=0
release_checks_required=0
if [[ "$RERUN_GROUP" == "all" && "$DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT" != "success" ]]; then
echo "::error::Docker runtime-assets preflight ended with ${DOCKER_RUNTIME_ASSETS_PREFLIGHT_RESULT}."
failed=1
elif [[ "$RERUN_GROUP" == "all" ]]; then
normal_ci_required=1
plugin_prerelease_required=1
release_checks_required=1
else
case "$RERUN_GROUP" in
ci)
normal_ci_required=1
;;
plugin-prerelease)
plugin_prerelease_required=1
;;
release-checks|install-smoke|cross-os|live-e2e|package|qa|qa-parity|qa-live)
release_checks_required=1
;;
esac
fi
append_child_overview
if [[ "$NORMAL_CI_RESULT" == "skipped" && -z "${NORMAL_CI_RUN_ID// }" ]]; then
check_child "normal_ci" "" "$normal_ci_required" || failed=1
check_child "normal_ci" "" 0 || failed=1
else
check_child "normal_ci" "$NORMAL_CI_RUN_ID" 1 || failed=1
fi
if [[ "$PLUGIN_PRERELEASE_RESULT" == "skipped" && -z "${PLUGIN_PRERELEASE_RUN_ID// }" ]]; then
check_child "plugin_prerelease" "" "$plugin_prerelease_required" || failed=1
check_child "plugin_prerelease" "" 0 || failed=1
else
check_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID" 1 || failed=1
fi
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" && -z "${RELEASE_CHECKS_RUN_ID// }" ]]; then
check_child "release_checks" "" "$release_checks_required" || failed=1
check_child "release_checks" "" 0 || failed=1
else
check_child "release_checks" "$RELEASE_CHECKS_RUN_ID" 1 || failed=1
fi
if [[ "$NPM_TELEGRAM_RESULT" == "skipped" && -z "${NPM_TELEGRAM_RUN_ID// }" ]]; then
check_child "npm_telegram" "" 0 || failed=1
elif [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 0 || echo "::warning::npm_telegram is advisory for Tideclaw alpha validation."
else
check_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID" 1 || failed=1
fi
@@ -1035,143 +968,4 @@ jobs:
summarize_child_timing "release_checks" "$RELEASE_CHECKS_RUN_ID"
summarize_child_timing "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
if [[ "$failed" != "0" ]]; then
summarize_failed_child "normal_ci" "$NORMAL_CI_RUN_ID"
summarize_failed_child "plugin_prerelease" "$PLUGIN_PRERELEASE_RUN_ID"
summarize_failed_child "release_checks" "$RELEASE_CHECKS_RUN_ID"
summarize_failed_child "npm_telegram" "$NPM_TELEGRAM_RUN_ID"
fi
exit "$failed"
- name: Request private evidence update
env:
RELEASE_PRIVATE_DISPATCH_TOKEN: ${{ secrets.OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN }}
TARGET_REF: ${{ inputs.ref }}
PACKAGE_SPEC: ${{ inputs.evidence_package_spec || inputs.npm_telegram_package_spec }}
GITHUB_RUN_ID_VALUE: ${{ github.run_id }}
RELEASE_CHECKS_RESULT: ${{ needs.release_checks.result }}
run: |
set -euo pipefail
if [[ "$RELEASE_CHECKS_RESULT" == "skipped" ]]; then
echo "Release checks were skipped by rerun group; skipping automatic private evidence update."
exit 0
fi
if [[ -z "${RELEASE_PRIVATE_DISPATCH_TOKEN// }" ]]; then
echo "OPENCLAW_RELEASES_PRIVATE_DISPATCH_TOKEN is not configured; skipping automatic private evidence update."
exit 0
fi
evidence_package_spec="$PACKAGE_SPEC"
if [[ -z "${evidence_package_spec// }" ]]; then
tag_ref="${TARGET_REF#refs/tags/}"
if [[ "$tag_ref" =~ ^v([0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*((-(alpha|beta)\.[1-9][0-9]*)|(-[1-9][0-9]*))?)$ ]]; then
evidence_package_spec="openclaw@${BASH_REMATCH[1]}"
fi
fi
release_id="${TARGET_REF#refs/tags/}"
release_id="${release_id#v}"
if [[ "$evidence_package_spec" =~ ^openclaw@(.+)$ ]]; then
release_id="${BASH_REMATCH[1]}"
fi
release_id="$(printf '%s' "$release_id" | tr '/:@ ' '----' | tr -cd 'A-Za-z0-9._-')"
if [[ -z "$release_id" ]]; then
echo "::warning::Could not derive release evidence id from target ref '${TARGET_REF}'; skipping automatic private evidence update."
exit 0
fi
payload="$(
jq -cn \
--arg full_validation_run_id "$GITHUB_RUN_ID_VALUE" \
--arg release_id "$release_id" \
--arg release_ref "$TARGET_REF" \
--arg package_spec "$evidence_package_spec" \
--arg notes "Automatically requested by Full Release Validation ${GITHUB_RUN_ID_VALUE} after child workflows completed; the parent summary re-checks current child run conclusions." \
'{
event_type: "openclaw_full_release_validation_completed",
client_payload: {
full_validation_run_id: $full_validation_run_id,
release_id: $release_id,
release_ref: $release_ref,
package_spec: $package_spec,
notes: $notes
}
}'
)"
if ! curl --fail-with-body \
-X POST \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer ${RELEASE_PRIVATE_DISPATCH_TOKEN}" \
-H "X-GitHub-Api-Version: 2022-11-28" \
https://api.github.com/repos/openclaw/releases-private/dispatches \
-d "$payload"; then
echo "::warning::Automatic private release evidence dispatch failed; child workflow validation remains authoritative."
{
echo "### Private release evidence dispatch failed"
echo
echo "Child workflow validation remains authoritative. Backfill durable evidence from \`openclaw/releases-private\`:"
echo
echo "\`\`\`bash"
echo "gh workflow run openclaw-release-evidence-from-full-validation.yml --repo openclaw/releases-private --ref main -f full_validation_run_id=${GITHUB_RUN_ID_VALUE} -f release_id=${release_id} -f release_ref=${TARGET_REF} -f package_spec=${evidence_package_spec}"
echo "\`\`\`"
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Write release validation manifest
if: ${{ success() }}
env:
TARGET_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
RERUN_GROUP: ${{ inputs.rerun_group }}
RUN_RELEASE_SOAK: ${{ inputs.run_release_soak || inputs.release_profile == 'full' }}
NORMAL_CI_RUN_ID: ${{ needs.normal_ci.outputs.run_id }}
PLUGIN_PRERELEASE_RUN_ID: ${{ needs.plugin_prerelease.outputs.run_id }}
RELEASE_CHECKS_RUN_ID: ${{ needs.release_checks.outputs.run_id }}
NPM_TELEGRAM_RUN_ID: ${{ needs.npm_telegram.outputs.run_id }}
run: |
set -euo pipefail
manifest_dir="${RUNNER_TEMP}/full-release-validation"
mkdir -p "$manifest_dir"
jq -n \
--arg workflowName "Full Release Validation" \
--arg runId "$GITHUB_RUN_ID" \
--arg runAttempt "$GITHUB_RUN_ATTEMPT" \
--arg workflowRef "$GITHUB_REF_NAME" \
--arg targetRef "$TARGET_REF" \
--arg targetSha "$TARGET_SHA" \
--arg releaseProfile "$RELEASE_PROFILE" \
--arg rerunGroup "$RERUN_GROUP" \
--arg runReleaseSoak "$RUN_RELEASE_SOAK" \
--arg normalCiRunId "$NORMAL_CI_RUN_ID" \
--arg pluginPrereleaseRunId "$PLUGIN_PRERELEASE_RUN_ID" \
--arg releaseChecksRunId "$RELEASE_CHECKS_RUN_ID" \
--arg npmTelegramRunId "$NPM_TELEGRAM_RUN_ID" \
'{
version: 1,
workflowName: $workflowName,
runId: $runId,
runAttempt: $runAttempt,
workflowRef: $workflowRef,
targetRef: $targetRef,
targetSha: $targetSha,
releaseProfile: $releaseProfile,
rerunGroup: $rerunGroup,
runReleaseSoak: $runReleaseSoak,
childRuns: {
normalCi: $normalCiRunId,
pluginPrerelease: $pluginPrereleaseRunId,
releaseChecks: $releaseChecksRunId,
npmTelegram: $npmTelegramRunId
}
}' > "${manifest_dir}/full-release-validation-manifest.json"
- name: Upload release validation manifest
if: ${{ success() }}
uses: actions/upload-artifact@v7
with:
name: full-release-validation-${{ github.run_id }}
path: ${{ runner.temp }}/full-release-validation
if-no-files-found: error

View File

@@ -100,7 +100,7 @@ jobs:
install-smoke-fast:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' && needs.preflight.outputs.run_full_install_smoke != 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
@@ -208,7 +208,7 @@ jobs:
root_dockerfile_image:
needs: [preflight]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
outputs:
image_ref: ${{ steps.image.outputs.image_ref }}
env:
@@ -284,7 +284,7 @@ jobs:
qr_package_install_smoke:
needs: [preflight]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout CLI
uses: actions/checkout@v6
@@ -299,7 +299,7 @@ jobs:
root_dockerfile_smokes:
needs: [preflight, root_dockerfile_image]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout CLI
uses: actions/checkout@v6
@@ -401,7 +401,7 @@ jobs:
installer_smoke:
needs: [preflight, root_dockerfile_image]
if: needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
env:
DOCKER_BUILD_SUMMARY: "false"
DOCKER_BUILD_RECORD_UPLOAD: "false"
@@ -471,7 +471,7 @@ jobs:
bun_global_install_smoke:
needs: [preflight, root_dockerfile_image]
if: needs.preflight.outputs.run_full_install_smoke == 'true' && needs.preflight.outputs.run_bun_global_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout CLI
uses: actions/checkout@v6
@@ -505,7 +505,7 @@ jobs:
docker-e2e-fast:
needs: [preflight]
if: needs.preflight.outputs.run_fast_install_smoke == 'true' || needs.preflight.outputs.run_full_install_smoke == 'true'
runs-on: ubuntu-24.04
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 12
env:
DOCKER_BUILD_SUMMARY: "false"

View File

@@ -760,7 +760,6 @@ jobs:
core.info(`Processed ${processed} pull requests.`);
label-issues:
if: github.event_name == 'issues'
permissions:
issues: write
runs-on: ubuntu-24.04

View File

@@ -33,11 +33,8 @@ jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -51,18 +48,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
@@ -168,7 +161,7 @@ jobs:
- name: Upload Mantis artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/mantis/

View File

@@ -21,7 +21,7 @@ on:
type: string
permissions:
contents: read
contents: write
issues: write
pull-requests: write
@@ -46,17 +46,15 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
)
)
}}
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -70,18 +68,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
@@ -127,7 +121,7 @@ jobs:
const normalized = body.toLowerCase();
const requested =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
normalized.includes("discord") &&
normalized.includes("status") &&
normalized.includes("reaction");
@@ -348,8 +342,8 @@ jobs:
--repo-root "$repo_root" \
--output-dir "$output_dir" \
--provider-mode live-frontier \
--model openai/gpt-5.5 \
--alt-model openai/gpt-5.5 \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--credential-source convex \
--credential-role ci \
@@ -528,7 +522,7 @@ jobs:
- name: Upload Mantis status reaction artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-status-reactions-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
@@ -544,6 +538,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -551,15 +546,9 @@ jobs:
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }}
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
shell: bash
run: |
set -euo pipefail
@@ -573,44 +562,3 @@ jobs:
--artifact-url "$ARTIFACT_URL" \
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
--request-source "$REQUEST_SOURCE"
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_refs, run_status_reactions]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -21,7 +21,7 @@ on:
type: string
permissions:
contents: read
contents: write
issues: write
pull-requests: write
@@ -46,17 +46,15 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
)
)
}}
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -70,18 +68,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
@@ -127,7 +121,7 @@ jobs:
const normalized = body.toLowerCase();
const requested =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
normalized.includes("discord") &&
normalized.includes("thread") &&
(normalized.includes("attachment") ||
@@ -536,7 +530,7 @@ jobs:
- name: Upload Mantis thread attachment artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
@@ -552,6 +546,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -559,15 +554,9 @@ jobs:
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }}
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
shell: bash
run: |
set -euo pipefail
@@ -595,44 +584,3 @@ jobs:
run: |
echo "Mantis comparison failed." >&2
exit 1
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_candidate, run_thread_attachment]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -44,7 +44,7 @@ on:
- prehydrated
permissions:
contents: read
contents: write
issues: write
pull-requests: write
@@ -64,11 +64,8 @@ jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: ubuntu-24.04
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -82,18 +79,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_ref:
name: Validate candidate ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: ubuntu-24.04
outputs:
candidate_revision: ${{ steps.validate.outputs.candidate_revision }}
@@ -281,8 +274,8 @@ jobs:
--credential-role ci \
--provider-mode live-frontier \
--hydrate-mode "$HYDRATE_MODE" \
--model openai/gpt-5.5 \
--alt-model openai/gpt-5.5 \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--scenario "$SCENARIO_ID" \
"${keep_args[@]}" \
@@ -359,7 +352,7 @@ jobs:
- name: Upload Mantis Slack desktop artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-slack-desktop-smoke-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
@@ -375,6 +368,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -382,15 +376,9 @@ jobs:
if: ${{ always() && inputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' && steps.upload_artifact.outputs.artifact-url != '' }}
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: workflow_dispatch
TARGET_PR: ${{ inputs.pr_number }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
REQUEST_SOURCE: workflow_dispatch
shell: bash
run: |
set -euo pipefail

View File

@@ -3,8 +3,6 @@ name: Mantis Telegram Desktop Proof
on:
issue_comment:
types: [created]
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned Mantis label trigger; trusted base workflow validates refs before checkout/use
types: [labeled]
workflow_dispatch:
inputs:
pr_number:
@@ -27,18 +25,10 @@ on:
description: Optional existing Crabbox desktop lease id or slug to reuse
required: false
type: string
publish_artifact_name:
description: Optional existing proof artifact name to publish without recapturing
required: false
type: string
publish_run_id:
description: Workflow run id that owns publish_artifact_name; required with publish_artifact_name
required: false
type: string
permissions:
actions: read
contents: read
contents: write
issues: write
pull-requests: write
@@ -57,11 +47,6 @@ jobs:
if: >-
${{
github.event_name == 'workflow_dispatch' ||
(
github.event_name == 'pull_request_target' &&
github.event.action == 'labeled' &&
github.event.label.name == 'mantis: telegram-visible-proof'
) ||
(
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
@@ -73,20 +58,11 @@ jobs:
)
}}
runs-on: ubuntu-24.04
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
if (context.eventName === "pull_request_target") {
core.info(`Accepted Mantis label trigger from ${context.actor}.`);
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
@@ -97,18 +73,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: ubuntu-24.04
outputs:
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
@@ -116,11 +88,8 @@ jobs:
crabbox_provider: ${{ steps.resolve.outputs.crabbox_provider }}
instructions: ${{ steps.resolve.outputs.instructions }}
lease_id: ${{ steps.resolve.outputs.lease_id }}
publish_artifact_name: ${{ steps.resolve.outputs.publish_artifact_name }}
publish_run_id: ${{ steps.resolve.outputs.publish_run_id }}
pr_number: ${{ steps.resolve.outputs.pr_number }}
request_source: ${{ steps.resolve.outputs.request_source }}
should_run: ${{ steps.resolve.outputs.should_run }}
steps:
- name: Resolve refs and target PR
id: resolve
@@ -136,70 +105,31 @@ jobs:
const inputs = context.payload.inputs ?? {};
const prNumber =
eventName === "workflow_dispatch"
? inputs.pr_number
: eventName === "pull_request_target"
? String(context.payload.pull_request?.number ?? "")
: String(context.payload.issue?.number ?? "");
eventName === "workflow_dispatch" ? inputs.pr_number : String(context.payload.issue?.number ?? "");
if (!prNumber) {
core.setFailed("Mantis Telegram desktop proof requires a pull request.");
return;
}
const body =
eventName === "workflow_dispatch"
? inputs.instructions || ""
: eventName === "issue_comment"
? context.payload.comment?.body || ""
: "";
if (eventName === "issue_comment") {
const normalized = body.toLowerCase();
const requestedDesktopProof =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
(normalized.includes("desktop proof") ||
normalized.includes("desktop-proof") ||
normalized.includes("telegram desktop") ||
normalized.includes("native telegram") ||
normalized.includes("visible proof") ||
normalized.includes("visible-proof") ||
normalized.includes("telegram-visible-proof"));
if (!requestedDesktopProof) {
core.notice("Comment mentioned Mantis but did not request Telegram desktop proof.");
setOutput("should_run", "false");
setOutput("baseline_ref", "");
setOutput("candidate_ref", "");
setOutput("pr_number", "");
setOutput("instructions", "");
setOutput("crabbox_provider", "");
setOutput("lease_id", "");
setOutput("publish_artifact_name", "");
setOutput("publish_run_id", "");
setOutput("request_source", "unsupported_issue_comment");
return;
}
}
const { owner, repo } = context.repo;
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: Number(prNumber),
});
const body = eventName === "workflow_dispatch" ? inputs.instructions || "" : context.payload.comment?.body || "";
const provider = inputs.crabbox_provider || "aws";
if (!["aws", "hetzner"].includes(provider)) {
core.setFailed(`Unsupported Crabbox provider for Mantis Telegram desktop proof: ${provider}`);
return;
}
setOutput("should_run", "true");
setOutput("baseline_ref", pr.base.sha);
setOutput("candidate_ref", pr.head.sha);
setOutput("pr_number", String(pr.number));
setOutput("instructions", body);
setOutput("crabbox_provider", provider);
setOutput("lease_id", inputs.crabbox_lease_id || "");
setOutput("publish_artifact_name", inputs.publish_artifact_name || "");
setOutput("publish_run_id", inputs.publish_run_id || "");
setOutput("request_source", eventName);
if (eventName === "issue_comment") {
@@ -214,7 +144,6 @@ jobs:
validate_refs:
name: Validate selected refs
needs: resolve_request
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
runs-on: ubuntu-24.04
outputs:
baseline_revision: ${{ steps.validate.outputs.baseline_revision }}
@@ -293,7 +222,6 @@ jobs:
run_telegram_desktop_proof:
name: Run agentic native Telegram proof
needs: [resolve_request, validate_refs]
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name == ''
runs-on: blacksmith-16vcpu-ubuntu-2404
timeout-minutes: 360
environment: qa-live-shared
@@ -308,36 +236,16 @@ jobs:
run: |
set -euo pipefail
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
run_has_active_jobs() {
local run_id="$1"
local run_state="$2"
if [[ "$run_state" != "in_progress" ]]; then
return 0
fi
local active_jobs
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
[[ "$active_jobs" != "0" ]]
}
while true; do
candidates="$(
blockers="$(
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
for status in queued in_progress waiting pending requested; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
--arg stale_before "$stale_before" \
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done | sort -u
)"
blockers=""
while IFS=$'\t' read -r created run_id run_state url; do
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
fi
done <<<"$candidates"
if [[ -z "$blockers" ]]; then
break
fi
@@ -426,7 +334,7 @@ jobs:
printf '%s\n' 'Defaults env_keep += "BASELINE_REF BASELINE_SHA CANDIDATE_REF CANDIDATE_SHA"'
printf '%s\n' 'Defaults env_keep += "CRABBOX_ACCESS_CLIENT_ID CRABBOX_ACCESS_CLIENT_SECRET CRABBOX_COORDINATOR CRABBOX_COORDINATOR_TOKEN CRABBOX_LEASE_ID CRABBOX_PROVIDER"'
printf '%s\n' 'Defaults env_keep += "GH_TOKEN MANTIS_CANDIDATE_TRUST MANTIS_INSTRUCTIONS MANTIS_OUTPUT_DIR MANTIS_PR_NUMBER"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_CREDENTIAL_OWNER_ID OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_BUILD_PRIVATE_QA OPENCLAW_ENABLE_PRIVATE_QA_CLI OPENCLAW_QA_CONVEX_SECRET_CI OPENCLAW_QA_CONVEX_SITE_URL OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN"'
printf '%s\n' 'Defaults env_keep += "OPENCLAW_TELEGRAM_USER_CRABBOX_BIN OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER OPENCLAW_TELEGRAM_USER_DRIVER_SCRIPT OPENCLAW_TELEGRAM_USER_PROOF_CMD"'
} | sudo tee /etc/sudoers.d/mantis-codex-env >/dev/null
sudo chmod 0440 /etc/sudoers.d/mantis-codex-env
@@ -462,7 +370,6 @@ jobs:
MANTIS_PR_NUMBER: ${{ needs.resolve_request.outputs.pr_number }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CREDENTIAL_OWNER_ID: mantis-telegram-desktop-${{ github.run_id }}-${{ github.run_attempt }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR }}
OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN: ${{ secrets.OPENCLAW_QA_MANTIS_CRABBOX_COORDINATOR_TOKEN }}
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN: /usr/local/bin/crabbox
@@ -473,54 +380,11 @@ jobs:
openai-api-key: ${{ secrets.OPENCLAW_MANTIS_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/mantis-telegram-desktop-proof.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: medium
effort: high
sandbox: danger-full-access
codex-args: '["-c","service_tier=\"fast\""]'
codex-home: /tmp/mantis-codex-home-${{ github.run_id }}
safety-strategy: unprivileged-user
codex-user: codex
allow-bot-users: clawsweeper[bot]
- name: Release leaked Telegram proof leases
if: ${{ always() }}
env:
CRABBOX_PROVIDER: ${{ needs.resolve_request.outputs.crabbox_provider }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
shell: bash
run: |
set -euo pipefail
if [[ ! -d .artifacts/qa-e2e ]]; then
exit 0
fi
status=0
mapfile -d '' session_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/session.json' -type f -print0)
for session_file in "${session_files[@]}"; do
lease_file="${session_file%/session.json}/.session/lease.json"
if [[ ! -f "$lease_file" ]]; then
continue
fi
if ! sudo -u codex env \
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
OPENCLAW_TELEGRAM_USER_CRABBOX_BIN=/usr/local/bin/crabbox \
OPENCLAW_TELEGRAM_USER_CRABBOX_PROVIDER="$CRABBOX_PROVIDER" \
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-crabbox-proof.ts" \
finish --session "$session_file" --preview-crop telegram-window; then
status=1
fi
done
mapfile -d '' lease_files < <(sudo find .artifacts/qa-e2e -path '*/telegram-user-crabbox/*/.session/lease.json' -type f -print0)
for lease_file in "${lease_files[@]}"; do
if ! sudo -u codex env \
OPENCLAW_QA_CONVEX_SECRET_CI="$OPENCLAW_QA_CONVEX_SECRET_CI" \
OPENCLAW_QA_CONVEX_SITE_URL="$OPENCLAW_QA_CONVEX_SITE_URL" \
node --import tsx "$GITHUB_WORKSPACE/scripts/e2e/telegram-user-credential.ts" \
release --lease-file "$lease_file"; then
status=1
fi
done
exit "$status"
- name: Inspect Mantis evidence manifest
id: inspect
@@ -541,7 +405,7 @@ jobs:
- name: Upload Mantis Telegram desktop artifacts
id: upload_artifact
if: ${{ always() && steps.inspect.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-telegram-desktop-proof-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.inspect.outputs.output_dir }}
@@ -557,6 +421,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -565,12 +430,6 @@ jobs:
env:
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
shell: bash
@@ -601,133 +460,3 @@ jobs:
run: |
echo "Mantis Telegram desktop proof failed: comparison=${COMPARISON_STATUS:-unset}." >&2
exit 1
publish_existing_telegram_desktop_proof:
name: Publish existing native Telegram proof
needs: resolve_request
if: needs.resolve_request.outputs.should_run == 'true' && needs.resolve_request.outputs.publish_artifact_name != ''
runs-on: ubuntu-24.04
environment: qa-live-shared
steps:
- name: Checkout harness ref
uses: actions/checkout@v6
with:
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Download existing proof artifact
env:
GH_TOKEN: ${{ github.token }}
PUBLISH_ARTIFACT_NAME: ${{ needs.resolve_request.outputs.publish_artifact_name }}
PUBLISH_RUN_ID: ${{ needs.resolve_request.outputs.publish_run_id }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${PUBLISH_RUN_ID:-}" ]]; then
echo "publish_run_id is required when publish_artifact_name is set." >&2
exit 1
fi
run_id="$PUBLISH_RUN_ID"
gh run download "$run_id" \
--repo "$GITHUB_REPOSITORY" \
--name "$PUBLISH_ARTIFACT_NAME" \
--dir "$MANTIS_OUTPUT_DIR"
artifacts_json="$(
gh api \
-H "Accept: application/vnd.github+json" \
"repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts"
)"
artifact_id="$(jq -r --arg name "$PUBLISH_ARTIFACT_NAME" '.artifacts[] | select(.name == $name) | .id' <<<"$artifacts_json" | head -n 1)"
if [[ -z "$artifact_id" || "$artifact_id" == "null" ]]; then
echo "Could not resolve artifact id for '${PUBLISH_ARTIFACT_NAME}' in run ${run_id}." >&2
exit 1
fi
echo "PUBLISH_RUN_ID=${run_id}" >> "$GITHUB_ENV"
echo "PUBLISH_ARTIFACT_URL=https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}/artifacts/${artifact_id}" >> "$GITHUB_ENV"
- name: Create Mantis GitHub App token
id: mantis_app_token
uses: actions/create-github-app-token@v3
with:
app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }}
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-issues: write
permission-pull-requests: write
- name: Comment PR with inline QA evidence
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
shell: bash
run: |
set -euo pipefail
root="$MANTIS_OUTPUT_DIR"
if [[ ! -f "$root/mantis-evidence.json" ]]; then
echo "Downloaded artifact does not contain ${root}/mantis-evidence.json." >&2
exit 1
fi
node scripts/mantis/publish-pr-evidence.mjs \
--manifest "$root/mantis-evidence.json" \
--target-pr "$TARGET_PR" \
--artifact-root "mantis/telegram-desktop/pr-${TARGET_PR}/published-${PUBLISH_RUN_ID}-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
--marker "<!-- mantis-telegram-desktop-proof -->" \
--artifact-url "$PUBLISH_ARTIFACT_URL" \
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${PUBLISH_RUN_ID}" \
--request-source "$REQUEST_SOURCE"
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_refs, run_telegram_desktop_proof]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -34,7 +34,7 @@ on:
permissions:
actions: read
contents: read
contents: write
issues: write
pull-requests: write
@@ -56,17 +56,15 @@ jobs:
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
(
contains(github.event.comment.body, '@openclaw-mantis') ||
contains(github.event.comment.body, '/openclaw-mantis')
contains(github.event.comment.body, '@Mantis') ||
contains(github.event.comment.body, '@mantis') ||
contains(github.event.comment.body, '/mantis')
)
)
}}
runs-on: ubuntu-24.04
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
@@ -80,18 +78,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
resolve_request:
name: Resolve Mantis request
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: ubuntu-24.04
outputs:
candidate_ref: ${{ steps.resolve.outputs.candidate_ref }}
@@ -139,18 +133,9 @@ jobs:
}
const normalized = body.toLowerCase();
const requestedDesktopProof =
normalized.includes("desktop proof") ||
normalized.includes("desktop-proof") ||
normalized.includes("telegram desktop") ||
normalized.includes("native telegram") ||
normalized.includes("visible proof") ||
normalized.includes("visible-proof") ||
normalized.includes("telegram-visible-proof");
const requested =
(normalized.includes("@openclaw-mantis") || normalized.includes("/openclaw-mantis")) &&
normalized.includes("telegram") &&
!requestedDesktopProof;
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
normalized.includes("telegram");
if (!requested) {
core.notice("Comment mentioned Mantis but did not request Telegram live QA.");
setOutput("should_run", "false");
@@ -272,36 +257,16 @@ jobs:
run: |
set -euo pipefail
current_created="$(gh api "repos/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" --jq .created_at)"
stale_before="$(date -u -d '8 hours ago' +%Y-%m-%dT%H:%M:%SZ)"
run_has_active_jobs() {
local run_id="$1"
local run_state="$2"
if [[ "$run_state" != "in_progress" ]]; then
return 0
fi
local active_jobs
active_jobs="$(gh run view "$run_id" --repo "$GITHUB_REPOSITORY" --json jobs --jq '[.jobs[] | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested")] | length')"
[[ "$active_jobs" != "0" ]]
}
while true; do
candidates="$(
blockers="$(
for workflow in mantis-telegram-desktop-proof.yml mantis-telegram-live.yml; do
for status in queued in_progress waiting pending requested; do
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --status "$status" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
--arg stale_before "$stale_before" \
'.[] | select(.databaseId != $current_id) | select(.createdAt >= $stale_before) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done
gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --limit 100 --json databaseId,status,createdAt,url \
| jq -r \
--argjson current_id "$GITHUB_RUN_ID" \
--arg current_created "$current_created" \
'.[] | select(.databaseId != $current_id) | select(.createdAt < $current_created or (.createdAt == $current_created and .databaseId < $current_id)) | select(.status == "queued" or .status == "in_progress" or .status == "waiting" or .status == "pending" or .status == "requested") | "\(.createdAt)\t#\(.databaseId)\t\(.status)\t\(.url)"'
done | sort -u
)"
blockers=""
while IFS=$'\t' read -r created run_id run_state url; do
if [[ -n "$run_id" ]] && run_has_active_jobs "${run_id#\#}" "$run_state"; then
blockers+="${created}"$'\t'"${run_id}"$'\t'"${run_state}"$'\t'"${url}"$'\n'
fi
done <<<"$candidates"
if [[ -z "$blockers" ]]; then
break
fi
@@ -414,7 +379,7 @@ jobs:
output_rel=".artifacts/qa-e2e/mantis/telegram-live"
root="$candidate_repo/$output_rel"
echo "output_dir=${root}" >> "$GITHUB_OUTPUT"
model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.5}"
model="${OPENCLAW_CI_OPENAI_MODEL:-openai/gpt-5.4}"
scenario_args=()
if [[ -n "${SCENARIO_INPUT// }" ]]; then
@@ -499,7 +464,7 @@ jobs:
- name: Upload Mantis Telegram artifacts
id: upload_artifact
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: mantis-telegram-live-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_mantis.outputs.output_dir }}
@@ -515,6 +480,7 @@ jobs:
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
permission-contents: write
permission-issues: write
permission-pull-requests: write
@@ -522,15 +488,9 @@ jobs:
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }}
env:
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
MANTIS_ARTIFACT_R2_ACCESS_KEY_ID: ${{ secrets.MANTIS_ARTIFACT_R2_ACCESS_KEY_ID }}
MANTIS_ARTIFACT_R2_BUCKET: openclaw-crabbox-artifacts
MANTIS_ARTIFACT_R2_ENDPOINT: ${{ vars.MANTIS_ARTIFACT_R2_ENDPOINT }}
MANTIS_ARTIFACT_R2_PUBLIC_BASE_URL: https://artifacts.openclaw.ai
MANTIS_ARTIFACT_R2_REGION: auto
MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY: ${{ secrets.MANTIS_ARTIFACT_R2_SECRET_ACCESS_KEY }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
shell: bash
run: |
set -euo pipefail
@@ -560,44 +520,3 @@ jobs:
run: |
echo "Mantis Telegram live failed: comparison=${COMPARISON_STATUS:-unset} telegram_exit=${TELEGRAM_EXIT:-unset}." >&2
exit 1
clear_issue_comment_reaction:
name: Clear Mantis command reaction
needs: [resolve_request, validate_ref, run_telegram_live]
if: ${{ always() && github.event_name == 'issue_comment' && needs.resolve_request.outputs.request_source == 'issue_comment' }}
runs-on: ubuntu-24.04
permissions:
issues: write
steps:
- name: Remove workflow eyes reaction
uses: actions/github-script@v8
with:
script: |
const { owner, repo } = context.repo;
const commentId = context.payload.comment?.id;
if (!commentId) {
core.info("No issue comment id found; skipping reaction cleanup.");
return;
}
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
owner,
repo,
comment_id: commentId,
per_page: 100,
});
const eyes = reactions.filter(
(reaction) => reaction.content === "eyes" && reaction.user?.login === "github-actions[bot]",
);
for (const reaction of eyes) {
await github.rest.reactions.deleteForIssueComment({
owner,
repo,
comment_id: commentId,
reaction_id: reaction.id,
});
core.info(`Removed eyes reaction ${reaction.id} from comment ${commentId}.`);
}
if (eyes.length === 0) {
core.info(`No workflow eyes reaction found on comment ${commentId}.`);
}

View File

@@ -40,18 +40,8 @@ on:
description: Optional comma-separated Telegram scenario ids
required: false
type: string
advisory:
description: Treat package Telegram failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat package Telegram failures as advisory for the caller
required: false
default: false
type: boolean
package_spec:
description: Published OpenClaw package spec to test when no artifact is supplied
required: true
@@ -110,7 +100,6 @@ jobs:
run_package_telegram_e2e:
name: Run package Telegram E2E
runs-on: blacksmith-32vcpu-ubuntu-2404
continue-on-error: ${{ inputs.advisory }}
timeout-minutes: 60
environment: qa-live-shared
permissions:
@@ -270,7 +259,7 @@ jobs:
- name: Upload npm Telegram E2E artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: npm-telegram-beta-e2e-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/

View File

@@ -86,18 +86,8 @@ on:
required: false
default: ""
type: string
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
ref:
description: Public OpenClaw ref to validate (tag, branch, or full commit SHA)
required: true
@@ -196,12 +186,11 @@ env:
PNPM_VERSION: "11.0.8"
OPENCLAW_REPOSITORY: openclaw/openclaw
TSX_VERSION: "4.21.0"
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.5' }}
OPENCLAW_CROSS_OS_OPENAI_MODEL: ${{ inputs.openai_model || vars.OPENCLAW_CROSS_OS_OPENAI_MODEL || 'openai/gpt-5.4' }}
jobs:
prepare:
runs-on: ubuntu-24.04
continue-on-error: ${{ inputs.advisory }}
outputs:
baseline_file_name: ${{ steps.baseline_metadata.outputs.file_name }}
baseline_spec: ${{ steps.baseline.outputs.value }}
@@ -524,7 +513,6 @@ jobs:
cross_os_release_checks:
name: "${{ matrix.display_name }} / ${{ matrix.suite_label }}"
needs: prepare
continue-on-error: ${{ inputs.advisory }}
strategy:
fail-fast: false
matrix: ${{ fromJson(needs.prepare.outputs.matrix) }}

View File

@@ -68,11 +68,6 @@ on:
required: false
default: ""
type: string
codex_plugin_spec:
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -102,18 +97,8 @@ on:
- beta
- stable
- full
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat failures as advisory for the caller
required: false
default: false
type: boolean
ref:
description: Ref, tag, or SHA to validate
required: true
@@ -178,11 +163,6 @@ on:
required: false
default: ""
type: string
codex_plugin_spec:
description: Optional Codex plugin install spec for the live package lane; blank packs extensions/codex from the selected ref
required: false
default: ""
type: string
include_live_suites:
description: Whether to run live-provider coverage
required: false
@@ -331,6 +311,9 @@ jobs:
set -euo pipefail
trusted_reason=""
git fetch --no-tags origin '+refs/heads/*:refs/remotes/origin/*'
git fetch --tags origin '+refs/tags/*:refs/tags/*'
# Resolve here instead of in actions/checkout so short SHAs work too.
if ! selected_sha="$(git rev-parse --verify "${INPUT_REF}^{commit}")"; then
echo "Ref '${INPUT_REF}' could not be resolved to a commit." >&2
@@ -444,7 +427,6 @@ jobs:
add_profile_suite live-cli-backend-docker "stable full"
add_profile_suite live-acp-bind-docker "stable full"
add_profile_suite live-codex-harness-docker "stable full"
add_profile_suite live-subagent-announce-docker "stable full"
add_profile_suite native-live-extensions-a-k "full"
add_profile_suite native-live-extensions-media-audio "full"
@@ -472,8 +454,7 @@ jobs:
validate_release_live_cache:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'live-cache')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -523,8 +504,7 @@ jobs:
validate_repo_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e && inputs.live_suite_filter == ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
env:
OPENCLAW_VITEST_MAX_WORKERS: "2"
@@ -553,8 +533,7 @@ jobs:
validate_special_e2e:
needs: validate_selected_ref
if: inputs.include_repo_e2e && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'openshell-e2e')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
@@ -628,8 +607,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (${{ matrix.label }})
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
@@ -641,7 +619,7 @@ jobs:
profiles: stable full
- chunk_id: package-update-openai
label: package/update OpenAI install
timeout_minutes: 45
timeout_minutes: 20
profiles: beta minimum stable full
- chunk_id: package-update-anthropic
label: package/update Anthropic install
@@ -744,7 +722,6 @@ jobs:
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_DOCKER_ALL_RELEASE_PROFILE: ${{ inputs.release_test_profile }}
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ inputs.published_upgrade_survivor_baselines }}
@@ -898,8 +875,7 @@ jobs:
plan_docker_lane_groups:
needs: validate_selected_ref
if: inputs.docker_lanes != ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-4vcpu-ubuntu-2404' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
outputs:
groups_json: ${{ steps.groups.outputs.groups_json }}
@@ -926,8 +902,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image, plan_docker_lane_groups]
if: inputs.docker_lanes != ''
name: Docker E2E targeted lanes (${{ matrix.group.label }})
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
strategy:
fail-fast: false
@@ -985,7 +960,6 @@ jobs:
OPENCLAW_DOCKER_E2E_PACKAGE_ARTIFACT_NAME: ${{ inputs.package_artifact_name || 'docker-e2e-package' }}
OPENCLAW_DOCKER_E2E_REPO_ROOT: ${{ github.workspace }}
OPENCLAW_DOCKER_E2E_SELECTED_SHA: ${{ needs.validate_selected_ref.outputs.selected_sha }}
OPENCLAW_CODEX_NPM_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }}
OPENCLAW_CURRENT_PACKAGE_TGZ: .artifacts/docker-e2e-package/openclaw-current.tgz
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPEC: ${{ inputs.published_upgrade_survivor_baseline }}
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: ${{ matrix.group.published_upgrade_survivor_baselines || inputs.published_upgrade_survivor_baselines }}
@@ -1137,8 +1111,7 @@ jobs:
needs: [validate_selected_ref, prepare_docker_e2e_image]
if: inputs.include_openwebui && !inputs.include_release_path_suites && inputs.docker_lanes == ''
name: Docker E2E (openwebui)
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -1265,8 +1238,7 @@ jobs:
prepare_docker_e2e_image:
needs: validate_selected_ref
if: inputs.include_release_path_suites || inputs.include_openwebui || inputs.docker_lanes != ''
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ inputs.release_test_profile == 'full' && 90 || 60 }}
permissions:
actions: read
@@ -1510,8 +1482,7 @@ jobs:
prepare_live_test_image:
needs: validate_selected_ref
if: inputs.include_live_suites && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-') || startsWith(inputs.live_suite_filter, 'docker-live-models'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1584,8 +1555,7 @@ jobs:
name: Docker live models (${{ matrix.provider_label }})
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && inputs.live_model_providers == '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
@@ -1737,8 +1707,7 @@ jobs:
name: Docker live models (selected providers)
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && inputs.live_model_providers != '' && (inputs.live_suite_filter == '' || inputs.live_suite_filter == 'docker-live-models')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: 45
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -1913,8 +1882,7 @@ jobs:
validate_live_provider_suites:
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || (startsWith(inputs.live_suite_filter, 'native-live-') && !startsWith(inputs.live_suite_filter, 'native-live-extensions-media') && inputs.live_suite_filter != 'native-live-extensions-a-k'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
@@ -1942,7 +1910,7 @@ jobs:
- suite_id: native-live-src-gateway-profiles-anthropic-opus
suite_group: native-live-src-gateway-profiles-anthropic
label: Native live gateway profiles Anthropic Opus
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=anthropic OPENCLAW_LIVE_GATEWAY_MODELS=anthropic/claude-opus-4-7,anthropic/claude-opus-4-6 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
timeout_minutes: 30
profile_env_only: false
advisory: true
@@ -2185,11 +2153,27 @@ jobs:
fi
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
# Keep the release-blocking CI lane on Codex API-key auth. The
# staged auth-file path remains supported for local maintainer
# reruns, but it can hang on stale subscription/session state in
# an otherwise healthy release run.
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
# Replace the staged config.toml with a minimal CI-safe config so
# the repo stays trusted for MCP/tool use without inheriting
# maintainer-local provider/profile overrides that do not exist
# inside CI.
# Codex's workspace-write sandbox relies on user namespaces that
# this Docker lane does not provide, so run Codex unsandboxed
# inside the already-isolated container to keep MCP cron/tool
# execution representative instead of failing on nested sandbox
# setup.
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
;;
live-codex-harness-docker)
# Keep CI on the API-key path for now. The staged Codex auth secret
@@ -2235,8 +2219,7 @@ jobs:
name: Docker live suites (${{ matrix.label }})
needs: [validate_selected_ref, prepare_live_test_image]
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'live-'))
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-32vcpu-ubuntu-2404' }}
runs-on: blacksmith-32vcpu-ubuntu-2404
timeout-minutes: ${{ matrix.timeout_minutes }}
strategy:
fail-fast: false
@@ -2308,12 +2291,6 @@ jobs:
timeout_minutes: 40
profile_env_only: false
profiles: stable full
- suite_id: live-subagent-announce-docker
label: Docker live subagent announce
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
timeout_minutes: 25
profile_env_only: false
profiles: stable full
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -2411,11 +2388,14 @@ jobs:
fi
case "${{ matrix.suite_id }}" in
live-cli-backend-docker)
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=codex-cli/gpt-5.4" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_ARGS=["exec","--json","--color","never","--sandbox","danger-full-access","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo 'OPENCLAW_LIVE_CLI_BACKEND_RESUME_ARGS=["exec","resume","{sessionId}","-c","sandbox_mode=\"danger-full-access\"","-c","service_tier=\"fast\"","--skip-git-repo-check"]' >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CLI_BACKEND_USE_CI_SAFE_CODEX_CONFIG=1" >> "$GITHUB_ENV"
;;
live-codex-harness-docker)
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
@@ -2455,8 +2435,7 @@ jobs:
name: Live media suites (${{ matrix.label }})
needs: validate_selected_ref
if: inputs.include_live_suites && !inputs.live_models_only && (inputs.live_suite_filter == '' || startsWith(inputs.live_suite_filter, 'native-live-extensions-media') || inputs.live_suite_filter == 'native-live-extensions-a-k')
continue-on-error: ${{ inputs.advisory }}
runs-on: ${{ github.event_name == 'workflow_call' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
container:
image: ghcr.io/openclaw/openclaw-live-media-runner:ubuntu-24.04
credentials:

View File

@@ -16,14 +16,6 @@ on:
description: Existing successful preflight workflow run id to promote without rebuilding
required: false
type: string
full_release_validation_run_id:
description: Successful Full Release Validation run id for this tag/SHA, required for real publish
required: false
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
npm_dist_tag:
description: npm dist-tag to publish to
required: true
@@ -36,7 +28,7 @@ on:
concurrency:
group: openclaw-npm-release-${{ github.event_name == 'workflow_dispatch' && format('{0}-{1}', inputs.tag, inputs.npm_dist_tag) || github.ref }}
cancel-in-progress: ${{ github.event_name == 'workflow_dispatch' && inputs.preflight_only && inputs.npm_dist_tag == 'alpha' }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -92,28 +84,6 @@ jobs:
ref: ${{ inputs.tag }}
fetch-depth: 0
- name: Validate Tideclaw alpha preflight target
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
env:
RELEASE_REF: ${{ inputs.tag }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_REF}" == *"-alpha."* && ! "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
echo "Tideclaw alpha preflight runs must target an alpha prerelease tag or SHA." >&2
exit 1
fi
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Tideclaw alpha preflight runs must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
exit 1
fi
alpha_branch="${WORKFLOW_REF#refs/heads/}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if ! git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
echo "Alpha preflight target must be reachable from ${alpha_branch}." >&2
exit 1
fi
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -199,27 +169,12 @@ jobs:
- name: Verify release contents
run: pnpm release:check
- name: Generate dependency release evidence
id: dependency_evidence
env:
RELEASE_REF: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
run: |
set -euo pipefail
node scripts/generate-dependency-release-evidence.mjs \
--release-ref "$RELEASE_REF" \
--npm-dist-tag "$RELEASE_NPM_DIST_TAG" \
--output-dir "$RUNNER_TEMP/openclaw-release-dependency-evidence" \
--github-output "$GITHUB_OUTPUT" \
--github-step-summary "$GITHUB_STEP_SUMMARY"
- name: Pack prepared npm tarball
id: packed_tarball
env:
OPENCLAW_PREPACK_PREPARED: "1"
RELEASE_REF: ${{ inputs.tag }}
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
DEPENDENCY_EVIDENCE_DIR: ${{ steps.dependency_evidence.outputs.dir }}
run: |
set -euo pipefail
PACK_OUTPUT="$RUNNER_TEMP/npm-pack-output.txt"
@@ -285,18 +240,12 @@ jobs:
fi
RELEASE_SHA="$(git rev-parse HEAD)"
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
if [[ "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ ]]; then
RELEASE_TAG="v${PACKAGE_VERSION}"
else
RELEASE_TAG="${RELEASE_REF}"
fi
TARBALL_NAME="$(basename "$PACK_PATH")"
TARBALL_SHA256="$(sha256sum "$PACK_PATH" | awk '{print $1}')"
ARTIFACT_DIR="$RUNNER_TEMP/openclaw-npm-preflight"
rm -rf "$ARTIFACT_DIR"
mkdir -p "$ARTIFACT_DIR"
cp "$PACK_PATH" "$ARTIFACT_DIR/"
cp -R "$DEPENDENCY_EVIDENCE_DIR" "$ARTIFACT_DIR/dependency-evidence"
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
printf '%s\n' "$RELEASE_NPM_DIST_TAG" > "$ARTIFACT_DIR/release-npm-dist-tag.txt"
@@ -312,8 +261,6 @@ jobs:
packageVersion: process.env.PACKAGE_VERSION,
tarballName: process.env.TARBALL_NAME,
tarballSha256: process.env.TARBALL_SHA256,
dependencyEvidenceDir: "dependency-evidence",
dependencyEvidenceManifest: "dependency-evidence/dependency-evidence-manifest.json",
};
fs.writeFileSync(
path.join(process.env.ARTIFACT_DIR, "preflight-manifest.json"),
@@ -321,36 +268,6 @@ jobs:
);
NODE
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
echo "release_tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT"
- name: Verify prepared npm tarball install
env:
PREFLIGHT_ARTIFACT_DIR: ${{ steps.packed_tarball.outputs.dir }}
run: |
set -euo pipefail
TARBALL_PATH="$(find "$PREFLIGHT_ARTIFACT_DIR" -maxdepth 1 -type f -name '*.tgz' -print | sort | tail -n 1)"
if [[ -z "$TARBALL_PATH" ]]; then
echo "Prepared preflight tarball not found." >&2
ls -la "$PREFLIGHT_ARTIFACT_DIR" >&2 || true
exit 1
fi
PACKAGE_VERSION="$(node -p "require('./package.json').version")"
node --import tsx scripts/openclaw-npm-prepublish-verify.ts "$TARBALL_PATH" "$PACKAGE_VERSION"
- name: Upload dependency release evidence
uses: actions/upload-artifact@v7
with:
name: openclaw-release-dependency-evidence-${{ inputs.tag }}
path: ${{ steps.dependency_evidence.outputs.dir }}
if-no-files-found: error
- name: Upload dependency release evidence tag alias
if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }}
uses: actions/upload-artifact@v7
with:
name: openclaw-release-dependency-evidence-${{ steps.packed_tarball.outputs.release_tag }}
path: ${{ steps.dependency_evidence.outputs.dir }}
if-no-files-found: error
- name: Upload prepared npm publish bundle
uses: actions/upload-artifact@v7
@@ -359,78 +276,31 @@ jobs:
path: ${{ steps.packed_tarball.outputs.dir }}
if-no-files-found: error
- name: Upload prepared npm publish bundle tag alias
if: ${{ steps.packed_tarball.outputs.release_tag != inputs.tag }}
uses: actions/upload-artifact@v7
with:
name: openclaw-npm-preflight-${{ steps.packed_tarball.outputs.release_tag }}
path: ${{ steps.packed_tarball.outputs.dir }}
if-no-files-found: error
validate_publish_request:
if: ${{ !inputs.preflight_only }}
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Require trusted workflow ref for publish
- name: Require main or release workflow ref for publish
env:
RELEASE_TAG: ${{ inputs.tag }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
tideclaw_alpha_publish=false
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_publish=true
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ "${tideclaw_alpha_publish}" != "true" ]]; then
echo "Real publish runs must be dispatched from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases. Use preflight_only=true for other branch validation."
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
echo "Real publish runs must be dispatched from main or release/YYYY.M.D. Use preflight_only=true for other branch validation."
exit 1
fi
- name: Require preflight artifact promotion on real publish
env:
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
run: |
set -euo pipefail
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2
exit 1
fi
if [[ -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then
echo "Real publish requires full_release_validation_run_id from a successful Full Release Validation run." >&2
exit 1
fi
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" && "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Workflow-dispatched real publish requires release_publish_run_id from the approved OpenClaw Release Publish workflow." >&2
exit 1
fi
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "OpenClaw npm publish dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct OpenClaw npm publish; relying on this workflow's npm-release environment approval."
exit 0
fi
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "OpenClaw npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
publish_openclaw_npm:
# KEEP THE REAL RELEASE/PUBLISH PATH ON A GITHUB-HOSTED RUNNER.
@@ -469,28 +339,6 @@ jobs:
ref: refs/tags/${{ inputs.tag }}
fetch-depth: 0
- name: Validate Tideclaw alpha publish target
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
env:
RELEASE_TAG: ${{ inputs.tag }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" == *"-alpha."* ]]; then
echo "Tideclaw alpha publish runs must target an alpha prerelease tag." >&2
exit 1
fi
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Tideclaw alpha publish runs must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
exit 1
fi
alpha_branch="${WORKFLOW_REF#refs/heads/}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if ! git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
echo "Alpha publish tag must be reachable from ${alpha_branch}." >&2
exit 1
fi
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
@@ -520,76 +368,13 @@ jobs:
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw NPM Release"], ["headBranch", process.env.EXPECTED_PREFLIGHT_BRANCH], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`);'
- name: Verify full release validation run metadata
env:
GH_TOKEN: ${{ github.token }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
- name: Download prepared npm tarball
env:
GH_TOKEN: ${{ github.token }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
download_preflight_artifact() {
local preferred_name fallback_name
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
rm -rf preflight-tarball
mkdir -p preflight-tarball
download_named_artifact() {
local artifact_name="$1"
for attempt in 1 2 3; do
if gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${artifact_name}" \
--dir preflight-tarball; then
return 0
fi
if [[ "$attempt" != "3" ]]; then
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
sleep $((attempt * 10))
fi
done
return 1
}
if download_named_artifact "${preferred_name}"; then
echo "Downloaded ${preferred_name}."
return 0
fi
echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact."
mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.expired != true) | .name' |
grep '^openclaw-npm-preflight-' || true)
if [[ "${#matches[@]}" != "1" ]]; then
echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2
printf 'Available preflight candidates:\n' >&2
printf -- '- %s\n' "${matches[@]:-<none>}" >&2
exit 1
fi
fallback_name="${matches[0]}"
download_named_artifact "${fallback_name}"
echo "Downloaded fallback preflight artifact ${fallback_name}."
}
download_preflight_artifact
- name: Download full release validation manifest
uses: actions/download-artifact@v8
with:
name: full-release-validation-${{ inputs.full_release_validation_run_id }}
path: full-release-validation
name: openclaw-npm-preflight-${{ inputs.tag }}
path: preflight-tarball
repository: ${{ github.repository }}
run-id: ${{ inputs.full_release_validation_run_id }}
run-id: ${{ inputs.preflight_run_id }}
github-token: ${{ github.token }}
- name: Validate release tag and package metadata
@@ -648,32 +433,6 @@ jobs:
exit 1
fi
- name: Verify full release validation target
run: |
set -euo pipefail
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
MANIFEST_FILE="full-release-validation/full-release-validation-manifest.json"
if [[ ! -f "$MANIFEST_FILE" ]]; then
echo "Full release validation manifest is missing." >&2
ls -la full-release-validation >&2 || true
exit 1
fi
WORKFLOW_NAME="$(jq -r '.workflowName // ""' "$MANIFEST_FILE")"
TARGET_SHA="$(jq -r '.targetSha // ""' "$MANIFEST_FILE")"
RERUN_GROUP="$(jq -r '.rerunGroup // ""' "$MANIFEST_FILE")"
if [[ "$WORKFLOW_NAME" != "Full Release Validation" ]]; then
echo "Full release validation manifest workflow mismatch: $WORKFLOW_NAME" >&2
exit 1
fi
if [[ "$TARGET_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
echo "Full release validation target SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $TARGET_SHA" >&2
exit 1
fi
if [[ "$RERUN_GROUP" != "all" ]]; then
echo "Full release validation must run rerun_group=all before npm publish; got $RERUN_GROUP" >&2
exit 1
fi
- name: Resolve publish tarball
id: publish_tarball
run: |

View File

@@ -30,8 +30,8 @@ on:
required: false
default: false
type: boolean
live_openai_candidate:
description: Run the live OpenAI GPT 5.5 agent-turn lane
live_gpt54:
description: Run the live OpenAI GPT 5.4 agent-turn lane
required: false
default: false
type: boolean
@@ -57,7 +57,7 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
OCM_VERSION: v0.2.15
KOVA_REPOSITORY: openclaw/Kova
PERFORMANCE_MODEL_ID: gpt-5.5
PERFORMANCE_MODEL_ID: gpt-5.4
jobs:
kova:
@@ -82,8 +82,8 @@ jobs:
deep_profile: "true"
live: "false"
include_filters: "scenario:fresh-install scenario:gateway-performance scenario:agent-cold-warm-message"
- lane: live-openai-candidate
title: Kova live OpenAI GPT 5.5 agent turn
- lane: live-gpt54
title: Kova live OpenAI GPT 5.4 agent turn
auth: live
repeat: "1"
deep_profile: "false"
@@ -119,9 +119,9 @@ jobs:
run_lane=false
reason="deep_profile input is false"
fi
if [[ "$LANE_ID" == "live-openai-candidate" && "${{ github.event_name }}" != "schedule" && "${{ inputs.live_openai_candidate || 'false' }}" != "true" ]]; then
if [[ "$LANE_ID" == "live-gpt54" && "${{ github.event_name }}" != "schedule" && "${{ inputs.live_gpt54 || 'false' }}" != "true" ]]; then
run_lane=false
reason="live_openai_candidate input is false"
reason="live_gpt54 input is false"
fi
echo "run=$run_lane" >> "$GITHUB_OUTPUT"
if [[ "$run_lane" != "true" ]]; then
@@ -200,7 +200,7 @@ jobs:
chmod 0755 "$HOME/.local/bin/kova"
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Pin Kova OpenAI model to GPT 5.5
- name: Pin Kova OpenAI model to GPT 5.4
if: steps.lane.outputs.run == 'true'
shell: bash
run: |
@@ -244,7 +244,7 @@ jobs:
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "OPENAI_API_KEY is not configured; live GPT 5.5 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
echo "OPENAI_API_KEY is not configured; live GPT 5.4 lane will be skipped." >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
kova setup --ci --json
@@ -468,7 +468,7 @@ jobs:
- name: Upload Kova artifacts
if: ${{ always() && steps.lane.outputs.run == 'true' }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v5
with:
name: openclaw-performance-${{ matrix.lane }}-${{ github.run_id }}-${{ github.run_attempt }}
path: |
@@ -489,7 +489,9 @@ jobs:
reports_root=".artifacts/clawgrit-reports"
mkdir -p "$reports_root"
git -C "$reports_root" init -b main
git -C "$reports_root" remote add origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
git -C "$reports_root" remote add origin https://github.com/openclaw/clawgrit-reports.git
auth_header="$(printf 'x-access-token:%s' "$CLAWGRIT_REPORTS_TOKEN" | base64 -w0)"
git -C "$reports_root" config http.https://github.com/.extraheader "AUTHORIZATION: basic ${auth_header}"
if git -C "$reports_root" ls-remote --exit-code --heads origin main >/dev/null 2>&1; then
git -C "$reports_root" fetch --depth=1 origin main
git -C "$reports_root" checkout -B main FETCH_HEAD
@@ -499,13 +501,10 @@ jobs:
- name: Publish to clawgrit reports
if: ${{ steps.kova.outputs.report_json != '' && steps.clawgrit.outputs.present == 'true' }}
env:
CLAWGRIT_REPORTS_TOKEN: ${{ secrets.CLAWGRIT_REPORTS_TOKEN }}
shell: bash
run: |
set -euo pipefail
reports_root=".artifacts/clawgrit-reports"
git -C "$reports_root" remote set-url origin "https://x-access-token:${CLAWGRIT_REPORTS_TOKEN}@github.com/openclaw/clawgrit-reports.git"
ref_slug="$(printf '%s' "${TESTED_REF}" | tr -c 'A-Za-z0-9._-' '-')"
run_slug="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
dest="${reports_root}/openclaw-performance/${ref_slug}/${run_slug}/${LANE_ID}"
@@ -561,14 +560,7 @@ jobs:
exit 0
fi
if [[ "$attempt" == "5" ]]; then
{
echo "### Clawgrit report publish skipped"
echo
echo "Kova artifacts were uploaded, but publishing the optional clawgrit report failed after ${attempt} attempts."
echo "Check the \`CLAWGRIT_REPORTS_TOKEN\` secret or the reports repository permissions."
} >> "$GITHUB_STEP_SUMMARY"
echo "::warning::Kova artifacts uploaded, but optional clawgrit report publish failed after ${attempt} attempts."
exit 0
exit 1
fi
sleep $((attempt * 2))
git -C "$reports_root" fetch --depth=1 origin main

View File

@@ -78,15 +78,10 @@ on:
required: false
default: ""
type: string
codex_plugin_spec:
description: Optional Codex plugin install spec for live Docker package checks; blank derives from release_package_spec or packs the selected ref
required: false
default: ""
type: string
concurrency:
group: openclaw-release-checks-${{ inputs.expected_sha || inputs.ref }}-${{ inputs.rerun_group }}
cancel-in-progress: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
@@ -117,23 +112,14 @@ jobs:
qa_live_slack_enabled: ${{ steps.inputs.outputs.qa_live_slack_enabled }}
release_package_spec: ${{ steps.inputs.outputs.release_package_spec }}
package_acceptance_package_spec: ${{ steps.inputs.outputs.package_acceptance_package_spec }}
codex_plugin_spec: ${{ steps.inputs.outputs.codex_plugin_spec }}
steps:
- name: Require trusted workflow ref for release checks
- name: Require main or release workflow ref for release checks
env:
RELEASE_REF: ${{ inputs.ref }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
tideclaw_alpha_check=false
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
workflow_branch="${WORKFLOW_REF#refs/heads/}"
if [[ "${RELEASE_REF}" == *"-alpha."* || "${RELEASE_REF}" =~ ^[0-9a-fA-F]{40}$ || "${RELEASE_REF}" == "${workflow_branch}" || "${RELEASE_REF}" == "refs/heads/${workflow_branch}" ]]; then
tideclaw_alpha_check=true
fi
fi
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]] && [[ "${tideclaw_alpha_check}" != "true" ]]; then
echo "Release checks must be dispatched from main, release/YYYY.M.D, a Full Release Validation release-ci/<sha>-<timestamp> ref, or a Tideclaw alpha branch for alpha prereleases." >&2
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]] && [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/release-ci/[0-9a-f]{12}-[0-9]+$ ]]; then
echo "Release checks must be dispatched from main, release/YYYY.M.D, or a Full Release Validation release-ci/<sha>-<timestamp> ref so workflow logic and secrets stay controlled." >&2
exit 1
fi
@@ -233,25 +219,6 @@ jobs:
fi
echo "sha=${selected_sha,,}" >> "$GITHUB_OUTPUT"
- name: Validate Tideclaw alpha target matches workflow branch
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
working-directory: workflow
env:
SELECTED_SHA: ${{ steps.ref.outputs.sha }}
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
if [[ ! "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Tideclaw alpha release checks must run from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
exit 1
fi
alpha_branch="${WORKFLOW_REF#refs/heads/}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if ! git merge-base --is-ancestor "${SELECTED_SHA}" "refs/remotes/origin/${alpha_branch}"; then
echo "Alpha release target ${SELECTED_SHA} must be reachable from ${alpha_branch}." >&2
exit 1
fi
- name: Capture selected inputs
id: inputs
env:
@@ -268,7 +235,6 @@ jobs:
RELEASE_QA_SLACK_LIVE_CI_ENABLED: ${{ vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED || 'false' }}
RELEASE_PACKAGE_SPEC_INPUT: ${{ inputs.release_package_spec }}
RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT: ${{ inputs.package_acceptance_package_spec }}
RELEASE_CODEX_PLUGIN_SPEC_INPUT: ${{ inputs.codex_plugin_spec }}
run: |
set -euo pipefail
qa_live_matrix_enabled=true
@@ -314,10 +280,6 @@ jobs:
if [[ "$release_profile" == "full" ]]; then
run_release_soak=true
fi
codex_plugin_spec="$RELEASE_CODEX_PLUGIN_SPEC_INPUT"
if [[ -z "${codex_plugin_spec// }" && "$RELEASE_PACKAGE_SPEC_INPUT" =~ ^openclaw@(.+)$ ]]; then
codex_plugin_spec="npm:@openclaw/codex@${BASH_REMATCH[1]}"
fi
filter="$(printf '%s' "$RELEASE_LIVE_SUITE_FILTER_INPUT" | tr '[:upper:]' '[:lower:]')"
if [[ -n "${filter// }" ]]; then
@@ -398,7 +360,6 @@ jobs:
printf 'qa_live_slack_enabled=%s\n' "$qa_live_slack_enabled"
printf 'release_package_spec=%s\n' "$RELEASE_PACKAGE_SPEC_INPUT"
printf 'package_acceptance_package_spec=%s\n' "$RELEASE_PACKAGE_ACCEPTANCE_PACKAGE_SPEC_INPUT"
printf 'codex_plugin_spec=%s\n' "$codex_plugin_spec"
} >> "$GITHUB_OUTPUT"
- name: Summarize validated ref
@@ -415,7 +376,6 @@ jobs:
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
RELEASE_PACKAGE_SPEC: ${{ inputs.release_package_spec }}
PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }}
CODEX_PLUGIN_SPEC: ${{ steps.inputs.outputs.codex_plugin_spec }}
run: |
{
echo "## Release checks"
@@ -445,11 +405,6 @@ jobs:
else
echo "- Package Acceptance package spec: prepared release artifact"
fi
if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then
echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`"
else
echo "- Codex plugin spec: packed from selected ref"
fi
if [[ "$RUN_RELEASE_SOAK" == "true" ]]; then
echo "- This run will execute blocking release validation plus exhaustive live/Docker soak coverage."
else
@@ -552,7 +507,6 @@ jobs:
permissions: read-all
uses: ./.github/workflows/openclaw-cross-os-release-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
provider: ${{ needs.resolve_target.outputs.provider }}
mode: ${{ needs.resolve_target.outputs.mode }}
@@ -561,10 +515,7 @@ jobs:
candidate_file_name: openclaw-current.tgz
candidate_version: ${{ needs.prepare_release_package.outputs.package_version }}
candidate_source_sha: ${{ needs.prepare_release_package.outputs.source_sha }}
openai_model: openai/gpt-5.5
ubuntu_runner: ubuntu-24.04
windows_runner: windows-2025
macos_runner: macos-26
openai_model: openai/gpt-5.4
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
@@ -584,7 +535,6 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: true
include_release_path_suites: false
@@ -650,7 +600,6 @@ jobs:
pull-requests: read
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
ref: ${{ needs.resolve_target.outputs.revision }}
include_repo_e2e: false
include_release_path_suites: true
@@ -658,7 +607,6 @@ jobs:
include_live_suites: false
release_test_profile: ${{ needs.resolve_target.outputs.release_profile }}
package_artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
codex_plugin_spec: ${{ needs.resolve_target.outputs.codex_plugin_spec }}
secrets: *live_e2e_release_secrets
package_acceptance_release_checks:
@@ -672,18 +620,17 @@ jobs:
pull-requests: read
uses: ./.github/workflows/package-acceptance.yml
with:
advisory: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
workflow_ref: ${{ github.ref_name }}
source: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec != '' || needs.resolve_target.outputs.release_package_spec != '') && 'npm' || 'artifact' }}
package_spec: ${{ needs.resolve_target.outputs.package_acceptance_package_spec || needs.resolve_target.outputs.release_package_spec || 'openclaw@beta' }}
artifact_name: ${{ needs.prepare_release_package.outputs.artifact_name }}
package_sha256: ${{ (needs.resolve_target.outputs.package_acceptance_package_spec == '' && needs.resolve_target.outputs.release_package_spec == '') && needs.prepare_release_package.outputs.package_sha256 || '' }}
suite_profile: custom
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update
docker_lanes: doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update
published_upgrade_survivor_baselines: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'last-stable-4 2026.4.23 2026.5.2 2026.4.15' || '' }}
published_upgrade_survivor_scenarios: ${{ needs.resolve_target.outputs.run_release_soak == 'true' && 'reported-issues' || '' }}
telegram_mode: mock-openai
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-long-final-reuses-preview,telegram-mention-gating
telegram_scenarios: telegram-help-command,telegram-commands-command,telegram-tools-compact-command,telegram-whoami-command,telegram-status-command,telegram-other-bot-command-gating,telegram-context-command,telegram-mentioned-message-reply,telegram-reply-chain-exact-marker,telegram-stream-final-single-message,telegram-long-final-reuses-preview,telegram-mention-gating
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
@@ -738,7 +685,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: read
@@ -747,9 +694,9 @@ jobs:
matrix:
include:
- lane: candidate
output_dir: openai-candidate
output_dir: gpt54
- lane: baseline
output_dir: anthropic-baseline
output_dir: opus46
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
@@ -795,7 +742,7 @@ jobs:
;;
baseline)
model="anthropic/claude-opus-4-7"
alt_model="anthropic/claude-sonnet-4-6"
alt_model="anthropic/claude-sonnet-4-7"
;;
*)
echo "Unknown QA parity lane: ${QA_PARITY_LANE}" >&2
@@ -813,7 +760,7 @@ jobs:
- name: Upload parity lane artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -825,7 +772,7 @@ jobs:
needs: [resolve_target, qa_lab_parity_lane_release_checks]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 20
permissions:
contents: read
@@ -849,7 +796,7 @@ jobs:
install-bun: "true"
- name: Download parity lane artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
pattern: release-qa-parity-*-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -864,202 +811,27 @@ jobs:
run: |
pnpm openclaw qa parity-report \
--repo-root . \
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
--baseline-label anthropic/claude-opus-4-7 \
--output-dir .artifacts/qa-e2e/parity
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
qa_lab_runtime_parity_release_checks:
name: Run QA Lab runtime parity lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
continue-on-error: true
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 30
permissions:
contents: read
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENAI_API_KEY: ""
ANTHROPIC_API_KEY: ""
OPENCLAW_LIVE_OPENAI_KEY: ""
OPENCLAW_LIVE_ANTHROPIC_KEY: ""
OPENCLAW_LIVE_GEMINI_KEY: ""
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ""
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run runtime parity lane
id: runtime_parity_lane
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity"
- name: Run standard runtime parity tier
if: ${{ always() && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--runtime-parity-tier standard \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-standard"
- name: Run soak runtime parity tier
id: runtime_parity_soak_lane
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_lane.outcome != 'skipped' && steps.runtime_parity_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
pnpm openclaw qa suite \
--provider-mode mock-openai \
--runtime-parity-tier soak \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "openai/gpt-5.5-alt" \
--runtime-pair pi,codex \
--output-dir ".artifacts/qa-e2e/runtime-parity-soak"
- name: Generate runtime parity report
if: always()
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary .artifacts/qa-e2e/runtime-parity/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-report
- name: Generate standard runtime parity report
if: always()
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
--output-dir .artifacts/qa-e2e/runtime-parity-standard-report
- name: Generate soak runtime parity report
if: ${{ always() && needs.resolve_target.outputs.run_release_soak == 'true' && steps.runtime_parity_soak_lane.outcome != 'skipped' && steps.runtime_parity_soak_lane.outcome != 'cancelled' }}
run: |
set -euo pipefail
summary=".artifacts/qa-e2e/runtime-parity-soak/qa-suite-summary.json"
if [[ ! -f "$summary" ]]; then
echo "No soak runtime parity summary was produced."
exit 0
fi
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--summary "$summary" \
--output-dir .artifacts/qa-e2e/runtime-parity-soak-report
- name: Upload runtime parity artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
runtime_tool_coverage_release_checks:
name: Enforce QA Lab runtime tool coverage
needs: [resolve_target, qa_lab_runtime_parity_release_checks]
if: always() && contains(fromJSON('["all","qa","qa-parity"]'), needs.resolve_target.outputs.rerun_group)
runs-on: ubuntu-24.04
timeout-minutes: 15
permissions:
contents: read
actions: read
env:
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.resolve_target.outputs.revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Download runtime parity artifacts
uses: actions/download-artifact@v8
with:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
- name: Enforce standard runtime tool coverage
run: |
set -euo pipefail
pnpm openclaw qa coverage \
--repo-root . \
--tools \
--summary .artifacts/qa-e2e/runtime-parity-standard/qa-suite-summary.json \
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
- name: Upload runtime tool coverage artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
retention-days: 14
if-no-files-found: warn
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_matrix_enabled == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1127,7 +899,7 @@ jobs:
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1139,7 +911,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_telegram_enabled == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1223,7 +995,7 @@ jobs:
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1235,7 +1007,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_discord_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_DISCORD_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1319,7 +1091,7 @@ jobs:
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1331,11 +1103,8 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_whatsapp_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_WHATSAPP_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
concurrency:
group: qa-live-whatsapp-shared
cancel-in-progress: false
permissions:
contents: read
pull-requests: read
@@ -1418,7 +1187,7 @@ jobs:
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1430,7 +1199,7 @@ jobs:
needs: [resolve_target]
if: contains(fromJSON('["all","qa","qa-live"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.qa_live_slack_enabled == 'true' && vars.OPENCLAW_RELEASE_QA_SLACK_LIVE_CI_ENABLED == 'true'
continue-on-error: true
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
@@ -1514,7 +1283,7 @@ jobs:
- name: Upload Slack QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
@@ -1532,8 +1301,6 @@ jobs:
- package_acceptance_release_checks
- qa_lab_parity_lane_release_checks
- qa_lab_parity_report_release_checks
- qa_lab_runtime_parity_release_checks
- runtime_tool_coverage_release_checks
- qa_live_matrix_release_checks
- qa_live_telegram_release_checks
- qa_live_discord_release_checks
@@ -1546,15 +1313,9 @@ jobs:
steps:
- name: Verify release check results
shell: bash
env:
WORKFLOW_REF: ${{ github.ref }}
run: |
set -euo pipefail
failed=0
tideclaw_alpha=false
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha=true
fi
for item in \
"prepare_release_package=${{ needs.prepare_release_package.result }}" \
"install_smoke_release_checks=${{ needs.install_smoke_release_checks.result }}" \
@@ -1564,8 +1325,6 @@ jobs:
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \
"qa_live_matrix_release_checks=${{ needs.qa_live_matrix_release_checks.result }}" \
"qa_live_telegram_release_checks=${{ needs.qa_live_telegram_release_checks.result }}" \
"qa_live_discord_release_checks=${{ needs.qa_live_discord_release_checks.result }}" \
@@ -1575,15 +1334,6 @@ jobs:
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
if [[ "$tideclaw_alpha" == "true" ]]; then
case "$name" in
prepare_release_package|install_smoke_release_checks) ;;
*)
echo "::warning::${name} ended with ${result}; Tideclaw alpha treats non-package-safety release-check lanes as advisory."
continue
;;
esac
fi
if [[ "$name" == qa_* ]]; then
echo "::warning::${name} ended with ${result}; QA release-check lanes are advisory and do not block release validation."
continue

View File

@@ -11,14 +11,6 @@ on:
description: Successful OpenClaw NPM Release preflight run id, required when publish_openclaw_npm=true
required: false
type: string
full_release_validation_run_id:
description: Successful Full Release Validation run id for this tag/SHA, required when publish_openclaw_npm=true
required: false
type: string
npm_telegram_run_id:
description: Optional successful NPM Telegram Beta E2E run id to include in final release evidence
required: false
type: string
npm_dist_tag:
description: npm dist-tag for the OpenClaw package
required: true
@@ -80,13 +72,11 @@ jobs:
timeout-minutes: 20
outputs:
sha: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
preflight_artifact_name: ${{ steps.preflight_artifact.outputs.name }}
steps:
- name: Validate inputs
env:
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
@@ -111,16 +101,8 @@ jobs:
echo "publish_openclaw_npm=true requires preflight_run_id." >&2
exit 1
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && -z "${FULL_RELEASE_VALIDATION_RUN_ID}" ]]; then
echo "publish_openclaw_npm=true requires full_release_validation_run_id." >&2
exit 1
fi
tideclaw_alpha_publish=false
if [[ "${RELEASE_TAG}" == *"-alpha."* && "${RELEASE_NPM_DIST_TAG}" == "alpha" && "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
tideclaw_alpha_publish=true
fi
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ && "${tideclaw_alpha_publish}" != "true" ]]; then
echo "publish_openclaw_npm=true requires dispatching this workflow from main, release/YYYY.M.D, or a Tideclaw alpha branch for alpha prereleases." >&2
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" && "${WORKFLOW_REF}" != "refs/heads/main" && ! "${WORKFLOW_REF}" =~ ^refs/heads/release/[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*$ ]]; then
echo "publish_openclaw_npm=true requires dispatching this workflow from main or release/YYYY.M.D." >&2
exit 1
fi
if [[ "${PLUGIN_PUBLISH_SCOPE}" == "selected" && -z "${PLUGINS}" ]]; then
@@ -140,64 +122,13 @@ jobs:
esac
- name: Download OpenClaw npm preflight manifest
id: preflight_artifact
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
RELEASE_TAG: ${{ inputs.tag }}
run: |
set -euo pipefail
preferred_name="openclaw-npm-preflight-${RELEASE_TAG}"
preflight_dir="${RUNNER_TEMP}/openclaw-npm-preflight-manifest"
rm -rf "${preflight_dir}"
mkdir -p "${preflight_dir}"
download_named_artifact() {
local artifact_name="$1"
for attempt in 1 2 3; do
if gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${artifact_name}" \
--dir "${preflight_dir}"; then
return 0
fi
if [[ "$attempt" != "3" ]]; then
echo "::warning::Artifact download for ${artifact_name} failed on attempt ${attempt}; retrying."
sleep $((attempt * 10))
fi
done
return 1
}
if download_named_artifact "${preferred_name}"; then
echo "name=${preferred_name}" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::warning::${preferred_name} not found; checking run artifacts for a single compatible preflight artifact."
mapfile -t matches < <(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${PREFLIGHT_RUN_ID}/artifacts" \
--jq '.artifacts[] | select(.expired != true) | .name' |
grep '^openclaw-npm-preflight-' || true)
if [[ "${#matches[@]}" != "1" ]]; then
echo "Expected ${preferred_name}, or exactly one openclaw-npm-preflight-* fallback artifact in run ${PREFLIGHT_RUN_ID}." >&2
printf 'Available preflight candidates:\n' >&2
printf -- '- %s\n' "${matches[@]:-<none>}" >&2
exit 1
fi
fallback_name="${matches[0]}"
download_named_artifact "${fallback_name}"
echo "name=${fallback_name}" >> "$GITHUB_OUTPUT"
- name: Download full release validation manifest
if: ${{ inputs.publish_openclaw_npm }}
uses: actions/download-artifact@v8
with:
name: full-release-validation-${{ inputs.full_release_validation_run_id }}
path: ${{ runner.temp }}/full-release-validation-manifest
name: openclaw-npm-preflight-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-npm-preflight-manifest
repository: ${{ github.repository }}
run-id: ${{ inputs.full_release_validation_run_id }}
run-id: ${{ inputs.preflight_run_id }}
github-token: ${{ github.token }}
- name: Checkout release tag
@@ -255,50 +186,7 @@ jobs:
fi
echo "sha=$release_sha" >> "$GITHUB_OUTPUT"
- name: Validate full release validation manifest
if: ${{ inputs.publish_openclaw_npm }}
env:
GH_TOKEN: ${{ github.token }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
EXPECTED_SHA: ${{ steps.ref.outputs.sha }}
EXPECTED_RELEASE_PROFILE: ${{ inputs.release_profile }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
RUN_JSON="$(gh run view "$FULL_RELEASE_VALIDATION_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "Full Release Validation"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"], ["status", "completed"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using full release validation run ${process.env.FULL_RELEASE_VALIDATION_RUN_ID}: ${run.url}`);'
manifest="${RUNNER_TEMP}/full-release-validation-manifest/full-release-validation-manifest.json"
if [[ ! -f "$manifest" ]]; then
echo "Full release validation manifest is missing." >&2
ls -la "${RUNNER_TEMP}/full-release-validation-manifest" >&2 || true
exit 1
fi
workflow_name="$(jq -r '.workflowName // ""' "$manifest")"
target_sha="$(jq -r '.targetSha // ""' "$manifest")"
release_profile="$(jq -r '.releaseProfile // ""' "$manifest")"
rerun_group="$(jq -r '.rerunGroup // ""' "$manifest")"
if [[ "$workflow_name" != "Full Release Validation" ]]; then
echo "Full release validation manifest workflow mismatch: $workflow_name" >&2
exit 1
fi
if [[ "$target_sha" != "$EXPECTED_SHA" ]]; then
echo "Full release validation target SHA mismatch: expected $EXPECTED_SHA, got $target_sha" >&2
exit 1
fi
if [[ "$release_profile" != "$EXPECTED_RELEASE_PROFILE" ]]; then
echo "Full release validation profile mismatch: expected $EXPECTED_RELEASE_PROFILE, got $release_profile" >&2
exit 1
fi
if [[ "$rerun_group" != "all" ]]; then
echo "Full release validation must run rerun_group=all before npm publish; got $rerun_group" >&2
exit 1
fi
- name: Validate release tag is reachable from a trusted release branch
env:
RELEASE_TAG: ${{ inputs.tag }}
WORKFLOW_REF_NAME: ${{ github.ref_name }}
- name: Validate release tag is reachable from main or release branch
run: |
set -euo pipefail
git fetch --no-tags origin \
@@ -312,17 +200,7 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
if [[ ! "${WORKFLOW_REF_NAME}" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
echo "Alpha publish tags must be dispatched from tideclaw/alpha/YYYY-MM-DD-HHMMZ." >&2
exit 1
fi
git fetch --no-tags origin "+refs/heads/${WORKFLOW_REF_NAME}:refs/remotes/origin/${WORKFLOW_REF_NAME}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${WORKFLOW_REF_NAME}"; then
exit 0
fi
fi
echo "Release tag must point to a commit reachable from main, release/*, or the matching Tideclaw alpha branch for alpha prereleases." >&2
echo "Release tag must point to a commit reachable from main or release/*." >&2
exit 1
- name: Summarize release target
@@ -330,7 +208,6 @@ jobs:
RELEASE_TAG: ${{ inputs.tag }}
TARGET_SHA: ${{ steps.manifest.outputs.sha || steps.ref.outputs.sha }}
RELEASE_PROFILE: ${{ inputs.release_profile }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
run: |
{
echo "### Release target"
@@ -338,9 +215,6 @@ jobs:
echo "- Tag: \`${RELEASE_TAG}\`"
echo "- SHA: \`${TARGET_SHA}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
if [[ -n "${FULL_RELEASE_VALIDATION_RUN_ID// }" ]]; then
echo "- Full release validation: \`${FULL_RELEASE_VALIDATION_RUN_ID}\`"
fi
} >> "$GITHUB_STEP_SUMMARY"
publish:
@@ -348,7 +222,6 @@ jobs:
needs: [resolve_release_target]
runs-on: ubuntu-latest
timeout-minutes: 60
environment: npm-release
steps:
- name: Checkout release SHA
uses: actions/checkout@v6
@@ -357,12 +230,6 @@ jobs:
fetch-depth: 1
persist-credentials: false
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
cache-key-suffix: release-publish
- name: Dispatch publish workflows
env:
GH_TOKEN: ${{ github.token }}
@@ -370,15 +237,11 @@ jobs:
CHILD_WORKFLOW_REF: ${{ github.ref_name }}
RELEASE_TAG: ${{ inputs.tag }}
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
FULL_RELEASE_VALIDATION_RUN_ID: ${{ inputs.full_release_validation_run_id }}
RELEASE_NPM_DIST_TAG: ${{ inputs.npm_dist_tag }}
PLUGIN_PUBLISH_SCOPE: ${{ inputs.plugin_publish_scope }}
PLUGINS: ${{ inputs.plugins }}
PUBLISH_OPENCLAW_NPM: ${{ inputs.publish_openclaw_npm && 'true' || 'false' }}
WAIT_FOR_CLAWHUB: ${{ inputs.wait_for_clawhub && 'true' || 'false' }}
PREFLIGHT_ARTIFACT_NAME: ${{ needs.resolve_release_target.outputs.preflight_artifact_name }}
NPM_TELEGRAM_RUN_ID: ${{ inputs.npm_telegram_run_id }}
POSTPUBLISH_EVIDENCE_DIR: ${{ runner.temp }}/openclaw-release-postpublish-evidence
run: |
set -euo pipefail
@@ -387,10 +250,7 @@ jobs:
shift
local before_json dispatch_output run_id
before_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
-F event=workflow_dispatch \
-F per_page=100 \
--jq '[.workflow_runs[].id]')"
before_json="$(gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 100 --json databaseId --jq '[.[].databaseId]')"
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"
printf '%s\n' "$dispatch_output" >&2
@@ -403,10 +263,8 @@ jobs:
if [[ -z "$run_id" ]]; then
for _ in $(seq 1 60); do
run_id="$(
BEFORE_IDS="$before_json" gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
-F event=workflow_dispatch \
-F per_page=50 \
--jq '.workflow_runs | map({databaseId:.id, createdAt:.created_at}) | map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
BEFORE_IDS="$before_json" gh run list --repo "$GITHUB_REPOSITORY" --workflow "$workflow" --event workflow_dispatch --limit 50 --json databaseId,createdAt \
--jq 'map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
)"
if [[ -n "$run_id" ]]; then
break
@@ -427,75 +285,6 @@ jobs:
printf '%s\n' "${run_id}"
}
print_pending_deployments() {
local workflow="$1"
local run_id="$2"
local pending_json
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
return 0
fi
echo "${workflow} pending environment approval:"
while IFS=$'\t' read -r env_id env_name can_approve; do
echo "- env=${env_name} canApprove=${can_approve}"
echo " approve: gh api -X POST repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments -F 'environment_ids[]=${env_id}' -f state=approved -f comment='Approve release gate'"
done < <(printf '%s' "${pending_json}" | jq -r '.[] | [.environment.id, .environment.name, .current_user_can_approve] | @tsv')
}
approve_pending_deployments() {
local workflow="$1"
local run_id="$2"
local pending_json approved
pending_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" 2>/dev/null || true)"
if [[ -z "${pending_json}" ]] || ! printf '%s' "${pending_json}" | jq -e 'length > 0' >/dev/null 2>&1; then
return 1
fi
approved=0
while IFS=$'\t' read -r env_id env_name; do
if [[ -z "${env_id}" ]]; then
continue
fi
echo "${workflow}: approving pending environment ${env_name} (${env_id})"
gh api -X POST "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/pending_deployments" \
-F "environment_ids[]=${env_id}" \
-f state=approved \
-f comment="Approve child release gate after parent release approval" >/dev/null
approved=1
done < <(printf '%s' "${pending_json}" | jq -r '.[] | select(.current_user_can_approve == true) | [.environment.id, .environment.name] | @tsv')
if [[ "${approved}" == "1" ]]; then
echo "${workflow}: approved available pending environment gates"
return 0
fi
return 1
}
print_failed_run_summary() {
local run_id="$1"
local failed_json
failed_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs \
--jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {databaseId, name, conclusion, url}' || true)"
if [[ -z "${failed_json}" ]]; then
return 0
fi
echo "Failed child job summary:"
printf '%s\n' "${failed_json}"
while IFS=$'\t' read -r job_id job_name; do
if [[ -z "${job_id}" ]]; then
continue
fi
echo "--- ${job_name} (${job_id}) log tail ---"
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --job "${job_id}" --log 2>/dev/null |
tail -200 || true
done < <(printf '%s\n' "${failed_json}" | jq -r '[.databaseId, .name] | @tsv' 2>/dev/null || true)
}
wait_for_run() {
local workflow="$1"
local run_id="$2"
@@ -513,8 +302,6 @@ jobs:
state="${status}:${updated_at}"
if [[ "$state" != "$last_state" ]]; then
echo "${workflow} still ${status} (updated ${updated_at}): ${url}"
print_pending_deployments "${workflow}" "${run_id}"
approve_pending_deployments "${workflow}" "${run_id}" || true
last_state="$state"
fi
sleep 30
@@ -542,7 +329,7 @@ jobs:
echo "- ${workflow}: ${conclusion} in ${duration_label} (${url})"
} >> "$GITHUB_STEP_SUMMARY"
if [[ "$conclusion" != "success" ]]; then
print_failed_run_summary "${run_id}"
gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true
return 1
fi
}
@@ -561,85 +348,6 @@ jobs:
wait_run_pid="$!"
}
wait_for_job_success() {
local workflow="$1"
local run_id="$2"
local job_name="$3"
local jobs_json job_json run_status run_conclusion status conclusion url deadline
deadline=$((SECONDS + 900))
while true; do
jobs_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,jobs)"
run_status="$(printf '%s' "$jobs_json" | jq -r '.status')"
run_conclusion="$(printf '%s' "$jobs_json" | jq -r '.conclusion // ""')"
job_json="$(printf '%s' "$jobs_json" | jq -c --arg name "$job_name" '.jobs[]? | select(.name == $name) | {status, conclusion, url}' | head -n 1)"
if [[ -n "$job_json" ]]; then
status="$(printf '%s' "$job_json" | jq -r '.status')"
conclusion="$(printf '%s' "$job_json" | jq -r '.conclusion // ""')"
url="$(printf '%s' "$job_json" | jq -r '.url // ""')"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" || "$conclusion" == "skipped" ]]; then
echo "${workflow} ${job_name} ${conclusion}: ${url}"
echo "- ${workflow} ${job_name}: ${conclusion} (${url})" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
echo "${workflow} ${job_name} failed: ${conclusion} ${url}" >&2
print_failed_run_summary "${run_id}"
return 1
fi
echo "${workflow} ${job_name} still ${status}: ${url}"
elif [[ "$run_status" == "completed" ]]; then
if [[ "$run_conclusion" == "success" ]]; then
echo "${workflow} completed before ${job_name} was needed."
echo "- ${workflow} ${job_name}: not needed" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
echo "${workflow} completed before ${job_name} with ${run_conclusion}." >&2
print_failed_run_summary "${run_id}"
return 1
else
echo "${workflow} waiting for ${job_name} to start: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}"
fi
if (( SECONDS >= deadline )); then
echo "${workflow} ${job_name} did not complete within 15 minutes." >&2
return 1
fi
sleep 10
done
}
approve_child_publish_environment() {
local workflow="$1"
local run_id="$2"
local run_json status conclusion deadline
deadline=$((SECONDS + 900))
while true; do
if approve_pending_deployments "${workflow}" "${run_id}"; then
echo "- ${workflow}: child environment gate approved" >> "$GITHUB_STEP_SUMMARY"
return 0
fi
run_json="$(gh run view --repo "$GITHUB_REPOSITORY" "$run_id" --json status,conclusion,url)"
status="$(printf '%s' "$run_json" | jq -r '.status')"
conclusion="$(printf '%s' "$run_json" | jq -r '.conclusion // ""')"
if [[ "$status" == "completed" ]]; then
if [[ "$conclusion" == "success" ]]; then
echo "${workflow}: completed before child environment approval was needed"
return 0
fi
echo "${workflow}: completed before child environment approval with ${conclusion}" >&2
print_failed_run_summary "${run_id}"
return 1
fi
if (( SECONDS >= deadline )); then
echo "${workflow}: child environment approval was not available within 15 minutes." >&2
print_pending_deployments "${workflow}" "${run_id}"
return 1
fi
sleep 10
done
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -693,153 +401,15 @@ jobs:
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
upload_dependency_evidence_release_asset() {
local release_version download_dir asset_path asset_name artifact_name
release_version="${RELEASE_TAG#v}"
download_dir="${RUNNER_TEMP}/openclaw-release-dependency-evidence-asset"
asset_name="openclaw-${release_version}-dependency-evidence.zip"
asset_path="${RUNNER_TEMP}/${asset_name}"
artifact_name="${PREFLIGHT_ARTIFACT_NAME:-openclaw-npm-preflight-${RELEASE_TAG}}"
rm -rf "${download_dir}" "${asset_path}"
mkdir -p "${download_dir}"
gh run download "${PREFLIGHT_RUN_ID}" \
--repo "${GITHUB_REPOSITORY}" \
--name "${artifact_name}" \
--dir "${download_dir}"
if [[ ! -d "${download_dir}/dependency-evidence" ]]; then
echo "Dependency evidence is missing from OpenClaw npm preflight artifact." >&2
find "${download_dir}" -maxdepth 2 -type f -print >&2 || true
exit 1
fi
(cd "${download_dir}" && zip -qr "${asset_path}" dependency-evidence)
gh release upload "${RELEASE_TAG}" "${asset_path}#${asset_name}" \
--repo "${GITHUB_REPOSITORY}" \
--clobber
echo "- Dependency evidence asset: \`${asset_name}\`" >> "$GITHUB_STEP_SUMMARY"
}
verify_published_release() {
local release_version evidence_path
local -a verify_args
release_version="${RELEASE_TAG#v}"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
verify_args=(
release:verify-beta
--
"${release_version}"
--tag "${RELEASE_TAG}"
--dist-tag "${RELEASE_NPM_DIST_TAG}"
--repo "${GITHUB_REPOSITORY}"
--workflow-ref "${CHILD_WORKFLOW_REF}"
--full-release-validation-run "${FULL_RELEASE_VALIDATION_RUN_ID}"
--plugin-npm-run "${plugin_npm_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
)
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
else
verify_args+=(--skip-clawhub)
fi
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
fi
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
verify_args+=(--npm-telegram-run "${NPM_TELEGRAM_RUN_ID}")
fi
pnpm "${verify_args[@]}"
{
echo "- Postpublish verification: passed"
echo "- Postpublish evidence: \`${evidence_path}\`"
} >> "$GITHUB_STEP_SUMMARY"
}
append_release_proof_to_github_release() {
local release_version body_file notes_file tarball integrity telegram_line clawhub_line
release_version="${RELEASE_TAG#v}"
body_file="${RUNNER_TEMP}/release-body.md"
notes_file="${RUNNER_TEMP}/release-notes-with-proof.md"
tarball="$(npm view "openclaw@${release_version}" dist.tarball --json | jq -r '.')"
integrity="$(npm view "openclaw@${release_version}" dist.integrity --json | jq -r '.')"
gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json body --jq .body > "${body_file}"
if [[ -n "${NPM_TELEGRAM_RUN_ID// }" ]]; then
telegram_line="- npm Telegram beta E2E: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${NPM_TELEGRAM_RUN_ID}"
else
telegram_line="- npm Telegram beta E2E: not supplied"
fi
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
clawhub_line="- plugin ClawHub publish: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
else
clawhub_line="- plugin ClawHub publish: dispatched separately, not awaited by this proof: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${plugin_clawhub_run_id}"
fi
RELEASE_BODY_FILE="${body_file}" \
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_REPO="${GITHUB_REPOSITORY}" \
RELEASE_TARBALL="${tarball}" \
RELEASE_INTEGRITY="${integrity}" \
RELEASE_PUBLISH_RUN_ID="${GITHUB_RUN_ID}" \
PREFLIGHT_RUN_ID="${PREFLIGHT_RUN_ID}" \
FULL_RELEASE_VALIDATION_RUN_ID="${FULL_RELEASE_VALIDATION_RUN_ID}" \
PLUGIN_NPM_RUN_ID="${plugin_npm_run_id}" \
OPENCLAW_NPM_RUN_ID="${openclaw_npm_run_id}" \
CLAWHUB_LINE="${clawhub_line}" \
TELEGRAM_LINE="${telegram_line}" \
node --input-type=module <<'NODE'
import { readFileSync, writeFileSync } from "node:fs";
const bodyFile = process.env.RELEASE_BODY_FILE;
const notesFile = process.env.RELEASE_NOTES_FILE;
if (!bodyFile || !notesFile) {
throw new Error("Missing release notes file paths.");
}
const body = readFileSync(bodyFile, "utf8").trimEnd();
const section = [
"### Release verification",
"",
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- full release CI report: https://github.com/openclaw/releases-private/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
`- full release validation: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.FULL_RELEASE_VALIDATION_RUN_ID}`,
`- plugin npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PLUGIN_NPM_RUN_ID}`,
process.env.CLAWHUB_LINE,
`- OpenClaw npm publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.OPENCLAW_NPM_RUN_ID}`,
process.env.TELEGRAM_LINE,
].join("\n");
const withoutOldProof = body.replace(/\n?### Release verification\n[\s\S]*?(?=\n### |\n## |$)/, "");
writeFileSync(notesFile, `${withoutOldProof.trimEnd()}\n\n${section}\n`);
NODE
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --notes-file "${notes_file}"
echo "- Release proof: appended to GitHub release" >> "$GITHUB_STEP_SUMMARY"
}
{
echo "### Publish sequence"
echo
echo "- Workflow ref: \`${CHILD_WORKFLOW_REF}\`"
echo "- Release tag: \`${RELEASE_TAG}\`"
echo "- Release SHA: \`${TARGET_SHA}\`"
echo "- Release approval: this workflow job"
echo "- Plugin npm and ClawHub publish: dispatched in parallel"
if [[ "${PUBLISH_OPENCLAW_NPM}" == "true" ]]; then
echo "- OpenClaw npm publish: starts after plugin npm succeeds"
echo "- OpenClaw npm publish: starts after plugin npm succeeds; ClawHub may still be running"
else
echo "- OpenClaw npm publish: skipped by input"
fi
@@ -850,8 +420,8 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}")
if [[ -n "${PLUGINS}" ]]; then
npm_args+=(-f plugins="${PLUGINS}")
clawhub_args+=(-f plugins="${PLUGINS}")
@@ -876,8 +446,6 @@ jobs:
-f tag="${RELEASE_TAG}" \
-f preflight_only=false \
-f preflight_run_id="${PREFLIGHT_RUN_ID}" \
-f full_release_validation_run_id="${FULL_RELEASE_VALIDATION_RUN_ID}" \
-f release_publish_run_id="${GITHUB_RUN_ID}" \
-f npm_dist_tag="${RELEASE_NPM_DIST_TAG}")"
echo "- OpenClaw npm run ID: \`${openclaw_npm_run_id}\`" >> "$GITHUB_STEP_SUMMARY"
else
@@ -892,13 +460,7 @@ jobs:
wait_for_run_background plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "${clawhub_result}"
clawhub_pid="${wait_run_pid}"
else
wait_for_job_success plugin-clawhub-release.yml "${plugin_clawhub_run_id}" "Validate release publish approval"
if approve_child_publish_environment plugin-clawhub-release.yml "${plugin_clawhub_run_id}"; then
:
else
echo "- plugin-clawhub-release.yml: child environment gate not ready; publish was left dispatched (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
echo "- plugin-clawhub-release.yml: publish not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
echo "- plugin-clawhub-release.yml: not awaited (${plugin_clawhub_run_id})" >> "$GITHUB_STEP_SUMMARY"
fi
openclaw_result=""
@@ -911,40 +473,22 @@ jobs:
fi
failed=0
openclaw_failed=0
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
openclaw_failed=1
fi
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
failed=1
openclaw_failed=1
fi
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
create_or_update_github_release
upload_dependency_evidence_release_asset
fi
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
failed=1
fi
if [[ -n "${openclaw_pid}" ]] && ! wait "${openclaw_pid}"; then
failed=1
fi
if [[ -f "${clawhub_result}" && "$(cat "${clawhub_result}")" != "success" ]]; then
failed=1
fi
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
append_release_proof_to_github_release
if [[ -n "${openclaw_result}" && -f "${openclaw_result}" && "$(cat "${openclaw_result}")" != "success" ]]; then
failed=1
fi
if [[ "${failed}" != "0" ]]; then
exit 1
fi
- name: Upload postpublish evidence
if: ${{ always() }}
uses: actions/upload-artifact@v7
with:
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: ignore
if [[ -n "${openclaw_npm_run_id}" ]]; then
create_or_update_github_release
fi

View File

@@ -6,7 +6,6 @@ on:
workflow_dispatch:
permissions:
actions: read
contents: read
packages: write
pull-requests: read
@@ -21,7 +20,6 @@ env:
jobs:
live_and_openwebui_checks:
permissions:
actions: read
contents: read
packages: write
pull-requests: read

View File

@@ -62,7 +62,7 @@ jobs:
- name: Upload SARIF as workflow artifact
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: opengrep-full-sarif
path: .opengrep-out/precise.sarif

View File

@@ -92,7 +92,7 @@ jobs:
- name: Upload SARIF as workflow artifact
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: opengrep-pr-diff-sarif
path: .opengrep-out/precise.sarif

View File

@@ -93,18 +93,8 @@ on:
required: false
default: ""
type: string
advisory:
description: Treat acceptance failures as advisory for the caller
required: false
default: false
type: boolean
workflow_call:
inputs:
advisory:
description: Treat acceptance failures as advisory for the caller
required: false
default: false
type: boolean
workflow_ref:
description: Trusted repo ref for workflow scripts and Docker E2E harness
required: false
@@ -294,7 +284,7 @@ env:
jobs:
resolve_package:
name: Resolve package candidate
runs-on: ubuntu-24.04
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
outputs:
docker_lanes: ${{ steps.profile.outputs.docker_lanes }}
@@ -396,10 +386,10 @@ jobs:
docker_lanes="npm-onboard-channel-agent gateway-network config-reload"
;;
package)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins-offline plugin-update"
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins-offline plugin-update"
;;
product)
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor root-managed-vps-upgrade update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
docker_lanes="npm-onboard-channel-agent doctor-switch update-channel-switch skill-install update-corrupt-plugin upgrade-survivor published-upgrade-survivor update-restart-auth plugins plugin-update mcp-channels cron-mcp-cleanup openai-web-search-minimal openwebui"
include_openwebui=true
;;
full)
@@ -519,7 +509,6 @@ jobs:
needs: resolve_package
uses: ./.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
with:
advisory: ${{ inputs.advisory }}
ref: ${{ needs.resolve_package.outputs.package_source_sha || inputs.workflow_ref }}
include_repo_e2e: false
include_release_path_suites: ${{ needs.resolve_package.outputs.include_release_path_suites == 'true' }}
@@ -584,7 +573,6 @@ jobs:
if: needs.resolve_package.outputs.telegram_enabled == 'true'
uses: ./.github/workflows/npm-telegram-beta-e2e.yml
with:
advisory: ${{ inputs.advisory }}
package_spec: ${{ inputs.package_spec }}
package_artifact_name: ${{ needs.resolve_package.outputs.package_artifact_name }}
package_label: openclaw@${{ needs.resolve_package.outputs.package_version }}
@@ -600,7 +588,7 @@ jobs:
name: Verify package acceptance
needs: [resolve_package, docker_acceptance, package_telegram]
if: always()
runs-on: ubuntu-24.04
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 5
steps:
- name: Verify package acceptance results
@@ -611,7 +599,6 @@ jobs:
shell: bash
run: |
set -euo pipefail
advisory="${{ inputs.advisory }}"
failed=0
for item in \
"resolve_package=${RESOLVE_RESULT}" \
@@ -621,10 +608,6 @@ jobs:
name="${item%%=*}"
result="${item#*=}"
if [[ "$result" != "success" && "$result" != "skipped" ]]; then
if [[ "$advisory" == "true" && "$name" != "resolve_package" ]]; then
echo "::warning::${name} ended with ${result}; package acceptance is advisory for this caller."
continue
fi
echo "::error::${name} ended with ${result}"
failed=1
fi

View File

@@ -16,14 +16,10 @@ on:
required: false
type: string
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from; defaults to the workflow ref
description: Commit SHA on main or a release branch to publish from; defaults to the workflow ref
required: false
default: ""
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
concurrency:
group: plugin-clawhub-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -86,9 +82,7 @@ jobs:
fi
echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on a trusted publish branch
env:
WORKFLOW_REF: ${{ github.ref }}
- name: Validate ref is on main or a release branch
run: |
set -euo pipefail
if git merge-base --is-ancestor HEAD origin/main; then
@@ -99,14 +93,7 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${WORKFLOW_REF#refs/heads/}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
fi
fi
echo "Plugin ClawHub publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
echo "Plugin ClawHub publishes must target a commit reachable from main or release/*." >&2
exit 1
- name: Validate publishable plugin metadata
@@ -181,56 +168,12 @@ jobs:
echo "::error::One or more selected plugin versions already exist on ClawHub. Bump the version before running a real publish."
exit 1
- name: Validate Tideclaw alpha plugin channels
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
run: |
set -euo pipefail
invalid="$(
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-clawhub-release-plan.json
)"
if [[ -n "${invalid}" ]]; then
echo "Tideclaw alpha ClawHub publishes may only publish alpha plugin versions." >&2
printf '%s\n' "${invalid}" >&2
exit 1
fi
- name: Verify OpenClaw ClawHub package ownership
if: steps.plan.outputs.has_candidates == 'true'
env:
CLAWHUB_REGISTRY: ${{ env.CLAWHUB_REGISTRY }}
run: node --import tsx scripts/plugin-clawhub-owner-preflight.ts .local/plugin-clawhub-release-plan.json
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_clawhub
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Plugin ClawHub publish dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct Plugin ClawHub Release dispatch; relying on this workflow's clawhub-plugin-release environment approval."
exit 0
fi
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin ClawHub publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
preview_plugin_pack:
needs: preview_plugins_clawhub
if: needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
@@ -324,12 +267,11 @@ jobs:
run: bash scripts/plugin-clawhub-publish.sh --dry-run "${PACKAGE_DIR}"
publish_plugins_clawhub:
needs: [preview_plugins_clawhub, preview_plugin_pack, validate_release_publish_approval]
needs: [preview_plugins_clawhub, preview_plugin_pack]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_clawhub.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: clawhub-plugin-release
permissions:
actions: read
contents: read
id-token: write
strategy:
@@ -502,14 +444,10 @@ jobs:
async function fetchWithRetry(url, options = {}) {
let lastStatus = "unknown";
for (let attempt = 1; attempt <= 12; attempt += 1) {
try {
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
} catch (error) {
lastStatus = error instanceof Error ? error.message : String(error);
const response = await fetch(url, { redirect: "manual", ...options });
lastStatus = response.status;
if (response.status !== 429 && response.status < 500) {
return response;
}
await new Promise((resolve) => setTimeout(resolve, attempt * 5000));
}

View File

@@ -25,17 +25,13 @@ on:
- selected
- all-publishable
ref:
description: Commit SHA on main, a release branch, or the matching Tideclaw alpha branch to publish from
description: Commit SHA on main or a release branch to publish from (copy from the preview run)
required: true
type: string
plugins:
description: Comma-separated plugin package names to publish when publish_scope=selected
required: false
type: string
release_publish_run_id:
description: Approved OpenClaw Release Publish workflow run id
required: false
type: string
concurrency:
group: plugin-npm-release-${{ github.event_name == 'workflow_dispatch' && inputs.ref || github.sha }}
@@ -75,9 +71,7 @@ jobs:
id: ref
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
- name: Validate ref is on a trusted publish branch
env:
WORKFLOW_REF: ${{ github.ref }}
- name: Validate ref is on main or a release branch
run: |
set -euo pipefail
git fetch --no-tags origin \
@@ -91,14 +85,7 @@ jobs:
exit 0
fi
done < <(git for-each-ref --format='%(refname)' refs/remotes/origin/release)
if [[ "${WORKFLOW_REF}" =~ ^refs/heads/tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then
alpha_branch="${WORKFLOW_REF#refs/heads/}"
git fetch --no-tags origin "+refs/heads/${alpha_branch}:refs/remotes/origin/${alpha_branch}"
if git merge-base --is-ancestor HEAD "refs/remotes/origin/${alpha_branch}"; then
exit 0
fi
fi
echo "Plugin npm publishes must target a commit reachable from main, release/*, or the matching Tideclaw alpha branch." >&2
echo "Plugin npm publishes must target a commit reachable from main or release/*." >&2
exit 1
- name: Validate publishable plugin metadata
@@ -164,50 +151,6 @@ jobs:
echo "Already published / skipped:"
jq -r '.skippedPublished[]? | "- \(.packageName)@\(.version)"' .local/plugin-npm-release-plan.json
- name: Validate Tideclaw alpha plugin channels
if: startsWith(github.ref, 'refs/heads/tideclaw/alpha/')
run: |
set -euo pipefail
invalid="$(
jq -r '.candidates[]? | select(.publishTag != "alpha" or .channel != "alpha") | "- \(.packageName)@\(.version) [\(.publishTag)]"' .local/plugin-npm-release-plan.json
)"
if [[ -n "${invalid}" ]]; then
echo "Tideclaw alpha plugin npm publishes may only publish alpha plugin versions." >&2
printf '%s\n' "${invalid}" >&2
exit 1
fi
validate_release_publish_approval:
name: Validate release publish approval
needs: preview_plugins_npm
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
steps:
- name: Validate release publish approval run
env:
GH_TOKEN: ${{ github.token }}
RELEASE_PUBLISH_RUN_ID: ${{ inputs.release_publish_run_id }}
EXPECTED_WORKFLOW_BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
if [[ -z "${RELEASE_PUBLISH_RUN_ID// }" ]]; then
if [[ "${GITHUB_ACTOR}" == "github-actions[bot]" ]]; then
echo "Plugin npm publish dispatched by another workflow must include release_publish_run_id." >&2
exit 1
fi
echo "Direct Plugin NPM Release dispatch; relying on this workflow's npm-release environment approval."
exit 0
fi
if [[ "${GITHUB_ACTOR}" != "github-actions[bot]" ]]; then
echo "Plugin npm publish must be dispatched by the OpenClaw Release Publish workflow, not directly by ${GITHUB_ACTOR}." >&2
exit 1
fi
RUN_JSON="$(gh run view "$RELEASE_PUBLISH_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,status,conclusion,url)"
printf '%s' "$RUN_JSON" | node -e 'const fs = require("node:fs"); const run = JSON.parse(fs.readFileSync(0, "utf8")); const checks = [["workflowName", "OpenClaw Release Publish"], ["headBranch", process.env.EXPECTED_WORKFLOW_BRANCH], ["event", "workflow_dispatch"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } if (run.status !== "in_progress") { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} must still be in_progress, got ${run.status ?? "<missing>"}.`); process.exit(1); } if (run.conclusion) { console.error(`Referenced release publish run ${process.env.RELEASE_PUBLISH_RUN_ID} already concluded ${run.conclusion}.`); process.exit(1); } console.log(`Using release publish approval run ${process.env.RELEASE_PUBLISH_RUN_ID}: ${run.url}`);'
preview_plugin_pack:
needs: preview_plugins_npm
if: needs.preview_plugins_npm.outputs.has_candidates == 'true'
@@ -240,12 +183,11 @@ jobs:
run: bash scripts/plugin-npm-publish.sh --pack-dry-run "${{ matrix.plugin.packageDir }}"
publish_plugins_npm:
needs: [preview_plugins_npm, preview_plugin_pack, validate_release_publish_approval]
needs: [preview_plugins_npm, preview_plugin_pack]
if: github.event_name == 'workflow_dispatch' && needs.preview_plugins_npm.outputs.has_candidates == 'true'
runs-on: ubuntu-latest
environment: npm-release
permissions:
actions: read
contents: read
id-token: write
strategy:

View File

@@ -209,7 +209,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_static == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || 'blacksmith-8vcpu-ubuntu-2404' }}
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
strategy:
fail-fast: false
@@ -245,7 +245,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_node == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || (matrix.runner || 'ubuntu-24.04') }}
runs-on: ${{ matrix.runner || 'ubuntu-24.04' }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -318,7 +318,7 @@ jobs:
name: ${{ matrix.check_name }}
needs: [preflight]
if: needs.preflight.outputs.run_plugin_prerelease_extensions == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'ubuntu-24.04' || matrix.runner }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 60
strategy:
fail-fast: false

View File

@@ -60,17 +60,13 @@ jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@v8
with:
script: |
if (context.eventName === "schedule") {
core.info("Scheduled default-branch QA run; actor permission check is only required for manual dispatch.");
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
@@ -83,18 +79,14 @@ jobs:
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
core.setFailed(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
@@ -186,8 +178,6 @@ jobs:
install-bun: "true"
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run OpenAI candidate lane
@@ -198,7 +188,7 @@ jobs:
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model openai/gpt-5.5-alt \
--output-dir .artifacts/qa-e2e/openai-candidate
--output-dir .artifacts/qa-e2e/gpt54
- name: Run Opus 4.7 lane
run: |
@@ -207,118 +197,28 @@ jobs:
--parity-pack agentic \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model anthropic/claude-opus-4-7 \
--alt-model anthropic/claude-sonnet-4-6 \
--output-dir .artifacts/qa-e2e/anthropic-baseline
--alt-model anthropic/claude-sonnet-4-7 \
--output-dir .artifacts/qa-e2e/opus46
- name: Generate parity report
run: |
pnpm openclaw qa parity-report \
--repo-root . \
--candidate-summary .artifacts/qa-e2e/openai-candidate/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/anthropic-baseline/qa-suite-summary.json \
--candidate-summary .artifacts/qa-e2e/gpt54/qa-suite-summary.json \
--baseline-summary .artifacts/qa-e2e/opus46/qa-suite-summary.json \
--candidate-label "${OPENCLAW_CI_OPENAI_MODEL}" \
--baseline-label anthropic/claude-opus-4-7 \
--output-dir .artifacts/qa-e2e/parity
- name: Upload parity artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-parity-${{ github.run_id }}-${{ github.run_attempt }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: warn
run_live_runtime_token_efficiency:
name: Run live runtime token-efficiency lane
needs: [authorize_actor, validate_selected_ref]
if: github.event_name == 'schedule'
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 45
environment: qa-live-shared
env:
QA_PARITY_CONCURRENCY: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
steps:
- name: Checkout selected ref
uses: actions/checkout@v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
pnpm-version: ${{ env.PNPM_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing required OPENAI_API_KEY." >&2
exit 1
fi
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run live runtime parity lane
id: run_lane
shell: bash
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
OPENCLAW_LIVE_OPENAI_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/runtime-token-efficiency-live-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
pnpm openclaw qa suite \
--repo-root . \
--provider-mode live-frontier \
--runtime-parity-tier standard \
--runtime-parity-tier live-only \
--concurrency "${QA_PARITY_CONCURRENCY}" \
--model "${OPENCLAW_CI_OPENAI_MODEL}" \
--alt-model "${OPENCLAW_CI_OPENAI_MODEL}" \
--runtime-pair pi,codex \
--fast \
--allow-failures \
--output-dir "${output_dir}/runtime-suite"
- name: Generate live runtime token-efficiency report
if: always() && steps.run_lane.outcome != 'skipped' && steps.run_lane.outcome != 'cancelled'
shell: bash
run: |
set -euo pipefail
pnpm openclaw qa parity-report \
--repo-root . \
--runtime-axis \
--token-efficiency \
--summary "${{ steps.run_lane.outputs.output_dir }}/runtime-suite/qa-suite-summary.json" \
--output-dir "${{ steps.run_lane.outputs.output_dir }}/runtime-report"
- name: Upload live runtime token-efficiency artifacts
if: always()
uses: actions/upload-artifact@v7
with:
name: qa-live-runtime-token-efficiency-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
retention-days: 14
if-no-files-found: warn
run_live_matrix:
name: Run Matrix live QA lane
needs: [authorize_actor, validate_selected_ref]
@@ -354,8 +254,6 @@ jobs:
fi
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Matrix live lane
@@ -389,7 +287,7 @@ jobs:
- name: Upload Matrix QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-matrix-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -440,8 +338,6 @@ jobs:
fi
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Matrix live lane shard
@@ -474,7 +370,7 @@ jobs:
- name: Upload Matrix QA shard artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-matrix-${{ matrix.profile }}-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -524,8 +420,6 @@ jobs:
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Telegram live lane
@@ -569,7 +463,7 @@ jobs:
- name: Upload Telegram QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-telegram-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -619,8 +513,6 @@ jobs:
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Discord live lane
@@ -655,8 +547,8 @@ jobs:
--repo-root . \
--output-dir "${output_dir}" \
--provider-mode live-frontier \
--model openai/gpt-5.5 \
--alt-model openai/gpt-5.5 \
--model openai/gpt-5.4 \
--alt-model openai/gpt-5.4 \
--fast \
--credential-source convex \
--credential-role ci \
@@ -664,7 +556,7 @@ jobs:
- name: Upload Discord QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-discord-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -676,9 +568,6 @@ jobs:
needs: [authorize_actor, validate_selected_ref]
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
concurrency:
group: qa-live-whatsapp-shared
cancel-in-progress: false
environment: qa-live-shared
steps:
- name: Checkout selected ref
@@ -717,8 +606,6 @@ jobs:
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run WhatsApp live lane
@@ -762,7 +649,7 @@ jobs:
- name: Upload WhatsApp QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-whatsapp-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}
@@ -812,8 +699,6 @@ jobs:
require_var OPENCLAW_QA_CONVEX_SECRET_CI
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
- name: Run Slack live lane
@@ -857,7 +742,7 @@ jobs:
- name: Upload Slack QA artifacts
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: qa-live-slack-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ steps.run_lane.outputs.output_dir }}

View File

@@ -18,7 +18,6 @@ jobs:
name: Real behavior proof
permissions:
contents: read
issues: read
pull-requests: read
runs-on: ubuntu-24.04
steps:
@@ -26,25 +25,5 @@ jobs:
with:
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@v3
id: app-token
continue-on-error: true
with:
app-id: "2729701"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
permission-issues: read
permission-members: read
- uses: actions/create-github-app-token@v3
id: app-token-fallback
if: steps.app-token.outcome == 'failure'
continue-on-error: true
with:
app-id: "2971289"
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
permission-issues: read
permission-members: read
- name: Check real behavior proof
env:
GH_APP_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
GITHUB_TOKEN: ${{ github.token }}
run: node scripts/github/real-behavior-proof-check.mjs

View File

@@ -76,9 +76,11 @@ jobs:
- name: install.sh in Docker
run: |
docker run --rm \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENCLAW_NO_PROMPT=1 \
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
node:24-bookworm-slim \
bash -lc 'bash /tmp/install.sh --version latest && openclaw --version'
bash -lc 'bash /tmp/install.sh --no-prompt --no-onboard --version latest && openclaw --version'
- name: install-cli.sh in Docker
run: |
@@ -132,29 +134,20 @@ jobs:
(github.event_name == 'push' && github.ref == 'refs/heads/main') ||
(github.event_name == 'workflow_dispatch' && inputs.sync_website)
runs-on: ubuntu-24.04
env:
OPENCLAW_GH_TOKEN: ${{ secrets.OPENCLAW_GH_TOKEN }}
steps:
- name: Skip website sync without token
if: env.OPENCLAW_GH_TOKEN == ''
run: echo "OPENCLAW_GH_TOKEN is not configured; installer verification passed, skipping website sync."
- name: Checkout OpenClaw
if: env.OPENCLAW_GH_TOKEN != ''
uses: actions/checkout@v6
with:
path: openclaw
- name: Checkout openclaw.ai
if: env.OPENCLAW_GH_TOKEN != ''
uses: actions/checkout@v6
with:
repository: openclaw/openclaw.ai
token: ${{ env.OPENCLAW_GH_TOKEN }}
token: ${{ secrets.OPENCLAW_GH_TOKEN }}
path: openclaw.ai
- name: Sync installer scripts
if: env.OPENCLAW_GH_TOKEN != ''
run: |
cp openclaw/scripts/install.sh openclaw.ai/public/install.sh
cp openclaw/scripts/install-cli.sh openclaw.ai/public/install-cli.sh
@@ -163,7 +156,6 @@ jobs:
chmod +x openclaw.ai/public/install.sh openclaw.ai/public/install-cli.sh
- name: Check for changes
if: env.OPENCLAW_GH_TOKEN != ''
id: changes
working-directory: openclaw.ai
run: |
@@ -204,10 +196,7 @@ jobs:
run: |
git config user.name "openclaw-installer-sync[bot]"
git config user.email "openclaw-installer-sync[bot]@users.noreply.github.com"
git add public/install.sh public/install-cli.sh public/install.ps1
if git ls-files --error-unmatch public/install.cmd >/dev/null 2>&1; then
git add -u -- public/install.cmd
fi
git add public/install.sh public/install-cli.sh public/install.ps1 public/install.cmd
git commit -m "chore: sync installers from openclaw ${GITHUB_SHA::12}"
git pull --rebase origin main
git push origin HEAD:main

View File

@@ -2,12 +2,8 @@ name: Workflow Sanity
on:
pull_request:
paths-ignore:
- "CHANGELOG.md"
push:
branches: [main]
paths-ignore:
- "CHANGELOG.md"
workflow_dispatch:
permissions:

9
.gitignore vendored
View File

@@ -41,7 +41,6 @@ apps/macos/.build/
apps/macos-mlx-tts/.build/
apps/shared/MoltbotKit/.build/
apps/shared/OpenClawKit/.build/
apps/shared/*/.build/
apps/shared/OpenClawKit/Package.resolved
**/ModuleCache/
bin/
@@ -51,7 +50,6 @@ apps/macos/.build-local/
apps/macos/.swiftpm/
apps/shared/MoltbotKit/.swiftpm/
apps/shared/OpenClawKit/.swiftpm/
apps/shared/*/.swiftpm/
Core/
apps/ios/*.xcodeproj/
apps/ios/*.xcworkspace/
@@ -110,9 +108,6 @@ USER.md
# local tooling
.serena/
# local QA evidence mirrors; CI publishes canonical Mantis files as Actions artifacts
mantis/
# Local project-agent skill installs. Only repo-owned skills are visible by
# default; promoting a new repo skill should require an intentional `git add -f`.
.agents/skills/*
@@ -120,8 +115,6 @@ mantis/
!.agents/skills/blacksmith-testbox/**
!.agents/skills/crabbox/
!.agents/skills/crabbox/**
!.agents/skills/clawdtributor/
!.agents/skills/clawdtributor/**
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/openclaw-docs/**
@@ -139,8 +132,6 @@ mantis/
!.agents/skills/openclaw-refactor-docs/**
!.agents/skills/openclaw-qa-testing/
!.agents/skills/openclaw-qa-testing/**
!.agents/skills/openclaw-release-ci/
!.agents/skills/openclaw-release-ci/**
!.agents/skills/openclaw-release-maintainer/
!.agents/skills/openclaw-release-maintainer/**
!.agents/skills/openclaw-secret-scanning-maintainer/

View File

@@ -8,7 +8,6 @@
},
"rules": {
"curly": "error",
"eslint/no-underscore-dangle": "error",
"eslint-plugin-unicorn/prefer-array-find": "error",
"eslint/no-array-constructor": "error",
"eslint/no-await-in-loop": "off",

View File

@@ -10,12 +10,12 @@ Skills own workflows; root owns hard policy and routing.
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
- Dependency-backed behavior: read upstream docs/source/types first. No API/default/error/timing guesses.
- Live-verify when feasible. Never print secrets.
- Live-verify when feasible. Check env/`~/.profile` for keys before saying blocked; never print secrets.
- Missing deps: `pnpm install`, retry once, then report first actionable error.
- CODEOWNERS: maint/refactor/tests ok. Larger behavior/product/security/ownership: owner ask/review.
- Product/docs/UI/changelog wording: "plugin/plugins"; `extensions/` is internal.
- New channel/plugin/app/doc surface: update `.github/labeler.yml` + GH labels.
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink; edit `AGENTS.md` only.
- New `AGENTS.md`: add sibling `CLAUDE.md` symlink.
## Map
@@ -31,36 +31,23 @@ Skills own workflows; root owns hard policy and routing.
- Core/tests: no deep plugin internals (`extensions/*/src/**`, `onboard.js`). Use public barrels, SDK facade, generic contracts.
- Owner boundary: owner-specific repair/detection/onboarding/auth/defaults/provider behavior lives in owner plugin. Shared/core gets generic seams only.
- Dependency ownership follows runtime ownership: plugin-only deps stay plugin-local; root deps only for core imports or intentionally internalized bundled plugin runtime.
- Internal bundled plugins ship in core dist; bundled-only facade loader ok only for them.
- External official plugins own package/deps and are excluded from core dist; core uses registry-aware `facade-runtime` or generic contracts.
- Externalizing a bundled plugin: update package excludes, official catalogs, docs, tests, and prove core runtime paths resolve installed plugin roots before root-dep removal.
- Legacy config repair belongs in `openclaw doctor --fix`, not startup/load-time core migrations. Runtime paths use canonical contracts.
- Fix shape: default to clean bounded refactor, not smallest patch. Move ownership to right boundary; delete stale abstractions, duplicate policy, dead branches, wrappers, fallback stacks.
- Lean code is a goal. No internal shims, aliases, legacy names, broad fallbacks, or defensive branches just to reduce diff or handle unrealistic edge cases.
- Handle real production states, shipped upgrade paths, security boundaries, and dependency contracts. Public/hostile/observed malformed input gets care; hypothetical malformed input does not.
- Public plugin SDK/API is the compat exception. New API first, old path only via named compat/deprecation metadata, docs, warnings when useful, tests for old+new, planned removal.
- Migrate internal/bundled callers to modern API in the same change. Do not let internal compat become permanent architecture.
- New seams: backward-compatible, documented, versioned. Third-party plugins exist.
- Channels are implementation under `src/channels/**`; plugin authors get SDK seams. Providers own auth/catalog/runtime hooks; core owns generic loop.
- Hot paths should carry prepared facts forward: provider id, model ref, channel id, target, capability family, attachment class. Do not rediscover with broad plugin/provider/channel/capability loaders.
- Do not fix repeated request-time discovery with scattered caches. Move the canonical fact earlier; reuse prepared runtime objects; delete duplicate lookup branches.
- Inline code comments: brief notes for tricky, bug-prone, or previously buggy logic.
- Gateway protocol changes: additive first; incompatible needs versioning/docs/client follow-through.
- Protocol version bumps: explicit owner confirmation only; never automatic/generated.
- Config contract: exported types, schema/help, metadata, baselines, docs aligned. Retired public keys stay retired; compat in raw migration/doctor only.
- Prompt cache: deterministic ordering for maps/sets/registries/plugin lists/files/network results before model/tool payloads. Preserve old transcript bytes when possible.
- Agent tool schema cleanup: remove stale args cleanly; no hidden compat for model-facing params just to avoid churn.
## Commands
- Runtime: Node 22.19+; Node 24 recommended. Keep Node + Bun paths working.
- Runtime: Node 22+. Keep Node + Bun paths working.
- Package manager/runtime: repo defaults only. No swaps without approval.
- Install: `pnpm install` (keep Bun lock/patches aligned if touched).
- Sharp/Homebrew libvips source-build fail: `SHARP_IGNORE_GLOBAL_LIBVIPS=1 pnpm install`.
- CLI: `pnpm openclaw ...` or `pnpm dev`; build: `pnpm build`.
- Tests in a normal source checkout: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- Tests in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm test*`; use `node scripts/run-vitest.mjs <path-or-filter>` for tiny explicit-file proof, or Crabbox/Testbox for anything broader.
- Checks in a normal source checkout: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Checks in a Codex worktree or linked/sparse checkout: avoid direct local `pnpm check*`; use `node scripts/crabbox-wrapper.mjs run ... --shell -- "pnpm check:changed"` so pnpm runs inside Testbox, not locally.
- Tests: `pnpm test <path-or-filter> [vitest args...]`, `pnpm test:changed`, `pnpm test:serial`, `pnpm test:coverage`; never raw `vitest`.
- Checks: `pnpm check:changed`; lanes: `pnpm changed:lanes --json`; staged: `pnpm check:changed --staged`; full: `pnpm check`.
- Extension tests: `pnpm test:extensions`, `pnpm test extensions`, `pnpm test extensions/<id>`.
- Typecheck: `tsgo` lanes only (`pnpm tsgo*`, `pnpm check:test-types`); never add `tsc --noEmit`, `typecheck`, `check:types`.
- Formatting: `oxfmt`, not Prettier. Use repo wrappers (`pnpm format:*`, `pnpm lint:*`, `scripts/run-oxlint.mjs`).
@@ -69,41 +56,32 @@ Skills own workflows; root owns hard policy and routing.
## Validation
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
- Small/narrow tests, lints, format checks, and type probes are fine locally.
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
- One/few files local. If a local command fans out, stop and move broad proof to Crabbox/Testbox.
- Before handoff/push: prove touched surface. Before landing to `main`: issue proof plus appropriate full/broad proof unless scope is clearly narrow.
- Pre-land/pre-commit code changes: use `$autoreview` until no accepted/actionable findings remain, unless equivalent manual review already done, trivial/docs-only, or user opts out.
- If proof is blocked, say exactly what is missing and why.
- Do not land related failing format/lint/type/build/tests. If unrelated on latest `origin/main`, say so with scoped proof.
- Docs/changelog-only and CI/workflow metadata-only: `git diff --check` plus relevant docs/workflow sanity; escalate only if scripts/config/generated/package/runtime behavior changed.
- Prompt snapshots: CI truth is Linux Node 24. If macOS local passes but CI drifts, reproduce/generate in Linux before rerun.
## GitHub / PRs
- Use `$openclaw-pr-maintainer` immediately for maintainer-side OpenClaw issue/PR review, triage, duplicates, labels, comments, close, land, or evidence. Contributor PR creation/refresh follows the requested contributor workflow; linked refs alone do not require maintainer archive tooling.
- Pasted GitHub issue/PR: first `git status -sb`; if dirty, yell; then `git push` + `git pull --ff-only`.
- PR refs: `gh pr view/diff` or `gh api`, not web search. Prefer `gitcrawl` for maintainer discovery; missing/stale `gitcrawl` falls through to live `gh`, not contributor setup. Verify live with `gh` before mutation.
- Bare issue/PR URL/number means review/report in chat. Suggest comment/close/merge when appropriate; mutate only when asked.
- No unsolicited PR comments/reviews/labels/retitles/rebases/fixups/landing. Exception: close/duplicate action that needs a reason comment after explicit close/sweep/landing request.
- Maintainer decision closes the cluster: if deciding reported behavior/proposed fix is not planned, comment+close all directly associated open issues/PRs unless explicitly told to keep one open. Associated means linked PRs/issues, duplicates, companion workaround PRs, and the canonical issue for the rejected behavior.
- Do not leave associated issues open for hypothetical future repros. Close with rationale; ask for a new issue or reopen only if concrete new evidence appears. Close comment states: decision, why, supported alternative, and what evidence would change the decision.
- PR review answer: bug/behavior, URL(s), affected surface, provenance for regressions when traceable, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
- PR review answer: bug/behavior, URL(s), affected surface, best-fix judgment, evidence from code/tests/CI/current or shipped behavior.
- Issue/PR final answer: last line is the full GitHub URL.
- Changelog: PR landings/fixes need one unless pure test/internal. Do not mention missing changelog as a review finding; Codex handles it during fix/landing.
- PR verification: before merge, post exact local commands, CI/Testbox run IDs, before/after proof when used, and known proof gaps.
- Issue fixed on `main` with proof: comment proof + commit/PR, then close.
- After landing or requested close/sweep: search duplicates; comment proof + canonical commit/PR/release before closing.
- After landing/ship final: include 2-5 sentence recap of what landed: behavior change, key files/surface, proof run, issue/PR state. Do not answer with only status/links.
- `ship` that fixes an issue: after push, comment proof + commit link, then close the issue.
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
- Real behavior proof section is parsed. Use exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Do not commit `.github/pr-assets`.
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
- Maintainers: may skip/ignore `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
- Maintainers: ignore `Real behavior proof` failures that only say PR body lacks real after-fix evidence.
- `/landpr`: use `~/.codex/prompts/landpr.md`; do not idle on `auto-response` or `check-docs`.
## Code
@@ -112,10 +90,6 @@ Skills own workflows; root owns hard policy and routing.
- No `@ts-nocheck`. Lint suppressions only intentional + explained.
- External boundaries: prefer `zod` or existing schema helpers.
- Runtime branching: discriminated unions/closed codes over freeform strings. Avoid semantic sentinels (`?? 0`, empty object/string).
- Formatter-friendly shape: when oxfmt explodes an expression vertically, extract named booleans, payloads, or small helpers. Do not change width or use format-ignore for local compactness.
- Calls should be boring: complex decisions happen above; call args/object fields are names, literals, or simple property reads.
- Prefer early returns over nested condition pyramids. Split code into gather -> normalize -> decide -> act.
- Use named intermediates only for domain meaning or readability; avoid temp-variable soup.
- Dynamic import: no static+dynamic import for same prod module. Use `*.runtime.ts` lazy boundary. After edits: `pnpm build`; check `[INEFFECTIVE_DYNAMIC_IMPORT]`.
- Cycles: keep `pnpm check:import-cycles` + architecture/madge green.
- Classes: no prototype mixins/mutations. Prefer inheritance/composition. Tests prefer per-instance stubs.
@@ -160,6 +134,7 @@ Skills own workflows; root owns hard policy and routing.
- Never commit real phone numbers, videos, credentials, live config.
- Secrets: channel/provider creds in `~/.openclaw/credentials/`; model auth profiles in `~/.openclaw/agents/<agentId>/agent/auth-profiles.json`.
- Env keys: check `~/.profile`; redact output.
- Dependency patches/overrides/vendor changes need explicit approval. `pnpm-workspace.yaml` patched dependencies use exact versions only.
- Carbon pins owner-only: do not change `@buape/carbon` unless Shadow (`@thewilloftheshadow`, verified by `gh`) asks.
- Releases/publish/version bumps need explicit approval. Use `$openclaw-release-maintainer`.
@@ -172,7 +147,6 @@ Skills own workflows; root owns hard policy and routing.
- "restart iOS/Android apps" = rebuild/reinstall/relaunch, not kill/launch.
- SwiftUI: Observation (`@Observable`, `@Bindable`) over new `ObservableObject`.
- Mac gateway: dev watch = `pnpm gateway:watch`; managed installs = `openclaw gateway restart/status --deep`; logs = `./scripts/clawlog.sh`. No launchd/ad-hoc tmux.
- Mac app permission testing: stable app path + real signing identity required. No `--no-sign`, `SIGN_IDENTITY=-`, or raw debug binary; TCC prompts/listing won't stick.
- Version bump surfaces live in `$openclaw-release-maintainer`.
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.

File diff suppressed because it is too large Load Diff

View File

@@ -72,8 +72,7 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
NODE_OPTIONS=--max-old-space-size=2048 pnpm install --frozen-lockfile \
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc && \
pnpm store add source-map@0.6.1
--config.supportedArchitectures.libc=glibc
# pnpm v10+ may append peer-resolution hashes to virtual-store folder names; do not hardcode `.pnpm/...`
# paths. Matrix's native downloader can hit transient release CDN errors while
@@ -117,8 +116,8 @@ ENV OPENCLAW_PREFER_PNPM=1
RUN pnpm_config_verify_deps_before_run=false pnpm ui:build
RUN pnpm_config_verify_deps_before_run=false pnpm qa:lab:build
# Prune dev dependencies, omitted plugin runtime packages, and build-only
# metadata before copying runtime assets into the final image.
# Prune dev dependencies and strip build-only metadata before copying
# runtime assets into the final image.
FROM build AS runtime-assets
ARG OPENCLAW_EXTENSIONS
ARG OPENCLAW_BUNDLED_PLUGIN_DIR
@@ -128,8 +127,8 @@ RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/sto
--config.supportedArchitectures.os=linux \
--config.supportedArchitectures.cpu="$(node -p 'process.arch')" \
--config.supportedArchitectures.libc=glibc && \
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" OPENCLAW_BUNDLED_PLUGIN_DIR="$OPENCLAW_BUNDLED_PLUGIN_DIR" node scripts/prune-docker-plugin-dist.mjs && \
node scripts/postinstall-bundled-plugins.mjs && \
OPENCLAW_EXTENSIONS="$OPENCLAW_EXTENSIONS" node scripts/prune-docker-plugin-dist.mjs && \
find dist -type f \( -name '*.d.ts' -o -name '*.d.mts' -o -name '*.d.cts' -o -name '*.map' \) -delete && \
node scripts/check-package-dist-imports.mjs /app
@@ -199,29 +198,13 @@ RUN install -d -m 0755 "$COREPACK_HOME" && \
chmod -R a+rX "$COREPACK_HOME"
# Install additional system packages needed by your skills or extensions.
# Example: docker build --build-arg OPENCLAW_IMAGE_APT_PACKAGES="python3 wget" .
# Legacy alias: OPENCLAW_DOCKER_APT_PACKAGES is still accepted as a fallback.
ARG OPENCLAW_IMAGE_APT_PACKAGES
# Example: docker build --build-arg OPENCLAW_DOCKER_APT_PACKAGES="python3 wget" .
ARG OPENCLAW_DOCKER_APT_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
packages="${OPENCLAW_IMAGE_APT_PACKAGES-$OPENCLAW_DOCKER_APT_PACKAGES}"; \
if [ -n "$packages" ]; then \
if [ -n "$OPENCLAW_DOCKER_APT_PACKAGES" ]; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $packages; \
fi
# Install additional Python packages needed by your plugins or skills.
# Example: docker build --build-arg OPENCLAW_IMAGE_PIP_PACKAGES="requests humanize" .
ARG OPENCLAW_IMAGE_PIP_PACKAGES=""
RUN --mount=type=cache,id=openclaw-bookworm-apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=openclaw-bookworm-apt-lists,target=/var/lib/apt,sharing=locked \
if [ -n "$OPENCLAW_IMAGE_PIP_PACKAGES" ]; then \
if ! python3 -m pip --version >/dev/null 2>&1; then \
apt-get update && \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends python3-pip; \
fi && \
python3 -m pip install --no-cache-dir --break-system-packages $OPENCLAW_IMAGE_PIP_PACKAGES; \
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends $OPENCLAW_DOCKER_APT_PACKAGES; \
fi
# Optionally install Chromium and Xvfb for browser automation.
@@ -310,4 +293,4 @@ USER node
HEALTHCHECK --interval=3m --timeout=10s --start-period=15s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:18789/healthz').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENTRYPOINT ["tini", "-s", "--"]
CMD ["node", "openclaw.mjs", "gateway"]
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 OpenClaw Foundation
Copyright (c) 2025 Peter Steinberger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -96,7 +96,7 @@ Model note: while many providers and models are supported, prefer a current flag
## Install (recommended)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
```bash
npm install -g openclaw@latest
@@ -109,7 +109,7 @@ OpenClaw Onboard installs the Gateway daemon (launchd/systemd user service) so i
## Quick start (TL;DR)
Runtime: **Node 24 (recommended) or Node 22.19+**.
Runtime: **Node 24 (recommended) or Node 22.16+**.
Full beginner guide (auth, pairing, channels): [Getting started](https://docs.openclaw.ai/start/getting-started)

View File

@@ -312,7 +312,7 @@ OpenClaw's web interface (Gateway Control UI + HTTP endpoints) is intended for *
### Node.js Version
OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended default runtime for new installs. The minimum version includes important security patches:
OpenClaw requires **Node.js 22.16.0 or later** (LTS). This version includes important security patches:
- CVE-2025-59466: async_hooks DoS vulnerability
- CVE-2026-21636: Permission model bypass vulnerability
@@ -320,7 +320,7 @@ OpenClaw requires **Node.js 22.19.0 or later** (LTS). Node 24 is the recommended
Verify your Node.js version:
```bash
node --version # Should be v22.19.0 or later
node --version # Should be v22.16.0 or later
```
### Docker Security

File diff suppressed because it is too large Load Diff

View File

@@ -209,16 +209,15 @@ Why these matter:
- Google Play treats SMS and Call Log access as highly restricted. In most cases, Play only allows them for the default SMS app, default Phone app, default Assistant, or a narrow policy exception.
- Review usually involves a `Permissions Declaration Form`, policy justification, and demo video evidence in Play Console.
- The Play build removes these behind the `play` flavor.
- Photo library access is also removed from the Play build. Use third-party builds for `photos.latest`.
- If we want a Play-safe build, these should be the first permissions removed behind a dedicated product flavor / variant.
Current OpenClaw Android implication:
- APK / sideload build can keep SMS, Call Log, and recent-photo features.
- Google Play build excludes SMS send/search, Call Log search, and recent-photo access unless the product is intentionally positioned and approved under the relevant policy exception.
- APK / sideload build can keep SMS and Call Log features.
- Google Play build should exclude SMS send/search and Call Log search unless the product is intentionally positioned and approved as a default-handler exception case.
- The repo now ships this split as Android product flavors:
- `play`: removes `READ_SMS`, `SEND_SMS`, `READ_CALL_LOG`, `READ_MEDIA_IMAGES`, `READ_MEDIA_VISUAL_USER_SELECTED`, and `READ_EXTERNAL_STORAGE`; hides SMS, Call Log, and Photos surfaces in onboarding, settings, and advertised node capabilities.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log / Photos functionality.
- `play`: removes `READ_SMS`, `SEND_SMS`, and `READ_CALL_LOG`, and hides SMS / Call Log surfaces in onboarding, settings, and advertised node capabilities.
- `thirdParty`: keeps the full permission set and the existing SMS / Call Log functionality.
Policy links:

View File

@@ -65,8 +65,8 @@ android {
applicationId = "ai.openclaw.app"
minSdk = 31
targetSdk = 36
versionCode = 2026052100
versionName = "2026.5.21"
versionCode = 2026051200
versionName = "2026.5.12"
ndk {
// Support all major ABIs — native libs are tiny (~47 KB per ABI)
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")

View File

@@ -5,7 +5,6 @@ import ai.openclaw.app.chat.ChatPendingToolCall
import ai.openclaw.app.chat.ChatSessionEntry
import ai.openclaw.app.chat.OutgoingAttachment
import ai.openclaw.app.gateway.GatewayEndpoint
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.node.CameraCaptureManager
import ai.openclaw.app.node.CanvasController
import ai.openclaw.app.node.SmsManager
@@ -82,40 +81,6 @@ class MainViewModel(
val statusText: StateFlow<String> = runtimeState(initial = "Offline") { it.statusText }
val serverName: StateFlow<String?> = runtimeState(initial = null) { it.serverName }
val remoteAddress: StateFlow<String?> = runtimeState(initial = null) { it.remoteAddress }
val gatewayVersion: StateFlow<String?> = runtimeState(initial = null) { it.gatewayVersion }
val gatewayUpdateAvailable: StateFlow<GatewayUpdateAvailableSummary?> = runtimeState(initial = null) { it.gatewayUpdateAvailable }
val modelCatalog: StateFlow<List<GatewayModelSummary>> = runtimeState(initial = emptyList()) { it.modelCatalog }
val modelAuthProviders: StateFlow<List<GatewayModelProviderSummary>> = runtimeState(initial = emptyList()) { it.modelAuthProviders }
val modelCatalogRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.modelCatalogRefreshing }
val modelCatalogErrorText: StateFlow<String?> = runtimeState(initial = null) { it.modelCatalogErrorText }
val gatewayDefaultAgentId: StateFlow<String?> = runtimeState(initial = null) { it.gatewayDefaultAgentId }
val gatewayAgents: StateFlow<List<GatewayAgentSummary>> = runtimeState(initial = emptyList()) { it.gatewayAgents }
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
val usageErrorText: StateFlow<String?> = runtimeState(initial = null) { it.usageErrorText }
val skillsSummary: StateFlow<GatewaySkillsSummary> = runtimeState(initial = GatewaySkillsSummary(skills = emptyList())) { it.skillsSummary }
val skillsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.skillsRefreshing }
val skillsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.skillsErrorText }
val nodesDevicesSummary: StateFlow<GatewayNodesDevicesSummary> =
runtimeState(initial = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())) { it.nodesDevicesSummary }
val nodesDevicesRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.nodesDevicesRefreshing }
val nodesDevicesErrorText: StateFlow<String?> = runtimeState(initial = null) { it.nodesDevicesErrorText }
val channelsSummary: StateFlow<GatewayChannelsSummary> =
runtimeState(initial = GatewayChannelsSummary(channels = emptyList())) { it.channelsSummary }
val channelsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.channelsRefreshing }
val channelsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.channelsErrorText }
val dreamingSummary: StateFlow<GatewayDreamingSummary> =
runtimeState(initial = GatewayDreamingSummary()) { it.dreamingSummary }
val dreamingRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.dreamingRefreshing }
val dreamingErrorText: StateFlow<String?> = runtimeState(initial = null) { it.dreamingErrorText }
val healthLogsSummary: StateFlow<GatewayHealthLogsSummary> =
runtimeState(initial = GatewayHealthLogsSummary()) { it.healthLogsSummary }
val healthLogsRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.healthLogsRefreshing }
val healthLogsErrorText: StateFlow<String?> = runtimeState(initial = null) { it.healthLogsErrorText }
val pendingGatewayTrust: StateFlow<NodeRuntime.GatewayTrustPrompt?> = runtimeState(initial = null) { it.pendingGatewayTrust }
val seamColorArgb: StateFlow<Long> = runtimeState(initial = 0xFF0EA5E9) { it.seamColorArgb }
val mainSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.mainSessionKey }
@@ -153,8 +118,6 @@ class MainViewModel(
val talkModeListening: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeListening }
val talkModeSpeaking: StateFlow<Boolean> = runtimeState(initial = false) { it.talkModeSpeaking }
val talkModeStatusText: StateFlow<String> = runtimeState(initial = "Off") { it.talkModeStatusText }
val talkModeConversation: StateFlow<List<VoiceConversationEntry>> =
runtimeState(initial = emptyList()) { it.talkModeConversation }
val chatSessionKey: StateFlow<String> = runtimeState(initial = "main") { it.chatSessionKey }
val chatSessionId: StateFlow<String?> = runtimeState(initial = null) { it.chatSessionId }
@@ -328,10 +291,6 @@ class MainViewModel(
ensureRuntime().setMicEnabled(enabled)
}
fun cancelMicCapture() {
ensureRuntime().cancelMicCapture()
}
fun setTalkModeEnabled(enabled: Boolean) {
ensureRuntime().setTalkModeEnabled(enabled)
}
@@ -394,42 +353,6 @@ class MainViewModel(
ensureRuntime().refreshHomeCanvasOverviewIfConnected()
}
fun refreshModelCatalog() {
ensureRuntime().refreshModelCatalog()
}
fun refreshAgents() {
ensureRuntime().refreshAgents()
}
fun refreshCronJobs() {
ensureRuntime().refreshCronJobs()
}
fun refreshUsage() {
ensureRuntime().refreshUsage()
}
fun refreshSkills() {
ensureRuntime().refreshSkills()
}
fun refreshNodesDevices() {
ensureRuntime().refreshNodesDevices()
}
fun refreshChannels() {
ensureRuntime().refreshChannels()
}
fun refreshDreaming() {
ensureRuntime().refreshDreaming()
}
fun refreshHealthLogs() {
ensureRuntime().refreshHealthLogs()
}
fun loadChat(sessionKey: String) {
ensureRuntime().loadChat(sessionKey)
}

File diff suppressed because it is too large Load Diff

View File

@@ -17,148 +17,80 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.coroutines.resume
class PermissionRequester internal constructor(
class PermissionRequester(
private val activity: ComponentActivity,
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
) {
private data class PendingPermissionRequest(
val deferred: CompletableDeferred<Map<String, Boolean>>,
var timedOut: Boolean = false,
)
private class PermissionRequestSlot(
val launcher: ActivityResultLauncher<Array<String>>,
var request: PendingPermissionRequest? = null,
)
constructor(activity: ComponentActivity) : this(
activity = activity,
launcherFactory = { callback ->
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions(), callback)
},
)
private val mutex = Mutex()
private val requestSlotsLock = Any()
private var pending: CompletableDeferred<Map<String, Boolean>>? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val launchers = List(4) { createPermissionRequestSlot(launcherFactory) }
private val launcher: ActivityResultLauncher<Array<String>> =
activity.registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { result ->
val p = pending
pending = null
p?.complete(result)
}
suspend fun requestIfMissing(
permissions: List<String>,
timeoutMs: Long = 20_000,
): Map<String, Boolean> {
return mutex.withLock {
while (true) {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
}
if (missing.isEmpty()) {
return permissions.associateWith { true }
): Map<String, Boolean> =
mutex.withLock {
val missing =
permissions.filter { perm ->
ContextCompat.checkSelfPermission(activity, perm) != PackageManager.PERMISSION_GRANTED
}
val needsRationale =
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
if (needsRationale) {
val proceed = showRationaleDialog(missing)
if (!proceed) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
}
}
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
val request = PendingPermissionRequest(deferred)
val slot = reservePermissionRequestSlot(request)
try {
withContext(Dispatchers.Main) {
slot.launcher.launch(missing.toTypedArray())
}
} catch (err: Throwable) {
clearPermissionRequestSlot(slot, request)
throw err
}
val result =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
request.timedOut = true
throw err
}
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
}
return merged
if (missing.isEmpty()) {
return permissions.associateWith { true }
}
error("unreachable")
}
}
private fun createPermissionRequestSlot(
launcherFactory: ((Map<String, Boolean>) -> Unit) -> ActivityResultLauncher<Array<String>>,
): PermissionRequestSlot {
var slot: PermissionRequestSlot? = null
val launcher = launcherFactory { result -> completePermissionRequest(checkNotNull(slot), result) }
val created = PermissionRequestSlot(launcher)
slot = created
return created
}
private fun reservePermissionRequestSlot(request: PendingPermissionRequest): PermissionRequestSlot =
synchronized(requestSlotsLock) {
val slot = launchers.firstOrNull { it.request == null } ?: error("permission request launcher busy")
slot.request = request
slot
}
private fun completePermissionRequest(
slot: PermissionRequestSlot,
result: Map<String, Boolean>,
) {
val request =
synchronized(requestSlotsLock) {
slot.request.also {
slot.request = null
val needsRationale =
missing.any { ActivityCompat.shouldShowRequestPermissionRationale(activity, it) }
if (needsRationale) {
val proceed = showRationaleDialog(missing)
if (!proceed) {
return permissions.associateWith { perm ->
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
}
}
} ?: return
if (request.timedOut) return
request.deferred.complete(result)
}
private fun clearPermissionRequestSlot(
slot: PermissionRequestSlot,
request: PendingPermissionRequest,
) {
synchronized(requestSlotsLock) {
if (slot.request === request) {
slot.request = null
}
val deferred = CompletableDeferred<Map<String, Boolean>>()
pending = deferred
withContext(Dispatchers.Main) {
launcher.launch(missing.toTypedArray())
}
val result =
withContext(Dispatchers.Default) {
kotlinx.coroutines.withTimeout(timeoutMs) { deferred.await() }
}
// Merge: if something was already granted, treat it as granted even if launcher omitted it.
val merged =
permissions.associateWith { perm ->
val nowGranted =
ContextCompat.checkSelfPermission(activity, perm) == PackageManager.PERMISSION_GRANTED
result[perm] == true || nowGranted
}
val denied =
merged.filterValues { !it }.keys.filter {
!ActivityCompat.shouldShowRequestPermissionRationale(activity, it)
}
if (denied.isNotEmpty()) {
showSettingsDialog(denied)
}
return merged
}
}
private suspend fun showRationaleDialog(permissions: List<String>): Boolean =
withContext(Dispatchers.Main) {

View File

@@ -58,7 +58,6 @@ class ChatController(
private val pendingRuns = mutableSetOf<String>()
private val pendingRunTimeoutJobs = ConcurrentHashMap<String, Job>()
private val optimisticMessagesByRunId = LinkedHashMap<String, ChatMessage>()
private val pendingRunTimeoutMs = 120_000L
private var lastHealthPollAtMs: Long? = null
@@ -77,7 +76,6 @@ class ChatController(
fun load(sessionKey: String) {
val key = normalizeRequestedSessionKey(sessionKey)
_sessionKey.value = key
optimisticMessagesByRunId.clear()
scope.launch { bootstrap(forceHealth = true, refreshSessions = true) }
}
@@ -115,7 +113,6 @@ class ChatController(
if (key.isEmpty()) return
if (key == _sessionKey.value) return
_sessionKey.value = key
optimisticMessagesByRunId.clear()
// Keep the thread switch path lean: history + health are needed immediately,
// but the session list is usually unchanged and can refresh on explicit pull-to-refresh.
scope.launch { bootstrap(forceHealth = true, refreshSessions = false) }
@@ -174,15 +171,14 @@ class ChatController(
)
}
}
val optimisticMessage =
_messages.value =
_messages.value +
ChatMessage(
id = UUID.randomUUID().toString(),
role = "user",
content = userContent,
timestampMs = System.currentTimeMillis(),
)
optimisticMessagesByRunId[runId] = optimisticMessage
_messages.value = _messages.value + optimisticMessage
armPendingRunTimeout(runId)
synchronized(pendingRuns) {
@@ -222,7 +218,6 @@ class ChatController(
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
clearPendingRun(runId)
armPendingRunTimeout(actualRunId)
synchronized(pendingRuns) {
@@ -233,7 +228,6 @@ class ChatController(
true
} catch (err: Throwable) {
clearPendingRun(runId)
removeOptimisticMessage(runId)
_errorText.value = err.message
false
}
@@ -308,7 +302,7 @@ class ChatController(
val historyJson = session.request("chat.history", """{"sessionKey":"$key"}""")
val history = parseHistory(historyJson, sessionKey = key, previousMessages = _messages.value)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
@@ -375,13 +369,7 @@ class ChatController(
if (state == "error") {
_errorText.value = payload["errorMessage"].asStringOrNull() ?: "Chat failed"
}
if (runId != null) {
clearPendingRun(runId)
optimisticMessagesByRunId.remove(runId)
} else {
clearPendingRuns()
optimisticMessagesByRunId.clear()
}
if (runId != null) clearPendingRun(runId) else clearPendingRuns()
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
@@ -390,7 +378,7 @@ class ChatController(
val historyJson =
session.request("chat.history", """{"sessionKey":"${_sessionKey.value}"}""")
val history = parseHistory(historyJson, sessionKey = _sessionKey.value, previousMessages = _messages.value)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_messages.value = history.messages
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
@@ -483,7 +471,6 @@ class ChatController(
}
if (!stillPending) return@launch
clearPendingRun(runId)
removeOptimisticMessage(runId)
_errorText.value = "Timed out waiting for a reply; try again or refresh."
}
}
@@ -501,18 +488,12 @@ class ChatController(
job.cancel()
}
pendingRunTimeoutJobs.clear()
optimisticMessagesByRunId.clear()
synchronized(pendingRuns) {
pendingRuns.clear()
_pendingRunCount.value = 0
}
}
private fun removeOptimisticMessage(runId: String) {
val message = optimisticMessagesByRunId.remove(runId) ?: return
_messages.value = _messages.value.filterNot { it.id == message.id }
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -639,54 +620,11 @@ internal fun reconcileMessageIds(
}
}
internal fun mergeOptimisticMessages(
incoming: List<ChatMessage>,
optimistic: Collection<ChatMessage>,
): List<ChatMessage> {
if (optimistic.isEmpty()) return incoming
val unmatchedIncoming = incoming.toMutableList()
val missingOptimistic =
optimistic.filter { message ->
val matchIndex =
unmatchedIncoming.indexOfFirst { incomingMessage ->
incomingMessageConsumesOptimistic(incomingMessage, message)
}
if (matchIndex >= 0) {
unmatchedIncoming.removeAt(matchIndex)
false
} else {
true
}
}
if (missingOptimistic.isEmpty()) return incoming
return (incoming + missingOptimistic).sortedWith(compareBy<ChatMessage> { it.timestampMs ?: Long.MAX_VALUE }.thenBy { it.id })
}
internal fun messageIdentityKey(message: ChatMessage): String? {
val contentKey = messageContentIdentityKey(message) ?: return null
val timestamp = message.timestampMs?.toString().orEmpty()
if (timestamp.isEmpty() && contentKey.isEmpty()) return null
return listOf(contentKey, timestamp).joinToString(separator = "|")
}
private fun optimisticMessageIdentityKey(message: ChatMessage): String? = messageContentIdentityKey(message)
private fun incomingMessageConsumesOptimistic(
incoming: ChatMessage,
optimistic: ChatMessage,
): Boolean {
if (optimisticMessageIdentityKey(incoming) != optimisticMessageIdentityKey(optimistic)) return false
val incomingTimestamp = incoming.timestampMs ?: return false
val optimisticTimestamp = optimistic.timestampMs ?: return true
return incomingTimestamp >= optimisticTimestamp
}
private fun messageContentIdentityKey(message: ChatMessage): String? {
val role = message.role.trim().lowercase()
if (role.isEmpty()) return null
val timestamp = message.timestampMs?.toString().orEmpty()
val contentFingerprint =
message.content.joinToString(separator = "\u001E") { part ->
listOf(
@@ -704,7 +642,8 @@ private fun messageContentIdentityKey(message: ChatMessage): String? {
).joinToString(separator = "\u001F")
}
return listOf(role, contentFingerprint).joinToString(separator = "|")
if (timestamp.isEmpty() && contentFingerprint.isEmpty()) return null
return listOf(role, timestamp, contentFingerprint).joinToString(separator = "|")
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject

View File

@@ -1,4 +1,4 @@
package ai.openclaw.app.gateway
const val GATEWAY_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 4
const val GATEWAY_MIN_PROTOCOL_VERSION = 3

View File

@@ -64,24 +64,9 @@ data class GatewayConnectErrorDetails(
val code: String?,
val canRetryWithDeviceToken: Boolean,
val recommendedNextStep: String?,
val pauseReconnect: Boolean? = null,
val reason: String? = null,
)
data class GatewayHelloSummary(
val serverName: String?,
val remoteAddress: String?,
val serverVersion: String?,
val mainSessionKey: String?,
val updateAvailable: GatewayUpdateAvailableSummary?,
)
data class GatewayUpdateAvailableSummary(
val currentVersion: String?,
val latestVersion: String?,
val channel: String?,
)
private data class SelectedConnectAuth(
val authToken: String?,
val authBootstrapToken: String?,
@@ -100,7 +85,7 @@ class GatewaySession(
private val scope: CoroutineScope,
private val identityStore: DeviceIdentityStore,
private val deviceAuthStore: DeviceAuthTokenStore,
private val onConnected: (GatewayHelloSummary) -> Unit,
private val onConnected: (serverName: String?, remoteAddress: String?, mainSessionKey: String?) -> Unit,
private val onDisconnected: (message: String) -> Unit,
private val onEvent: (event: String, payloadJson: String?) -> Unit,
private val onInvoke: (suspend (InvokeRequest) -> InvokeResult)? = null,
@@ -163,10 +148,7 @@ class GatewaySession(
val tls: GatewayTlsParams?,
)
private val lifecycleLock = Any()
@Volatile private var desired: DesiredConnection? = null
private var desired: DesiredConnection? = null
private var job: Job? = null
@Volatile private var currentConnection: Connection? = null
@@ -185,39 +167,26 @@ class GatewaySession(
options: GatewayConnectOptions,
tls: GatewayTlsParams? = null,
) {
val connectionToClose: Connection?
synchronized(lifecycleLock) {
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
connectionToClose = currentConnection
if (job?.isActive != true) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
desired = DesiredConnection(endpoint, token, bootstrapToken, password, options, tls)
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
if (job == null) {
job = scope.launch(Dispatchers.IO) { runLoop() }
}
connectionToClose?.closeQuietly()
}
fun disconnect() {
val jobToCancel: Job?
val connectionToClose: Connection?
synchronized(lifecycleLock) {
desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
connectionToClose = currentConnection
jobToCancel = job
job = null
}
connectionToClose?.closeQuietly()
desired = null
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
currentConnection?.closeQuietly()
scope.launch(Dispatchers.IO) {
jobToCancel?.cancelAndJoin()
if (desired == null) {
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
job?.cancelAndJoin()
job = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
onDisconnected("Offline")
}
}
@@ -346,22 +315,6 @@ class GatewaySession(
return RpcResult(ok = res.ok, payloadJson = res.payloadJson, error = res.error)
}
suspend fun sendRequestFrame(
method: String,
paramsJson: String?,
timeoutMs: Long = 15_000,
onError: (ErrorShape) -> Unit = {},
) {
val conn = currentConnection ?: throw IllegalStateException("not connected")
val params =
if (paramsJson.isNullOrBlank()) {
null
} else {
json.parseToJsonElement(paramsJson)
}
conn.sendRequestFrame(method = method, params = params, timeoutMs = timeoutMs, onError = onError)
}
private data class RpcResponse(
val id: String,
val ok: Boolean,
@@ -406,12 +359,14 @@ class GatewaySession(
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
val frame =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
sendJson(frame)
return try {
withTimeout(timeoutMs) { deferred.await() }
} catch (err: TimeoutCancellationException) {
@@ -420,57 +375,13 @@ class GatewaySession(
}
}
suspend fun sendRequestFrame(
method: String,
params: JsonElement?,
timeoutMs: Long,
onError: (ErrorShape) -> Unit,
) {
val id = UUID.randomUUID().toString()
val deferred = CompletableDeferred<RpcResponse>()
pending[id] = deferred
try {
sendJson(buildRequestFrame(id = id, method = method, params = params))
} catch (err: Throwable) {
pending.remove(id)
throw err
}
scope.launch(Dispatchers.IO) {
val response =
try {
withTimeout(timeoutMs) { deferred.await() }
} catch (_: TimeoutCancellationException) {
pending.remove(id)
onError(ErrorShape("UNAVAILABLE", "request timeout"))
return@launch
}
if (!response.ok) {
onError(response.error ?: ErrorShape("UNAVAILABLE", "request failed"))
}
}
}
suspend fun sendJson(obj: JsonObject) {
val jsonString = obj.toString()
writeLock.withLock {
if (socket?.send(jsonString) != true) {
throw IllegalStateException("gateway send failed")
}
socket?.send(jsonString)
}
}
private fun buildRequestFrame(
id: String,
method: String,
params: JsonElement?,
): JsonObject =
buildJsonObject {
put("type", JsonPrimitive("req"))
put("id", JsonPrimitive(id))
put("method", JsonPrimitive(method))
if (params != null) put("params", params)
}
suspend fun awaitClose() = closedDeferred.await()
fun closeQuietly() {
@@ -618,8 +529,8 @@ class GatewaySession(
val allowedOperatorScopes =
setOf(
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.talk.secrets",
"operator.write",
)
scopes.filter { allowedOperatorScopes.contains(it) }.distinct().sorted()
@@ -662,9 +573,7 @@ class GatewaySession(
pendingDeviceTokenRetry = false
deviceTokenRetryBudgetUsed = false
reconnectPausedForAuthFailure = false
val server = obj["server"].asObjectOrNull()
val serverName = server?.get("host").asStringOrNull()
val serverVersion = server?.get("version").asStringOrNull()
val serverName = obj["server"].asObjectOrNull()?.get("host").asStringOrNull()
val authObj = obj["auth"].asObjectOrNull()
val deviceToken = authObj?.get("deviceToken").asStringOrNull()
val authRole = authObj?.get("role").asStringOrNull() ?: options.role
@@ -702,33 +611,13 @@ class GatewaySession(
?.let { normalized -> surface to normalized }
} ?: emptyList()
pluginSurfaceUrls = normalizedPluginSurfaceUrls.toMap()
val snapshot = obj["snapshot"].asObjectOrNull()
val sessionDefaults =
snapshot
obj["snapshot"]
.asObjectOrNull()
?.get("sessionDefaults")
.asObjectOrNull()
mainSessionKey = sessionDefaults?.get("mainSessionKey").asStringOrNull()
onConnected(
GatewayHelloSummary(
serverName = serverName,
remoteAddress = remoteAddress,
serverVersion = serverVersion,
mainSessionKey = mainSessionKey,
updateAvailable = parseUpdateAvailable(snapshot?.get("updateAvailable").asObjectOrNull()),
),
)
}
private fun parseUpdateAvailable(value: JsonObject?): GatewayUpdateAvailableSummary? {
if (value == null) return null
val latestVersion = value["latestVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val currentVersion = value["currentVersion"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
val channel = value["channel"].asStringOrNull()?.trim()?.takeIf { it.isNotEmpty() }
return GatewayUpdateAvailableSummary(
currentVersion = currentVersion,
latestVersion = latestVersion,
channel = channel,
)
onConnected(serverName, remoteAddress, mainSessionKey)
}
private fun buildConnectParams(
@@ -847,7 +736,6 @@ class GatewaySession(
code = it["code"].asStringOrNull(),
canRetryWithDeviceToken = it["canRetryWithDeviceToken"].asBooleanOrNull() == true,
recommendedNextStep = it["recommendedNextStep"].asStringOrNull(),
pauseReconnect = it["pauseReconnect"].asBooleanOrNull(),
reason = it["reason"].asStringOrNull(),
)
}
@@ -1015,11 +903,9 @@ class GatewaySession(
conn.connect()
conn.awaitClose()
} finally {
if (currentConnection === conn) {
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
currentConnection = null
pluginSurfaceUrls = emptyMap()
mainSessionKey = null
}
}
@@ -1154,17 +1040,20 @@ class GatewaySession(
detailCode == "AUTH_TOKEN_MISMATCH"
}
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean {
val target = desired
return shouldPauseGatewayReconnectAfterAuthFailure(
error = error,
hasBootstrapToken = target?.bootstrapToken?.trim()?.isNotEmpty() == true,
role = target?.options?.role,
scopes = target?.options?.scopes ?: emptyList(),
deviceTokenRetryBudgetUsed = deviceTokenRetryBudgetUsed,
pendingDeviceTokenRetry = pendingDeviceTokenRetry,
)
}
private fun shouldPauseReconnectAfterAuthFailure(error: ErrorShape): Boolean =
when (error.details?.code) {
"AUTH_TOKEN_MISSING",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
"AUTH_PASSWORD_MISMATCH",
"AUTH_RATE_LIMITED",
"PAIRING_REQUIRED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false
}
private fun shouldClearStoredDeviceTokenAfterRetry(error: ErrorShape): Boolean = error.details?.code == "AUTH_DEVICE_TOKEN_MISMATCH"
@@ -1179,38 +1068,6 @@ class GatewaySession(
}
}
internal fun shouldPauseGatewayReconnectAfterAuthFailure(
error: GatewaySession.ErrorShape,
hasBootstrapToken: Boolean,
role: String?,
scopes: List<String>,
deviceTokenRetryBudgetUsed: Boolean,
pendingDeviceTokenRetry: Boolean,
): Boolean =
when (error.details?.code) {
"AUTH_TOKEN_MISSING",
"AUTH_BOOTSTRAP_TOKEN_INVALID",
"AUTH_PASSWORD_MISSING",
"AUTH_PASSWORD_MISMATCH",
"AUTH_RATE_LIMITED",
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED",
"DEVICE_IDENTITY_REQUIRED",
-> true
"PAIRING_REQUIRED" ->
!(
hasBootstrapToken &&
role?.trim() == "node" &&
scopes.isEmpty() &&
error.details.reason == "not-paired" &&
(
error.details.pauseReconnect == false ||
error.details.recommendedNextStep == "wait_then_retry"
)
)
"AUTH_TOKEN_MISMATCH" -> deviceTokenRetryBudgetUsed && !pendingDeviceTokenRetry
else -> false
}
internal fun buildGatewayWebSocketUrl(
host: String,
port: Int,

View File

@@ -53,10 +53,7 @@ fun buildGatewayTlsConfig(
onStore: ((String) -> Unit)? = null,
): GatewayTlsConfig? {
if (params == null) return null
val expected =
params.expectedFingerprint
?.let(::normalizeGatewayTlsFingerprint)
?.takeIf { it.isNotBlank() }
val expected = params.expectedFingerprint?.let(::normalizeFingerprint)
val defaultTrust = defaultTrustManager()
@SuppressLint("CustomX509TrustManager")
@@ -203,7 +200,7 @@ private fun sha256Hex(data: ByteArray): String {
return out.toString()
}
fun normalizeGatewayTlsFingerprint(raw: String): String {
private fun normalizeFingerprint(raw: String): String {
val stripped =
raw
.trim()

View File

@@ -22,7 +22,6 @@ class ConnectionManager(
private val readSmsAvailable: () -> Boolean,
private val smsSearchPossible: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val hasRecordAudioPermission: () -> Boolean,
private val manualTls: () -> Boolean,
) {
@@ -97,7 +96,6 @@ class ConnectionManager(
readSmsAvailable = readSmsAvailable(),
smsSearchPossible = smsSearchPossible(),
callLogAvailable = callLogAvailable(),
photosAvailable = photosAvailable(),
voiceWakeEnabled = voiceWakeMode() != VoiceWakeMode.Off && hasRecordAudioPermission(),
motionActivityAvailable = motionActivityAvailable(),
motionPedometerAvailable = motionPedometerAvailable(),
@@ -162,15 +160,7 @@ class ConnectionManager(
fun buildOperatorConnectOptions(): GatewayConnectOptions =
GatewayConnectOptions(
role = "operator",
// QR bootstrap hands Android a bounded operator token that includes approvals; keep the
// default operator reconnect request aligned so the post-bootstrap loop can approve work.
scopes =
listOf(
"operator.approvals",
"operator.pairing",
"operator.read",
"operator.write",
),
scopes = listOf("operator.read", "operator.write", "operator.talk.secrets"),
caps = emptyList(),
commands = emptyList(),
permissions = emptyMap(),

View File

@@ -28,7 +28,6 @@ class DeviceHandler(
private val appContext: Context,
private val smsEnabled: Boolean = SensitiveFeatureConfig.smsEnabled,
private val callLogEnabled: Boolean = SensitiveFeatureConfig.callLogEnabled,
private val photosEnabled: Boolean = SensitiveFeatureConfig.photosEnabled,
) {
companion object {
internal fun hasAnySmsCapability(
@@ -151,9 +150,7 @@ class DeviceHandler(
val smsReadGranted = hasPermission(Manifest.permission.READ_SMS)
val notificationAccess = DeviceNotificationListenerService.isAccessEnabled(appContext)
val photosGranted =
if (!photosEnabled) {
false
} else if (Build.VERSION.SDK_INT >= 33) {
if (Build.VERSION.SDK_INT >= 33) {
hasPermission(Manifest.permission.READ_MEDIA_IMAGES)
} else {
hasPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
@@ -251,7 +248,7 @@ class DeviceHandler(
"photos",
permissionStateJson(
granted = photosGranted,
promptableWhenDenied = photosEnabled,
promptableWhenDenied = true,
),
)
put(

View File

@@ -23,7 +23,6 @@ data class NodeRuntimeFlags(
val readSmsAvailable: Boolean,
val smsSearchPossible: Boolean,
val callLogAvailable: Boolean,
val photosAvailable: Boolean,
val voiceWakeEnabled: Boolean,
val motionActivityAvailable: Boolean,
val motionPedometerAvailable: Boolean,
@@ -38,7 +37,6 @@ enum class InvokeCommandAvailability {
ReadSmsAvailable,
RequestableSmsSearchAvailable,
CallLogAvailable,
PhotosAvailable,
MotionActivityAvailable,
MotionPedometerAvailable,
DebugBuild,
@@ -50,7 +48,6 @@ enum class NodeCapabilityAvailability {
LocationEnabled,
SmsAvailable,
CallLogAvailable,
PhotosAvailable,
VoiceWakeEnabled,
MotionAvailable,
}
@@ -90,10 +87,7 @@ object InvokeCommandRegistry {
name = OpenClawCapability.Location.rawValue,
availability = NodeCapabilityAvailability.LocationEnabled,
),
NodeCapabilitySpec(
name = OpenClawCapability.Photos.rawValue,
availability = NodeCapabilityAvailability.PhotosAvailable,
),
NodeCapabilitySpec(name = OpenClawCapability.Photos.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Contacts.rawValue),
NodeCapabilitySpec(name = OpenClawCapability.Calendar.rawValue),
NodeCapabilitySpec(
@@ -194,7 +188,6 @@ object InvokeCommandRegistry {
),
InvokeCommandSpec(
name = OpenClawPhotosCommand.Latest.rawValue,
availability = InvokeCommandAvailability.PhotosAvailable,
),
InvokeCommandSpec(
name = OpenClawContactsCommand.Search.rawValue,
@@ -251,7 +244,6 @@ object InvokeCommandRegistry {
NodeCapabilityAvailability.LocationEnabled -> flags.locationEnabled
NodeCapabilityAvailability.SmsAvailable -> flags.sendSmsAvailable || flags.readSmsAvailable
NodeCapabilityAvailability.CallLogAvailable -> flags.callLogAvailable
NodeCapabilityAvailability.PhotosAvailable -> flags.photosAvailable
NodeCapabilityAvailability.VoiceWakeEnabled -> flags.voiceWakeEnabled
NodeCapabilityAvailability.MotionAvailable -> flags.motionActivityAvailable || flags.motionPedometerAvailable
}
@@ -268,7 +260,6 @@ object InvokeCommandRegistry {
InvokeCommandAvailability.ReadSmsAvailable -> flags.readSmsAvailable
InvokeCommandAvailability.RequestableSmsSearchAvailable -> flags.smsSearchPossible
InvokeCommandAvailability.CallLogAvailable -> flags.callLogAvailable
InvokeCommandAvailability.PhotosAvailable -> flags.photosAvailable
InvokeCommandAvailability.MotionActivityAvailable -> flags.motionActivityAvailable
InvokeCommandAvailability.MotionPedometerAvailable -> flags.motionPedometerAvailable
InvokeCommandAvailability.DebugBuild -> flags.debugBuild

View File

@@ -77,7 +77,6 @@ class InvokeDispatcher(
private val smsFeatureEnabled: () -> Boolean,
private val smsTelephonyAvailable: () -> Boolean,
private val callLogAvailable: () -> Boolean,
private val photosAvailable: () -> Boolean,
private val debugBuild: () -> Boolean,
private val onCanvasA2uiPush: () -> Unit,
private val onCanvasA2uiReset: () -> Unit,
@@ -326,15 +325,6 @@ class InvokeDispatcher(
message = "CALL_LOG_UNAVAILABLE: call log not available on this build",
)
}
InvokeCommandAvailability.PhotosAvailable ->
if (photosAvailable()) {
null
} else {
GatewaySession.InvokeResult.error(
code = "PHOTOS_UNAVAILABLE",
message = "PHOTOS_UNAVAILABLE: photos not available on this build",
)
}
InvokeCommandAvailability.DebugBuild ->
if (debugBuild()) {
null

View File

@@ -27,23 +27,28 @@ internal object JpegSizeLimiter {
require(initialWidth > 0 && initialHeight > 0) { "Invalid image size" }
require(maxBytes > 0) { "Invalid maxBytes" }
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var width = initialWidth
var height = initialHeight
var best: JpegSizeLimiterResult? = null
val clampedStartQuality = startQuality.coerceIn(minQuality, 100)
var best =
JpegSizeLimiterResult(
bytes = encode(width, height, clampedStartQuality),
width = width,
height = height,
quality = clampedStartQuality,
)
if (best.bytes.size <= maxBytes) return best
repeat(maxScaleAttempts + 1) { scaleAttempt ->
repeat(maxScaleAttempts) {
var quality = clampedStartQuality
repeat(maxQualityAttempts) {
val bytes = encode(width, height, quality)
val attempt = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
best = attempt
best = JpegSizeLimiterResult(bytes = bytes, width = width, height = height, quality = quality)
if (bytes.size <= maxBytes) return best
if (quality <= minQuality) return@repeat
quality = max(minQuality, (quality * 0.75).roundToInt())
}
if (scaleAttempt == maxScaleAttempts) return@repeat
val minScale = (minSize.toDouble() / min(width, height).toDouble()).coerceAtMost(1.0)
val nextScale = max(scaleStep, minScale)
val nextWidth = max(minSize, (width * nextScale).roundToInt())
@@ -53,11 +58,10 @@ internal object JpegSizeLimiter {
height = min(nextHeight, height)
}
val failed = checkNotNull(best)
if (failed.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${failed.bytes.size} bytes > $maxBytes bytes")
if (best.bytes.size > maxBytes) {
throw IllegalStateException("CAMERA_TOO_LARGE: ${best.bytes.size} bytes > $maxBytes bytes")
}
return failed
return best
}
}

View File

@@ -1,139 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawPrimaryButton
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ScreenShare
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
internal fun CanvasSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val currentUrl by viewModel.canvasCurrentUrl.collectAsState()
val hydrated by viewModel.canvasA2uiHydrated.collectAsState()
val rehydratePending by viewModel.canvasRehydratePending.collectAsState()
val rehydrateErrorText by viewModel.canvasRehydrateErrorText.collectAsState()
val hasLivePage = currentUrl?.isNotBlank() == true
val showCanvasSurface = isConnected
val canvasLabel = if (hasLivePage) "Live page" else "Home canvas"
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshHomeCanvasOverviewIfConnected()
}
}
SettingsDetailFrame(
title = "Canvas",
subtitle = "Current screen output and interactive app surface.",
icon = Icons.AutoMirrored.Filled.ScreenShare,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Connection", if (isConnected) "Online" else "Offline"),
SettingsMetric("Surface", canvasLabel),
SettingsMetric("Bridge", if (hasLivePage && hydrated) "Ready" else "Standby"),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawPrimaryButton(
text = if (rehydratePending) "Refreshing" else "Refresh Screen",
onClick = { viewModel.requestCanvasRehydrate(source = "settings_canvas") },
enabled = isConnected && !rehydratePending,
modifier = Modifier.weight(1f),
)
ClawSecondaryButton(
text = "Reconnect",
onClick = viewModel::refreshGatewayConnection,
modifier = Modifier.weight(1f),
)
}
rehydrateErrorText?.let {
ClawPanel {
Text(text = it, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 8.dp)) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(text = canvasLabel, style = ClawTheme.type.section, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Surface(
modifier = Modifier.fillMaxWidth().height(520.dp).clip(RoundedCornerShape(ClawTheme.radii.panel)),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.canvas,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box {
if (showCanvasSurface) {
CanvasScreen(viewModel = viewModel, visible = true, modifier = Modifier.fillMaxWidth().height(520.dp))
} else {
CanvasStandbyPanel(isConnected = isConnected)
}
}
}
}
}
}
}
@Composable
private fun CanvasStandbyPanel(isConnected: Boolean) {
Column(
modifier = Modifier.fillMaxWidth().height(520.dp).padding(horizontal = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Surface(
modifier = Modifier.size(54.dp),
shape = RoundedCornerShape(ClawTheme.radii.panel),
color = ClawTheme.colors.surfacePressed,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
contentColor = ClawTheme.colors.text,
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.AutoMirrored.Filled.ScreenShare, contentDescription = null, modifier = Modifier.size(26.dp))
}
}
Text(
text = if (isConnected) "Screen surface ready" else "Connect the gateway",
style = ClawTheme.type.title,
color = ClawTheme.colors.text,
modifier = Modifier.padding(top = 18.dp),
)
Text(
text = if (isConnected) "Canvas output appears here when OpenClaw opens an app surface." else "Canvas output needs an active gateway connection.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
modifier = Modifier.padding(top = 6.dp),
)
}
}

View File

@@ -1,159 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayChannelSummary
import ai.openclaw.app.GatewayChannelsSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawDetailRow
import ai.openclaw.app.ui.design.ClawListPanel
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawSecondaryButton
import ai.openclaw.app.ui.design.ClawStatus
import ai.openclaw.app.ui.design.ClawStatusPill
import ai.openclaw.app.ui.design.ClawTextBadge
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
internal fun ChannelsSettingsScreen(
viewModel: MainViewModel,
onBack: () -> Unit,
) {
val summary by viewModel.channelsSummary.collectAsState()
val refreshing by viewModel.channelsRefreshing.collectAsState()
val errorText by viewModel.channelsErrorText.collectAsState()
val isConnected by viewModel.isConnected.collectAsState()
val channels = summary.channels
LaunchedEffect(isConnected) {
if (isConnected) {
viewModel.refreshChannels()
}
}
SettingsDetailFrame(
title = "Channels",
subtitle = "Messaging surfaces connected to this gateway.",
icon = Icons.Default.Notifications,
onBack = onBack,
) {
SettingsMetricPanel(
rows =
listOf(
SettingsMetric("Channels", channels.size.toString()),
SettingsMetric("Connected", channels.count { it.connected }.toString()),
SettingsMetric("Configured", channels.count { it.configured }.toString()),
SettingsMetric("Issues", channels.count { it.error != null }.toString()),
),
)
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
ClawSecondaryButton(
text = if (refreshing) "Refreshing" else "Refresh",
onClick = viewModel::refreshChannels,
enabled = isConnected && !refreshing,
modifier = Modifier.weight(1f),
)
}
errorText?.let { error ->
ClawPanel {
Text(text = error, style = ClawTheme.type.body, color = ClawTheme.colors.warning)
}
}
if (summary.partial || summary.warnings.isNotEmpty()) {
ClawPanel {
Text(text = channelsWarningText(summary), style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
when {
!isConnected ->
ClawPanel {
Text(text = "Connect the gateway to load channels.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
channels.isEmpty() ->
ClawPanel {
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
Text(text = "No channels found.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
Text(text = "Telegram, WhatsApp, email, and other channels appear here after setup.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
}
}
else -> ChannelsPanel(channels = channels)
}
}
}
@Composable
private fun ChannelsPanel(channels: List<GatewayChannelSummary>) {
ClawListPanel(items = channels) { channel ->
ChannelRow(channel = channel)
}
}
@Composable
private fun ChannelRow(channel: GatewayChannelSummary) {
ClawDetailRow(
title = channel.label,
subtitle = channelSubtitle(channel),
leading = { ClawTextBadge(text = channelBadge(channel.label)) },
trailing = { ClawStatusPill(text = channelStatusText(channel), status = channelStatus(channel)) },
)
}
private fun channelSubtitle(channel: GatewayChannelSummary): String {
val accounts =
when (channel.accountCount) {
0 -> null
1 -> "1 account"
else -> "${channel.accountCount} accounts"
}
val lifecycle =
when {
channel.connected -> "Connected"
channel.running -> "Running"
channel.linked -> "Linked"
channel.configured -> "Configured"
channel.enabled -> "Enabled"
else -> "Off"
}
return listOfNotNull(accounts, lifecycle, channel.error).joinToString(" · ")
}
private fun channelStatusText(channel: GatewayChannelSummary): String =
when {
channel.error != null -> "Issue"
channel.connected -> "Connected"
channel.running -> "Running"
channel.linked || channel.configured -> "Ready"
channel.enabled -> "Setup"
else -> "Off"
}
private fun channelStatus(channel: GatewayChannelSummary): ClawStatus =
when {
channel.error != null -> ClawStatus.Danger
channel.connected || channel.running -> ClawStatus.Success
channel.linked || channel.configured -> ClawStatus.Neutral
channel.enabled -> ClawStatus.Warning
else -> ClawStatus.Neutral
}
private fun channelBadge(label: String): String =
label
.split(' ', '-', '_')
.filter { it.isNotBlank() }
.take(2)
.mapNotNull { it.firstOrNull()?.uppercaseChar()?.toString() }
.joinToString("")
.ifBlank { "C" }
private fun channelsWarningText(summary: GatewayChannelsSummary): String = summary.warnings.firstOrNull()?.takeIf { it.isNotBlank() } ?: "Some channel status checks did not complete."

View File

@@ -1,320 +0,0 @@
package ai.openclaw.app.ui
import ai.openclaw.app.GatewayModelProviderSummary
import ai.openclaw.app.GatewayModelSummary
import ai.openclaw.app.MainViewModel
import ai.openclaw.app.ui.design.ClawEmptyState
import ai.openclaw.app.ui.design.ClawPanel
import ai.openclaw.app.ui.design.ClawScaffold
import ai.openclaw.app.ui.design.ClawSeparatedColumn
import ai.openclaw.app.ui.design.ClawTextField
import ai.openclaw.app.ui.design.ClawTheme
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.outlined.AccessTime
import androidx.compose.material.icons.outlined.ChatBubbleOutline
import androidx.compose.material.icons.outlined.Inventory2
import androidx.compose.material.icons.outlined.MicNone
import androidx.compose.material.icons.outlined.Settings
import androidx.compose.material3.Icon
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@Composable
internal fun CommandPalette(
viewModel: MainViewModel,
onDismiss: () -> Unit,
onOpenChat: () -> Unit,
onOpenVoice: () -> Unit,
onOpenSessions: () -> Unit,
onOpenProviders: () -> Unit,
onOpenSettings: () -> Unit,
onOpenSession: (String) -> Unit,
) {
val isConnected by viewModel.isConnected.collectAsState()
val sessions by viewModel.chatSessions.collectAsState()
val models by viewModel.modelCatalog.collectAsState()
val providers by viewModel.modelAuthProviders.collectAsState()
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
var query by rememberSaveable { mutableStateOf("") }
val normalizedQuery = query.trim().lowercase()
val quickActions =
listOf(
CommandItem("Open Chat", "Start or continue a conversation", Icons.Outlined.ChatBubbleOutline, onOpenChat),
CommandItem("Start Voice", "Talk or dictate with OpenClaw", Icons.Outlined.MicNone, onOpenVoice),
CommandItem("Browse Sessions", "Find previous conversations", Icons.Outlined.AccessTime, onOpenSessions),
CommandItem("Providers & Models", providerCommandSubtitle(isConnected, providers, models), Icons.Outlined.Inventory2, onOpenProviders),
CommandItem("Settings", "Gateway, voice, notifications, privacy", Icons.Outlined.Settings, onOpenSettings),
)
val actionRows = quickActions.filter { it.matches(normalizedQuery) }
val sessionRows =
sessions
.filter { session ->
val title = commandSessionTitle(session.displayName)
normalizedQuery.isEmpty() || title.lowercase().contains(normalizedQuery)
}.take(5)
Surface(modifier = Modifier.fillMaxSize(), color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
CommandIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Close search", onClick = onDismiss)
Text(text = "Search", style = ClawTheme.type.title, color = ClawTheme.colors.text, modifier = Modifier.weight(1f), textAlign = TextAlign.Center)
CommandAvatar(text = "OC")
}
}
item {
ClawTextField(value = query, onValueChange = { query = it }, placeholder = "Search OpenClaw")
}
item {
CommandSectionLabel(title = "Quick actions")
}
if (actionRows.isEmpty()) {
item {
ClawEmptyState(title = "No actions found", body = "Try Chat, Voice, Sessions, Providers, or Settings.")
}
} else {
item {
CommandActionList(rows = actionRows)
}
}
item {
CommandSectionLabel(title = "Sessions")
}
if (sessionRows.isEmpty()) {
item {
ClawPanel {
Text(
text = if (isConnected) "No matching sessions yet." else "Connect the Gateway to search sessions.",
style = ClawTheme.type.body,
color = ClawTheme.colors.textMuted,
)
}
}
} else {
item {
CommandSessionList(
rows =
sessionRows.map { session ->
CommandSessionRow(
key = session.key,
title = commandSessionTitle(session.displayName),
subtitle = if (pendingRunCount > 0) "Assistant working" else "OpenClaw session",
metadata = session.updatedAtMs?.let(::commandRelativeTime) ?: "now",
)
},
onOpen = onOpenSession,
)
}
}
}
}
}
}
private data class CommandItem(
val title: String,
val subtitle: String,
val icon: ImageVector,
val onClick: () -> Unit,
) {
fun matches(query: String): Boolean = query.isEmpty() || title.lowercase().contains(query) || subtitle.lowercase().contains(query)
}
private data class CommandSessionRow(
val key: String,
val title: String,
val subtitle: String,
val metadata: String,
)
@Composable
private fun CommandActionList(rows: List<CommandItem>) {
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
ClawSeparatedColumn(items = rows) { row ->
CommandActionRow(row = row)
}
}
}
@Composable
private fun CommandActionRow(row: CommandItem) {
Surface(color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 52.dp)
.clip(RoundedCornerShape(ClawTheme.radii.row))
.clickable(onClick = row.onClick)
.padding(horizontal = 2.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(19.dp), tint = ClawTheme.colors.text)
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Open ${row.title}",
modifier = Modifier.size(17.dp),
tint = ClawTheme.colors.textMuted,
)
}
}
}
@Composable
private fun CommandSessionList(
rows: List<CommandSessionRow>,
onOpen: (String) -> Unit,
) {
ClawPanel(contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) {
ClawSeparatedColumn(items = rows) { row ->
CommandSessionListRow(row = row, onClick = { onOpen(row.key) })
}
}
}
@Composable
private fun CommandSessionListRow(
row: CommandSessionRow,
onClick: () -> Unit,
) {
Surface(color = ClawTheme.colors.canvas, contentColor = ClawTheme.colors.text) {
Row(
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 58.dp)
.clip(RoundedCornerShape(ClawTheme.radii.row))
.clickable(onClick = onClick)
.padding(horizontal = 2.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Surface(
modifier = Modifier.size(30.dp),
shape = CircleShape,
color = ClawTheme.colors.canvas,
border = BorderStroke(1.dp, ClawTheme.colors.borderStrong),
) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(15.dp), tint = ClawTheme.colors.text)
}
}
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
Text(text = row.title, style = ClawTheme.type.body, color = ClawTheme.colors.text, maxLines = 1)
Text(text = row.subtitle, style = ClawTheme.type.caption, color = ClawTheme.colors.textSubtle, maxLines = 1)
}
Text(text = row.metadata, style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
Icon(
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = "Open session",
modifier = Modifier.size(17.dp),
tint = ClawTheme.colors.textMuted,
)
}
}
}
@Composable
private fun CommandIconButton(
icon: ImageVector,
contentDescription: String,
onClick: () -> Unit,
) {
Surface(onClick = onClick, modifier = Modifier.size(ClawTheme.spacing.touchTarget), shape = CircleShape, color = Color.Transparent, contentColor = ClawTheme.colors.text) {
Box(contentAlignment = Alignment.Center) {
Icon(imageVector = icon, contentDescription = contentDescription, modifier = Modifier.size(18.dp))
}
}
}
@Composable
private fun CommandAvatar(text: String) {
Surface(
modifier = Modifier.size(34.dp),
shape = CircleShape,
color = ClawTheme.colors.surfaceRaised,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = text.take(2).uppercase(), style = ClawTheme.type.label)
}
}
}
@Composable
private fun CommandSectionLabel(title: String) {
Row(modifier = Modifier.fillMaxWidth()) {
Text(text = title.uppercase(), style = ClawTheme.type.caption, color = ClawTheme.colors.textMuted)
}
}
private fun providerCommandSubtitle(
isConnected: Boolean,
providers: List<GatewayModelProviderSummary>,
models: List<GatewayModelSummary>,
): String {
if (!isConnected) return "Connect Gateway to load models"
val readyProviderCount = providers.count { modelProviderReady(it.status) }
if (readyProviderCount > 0) return "$readyProviderCount providers ready"
if (models.isNotEmpty()) return "${models.size} models available"
return "Configure model access"
}
private fun commandSessionTitle(displayName: String?): String = displayName?.takeIf { it.isNotBlank() } ?: "Main session"
private fun commandRelativeTime(updatedAtMs: Long): String {
val deltaMs = (System.currentTimeMillis() - updatedAtMs).coerceAtLeast(0L)
val minutes = deltaMs / 60_000L
if (minutes < 1) return "now"
if (minutes < 60) return "${minutes}m"
val hours = minutes / 60
if (hours < 24) return "${hours}h"
return "${hours / 24}d"
}

View File

@@ -100,14 +100,8 @@ fun ConnectTabScreen(viewModel: MainViewModel) {
containerColor = mobileCardSurface,
title = { Text("Trust this gateway?", style = mobileHeadline, color = mobileText) },
text = {
val message =
if (prompt.previousFingerprintSha256.isNullOrBlank()) {
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}"
} else {
"The gateway TLS certificate changed. Only continue if you expected this.\n\nOld SHA-256 fingerprint:\n${prompt.previousFingerprintSha256}\n\nNew SHA-256 fingerprint:\n${prompt.fingerprintSha256}"
}
Text(
message,
"First-time TLS connection.\n\nVerify this SHA-256 fingerprint before trusting:\n${prompt.fingerprintSha256}",
style = mobileCallout,
color = mobileText,
)

Some files were not shown because too many files have changed in this diff Show More