Skip to content

fix(web): preserve mcp args and instance labels (#1299) #86

fix(web): preserve mcp args and instance labels (#1299)

fix(web): preserve mcp args and instance labels (#1299) #86

name: Dev CI and Beta Release
on:
push:
branches: [dev]
workflow_dispatch:
permissions:
contents: read
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
concurrency:
group: dev-beta-version-${{ github.ref }}
cancel-in-progress: false
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: Release workflow tests
run: node --test scripts/ci/semantic-beta-version.test.mjs scripts/ci/dev-beta-release-workflow.test.mjs
- 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"
fi
if ! git ls-remote --exit-code --tags origin "refs/tags/$TAG" >/dev/null 2>&1; then
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_zuey_binary:
needs: beta_version
if: needs.beta_version.outputs.released == 'true'
runs-on: ubuntu-latest
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: linux
GOARCH: amd64
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}-linux-amd64.tar.gz" goclaw migrations/ skills/
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: binary-linux-amd64
path: goclaw-*.tar.gz
build_remaining_binaries:
needs: beta_version
if: needs.beta_version.outputs.released == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- 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/ skills/
- 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_zuey_binary]
if: needs.beta_version.outputs.released == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download zuey release artifact
uses: actions/download-artifact@v4
with:
name: binary-linux-amd64
path: artifacts
- 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
complete_release_artifacts:
needs: [beta_version, publish_release, build_zuey_binary, build_remaining_binaries, deploy_zuey_beta]
if: needs.beta_version.outputs.released == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all binary artifacts
uses: actions/download-artifact@v4
with:
pattern: binary-*
path: artifacts
merge-multiple: true
- name: Refresh prerelease assets
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)
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<<EOF"
echo "${GHCR_IMAGE}:${TAG}${SUFFIX}"
if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then
echo "${DOCKERHUB_IMAGE}:${TAG}${SUFFIX}"
fi
echo "EOF"
} >> "$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
concurrency:
group: dev-beta-promote-aliases
cancel-in-progress: false
permissions:
contents: read
packages: write
env:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
steps:
- name: Check latest beta tag
id: beta_freshness
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.beta_version.outputs.tag }}
run: |
latest_beta="$(
gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/v" --paginate \
--jq '.[].ref | select(test("^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+-beta\\.[0-9]+$")) | sub("^refs/tags/"; "")' \
| sort -V \
| tail -n 1
)"
if [[ -z "$latest_beta" ]]; then
echo "::error::No beta tags found; refusing to promote aliases"
exit 1
fi
if [[ "$latest_beta" != "$TAG" ]]; then
echo "::notice::Skipping stale beta alias promotion for ${TAG}; latest beta is ${latest_beta}"
echo "current=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
if: steps.beta_freshness.outputs.current == 'true'
- name: Log in to GHCR
if: steps.beta_freshness.outputs.current == 'true'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: steps.beta_freshness.outputs.current == 'true' && env.DOCKERHUB_USERNAME != '' && env.DOCKERHUB_TOKEN != ''
uses: docker/login-action@v3
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Promote beta aliases
if: steps.beta_freshness.outputs.current == 'true'
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' && github.repository != 'nextlevelbuilder/goclaw'
runs-on: ubuntu-latest
timeout-minutes: 20
concurrency:
group: dev-beta-zuey-deploy
cancel-in-progress: false
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 }}
ZUEY_SSH_HOST: ${{ vars.ZUEY_SSH_HOST || '82.197.71.246' }}
ZUEY_SSH_PORT: ${{ vars.ZUEY_SSH_PORT || '2233' }}
ZUEY_SSH_USER: ${{ vars.ZUEY_SSH_USER || 'zuey' }}
steps:
- name: Check latest beta tag
id: beta_freshness
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
latest_beta="$(
gh api "repos/${GITHUB_REPOSITORY}/git/matching-refs/tags/v" --paginate \
--jq '.[].ref | select(test("^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+-beta\\.[0-9]+$")) | sub("^refs/tags/"; "")' \
| sort -V \
| tail -n 1
)"
if [[ -z "$latest_beta" ]]; then
echo "::error::No beta tags found; refusing to deploy"
exit 1
fi
if [[ "$latest_beta" != "$TAG" ]]; then
echo "::notice::Skipping stale zuey deploy for ${TAG}; latest beta is ${latest_beta}"
echo "current=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "current=true" >> "$GITHUB_OUTPUT"
- name: Validate deploy configuration
if: steps.beta_freshness.outputs.current == 'true'
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: Checkout repository (for VPS script sync)
if: steps.beta_freshness.outputs.current == 'true'
uses: actions/checkout@v4
with:
ref: ${{ needs.beta_version.outputs.tag }}
- name: Sync zuey ops scripts to VPS
if: steps.beta_freshness.outputs.current == 'true'
env:
ZUEY_SSH_PRIVATE_KEY_B64: ${{ secrets.ZUEY_SSH_PRIVATE_KEY_B64 }}
ZUEY_SUDO_PASS: ${{ secrets.ZUEY_SUDO_PASS }}
run: |
set -euo pipefail
if [[ -z "${ZUEY_SSH_PRIVATE_KEY_B64:-}" || -z "${ZUEY_SUDO_PASS:-}" ]]; then
echo "::warning::ZUEY_SSH_PRIVATE_KEY_B64 or ZUEY_SUDO_PASS not configured; skipping VPS script sync. Store the private key as base64 (\`base64 -w0 < /path/to/key\`) in the ZUEY_SSH_PRIVATE_KEY_B64 secret to keep /usr/local/bin/goclaw-deploy and /usr/local/bin/goclaw-upgrade-release in sync with the repo on every beta deploy."
exit 0
fi
# Verify the two scripts exist in the checked-out tag
for f in scripts/zuey/goclaw-deploy.sh scripts/zuey/goclaw-upgrade-release.sh; do
test -f "$f" || { echo "::error::$f missing in repo"; exit 1; }
bash -n "$f" || { echo "::error::$f has syntax error"; exit 1; }
done
# Stage SSH key: base64-decode, write with 0600, validate format.
# Why base64: GitHub Secrets storage round-trips can normalize/strip
# newlines in PEM blocks, producing `error in libcrypto` on load.
# Base64 is a single line, immune to whitespace mangling.
install -m 700 -d ~/.ssh
# Create the key file with a tight umask so it is born at 0600 —
# avoids the brief 0644 window between `>` redirect and `chmod`.
if ! ( umask 077 && printf %s "$ZUEY_SSH_PRIVATE_KEY_B64" | base64 -d > ~/.ssh/id_zuey ) 2>/dev/null; then
echo "::error::ZUEY_SSH_PRIVATE_KEY_B64 is not valid base64. Regenerate with: base64 -w0 < /path/to/private_key"
exit 1
fi
if ! ssh-keygen -y -f ~/.ssh/id_zuey >/dev/null 2>&1; then
echo "::error::Decoded SSH key is not a valid OpenSSH/PEM private key. Verify the source file is a real private key and re-encode."
rm -f ~/.ssh/id_zuey
exit 1
fi
ssh-keyscan -p "$ZUEY_SSH_PORT" -H "$ZUEY_SSH_HOST" >> ~/.ssh/known_hosts 2>/dev/null
SSH_BASE=(-i ~/.ssh/id_zuey -o BatchMode=yes -o StrictHostKeyChecking=yes -o ConnectTimeout=15)
# Upload (-O selects legacy scp protocol; OpenSSH 9.x defaults to sftp
# which is fine here, but -O is portable across runner image versions)
scp -O -P "$ZUEY_SSH_PORT" "${SSH_BASE[@]}" \
scripts/zuey/goclaw-deploy.sh \
scripts/zuey/goclaw-upgrade-release.sh \
"${ZUEY_SSH_USER}@${ZUEY_SSH_HOST}:/tmp/"
# Install on host: backup-if-changed → install (root:root 0755) →
# syntax check. The sudo password is shell-quoted via printf %q and
# interpolated into the remote command line; the SSH channel is
# encrypted and GitHub Actions auto-masks the secret in logs.
# sudo -S reads from stdin and does not echo the password.
quoted_pass=$(printf %q "$ZUEY_SUDO_PASS")
ssh -p "$ZUEY_SSH_PORT" "${SSH_BASE[@]}" "${ZUEY_SSH_USER}@${ZUEY_SSH_HOST}" \
"SUDOPASS=$quoted_pass bash -s" <<'REMOTE'
set -euo pipefail
ts=$(date +%Y%m%d-%H%M%S)
for name in goclaw-deploy goclaw-upgrade-release; do
src="/tmp/${name}.sh"
dst="/usr/local/bin/${name}"
if [ ! -f "$src" ]; then echo "::error::missing $src"; exit 1; fi
if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
echo "no change: $name"
rm -f "$src"
continue
fi
if [ -f "$dst" ]; then
echo "$SUDOPASS" | sudo -S cp -p "$dst" "${dst}.bak-${ts}"
fi
echo "$SUDOPASS" | sudo -S install -o root -g root -m 0755 "$src" "$dst"
echo "$SUDOPASS" | sudo -S bash -n "$dst"
rm -f "$src"
echo "installed: $name"
done
REMOTE
# Clean up the private key from the runner FS
shred -u ~/.ssh/id_zuey 2>/dev/null || rm -f ~/.ssh/id_zuey
- name: Trigger zuey gateway upgrade
if: steps.beta_freshness.outputs.current == 'true'
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
if: steps.beta_freshness.outputs.current == 'true'
run: |
base_url="${GOCLAW_DEPLOY_URL%/}"
for attempt in {1..90}; do
status_err="$(mktemp)"
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" 2>"$status_err" || true)"
if [[ -z "$status_json" ]]; then
echo "upgrade status unavailable; attempt ${attempt}/90"
cat "$status_err"
rm -f "$status_err"
sleep 10
continue
fi
rm -f "$status_err"
state="$(python3 -c 'import json,sys; print(json.load(sys.stdin).get("state", ""))' <<< "$status_json" 2>/dev/null || true)"
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
if: steps.beta_freshness.outputs.current == 'true'
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"