Compare commits

...

2 Commits

Author SHA1 Message Date
quotentiroler
ade7f0a8a0 feat(ci): activate staged pipeline enable push triggers + ci.yml gates
Enable the staged branch promotion pipeline:

- ci.yml: remove push-to-main trigger (handled by release-orchestrator),
  add workflow_call input for test_stage, gate Windows tests to beta+
  and macOS tests to stable-only to reduce PR CI cost.

- Re-enable push triggers on feature-pr, hotfix-pr, promote-branch,
  and release-orchestrator workflows.

- Update CONTRIBUTING.md to reflect active branch strategy.

IMPORTANT: Before merging, ensure develop/alpha/beta branches are
created and the team is ready for the new workflow.

Depends on: dev/ci-additive-workflows (PR1)
2026-02-07 09:51:22 -08:00
quotentiroler
b4aefa3937 feat(ci): add staged release pipeline workflows (dormant)
Add all new CI/CD workflow files for the staged branch promotion pipeline
(develop  alpha  beta  main). Push triggers are intentionally disabled
workflows use workflow_dispatch or workflow_call only.

No changes to existing ci.yml. All workflows are inert until activated in
a follow-up PR.

New workflows:
- feature-pr.yml: auto-create PRs to develop (disabled)
- hotfix-pr.yml: emergency hotfix PRs to main (disabled)
- promote-branch.yml: staged promotion (disabled)
- release-orchestrator.yml: release pipeline (disabled)
- deployment-strategy.yml: npm + Docker deploy (workflow_call)
- testing-strategy.yml: progressive test gates (workflow_call)
- generate-changelog.yml: changelog generation (workflow_call)
- version-operations.yml: version bumping (workflow_call)
- release.yml: manual release trigger (workflow_dispatch)
- rollback.yml: emergency rollback (workflow_dispatch)
- discord-notify action: reusable notification

Also adds pipeline docs and updates CONTRIBUTING.md with future branch
strategy (clearly marked as not yet active).

Split from #10755 for safe, additive merge.
2026-02-07 09:50:02 -08:00
14 changed files with 2156 additions and 9 deletions

View 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 }}"

View File

@@ -1,12 +1,18 @@
name: CI
on:
push:
branches: [main]
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.event.pull_request.number || github.sha }}
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
@@ -187,6 +193,10 @@ 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
@@ -295,12 +305,9 @@ jobs:
- name: Run ${{ matrix.task }} (${{ matrix.runtime }})
run: ${{ matrix.command }}
# Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially
# on a single runner. GitHub limits macOS concurrent jobs to 5 per org;
# running 4 separate jobs per PR (as before) starved the queue. One job
# per PR allows 5 PRs to run macOS checks simultaneously.
macos:
if: github.event_name == 'pull_request'
checks-macos:
# macOS tests: stable staging only (not on regular PRs to save compute)
if: inputs.test_stage == 'stable'
runs-on: macos-latest
steps:
- name: Checkout

View 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
View 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"

129
.github/workflows/generate-changelog.yml vendored Normal file
View 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
View 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"

319
.github/workflows/promote-branch.yml vendored Normal file
View 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"

View 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
View 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
View 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
View 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
View 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"

View File

@@ -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.

131
docs/reference/pipeline.md Normal file
View 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.