mirror of
https://github.com/openclaw/clawhub.git
synced 2026-08-14 08:52:21 +00:00
432 lines
17 KiB
YAML
432 lines
17 KiB
YAML
name: Deploy
|
||
|
||
on:
|
||
workflow_dispatch:
|
||
inputs:
|
||
target:
|
||
description: "What to deploy"
|
||
required: true
|
||
default: full
|
||
type: choice
|
||
options:
|
||
- full
|
||
- backend
|
||
- frontend
|
||
allow_deleting_large_indexes:
|
||
description: "Allow Convex to delete large indexes"
|
||
required: true
|
||
default: false
|
||
type: boolean
|
||
active_rollout_deploy_confirm:
|
||
description: "For backend-only deploys with active external-skill rollouts, enter: pause-and-restore-active-rollouts"
|
||
required: false
|
||
default: ""
|
||
type: string
|
||
|
||
concurrency:
|
||
group: deploy-production
|
||
cancel-in-progress: false
|
||
|
||
permissions: {}
|
||
|
||
jobs:
|
||
validate-deploy-request:
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 5
|
||
outputs:
|
||
deploy_backend: ${{ steps.mode.outputs.deploy_backend }}
|
||
deploy_frontend: ${{ steps.mode.outputs.deploy_frontend }}
|
||
run_smoke: ${{ steps.mode.outputs.run_smoke }}
|
||
target: ${{ steps.mode.outputs.target }}
|
||
steps:
|
||
- name: Require main ref for production deploy
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
||
echo "Production deploys must run from main."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Resolve deploy mode
|
||
id: mode
|
||
env:
|
||
ACTIVE_ROLLOUT_DEPLOY_CONFIRM: ${{ inputs.active_rollout_deploy_confirm }}
|
||
run: |
|
||
set -euo pipefail
|
||
target="${{ inputs.target }}"
|
||
rollout_confirmation="$ACTIVE_ROLLOUT_DEPLOY_CONFIRM"
|
||
if [[ -n "$rollout_confirmation" && "$target" != "backend" ]]; then
|
||
echo "::error::Active rollout pause/restore is supported only for backend deploys."
|
||
exit 1
|
||
fi
|
||
if [[ -n "$rollout_confirmation" && "$rollout_confirmation" != "pause-and-restore-active-rollouts" ]]; then
|
||
echo "::error::Invalid active rollout deploy confirmation."
|
||
exit 1
|
||
fi
|
||
case "$target" in
|
||
full)
|
||
echo "deploy_backend=true" >> "$GITHUB_OUTPUT"
|
||
echo "deploy_frontend=true" >> "$GITHUB_OUTPUT"
|
||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||
;;
|
||
backend)
|
||
echo "deploy_backend=true" >> "$GITHUB_OUTPUT"
|
||
echo "deploy_frontend=false" >> "$GITHUB_OUTPUT"
|
||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||
;;
|
||
frontend)
|
||
echo "deploy_backend=false" >> "$GITHUB_OUTPUT"
|
||
echo "deploy_frontend=true" >> "$GITHUB_OUTPUT"
|
||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||
;;
|
||
*)
|
||
echo "Unsupported deploy target: $target" >&2
|
||
exit 1
|
||
;;
|
||
esac
|
||
echo "target=$target" >> "$GITHUB_OUTPUT"
|
||
|
||
deploy-production:
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 45
|
||
needs: validate-deploy-request
|
||
outputs:
|
||
deployment_url: ${{ steps.vercel.outputs.deployment_url }}
|
||
permissions:
|
||
contents: read
|
||
statuses: read
|
||
environment:
|
||
name: Production
|
||
url: https://clawhub.ai
|
||
env:
|
||
PLAYWRIGHT_BASE_URL: https://clawhub.ai
|
||
steps:
|
||
- name: Check deploy configuration
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: |
|
||
set -euo pipefail
|
||
missing=()
|
||
|
||
if [[ "${{ needs.validate-deploy-request.outputs.deploy_backend }}" == "true" && -z "$CONVEX_DEPLOY_KEY" ]]; then
|
||
missing+=("CONVEX_DEPLOY_KEY")
|
||
fi
|
||
|
||
if (( ${#missing[@]} > 0 )); then
|
||
echo "::error::Missing required production environment secrets: ${missing[*]}"
|
||
exit 1
|
||
fi
|
||
|
||
echo "Deploy target: ${{ needs.validate-deploy-request.outputs.target }}"
|
||
echo "Allow deleting large Convex indexes: ${{ inputs.allow_deleting_large_indexes }}"
|
||
|
||
- uses: actions/checkout@v7.0.1
|
||
|
||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||
with:
|
||
bun-version: 1.3.10
|
||
|
||
- name: Install
|
||
run: bun install --frozen-lockfile
|
||
|
||
- name: Inspect external skill rollout modes
|
||
id: rollout
|
||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||
env:
|
||
ACTIVE_ROLLOUT_DEPLOY_CONFIRM: ${{ inputs.active_rollout_deploy_confirm }}
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: |
|
||
set -euo pipefail
|
||
names="$(bunx convex env list --names-only --prod)"
|
||
pause_required=false
|
||
|
||
for name in \
|
||
CLAWHUB_SKILLS_SH_ROLLOUT_MODE \
|
||
CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE
|
||
do
|
||
value=""
|
||
if grep -Fxq "$name" <<< "$names"; then
|
||
value="$(bunx convex env get "$name" --prod)"
|
||
fi
|
||
case "$value" in
|
||
""|off)
|
||
expected_mode=off
|
||
restore_mode=""
|
||
;;
|
||
test|production)
|
||
expected_mode="$value"
|
||
restore_mode="$value"
|
||
pause_required=true
|
||
;;
|
||
*)
|
||
echo "::error::$name has unsupported rollout mode '$value'"
|
||
exit 1
|
||
;;
|
||
esac
|
||
|
||
case "$name" in
|
||
CLAWHUB_SKILLS_SH_ROLLOUT_MODE)
|
||
echo "skills_sh_expected_mode=$expected_mode" >> "$GITHUB_OUTPUT"
|
||
echo "skills_sh_restore_mode=$restore_mode" >> "$GITHUB_OUTPUT"
|
||
;;
|
||
CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE)
|
||
echo "github_skill_sync_expected_mode=$expected_mode" >> "$GITHUB_OUTPUT"
|
||
echo "github_skill_sync_restore_mode=$restore_mode" >> "$GITHUB_OUTPUT"
|
||
;;
|
||
esac
|
||
done
|
||
|
||
echo "pause_required=$pause_required" >> "$GITHUB_OUTPUT"
|
||
if [[ "$pause_required" == "true" && "$ACTIVE_ROLLOUT_DEPLOY_CONFIRM" != "pause-and-restore-active-rollouts" ]]; then
|
||
echo "::error::Active external-skill rollouts require a backend deploy with active_rollout_deploy_confirm=pause-and-restore-active-rollouts."
|
||
exit 1
|
||
fi
|
||
|
||
- name: Pause external skill rollouts
|
||
if: steps.rollout.outputs.pause_required == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
GITHUB_SKILL_SYNC_RESTORE_MODE: ${{ steps.rollout.outputs.github_skill_sync_restore_mode }}
|
||
SKILLS_SH_RESTORE_MODE: ${{ steps.rollout.outputs.skills_sh_restore_mode }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ -n "$SKILLS_SH_RESTORE_MODE" ]]; then
|
||
bunx convex env set CLAWHUB_SKILLS_SH_ROLLOUT_MODE off --prod
|
||
fi
|
||
if [[ -n "$GITHUB_SKILL_SYNC_RESTORE_MODE" ]]; then
|
||
bunx convex env set CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE off --prod
|
||
fi
|
||
|
||
- name: Stamp Convex runtime environment
|
||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: bunx convex env set CLAWHUB_ENV production --prod
|
||
|
||
- name: Stamp Convex build SHA
|
||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: bunx convex env set APP_BUILD_SHA "${GITHUB_SHA}" --prod
|
||
|
||
- name: Stamp Convex deploy time
|
||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: bunx convex env set APP_DEPLOYED_AT "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" --prod
|
||
|
||
- name: Deploy Convex
|
||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: |
|
||
set -euo pipefail
|
||
if [[ "${{ inputs.allow_deleting_large_indexes }}" == "true" ]]; then
|
||
bunx convex deploy --typecheck=disable --yes --allow-deleting-large-indexes
|
||
else
|
||
bun run convex:deploy
|
||
fi
|
||
|
||
- name: Publish promotions feed snapshot
|
||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: bunx convex run promotionsFeed:publishInternal --prod
|
||
|
||
- name: Verify Convex contract
|
||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: bun run verify:convex-contract -- --prod
|
||
|
||
- name: Verify dark rollout capabilities
|
||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
run: |
|
||
set -euo pipefail
|
||
capabilities="$(bunx convex run rolloutCapabilities:getPublicCapabilities --prod)"
|
||
jq -e '
|
||
.environment == "production" and
|
||
.skillsSh.mode == "off" and
|
||
.skillsSh.runtimeEnabled == false and
|
||
.skillsSh.publicCatalogEnabled == false and
|
||
.skillsSh.scanPlanningEnabled == false and
|
||
.skillsSh.scanAdmissionEnabled == false and
|
||
.githubSkillSync.mode == "off" and
|
||
.githubSkillSync.selfServiceEnabled == false
|
||
' <<< "$capabilities"
|
||
|
||
- name: Restore external skill rollouts
|
||
if: always() && steps.rollout.outputs.pause_required == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
GITHUB_SKILL_SYNC_RESTORE_MODE: ${{ steps.rollout.outputs.github_skill_sync_restore_mode }}
|
||
SKILLS_SH_RESTORE_MODE: ${{ steps.rollout.outputs.skills_sh_restore_mode }}
|
||
run: |
|
||
set -uo pipefail
|
||
restore_failed=0
|
||
if [[ -n "$SKILLS_SH_RESTORE_MODE" ]] &&
|
||
! bunx convex env set CLAWHUB_SKILLS_SH_ROLLOUT_MODE "$SKILLS_SH_RESTORE_MODE" --prod
|
||
then
|
||
echo "::error::Failed to restore CLAWHUB_SKILLS_SH_ROLLOUT_MODE."
|
||
restore_failed=1
|
||
fi
|
||
if [[ -n "$GITHUB_SKILL_SYNC_RESTORE_MODE" ]] &&
|
||
! bunx convex env set CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE "$GITHUB_SKILL_SYNC_RESTORE_MODE" --prod
|
||
then
|
||
echo "::error::Failed to restore CLAWHUB_GITHUB_SKILL_SYNC_ROLLOUT_MODE."
|
||
restore_failed=1
|
||
fi
|
||
exit "$restore_failed"
|
||
|
||
- name: Verify restored external skill rollouts
|
||
if: always() && steps.rollout.outputs.pause_required == 'true'
|
||
env:
|
||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||
GITHUB_SKILL_SYNC_EXPECTED_MODE: ${{ steps.rollout.outputs.github_skill_sync_expected_mode }}
|
||
SKILLS_SH_EXPECTED_MODE: ${{ steps.rollout.outputs.skills_sh_expected_mode }}
|
||
run: |
|
||
set -euo pipefail
|
||
capabilities="$(bunx convex run rolloutCapabilities:getPublicCapabilities --prod)"
|
||
jq -e \
|
||
--arg skills_sh_mode "$SKILLS_SH_EXPECTED_MODE" \
|
||
--arg github_skill_sync_mode "$GITHUB_SKILL_SYNC_EXPECTED_MODE" \
|
||
'
|
||
.environment == "production" and
|
||
.skillsSh.mode == $skills_sh_mode and
|
||
.githubSkillSync.mode == $github_skill_sync_mode
|
||
' <<< "$capabilities"
|
||
|
||
- name: Wait for Vercel production deployment
|
||
id: vercel
|
||
if: needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||
env:
|
||
GH_TOKEN: ${{ github.token }}
|
||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||
GITHUB_SHA: ${{ github.sha }}
|
||
VERCEL_STATUS_CONTEXT: Vercel – clawhub
|
||
run: |
|
||
set -euo pipefail
|
||
for attempt in {1..90}; do
|
||
if ! status_json="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/status" \
|
||
--jq '.statuses[] | select(.context == env.VERCEL_STATUS_CONTEXT) | {state, target_url, description} | @base64' \
|
||
2>/dev/null | head -n1)"; then
|
||
echo "GitHub status check failed for $GITHUB_SHA on attempt $attempt; retrying..."
|
||
sleep 10
|
||
continue
|
||
fi
|
||
|
||
if [[ -z "$status_json" ]]; then
|
||
state=""
|
||
target_url=""
|
||
else
|
||
state="$(printf '%s' "$status_json" | base64 -d | jq -r '.state // ""')"
|
||
target_url="$(printf '%s' "$status_json" | base64 -d | jq -r '.target_url // ""')"
|
||
fi
|
||
|
||
case "$state" in
|
||
success)
|
||
echo "Vercel production deployment ready for $GITHUB_SHA"
|
||
echo "deployment_url=$target_url" >> "$GITHUB_OUTPUT"
|
||
exit 0
|
||
;;
|
||
failure|error)
|
||
echo "::error::Vercel production deployment failed for $GITHUB_SHA"
|
||
exit 1
|
||
;;
|
||
pending)
|
||
echo "Vercel deployment pending for $GITHUB_SHA on attempt $attempt; waiting..."
|
||
;;
|
||
*)
|
||
echo "Vercel status for $GITHUB_SHA not published yet on attempt $attempt; waiting..."
|
||
;;
|
||
esac
|
||
|
||
sleep 10
|
||
done
|
||
|
||
echo "::error::Timed out waiting for Vercel production deployment for $GITHUB_SHA"
|
||
exit 1
|
||
|
||
- name: Install Playwright browser
|
||
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||
run: bunx playwright install --with-deps chromium webkit
|
||
|
||
- name: Smoke test production HTTP
|
||
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
|
||
run: bun run test:e2e:prod-http
|
||
|
||
- name: Write authenticated storage state
|
||
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||
env:
|
||
PLAYWRIGHT_AUTH_STORAGE_STATE_JSON: ${{ secrets.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON }}
|
||
run: |
|
||
if [[ -z "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" ]]; then
|
||
echo "PLAYWRIGHT_AUTH_STORAGE_STATE_JSON not set; authenticated smoke will be skipped."
|
||
exit 0
|
||
fi
|
||
echo "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" > "$RUNNER_TEMP/playwright-auth.json"
|
||
echo "PLAYWRIGHT_AUTH_STORAGE_STATE=$RUNNER_TEMP/playwright-auth.json" >> "$GITHUB_ENV"
|
||
|
||
- name: Smoke test production UI
|
||
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||
run: bunx playwright test --workers=1 e2e/menu-smoke.pw.test.ts e2e/publish-entry-workflows.pw.test.ts e2e/upload-auth-smoke.pw.test.ts
|
||
|
||
tag-production-deployment:
|
||
runs-on: ubuntu-latest
|
||
timeout-minutes: 10
|
||
needs:
|
||
- validate-deploy-request
|
||
- deploy-production
|
||
if: needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||
permissions:
|
||
contents: write
|
||
steps:
|
||
- uses: actions/checkout@v7.0.1
|
||
with:
|
||
fetch-depth: 0
|
||
|
||
- name: Tag production frontend deployment
|
||
env:
|
||
DEPLOY_TARGET: ${{ needs.validate-deploy-request.outputs.target }}
|
||
DEPLOYMENT_URL: ${{ needs.deploy-production.outputs.deployment_url }}
|
||
run: |
|
||
set -euo pipefail
|
||
deployed_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||
tag_name="deploy/prod/$(date -u +"%Y%m%d-%H%M%SZ")-${GITHUB_SHA::7}"
|
||
version_prefix="prod/v$(date -u +"%Y.%m.%d")."
|
||
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||
|
||
git config user.name "github-actions[bot]"
|
||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||
|
||
next_version=1
|
||
while IFS= read -r existing_tag; do
|
||
existing_tag="${existing_tag#refs/tags/}"
|
||
existing_tag="${existing_tag%\^\{\}}"
|
||
suffix="${existing_tag##*.}"
|
||
if [[ "$existing_tag" == "$version_prefix"* && "$suffix" =~ ^[0-9]+$ && "$suffix" -ge "$next_version" ]]; then
|
||
next_version=$((suffix + 1))
|
||
fi
|
||
done < <(git ls-remote --tags origin "refs/tags/${version_prefix}*" | awk '{print $2}' | sort -u)
|
||
version_tag="${version_prefix}${next_version}"
|
||
|
||
git tag -a "$tag_name" "$GITHUB_SHA" \
|
||
-m "Production frontend deploy $tag_name" \
|
||
-m "SHA: $GITHUB_SHA" \
|
||
-m "Version: $version_tag" \
|
||
-m "Deployed at: $deployed_at" \
|
||
-m "Target: $DEPLOY_TARGET" \
|
||
-m "Vercel: ${DEPLOYMENT_URL:-unknown}" \
|
||
-m "Run: $run_url"
|
||
git tag -a "$version_tag" "$GITHUB_SHA" \
|
||
-m "Production frontend deploy $version_tag" \
|
||
-m "SHA: $GITHUB_SHA" \
|
||
-m "Timestamp tag: $tag_name" \
|
||
-m "Deployed at: $deployed_at" \
|
||
-m "Target: $DEPLOY_TARGET" \
|
||
-m "Vercel: ${DEPLOYMENT_URL:-unknown}" \
|
||
-m "Run: $run_url"
|
||
git push origin "refs/tags/$tag_name" "refs/tags/$version_tag"
|