Compare commits
51 Commits
v2026.2.6-
...
dev/ci
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1041cf773 | ||
|
|
ae5afafd75 | ||
|
|
51ecca3828 | ||
|
|
6f2f7894c9 | ||
|
|
b4b7712773 | ||
|
|
8c811194e3 | ||
|
|
d3518eaf9f | ||
|
|
716f6ae376 | ||
|
|
e200182043 | ||
|
|
3e8c8916cc | ||
|
|
b43af44dc6 | ||
|
|
b351ae1d8c | ||
|
|
e8e55c24ef | ||
|
|
845eafaacb | ||
|
|
4f07791455 | ||
|
|
45789653c0 | ||
|
|
0003cd969e | ||
|
|
6035bbcd2c | ||
|
|
a4d5c7f673 | ||
|
|
9a3f62cb86 | ||
|
|
1007d71f0c | ||
|
|
9f703a44dc | ||
|
|
85ed6c7fa4 | ||
|
|
ad4dd0422e | ||
|
|
4ba9809f18 | ||
|
|
80d42eb0ba | ||
|
|
2b6cf03b47 | ||
|
|
88ffad1c4f | ||
|
|
875324e7c7 | ||
|
|
2d7428a7f2 | ||
|
|
47596257ea | ||
|
|
ab3045cb48 | ||
|
|
43c0a7fe1c | ||
|
|
0b51f0d762 | ||
|
|
7a9deb2400 | ||
|
|
3997316fb0 | ||
|
|
360851366f | ||
|
|
7af00f040a | ||
|
|
4d30f97407 | ||
|
|
ff948a6dd7 | ||
|
|
ad759c9446 | ||
|
|
9ccbd57016 | ||
|
|
52c9d3480f | ||
|
|
517a8eafe5 | ||
|
|
c8e67ad5d5 | ||
|
|
fb5280e1b5 | ||
|
|
009abd306a | ||
|
|
8c53dfb74f | ||
|
|
7bf4080608 | ||
|
|
1de05ad068 | ||
|
|
30ac80b96b |
69
.github/actions/discord-notify/action.yml
vendored
Normal file
69
.github/actions/discord-notify/action.yml
vendored
Normal file
@@ -0,0 +1,69 @@
|
||||
name: Discord Notify
|
||||
description: Send notifications to Discord webhook
|
||||
|
||||
inputs:
|
||||
webhook_url:
|
||||
description: Discord webhook URL
|
||||
required: true
|
||||
title:
|
||||
description: Notification title
|
||||
required: true
|
||||
description:
|
||||
description: Notification description
|
||||
required: true
|
||||
color:
|
||||
description: Embed color (decimal)
|
||||
required: false
|
||||
default: "3447003"
|
||||
username:
|
||||
description: Bot username
|
||||
required: false
|
||||
default: "OpenClaw CI"
|
||||
avatar_url:
|
||||
description: Bot avatar URL
|
||||
required: false
|
||||
default: "https://avatars.githubusercontent.com/u/182880377"
|
||||
timestamp:
|
||||
description: Include timestamp
|
||||
required: false
|
||||
default: "true"
|
||||
fields:
|
||||
description: JSON array of embed fields
|
||||
required: false
|
||||
default: "[]"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Send Discord notification
|
||||
shell: bash
|
||||
run: |
|
||||
TIMESTAMP=""
|
||||
if [ "${{ inputs.timestamp }}" = "true" ]; then
|
||||
TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
fi
|
||||
|
||||
# Build JSON payload with jq to handle escaping properly
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg username "${{ inputs.username }}" \
|
||||
--arg avatar_url "${{ inputs.avatar_url }}" \
|
||||
--arg title "${{ inputs.title }}" \
|
||||
--arg description "${{ inputs.description }}" \
|
||||
--argjson color "${{ inputs.color }}" \
|
||||
--argjson fields '${{ inputs.fields }}' \
|
||||
--arg timestamp "$TIMESTAMP" \
|
||||
--argjson add_timestamp "${{ inputs.timestamp == 'true' }}" \
|
||||
'{
|
||||
username: $username,
|
||||
avatar_url: $avatar_url,
|
||||
embeds: [{
|
||||
title: $title,
|
||||
description: $description,
|
||||
color: $color,
|
||||
fields: $fields
|
||||
} + (if $add_timestamp then {timestamp: $timestamp} else {} end)]
|
||||
}')
|
||||
|
||||
curl -sS -H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD" \
|
||||
"${{ inputs.webhook_url }}"
|
||||
142
.github/workflows/ci.yml
vendored
142
.github/workflows/ci.yml
vendored
@@ -1,8 +1,19 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
workflow_call:
|
||||
# Called by testing-strategy.yml for staged releases
|
||||
inputs:
|
||||
test_stage:
|
||||
description: "Testing stage: develop, alpha, beta, or stable. Controls which platform tests run."
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
install-check:
|
||||
@@ -182,10 +193,16 @@ jobs:
|
||||
fi
|
||||
|
||||
checks-windows:
|
||||
# Windows tests: beta+ staging only (not on regular PRs to save compute)
|
||||
if: |
|
||||
inputs.test_stage == 'beta' ||
|
||||
inputs.test_stage == 'stable'
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
CLAWDBOT_TEST_WORKERS: 1
|
||||
# Keep total concurrency predictable on the 4 vCPU runner:
|
||||
# `scripts/test-parallel.mjs` runs some vitest suites in parallel processes.
|
||||
OPENCLAW_TEST_WORKERS: 2
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -208,6 +225,25 @@ jobs:
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Try to exclude workspace from Windows Defender (best-effort)
|
||||
shell: pwsh
|
||||
run: |
|
||||
$cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue
|
||||
if (-not $cmd) {
|
||||
Write-Host "Add-MpPreference not available, skipping Defender exclusions."
|
||||
exit 0
|
||||
}
|
||||
|
||||
try {
|
||||
# Defender sometimes intercepts process spawning (vitest workers). If this fails
|
||||
# (eg hardened images), keep going and rely on worker limiting above.
|
||||
Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop
|
||||
Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop
|
||||
Write-Host "Defender exclusions applied."
|
||||
} catch {
|
||||
Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -270,14 +306,9 @@ jobs:
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
checks-macos:
|
||||
if: github.event_name == 'pull_request'
|
||||
# macOS tests: stable staging only (not on regular PRs to save compute)
|
||||
if: inputs.test_stage == 'stable'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: test
|
||||
command: pnpm test
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -297,6 +328,7 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
|
||||
# --- Node/pnpm setup (for TS tests) ---
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -336,71 +368,20 @@ jobs:
|
||||
pnpm -v
|
||||
pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
# --- Run all checks sequentially (fast gates first) ---
|
||||
- name: TS tests (macOS)
|
||||
env:
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
run: ${{ matrix.command }}
|
||||
|
||||
macos-app:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: macos-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- task: lint
|
||||
command: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
- task: build
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
- task: test
|
||||
command: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
run: pnpm test
|
||||
|
||||
# --- Xcode/Swift setup ---
|
||||
- name: Select Xcode 26.1
|
||||
run: |
|
||||
sudo xcode-select -s /Applications/Xcode_26.1.app
|
||||
xcodebuild -version
|
||||
|
||||
- name: Install XcodeGen / SwiftLint / SwiftFormat
|
||||
run: |
|
||||
brew install xcodegen swiftlint swiftformat
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Show toolchain
|
||||
run: |
|
||||
@@ -408,8 +389,35 @@ jobs:
|
||||
xcodebuild -version
|
||||
swift --version
|
||||
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: ${{ matrix.command }}
|
||||
- name: Swift lint
|
||||
run: |
|
||||
swiftlint --config .swiftlint.yml
|
||||
swiftformat --lint apps/macos/Sources --config .swiftformat
|
||||
|
||||
- name: Swift build (release)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift build --package-path apps/macos --configuration release; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift build failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Swift test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in 1 2 3; do
|
||||
if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then
|
||||
exit 0
|
||||
fi
|
||||
echo "swift test failed (attempt $attempt/3). Retrying…"
|
||||
sleep $((attempt * 20))
|
||||
done
|
||||
exit 1
|
||||
|
||||
ios:
|
||||
if: false # ignore iOS in CI for now
|
||||
runs-on: macos-latest
|
||||
|
||||
349
.github/workflows/deployment-strategy.yml
vendored
Normal file
349
.github/workflows/deployment-strategy.yml
vendored
Normal file
@@ -0,0 +1,349 @@
|
||||
name: Deployment Strategy
|
||||
|
||||
# Reusable deployment workflow for staged releases
|
||||
#
|
||||
# Deployment targets by stage:
|
||||
# - alpha: npm @alpha tag only
|
||||
# - beta: npm @beta tag + Docker (ghcr.io) beta tag
|
||||
# - stable: npm @latest + Docker latest + multi-arch manifest
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
deployment_stage:
|
||||
description: "Deployment stage: alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
app_version:
|
||||
description: "Version of the application to deploy"
|
||||
required: true
|
||||
type: string
|
||||
source_branch:
|
||||
description: "Source branch for deployment"
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
deployment_status:
|
||||
description: "Status of the deployment"
|
||||
value: ${{ jobs.deploy-summary.outputs.status }}
|
||||
npm_url:
|
||||
description: "npm package URL"
|
||||
value: ${{ jobs.deploy-summary.outputs.npm_url }}
|
||||
docker_url:
|
||||
description: "Docker image URL"
|
||||
value: ${{ jobs.deploy-summary.outputs.docker_url }}
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
DISCORD_WEBHOOK_URL:
|
||||
required: false
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# npm publish (all stages)
|
||||
npm-publish:
|
||||
name: npm Publish (${{ inputs.deployment_stage }})
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
outputs:
|
||||
status: ${{ steps.publish.outputs.status }}
|
||||
npm_url: ${{ steps.publish.outputs.npm_url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_branch }}
|
||||
submodules: false
|
||||
|
||||
- name: Checkout submodules (retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git submodule sync --recursive
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if git -c protocol.version=2 submodule update --init --force --depth=1 --recursive; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Submodule update failed (attempt $attempt/5). Retrying…"
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Determine npm tag
|
||||
id: npm-tag
|
||||
run: |
|
||||
case "${{ inputs.deployment_stage }}" in
|
||||
alpha)
|
||||
echo "tag=alpha" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
beta)
|
||||
echo "tag=beta" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
stable)
|
||||
echo "tag=latest" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: Publish to npm
|
||||
id: publish
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$NODE_AUTH_TOKEN" ]; then
|
||||
echo "NPM_TOKEN not set, skipping publish"
|
||||
echo "status=skipped" >> $GITHUB_OUTPUT
|
||||
echo "npm_url=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
NPM_TAG="${{ steps.npm-tag.outputs.tag }}"
|
||||
|
||||
if npm publish --tag "$NPM_TAG" --access public; then
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
echo "npm_url=https://www.npmjs.com/package/openclaw/v/${{ inputs.app_version }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
echo "npm_url=" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Docker build - amd64 (beta+ only)
|
||||
docker-amd64:
|
||||
name: Docker amd64 (${{ inputs.deployment_stage }})
|
||||
if: inputs.deployment_stage != 'alpha'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_branch }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.app_version }}-amd64
|
||||
type=raw,value=${{ inputs.deployment_stage }}-amd64
|
||||
|
||||
- name: Build and push amd64
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Docker build - arm64 (beta+ only)
|
||||
docker-arm64:
|
||||
name: Docker arm64 (${{ inputs.deployment_stage }})
|
||||
if: inputs.deployment_stage != 'alpha'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
digest: ${{ steps.build.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_branch }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=${{ inputs.app_version }}-arm64
|
||||
type=raw,value=${{ inputs.deployment_stage }}-arm64
|
||||
|
||||
- name: Build and push arm64
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
push: true
|
||||
|
||||
# Create multi-arch manifest (beta+ only)
|
||||
docker-manifest:
|
||||
name: Docker Manifest (${{ inputs.deployment_stage }})
|
||||
if: inputs.deployment_stage != 'alpha'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [docker-amd64, docker-arm64]
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
docker_url: ${{ steps.manifest.outputs.docker_url }}
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifest
|
||||
id: manifest
|
||||
run: |
|
||||
STAGE="${{ inputs.deployment_stage }}"
|
||||
VERSION="${{ inputs.app_version }}"
|
||||
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
|
||||
# Create version manifest
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${VERSION}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
|
||||
# Create stage manifest (beta or latest)
|
||||
if [ "$STAGE" = "stable" ]; then
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:latest" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
echo "docker_url=${IMAGE}:latest" >> $GITHUB_OUTPUT
|
||||
else
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE}:${STAGE}" \
|
||||
"${IMAGE}:${VERSION}-amd64" \
|
||||
"${IMAGE}:${VERSION}-arm64"
|
||||
echo "docker_url=${IMAGE}:${STAGE}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Deployment summary
|
||||
deploy-summary:
|
||||
name: Deployment Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [npm-publish, docker-manifest]
|
||||
if: "!cancelled()"
|
||||
outputs:
|
||||
status: ${{ steps.summary.outputs.status }}
|
||||
npm_url: ${{ steps.summary.outputs.npm_url }}
|
||||
docker_url: ${{ steps.summary.outputs.docker_url }}
|
||||
steps:
|
||||
- name: Summarize deployment
|
||||
id: summary
|
||||
run: |
|
||||
NPM_STATUS="${{ needs.npm-publish.outputs.status || 'skipped' }}"
|
||||
NPM_URL="${{ needs.npm-publish.outputs.npm_url }}"
|
||||
DOCKER_URL="${{ needs.docker-manifest.outputs.docker_url || '' }}"
|
||||
|
||||
echo "npm_url=$NPM_URL" >> $GITHUB_OUTPUT
|
||||
echo "docker_url=$DOCKER_URL" >> $GITHUB_OUTPUT
|
||||
|
||||
if [ "$NPM_STATUS" = "success" ] || [ "$NPM_STATUS" = "skipped" ]; then
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Generate summary
|
||||
echo "## Deployment Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Property | Value |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Stage | ${{ inputs.deployment_stage }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Version | ${{ inputs.app_version }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| npm | $NPM_STATUS |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Docker | ${{ needs.docker-manifest.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Discord notification
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs: deploy-summary
|
||||
if: "!cancelled()"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord success notification
|
||||
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.deploy-summary.outputs.status == 'success' }}
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "🚀 Deployed: ${{ inputs.deployment_stage }} v${{ inputs.app_version }}"
|
||||
description: |
|
||||
**npm**: ${{ needs.deploy-summary.outputs.npm_url || 'skipped' }}
|
||||
**Docker**: ${{ needs.deploy-summary.outputs.docker_url || 'skipped' }}
|
||||
color: "3066993"
|
||||
|
||||
- name: Discord failure notification
|
||||
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.deploy-summary.outputs.status != 'success' }}
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "❌ Deployment Failed: ${{ inputs.deployment_stage }}"
|
||||
description: |
|
||||
**Version**: ${{ inputs.app_version }}
|
||||
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
color: "15158332"
|
||||
94
.github/workflows/feature-pr.yml
vendored
Normal file
94
.github/workflows/feature-pr.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
name: Feature PR
|
||||
|
||||
# Auto-create PR from dev/* branches to develop
|
||||
# This is the entry point for new features into the staging pipeline
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "dev/**"
|
||||
- "feature/**"
|
||||
- "fix/**"
|
||||
|
||||
concurrency:
|
||||
group: feature-pr-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
create-pr:
|
||||
name: Create PR to develop
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure develop branch exists
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if git ls-remote --heads origin develop | grep -q develop; then
|
||||
echo "develop branch already exists"
|
||||
else
|
||||
echo "develop branch does not exist — creating from main"
|
||||
git push origin origin/main:refs/heads/develop
|
||||
fi
|
||||
|
||||
- name: Check for existing PR
|
||||
id: check-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
TARGET="develop"
|
||||
|
||||
# Check if PR already exists
|
||||
EXISTING=$(gh pr list --head "$BRANCH" --base "$TARGET" --json number --jq '.[0].number // empty')
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
|
||||
echo "PR #$EXISTING already exists for $BRANCH → $TARGET"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create PR
|
||||
if: steps.check-pr.outputs.exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
TARGET="develop"
|
||||
|
||||
# Extract title from branch name (dev/foo-bar → foo bar)
|
||||
TITLE=$(echo "$BRANCH" | sed 's|^dev/||; s|^feature/||; s|^fix/||; s|-| |g; s|_| |g')
|
||||
|
||||
# Capitalize first letter
|
||||
TITLE="$(echo "${TITLE:0:1}" | tr '[:lower:]' '[:upper:]')${TITLE:1}"
|
||||
|
||||
# Create PR body
|
||||
BODY=$(cat << 'PRBODY'
|
||||
Auto-created PR from feature branch.
|
||||
|
||||
## Changes
|
||||
|
||||
<!-- Describe your changes here -->
|
||||
|
||||
---
|
||||
*This PR was auto-created by the feature-pr workflow.*
|
||||
PRBODY
|
||||
)
|
||||
|
||||
gh pr create \
|
||||
--base "$TARGET" \
|
||||
--head "$BRANCH" \
|
||||
--title "$TITLE" \
|
||||
--body "$BODY"
|
||||
|
||||
echo "Created PR: $BRANCH → $TARGET"
|
||||
4
.github/workflows/formal-conformance.yml
vendored
4
.github/workflows/formal-conformance.yml
vendored
@@ -3,6 +3,10 @@ name: Formal models (informational conformance)
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: formal-conformance-${{ github.event.pull_request.number || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
formal_conformance:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
129
.github/workflows/generate-changelog.yml
vendored
Normal file
129
.github/workflows/generate-changelog.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
name: Generate Changelog
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
version:
|
||||
description: "Version for the changelog"
|
||||
required: true
|
||||
type: string
|
||||
release_type:
|
||||
description: "Release type: alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
outputs:
|
||||
changelog:
|
||||
description: "Generated changelog content"
|
||||
value: ${{ jobs.generate.outputs.changelog }}
|
||||
changelog_file:
|
||||
description: "Path to changelog file"
|
||||
value: ${{ jobs.generate.outputs.changelog_file }}
|
||||
|
||||
jobs:
|
||||
generate:
|
||||
name: Generate Changelog
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changelog: ${{ steps.generate.outputs.changelog }}
|
||||
changelog_file: ${{ steps.generate.outputs.changelog_file }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
id: generate
|
||||
run: |
|
||||
VERSION="${{ inputs.version }}"
|
||||
RELEASE_TYPE="${{ inputs.release_type }}"
|
||||
DATE=$(date +%Y-%m-%d)
|
||||
|
||||
# Start building changelog
|
||||
CHANGELOG="## v${VERSION} (${DATE})\n\n"
|
||||
|
||||
# Initialize sections
|
||||
FEATURES=""
|
||||
FIXES=""
|
||||
DOCS=""
|
||||
CHORES=""
|
||||
OTHER=""
|
||||
|
||||
# Get commits since last tag
|
||||
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
COMMITS=$(git log ${LATEST_TAG}..HEAD --oneline --format="%s")
|
||||
else
|
||||
COMMITS=$(git log --oneline --format="%s" | head -50)
|
||||
fi
|
||||
|
||||
# Categorize commits by conventional commit type
|
||||
while IFS= read -r commit; do
|
||||
if [ -z "$commit" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract type from conventional commit
|
||||
if [[ "$commit" =~ ^feat(\(.+\))?:\ (.+)$ ]]; then
|
||||
FEATURES="${FEATURES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^fix(\(.+\))?:\ (.+)$ ]]; then
|
||||
FIXES="${FIXES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^docs(\(.+\))?:\ (.+)$ ]]; then
|
||||
DOCS="${DOCS}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^chore(\(.+\))?:\ (.+)$ ]]; then
|
||||
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^refactor(\(.+\))?:\ (.+)$ ]]; then
|
||||
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^test(\(.+\))?:\ (.+)$ ]]; then
|
||||
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
|
||||
elif [[ "$commit" =~ ^ci(\(.+\))?:\ (.+)$ ]]; then
|
||||
CHORES="${CHORES}- ${BASH_REMATCH[2]}\n"
|
||||
else
|
||||
# Non-conventional commit, add to other
|
||||
OTHER="${OTHER}- ${commit}\n"
|
||||
fi
|
||||
done <<< "$COMMITS"
|
||||
|
||||
# Build final changelog
|
||||
if [ -n "$FEATURES" ]; then
|
||||
CHANGELOG="${CHANGELOG}### ✨ Features\n\n${FEATURES}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$FIXES" ]; then
|
||||
CHANGELOG="${CHANGELOG}### 🐛 Bug Fixes\n\n${FIXES}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$DOCS" ]; then
|
||||
CHANGELOG="${CHANGELOG}### 📚 Documentation\n\n${DOCS}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$CHORES" ]; then
|
||||
CHANGELOG="${CHANGELOG}### 🔧 Maintenance\n\n${CHORES}\n"
|
||||
fi
|
||||
|
||||
if [ -n "$OTHER" ]; then
|
||||
CHANGELOG="${CHANGELOG}### Other Changes\n\n${OTHER}\n"
|
||||
fi
|
||||
|
||||
# If no categorized commits, add a simple message
|
||||
if [ -z "$FEATURES" ] && [ -z "$FIXES" ] && [ -z "$DOCS" ] && [ -z "$CHORES" ] && [ -z "$OTHER" ]; then
|
||||
CHANGELOG="${CHANGELOG}No notable changes in this release.\n"
|
||||
fi
|
||||
|
||||
# Add release metadata
|
||||
CHANGELOG="${CHANGELOG}\n---\n\n"
|
||||
CHANGELOG="${CHANGELOG}**Release Type**: ${RELEASE_TYPE}\n"
|
||||
CHANGELOG="${CHANGELOG}**Full Changelog**: https://github.com/${{ github.repository }}/compare/${LATEST_TAG:-initial}...v${VERSION}\n"
|
||||
|
||||
# Escape for multiline output (random delimiter prevents collision with commit messages)
|
||||
DELIMITER="CHANGELOG_$(openssl rand -hex 16)"
|
||||
echo "changelog<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo -e "$CHANGELOG" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "changelog_file=CHANGELOG.md" >> $GITHUB_OUTPUT
|
||||
|
||||
# Also write to step summary
|
||||
echo "## Generated Changelog" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo -e "$CHANGELOG" >> $GITHUB_STEP_SUMMARY
|
||||
93
.github/workflows/hotfix-pr.yml
vendored
Normal file
93
.github/workflows/hotfix-pr.yml
vendored
Normal file
@@ -0,0 +1,93 @@
|
||||
name: Hotfix PR
|
||||
|
||||
# Emergency hotfix workflow - bypasses staging pipeline
|
||||
# Use for critical security fixes or production-breaking bugs only
|
||||
#
|
||||
# Flow: hotfix/* → main (directly, with expedited review)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "hotfix/**"
|
||||
|
||||
concurrency:
|
||||
group: hotfix-${{ github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
create-pr:
|
||||
name: Create Hotfix PR
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check for existing PR
|
||||
id: check-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
|
||||
EXISTING=$(gh pr list --head "$BRANCH" --base main --json number --jq '.[0].number // empty')
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
|
||||
echo "Hotfix PR #$EXISTING already exists"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Hotfix PR
|
||||
if: steps.check-pr.outputs.exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
BRANCH="${{ github.ref_name }}"
|
||||
|
||||
# Extract title from branch name
|
||||
TITLE=$(echo "$BRANCH" | sed 's|^hotfix/||; s|-| |g; s|_| |g')
|
||||
TITLE="🚨 HOTFIX: $(echo "${TITLE:0:1}" | tr '[:lower:]' '[:upper:]')${TITLE:1}"
|
||||
|
||||
# Create PR body
|
||||
BODY=$(cat << 'PRBODY'
|
||||
## 🚨 Emergency Hotfix
|
||||
|
||||
**This PR bypasses the normal staging pipeline.**
|
||||
|
||||
### What's broken?
|
||||
<!-- Describe the production issue -->
|
||||
|
||||
### Root cause
|
||||
<!-- Brief explanation of what went wrong -->
|
||||
|
||||
### Fix
|
||||
<!-- What this hotfix does -->
|
||||
|
||||
### Verification
|
||||
- [ ] Tested locally
|
||||
- [ ] Reviewed by at least one other maintainer
|
||||
- [ ] Post-merge monitoring plan in place
|
||||
|
||||
---
|
||||
⚠️ **After merging:** Cherry-pick this fix to `develop`, `alpha`, and `beta` branches to keep them in sync.
|
||||
|
||||
*This PR was auto-created by the hotfix-pr workflow.*
|
||||
PRBODY
|
||||
)
|
||||
|
||||
gh pr create \
|
||||
--base main \
|
||||
--head "$BRANCH" \
|
||||
--title "$TITLE" \
|
||||
--label "hotfix,priority:critical" \
|
||||
--body "$BODY"
|
||||
|
||||
echo "Created hotfix PR: $BRANCH → main"
|
||||
4
.github/workflows/install-smoke.yml
vendored
4
.github/workflows/install-smoke.yml
vendored
@@ -6,6 +6,10 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: install-smoke-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
install-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
319
.github/workflows/promote-branch.yml
vendored
Normal file
319
.github/workflows/promote-branch.yml
vendored
Normal file
@@ -0,0 +1,319 @@
|
||||
name: Promote Branch
|
||||
|
||||
# Staged branch promotion for openclaw:
|
||||
#
|
||||
# develop → alpha → beta → main
|
||||
#
|
||||
# - External contributors: target `develop`
|
||||
# - develop → alpha: auto-creates PR after core checks pass
|
||||
# - alpha → beta: auto-creates PR after alpha tests pass (+ secrets scan)
|
||||
# - beta → main: auto-creates PR after full tests pass (+ Windows)
|
||||
#
|
||||
# Merging to main triggers a release (handled separately by release workflow)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- alpha
|
||||
- beta
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "*.md"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
source_branch:
|
||||
description: "Source branch to promote from"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- develop
|
||||
- alpha
|
||||
- beta
|
||||
skip_tests:
|
||||
description: "Skip tests (use with caution)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
concurrency:
|
||||
group: promote-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# Determine promotion target
|
||||
determine-target:
|
||||
name: Determine Target Branch
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
source: ${{ steps.determine.outputs.source }}
|
||||
target: ${{ steps.determine.outputs.target }}
|
||||
test_stage: ${{ steps.determine.outputs.test_stage }}
|
||||
should_promote: ${{ steps.determine.outputs.should_promote }}
|
||||
steps:
|
||||
- name: Determine promotion target
|
||||
id: determine
|
||||
run: |
|
||||
# Get source branch
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
SOURCE="${{ inputs.source_branch }}"
|
||||
else
|
||||
SOURCE="${{ github.ref_name }}"
|
||||
fi
|
||||
|
||||
echo "source=$SOURCE" >> $GITHUB_OUTPUT
|
||||
|
||||
case "$SOURCE" in
|
||||
develop)
|
||||
echo "target=alpha" >> $GITHUB_OUTPUT
|
||||
echo "test_stage=develop" >> $GITHUB_OUTPUT
|
||||
echo "should_promote=true" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
alpha)
|
||||
echo "target=beta" >> $GITHUB_OUTPUT
|
||||
echo "test_stage=alpha" >> $GITHUB_OUTPUT
|
||||
echo "should_promote=true" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
beta)
|
||||
echo "target=main" >> $GITHUB_OUTPUT
|
||||
echo "test_stage=beta" >> $GITHUB_OUTPUT
|
||||
echo "should_promote=true" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
*)
|
||||
echo "target=" >> $GITHUB_OUTPUT
|
||||
echo "test_stage=" >> $GITHUB_OUTPUT
|
||||
echo "should_promote=false" >> $GITHUB_OUTPUT
|
||||
;;
|
||||
esac
|
||||
|
||||
# Ensure target branch exists (create from main if not)
|
||||
ensure-target-branch:
|
||||
name: Ensure Target Branch
|
||||
needs: determine-target
|
||||
if: needs.determine-target.outputs.should_promote == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create target branch if missing
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TARGET="${{ needs.determine-target.outputs.target }}"
|
||||
|
||||
if git ls-remote --exit-code origin "refs/heads/$TARGET" >/dev/null 2>&1; then
|
||||
echo "Branch '$TARGET' already exists"
|
||||
else
|
||||
echo "Branch '$TARGET' does not exist — creating from main"
|
||||
git push origin "origin/main:refs/heads/$TARGET"
|
||||
fi
|
||||
|
||||
# Run stage-appropriate tests
|
||||
run-tests:
|
||||
name: Run Tests
|
||||
needs: [determine-target, ensure-target-branch]
|
||||
if: ${{ needs.determine-target.outputs.should_promote == 'true' && (github.event_name != 'workflow_dispatch' || !inputs.skip_tests) }}
|
||||
uses: ./.github/workflows/testing-strategy.yml
|
||||
with:
|
||||
test_stage: ${{ needs.determine-target.outputs.test_stage }}
|
||||
app_version: ${{ github.sha }}
|
||||
secrets: inherit
|
||||
|
||||
# Create promotion PR
|
||||
create-promotion-pr:
|
||||
name: Create Promotion PR
|
||||
needs: [determine-target, ensure-target-branch, run-tests]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.determine-target.outputs.should_promote == 'true' &&
|
||||
(needs.run-tests.outputs.test_status == 'passed' || (github.event_name == 'workflow_dispatch' && inputs.skip_tests))
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pr_number: ${{ steps.output-pr.outputs.pull-request-number }}
|
||||
pr_url: ${{ steps.output-pr.outputs.pull-request-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.determine-target.outputs.source }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get commit info
|
||||
id: commits
|
||||
run: |
|
||||
TARGET="${{ needs.determine-target.outputs.target }}"
|
||||
|
||||
# Fetch target branch
|
||||
git fetch origin $TARGET 2>/dev/null || true
|
||||
|
||||
# Get commits not in target
|
||||
if git rev-parse origin/$TARGET >/dev/null 2>&1; then
|
||||
COMMIT_COUNT=$(git rev-list --count origin/$TARGET..HEAD 2>/dev/null || echo "0")
|
||||
COMMIT_SUMMARY=$(git log origin/$TARGET..HEAD --oneline --format="- %s (%h)" 2>/dev/null | head -20 || echo "Initial promotion")
|
||||
else
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD 2>/dev/null || echo "0")
|
||||
COMMIT_SUMMARY=$(git log --oneline --format="- %s (%h)" 2>/dev/null | head -20 || echo "Initial promotion")
|
||||
fi
|
||||
|
||||
echo "count=$COMMIT_COUNT" >> $GITHUB_OUTPUT
|
||||
DELIM="COMMITS_$(openssl rand -hex 16)"
|
||||
echo "summary<<${DELIM}" >> $GITHUB_OUTPUT
|
||||
echo "$COMMIT_SUMMARY" >> $GITHUB_OUTPUT
|
||||
echo "${DELIM}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check for existing PR
|
||||
id: check-pr
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
SOURCE="${{ needs.determine-target.outputs.source }}"
|
||||
TARGET="${{ needs.determine-target.outputs.target }}"
|
||||
|
||||
EXISTING=$(gh pr list --head "$SOURCE" --base "$TARGET" --json number --jq '.[0].number // empty')
|
||||
|
||||
if [ -n "$EXISTING" ]; then
|
||||
echo "exists=true" >> $GITHUB_OUTPUT
|
||||
echo "pr_number=$EXISTING" >> $GITHUB_OUTPUT
|
||||
echo "pr_url=https://github.com/${{ github.repository }}/pull/$EXISTING" >> $GITHUB_OUTPUT
|
||||
echo "Promotion PR #$EXISTING already exists for $SOURCE → $TARGET"
|
||||
else
|
||||
echo "exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create-pr
|
||||
if: steps.check-pr.outputs.exists != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
SOURCE="${{ needs.determine-target.outputs.source }}"
|
||||
TARGET="${{ needs.determine-target.outputs.target }}"
|
||||
TEST_STAGE="${{ needs.determine-target.outputs.test_stage }}"
|
||||
COMMIT_COUNT="${{ steps.commits.outputs.count }}"
|
||||
|
||||
# Write PR body to a temp file to avoid shell quoting issues
|
||||
BODY_FILE=$(mktemp)
|
||||
cat > "$BODY_FILE" <<__PRBODY__
|
||||
## Staged Promotion
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Source | \`${SOURCE}\` |
|
||||
| Target | \`${TARGET}\` |
|
||||
| Test Stage | \`${TEST_STAGE}\` |
|
||||
|
||||
### Changes (${COMMIT_COUNT} commits)
|
||||
|
||||
${{ steps.commits.outputs.summary }}
|
||||
|
||||
### Checklist
|
||||
|
||||
- [ ] Changes reviewed
|
||||
- [ ] CI passing
|
||||
- [ ] Ready to promote
|
||||
|
||||
---
|
||||
*Auto-generated by the branch promotion workflow.*
|
||||
__PRBODY__
|
||||
|
||||
PR_URL=$(gh pr create \
|
||||
--base "$TARGET" \
|
||||
--head "$SOURCE" \
|
||||
--title "🚀 Promote: $SOURCE → $TARGET" \
|
||||
--body-file "$BODY_FILE" \
|
||||
--label "promotion")
|
||||
|
||||
rm -f "$BODY_FILE"
|
||||
|
||||
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
|
||||
|
||||
echo "pr_number=$PR_NUMBER" >> $GITHUB_OUTPUT
|
||||
echo "pr_url=$PR_URL" >> $GITHUB_OUTPUT
|
||||
echo "Created promotion PR: $SOURCE → $TARGET"
|
||||
|
||||
- name: Output existing PR
|
||||
id: output-pr
|
||||
run: |
|
||||
if [ "${{ steps.check-pr.outputs.exists }}" = "true" ]; then
|
||||
echo "pull-request-number=${{ steps.check-pr.outputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
echo "pull-request-url=${{ steps.check-pr.outputs.pr_url }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "pull-request-number=${{ steps.create-pr.outputs.pr_number }}" >> $GITHUB_OUTPUT
|
||||
echo "pull-request-url=${{ steps.create-pr.outputs.pr_url }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Auto-merge for develop → alpha (fast-track, new PRs only)
|
||||
auto-merge:
|
||||
name: Auto-merge (develop → alpha)
|
||||
needs: [determine-target, create-promotion-pr]
|
||||
if: |
|
||||
needs.determine-target.outputs.source == 'develop' &&
|
||||
needs.create-promotion-pr.outputs.pr_number != '' &&
|
||||
needs.create-promotion-pr.result == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Enable auto-merge
|
||||
run: |
|
||||
gh pr merge ${{ needs.create-promotion-pr.outputs.pr_number }} \
|
||||
--auto \
|
||||
--squash \
|
||||
--repo ${{ github.repository }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Notify about promotion
|
||||
notify-promotion:
|
||||
name: Notify Promotion
|
||||
needs: [determine-target, create-promotion-pr]
|
||||
if: "!cancelled() && needs.create-promotion-pr.outputs.pr_url != ''"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "🔄 Promotion PR: ${{ needs.determine-target.outputs.source }} → ${{ needs.determine-target.outputs.target }}"
|
||||
description: |
|
||||
**PR**: ${{ needs.create-promotion-pr.outputs.pr_url }}
|
||||
**Stage**: ${{ needs.determine-target.outputs.test_stage }}
|
||||
color: "3447003"
|
||||
|
||||
# Handle failed tests
|
||||
notify-failure:
|
||||
name: Notify Test Failure
|
||||
needs: [determine-target, run-tests]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.run-tests.outputs.test_status != 'passed' &&
|
||||
!inputs.skip_tests
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "❌ Promotion Blocked: ${{ needs.determine-target.outputs.source }}"
|
||||
description: |
|
||||
**Target**: ${{ needs.determine-target.outputs.target }}
|
||||
**Reason**: Tests failed
|
||||
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
color: "15158332"
|
||||
264
.github/workflows/release-orchestrator.yml
vendored
Normal file
264
.github/workflows/release-orchestrator.yml
vendored
Normal file
@@ -0,0 +1,264 @@
|
||||
name: Release Orchestrator
|
||||
|
||||
# Orchestrates staged releases for openclaw
|
||||
#
|
||||
# This workflow is called when code is promoted to main (stable release)
|
||||
# or can be triggered manually for alpha/beta releases from their branches.
|
||||
#
|
||||
# Flow: version → changelog → test → deploy → release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "*.md"
|
||||
- ".github/workflows/docs-*.yml"
|
||||
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_type:
|
||||
description: "Release type: alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
source_branch:
|
||||
description: "Source branch for the release"
|
||||
required: true
|
||||
type: string
|
||||
dry_run:
|
||||
description: "Perform a dry run without publishing"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
version:
|
||||
description: "The released version"
|
||||
value: ${{ jobs.version.outputs.new_version }}
|
||||
release_url:
|
||||
description: "URL to the GitHub release"
|
||||
value: ${{ jobs.release.outputs.release_url }}
|
||||
status:
|
||||
description: "Release status"
|
||||
value: ${{ jobs.release.outputs.status }}
|
||||
secrets:
|
||||
NPM_TOKEN:
|
||||
required: false
|
||||
DISCORD_WEBHOOK_URL:
|
||||
required: false
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
# Determine release parameters (push vs workflow_call)
|
||||
determine-params:
|
||||
name: Determine Parameters
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_type: ${{ steps.params.outputs.release_type }}
|
||||
source_branch: ${{ steps.params.outputs.source_branch }}
|
||||
dry_run: ${{ steps.params.outputs.dry_run }}
|
||||
steps:
|
||||
- name: Set parameters
|
||||
id: params
|
||||
run: |
|
||||
# When triggered by push to main, use stable defaults
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
echo "release_type=stable" >> $GITHUB_OUTPUT
|
||||
echo "source_branch=main" >> $GITHUB_OUTPUT
|
||||
echo "dry_run=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
# workflow_call - use provided inputs
|
||||
echo "release_type=${{ inputs.release_type }}" >> $GITHUB_OUTPUT
|
||||
echo "source_branch=${{ inputs.source_branch }}" >> $GITHUB_OUTPUT
|
||||
echo "dry_run=${{ inputs.dry_run }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Get commits since last release
|
||||
get-commits:
|
||||
name: Get Commits
|
||||
needs: determine-params
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
commits: ${{ steps.commits.outputs.commits }}
|
||||
has_changes: ${{ steps.commits.outputs.has_changes }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.determine-params.outputs.source_branch }}
|
||||
|
||||
- name: Get commits since last tag
|
||||
id: commits
|
||||
run: |
|
||||
# Get latest tag for this release type
|
||||
case "${{ needs.determine-params.outputs.release_type }}" in
|
||||
alpha)
|
||||
PATTERN="v*-alpha.*"
|
||||
;;
|
||||
beta)
|
||||
PATTERN="v*-beta.*"
|
||||
;;
|
||||
stable)
|
||||
PATTERN="v[0-9]*.[0-9]*.[0-9]*"
|
||||
;;
|
||||
esac
|
||||
|
||||
# Filter out prerelease tags for stable (glob * matches -alpha/-beta suffixes)
|
||||
if [ "${{ needs.determine-params.outputs.release_type }}" = "stable" ]; then
|
||||
LATEST_TAG=$(git tag -l "$PATTERN" --sort=-v:refname | grep -v -E '-(alpha|beta)\.' | head -1)
|
||||
else
|
||||
LATEST_TAG=$(git tag -l "$PATTERN" --sort=-v:refname | head -1)
|
||||
fi
|
||||
|
||||
if [ -z "$LATEST_TAG" ]; then
|
||||
# No previous tag, use all commits
|
||||
LATEST_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
echo "No previous ${{ needs.determine-params.outputs.release_type }} tag found, using initial commit"
|
||||
else
|
||||
echo "Latest ${{ needs.determine-params.outputs.release_type }} tag: $LATEST_TAG"
|
||||
fi
|
||||
|
||||
COMMITS=$(git log ${LATEST_TAG}..HEAD --oneline --format="- %s (%h)")
|
||||
|
||||
if [ -z "$COMMITS" ]; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "commits=" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
DELIM="COMMITS_$(openssl rand -hex 16)"
|
||||
echo "commits<<${DELIM}" >> $GITHUB_OUTPUT
|
||||
echo "$COMMITS" >> $GITHUB_OUTPUT
|
||||
echo "${DELIM}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Version operations
|
||||
version:
|
||||
name: Version
|
||||
needs: [determine-params, get-commits]
|
||||
if: needs.get-commits.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/version-operations.yml
|
||||
with:
|
||||
release_type: ${{ needs.determine-params.outputs.release_type }}
|
||||
source_branch: ${{ needs.determine-params.outputs.source_branch }}
|
||||
should_bump: true
|
||||
dry_run: ${{ needs.determine-params.outputs.dry_run }}
|
||||
|
||||
# Generate changelog
|
||||
changelog:
|
||||
name: Changelog
|
||||
needs: [determine-params, get-commits, version]
|
||||
if: needs.get-commits.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/generate-changelog.yml
|
||||
with:
|
||||
version: ${{ needs.version.outputs.new_version }}
|
||||
release_type: ${{ needs.determine-params.outputs.release_type }}
|
||||
|
||||
# Run full test suite for the release type
|
||||
test:
|
||||
name: Test
|
||||
needs: [determine-params, get-commits, version]
|
||||
if: needs.get-commits.outputs.has_changes == 'true'
|
||||
uses: ./.github/workflows/testing-strategy.yml
|
||||
with:
|
||||
test_stage: ${{ needs.determine-params.outputs.release_type }}
|
||||
app_version: ${{ needs.version.outputs.new_version }}
|
||||
secrets: inherit
|
||||
|
||||
# Deploy (npm + Docker)
|
||||
deploy:
|
||||
name: Deploy
|
||||
needs: [determine-params, version, test]
|
||||
if: ${{ needs.determine-params.outputs.dry_run != 'true' && needs.test.outputs.test_status == 'passed' }}
|
||||
uses: ./.github/workflows/deployment-strategy.yml
|
||||
with:
|
||||
deployment_stage: ${{ needs.determine-params.outputs.release_type }}
|
||||
app_version: ${{ needs.version.outputs.new_version }}
|
||||
source_branch: ${{ needs.determine-params.outputs.source_branch }}
|
||||
secrets: inherit
|
||||
|
||||
# Create GitHub release
|
||||
release:
|
||||
name: GitHub Release
|
||||
needs: [determine-params, version, changelog, deploy]
|
||||
if: ${{ needs.determine-params.outputs.dry_run != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_url: ${{ steps.create-release.outputs.html_url }}
|
||||
status: ${{ steps.status.outputs.status }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.determine-params.outputs.source_branch }}
|
||||
|
||||
- name: Create GitHub Release
|
||||
id: create-release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ needs.version.outputs.new_version }}
|
||||
name: openclaw ${{ needs.version.outputs.new_version }}
|
||||
body: ${{ needs.changelog.outputs.changelog }}
|
||||
prerelease: ${{ needs.determine-params.outputs.release_type != 'stable' }}
|
||||
draft: false
|
||||
|
||||
- name: Set status
|
||||
id: status
|
||||
run: echo "status=success" >> $GITHUB_OUTPUT
|
||||
|
||||
# Notify on success
|
||||
notify-success:
|
||||
name: Notify Success
|
||||
needs: [determine-params, version, release]
|
||||
if: ${{ !cancelled() && needs.release.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "🎉 Released: openclaw v${{ needs.version.outputs.new_version }}"
|
||||
description: |
|
||||
**Type**: ${{ needs.determine-params.outputs.release_type }}
|
||||
**Release**: ${{ needs.release.outputs.release_url }}
|
||||
color: "3066993"
|
||||
|
||||
# Notify on failure
|
||||
notify-failure:
|
||||
name: Notify Failure
|
||||
needs: [determine-params, version, test, deploy, release]
|
||||
if: |
|
||||
!cancelled() &&
|
||||
needs.version.result != 'skipped' &&
|
||||
(needs.test.result == 'failure' || needs.deploy.result == 'failure' || needs.release.result == 'failure')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "❌ Release Failed: ${{ needs.determine-params.outputs.release_type }}"
|
||||
description: |
|
||||
**Branch**: ${{ needs.determine-params.outputs.source_branch }}
|
||||
**Tests**: ${{ needs.test.outputs.test_status }}
|
||||
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
color: "15158332"
|
||||
51
.github/workflows/release.yml
vendored
Normal file
51
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Release
|
||||
|
||||
# Manual release workflow - triggers the release orchestrator
|
||||
#
|
||||
# Branch → Release Type mapping:
|
||||
# alpha → releases from 'alpha' branch with -alpha.N suffix
|
||||
# beta → releases from 'beta' branch with -beta.N suffix
|
||||
# stable → releases from 'main' branch with YYYY.M.D version
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_type:
|
||||
description: "Release type"
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- alpha
|
||||
- beta
|
||||
- stable
|
||||
default: "alpha"
|
||||
dry_run:
|
||||
description: "Dry run (no publish)"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
determine-branch:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
branch: ${{ steps.branch.outputs.name }}
|
||||
steps:
|
||||
- name: Determine source branch
|
||||
id: branch
|
||||
run: |
|
||||
case "${{ inputs.release_type }}" in
|
||||
alpha) echo "name=alpha" >> $GITHUB_OUTPUT ;;
|
||||
beta) echo "name=beta" >> $GITHUB_OUTPUT ;;
|
||||
stable) echo "name=main" >> $GITHUB_OUTPUT ;;
|
||||
esac
|
||||
|
||||
release:
|
||||
name: Release
|
||||
needs: determine-branch
|
||||
uses: ./.github/workflows/release-orchestrator.yml
|
||||
with:
|
||||
release_type: ${{ inputs.release_type }}
|
||||
source_branch: ${{ needs.determine-branch.outputs.branch }}
|
||||
dry_run: ${{ inputs.dry_run }}
|
||||
secrets: inherit
|
||||
256
.github/workflows/rollback.yml
vendored
Normal file
256
.github/workflows/rollback.yml
vendored
Normal file
@@ -0,0 +1,256 @@
|
||||
name: Rollback
|
||||
|
||||
# Emergency rollback workflow
|
||||
#
|
||||
# Reverts npm + Docker to a previous known-good version.
|
||||
# Does NOT revert git — the bad commits stay in history.
|
||||
# Create a hotfix branch to fix forward after rolling back.
|
||||
#
|
||||
# What it does:
|
||||
# 1. Re-tags the previous version as @latest / :latest on npm + Docker
|
||||
# 2. Creates a GitHub release noting the rollback
|
||||
# 3. Notifies Discord
|
||||
#
|
||||
# What it does NOT do:
|
||||
# - Revert git commits (fix forward instead)
|
||||
# - Remove the bad version from npm (use `npm unpublish` manually if needed)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
rollback_to:
|
||||
description: "Version to roll back to (e.g. 2026.2.5)"
|
||||
required: true
|
||||
type: string
|
||||
reason:
|
||||
description: "Reason for rollback"
|
||||
required: true
|
||||
type: string
|
||||
rollback_npm:
|
||||
description: "Roll back npm dist-tag"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
rollback_docker:
|
||||
description: "Roll back Docker :latest tag"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
concurrency:
|
||||
group: rollback
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# Validate the target version exists
|
||||
validate:
|
||||
name: Validate Target Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
tag_exists: ${{ steps.check.outputs.tag_exists }}
|
||||
npm_exists: ${{ steps.check.outputs.npm_exists }}
|
||||
docker_exists: ${{ steps.check.outputs.docker_exists }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate version
|
||||
id: check
|
||||
run: |
|
||||
VERSION="${{ inputs.rollback_to }}"
|
||||
|
||||
# Check git tag
|
||||
if git tag -l "v${VERSION}" | grep -q .; then
|
||||
echo "tag_exists=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Git tag v${VERSION} exists"
|
||||
else
|
||||
echo "tag_exists=false" >> $GITHUB_OUTPUT
|
||||
echo "❌ Git tag v${VERSION} not found"
|
||||
fi
|
||||
|
||||
# Check npm
|
||||
if npm view "openclaw@${VERSION}" version 2>/dev/null | grep -q "${VERSION}"; then
|
||||
echo "npm_exists=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ npm version ${VERSION} exists"
|
||||
else
|
||||
echo "npm_exists=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ npm version ${VERSION} not found (npm rollback will be skipped)"
|
||||
fi
|
||||
|
||||
# Check Docker
|
||||
if docker manifest inspect "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${VERSION}" >/dev/null 2>&1; then
|
||||
echo "docker_exists=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ Docker image ${VERSION} exists"
|
||||
else
|
||||
echo "docker_exists=false" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Docker image ${VERSION} not found (Docker rollback will be skipped)"
|
||||
fi
|
||||
|
||||
- name: Fail if tag doesn't exist
|
||||
if: steps.check.outputs.tag_exists != 'true'
|
||||
run: |
|
||||
echo "::error::Version v${{ inputs.rollback_to }} does not exist as a git tag"
|
||||
exit 1
|
||||
|
||||
# Roll back npm dist-tag
|
||||
rollback-npm:
|
||||
name: Rollback npm
|
||||
needs: validate
|
||||
if: ${{ inputs.rollback_npm && needs.validate.outputs.npm_exists == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
status: ${{ steps.rollback.outputs.status }}
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- name: Roll back npm @latest tag
|
||||
id: rollback
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ inputs.rollback_to }}"
|
||||
|
||||
if [ -z "$NODE_AUTH_TOKEN" ]; then
|
||||
echo "::warning::NPM_TOKEN not set, skipping npm rollback"
|
||||
echo "status=skipped" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Move the @latest dist-tag to the rollback version
|
||||
if npm dist-tag add "openclaw@${VERSION}" latest; then
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
echo "✅ npm @latest now points to ${VERSION}"
|
||||
|
||||
# Show current dist-tags for verification
|
||||
npm dist-tag ls openclaw
|
||||
else
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
echo "::error::Failed to update npm dist-tag"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Roll back Docker :latest tag
|
||||
rollback-docker:
|
||||
name: Rollback Docker
|
||||
needs: validate
|
||||
if: ${{ inputs.rollback_docker && needs.validate.outputs.docker_exists == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
outputs:
|
||||
status: ${{ steps.rollback.outputs.status }}
|
||||
steps:
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Roll back Docker :latest tag
|
||||
id: rollback
|
||||
run: |
|
||||
VERSION="${{ inputs.rollback_to }}"
|
||||
IMAGE="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
|
||||
|
||||
# Re-tag the rollback version as :latest
|
||||
if docker buildx imagetools create -t "${IMAGE}:latest" "${IMAGE}:${VERSION}"; then
|
||||
echo "status=success" >> $GITHUB_OUTPUT
|
||||
echo "✅ Docker :latest now points to ${VERSION}"
|
||||
else
|
||||
echo "status=failed" >> $GITHUB_OUTPUT
|
||||
echo "::error::Failed to retag Docker image"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create rollback release note
|
||||
create-rollback-release:
|
||||
name: Create Rollback Release
|
||||
needs: [validate, rollback-npm, rollback-docker]
|
||||
if: "!cancelled() && needs.validate.result == 'success'"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get current version
|
||||
id: current
|
||||
run: |
|
||||
CURRENT=$(node -p "require('./package.json').version")
|
||||
echo "version=$CURRENT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create rollback release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
tag_name: v${{ inputs.rollback_to }}
|
||||
name: "⚠️ Rollback to openclaw ${{ inputs.rollback_to }}"
|
||||
body: |
|
||||
## ⚠️ Rollback
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Rolled back from | `${{ steps.current.outputs.version }}` |
|
||||
| Rolled back to | `${{ inputs.rollback_to }}` |
|
||||
| Initiated by | @${{ github.actor }} |
|
||||
|
||||
### Reason
|
||||
|
||||
${{ inputs.reason }}
|
||||
|
||||
### Rollback Status
|
||||
|
||||
| Target | Status |
|
||||
|--------|--------|
|
||||
| npm @latest | ${{ needs.rollback-npm.outputs.status || 'skipped' }} |
|
||||
| Docker :latest | ${{ needs.rollback-docker.outputs.status || 'skipped' }} |
|
||||
|
||||
### Next Steps
|
||||
|
||||
1. Investigate the issue in the rolled-back version
|
||||
2. Create a `hotfix/*` branch with the fix
|
||||
3. Merge via the hotfix workflow to restore forward progress
|
||||
|
||||
---
|
||||
*This release was created by the rollback workflow.*
|
||||
make_latest: false
|
||||
prerelease: false
|
||||
|
||||
# Notify
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs: [validate, rollback-npm, rollback-docker, create-rollback-release]
|
||||
if: "!cancelled() && needs.validate.result == 'success'"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord notification
|
||||
if: env.DISCORD_WEBHOOK_URL != ''
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "⚠️ ROLLBACK: openclaw → v${{ inputs.rollback_to }}"
|
||||
description: |
|
||||
**Reason**: ${{ inputs.reason }}
|
||||
**Initiated by**: @${{ github.actor }}
|
||||
**npm**: ${{ needs.rollback-npm.outputs.status || 'skipped' }}
|
||||
**Docker**: ${{ needs.rollback-docker.outputs.status || 'skipped' }}
|
||||
color: "15105570"
|
||||
153
.github/workflows/testing-strategy.yml
vendored
Normal file
153
.github/workflows/testing-strategy.yml
vendored
Normal file
@@ -0,0 +1,153 @@
|
||||
name: Testing Strategy
|
||||
|
||||
# Reusable testing workflow for staged releases
|
||||
# Passes test_stage to ci.yml to control which platform tests run
|
||||
#
|
||||
# Progressive test coverage by stage:
|
||||
# - develop/alpha: core checks + secrets + android
|
||||
# - beta: + Windows tests
|
||||
# - stable: + macOS tests, macOS app, install smoke
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
test_stage:
|
||||
description: "Testing stage: develop, alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
app_version:
|
||||
description: "Version of the application being tested"
|
||||
required: false
|
||||
type: string
|
||||
default: "dev"
|
||||
outputs:
|
||||
test_status:
|
||||
description: "Overall test status"
|
||||
value: ${{ jobs.test-summary.outputs.overall_status }}
|
||||
secrets:
|
||||
DISCORD_WEBHOOK_URL:
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
# Run CI with stage-appropriate platform gates
|
||||
ci:
|
||||
name: Core CI
|
||||
uses: ./.github/workflows/ci.yml
|
||||
with:
|
||||
test_stage: ${{ inputs.test_stage }}
|
||||
secrets: inherit
|
||||
|
||||
# Install smoke test (stable only)
|
||||
install-smoke:
|
||||
name: Install Smoke Test
|
||||
if: inputs.test_stage == 'stable'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm (corepack retry)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
corepack enable
|
||||
for attempt in 1 2 3; do
|
||||
if corepack prepare pnpm@10.23.0 --activate; then
|
||||
pnpm -v
|
||||
exit 0
|
||||
fi
|
||||
echo "corepack prepare failed (attempt $attempt/3). Retrying..."
|
||||
sleep $((attempt * 10))
|
||||
done
|
||||
exit 1
|
||||
|
||||
- name: Install pnpm deps (minimal)
|
||||
run: pnpm install --ignore-scripts --frozen-lockfile
|
||||
|
||||
- name: Run installer smoke tests
|
||||
env:
|
||||
CLAWDBOT_INSTALL_URL: https://openclaw.ai/install.sh
|
||||
CLAWDBOT_INSTALL_CLI_URL: https://openclaw.ai/install-cli.sh
|
||||
CLAWDBOT_NO_ONBOARD: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: "1"
|
||||
CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS: "1"
|
||||
run: pnpm test:install:smoke
|
||||
|
||||
# Test summary
|
||||
test-summary:
|
||||
name: Test Summary (${{ inputs.test_stage }})
|
||||
runs-on: ubuntu-latest
|
||||
needs: [ci, install-smoke]
|
||||
if: "!cancelled()"
|
||||
outputs:
|
||||
overall_status: ${{ steps.summary.outputs.overall_status }}
|
||||
steps:
|
||||
- name: Generate summary
|
||||
id: summary
|
||||
run: |
|
||||
echo "## 🧪 Test Results - ${{ inputs.test_stage }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Test Suite | Result |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|------------|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| CI (checks + secrets) | ${{ needs.ci.result }} |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Install Smoke | ${{ needs.install-smoke.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# CI must pass (includes platform-specific jobs based on test_stage)
|
||||
if [ "${{ needs.ci.result }}" != "success" ]; then
|
||||
echo "overall_status=failed" >> $GITHUB_OUTPUT
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ❌ CI failed" >> $GITHUB_STEP_SUMMARY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Stage-specific checks
|
||||
STAGE="${{ inputs.test_stage }}"
|
||||
FAILED=false
|
||||
|
||||
if [ "$STAGE" = "stable" ]; then
|
||||
if [ "${{ needs.install-smoke.result }}" = "failure" ]; then
|
||||
FAILED=true
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$FAILED" = "true" ]; then
|
||||
echo "overall_status=failed" >> $GITHUB_OUTPUT
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ❌ Some stage-specific tests failed" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "overall_status=passed" >> $GITHUB_OUTPUT
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ✅ All required tests passed!" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
# Discord notifications
|
||||
notify:
|
||||
name: Discord Notification
|
||||
needs: test-summary
|
||||
if: "!cancelled()"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Discord success notification
|
||||
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.test-summary.outputs.overall_status == 'passed' }}
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "✅ Tests Passed: ${{ inputs.test_stage }} v${{ inputs.app_version }}"
|
||||
description: "All tests passed for ${{ inputs.test_stage }} stage!"
|
||||
color: "3066993"
|
||||
|
||||
- name: Discord failure notification
|
||||
if: ${{ env.DISCORD_WEBHOOK_URL != '' && needs.test-summary.outputs.overall_status != 'passed' }}
|
||||
uses: ./.github/actions/discord-notify
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||
title: "❌ Tests Failed: ${{ inputs.test_stage }} v${{ inputs.app_version }}"
|
||||
description: |
|
||||
Some tests failed for ${{ inputs.test_stage }} stage.
|
||||
[View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
||||
color: "15158332"
|
||||
188
.github/workflows/version-operations.yml
vendored
Normal file
188
.github/workflows/version-operations.yml
vendored
Normal file
@@ -0,0 +1,188 @@
|
||||
name: Version Operations
|
||||
|
||||
# Version bump workflow for openclaw
|
||||
#
|
||||
# Version format: YYYY.M.D (stable) or YYYY.M.D-{alpha,beta}.N (prerelease)
|
||||
# Examples: 2026.2.6, 2026.2.6-alpha.1, 2026.2.6-beta.3
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_type:
|
||||
description: "Release type: alpha, beta, or stable"
|
||||
required: true
|
||||
type: string
|
||||
source_branch:
|
||||
description: "Source branch"
|
||||
required: true
|
||||
type: string
|
||||
should_bump:
|
||||
description: "Whether to bump the version"
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
dry_run:
|
||||
description: "Perform a dry run without committing"
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
outputs:
|
||||
current_version:
|
||||
description: "Current version before bump"
|
||||
value: ${{ jobs.version.outputs.current_version }}
|
||||
new_version:
|
||||
description: "New version after bump"
|
||||
value: ${{ jobs.version.outputs.new_version }}
|
||||
version_tag:
|
||||
description: "Version tag (with v prefix)"
|
||||
value: ${{ jobs.version.outputs.version_tag }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
version:
|
||||
name: Version Operations
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
current_version: ${{ steps.get-version.outputs.current }}
|
||||
new_version: ${{ steps.bump-version.outputs.new }}
|
||||
version_tag: ${{ steps.bump-version.outputs.tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ inputs.source_branch }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Get current version
|
||||
id: get-version
|
||||
run: |
|
||||
CURRENT_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "current=$CURRENT_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Current version: $CURRENT_VERSION"
|
||||
|
||||
- name: Calculate new version
|
||||
id: bump-version
|
||||
run: |
|
||||
CURRENT="${{ steps.get-version.outputs.current }}"
|
||||
RELEASE_TYPE="${{ inputs.release_type }}"
|
||||
|
||||
# Get current date components
|
||||
YEAR=$(date +%Y)
|
||||
MONTH=$(date +%-m)
|
||||
DAY=$(date +%-d)
|
||||
TODAY="${YEAR}.${MONTH}.${DAY}"
|
||||
|
||||
# Parse current version to check if it's today + same type
|
||||
# Patterns: YYYY.M.D or YYYY.M.D-type.N
|
||||
if [[ "$CURRENT" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-([a-z]+)\.([0-9]+))?$ ]]; then
|
||||
CURR_DATE="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
|
||||
CURR_TYPE="${BASH_REMATCH[5]}"
|
||||
CURR_NUM="${BASH_REMATCH[6]:-0}"
|
||||
else
|
||||
CURR_DATE=""
|
||||
CURR_TYPE=""
|
||||
CURR_NUM=0
|
||||
fi
|
||||
|
||||
case "$RELEASE_TYPE" in
|
||||
alpha)
|
||||
if [ "$CURR_DATE" = "$TODAY" ] && [ "$CURR_TYPE" = "alpha" ]; then
|
||||
# Same day, same type - increment prerelease number
|
||||
NEW_NUM=$((CURR_NUM + 1))
|
||||
else
|
||||
# New day or different type - start at 1
|
||||
NEW_NUM=1
|
||||
fi
|
||||
NEW_VERSION="${TODAY}-alpha.${NEW_NUM}"
|
||||
;;
|
||||
beta)
|
||||
if [ "$CURR_DATE" = "$TODAY" ] && [ "$CURR_TYPE" = "beta" ]; then
|
||||
NEW_NUM=$((CURR_NUM + 1))
|
||||
else
|
||||
NEW_NUM=1
|
||||
fi
|
||||
NEW_VERSION="${TODAY}-beta.${NEW_NUM}"
|
||||
;;
|
||||
stable)
|
||||
# Stable releases use date; append counter if tag already exists
|
||||
if git tag -l "v${TODAY}" | grep -q .; then
|
||||
# Tag exists, find next available counter
|
||||
COUNTER=1
|
||||
while git tag -l "v${TODAY}.${COUNTER}" | grep -q .; do
|
||||
COUNTER=$((COUNTER + 1))
|
||||
done
|
||||
NEW_VERSION="${TODAY}.${COUNTER}"
|
||||
else
|
||||
NEW_VERSION="${TODAY}"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Unknown release type: $RELEASE_TYPE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "new=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "New version: $NEW_VERSION"
|
||||
|
||||
- name: Update package.json
|
||||
if: ${{ inputs.should_bump && !inputs.dry_run }}
|
||||
run: |
|
||||
NEW_VERSION="${{ steps.bump-version.outputs.new }}"
|
||||
|
||||
# Update package.json version
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
pkg.version = '$NEW_VERSION';
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
"
|
||||
|
||||
echo "Updated package.json to version $NEW_VERSION"
|
||||
|
||||
- name: Sync extension versions
|
||||
if: ${{ inputs.should_bump && !inputs.dry_run }}
|
||||
run: |
|
||||
# Run plugins:sync if available (aligns extension package versions)
|
||||
if npm run --silent plugins:sync 2>/dev/null; then
|
||||
echo "Extension versions synced"
|
||||
else
|
||||
echo "plugins:sync not available, skipping"
|
||||
fi
|
||||
|
||||
- name: Commit version bump
|
||||
if: ${{ inputs.should_bump && !inputs.dry_run }}
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
NEW_VERSION="${{ steps.bump-version.outputs.new }}"
|
||||
|
||||
# Stage all version-related changes
|
||||
git add package.json
|
||||
git add extensions/*/package.json 2>/dev/null || true
|
||||
|
||||
# Check if there are changes to commit
|
||||
if git diff --cached --quiet; then
|
||||
echo "No version changes to commit"
|
||||
else
|
||||
git commit -m "chore: bump version to $NEW_VERSION"
|
||||
git push origin ${{ inputs.source_branch }}
|
||||
fi
|
||||
|
||||
- name: Create tag
|
||||
if: ${{ inputs.should_bump && !inputs.dry_run }}
|
||||
run: |
|
||||
TAG="${{ steps.bump-version.outputs.tag }}"
|
||||
|
||||
git tag -a "$TAG" -m "Release $TAG"
|
||||
git push origin "$TAG"
|
||||
5
.github/workflows/workflow-sanity.yml
vendored
5
.github/workflows/workflow-sanity.yml
vendored
@@ -3,6 +3,11 @@ name: Workflow Sanity
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: workflow-sanity-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
no-tabs:
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"assets/",
|
||||
"dist/",
|
||||
"docs/_layouts/",
|
||||
"extensions/",
|
||||
"node_modules/",
|
||||
"patches/",
|
||||
"pnpm-lock.yaml/",
|
||||
|
||||
@@ -10,10 +10,12 @@ Docs: https://docs.openclaw.ai
|
||||
- Cron: `cron run` defaults to force execution; use `--due` to restrict to due-only. (#10776) Thanks @tyler6204.
|
||||
- Models: support Anthropic Opus 4.6 and OpenAI Codex gpt-5.3-codex (forward-compat fallbacks). (#9853, #10720, #9995) Thanks @TinyTb, @calvin-hpnet, @tyler6204.
|
||||
- Providers: add xAI (Grok) support. (#9885) Thanks @grp06.
|
||||
- Providers: add Baidu Qianfan support. (#8868) Thanks @ide-rea.
|
||||
- Web UI: add token usage dashboard. (#10072) Thanks @Takhoffman.
|
||||
- Memory: native Voyage AI support. (#7078) Thanks @mcinteerj.
|
||||
- Sessions: cap sessions_history payloads to reduce context overflow. (#10000) Thanks @gut-puncture.
|
||||
- CLI: sort commands alphabetically in help output. (#8068) Thanks @deepsoumya617.
|
||||
- CI: optimize pipeline throughput (macOS consolidation, Windows perf, workflow concurrency). (#10784) Thanks @mcaxtr.
|
||||
- Agents: bump pi-mono to 0.52.7; add embedded forward-compat fallback for Opus 4.6 model ids.
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,6 +25,9 @@ Welcome to the lobster tank! 🦞
|
||||
- **Gustavo Madeira Santana** - Multi-agents, CLI, web UI
|
||||
- GitHub: [@gumadeiras](https://github.com/gumadeiras) · X: [@gumadeiras](https://x.com/gumadeiras)
|
||||
|
||||
- **Maximilian Nussbaumer** - DevOps, CI/CD
|
||||
- GitHub: [@quotentiroler](https://github.com/quotentiroler)
|
||||
|
||||
## How to Contribute
|
||||
|
||||
1. **Bugs & small fixes** → Open a PR!
|
||||
@@ -76,3 +79,44 @@ We are currently prioritizing:
|
||||
- **Performance**: Optimizing token usage and compaction logic.
|
||||
|
||||
Check the [GitHub Issues](https://github.com/openclaw/openclaw/issues) for "good first issue" labels!
|
||||
|
||||
## Core vs ClawHub
|
||||
|
||||
Not everything belongs in the main repo. Here's how to decide:
|
||||
|
||||
| Belongs in **Core** | Belongs on **[ClawHub](https://clawhub.ai)** |
|
||||
| ---------------------------------------------- | ---------------------------------------------------- |
|
||||
| Channel integrations (Telegram, Discord, etc.) | Domain-specific skills (QR codes, image tools, etc.) |
|
||||
| CLI commands and infrastructure | Custom workflows and automations |
|
||||
| Provider integrations (LLM backends) | Niche or experimental utilities |
|
||||
| Security, routing, and core plumbing | Third-party service integrations |
|
||||
|
||||
**Rule of thumb:** if it adds new dependencies or is useful to some users but not most, it belongs on ClawHub. When in doubt, ask in Discord or open a Discussion before writing code.
|
||||
|
||||
Skills submitted as PRs to this repo will be redirected to ClawHub. If the core maintainers later decide certain functionality should be first-party, it will be integrated into core.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
We use staged branch promotion to keep `main` stable:
|
||||
|
||||
```
|
||||
dev/* / feature/* / fix/* → develop → alpha → beta → main
|
||||
```
|
||||
|
||||
### For External Contributors
|
||||
|
||||
1. Fork the repo
|
||||
2. Create your branch (`dev/my-feature`, `fix/some-bug`, etc.)
|
||||
3. Open a PR targeting `develop` (not `main`)
|
||||
4. CI runs lightweight checks only — no heavy platform tests on your PR
|
||||
5. Once merged to `develop`, your changes promote through alpha → beta → main automatically
|
||||
|
||||
**Do not target `main`** — PRs to `main` will be redirected to `develop`.
|
||||
|
||||
### For Maintainers
|
||||
|
||||
- **Regular changes**: merge to `develop`, let the pipeline promote
|
||||
- **Hotfixes**: use `hotfix/*` branches for emergency fixes that bypass staging directly to `main`
|
||||
- **Docs-only changes**: skip the test pipeline automatically (paths-ignore)
|
||||
|
||||
See [Pipeline docs](https://docs.openclaw.ai/reference/pipeline) for full details.
|
||||
|
||||
@@ -44,5 +44,5 @@ USER node
|
||||
#
|
||||
# For container platforms requiring external health checks:
|
||||
# 1. Set OPENCLAW_GATEWAY_TOKEN or OPENCLAW_GATEWAY_PASSWORD env var
|
||||
# 2. Override CMD: ["node","dist/index.js","gateway","--allow-unconfigured","--bind","lan"]
|
||||
CMD ["node", "dist/index.js", "gateway", "--allow-unconfigured"]
|
||||
# 2. Override CMD: ["node","openclaw.mjs","gateway","--allow-unconfigured","--bind","lan"]
|
||||
CMD ["node", "openclaw.mjs", "gateway", "--allow-unconfigured"]
|
||||
|
||||
@@ -17,6 +17,8 @@ the right time, and can optionally deliver output back to a chat.
|
||||
If you want _“run this every morning”_ or _“poke the agent in 20 minutes”_,
|
||||
cron is the mechanism.
|
||||
|
||||
Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
||||
|
||||
## TL;DR
|
||||
|
||||
- Cron runs **inside the Gateway** (not inside the model).
|
||||
|
||||
122
docs/automation/troubleshooting.md
Normal file
122
docs/automation/troubleshooting.md
Normal file
@@ -0,0 +1,122 @@
|
||||
---
|
||||
summary: "Troubleshoot cron and heartbeat scheduling and delivery"
|
||||
read_when:
|
||||
- Cron did not run
|
||||
- Cron ran but no message was delivered
|
||||
- Heartbeat seems silent or skipped
|
||||
title: "Automation Troubleshooting"
|
||||
---
|
||||
|
||||
# Automation troubleshooting
|
||||
|
||||
Use this page for scheduler and delivery issues (`cron` + `heartbeat`).
|
||||
|
||||
## Command ladder
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
Then run automation checks:
|
||||
|
||||
```bash
|
||||
openclaw cron status
|
||||
openclaw cron list
|
||||
openclaw system heartbeat last
|
||||
```
|
||||
|
||||
## Cron not firing
|
||||
|
||||
```bash
|
||||
openclaw cron status
|
||||
openclaw cron list
|
||||
openclaw cron runs --id <jobId> --limit 20
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
Good output looks like:
|
||||
|
||||
- `cron status` reports enabled and a future `nextWakeAtMs`.
|
||||
- Job is enabled and has a valid schedule/timezone.
|
||||
- `cron runs` shows `ok` or explicit skip reason.
|
||||
|
||||
Common signatures:
|
||||
|
||||
- `cron: scheduler disabled; jobs will not run automatically` → cron disabled in config/env.
|
||||
- `cron: timer tick failed` → scheduler tick crashed; inspect surrounding stack/log context.
|
||||
- `reason: not-due` in run output → manual run called without `--force` and job not due yet.
|
||||
|
||||
## Cron fired but no delivery
|
||||
|
||||
```bash
|
||||
openclaw cron runs --id <jobId> --limit 20
|
||||
openclaw cron list
|
||||
openclaw channels status --probe
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
Good output looks like:
|
||||
|
||||
- Run status is `ok`.
|
||||
- Delivery mode/target are set for isolated jobs.
|
||||
- Channel probe reports target channel connected.
|
||||
|
||||
Common signatures:
|
||||
|
||||
- Run succeeded but delivery mode is `none` → no external message is expected.
|
||||
- Delivery target missing/invalid (`channel`/`to`) → run may succeed internally but skip outbound.
|
||||
- Channel auth errors (`unauthorized`, `missing_scope`, `Forbidden`) → delivery blocked by channel credentials/permissions.
|
||||
|
||||
## Heartbeat suppressed or skipped
|
||||
|
||||
```bash
|
||||
openclaw system heartbeat last
|
||||
openclaw logs --follow
|
||||
openclaw config get agents.defaults.heartbeat
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
Good output looks like:
|
||||
|
||||
- Heartbeat enabled with non-zero interval.
|
||||
- Last heartbeat result is `ran` (or skip reason is understood).
|
||||
|
||||
Common signatures:
|
||||
|
||||
- `heartbeat skipped` with `reason=quiet-hours` → outside `activeHours`.
|
||||
- `requests-in-flight` → main lane busy; heartbeat deferred.
|
||||
- `empty-heartbeat-file` → `HEARTBEAT.md` exists but has no actionable content.
|
||||
- `alerts-disabled` → visibility settings suppress outbound heartbeat messages.
|
||||
|
||||
## Timezone and activeHours gotchas
|
||||
|
||||
```bash
|
||||
openclaw config get agents.defaults.heartbeat.activeHours
|
||||
openclaw config get agents.defaults.heartbeat.activeHours.timezone
|
||||
openclaw config get agents.defaults.userTimezone || echo "agents.defaults.userTimezone not set"
|
||||
openclaw cron list
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
Quick rules:
|
||||
|
||||
- `Config path not found: agents.defaults.userTimezone` means the key is unset; heartbeat falls back to host timezone (or `activeHours.timezone` if set).
|
||||
- Cron without `--tz` uses gateway host timezone.
|
||||
- Heartbeat `activeHours` uses configured timezone resolution (`user`, `local`, or explicit IANA tz).
|
||||
- ISO timestamps without timezone are treated as UTC for cron `at` schedules.
|
||||
|
||||
Common signatures:
|
||||
|
||||
- Jobs run at the wrong wall-clock time after host timezone changes.
|
||||
- Heartbeat always skipped during your daytime because `activeHours.timezone` is wrong.
|
||||
|
||||
Related:
|
||||
|
||||
- [/automation/cron-jobs](/automation/cron-jobs)
|
||||
- [/gateway/heartbeat](/gateway/heartbeat)
|
||||
- [/automation/cron-vs-heartbeat](/automation/cron-vs-heartbeat)
|
||||
- [/concepts/timezone](/concepts/timezone)
|
||||
@@ -202,6 +202,32 @@ Once verified, the bot can decrypt messages in encrypted rooms.
|
||||
| Location | ✅ Supported (geo URI; altitude ignored) |
|
||||
| Native commands | ✅ Supported |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run this ladder first:
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
Then confirm DM pairing state if needed:
|
||||
|
||||
```bash
|
||||
openclaw pairing list matrix
|
||||
```
|
||||
|
||||
Common failures:
|
||||
|
||||
- Logged in but room messages ignored: room blocked by `groupPolicy` or room allowlist.
|
||||
- DMs ignored: sender pending approval when `channels.matrix.dm.policy="pairing"`.
|
||||
- Encrypted rooms fail: crypto support or encryption settings mismatch.
|
||||
|
||||
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
||||
|
||||
## Configuration reference (Matrix)
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
@@ -168,6 +168,32 @@ Config:
|
||||
- Groups: `signal:group:<groupId>`.
|
||||
- Usernames: `username:<name>` (if supported by your Signal account).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run this ladder first:
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
Then confirm DM pairing state if needed:
|
||||
|
||||
```bash
|
||||
openclaw pairing list signal
|
||||
```
|
||||
|
||||
Common failures:
|
||||
|
||||
- Daemon reachable but no replies: verify account/daemon settings (`httpUrl`, `account`) and receive mode.
|
||||
- DMs ignored: sender is pending pairing approval.
|
||||
- Group messages ignored: group sender/mention gating blocks delivery.
|
||||
|
||||
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
||||
|
||||
## Configuration reference (Signal)
|
||||
|
||||
Full configuration: [Configuration](/gateway/configuration)
|
||||
|
||||
@@ -537,6 +537,32 @@ Slack tool actions can be gated with `channels.slack.actions.*`:
|
||||
scopes you expect (`chat:write`, `reactions:write`, `pins:write`,
|
||||
`files:write`) or those operations will fail.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Run this ladder first:
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
Then confirm DM pairing state if needed:
|
||||
|
||||
```bash
|
||||
openclaw pairing list slack
|
||||
```
|
||||
|
||||
Common failures:
|
||||
|
||||
- Connected but no channel replies: channel blocked by `groupPolicy` or not in `channels.slack.channels` allowlist.
|
||||
- DMs ignored: sender not approved when `channels.slack.dm.policy="pairing"`.
|
||||
- API errors (`missing_scope`, `not_in_channel`, auth failures): bot/app tokens or Slack scopes are incomplete.
|
||||
|
||||
For triage flow: [/channels/troubleshooting](/channels/troubleshooting).
|
||||
|
||||
## Notes
|
||||
|
||||
- Mention gating is controlled via `channels.slack.channels` (set `requireMention` to `true`); `agents.list[].groupChat.mentionPatterns` (or `messages.groupChat.mentionPatterns`) also count as mentions.
|
||||
|
||||
@@ -1,30 +1,116 @@
|
||||
---
|
||||
summary: "Channel-specific troubleshooting shortcuts (Discord/Telegram/WhatsApp/iMessage)"
|
||||
summary: "Fast channel level troubleshooting with per channel failure signatures and fixes"
|
||||
read_when:
|
||||
- A channel connects but messages don’t flow
|
||||
- Investigating channel misconfiguration (intents, permissions, privacy mode)
|
||||
- Channel transport says connected but replies fail
|
||||
- You need channel specific checks before deep provider docs
|
||||
title: "Channel Troubleshooting"
|
||||
---
|
||||
|
||||
# Channel troubleshooting
|
||||
|
||||
Start with:
|
||||
Use this page when a channel connects but behavior is wrong.
|
||||
|
||||
## Command ladder
|
||||
|
||||
Run these in order first:
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
`channels status --probe` prints warnings when it can detect common channel misconfigurations, and includes small live checks (credentials, some permissions/membership).
|
||||
Healthy baseline:
|
||||
|
||||
## Channels
|
||||
- `Runtime: running`
|
||||
- `RPC probe: ok`
|
||||
- Channel probe shows connected/ready
|
||||
|
||||
- Discord: [/channels/discord#troubleshooting](/channels/discord#troubleshooting)
|
||||
- Telegram: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
|
||||
- WhatsApp: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick)
|
||||
- iMessage (legacy): [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc)
|
||||
## WhatsApp
|
||||
|
||||
## Telegram quick fixes
|
||||
### WhatsApp failure signatures
|
||||
|
||||
- Logs show `HttpError: Network request for 'sendMessage' failed` or `sendChatAction` → check IPv6 DNS. If `api.telegram.org` resolves to IPv6 first and the host lacks IPv6 egress, force IPv4 or enable IPv6. See [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting).
|
||||
- Logs show `setMyCommands failed` → check outbound HTTPS and DNS reachability to `api.telegram.org` (common on locked-down VPS or proxies).
|
||||
| Symptom | Fastest check | Fix |
|
||||
| ------------------------------- | --------------------------------------------------- | ------------------------------------------------------- |
|
||||
| Connected but no DM replies | `openclaw pairing list whatsapp` | Approve sender or switch DM policy/allowlist. |
|
||||
| Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. |
|
||||
| Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Re-login and verify credentials directory is healthy. |
|
||||
|
||||
Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick)
|
||||
|
||||
## Telegram
|
||||
|
||||
### Telegram failure signatures
|
||||
|
||||
| Symptom | Fastest check | Fix |
|
||||
| --------------------------------- | ----------------------------------------------- | --------------------------------------------------------- |
|
||||
| `/start` but no usable reply flow | `openclaw pairing list telegram` | Approve pairing or change DM policy. |
|
||||
| Bot online but group stays silent | Verify mention requirement and bot privacy mode | Disable privacy mode for group visibility or mention bot. |
|
||||
| Send failures with network errors | Inspect logs for Telegram API call failures | Fix DNS/IPv6/proxy routing to `api.telegram.org`. |
|
||||
|
||||
Full troubleshooting: [/channels/telegram#troubleshooting](/channels/telegram#troubleshooting)
|
||||
|
||||
## Discord
|
||||
|
||||
### Discord failure signatures
|
||||
|
||||
| Symptom | Fastest check | Fix |
|
||||
| ------------------------------- | ----------------------------------- | --------------------------------------------------------- |
|
||||
| Bot online but no guild replies | `openclaw channels status --probe` | Allow guild/channel and verify message content intent. |
|
||||
| Group messages ignored | Check logs for mention gating drops | Mention bot or set guild/channel `requireMention: false`. |
|
||||
| DM replies missing | `openclaw pairing list discord` | Approve DM pairing or adjust DM policy. |
|
||||
|
||||
Full troubleshooting: [/channels/discord#troubleshooting](/channels/discord#troubleshooting)
|
||||
|
||||
## Slack
|
||||
|
||||
### Slack failure signatures
|
||||
|
||||
| Symptom | Fastest check | Fix |
|
||||
| -------------------------------------- | ----------------------------------------- | ------------------------------------------------- |
|
||||
| Socket mode connected but no responses | `openclaw channels status --probe` | Verify app token + bot token and required scopes. |
|
||||
| DMs blocked | `openclaw pairing list slack` | Approve pairing or relax DM policy. |
|
||||
| Channel message ignored | Check `groupPolicy` and channel allowlist | Allow the channel or switch policy to `open`. |
|
||||
|
||||
Full troubleshooting: [/channels/slack#troubleshooting](/channels/slack#troubleshooting)
|
||||
|
||||
## iMessage and BlueBubbles
|
||||
|
||||
### iMessage and BlueBubbles failure signatures
|
||||
|
||||
| Symptom | Fastest check | Fix |
|
||||
| -------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------- |
|
||||
| No inbound events | Verify webhook/server reachability and app permissions | Fix webhook URL or BlueBubbles server state. |
|
||||
| Can send but no receive on macOS | Check macOS privacy permissions for Messages automation | Re-grant TCC permissions and restart channel process. |
|
||||
| DM sender blocked | `openclaw pairing list imessage` or `openclaw pairing list bluebubbles` | Approve pairing or update allowlist. |
|
||||
|
||||
Full troubleshooting:
|
||||
|
||||
- [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc)
|
||||
- [/channels/bluebubbles#troubleshooting](/channels/bluebubbles#troubleshooting)
|
||||
|
||||
## Signal
|
||||
|
||||
### Signal failure signatures
|
||||
|
||||
| Symptom | Fastest check | Fix |
|
||||
| ------------------------------- | ------------------------------------------ | -------------------------------------------------------- |
|
||||
| Daemon reachable but bot silent | `openclaw channels status --probe` | Verify `signal-cli` daemon URL/account and receive mode. |
|
||||
| DM blocked | `openclaw pairing list signal` | Approve sender or adjust DM policy. |
|
||||
| Group replies do not trigger | Check group allowlist and mention patterns | Add sender/group or loosen gating. |
|
||||
|
||||
Full troubleshooting: [/channels/signal#troubleshooting](/channels/signal#troubleshooting)
|
||||
|
||||
## Matrix
|
||||
|
||||
### Matrix failure signatures
|
||||
|
||||
| Symptom | Fastest check | Fix |
|
||||
| ----------------------------------- | -------------------------------------------- | ----------------------------------------------- |
|
||||
| Logged in but ignores room messages | `openclaw channels status --probe` | Check `groupPolicy` and room allowlist. |
|
||||
| DMs do not process | `openclaw pairing list matrix` | Approve sender or adjust DM policy. |
|
||||
| Encrypted rooms fail | Verify crypto module and encryption settings | Enable encryption support and rejoin/sync room. |
|
||||
|
||||
Full troubleshooting: [/channels/matrix#troubleshooting](/channels/matrix#troubleshooting)
|
||||
|
||||
@@ -98,6 +98,10 @@
|
||||
"source": "/opencode",
|
||||
"destination": "/providers/opencode"
|
||||
},
|
||||
{
|
||||
"source": "/qianfan",
|
||||
"destination": "/providers/qianfan"
|
||||
},
|
||||
{
|
||||
"source": "/mattermost",
|
||||
"destination": "/channels/mattermost"
|
||||
@@ -660,7 +664,7 @@
|
||||
},
|
||||
{
|
||||
"source": "/troubleshooting",
|
||||
"destination": "/gateway/troubleshooting"
|
||||
"destination": "/help/troubleshooting"
|
||||
},
|
||||
{
|
||||
"source": "/web/tui",
|
||||
@@ -962,6 +966,7 @@
|
||||
"hooks/soul-evil",
|
||||
"automation/cron-jobs",
|
||||
"automation/cron-vs-heartbeat",
|
||||
"automation/troubleshooting",
|
||||
"automation/webhook",
|
||||
"automation/gmail-pubsub",
|
||||
"automation/poll",
|
||||
@@ -972,6 +977,7 @@
|
||||
"group": "Media and devices",
|
||||
"pages": [
|
||||
"nodes/index",
|
||||
"nodes/troubleshooting",
|
||||
"nodes/images",
|
||||
"nodes/audio",
|
||||
"nodes/camera",
|
||||
@@ -1006,7 +1012,8 @@
|
||||
"providers/opencode",
|
||||
"providers/glm",
|
||||
"providers/zai",
|
||||
"providers/synthetic"
|
||||
"providers/synthetic",
|
||||
"providers/qianfan"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1453,7 +1453,7 @@ working directory). The path must exist to be used.
|
||||
|
||||
### `agents.defaults.skipBootstrap`
|
||||
|
||||
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, and `BOOTSTRAP.md`).
|
||||
Disables automatic creation of the workspace bootstrap files (`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`, and `BOOTSTRAP.md`).
|
||||
|
||||
Use this for pre-seeded deployments where your workspace files come from a repo.
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ title: "Heartbeat"
|
||||
Heartbeat runs **periodic agent turns** in the main session so the model can
|
||||
surface anything that needs attention without spamming you.
|
||||
|
||||
Troubleshooting: [/automation/troubleshooting](/automation/troubleshooting)
|
||||
|
||||
## Quick start (beginner)
|
||||
|
||||
1. Leave heartbeats enabled (default is `30m`, or `1h` for Anthropic OAuth/setup-token) or set your own cadence.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,98 +1,265 @@
|
||||
---
|
||||
summary: "Troubleshooting hub: symptoms → checks → fixes"
|
||||
summary: "Symptom first troubleshooting hub for OpenClaw"
|
||||
read_when:
|
||||
- You see an error and want the fix path
|
||||
- The installer says “success” but the CLI doesn’t work
|
||||
- OpenClaw is not working and you need the fastest path to a fix
|
||||
- You want a triage flow before diving into deep runbooks
|
||||
title: "Troubleshooting"
|
||||
---
|
||||
|
||||
# Troubleshooting
|
||||
|
||||
If you only have 2 minutes, use this page as a triage front door.
|
||||
|
||||
## First 60 seconds
|
||||
|
||||
Run these in order:
|
||||
Run this exact ladder in order:
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw status --all
|
||||
openclaw gateway probe
|
||||
openclaw logs --follow
|
||||
openclaw gateway status
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
If the gateway is reachable, deep probes:
|
||||
Good output in one line:
|
||||
|
||||
```bash
|
||||
openclaw status --deep
|
||||
- `openclaw status` → shows configured channels and no obvious auth errors.
|
||||
- `openclaw status --all` → full report is present and shareable.
|
||||
- `openclaw gateway probe` → expected gateway target is reachable.
|
||||
- `openclaw gateway status` → `Runtime: running` and `RPC probe: ok`.
|
||||
- `openclaw doctor` → no blocking config/service errors.
|
||||
- `openclaw channels status --probe` → channels report `connected` or `ready`.
|
||||
- `openclaw logs --follow` → steady activity, no repeating fatal errors.
|
||||
|
||||
## Decision tree
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[OpenClaw is not working] --> B{What breaks first}
|
||||
B --> C[No replies]
|
||||
B --> D[Dashboard or Control UI will not connect]
|
||||
B --> E[Gateway will not start or service not running]
|
||||
B --> F[Channel connects but messages do not flow]
|
||||
B --> G[Cron or heartbeat did not fire or did not deliver]
|
||||
B --> H[Node is paired but camera canvas screen exec fails]
|
||||
B --> I[Browser tool fails]
|
||||
|
||||
C --> C1[/No replies section/]
|
||||
D --> D1[/Control UI section/]
|
||||
E --> E1[/Gateway section/]
|
||||
F --> F1[/Channel flow section/]
|
||||
G --> G1[/Automation section/]
|
||||
H --> H1[/Node tools section/]
|
||||
I --> I1[/Browser section/]
|
||||
```
|
||||
|
||||
## Common “it broke” cases
|
||||
<AccordionGroup>
|
||||
<Accordion title="No replies">
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw channels status --probe
|
||||
openclaw pairing list <channel>
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
### `openclaw: command not found`
|
||||
Good output looks like:
|
||||
|
||||
Almost always a Node/npm PATH issue. Start here:
|
||||
- `Runtime: running`
|
||||
- `RPC probe: ok`
|
||||
- Your channel shows connected/ready in `channels status --probe`
|
||||
- Sender appears approved (or DM policy is open/allowlist)
|
||||
|
||||
- [Install (Node/npm PATH sanity)](/install#nodejs--npm-path-sanity)
|
||||
Common log signatures:
|
||||
|
||||
### Installer fails (or you need full logs)
|
||||
- `drop guild message (mention required` → mention gating blocked the message in Discord.
|
||||
- `pairing request` → sender is unapproved and waiting for DM pairing approval.
|
||||
- `blocked` / `allowlist` in channel logs → sender, room, or group is filtered.
|
||||
|
||||
Re-run the installer in verbose mode to see the full trace and npm output:
|
||||
Deep pages:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --verbose
|
||||
```
|
||||
- [/gateway/troubleshooting#no-replies](/gateway/troubleshooting#no-replies)
|
||||
- [/channels/troubleshooting](/channels/troubleshooting)
|
||||
- [/start/pairing](/start/pairing)
|
||||
|
||||
For beta installs:
|
||||
</Accordion>
|
||||
|
||||
```bash
|
||||
curl -fsSL https://openclaw.ai/install.sh | bash -s -- --beta --verbose
|
||||
```
|
||||
<Accordion title="Dashboard or Control UI will not connect">
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
You can also set `OPENCLAW_VERBOSE=1` instead of the flag.
|
||||
Good output looks like:
|
||||
|
||||
### Gateway “unauthorized”, can’t connect, or keeps reconnecting
|
||||
- `Dashboard: http://...` is shown in `openclaw gateway status`
|
||||
- `RPC probe: ok`
|
||||
- No auth loop in logs
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Gateway authentication](/gateway/authentication)
|
||||
Common log signatures:
|
||||
|
||||
### Control UI fails on HTTP (device identity required)
|
||||
- `device identity required` → HTTP/non-secure context cannot complete device auth.
|
||||
- `unauthorized` / reconnect loop → wrong token/password or auth mode mismatch.
|
||||
- `gateway connect failed:` → UI is targeting the wrong URL/port or unreachable gateway.
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Control UI](/web/control-ui#insecure-http)
|
||||
Deep pages:
|
||||
|
||||
### `docs.openclaw.ai` shows an SSL error (Comcast/Xfinity)
|
||||
- [/gateway/troubleshooting#dashboard-control-ui-connectivity](/gateway/troubleshooting#dashboard-control-ui-connectivity)
|
||||
- [/web/control-ui](/web/control-ui)
|
||||
- [/gateway/authentication](/gateway/authentication)
|
||||
|
||||
Some Comcast/Xfinity connections block `docs.openclaw.ai` via Xfinity Advanced Security.
|
||||
Disable Advanced Security or add `docs.openclaw.ai` to the allowlist, then retry.
|
||||
</Accordion>
|
||||
|
||||
- Xfinity Advanced Security help: [https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security](https://www.xfinity.com/support/articles/using-xfinity-xfi-advanced-security)
|
||||
- Quick sanity checks: try a mobile hotspot or VPN to confirm it’s ISP-level filtering
|
||||
<Accordion title="Gateway will not start or service installed but not running">
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
### Service says running, but RPC probe fails
|
||||
Good output looks like:
|
||||
|
||||
- [Gateway troubleshooting](/gateway/troubleshooting)
|
||||
- [Background process / service](/gateway/background-process)
|
||||
- `Service: ... (loaded)`
|
||||
- `Runtime: running`
|
||||
- `RPC probe: ok`
|
||||
|
||||
### Model/auth failures (rate limit, billing, “all models failed”)
|
||||
Common log signatures:
|
||||
|
||||
- [Models](/cli/models)
|
||||
- [OAuth / auth concepts](/concepts/oauth)
|
||||
- `Gateway start blocked: set gateway.mode=local` → gateway mode is unset/remote.
|
||||
- `refusing to bind gateway ... without auth` → non-loopback bind without token/password.
|
||||
- `another gateway instance is already listening` or `EADDRINUSE` → port already taken.
|
||||
|
||||
### `/model` says `model not allowed`
|
||||
Deep pages:
|
||||
|
||||
This usually means `agents.defaults.models` is configured as an allowlist. When it’s non-empty,
|
||||
only those provider/model keys can be selected.
|
||||
- [/gateway/troubleshooting#gateway-service-not-running](/gateway/troubleshooting#gateway-service-not-running)
|
||||
- [/gateway/background-process](/gateway/background-process)
|
||||
- [/gateway/configuration](/gateway/configuration)
|
||||
|
||||
- Check the allowlist: `openclaw config get agents.defaults.models`
|
||||
- Add the model you want (or clear the allowlist) and retry `/model`
|
||||
- Use `/models` to browse the allowed providers/models
|
||||
</Accordion>
|
||||
|
||||
### When filing an issue
|
||||
<Accordion title="Channel connects but messages do not flow">
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
Paste a safe report:
|
||||
Good output looks like:
|
||||
|
||||
```bash
|
||||
openclaw status --all
|
||||
```
|
||||
- Channel transport is connected.
|
||||
- Pairing/allowlist checks pass.
|
||||
- Mentions are detected where required.
|
||||
|
||||
If you can, include the relevant log tail from `openclaw logs --follow`.
|
||||
Common log signatures:
|
||||
|
||||
- `mention required` → group mention gating blocked processing.
|
||||
- `pairing` / `pending` → DM sender is not approved yet.
|
||||
- `not_in_channel`, `missing_scope`, `Forbidden`, `401/403` → channel permission token issue.
|
||||
|
||||
Deep pages:
|
||||
|
||||
- [/gateway/troubleshooting#channel-connected-messages-not-flowing](/gateway/troubleshooting#channel-connected-messages-not-flowing)
|
||||
- [/channels/troubleshooting](/channels/troubleshooting)
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Cron or heartbeat did not fire or did not deliver">
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw cron status
|
||||
openclaw cron list
|
||||
openclaw cron runs --id <jobId> --limit 20
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
Good output looks like:
|
||||
|
||||
- `cron.status` shows enabled with a next wake.
|
||||
- `cron runs` shows recent `ok` entries.
|
||||
- Heartbeat is enabled and not outside active hours.
|
||||
|
||||
Common log signatures:
|
||||
|
||||
- `cron: scheduler disabled; jobs will not run automatically` → cron is disabled.
|
||||
- `heartbeat skipped` with `reason=quiet-hours` → outside configured active hours.
|
||||
- `requests-in-flight` → main lane busy; heartbeat wake was deferred.
|
||||
- `unknown accountId` → heartbeat delivery target account does not exist.
|
||||
|
||||
Deep pages:
|
||||
|
||||
- [/gateway/troubleshooting#cron-and-heartbeat-delivery](/gateway/troubleshooting#cron-and-heartbeat-delivery)
|
||||
- [/automation/troubleshooting](/automation/troubleshooting)
|
||||
- [/gateway/heartbeat](/gateway/heartbeat)
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Node is paired but tool fails camera canvas screen exec">
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw nodes status
|
||||
openclaw nodes describe --node <idOrNameOrIp>
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
Good output looks like:
|
||||
|
||||
- Node is listed as connected and paired for role `node`.
|
||||
- Capability exists for the command you are invoking.
|
||||
- Permission state is granted for the tool.
|
||||
|
||||
Common log signatures:
|
||||
|
||||
- `NODE_BACKGROUND_UNAVAILABLE` → bring node app to foreground.
|
||||
- `*_PERMISSION_REQUIRED` → OS permission was denied/missing.
|
||||
- `SYSTEM_RUN_DENIED: approval required` → exec approval is pending.
|
||||
- `SYSTEM_RUN_DENIED: allowlist miss` → command not on exec allowlist.
|
||||
|
||||
Deep pages:
|
||||
|
||||
- [/gateway/troubleshooting#node-paired-tool-fails](/gateway/troubleshooting#node-paired-tool-fails)
|
||||
- [/nodes/troubleshooting](/nodes/troubleshooting)
|
||||
- [/tools/exec-approvals](/tools/exec-approvals)
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Browser tool fails">
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw browser status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
```
|
||||
|
||||
Good output looks like:
|
||||
|
||||
- Browser status shows `running: true` and a chosen browser/profile.
|
||||
- `openclaw` profile starts or `chrome` relay has an attached tab.
|
||||
|
||||
Common log signatures:
|
||||
|
||||
- `Failed to start Chrome CDP on port` → local browser launch failed.
|
||||
- `browser.executablePath not found` → configured binary path is wrong.
|
||||
- `Chrome extension relay is running, but no tab is connected` → extension not attached.
|
||||
- `Browser attachOnly is enabled ... not reachable` → attach-only profile has no live CDP target.
|
||||
|
||||
Deep pages:
|
||||
|
||||
- [/gateway/troubleshooting#browser-tool-fails](/gateway/troubleshooting#browser-tool-fails)
|
||||
- [/tools/browser-linux-troubleshooting](/tools/browser-linux-troubleshooting)
|
||||
- [/tools/chrome-extension](/tools/chrome-extension)
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -19,6 +19,7 @@ Notes:
|
||||
|
||||
- Nodes are **peripherals**, not gateways. They don’t run the gateway service.
|
||||
- Telegram/WhatsApp/etc. messages land on the **gateway**, not on nodes.
|
||||
- Troubleshooting runbook: [/nodes/troubleshooting](/nodes/troubleshooting)
|
||||
|
||||
## Pairing + status
|
||||
|
||||
|
||||
112
docs/nodes/troubleshooting.md
Normal file
112
docs/nodes/troubleshooting.md
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
summary: "Troubleshoot node pairing, foreground requirements, permissions, and tool failures"
|
||||
read_when:
|
||||
- Node is connected but camera/canvas/screen/exec tools fail
|
||||
- You need the node pairing versus approvals mental model
|
||||
title: "Node Troubleshooting"
|
||||
---
|
||||
|
||||
# Node troubleshooting
|
||||
|
||||
Use this page when a node is visible in status but node tools fail.
|
||||
|
||||
## Command ladder
|
||||
|
||||
```bash
|
||||
openclaw status
|
||||
openclaw gateway status
|
||||
openclaw logs --follow
|
||||
openclaw doctor
|
||||
openclaw channels status --probe
|
||||
```
|
||||
|
||||
Then run node specific checks:
|
||||
|
||||
```bash
|
||||
openclaw nodes status
|
||||
openclaw nodes describe --node <idOrNameOrIp>
|
||||
openclaw approvals get --node <idOrNameOrIp>
|
||||
```
|
||||
|
||||
Healthy signals:
|
||||
|
||||
- Node is connected and paired for role `node`.
|
||||
- `nodes describe` includes the capability you are calling.
|
||||
- Exec approvals show expected mode/allowlist.
|
||||
|
||||
## Foreground requirements
|
||||
|
||||
`canvas.*`, `camera.*`, and `screen.*` are foreground only on iOS/Android nodes.
|
||||
|
||||
Quick check and fix:
|
||||
|
||||
```bash
|
||||
openclaw nodes describe --node <idOrNameOrIp>
|
||||
openclaw nodes canvas snapshot --node <idOrNameOrIp>
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
If you see `NODE_BACKGROUND_UNAVAILABLE`, bring the node app to the foreground and retry.
|
||||
|
||||
## Permissions matrix
|
||||
|
||||
| Capability | iOS | Android | macOS node app | Typical failure code |
|
||||
| ---------------------------- | --------------------------------------- | -------------------------------------------- | ----------------------------- | ------------------------------ |
|
||||
| `camera.snap`, `camera.clip` | Camera (+ mic for clip audio) | Camera (+ mic for clip audio) | Camera (+ mic for clip audio) | `*_PERMISSION_REQUIRED` |
|
||||
| `screen.record` | Screen Recording (+ mic optional) | Screen capture prompt (+ mic optional) | Screen Recording | `*_PERMISSION_REQUIRED` |
|
||||
| `location.get` | While Using or Always (depends on mode) | Foreground/Background location based on mode | Location permission | `LOCATION_PERMISSION_REQUIRED` |
|
||||
| `system.run` | n/a (node host path) | n/a (node host path) | Exec approvals required | `SYSTEM_RUN_DENIED` |
|
||||
|
||||
## Pairing versus approvals
|
||||
|
||||
These are different gates:
|
||||
|
||||
1. **Device pairing**: can this node connect to the gateway?
|
||||
2. **Exec approvals**: can this node run a specific shell command?
|
||||
|
||||
Quick checks:
|
||||
|
||||
```bash
|
||||
openclaw devices list
|
||||
openclaw nodes status
|
||||
openclaw approvals get --node <idOrNameOrIp>
|
||||
openclaw approvals allowlist add --node <idOrNameOrIp> "/usr/bin/uname"
|
||||
```
|
||||
|
||||
If pairing is missing, approve the node device first.
|
||||
If pairing is fine but `system.run` fails, fix exec approvals/allowlist.
|
||||
|
||||
## Common node error codes
|
||||
|
||||
- `NODE_BACKGROUND_UNAVAILABLE` → app is backgrounded; bring it foreground.
|
||||
- `CAMERA_DISABLED` → camera toggle disabled in node settings.
|
||||
- `*_PERMISSION_REQUIRED` → OS permission missing/denied.
|
||||
- `LOCATION_DISABLED` → location mode is off.
|
||||
- `LOCATION_PERMISSION_REQUIRED` → requested location mode not granted.
|
||||
- `LOCATION_BACKGROUND_UNAVAILABLE` → app is backgrounded but only While Using permission exists.
|
||||
- `SYSTEM_RUN_DENIED: approval required` → exec request needs explicit approval.
|
||||
- `SYSTEM_RUN_DENIED: allowlist miss` → command blocked by allowlist mode.
|
||||
|
||||
## Fast recovery loop
|
||||
|
||||
```bash
|
||||
openclaw nodes status
|
||||
openclaw nodes describe --node <idOrNameOrIp>
|
||||
openclaw approvals get --node <idOrNameOrIp>
|
||||
openclaw logs --follow
|
||||
```
|
||||
|
||||
If still stuck:
|
||||
|
||||
- Re-approve device pairing.
|
||||
- Re-open node app (foreground).
|
||||
- Re-grant OS permissions.
|
||||
- Recreate/adjust exec approval policy.
|
||||
|
||||
Related:
|
||||
|
||||
- [/nodes/index](/nodes/index)
|
||||
- [/nodes/camera](/nodes/camera)
|
||||
- [/nodes/location-command](/nodes/location-command)
|
||||
- [/tools/exec-approvals](/tools/exec-approvals)
|
||||
- [/gateway/pairing](/gateway/pairing)
|
||||
@@ -50,6 +50,7 @@ See [Venice AI](/providers/venice).
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Venice (Venice AI, privacy-focused)](/providers/venice)
|
||||
- [Ollama (local models)](/providers/ollama)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
|
||||
## Transcription providers
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ See [Venice AI](/providers/venice).
|
||||
- [MiniMax](/providers/minimax)
|
||||
- [Venice (Venice AI)](/providers/venice)
|
||||
- [Amazon Bedrock](/bedrock)
|
||||
- [Qianfan](/providers/qianfan)
|
||||
|
||||
For the full provider catalog (xAI, Groq, Mistral, etc.) and advanced configuration,
|
||||
see [Model providers](/concepts/model-providers).
|
||||
|
||||
38
docs/providers/qianfan.md
Normal file
38
docs/providers/qianfan.md
Normal file
@@ -0,0 +1,38 @@
|
||||
---
|
||||
summary: "Use Qianfan's unified API to access many models in OpenClaw"
|
||||
read_when:
|
||||
- You want a single API key for many LLMs
|
||||
- You need Baidu Qianfan setup guidance
|
||||
title: "Qianfan"
|
||||
---
|
||||
|
||||
# Qianfan Provider Guide
|
||||
|
||||
Qianfan is Baidu's MaaS platform, provides a **unified API** that routes requests to many models behind a single
|
||||
endpoint and API key. It is OpenAI-compatible, so most OpenAI SDKs work by switching the base URL.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Baidu Cloud account with Qianfan API access
|
||||
2. An API key from the Qianfan console
|
||||
3. OpenClaw installed on your system
|
||||
|
||||
## Getting Your API Key
|
||||
|
||||
1. Visit the [Qianfan Console](https://console.bce.baidu.com/qianfan/ais/console/apiKey)
|
||||
2. Create a new application or select an existing one
|
||||
3. Generate an API key (format: `bce-v3/ALTAK-...`)
|
||||
4. Copy the API key for use with OpenClaw
|
||||
|
||||
## CLI setup
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice qianfan-api-key
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [OpenClaw Configuration](/configuration)
|
||||
- [Model Providers](/concepts/model-providers)
|
||||
- [Agent Setup](/agents)
|
||||
- [Qianfan API Documentation](https://cloud.baidu.com/doc/qianfan-api/s/3m7of64lb)
|
||||
131
docs/reference/pipeline.md
Normal file
131
docs/reference/pipeline.md
Normal file
@@ -0,0 +1,131 @@
|
||||
# Release Pipeline
|
||||
|
||||
This document describes openclaw's staged release pipeline for contributors and maintainers.
|
||||
|
||||
## Branch Strategy
|
||||
|
||||
```
|
||||
dev/* ──────► develop ──────► alpha ──────► beta ──────► main
|
||||
feature/* │ │ │ │
|
||||
fix/* │ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
Internal Alpha Beta Stable
|
||||
testing testers testers release
|
||||
```
|
||||
|
||||
### Branch Purposes
|
||||
|
||||
| Branch | Purpose | npm tag | Who uses it |
|
||||
| ----------------------------- | ------------------- | --------- | ---------------- |
|
||||
| `dev/*`, `feature/*`, `fix/*` | Active development | - | Contributors |
|
||||
| `develop` | Integration branch | - | CI validation |
|
||||
| `alpha` | Early testing | `@alpha` | Internal testers |
|
||||
| `beta` | Pre-release testing | `@beta` | Beta testers |
|
||||
| `main` | Production releases | `@latest` | Everyone |
|
||||
|
||||
## Workflow Overview
|
||||
|
||||
### 1. Feature Development
|
||||
|
||||
1. Create a branch: `git checkout -b dev/my-feature`
|
||||
2. Make changes and push
|
||||
3. **Auto-PR created** to `develop` via `feature-pr.yml`
|
||||
4. Get review, iterate, merge to `develop`
|
||||
|
||||
### 2. Promotion Through Stages
|
||||
|
||||
When code lands in `develop`, the `promote-branch.yml` workflow:
|
||||
|
||||
1. Runs tests appropriate for that stage
|
||||
2. Creates a PR to the next branch (develop → alpha → beta → main)
|
||||
3. Auto-merges `develop → alpha` if tests pass
|
||||
4. Requires manual approval for `alpha → beta` and `beta → main`
|
||||
|
||||
### 3. Releases
|
||||
|
||||
Releases are triggered manually via the **Release** workflow:
|
||||
|
||||
1. Go to Actions → Release → Run workflow
|
||||
2. Select release type: `alpha`, `beta`, or `stable`
|
||||
3. Workflow runs: version bump → changelog → tests → npm publish → Docker push
|
||||
|
||||
## Test Coverage by Stage
|
||||
|
||||
| Stage | Tests Run |
|
||||
| ------- | ----------------------------------------------------- |
|
||||
| develop | tsgo, lint, format, protocol, unit tests (Node + Bun) |
|
||||
| alpha | + secrets scan |
|
||||
| beta | + Windows tests |
|
||||
| stable | + macOS tests, install smoke tests |
|
||||
|
||||
## Emergency Hotfixes
|
||||
|
||||
For critical production issues:
|
||||
|
||||
1. Create branch: `git checkout -b hotfix/critical-bug`
|
||||
2. Push → **Auto-PR created** directly to `main`
|
||||
3. Get expedited review (skip staging)
|
||||
4. After merge, cherry-pick to `develop`, `alpha`, `beta` to sync
|
||||
|
||||
```bash
|
||||
# After hotfix merges to main
|
||||
git checkout develop && git cherry-pick <commit-sha> && git push
|
||||
git checkout alpha && git cherry-pick <commit-sha> && git push
|
||||
git checkout beta && git cherry-pick <commit-sha> && git push
|
||||
```
|
||||
|
||||
## npm Installation by Channel
|
||||
|
||||
```bash
|
||||
# Stable (default)
|
||||
npm install -g openclaw
|
||||
|
||||
# Beta testing
|
||||
npm install -g openclaw@beta
|
||||
|
||||
# Alpha testing (bleeding edge)
|
||||
npm install -g openclaw@alpha
|
||||
```
|
||||
|
||||
## Docker Images
|
||||
|
||||
Images are published to GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
# Stable
|
||||
docker pull ghcr.io/openclaw/openclaw:latest
|
||||
|
||||
# Beta
|
||||
docker pull ghcr.io/openclaw/openclaw:beta
|
||||
|
||||
# Specific version
|
||||
docker pull ghcr.io/openclaw/openclaw:2026.2.6
|
||||
```
|
||||
|
||||
## Version Format
|
||||
|
||||
- **Stable**: `YYYY.M.D` (e.g., `2026.2.6`)
|
||||
- **Beta**: `YYYY.M.D-beta.N` (e.g., `2026.2.6-beta.1`)
|
||||
- **Alpha**: `YYYY.M.D-alpha.N` (e.g., `2026.2.6-alpha.3`)
|
||||
|
||||
## Setup
|
||||
|
||||
### Required Secrets
|
||||
|
||||
Configure these in GitHub repo Settings → Secrets and variables → Actions:
|
||||
|
||||
| Secret | Required? | Purpose | How to get it |
|
||||
| --------------------- | -------------------- | ------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `GITHUB_TOKEN` | Automatic | PR creation, Docker registry, branch ops | Provided by GitHub Actions — no setup needed |
|
||||
| `NPM_TOKEN` | Yes (for publishing) | npm publish with `@alpha`, `@beta`, `@latest` tags | npmjs.com → Access Tokens → Generate New Token → Automation |
|
||||
| `DISCORD_WEBHOOK_URL` | Optional | Notifications for promotions, test results, deployments | Discord → Server Settings → Integrations → Webhooks |
|
||||
|
||||
Without `NPM_TOKEN`, the pipeline runs normally but skips npm publishing. Without `DISCORD_WEBHOOK_URL`, notifications are silently skipped.
|
||||
|
||||
### Branch Setup
|
||||
|
||||
Staging branches are auto-created from `main` when the first promotion runs. No manual setup required.
|
||||
|
||||
### Rollback
|
||||
|
||||
The rollback workflow (`Actions → Rollback`) re-tags npm and Docker to a previous version. Requires `NPM_TOKEN` and is manual-trigger only.
|
||||
@@ -80,7 +80,7 @@ When onboarding finishes, we auto-open the dashboard and print a clean (non-toke
|
||||
|
||||
OpenClaw reads operating instructions and “memory” from its workspace directory.
|
||||
|
||||
By default, OpenClaw uses `~/.openclaw/workspace` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it).
|
||||
By default, OpenClaw uses `~/.openclaw/workspace` as the agent workspace, and will create it (plus starter `AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`) automatically on setup/first agent run. `BOOTSTRAP.md` is only created when the workspace is brand new (it should not come back after you delete it). `MEMORY.md` is optional (not auto-created); when present, it is loaded for normal sessions. Subagent sessions only inject `AGENTS.md` and `TOOLS.md`.
|
||||
|
||||
Tip: treat this folder like OpenClaw’s “memory” and make it a git repo (ideally private) so your `AGENTS.md` + memory files are backed up. If git is installed, brand-new workspaces are auto-initialized.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/bluebubbles",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw BlueBubbles channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -26,7 +26,9 @@ const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
||||
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
||||
const trimmed = input?.trim() ?? "";
|
||||
const base = trimmed ? path.basename(trimmed) : "";
|
||||
return base || fallback;
|
||||
const name = base || fallback;
|
||||
// Strip characters that could enable multipart header injection (CWE-93)
|
||||
return name.replace(/[\r\n"\\]/g, "_");
|
||||
}
|
||||
|
||||
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import path from "node:path";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
|
||||
|
||||
@@ -336,10 +337,13 @@ export async function setGroupIconBlueBubbles(
|
||||
const parts: Uint8Array[] = [];
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Sanitize filename to prevent multipart header injection (CWE-93)
|
||||
const safeFilename = path.basename(filename).replace(/[\r\n"\\]/g, "_") || "icon.png";
|
||||
|
||||
// Add file field named "icon" as per API spec
|
||||
parts.push(encoder.encode(`--${boundary}\r\n`));
|
||||
parts.push(
|
||||
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`),
|
||||
encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${safeFilename}"\r\n`),
|
||||
);
|
||||
parts.push(
|
||||
encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`),
|
||||
|
||||
@@ -393,6 +393,48 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 when request body times out (Slow-Loris protection)", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const account = createMockAccount();
|
||||
const config: OpenClawConfig = {};
|
||||
const core = createMockRuntime();
|
||||
setBlueBubblesRuntime(core);
|
||||
|
||||
unregister = registerBlueBubblesWebhookTarget({
|
||||
account,
|
||||
config,
|
||||
runtime: { log: vi.fn(), error: vi.fn() },
|
||||
core,
|
||||
path: "/bluebubbles-webhook",
|
||||
});
|
||||
|
||||
// Create a request that never sends data or ends (simulates slow-loris)
|
||||
const req = new EventEmitter() as IncomingMessage;
|
||||
req.method = "POST";
|
||||
req.url = "/bluebubbles-webhook";
|
||||
req.headers = {};
|
||||
(req as unknown as { socket: { remoteAddress: string } }).socket = {
|
||||
remoteAddress: "127.0.0.1",
|
||||
};
|
||||
req.destroy = vi.fn();
|
||||
|
||||
const res = createMockResponse();
|
||||
|
||||
const handledPromise = handleBlueBubblesWebhookRequest(req, res);
|
||||
|
||||
// Advance past the 30s timeout
|
||||
await vi.advanceTimersByTimeAsync(31_000);
|
||||
|
||||
const handled = await handledPromise;
|
||||
expect(handled).toBe(true);
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(req.destroy).toHaveBeenCalled();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("authenticates via password query parameter", async () => {
|
||||
const account = createMockAccount({ password: "secret-token" });
|
||||
const config: OpenClawConfig = {};
|
||||
|
||||
@@ -508,14 +508,29 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
|
||||
};
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
||||
async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
|
||||
let done = false;
|
||||
const finish = (result: { ok: boolean; value?: unknown; error?: string }) => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish({ ok: false, error: "request body timeout" });
|
||||
req.destroy();
|
||||
}, timeoutMs);
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > maxBytes) {
|
||||
resolve({ ok: false, error: "payload too large" });
|
||||
finish({ ok: false, error: "payload too large" });
|
||||
req.destroy();
|
||||
return;
|
||||
}
|
||||
@@ -525,27 +540,30 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
|
||||
try {
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
if (!raw.trim()) {
|
||||
resolve({ ok: false, error: "empty payload" });
|
||||
finish({ ok: false, error: "empty payload" });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve({ ok: true, value: JSON.parse(raw) as unknown });
|
||||
finish({ ok: true, value: JSON.parse(raw) as unknown });
|
||||
return;
|
||||
} catch {
|
||||
const params = new URLSearchParams(raw);
|
||||
const payload = params.get("payload") ?? params.get("data") ?? params.get("message");
|
||||
if (payload) {
|
||||
resolve({ ok: true, value: JSON.parse(payload) as unknown });
|
||||
finish({ ok: true, value: JSON.parse(payload) as unknown });
|
||||
return;
|
||||
}
|
||||
throw new Error("invalid json");
|
||||
}
|
||||
} catch (err) {
|
||||
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
});
|
||||
req.on("error", (err) => {
|
||||
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
finish({ ok: false, error: err instanceof Error ? err.message : String(err) });
|
||||
});
|
||||
req.on("close", () => {
|
||||
finish({ ok: false, error: "connection closed" });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -16,7 +16,9 @@ export type BlueBubblesServerInfo = {
|
||||
computer_id?: string;
|
||||
};
|
||||
|
||||
/** Cache server info by account ID to avoid repeated API calls */
|
||||
/** Cache server info by account ID to avoid repeated API calls.
|
||||
* Size-capped to prevent unbounded growth (#4948). */
|
||||
const MAX_SERVER_INFO_CACHE_SIZE = 64;
|
||||
const serverInfoCache = new Map<string, { info: BlueBubblesServerInfo; expires: number }>();
|
||||
const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
@@ -56,6 +58,13 @@ export async function fetchBlueBubblesServerInfo(params: {
|
||||
const data = payload?.data as BlueBubblesServerInfo | undefined;
|
||||
if (data) {
|
||||
serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS });
|
||||
// Evict oldest entries if cache exceeds max size
|
||||
if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) {
|
||||
const oldest = serverInfoCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
serverInfoCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
return data ?? null;
|
||||
} catch {
|
||||
|
||||
@@ -370,6 +370,16 @@ describe("send", () => {
|
||||
).rejects.toThrow("requires text");
|
||||
});
|
||||
|
||||
it("throws when text becomes empty after markdown stripping", async () => {
|
||||
// Edge case: input like "***" or "---" passes initial check but becomes empty after stripMarkdown
|
||||
await expect(
|
||||
sendMessageBlueBubbles("+15551234567", "***", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
}),
|
||||
).rejects.toThrow("empty after markdown removal");
|
||||
});
|
||||
|
||||
it("throws when serverUrl is missing", async () => {
|
||||
await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow(
|
||||
"serverUrl is required",
|
||||
@@ -438,6 +448,77 @@ describe("send", () => {
|
||||
expect(body.method).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips markdown formatting from outbound messages", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
data: [
|
||||
{
|
||||
guid: "iMessage;-;+15551234567",
|
||||
participants: [{ address: "+15551234567" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "msg-uuid-stripped" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles(
|
||||
"+15551234567",
|
||||
"**Bold** and *italic* with `code`\n## Header",
|
||||
{
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.messageId).toBe("msg-uuid-stripped");
|
||||
|
||||
const sendCall = mockFetch.mock.calls[1];
|
||||
const body = JSON.parse(sendCall[1].body);
|
||||
// Markdown should be stripped: no asterisks, backticks, or hashes
|
||||
expect(body.message).toBe("Bold and italic with code\nHeader");
|
||||
});
|
||||
|
||||
it("strips markdown when creating a new chat", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: [] }),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: () =>
|
||||
Promise.resolve(
|
||||
JSON.stringify({
|
||||
data: { guid: "new-msg-stripped" },
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", {
|
||||
serverUrl: "http://localhost:1234",
|
||||
password: "test",
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("new-msg-stripped");
|
||||
|
||||
const createCall = mockFetch.mock.calls[1];
|
||||
expect(createCall[0]).toContain("/api/v1/chat/new");
|
||||
const body = JSON.parse(createCall[1].body);
|
||||
// Markdown should be stripped
|
||||
expect(body.message).toBe("Welcome to the chat!");
|
||||
});
|
||||
|
||||
it("creates a new chat when handle target is missing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk";
|
||||
import crypto from "node:crypto";
|
||||
import { stripMarkdown } from "openclaw/plugin-sdk";
|
||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||
import {
|
||||
extractHandleFromChatGuid,
|
||||
@@ -332,6 +333,7 @@ async function createNewChatWithMessage(params: {
|
||||
const payload = {
|
||||
addresses: [params.address],
|
||||
message: params.message,
|
||||
tempGuid: `temp-${crypto.randomUUID()}`,
|
||||
};
|
||||
const res = await blueBubblesFetchWithTimeout(
|
||||
url,
|
||||
@@ -377,6 +379,11 @@ export async function sendMessageBlueBubbles(
|
||||
if (!trimmedText.trim()) {
|
||||
throw new Error("BlueBubbles send requires text");
|
||||
}
|
||||
// Strip markdown early and validate - ensures messages like "***" or "---" don't become empty
|
||||
const strippedText = stripMarkdown(trimmedText);
|
||||
if (!strippedText.trim()) {
|
||||
throw new Error("BlueBubbles send requires text (message was empty after markdown removal)");
|
||||
}
|
||||
|
||||
const account = resolveBlueBubblesAccount({
|
||||
cfg: opts.cfg ?? {},
|
||||
@@ -406,7 +413,7 @@ export async function sendMessageBlueBubbles(
|
||||
baseUrl,
|
||||
password,
|
||||
address: target.address,
|
||||
message: trimmedText,
|
||||
message: strippedText,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -419,7 +426,7 @@ export async function sendMessageBlueBubbles(
|
||||
const payload: Record<string, unknown> = {
|
||||
chatGuid,
|
||||
tempGuid: crypto.randomUUID(),
|
||||
message: trimmedText,
|
||||
message: strippedText,
|
||||
};
|
||||
if (needsPrivateApi) {
|
||||
payload.method = "private-api";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/copilot-proxy",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Copilot Proxy provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/diagnostics-otel",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw diagnostics OpenTelemetry exporter",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/discord",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Discord channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/feishu",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Feishu/Lark channel plugin (community maintained by @m1heng)",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-antigravity-auth",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Google Antigravity OAuth provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/google-gemini-cli-auth",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Gemini CLI OAuth provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/googlechat",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Google Chat channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,6 +8,8 @@ const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceacc
|
||||
const CHAT_CERTS_URL =
|
||||
"https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com";
|
||||
|
||||
// Size-capped to prevent unbounded growth in long-running deployments (#4948)
|
||||
const MAX_AUTH_CACHE_SIZE = 32;
|
||||
const authCache = new Map<string, { key: string; auth: GoogleAuth }>();
|
||||
const verifyClient = new OAuth2Client();
|
||||
|
||||
@@ -30,20 +32,32 @@ function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth {
|
||||
return cached.auth;
|
||||
}
|
||||
|
||||
const evictOldest = () => {
|
||||
if (authCache.size > MAX_AUTH_CACHE_SIZE) {
|
||||
const oldest = authCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
authCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (account.credentialsFile) {
|
||||
const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
evictOldest();
|
||||
return auth;
|
||||
}
|
||||
|
||||
if (account.credentials) {
|
||||
const auth = new GoogleAuth({ credentials: account.credentials, scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
evictOldest();
|
||||
return auth;
|
||||
}
|
||||
|
||||
const auth = new GoogleAuth({ scopes: [CHAT_SCOPE] });
|
||||
authCache.set(account.accountId, { key, auth });
|
||||
evictOldest();
|
||||
return auth;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/imessage",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw iMessage channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/line",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw LINE channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/llm-task",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw JSON-only LLM task plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/lobster",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "Lobster workflow tool plugin (typed pipelines + resumable approvals)",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/matrix",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Matrix channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -17,7 +17,18 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
// Size-capped to prevent unbounded growth (#4948)
|
||||
const MAX_DIRECT_ROOM_CACHE_SIZE = 1024;
|
||||
const directRoomCache = new Map<string, string>();
|
||||
function setDirectRoomCached(key: string, value: string): void {
|
||||
directRoomCache.set(key, value);
|
||||
if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) {
|
||||
const oldest = directRoomCache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
directRoomCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function persistDirectRoom(
|
||||
client: MatrixClient,
|
||||
@@ -62,7 +73,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
|
||||
const directContent = await client.getAccountData(EventType.Direct);
|
||||
const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
|
||||
if (list.length > 0) {
|
||||
directRoomCache.set(trimmed, list[0]);
|
||||
setDirectRoomCached(trimmed, list[0]);
|
||||
return list[0];
|
||||
}
|
||||
} catch {
|
||||
@@ -86,7 +97,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
|
||||
}
|
||||
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
|
||||
if (members.length === 2) {
|
||||
directRoomCache.set(trimmed, roomId);
|
||||
setDirectRoomCached(trimmed, roomId);
|
||||
await persistDirectRoom(client, trimmed, roomId);
|
||||
return roomId;
|
||||
}
|
||||
@@ -99,7 +110,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis
|
||||
}
|
||||
|
||||
if (fallbackRoom) {
|
||||
directRoomCache.set(trimmed, fallbackRoom);
|
||||
setDirectRoomCached(trimmed, fallbackRoom);
|
||||
await persistDirectRoom(client, trimmed, fallbackRoom);
|
||||
return fallbackRoom;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/mattermost",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Mattermost channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-core",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw core memory search plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* Provides seamless auto-recall and auto-capture via lifecycle hooks.
|
||||
*/
|
||||
|
||||
import type * as LanceDB from "@lancedb/lancedb";
|
||||
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import * as lancedb from "@lancedb/lancedb";
|
||||
import { Type } from "@sinclair/typebox";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import OpenAI from "openai";
|
||||
@@ -23,6 +23,19 @@ import {
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
|
||||
const loadLanceDB = async (): Promise<typeof import("@lancedb/lancedb")> => {
|
||||
if (!lancedbImportPromise) {
|
||||
lancedbImportPromise = import("@lancedb/lancedb");
|
||||
}
|
||||
try {
|
||||
return await lancedbImportPromise;
|
||||
} catch (err) {
|
||||
// Common on macOS today: upstream package may not ship darwin native bindings.
|
||||
throw new Error(`memory-lancedb: failed to load LanceDB. ${String(err)}`);
|
||||
}
|
||||
};
|
||||
|
||||
type MemoryEntry = {
|
||||
id: string;
|
||||
text: string;
|
||||
@@ -44,8 +57,8 @@ type MemorySearchResult = {
|
||||
const TABLE_NAME = "memories";
|
||||
|
||||
class MemoryDB {
|
||||
private db: lancedb.Connection | null = null;
|
||||
private table: lancedb.Table | null = null;
|
||||
private db: LanceDB.Connection | null = null;
|
||||
private table: LanceDB.Table | null = null;
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
constructor(
|
||||
@@ -66,6 +79,7 @@ class MemoryDB {
|
||||
}
|
||||
|
||||
private async doInitialize(): Promise<void> {
|
||||
const lancedb = await loadLanceDB();
|
||||
this.db = await lancedb.connect(this.dbPath);
|
||||
const tables = await this.db.tableNames();
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/memory-lancedb",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/minimax-portal-auth",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw MiniMax Portal OAuth provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/msteams",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Microsoft Teams channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nextcloud-talk",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Nextcloud Talk channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/nostr",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Nostr channel plugin for NIP-04 encrypted DMs",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -229,31 +229,58 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
async function readJsonBody(req: IncomingMessage, maxBytes = 64 * 1024): Promise<unknown> {
|
||||
async function readJsonBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes = 64 * 1024,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(() => {
|
||||
const err = new Error("Request body timeout");
|
||||
req.destroy(err);
|
||||
reject(err);
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
reject(new Error("Request body too large"));
|
||||
req.destroy();
|
||||
finish(() => {
|
||||
reject(new Error("Request body too large"));
|
||||
req.destroy();
|
||||
});
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
try {
|
||||
const body = Buffer.concat(chunks).toString("utf-8");
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch {
|
||||
reject(new Error("Invalid JSON"));
|
||||
}
|
||||
finish(() => {
|
||||
try {
|
||||
const body = Buffer.concat(chunks).toString("utf-8");
|
||||
resolve(body ? JSON.parse(body) : {});
|
||||
} catch {
|
||||
reject(new Error("Invalid JSON"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
req.on("error", reject);
|
||||
req.on("error", (err) => finish(() => reject(err)));
|
||||
req.on("close", () => finish(() => reject(new Error("Connection closed"))));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/open-prose",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/signal",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Signal channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/slack",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Slack channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/telegram",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Telegram channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/tlon",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Tlon/Urbit channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/twitch",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Twitch channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/voice-call",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw voice-call plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -296,23 +296,48 @@ export class VoiceCallWebhookServer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read request body as string.
|
||||
* Read request body as string with timeout protection.
|
||||
*/
|
||||
private readBody(req: http.IncomingMessage, maxBytes: number): Promise<string> {
|
||||
private readBody(
|
||||
req: http.IncomingMessage,
|
||||
maxBytes: number,
|
||||
timeoutMs = 30_000,
|
||||
): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let done = false;
|
||||
const finish = (fn: () => void) => {
|
||||
if (done) {
|
||||
return;
|
||||
}
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
fn();
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
finish(() => {
|
||||
const err = new Error("Request body timeout");
|
||||
req.destroy(err);
|
||||
reject(err);
|
||||
});
|
||||
}, timeoutMs);
|
||||
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > maxBytes) {
|
||||
req.destroy();
|
||||
reject(new Error("PayloadTooLarge"));
|
||||
finish(() => {
|
||||
req.destroy();
|
||||
reject(new Error("PayloadTooLarge"));
|
||||
});
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
||||
req.on("error", reject);
|
||||
req.on("end", () => finish(() => resolve(Buffer.concat(chunks).toString("utf-8"))));
|
||||
req.on("error", (err) => finish(() => reject(err)));
|
||||
req.on("close", () => finish(() => reject(new Error("Connection closed"))));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/whatsapp",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw WhatsApp channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/zalo",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Zalo channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# Changelog
|
||||
|
||||
## 2026.2.6-3
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6-2
|
||||
|
||||
### Changes
|
||||
|
||||
- Version alignment with core OpenClaw release numbers.
|
||||
|
||||
## 2026.2.6
|
||||
|
||||
### Changes
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openclaw/zalouser",
|
||||
"version": "2026.2.6",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "OpenClaw Zalo Personal Account plugin via zca-cli",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
21
openclaw.mjs
21
openclaw.mjs
@@ -11,4 +11,23 @@ if (module.enableCompileCache && !process.env.NODE_DISABLE_COMPILE_CACHE) {
|
||||
}
|
||||
}
|
||||
|
||||
await import("./dist/entry.js");
|
||||
const tryImport = async (specifier) => {
|
||||
try {
|
||||
await import(specifier);
|
||||
return true;
|
||||
} catch (err) {
|
||||
// Only swallow missing-module errors; rethrow real runtime errors.
|
||||
if (err && typeof err === "object" && "code" in err && err.code === "ERR_MODULE_NOT_FOUND") {
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
if (await tryImport("./dist/entry.js")) {
|
||||
// OK
|
||||
} else if (await tryImport("./dist/entry.mjs")) {
|
||||
// OK
|
||||
} else {
|
||||
throw new Error("openclaw: missing dist/entry.(m)js (build output).");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw",
|
||||
"version": "2026.2.6-1",
|
||||
"version": "2026.2.6-3",
|
||||
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
|
||||
"keywords": [],
|
||||
"license": "MIT",
|
||||
|
||||
@@ -6,7 +6,7 @@ WORKDIR /app
|
||||
|
||||
ENV NODE_OPTIONS="--disable-warning=ExperimentalWarning"
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml tsconfig.json tsdown.config.ts vitest.config.ts vitest.e2e.config.ts openclaw.mjs ./
|
||||
COPY src ./src
|
||||
COPY test ./test
|
||||
COPY scripts ./scripts
|
||||
|
||||
@@ -80,10 +80,20 @@ LOGINCTL
|
||||
fi
|
||||
npm install -g --prefix /tmp/npm-prefix "/app/$pkg_tgz"
|
||||
|
||||
npm_bin="/tmp/npm-prefix/bin/openclaw"
|
||||
npm_entry="/tmp/npm-prefix/lib/node_modules/openclaw/dist/index.js"
|
||||
git_entry="/app/dist/index.js"
|
||||
git_cli="/app/openclaw.mjs"
|
||||
npm_bin="/tmp/npm-prefix/bin/openclaw"
|
||||
npm_root="/tmp/npm-prefix/lib/node_modules/openclaw"
|
||||
if [ -f "$npm_root/dist/index.mjs" ]; then
|
||||
npm_entry="$npm_root/dist/index.mjs"
|
||||
else
|
||||
npm_entry="$npm_root/dist/index.js"
|
||||
fi
|
||||
|
||||
if [ -f "/app/dist/index.mjs" ]; then
|
||||
git_entry="/app/dist/index.mjs"
|
||||
else
|
||||
git_entry="/app/dist/index.js"
|
||||
fi
|
||||
git_cli="/app/openclaw.mjs"
|
||||
|
||||
assert_entrypoint() {
|
||||
local unit_path="$1"
|
||||
|
||||
@@ -31,7 +31,7 @@ echo "Starting gateway container..."
|
||||
-e "OPENCLAW_SKIP_CRON=1" \
|
||||
-e "OPENCLAW_SKIP_CANVAS_HOST=1" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "node dist/index.js gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1"
|
||||
bash -lc "entry=dist/index.mjs; [ -f \"\$entry\" ] || entry=dist/index.js; node \"\$entry\" gateway --port $PORT --bind lan --allow-unconfigured > /tmp/gateway-net-e2e.log 2>&1"
|
||||
|
||||
echo "Waiting for gateway to come up..."
|
||||
ready=0
|
||||
@@ -77,9 +77,9 @@ docker run --rm \
|
||||
-e "GW_URL=ws://$GW_NAME:$PORT" \
|
||||
-e "GW_TOKEN=$TOKEN" \
|
||||
"$IMAGE_NAME" \
|
||||
bash -lc "node - <<'NODE'
|
||||
bash -lc "node --import tsx - <<'NODE'
|
||||
import { WebSocket } from \"ws\";
|
||||
import { PROTOCOL_VERSION } from \"./dist/gateway/protocol/index.js\";
|
||||
import { PROTOCOL_VERSION } from \"./src/gateway/protocol/index.ts\";
|
||||
|
||||
const url = process.env.GW_URL;
|
||||
const token = process.env.GW_TOKEN;
|
||||
|
||||
@@ -10,9 +10,20 @@ docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
echo "Running onboarding E2E..."
|
||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
|
||||
trap "" PIPE
|
||||
export TERM=xterm-256color
|
||||
ONBOARD_FLAGS="--flow quickstart --auth-choice skip --skip-channels --skip-skills --skip-daemon --skip-ui"
|
||||
# tsdown may emit dist/index.js or dist/index.mjs depending on runtime/bundler.
|
||||
if [ -f dist/index.mjs ]; then
|
||||
OPENCLAW_ENTRY="dist/index.mjs"
|
||||
elif [ -f dist/index.js ]; then
|
||||
OPENCLAW_ENTRY="dist/index.js"
|
||||
else
|
||||
echo "Missing dist/index.(m)js (build output):"
|
||||
ls -la dist || true
|
||||
exit 1
|
||||
fi
|
||||
export OPENCLAW_ENTRY
|
||||
|
||||
# Provide a minimal trash shim to avoid noisy "missing trash" logs in containers.
|
||||
export PATH="/tmp/openclaw-bin:$PATH"
|
||||
@@ -82,10 +93,10 @@ TRASH
|
||||
done
|
||||
}
|
||||
|
||||
start_gateway() {
|
||||
node dist/index.js gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
|
||||
GATEWAY_PID="$!"
|
||||
}
|
||||
start_gateway() {
|
||||
node "$OPENCLAW_ENTRY" gateway --port 18789 --bind loopback --allow-unconfigured > /tmp/gateway-e2e.log 2>&1 &
|
||||
GATEWAY_PID="$!"
|
||||
}
|
||||
|
||||
wait_for_gateway() {
|
||||
for _ in $(seq 1 20); do
|
||||
@@ -184,9 +195,9 @@ TRASH
|
||||
local send_fn="$3"
|
||||
local validate_fn="${4:-}"
|
||||
|
||||
# Default onboarding command wrapper.
|
||||
run_wizard_cmd "$case_name" "$home_dir" "node dist/index.js onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
|
||||
}
|
||||
# Default onboarding command wrapper.
|
||||
run_wizard_cmd "$case_name" "$home_dir" "node \"$OPENCLAW_ENTRY\" onboard $ONBOARD_FLAGS" "$send_fn" true "$validate_fn"
|
||||
}
|
||||
|
||||
make_home() {
|
||||
mktemp -d "/tmp/openclaw-e2e-$1.XXXXXX"
|
||||
@@ -263,14 +274,14 @@ TRASH
|
||||
send "" 1.0
|
||||
}
|
||||
|
||||
run_case_local_basic() {
|
||||
local home_dir
|
||||
home_dir="$(make_home local-basic)"
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME"
|
||||
node dist/index.js onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
run_case_local_basic() {
|
||||
local home_dir
|
||||
home_dir="$(make_home local-basic)"
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME"
|
||||
node "$OPENCLAW_ENTRY" onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--skip-channels \
|
||||
@@ -343,11 +354,11 @@ NODE
|
||||
local home_dir
|
||||
home_dir="$(make_home remote-non-interactive)"
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME"
|
||||
# Smoke test non-interactive remote config write.
|
||||
node dist/index.js onboard --non-interactive --accept-risk \
|
||||
--mode remote \
|
||||
--remote-url ws://gateway.local:18789 \
|
||||
mkdir -p "$HOME"
|
||||
# Smoke test non-interactive remote config write.
|
||||
node "$OPENCLAW_ENTRY" onboard --non-interactive --accept-risk \
|
||||
--mode remote \
|
||||
--remote-url ws://gateway.local:18789 \
|
||||
--remote-token remote-token \
|
||||
--skip-skills \
|
||||
--skip-health
|
||||
@@ -388,7 +399,7 @@ NODE
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME/.openclaw"
|
||||
# Seed a remote config to exercise reset path.
|
||||
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
|
||||
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"agents": { "defaults": { "workspace": "/root/old" } },
|
||||
"gateway": {
|
||||
@@ -398,9 +409,9 @@ NODE
|
||||
}
|
||||
JSON
|
||||
|
||||
node dist/index.js onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
node "$OPENCLAW_ENTRY" onboard \
|
||||
--non-interactive \
|
||||
--accept-risk \
|
||||
--flow quickstart \
|
||||
--mode local \
|
||||
--reset \
|
||||
@@ -438,10 +449,10 @@ NODE
|
||||
}
|
||||
|
||||
run_case_channels() {
|
||||
local home_dir
|
||||
home_dir="$(make_home channels)"
|
||||
# Channels-only configure flow.
|
||||
run_wizard_cmd channels "$home_dir" "node dist/index.js configure --section channels" send_channels_flow
|
||||
local home_dir
|
||||
home_dir="$(make_home channels)"
|
||||
# Channels-only configure flow.
|
||||
run_wizard_cmd channels "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section channels" send_channels_flow
|
||||
|
||||
config_path="$HOME/.openclaw/openclaw.json"
|
||||
assert_file "$config_path"
|
||||
@@ -483,7 +494,7 @@ NODE
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME/.openclaw"
|
||||
# Seed skills config to ensure it survives the wizard.
|
||||
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
|
||||
cat > "$HOME/.openclaw/openclaw.json" <<'"'"'JSON'"'"'
|
||||
{
|
||||
"skills": {
|
||||
"allowBundled": ["__none__"],
|
||||
@@ -492,7 +503,7 @@ NODE
|
||||
}
|
||||
JSON
|
||||
|
||||
run_wizard_cmd skills "$home_dir" "node dist/index.js configure --section skills" send_skills_flow
|
||||
run_wizard_cmd skills "$home_dir" "node \"$OPENCLAW_ENTRY\" configure --section skills" send_skills_flow
|
||||
|
||||
config_path="$HOME/.openclaw/openclaw.json"
|
||||
assert_file "$config_path"
|
||||
|
||||
@@ -8,11 +8,21 @@ echo "Building Docker image..."
|
||||
docker build -t "$IMAGE_NAME" -f "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR"
|
||||
|
||||
echo "Running plugins Docker E2E..."
|
||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
docker run --rm -t "$IMAGE_NAME" bash -lc '
|
||||
set -euo pipefail
|
||||
if [ -f dist/index.mjs ]; then
|
||||
OPENCLAW_ENTRY="dist/index.mjs"
|
||||
elif [ -f dist/index.js ]; then
|
||||
OPENCLAW_ENTRY="dist/index.js"
|
||||
else
|
||||
echo "Missing dist/index.(m)js (build output):"
|
||||
ls -la dist || true
|
||||
exit 1
|
||||
fi
|
||||
export OPENCLAW_ENTRY
|
||||
|
||||
home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX")
|
||||
export HOME="$home_dir"
|
||||
home_dir=$(mktemp -d "/tmp/openclaw-plugins-e2e.XXXXXX")
|
||||
export HOME="$home_dir"
|
||||
mkdir -p "$HOME/.openclaw/extensions/demo-plugin"
|
||||
|
||||
cat > "$HOME/.openclaw/extensions/demo-plugin/index.js" <<'"'"'JS'"'"'
|
||||
@@ -38,7 +48,7 @@ JS
|
||||
}
|
||||
JSON
|
||||
|
||||
node dist/index.js plugins list --json > /tmp/plugins.json
|
||||
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins.json
|
||||
|
||||
node - <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
@@ -99,8 +109,8 @@ JS
|
||||
JSON
|
||||
tar -czf /tmp/demo-plugin-tgz.tgz -C "$pack_dir" package
|
||||
|
||||
node dist/index.js plugins install /tmp/demo-plugin-tgz.tgz
|
||||
node dist/index.js plugins list --json > /tmp/plugins2.json
|
||||
node "$OPENCLAW_ENTRY" plugins install /tmp/demo-plugin-tgz.tgz
|
||||
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins2.json
|
||||
|
||||
node - <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
@@ -145,8 +155,8 @@ JS
|
||||
}
|
||||
JSON
|
||||
|
||||
node dist/index.js plugins install "$dir_plugin"
|
||||
node dist/index.js plugins list --json > /tmp/plugins3.json
|
||||
node "$OPENCLAW_ENTRY" plugins install "$dir_plugin"
|
||||
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins3.json
|
||||
|
||||
node - <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
@@ -192,8 +202,8 @@ JS
|
||||
}
|
||||
JSON
|
||||
|
||||
node dist/index.js plugins install "file:$file_pack_dir/package"
|
||||
node dist/index.js plugins list --json > /tmp/plugins4.json
|
||||
node "$OPENCLAW_ENTRY" plugins install "file:$file_pack_dir/package"
|
||||
node "$OPENCLAW_ENTRY" plugins list --json > /tmp/plugins4.json
|
||||
|
||||
node - <<'"'"'NODE'"'"'
|
||||
const fs = require("node:fs");
|
||||
|
||||
@@ -7,9 +7,9 @@ import { join, resolve } from "node:path";
|
||||
type PackFile = { path: string };
|
||||
type PackResult = { files?: PackFile[] };
|
||||
|
||||
const requiredPaths = [
|
||||
"dist/index.js",
|
||||
"dist/entry.js",
|
||||
const requiredPathGroups = [
|
||||
["dist/index.js", "dist/index.mjs"],
|
||||
["dist/entry.js", "dist/entry.mjs"],
|
||||
"dist/plugin-sdk/index.js",
|
||||
"dist/plugin-sdk/index.d.ts",
|
||||
"dist/build-info.json",
|
||||
@@ -82,7 +82,14 @@ function main() {
|
||||
const files = results.flatMap((entry) => entry.files ?? []);
|
||||
const paths = new Set(files.map((file) => file.path));
|
||||
|
||||
const missing = requiredPaths.filter((path) => !paths.has(path));
|
||||
const missing = requiredPathGroups
|
||||
.flatMap((group) => {
|
||||
if (Array.isArray(group)) {
|
||||
return group.some((path) => paths.has(path)) ? [] : [group.join(" or ")];
|
||||
}
|
||||
return paths.has(group) ? [] : [group];
|
||||
})
|
||||
.toSorted();
|
||||
const forbidden = [...paths].filter((path) =>
|
||||
forbiddenPrefixes.some((prefix) => path.startsWith(prefix)),
|
||||
);
|
||||
|
||||
@@ -29,15 +29,13 @@ const shardCount = isWindowsCi
|
||||
? shardOverride
|
||||
: 2
|
||||
: 1;
|
||||
const windowsCiArgs = isWindowsCi
|
||||
? ["--no-file-parallelism", "--dangerouslyIgnoreUnhandledErrors"]
|
||||
: [];
|
||||
const windowsCiArgs = isWindowsCi ? ["--dangerouslyIgnoreUnhandledErrors"] : [];
|
||||
const passthroughArgs = process.argv.slice(2);
|
||||
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
|
||||
const resolvedOverride =
|
||||
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||
const parallelRuns = isWindowsCi ? [] : runs.filter((entry) => entry.name !== "gateway");
|
||||
const serialRuns = isWindowsCi ? runs : runs.filter((entry) => entry.name === "gateway");
|
||||
const parallelRuns = runs.filter((entry) => entry.name !== "gateway");
|
||||
const serialRuns = runs.filter((entry) => entry.name === "gateway");
|
||||
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
|
||||
const parallelCount = Math.max(1, parallelRuns.length);
|
||||
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelCount));
|
||||
|
||||
@@ -7,9 +7,13 @@ const distDir = path.join(rootDir, "dist");
|
||||
const cliDir = path.join(distDir, "cli");
|
||||
|
||||
const findCandidates = () =>
|
||||
fs
|
||||
.readdirSync(distDir)
|
||||
.filter((entry) => entry.startsWith("daemon-cli-") && entry.endsWith(".js"));
|
||||
fs.readdirSync(distDir).filter((entry) => {
|
||||
if (!entry.startsWith("daemon-cli-")) {
|
||||
return false;
|
||||
}
|
||||
// tsdown can emit either .js or .mjs depending on bundler settings/runtime.
|
||||
return entry.endsWith(".js") || entry.endsWith(".mjs");
|
||||
});
|
||||
|
||||
// In rare cases, build output can land slightly after this script starts (depending on FS timing).
|
||||
// Retry briefly to avoid flaky builds.
|
||||
|
||||
@@ -257,6 +257,30 @@ describe("getApiKeyForModel", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves Qianfan API key from env", async () => {
|
||||
const previous = process.env.QIANFAN_API_KEY;
|
||||
|
||||
try {
|
||||
process.env.QIANFAN_API_KEY = "qianfan-test-key";
|
||||
|
||||
vi.resetModules();
|
||||
const { resolveApiKeyForProvider } = await import("./model-auth.js");
|
||||
|
||||
const resolved = await resolveApiKeyForProvider({
|
||||
provider: "qianfan",
|
||||
store: { version: 1, profiles: {} },
|
||||
});
|
||||
expect(resolved.apiKey).toBe("qianfan-test-key");
|
||||
expect(resolved.source).toContain("QIANFAN_API_KEY");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.QIANFAN_API_KEY;
|
||||
} else {
|
||||
process.env.QIANFAN_API_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves Vercel AI Gateway API key from env", async () => {
|
||||
const previousGatewayKey = process.env.AI_GATEWAY_API_KEY;
|
||||
|
||||
|
||||
@@ -302,6 +302,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
|
||||
venice: "VENICE_API_KEY",
|
||||
mistral: "MISTRAL_API_KEY",
|
||||
opencode: "OPENCODE_API_KEY",
|
||||
qianfan: "QIANFAN_API_KEY",
|
||||
ollama: "OLLAMA_API_KEY",
|
||||
};
|
||||
const envVar = envMap[normalized];
|
||||
|
||||
25
src/agents/models-config.providers.qianfan.test.ts
Normal file
25
src/agents/models-config.providers.qianfan.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveImplicitProviders } from "./models-config.providers.js";
|
||||
|
||||
describe("Qianfan provider", () => {
|
||||
it("should include qianfan when QIANFAN_API_KEY is configured", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const previous = process.env.QIANFAN_API_KEY;
|
||||
process.env.QIANFAN_API_KEY = "test-key";
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProviders({ agentDir });
|
||||
expect(providers?.qianfan).toBeDefined();
|
||||
expect(providers?.qianfan?.apiKey).toBe("QIANFAN_API_KEY");
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.QIANFAN_API_KEY;
|
||||
} else {
|
||||
process.env.QIANFAN_API_KEY = previous;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,17 @@ const OLLAMA_DEFAULT_COST = {
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
export const QIANFAN_BASE_URL = "https://qianfan.baidubce.com/v2";
|
||||
export const QIANFAN_DEFAULT_MODEL_ID = "deepseek-v3.2";
|
||||
const QIANFAN_DEFAULT_CONTEXT_WINDOW = 98304;
|
||||
const QIANFAN_DEFAULT_MAX_TOKENS = 32768;
|
||||
const QIANFAN_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
interface OllamaModel {
|
||||
name: string;
|
||||
modified_at: string;
|
||||
@@ -403,6 +414,33 @@ async function buildOllamaProvider(): Promise<ProviderConfig> {
|
||||
};
|
||||
}
|
||||
|
||||
export function buildQianfanProvider(): ProviderConfig {
|
||||
return {
|
||||
baseUrl: QIANFAN_BASE_URL,
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: QIANFAN_DEFAULT_MODEL_ID,
|
||||
name: "DEEPSEEK V3.2",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: QIANFAN_DEFAULT_COST,
|
||||
contextWindow: QIANFAN_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: QIANFAN_DEFAULT_MAX_TOKENS,
|
||||
},
|
||||
{
|
||||
id: "ernie-5.0-thinking-preview",
|
||||
name: "ERNIE-5.0-Thinking-Preview",
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
cost: QIANFAN_DEFAULT_COST,
|
||||
contextWindow: 119000,
|
||||
maxTokens: 64000,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveImplicitProviders(params: {
|
||||
agentDir: string;
|
||||
}): Promise<ModelsConfig["providers"]> {
|
||||
@@ -498,6 +536,13 @@ export async function resolveImplicitProviders(params: {
|
||||
providers.ollama = { ...(await buildOllamaProvider()), apiKey: ollamaKey };
|
||||
}
|
||||
|
||||
const qianfanKey =
|
||||
resolveEnvApiKeyVarName("qianfan") ??
|
||||
resolveApiKeyFromProfiles({ provider: "qianfan", store: authStore });
|
||||
if (qianfanKey) {
|
||||
providers.qianfan = { ...buildQianfanProvider(), apiKey: qianfanKey };
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user