diff --git a/.github/workflows/dev-beta-release.yaml b/.github/workflows/dev-beta-release.yaml new file mode 100644 index 00000000..a949f3fb --- /dev/null +++ b/.github/workflows/dev-beta-release.yaml @@ -0,0 +1,320 @@ +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 + + release_preflight: + needs: [go, web] + if: github.ref == 'refs/heads/dev' + runs-on: ubuntu-latest + steps: + - name: Validate release secrets + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + test -n "$DOCKERHUB_USERNAME" || { echo "::error::Missing DOCKERHUB_USERNAME secret"; exit 1; } + test -n "$DOCKERHUB_TOKEN" || { echo "::error::Missing DOCKERHUB_TOKEN secret"; exit 1; } + + beta_version: + needs: [go, web, release_preflight] + 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 }} + TAG: ${{ needs.beta_version.outputs.tag }} + run: | + 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 + 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 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Extract metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: | + ${{ env.GHCR_IMAGE }} + ${{ env.DOCKERHUB_IMAGE }} + tags: | + type=raw,value=${{ needs.beta_version.outputs.tag }},suffix=${{ matrix.suffix }} + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + 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 + 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 + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.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" + docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:beta" "${DOCKERHUB_IMAGE}:${TAG}" + docker buildx imagetools create -t "${DOCKERHUB_IMAGE}:beta-full" "${DOCKERHUB_IMAGE}:${TAG}-full" diff --git a/CLAUDE.md b/CLAUDE.md index cb17ab16..3990b838 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,6 +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 | | `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 | @@ -136,7 +137,8 @@ git tag v3.0.0 && git push origin v3.0.0 **Beta release** (from dev): ```bash -git tag v2.67.0-beta.1 && git push origin v2.67.0-beta.1 # standard beta +git push origin dev # auto beta via CI +git tag v2.67.0-beta.1 && git push origin v2.67.0-beta.1 # manual/backfill beta git tag lite-v1.2.0-beta.1 && git push origin lite-v1.2.0-beta.1 # lite beta ``` @@ -163,9 +165,10 @@ 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 - `release-beta.yaml`: tag-triggered (`v*-beta*`, `v*-rc*`) — never matches clean semver - `release-desktop.yaml`: tag-triggered (`lite-v*`) — `lite-` prefix prevents overlap -- No workflow triggers overlap — each tag pattern is distinct. Merging to `main` only triggers CI, not release +- Stable and desktop tag patterns remain distinct. `dev` branch pushes create beta releases only after CI passes ## Desktop Edition (Lite) diff --git a/docs/project-changelog.md b/docs/project-changelog.md index b4923f45..920939cc 100644 --- a/docs/project-changelog.md +++ b/docs/project-changelog.md @@ -6,6 +6,16 @@ Significant changes, features, and fixes in reverse chronological order. ## 2026-05-17 +### CI/CD: dev branch beta automation + +**Features** + +- Added a `Dev CI and Beta Release` GitHub Actions workflow for `dev` pushes that runs Go and Web UI checks before publishing a beta prerelease. +- Added semantic-release-style beta version calculation from Conventional Commits, creating `vX.Y.Z-beta.N` tags and prereleases automatically after tests pass. +- Beta automation uploads Linux binaries and publishes `beta` Docker image tags for the same release version. + +--- + ### Agent Permissions: channel and workspace matrix **Features** diff --git a/scripts/ci/semantic-beta-version.mjs b/scripts/ci/semantic-beta-version.mjs new file mode 100644 index 00000000..f4a01609 --- /dev/null +++ b/scripts/ci/semantic-beta-version.mjs @@ -0,0 +1,180 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import { writeFileSync, appendFileSync } from "node:fs"; + +const prerelease = process.env.PRERELEASE_ID || "beta"; +const initialVersion = process.env.INITIAL_VERSION || "0.1.0"; + +function git(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +function setOutput(name, value) { + const output = process.env.GITHUB_OUTPUT; + if (output) { + appendFileSync(output, `${name}=${value}\n`); + return; + } + console.log(`${name}=${value}`); +} + +function parseVersion(value) { + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+)\.(\d+))?$/.exec(value); + if (!match) return null; + return { + raw: value, + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + preid: match[4] || "", + prenumber: match[5] ? Number(match[5]) : 0, + }; +} + +function tagCommit(tag) { + return git(["rev-list", "-n", "1", tag]); +} + +function compareBase(a, b) { + return a.major - b.major || a.minor - b.minor || a.patch - b.patch; +} + +function compareVersion(a, b) { + const base = compareBase(a, b); + if (base !== 0) return base; + if (!a.preid && b.preid) return 1; + if (a.preid && !b.preid) return -1; + if (a.preid !== b.preid) return a.preid.localeCompare(b.preid); + return a.prenumber - b.prenumber; +} + +function bump(base, level) { + if (level === "major") return { major: base.major + 1, minor: 0, patch: 0 }; + if (level === "minor") return { major: base.major, minor: base.minor + 1, patch: 0 }; + return { major: base.major, minor: base.minor, patch: base.patch + 1 }; +} + +function versionText(version) { + return `${version.major}.${version.minor}.${version.patch}`; +} + +function commitLevel(message) { + const header = message.split(/\r?\n/, 1)[0] || ""; + if (/^[a-zA-Z]+(?:\([^)]+\))?!:/.test(header) || /\nBREAKING[ -]CHANGE:/.test(message)) { + return "major"; + } + if (/^feat(?:\([^)]+\))?:/.test(header)) return "minor"; + if (/^(fix|perf)(?:\([^)]+\))?:/.test(header) || /^revert:/.test(header)) return "patch"; + return ""; +} + +function maxBase(a, b) { + return compareBase(a, b) >= 0 ? a : b; +} + +function writeNoRelease(reason) { + setOutput("released", "false"); + setOutput("version", ""); + setOutput("tag", ""); + console.log(reason); +} + +const tags = git(["tag", "--merged", "HEAD", "--list", "v[0-9]*"]) + .split(/\r?\n/) + .filter(Boolean) + .map(parseVersion) + .filter(Boolean) + .map((tag) => ({ ...tag, commit: tagCommit(tag.raw) })) + .sort(compareVersion) + .reverse(); + +const latest = tags[0]; +const latestStable = tags.find((tag) => !tag.preid); +const latestPrerelease = tags.find((tag) => tag.preid === prerelease); +const head = git(["rev-parse", "HEAD"]); +const previousTag = tags.find((tag) => tag.raw !== latest?.raw); +const repairTag = latest?.preid === prerelease && latest.commit === head ? latest : null; +const range = latest ? [`${latest.raw}..HEAD`] : []; +const logRange = repairTag && previousTag ? [`${previousTag.raw}..HEAD`] : range; +const log = git(["log", "--format=%B%x1e", ...logRange]); +const messages = log.split("\x1e").map((message) => message.trim()).filter(Boolean); + +if (latest?.preid && latest.preid !== prerelease) { + writeNoRelease(`Latest prerelease tag ${latest.raw} is not a ${prerelease} release; skipping ${prerelease} automation.`); + process.exit(0); +} + +if (repairTag) { + const version = repairTag.raw.replace(/^v/, ""); + const releaseNotes = [ + `## ${repairTag.raw}`, + "", + `Automated ${prerelease} release from ${head}.`, + "", + "### Changes", + "", + ...(messages.length ? messages.map((message) => `- ${message.split(/\r?\n/, 1)[0]}`) : ["- Repair release assets for this tag."]), + "", + ].join("\n"); + + writeFileSync("release-notes.md", releaseNotes); + setOutput("released", "true"); + setOutput("version", version); + setOutput("tag", repairTag.raw); + setOutput("notes_path", "release-notes.md"); + console.log(`Repairing ${prerelease} release: ${repairTag.raw}`); + process.exit(0); +} + +let level = ""; +for (const message of messages) { + const next = commitLevel(message); + if (next === "major") { + level = "major"; + break; + } + if (next === "minor" && level !== "major") level = "minor"; + if (next === "patch" && !level) level = "patch"; +} + +if (!level) { + writeNoRelease("No release-worthy conventional commits found since the last release tag."); + process.exit(0); +} + +const zero = { major: 0, minor: 0, patch: 0 }; +const initial = parseVersion(initialVersion) || { major: 0, minor: 1, patch: 0 }; +const stableBase = latestStable || initial || zero; +const bumpedBase = bump(stableBase, level); +const prereleaseBase = latestPrerelease || zero; +let targetBase = maxBase(bumpedBase, prereleaseBase); +if (!latest && compareBase(targetBase, initial) < 0) targetBase = initial; + +const nextNumber = compareBase(targetBase, prereleaseBase) === 0 + ? latestPrerelease.prenumber + 1 + : 1; +const version = `${versionText(targetBase)}-${prerelease}.${nextNumber}`; +const tag = `v${version}`; + +if (tags.some((existing) => existing.raw === tag)) { + writeNoRelease(`Computed tag ${tag} already exists.`); + process.exit(0); +} + +const releaseNotes = [ + `## ${tag}`, + "", + `Automated ${prerelease} release from ${process.env.GITHUB_SHA || git(["rev-parse", "HEAD"])}.`, + "", + "### Changes", + "", + ...messages.map((message) => `- ${message.split(/\r?\n/, 1)[0]}`), + "", +].join("\n"); + +writeFileSync("release-notes.md", releaseNotes); +setOutput("released", "true"); +setOutput("version", version); +setOutput("tag", tag); +setOutput("notes_path", "release-notes.md"); +console.log(`Next ${prerelease} release: ${tag}`);