feat(ci): automate dev beta releases

This commit is contained in:
Duy Nguyen committed 2026-05-17 18:36:13 +07:00
1 parent d57d3810e4
commit 9a6fd2dec9
4 files changed
+515 -2

No files matched your search

+320
View File
@@ -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"
+5 -2
View File
@@ -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)
+10
View File
@@ -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**
+180
View File
@@ -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}`);