From 6035bbcd2cd41f735a60fd24c5f348bfb3b5e6bd Mon Sep 17 00:00:00 2001 From: quotentiroler Date: Fri, 6 Feb 2026 15:25:34 -0800 Subject: [PATCH] feat(ci): implement staged branch promotion workflow - Add testing-strategy.yml that calls existing ci.yml + adds macOS/smoke for stable - Add promote-branch.yml for develop alpha beta main promotion PRs - Add deployment-strategy.yml for npm (alpha/beta/latest) + Docker (GHCR) - Add release-orchestrator.yml to coordinate version changelog test deploy - Add version-operations.yml for YYYY.M.D versioning with prerelease suffixes - Add generate-changelog.yml for conventional commit parsing - Add release.yml manual trigger workflow - Add discord-notify composite action for notifications - Modify ci.yml to support workflow_call for reuse by testing-strategy --- .github/actions/discord-notify/action.yml | 66 ++++ .github/workflows/ci.yml | 12 + .github/workflows/deployment-strategy.yml | 349 +++++++++++++++++++++ .github/workflows/generate-changelog.yml | 133 ++++++++ .github/workflows/promote-branch.yml | 248 +++++++++++++++ .github/workflows/release-orchestrator.yml | 213 +++++++++++++ .github/workflows/release.yml | 52 +++ .github/workflows/testing-strategy.yml | 219 +++++++++++++ .github/workflows/version-operations.yml | 176 +++++++++++ 9 files changed, 1468 insertions(+) create mode 100644 .github/actions/discord-notify/action.yml create mode 100644 .github/workflows/deployment-strategy.yml create mode 100644 .github/workflows/generate-changelog.yml create mode 100644 .github/workflows/promote-branch.yml create mode 100644 .github/workflows/release-orchestrator.yml create mode 100644 .github/workflows/release.yml create mode 100644 .github/workflows/testing-strategy.yml create mode 100644 .github/workflows/version-operations.yml diff --git a/.github/actions/discord-notify/action.yml b/.github/actions/discord-notify/action.yml new file mode 100644 index 0000000000..d367c3fe93 --- /dev/null +++ b/.github/actions/discord-notify/action.yml @@ -0,0 +1,66 @@ +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="\"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"," + fi + + # Escape description for JSON + DESCRIPTION=$(echo '${{ inputs.description }}' | jq -Rs .) + + PAYLOAD=$(cat <> $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: always() + 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: always() + 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' diff --git a/.github/workflows/generate-changelog.yml b/.github/workflows/generate-changelog.yml new file mode 100644 index 0000000000..22cc53534b --- /dev/null +++ b/.github/workflows/generate-changelog.yml @@ -0,0 +1,133 @@ +name: Generate Changelog + +on: + workflow_call: + inputs: + version: + description: 'Version for the changelog' + required: true + type: string + commits: + description: 'JSON string of commits' + required: false + type: string + default: '' + 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 + echo "changelog<> $GITHUB_OUTPUT + echo -e "$CHANGELOG" >> $GITHUB_OUTPUT + echo "EOF" >> $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 diff --git a/.github/workflows/promote-branch.yml b/.github/workflows/promote-branch.yml new file mode 100644 index 0000000000..e220617540 --- /dev/null +++ b/.github/workflows/promote-branch.yml @@ -0,0 +1,248 @@ +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 + 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 + +permissions: + contents: read + 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 + + # Run stage-appropriate tests + run-tests: + name: Run Tests + needs: determine-target + if: ${{ needs.determine-target.outputs.should_promote == 'true' && !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, run-tests] + if: | + always() && + needs.determine-target.outputs.should_promote == 'true' && + (needs.run-tests.outputs.test_status == 'passed' || inputs.skip_tests) + runs-on: ubuntu-latest + outputs: + pr_number: ${{ steps.create-pr.outputs.pull-request-number }} + pr_url: ${{ steps.create-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 + echo "summary<> $GITHUB_OUTPUT + echo "$COMMIT_SUMMARY" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create Pull Request + id: create-pr + uses: peter-evans/create-pull-request@v6 + with: + token: ${{ secrets.GITHUB_TOKEN }} + branch: promote/${{ needs.determine-target.outputs.source }}-to-${{ needs.determine-target.outputs.target }} + base: ${{ needs.determine-target.outputs.target }} + title: 'πŸš€ Promote: ${{ needs.determine-target.outputs.source }} β†’ ${{ needs.determine-target.outputs.target }}' + body: | + ## Staged Promotion + + | Property | Value | + |----------|-------| + | Source | `${{ needs.determine-target.outputs.source }}` | + | Target | `${{ needs.determine-target.outputs.target }}` | + | Test Stage | `${{ needs.determine-target.outputs.test_stage }}` | + | Test Result | ${{ needs.run-tests.outputs.test_status == 'passed' && 'βœ… Passed' || (inputs.skip_tests && '⚠️ Skipped' || '❓ Unknown') }} | + + ### Changes (${{ steps.commits.outputs.count }} commits) + + ${{ steps.commits.outputs.summary }} + + ### Test Coverage by Stage + + | Stage | Tests | + |-------|-------| + | develop | tsgo, lint, format, protocol, unit (Node + Bun) | + | alpha | + secrets scan | + | beta | + Windows tests | + | stable | + macOS tests, install smoke | + + ### Checklist + + - [ ] Changes reviewed + - [ ] CI passing + - [ ] Ready to promote + + --- + *Auto-generated by the branch promotion workflow.* + labels: | + promotion + stage:${{ needs.determine-target.outputs.test_stage }} + draft: false + + # Auto-merge for develop β†’ alpha (fast-track) + 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 != '' + 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: always() && 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: | + always() && + 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' diff --git a/.github/workflows/release-orchestrator.yml b/.github/workflows/release-orchestrator.yml new file mode 100644 index 0000000000..6159944a99 --- /dev/null +++ b/.github/workflows/release-orchestrator.yml @@ -0,0 +1,213 @@ +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: + 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 + +jobs: + # Get commits since last release + get-commits: + name: Get Commits + 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: ${{ inputs.source_branch }} + + - name: Get commits since last tag + id: commits + run: | + # Get latest tag for this release type + case "${{ inputs.release_type }}" in + alpha) + PATTERN="v*-alpha.*" + ;; + beta) + PATTERN="v*-beta.*" + ;; + stable) + PATTERN="v[0-9]*.[0-9]*.[0-9]*" + ;; + esac + + LATEST_TAG=$(git tag -l "$PATTERN" --sort=-v:refname | head -1) + + if [ -z "$LATEST_TAG" ]; then + # No previous tag, use all commits + LATEST_TAG=$(git rev-list --max-parents=0 HEAD) + echo "No previous ${{ inputs.release_type }} tag found, using initial commit" + else + echo "Latest ${{ inputs.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 + echo "commits<> $GITHUB_OUTPUT + echo "$COMMITS" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + fi + + # Version operations + version: + name: Version + needs: get-commits + if: needs.get-commits.outputs.has_changes == 'true' + uses: ./.github/workflows/version-operations.yml + with: + release_type: ${{ inputs.release_type }} + source_branch: ${{ inputs.source_branch }} + should_bump: true + dry_run: ${{ inputs.dry_run }} + + # Generate changelog + changelog: + name: Changelog + needs: [get-commits, version] + uses: ./.github/workflows/generate-changelog.yml + with: + version: ${{ needs.version.outputs.new_version }} + commits: ${{ needs.get-commits.outputs.commits }} + release_type: ${{ inputs.release_type }} + + # Run full test suite for the release type + test: + name: Test + needs: version + uses: ./.github/workflows/testing-strategy.yml + with: + test_stage: ${{ inputs.release_type == 'stable' && 'stable' || inputs.release_type }} + app_version: ${{ needs.version.outputs.new_version }} + secrets: inherit + + # Deploy (npm + Docker) + deploy: + name: Deploy + needs: [version, test] + if: ${{ !inputs.dry_run && needs.test.outputs.test_status == 'passed' }} + uses: ./.github/workflows/deployment-strategy.yml + with: + deployment_stage: ${{ inputs.release_type }} + app_version: ${{ needs.version.outputs.new_version }} + source_branch: ${{ inputs.source_branch }} + secrets: inherit + + # Create GitHub release + release: + name: GitHub Release + needs: [version, changelog, deploy] + if: ${{ !inputs.dry_run }} + 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: ${{ inputs.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: ${{ inputs.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: [version, release] + if: ${{ always() && 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**: ${{ inputs.release_type }} + **Release**: ${{ needs.release.outputs.release_url }} + color: '3066993' + + # Notify on failure + notify-failure: + name: Notify Failure + needs: [version, test, deploy, release] + if: ${{ always() && (needs.test.outputs.test_status != 'passed' || 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: ${{ inputs.release_type }}' + description: | + **Branch**: ${{ inputs.source_branch }} + **Tests**: ${{ needs.test.outputs.test_status }} + [View Logs](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) + color: '15158332' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..789fb8e33f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,52 @@ +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 }} + should_bump_version: true + dry_run: ${{ inputs.dry_run }} + secrets: inherit diff --git a/.github/workflows/testing-strategy.yml b/.github/workflows/testing-strategy.yml new file mode 100644 index 0000000000..bfe07d871e --- /dev/null +++ b/.github/workflows/testing-strategy.yml @@ -0,0 +1,219 @@ +name: Testing Strategy + +# Reusable testing workflow for staged releases +# Calls ci.yml for core tests, adds stage-specific extras +# +# Test coverage by stage: +# - develop/alpha/beta: Uses ci.yml (checks, secrets, windows) +# - stable: + macOS tests, 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 existing CI workflow (checks, secrets, windows) + ci: + name: Core CI + uses: ./.github/workflows/ci.yml + secrets: inherit + + # macOS tests (stable only) + checks-macos: + name: macOS Checks + 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 + 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 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.x + check-latest: true + + - 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: Capture node path + run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" + + - name: Install dependencies + env: + CI: true + run: | + export PATH="$NODE_BIN:$PATH" + pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true + + - name: Run ${{ matrix.task }} + env: + NODE_OPTIONS: --max-old-space-size=4096 + run: ${{ matrix.command }} + + # 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, checks-macos, install-smoke] + if: always() + 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 + windows) | ${{ needs.ci.result }} |" >> $GITHUB_STEP_SUMMARY + echo "| macOS Checks | ${{ needs.checks-macos.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY + echo "| Install Smoke | ${{ needs.install-smoke.result || 'skipped' }} |" >> $GITHUB_STEP_SUMMARY + + # CI must pass + if [ "${{ needs.ci.result }}" != "success" ]; then + echo "overall_status=failed" >> $GITHUB_OUTPUT + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ❌ Core CI failed" >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + # Stage-specific checks (stable only has extras) + STAGE="${{ inputs.test_stage }}" + FAILED=false + + if [ "$STAGE" = "stable" ]; then + if [ "${{ needs.checks-macos.result }}" = "failure" ]; then + FAILED=true + fi + 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: always() + 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' diff --git a/.github/workflows/version-operations.yml b/.github/workflows/version-operations.yml new file mode 100644 index 0000000000..f3e1bd2b92 --- /dev/null +++ b/.github/workflows/version-operations.yml @@ -0,0 +1,176 @@ +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 }} + +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 just the date + NEW_VERSION="${TODAY}" + ;; + *) + 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"