diff --git a/.github/workflows/dev-beta-release.yaml b/.github/workflows/dev-beta-release.yaml index 8c027273..928f35ac 100644 --- a/.github/workflows/dev-beta-release.yaml +++ b/.github/workflows/dev-beta-release.yaml @@ -190,6 +190,7 @@ jobs: 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" \ @@ -325,3 +326,86 @@ jobs: 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" diff --git a/CLAUDE.md b/CLAUDE.md index 3990b838..c7a1e0d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,7 +123,7 @@ make desktop-dmg VERSION=0.1.0 # Create .dmg installer (macOS only | Workflow | Trigger | Purpose | |----------|---------|---------| | `ci.yaml` | push main, PR→main/dev | Go build+test+vet, Web build | -| `dev-beta-release.yaml` | push dev | Go build+test+vet, Web build, semantic beta prerelease, beta Docker | +| `dev-beta-release.yaml` | push dev | Go build+test+vet, Web build, semantic beta prerelease, beta Docker, zuey VPS deploy | | `release.yaml` | tag `v[0-9]+.[0-9]+.[0-9]+` | Binaries + Docker (4 variants + web) + Discord | | `release-beta.yaml` | tag `v*-beta*` / `v*-rc*` | Beta binaries + Docker + GitHub prerelease | | `release-desktop.yaml` | tag `lite-v*` | Desktop app (macOS+Windows), auto prerelease for `-beta`/`-rc` tags | @@ -165,7 +165,7 @@ OTel and Tailscale variants are not pre-built — build from source with the app ### Tag Pattern Safety - `release.yaml`: tag-triggered (`v[0-9]+.[0-9]+.[0-9]+`) — clean semver only, no beta/rc -- `dev-beta-release.yaml`: branch-triggered on `dev`; creates `vX.Y.Z-beta.N` tags after CI passes +- `dev-beta-release.yaml`: branch-triggered on `dev`; creates `vX.Y.Z-beta.N` tags after CI passes, then deploys that tag to zuey via the protected gateway upgrade endpoint - `release-beta.yaml`: tag-triggered (`v*-beta*`, `v*-rc*`) — never matches clean semver - `release-desktop.yaml`: tag-triggered (`lite-v*`) — `lite-` prefix prevents overlap - Stable and desktop tag patterns remain distinct. `dev` branch pushes create beta releases only after CI passes diff --git a/docs/deployment-guide.md b/docs/deployment-guide.md index 5c79d5cb..88c37c21 100644 --- a/docs/deployment-guide.md +++ b/docs/deployment-guide.md @@ -162,7 +162,7 @@ sudo /usr/local/bin/goclaw-upgrade-release latest sudo /usr/local/bin/goclaw-upgrade-release v3.12.0 ``` -The script downloads the Linux amd64 GitHub Release tarball from `digitopvn/goclaw`, follows GitHub release redirects, verifies `CHECKSUMS.sha256`, extracts to `/opt/goclaw/releases/`, and calls `goclaw-deploy`. +The script downloads the Linux amd64 GitHub Release tarball from `digitopvn/goclaw`, follows GitHub release redirects, verifies `CHECKSUMS.sha256` when present, falls back to the GitHub release asset SHA256 digest for beta assets without checksum files, extracts to `/opt/goclaw/releases/`, and calls `goclaw-deploy`. The HTTP API still accepts only `tag`; it does not accept repo names or custom download URLs. @@ -188,6 +188,29 @@ Keep upgrade tokens in server env files or secret managers. Do not put real toke The remote trigger endpoint fails closed unless `GOCLAW_UPGRADE_TRIGGER_TOKEN` is configured in the gateway environment. +### Automatic Beta Deploy From `dev` + +Pushing or merging into `dev` runs `.github/workflows/dev-beta-release.yaml`. After Go/Web checks pass, the workflow creates the next semantic beta tag, publishes the prerelease assets, promotes beta Docker aliases, then deploys that exact beta tag to the zuey VPS through the gateway upgrade endpoint. + +Required GitHub Actions configuration: + +| Name | Type | Value | +|---|---|---| +| `ZUEY_GOCLAW_URL` | Secret | Public gateway URL, for example `https://goclaw.zuey.me` | +| `ZUEY_GOCLAW_GATEWAY_TOKEN` | Secret | Gateway bearer token from the server env | +| `ZUEY_GOCLAW_UPGRADE_TOKEN` | Secret | Upgrade trigger token from the server env | +| `ZUEY_GOCLAW_USER_ID` | Variable | Optional owner identity, defaults to `system` | + +The deploy job sends: + +```bash +POST /v1/system/gateway/upgrade {"tag":"vX.Y.Z-beta.N"} +GET /v1/system/gateway/upgrade/status +GET /health +``` + +The workflow fails if the upgrade status becomes `failed`, times out, or public health does not return `{"status":"ok"}`. + Manual local-build fallback: Build locally with embedded web UI: diff --git a/docs/project-changelog.md b/docs/project-changelog.md index ef5732da..3db8a4cb 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -4,6 +4,23 @@ Significant changes, features, and fixes in reverse chronological order. --- +## 2026-05-22 + +### CI/CD: zuey beta deploy + +**Features** + +- Added automatic zuey VPS deployment to the `Dev CI and Beta Release` workflow after beta prerelease assets are published. +- The deploy job triggers the protected gateway upgrade endpoint with the generated `vX.Y.Z-beta.N` tag, waits for upgrade status, and verifies public `/health`. +- Beta prereleases now upload `CHECKSUMS.sha256` alongside binary assets. + +**Fixes** + +- Updated the host release-upgrade script to support beta asset filenames with a leading `v`. +- Added checksum fallback to GitHub release asset SHA256 digests when beta releases do not publish `CHECKSUMS.sha256`. + +--- + ## 2026-05-20 ### HTTP API contract hardening diff --git a/scripts/goclaw-upgrade-release.sh b/scripts/goclaw-upgrade-release.sh index d2b45061..f2056964 100644 --- a/scripts/goclaw-upgrade-release.sh +++ b/scripts/goclaw-upgrade-release.sh @@ -113,25 +113,94 @@ if ! [[ "$RESOLVED_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-(beta|rc)\.[0-9]+)?$ ]]; th fi VERSION="${RESOLVED_TAG#v}" -ASSET="goclaw-${VERSION}-linux-amd64.tar.gz" -ASSET_URL="https://github.com/${REPO}/releases/download/${RESOLVED_TAG}/${ASSET}" +ASSET="" +ASSET_URL="" CHECKSUM_URL="https://github.com/${REPO}/releases/download/${RESOLVED_TAG}/CHECKSUMS.sha256" TARGET_DIR="${RELEASES_DIR}/${RESOLVED_TAG}" -log "requested=${REQUESTED_TAG} resolved=${RESOLVED_TAG} asset=${ASSET}" +download_release_asset() { + local candidate url + for candidate in "goclaw-${VERSION}-linux-amd64.tar.gz" "goclaw-${RESOLVED_TAG}-linux-amd64.tar.gz"; do + url="https://github.com/${REPO}/releases/download/${RESOLVED_TAG}/${candidate}" + log "downloading release asset candidate=${candidate}" + if curl -fsSL -o "$candidate" "$url"; then + ASSET="$candidate" + ASSET_URL="$url" + return 0 + fi + rm -f "$candidate" + done + fail "linux amd64 release asset not found for ${RESOLVED_TAG}" +} + +github_release_asset_digest() { + local asset_name="$1" + curl -fsSL "https://api.github.com/repos/${REPO}/releases/tags/${RESOLVED_TAG}" | python3 -c ' +import json +import sys + +name = sys.argv[1] +release = json.load(sys.stdin) +for asset in release.get("assets", []): + if asset.get("name") == name: + digest = asset.get("digest", "") + if digest.startswith("sha256:"): + print(digest.split(":", 1)[1]) + raise SystemExit(0) +raise SystemExit(1) +' "$asset_name" +} + +verify_release_asset() { + if curl -fsSLO "$CHECKSUM_URL"; then + if grep " ${ASSET}$\|${ASSET}$" CHECKSUMS.sha256 | sha256sum -c -; then + log "checksum verified via CHECKSUMS.sha256" + return 0 + fi + log "checksum file did not verify ${ASSET}; falling back to release asset digest" + else + log "CHECKSUMS.sha256 unavailable; falling back to release asset digest" + fi + + local expected actual + if ! expected="$(github_release_asset_digest "$ASSET")"; then + fail "missing sha256 digest for ${ASSET}" + fi + read -r actual _ < <(sha256sum "$ASSET") + if [ "$actual" != "$expected" ]; then + fail "release asset digest verification failed" + fi + log "checksum verified via GitHub release asset digest" +} + +log "requested=${REQUESTED_TAG} resolved=${RESOLVED_TAG}" + +target_release_is_active() { + [ -d "$TARGET_DIR" ] || return 1 + [ -L "${BASE_DIR}/current" ] || return 1 + [ "$(readlink -f "${BASE_DIR}/current")" = "$(readlink -f "$TARGET_DIR")" ] +} if [ "$DRY_RUN" = "1" ]; then TMP_DIR="$(mktemp -d)" cleanup() { rm -rf "$TMP_DIR"; } trap cleanup EXIT cd "$TMP_DIR" - curl -fsSLO "$ASSET_URL" - curl -fsSLO "$CHECKSUM_URL" - grep " ${ASSET}$\|${ASSET}$" CHECKSUMS.sha256 | sha256sum -c - + download_release_asset + verify_release_asset log "dry-run ok" exit 0 fi +if target_release_is_active; then + if [ ! -x "$TARGET_DIR/goclaw" ] || [ ! -d "$TARGET_DIR/migrations" ]; then + fail "active release is missing goclaw binary or migrations directory" + fi + log "target release already active: ${RESOLVED_TAG}" + write_status "succeeded" "$REQUESTED_TAG" "$RESOLVED_TAG" "" + exit 0 +fi + write_status "running" "$REQUESTED_TAG" "$RESOLVED_TAG" "" TMP_DIR="$(mktemp -d)" @@ -139,11 +208,8 @@ cleanup() { rm -rf "$TMP_DIR"; } trap cleanup EXIT cd "$TMP_DIR" -log "downloading release asset" -curl -fsSLO "$ASSET_URL" -curl -fsSLO "$CHECKSUM_URL" - -grep " ${ASSET}$\|${ASSET}$" CHECKSUMS.sha256 | sha256sum -c - || fail "checksum verification failed" +download_release_asset +verify_release_asset if [ -e "$TARGET_DIR" ]; then fail "target release already exists: $TARGET_DIR"