name: Dev CI and Beta Release on: push: branches: [dev] workflow_dispatch: permissions: contents: read concurrency: group: dev-beta-release-${{ github.ref }} cancel-in-progress: false env: GHCR_IMAGE: ghcr.io/${{ github.repository }} DOCKERHUB_IMAGE: digitop/goclaw INITIAL_VERSION: 3.11.3 PRERELEASE_ID: beta jobs: go: runs-on: ubuntu-latest services: pg: image: pgvector/pgvector:pg18 env: POSTGRES_PASSWORD: test POSTGRES_DB: goclaw_test ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 3s --health-retries 10 env: TEST_DATABASE_URL: postgres://postgres:test@localhost:5432/goclaw_test?sslmode=disable steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 with: go-version-file: go.mod cache-dependency-path: go.sum - run: go build ./... - run: go build -tags sqliteonly ./... - run: go vet ./... - name: Unit tests run: go test -race -timeout=5m -coverpkg=./... -coverprofile=coverage.out ./... - name: Invariant tests (P0) run: go test -race -timeout=90s -tags integration ./tests/invariants/... - name: Contract tests (P1) run: go test -race -timeout=90s -tags integration ./tests/contracts/... || echo "::warning::Contract tests skipped (no server configured)" continue-on-error: true - name: Integration tests run: go test -race -timeout=180s -tags integration ./tests/integration/ - name: Coverage summary run: go tool cover -func=coverage.out | tail -1 web: runs-on: ubuntu-latest defaults: run: working-directory: ui/web steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: version: 10 - uses: actions/setup-node@v4 with: node-version: 22 cache: pnpm cache-dependency-path: ui/web/pnpm-lock.yaml - run: pnpm install --frozen-lockfile - run: pnpm lint - run: pnpm build beta_version: needs: [go, web] if: github.ref == 'refs/heads/dev' runs-on: ubuntu-latest permissions: contents: write outputs: released: ${{ steps.version.outputs.released }} version: ${{ steps.version.outputs.version }} tag: ${{ steps.version.outputs.tag }} notes_path: ${{ steps.version.outputs.notes_path }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Fetch upstream release tags run: git fetch --force --tags https://github.com/nextlevelbuilder/goclaw.git "refs/tags/v*:refs/tags/v*" - name: Compute semantic beta version id: version run: node scripts/ci/semantic-beta-version.mjs - name: Create or verify beta tag if: steps.version.outputs.released == 'true' env: TAG: ${{ steps.version.outputs.tag }} run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" if ! git rev-parse "$TAG" >/dev/null 2>&1; then git tag -a "$TAG" -m "Release $TAG" git push origin "$TAG" fi - name: Upload release notes if: steps.version.outputs.released == 'true' uses: actions/upload-artifact@v4 with: name: release-notes path: ${{ steps.version.outputs.notes_path }} build_binaries: needs: beta_version if: needs.beta_version.outputs.released == 'true' runs-on: ubuntu-latest strategy: fail-fast: false matrix: include: - goos: linux goarch: amd64 - goos: linux goarch: arm64 steps: - uses: actions/checkout@v4 with: ref: ${{ needs.beta_version.outputs.tag }} - uses: actions/setup-go@v5 with: go-version-file: go.mod cache-dependency-path: go.sum - uses: actions/setup-node@v4 with: node-version: 22 - name: Build web UI run: | corepack enable && corepack prepare pnpm@10.28.2 --activate cd ui/web && pnpm install --frozen-lockfile && pnpm build && cd ../.. mkdir -p internal/webui/dist cp -r ui/web/dist/* internal/webui/dist/ - name: Build binary env: GOOS: ${{ matrix.goos }} GOARCH: ${{ matrix.goarch }} VERSION: ${{ needs.beta_version.outputs.tag }} run: | CGO_ENABLED=0 go build -tags embedui \ -ldflags="-s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=${VERSION}" \ -o goclaw . tar -czf "goclaw-${VERSION}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" goclaw migrations/ - name: Upload artifacts uses: actions/upload-artifact@v4 with: name: binary-${{ matrix.goos }}-${{ matrix.goarch }} path: goclaw-*.tar.gz publish_release: needs: [beta_version, build_binaries, promote_beta_aliases] if: needs.beta_version.outputs.released == 'true' runs-on: ubuntu-latest permissions: contents: write steps: - name: Download all artifacts uses: actions/download-artifact@v4 with: pattern: binary-* path: artifacts merge-multiple: true - name: Download release notes uses: actions/download-artifact@v4 with: name: release-notes path: release-notes - name: Publish prerelease env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_REPO: ${{ github.repository }} TAG: ${{ needs.beta_version.outputs.tag }} run: | (cd artifacts && sha256sum goclaw-*.tar.gz > CHECKSUMS.sha256) if gh release view "$TAG" >/dev/null 2>&1; then gh release edit "$TAG" \ --title "GoClaw $TAG" \ --notes-file release-notes/release-notes.md \ --prerelease else gh release create "$TAG" \ --title "GoClaw $TAG" \ --notes-file release-notes/release-notes.md \ --prerelease fi gh release upload "$TAG" artifacts/* --clobber docker_images: needs: beta_version if: needs.beta_version.outputs.released == 'true' runs-on: ubuntu-latest permissions: contents: read packages: write env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} strategy: fail-fast: false matrix: include: - variant: latest suffix: "" enable_otel: "false" enable_embedui: "true" enable_python: "true" enable_full_skills: "false" - variant: full suffix: "-full" enable_otel: "false" enable_embedui: "true" enable_python: "true" enable_full_skills: "true" steps: - uses: actions/checkout@v4 with: ref: ${{ needs.beta_version.outputs.tag }} - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' uses: docker/login-action@v3 with: username: ${{ env.DOCKERHUB_USERNAME }} password: ${{ env.DOCKERHUB_TOKEN }} - name: Resolve Docker tags id: docker_tags env: TAG: ${{ needs.beta_version.outputs.tag }} SUFFIX: ${{ matrix.suffix }} run: | { echo "tags<> "$GITHUB_OUTPUT" if [[ -z "$DOCKERHUB_USERNAME" || -z "$DOCKERHUB_TOKEN" ]]; then echo "::notice::Docker Hub secrets not configured; publishing GHCR only." fi - name: Build and push uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.docker_tags.outputs.tags }} build-args: | ENABLE_OTEL=${{ matrix.enable_otel }} ENABLE_EMBEDUI=${{ matrix.enable_embedui }} ENABLE_PYTHON=${{ matrix.enable_python }} ENABLE_FULL_SKILLS=${{ matrix.enable_full_skills }} VERSION=${{ needs.beta_version.outputs.tag }} cache-from: type=gha,scope=dev-beta-${{ matrix.variant }} cache-to: type=gha,mode=max,scope=dev-beta-${{ matrix.variant }} promote_beta_aliases: needs: [beta_version, docker_images] if: needs.beta_version.outputs.released == 'true' runs-on: ubuntu-latest permissions: contents: read packages: write env: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} steps: - uses: docker/setup-buildx-action@v3 - name: Log in to GHCR uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Log in to Docker Hub if: env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != '' uses: docker/login-action@v3 with: username: ${{ env.DOCKERHUB_USERNAME }} password: ${{ env.DOCKERHUB_TOKEN }} - name: Promote beta aliases env: TAG: ${{ needs.beta_version.outputs.tag }} run: | docker buildx imagetools create -t "${GHCR_IMAGE}:beta" "${GHCR_IMAGE}:${TAG}" docker buildx imagetools create -t "${GHCR_IMAGE}:beta-full" "${GHCR_IMAGE}:${TAG}-full" if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:beta" "${DOCKERHUB_IMAGE}:${TAG}" docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:beta-full" "${DOCKERHUB_IMAGE}:${TAG}-full" else echo "::notice::Docker Hub secrets not configured; promoted GHCR beta aliases only." fi deploy_zuey_beta: needs: [beta_version, publish_release] if: needs.beta_version.outputs.released == 'true' && github.repository == 'digitopvn/goclaw' runs-on: ubuntu-latest timeout-minutes: 20 permissions: contents: read env: GOCLAW_DEPLOY_URL: ${{ secrets.ZUEY_GOCLAW_URL }} GOCLAW_GATEWAY_TOKEN: ${{ secrets.ZUEY_GOCLAW_GATEWAY_TOKEN }} GOCLAW_UPGRADE_TOKEN: ${{ secrets.ZUEY_GOCLAW_UPGRADE_TOKEN }} GOCLAW_DEPLOY_USER_ID: ${{ vars.ZUEY_GOCLAW_USER_ID || 'system' }} TAG: ${{ needs.beta_version.outputs.tag }} steps: - name: Validate deploy configuration run: | missing=0 for name in GOCLAW_DEPLOY_URL GOCLAW_GATEWAY_TOKEN GOCLAW_UPGRADE_TOKEN TAG; do if [[ -z "${!name}" ]]; then echo "::error::${name} is not configured" missing=1 fi done exit "$missing" - name: Trigger zuey gateway upgrade run: | base_url="${GOCLAW_DEPLOY_URL%/}" body="$(mktemp)" payload="$(printf '{"tag":"%s"}' "$TAG")" status_code="$(curl -sS --retry 3 --retry-delay 2 \ -o "$body" \ -w "%{http_code}" \ -X POST "${base_url}/v1/system/gateway/upgrade" \ -H "Authorization: Bearer ${GOCLAW_GATEWAY_TOKEN}" \ -H "X-GoClaw-Upgrade-Token: ${GOCLAW_UPGRADE_TOKEN}" \ -H "X-GoClaw-User-Id: ${GOCLAW_DEPLOY_USER_ID}" \ -H "Content-Type: application/json" \ --data "$payload")" if [[ "$status_code" != "202" ]]; then echo "::error::gateway upgrade trigger failed with HTTP ${status_code}" cat "$body" exit 1 fi cat "$body" - name: Wait for zuey gateway upgrade run: | base_url="${GOCLAW_DEPLOY_URL%/}" for attempt in {1..90}; do status_json="$(curl -fsS --retry 3 --retry-delay 2 \ -H "Authorization: Bearer ${GOCLAW_GATEWAY_TOKEN}" \ -H "X-GoClaw-Upgrade-Token: ${GOCLAW_UPGRADE_TOKEN}" \ -H "X-GoClaw-User-Id: ${GOCLAW_DEPLOY_USER_ID}" \ "${base_url}/v1/system/gateway/upgrade/status")" state="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("state", ""))' <<< "$status_json")" if [[ "$state" == "succeeded" ]]; then echo "$status_json" exit 0 fi if [[ "$state" == "failed" ]]; then echo "::error::gateway upgrade failed" echo "$status_json" exit 1 fi echo "upgrade state=${state:-unknown}; attempt ${attempt}/90" sleep 10 done echo "::error::gateway upgrade timed out" exit 1 - name: Verify public health run: | base_url="${GOCLAW_DEPLOY_URL%/}" health_json="$(curl -fsS --retry 5 --retry-delay 3 "${base_url}/health")" status="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("status", ""))' <<< "$health_json")" if [[ "$status" != "ok" ]]; then echo "::error::unexpected health response" echo "$health_json" exit 1 fi echo "$health_json"