From 0afb80682a8c22bf7e2507577ee2cfbffaa40e89 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Tue, 2 Dec 2025 08:05:49 +0300 Subject: [PATCH 1/6] feat: implement git tag-based versioning and automated releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implemented automatic version detection from git tags and automated CI/CD release workflow: Version Detection: - Modified Makefile to auto-detect git tags as version - Updated build.sh with same git tag detection logic - Version detection priority: exact tag > git describe > "dev" - Automatically strips 'v' prefix from tags (v1.0.0 → 1.0.0) CI/CD Automation: - Created GitHub Actions workflow (.github/workflows/release.yml) - Triggers on git tag push (v*) - Builds binaries for 5 platforms (Linux, macOS, Windows) - Generates SHA256 checksums - Auto-generates release notes from commits - Publishes GitHub Release with all artifacts Documentation: - Added VERSION.md - comprehensive versioning guide - Added RELEASE.md - step-by-step release process - Includes CI/CD workflow documentation Code Quality: - Fixed octal notation in buildsystem.go (0111 → 0o111) Usage: make build # Auto-detects version from git tags make build VERSION=x.y.z # Manual override Release Process: git tag -a v1.0.0 -m "Release version 1.0.0" git push origin v1.0.0 # GitHub Actions handles the rest! --- .github/workflows/release.yml | 261 +++++++++++-------------------- Makefile | 57 +++++++ RELEASE.md | 144 +++++++++++++++++ VERSION.md | 141 +++++++++++++++++ build.sh | 18 ++- internal/services/buildsystem.go | 4 +- 6 files changed, 448 insertions(+), 177 deletions(-) create mode 100644 Makefile create mode 100644 RELEASE.md create mode 100644 VERSION.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2c96bf5..4d404e3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,208 +1,121 @@ -name: Build and Release +name: Release on: - pull_request: - branches: - - main push: tags: - - "v*" # Triggers on version tags like v1.0.0 - workflow_dispatch: + - 'v*' # Triggers on version tags like v1.0.0, v2.1.3, etc. permissions: contents: write jobs: - build: - name: Build Binaries - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-latest - goos: linux - goarch: amd64 - - os: ubuntu-latest - goos: linux - goarch: arm64 - - os: macos-latest - goos: darwin - goarch: amd64 - - os: macos-14 - goos: darwin - goarch: arm64 - - os: ubuntu-latest - goos: windows - goarch: amd64 - + release: + name: Create Release + runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Set up Go - uses: actions/setup-go@v4 with: - go-version: "1.23" + fetch-depth: 0 - - name: Set up Node.js - uses: actions/setup-node@v4 + - name: Set up Go + uses: actions/setup-go@v5 with: - node-version: "18" - cache: "yarn" - cache-dependency-path: web/yarn.lock + go-version: '1.21' - - name: Install frontend dependencies + - name: Get version from tag + id: version run: | - cd web - yarn install --frozen-lockfile + # Remove 'v' prefix from tag + VERSION=${GITHUB_REF#refs/tags/v} + echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT - - name: Build frontend + - name: Build binaries run: | - cd web - yarn build + # Build for multiple platforms + VERSION=${{ steps.version.outputs.version }} + COMMIT=$(git rev-parse --short HEAD) + DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + LDFLAGS="-X main.version=$VERSION -X main.commit=$COMMIT -X main.date=$DATE" - - name: Install cross-compilation tools - run: | - if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then - sudo apt-get update - if [ "${{ matrix.goos }}" = "windows" ]; then - sudo apt-get install -y gcc-mingw-w64 - elif [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then - sudo apt-get install -y gcc-aarch64-linux-gnu - else - sudo apt-get install -y gcc-multilib - fi - elif [[ "${{ matrix.os }}" == macos* ]]; then - echo "Using native macOS compilation" - fi + # Linux amd64 + echo "Building for Linux amd64..." + GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-linux-amd64 . - - name: Set up cross-compilation environment - run: | - if [ "${{ matrix.goos }}" = "windows" ]; then - echo "CC=x86_64-w64-mingw32-gcc" >> $GITHUB_ENV - elif [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then - echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV - fi + # Linux arm64 + echo "Building for Linux arm64..." + GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o vertex-linux-arm64 . - - name: Build binary - env: - GOOS: ${{ matrix.goos }} - GOARCH: ${{ matrix.goarch }} - CGO_ENABLED: 1 - run: | - BINARY_NAME="vertex-${{ matrix.goos }}-${{ matrix.goarch }}" - if [ "${{ matrix.goos }}" = "windows" ]; then - BINARY_NAME="${BINARY_NAME}.exe" - fi - go build -ldflags="-s -w" -o "${BINARY_NAME}" - - if [[ "${{ matrix.os }}" == macos* ]]; then - shasum -a 256 "${BINARY_NAME}" > "${BINARY_NAME}.sha256" - else - sha256sum "${BINARY_NAME}" > "${BINARY_NAME}.sha256" - fi - - - name: Upload artifacts - if: github.event_name != 'pull_request' # don't upload artifacts for PRs - uses: actions/upload-artifact@v4 - with: - name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} - path: | - vertex-${{ matrix.goos }}-${{ matrix.goarch }}* + # macOS amd64 + echo "Building for macOS amd64..." + GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-darwin-amd64 . - docker: - name: Build and Push Docker Images - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - needs: build - - steps: - - name: Checkout code - uses: actions/checkout@v4 + # macOS arm64 + echo "Building for macOS arm64..." + GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o vertex-darwin-arm64 . - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "18" - cache: "yarn" - cache-dependency-path: web/yarn.lock + # Windows amd64 + echo "Building for Windows amd64..." + GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-windows-amd64.exe . - - name: Build frontend + - name: Create checksums run: | - cd web - yarn install --frozen-lockfile - yarn build - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Login 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: ${{ secrets.DOCKERHUB_USERNAME }}/vertex - tags: | - type=ref,event=tag - type=raw,value=latest - type=raw,value={{version}} - - - name: Build and push Docker image - uses: docker/build-push-action@v5 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - build-args: | - BUILDKIT_INLINE_CACHE=1 - - release: - name: Create Release - needs: [build, docker] - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - pattern: binaries-* - merge-multiple: true - - - name: List downloaded files - run: | - echo "Downloaded files:" - find . -name "vertex-*" -type f - find . -name "*.sha256" -type f + sha256sum vertex-* > checksums.txt + cat checksums.txt - name: Generate release notes + id: release_notes run: | - echo "# Vertex Service Manager ${GITHUB_REF#refs/tags/}" > release-notes.md - echo "" >> release-notes.md - echo "## 🚀 Features" >> release-notes.md - echo "- Complete microservice management platform" >> release-notes.md - echo "- Embedded React web interface" >> release-notes.md - echo "- Cross-platform support" >> release-notes.md - echo "- Real-time monitoring and logs" >> release-notes.md - echo "" >> release-notes.md + # Get commits since last tag + PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") + if [ -n "$PREVIOUS_TAG" ]; then + echo "## Changes since $PREVIOUS_TAG" > release_notes.md + echo "" >> release_notes.md + git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" >> release_notes.md + else + echo "## Initial Release" > release_notes.md + echo "" >> release_notes.md + echo "First release of Vertex Service Manager" >> release_notes.md + fi + + echo "" >> release_notes.md + echo "## Installation" >> release_notes.md + echo "" >> release_notes.md + echo "Download the appropriate binary for your platform:" >> release_notes.md + echo "" >> release_notes.md + echo "- **Linux (amd64)**: \`vertex-linux-amd64\`" >> release_notes.md + echo "- **Linux (arm64)**: \`vertex-linux-arm64\`" >> release_notes.md + echo "- **macOS (Intel)**: \`vertex-darwin-amd64\`" >> release_notes.md + echo "- **macOS (Apple Silicon)**: \`vertex-darwin-arm64\`" >> release_notes.md + echo "- **Windows (amd64)**: \`vertex-windows-amd64.exe\`" >> release_notes.md + echo "" >> release_notes.md + echo "Make the binary executable and move it to your PATH:" >> release_notes.md + echo "\`\`\`bash" >> release_notes.md + echo "chmod +x vertex-*" >> release_notes.md + echo "sudo mv vertex-* /usr/local/bin/vertex" >> release_notes.md + echo "\`\`\`" >> release_notes.md + echo "" >> release_notes.md + echo "Verify the installation:" >> release_notes.md + echo "\`\`\`bash" >> release_notes.md + echo "vertex version" >> release_notes.md + echo "\`\`\`" >> release_notes.md + + cat release_notes.md - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@v1 with: - files: | - vertex-* - body_path: release-notes.md + name: Release ${{ steps.version.outputs.tag }} + body_path: release_notes.md draft: false prerelease: false + files: | + vertex-linux-amd64 + vertex-linux-arm64 + vertex-darwin-amd64 + vertex-darwin-arm64 + vertex-windows-amd64.exe + checksums.txt + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ddf3334 --- /dev/null +++ b/Makefile @@ -0,0 +1,57 @@ +.PHONY: build build-release version install clean help + +# Version information +# Try to get version from git tag, fallback to dev +GIT_TAG := $(shell git describe --tags --exact-match 2>/dev/null) +ifneq ($(GIT_TAG),) + # If we're on a tag, use it (strip 'v' prefix if present) + VERSION ?= $(shell echo $(GIT_TAG) | sed 's/^v//') +else + # Otherwise, try to get the latest tag + commit count + GIT_DESCRIBE := $(shell git describe --tags --always 2>/dev/null) + ifneq ($(GIT_DESCRIBE),) + VERSION ?= $(GIT_DESCRIBE) + else + VERSION ?= dev + endif +endif + +COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") + +# Build flags +LDFLAGS := -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.date=$(DATE) + +help: ## Show this help message + @echo 'Usage: make [target]' + @echo '' + @echo 'Available targets:' + @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +build: ## Build vertex with version information + @echo "Building Vertex $(VERSION) ($(COMMIT)) ..." + @go build -ldflags="$(LDFLAGS)" -o vertex . + @echo "✓ Build complete: ./vertex" + +build-release: ## Build release version (set VERSION=x.x.x) + @if [ "$(VERSION)" = "dev" ]; then \ + echo "Error: VERSION must be set for release builds"; \ + echo "Usage: make build-release VERSION=1.0.0"; \ + exit 1; \ + fi + @echo "Building Vertex $(VERSION) ($(COMMIT)) ..." + @go build -ldflags="$(LDFLAGS)" -o vertex . + @echo "✓ Release build complete: ./vertex" + +version: build ## Build and show version information + @./vertex version + +install: build ## Build and install vertex + @./vertex install + +clean: ## Remove build artifacts + @rm -f vertex vertex-* + @echo "✓ Build artifacts removed" + +# Quick development build (alias) +dev: build ## Alias for build diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..3b729ae --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,144 @@ +# Release Guide + +## Quick Release Process + +1. **Commit all changes** + ```bash + git add . + git commit -m "feat: your changes" + ``` + +2. **Create and push version tag** + ```bash + git tag -a v1.0.0 -m "Release version 1.0.0" + git push origin v1.0.0 + ``` + +3. **Wait for GitHub Actions** + - Workflow automatically builds binaries for all platforms + - Creates GitHub Release with binaries and release notes + - Check: https://github.com/zechtz/vertex/actions + +4. **Done!** + - Users can download from: https://github.com/zechtz/vertex/releases + +## What Happens Automatically + +When you push a tag (e.g., `v1.0.0`): + +1. **GitHub Actions triggers** (`.github/workflows/release.yml`) +2. **Builds binaries** for: + - Linux (amd64, arm64) + - macOS (Intel, Apple Silicon) + - Windows (amd64) +3. **Generates SHA256 checksums** +4. **Creates release notes** from git commits +5. **Publishes GitHub Release** with all artifacts + +## Local Testing + +Test the build before tagging: + +```bash +# Build locally with current git tag +make build +./vertex version + +# Build with specific version (override) +make build VERSION=1.0.0-test +./vertex version +``` + +## Version Numbering + +Follow [Semantic Versioning](https://semver.org/): + +- `v1.0.0` - Major release (breaking changes) +- `v1.1.0` - Minor release (new features, backward compatible) +- `v1.1.1` - Patch release (bug fixes) +- `v1.0.0-beta.1` - Pre-release + +## Example Workflow + +```bash +# 1. Make changes +vim internal/services/manager.go + +# 2. Test locally +make build +./vertex version + +# 3. Commit +git add . +git commit -m "feat: add new service management feature" + +# 4. Tag and push +git tag -a v1.2.0 -m "Release version 1.2.0 - Add service management" +git push origin main +git push origin v1.2.0 + +# 5. Monitor GitHub Actions +# Visit: https://github.com/zechtz/vertex/actions + +# 6. Release is ready! +# Visit: https://github.com/zechtz/vertex/releases/latest +``` + +## Troubleshooting + +### Build fails in GitHub Actions + +- Check the Actions tab for detailed logs +- Common issues: + - Tests failing + - Dependencies not available + - Go version mismatch + +### Version not detected + +```bash +# Check git tags +git tag -l + +# Check current version +git describe --tags + +# If no tags exist, create one +git tag -a v0.1.0 -m "Initial release" +``` + +### Need to re-release + +If you need to fix a release: + +```bash +# Delete the tag locally and remotely +git tag -d v1.0.0 +git push origin :refs/tags/v1.0.0 + +# Delete the GitHub Release (via web UI) +# Make your fixes, commit, and re-tag +git tag -a v1.0.0 -m "Release version 1.0.0" +git push origin v1.0.0 +``` + +## Manual Release (Without CI/CD) + +If you need to create a release manually: + +```bash +# Build all platforms +./build.sh + +# This creates: +# - vertex-linux-amd64 +# - vertex-linux-arm64 +# - vertex-darwin-amd64 +# - vertex-darwin-arm64 +# - vertex-windows-amd64.exe + +# Create checksums +sha256sum vertex-* > checksums.txt + +# Manually create GitHub Release and upload files +``` diff --git a/VERSION.md b/VERSION.md new file mode 100644 index 0000000..9e55bb5 --- /dev/null +++ b/VERSION.md @@ -0,0 +1,141 @@ +# Vertex Versioning + +## Automatic Versioning from Git Tags + +Vertex automatically detects and uses git tags as version numbers. This integrates seamlessly with CI/CD workflows. + +### Quick Start + +```bash +# Development build (automatically uses git tag or "dev") +make build + +# Build and show version +make version + +# Manual version override (if needed) +make build VERSION=1.0.0 +``` + +### How Version Detection Works + +1. **On a tagged commit**: Uses the git tag (e.g., `v1.0.0` → version `1.0.0`) +2. **Between tags**: Uses `git describe` output (e.g., `v1.0.0-5-g1234abc`) +3. **No tags**: Falls back to `dev` + +The version is automatically stripped of the `v` prefix if present. + +### Using the Build Script + +The build script also auto-detects git tags: + +```bash +# Auto-detect version from git tags +./build.sh + +# Manual override if needed +VERSION=1.0.0 ./build.sh +``` + +The build script creates cross-platform binaries. + +## Checking Version + +Once built, check the version with: + +```bash +./vertex version +# or +./vertex --version +``` + +Example output: +``` +Vertex 0.1.0 +Commit: fc8869d +Built: 2025-12-02T04:59:13Z +``` + +## Available Make Targets + +- `make help` - Show all available targets +- `make build` - Build with version info (defaults to "dev") +- `make build-release VERSION=x.x.x` - Build a release version +- `make version` - Build and display version +- `make install` - Build and install vertex +- `make clean` - Remove build artifacts +- `make dev` - Alias for `make build` + +## Version Information + +The version information is embedded at build time and includes: + +- **Version**: Semantic version (e.g., 1.0.0) or "dev" for development builds +- **Commit**: Short git commit hash (e.g., fc8869d) +- **Built**: Build timestamp in UTC (e.g., 2025-12-02T04:59:13Z) + +## Automated CI/CD Release Process + +Vertex uses GitHub Actions to automatically build and release binaries when you push a version tag. + +### Creating a Release + +1. **Commit your changes**: + ```bash + git add . + git commit -m "feat: add new feature" + ``` + +2. **Create and push a version tag**: + ```bash + git tag -a v1.0.0 -m "Release version 1.0.0" + git push origin v1.0.0 + ``` + +3. **GitHub Actions automatically**: + - Detects the tag push + - Builds binaries for all platforms: + - Linux (amd64, arm64) + - macOS (amd64, arm64) + - Windows (amd64) + - Generates release notes from commits + - Creates checksums + - Publishes a GitHub Release with all binaries + +4. **Users can download** the pre-built binaries from the GitHub Releases page + +### Version Tag Format + +Use semantic versioning with a `v` prefix: +- `v1.0.0` - Major release +- `v1.1.0` - Minor release (new features) +- `v1.1.1` - Patch release (bug fixes) +- `v2.0.0-beta.1` - Pre-release + +### Manual Release (Local Build) + +If you need to build locally without CI/CD: + +```bash +# Tag your commit +git tag -a v1.0.0 -m "Release version 1.0.0" + +# Build (automatically uses the tag) +make build + +# Or use build script for cross-platform binaries +./build.sh + +# Verify version +./vertex version +``` + +### Release Checklist + +- [ ] All tests passing +- [ ] Version number follows semantic versioning +- [ ] CHANGELOG updated (if you maintain one) +- [ ] Create annotated git tag: `git tag -a vX.Y.Z -m "Release version X.Y.Z"` +- [ ] Push tag: `git push origin vX.Y.Z` +- [ ] Verify GitHub Actions workflow completes successfully +- [ ] Check GitHub Releases page for published release diff --git a/build.sh b/build.sh index 3262385..3508071 100755 --- a/build.sh +++ b/build.sh @@ -3,7 +3,23 @@ # Cross-platform build script for Vertex set -e -VERSION=${VERSION:-"dev"} +# Try to get version from git tag +if [ -z "$VERSION" ]; then + GIT_TAG=$(git describe --tags --exact-match 2>/dev/null || echo "") + if [ -n "$GIT_TAG" ]; then + # If we're on a tag, use it (strip 'v' prefix if present) + VERSION=$(echo "$GIT_TAG" | sed 's/^v//') + else + # Otherwise, try to get the latest tag + commit count + GIT_DESCRIBE=$(git describe --tags --always 2>/dev/null || echo "") + if [ -n "$GIT_DESCRIBE" ]; then + VERSION="$GIT_DESCRIBE" + else + VERSION="dev" + fi + fi +fi + COMMIT=${COMMIT:-$(git rev-parse --short HEAD 2>/dev/null || echo "unknown")} DATE=${DATE:-$(date -u +"%Y-%m-%dT%H:%M:%SZ")} diff --git a/internal/services/buildsystem.go b/internal/services/buildsystem.go index 4a858df..6075f29 100644 --- a/internal/services/buildsystem.go +++ b/internal/services/buildsystem.go @@ -233,7 +233,7 @@ func GenerateMavenWrapper(serviceDir string) error { log.Printf("[DEBUG] Using %s path: %s", mvnExecutable, mvnPath) // Verify executable permissions - if info, err := os.Stat(mvnPath); err != nil || info.Mode().Perm()&0111 == 0 { + if info, err := os.Stat(mvnPath); err != nil || info.Mode().Perm()&0o111 == 0 { return fmt.Errorf("%s path %s is not executable or inaccessible: %v", mvnExecutable, mvnPath, err) } @@ -268,7 +268,7 @@ func GenerateGradleWrapper(serviceDir string) error { // Make gradlew executable on Unix systems gradlewPath := filepath.Join(serviceDir, "gradlew") - if err := os.Chmod(gradlewPath, 0755); err != nil { + if err := os.Chmod(gradlewPath, 0o755); err != nil { log.Printf("[WARN] Failed to make gradlew executable: %v", err) } From 9671e2a05df29d4a7c1e14227c8996c80b311892 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Tue, 2 Dec 2025 08:12:48 +0300 Subject: [PATCH 2/6] fix: add frontend build steps to release workflow The release workflow was failing with "pattern dist/*: no matching files found" because the Go build process embeds the frontend from web/dist/, but the workflow wasn't building the frontend before building the Go binaries. Added steps to: - Set up Node.js 18 with Yarn cache - Install frontend dependencies with yarn install --frozen-lockfile - Build frontend with yarn build This ensures web/dist/ exists before the Go build embeds it. --- .github/workflows/release.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d404e3..b73a59f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,23 @@ jobs: with: go-version: '1.21' + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + cache: 'yarn' + cache-dependency-path: web/yarn.lock + + - name: Install frontend dependencies + run: | + cd web + yarn install --frozen-lockfile + + - name: Build frontend + run: | + cd web + yarn build + - name: Get version from tag id: version run: | From 14b045aa76885647f36eed9f258ee1e1aec43ab3 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Tue, 2 Dec 2025 08:20:49 +0300 Subject: [PATCH 3/6] fix: restore Docker build and add frontend build to Makefile Restored the original GitHub Actions workflow that was accidentally simplified, which removed critical functionality: GitHub Actions Workflow (.github/workflows/release.yml): - Restored matrix build strategy for parallel platform builds - Re-added Docker image build and push to Docker Hub - Added workflow_dispatch trigger for manual runs - Added pull_request trigger for testing - Improved release notes with commit history - Added Docker installation instructions to release notes - Integrated git tag-based versioning into matrix builds Makefile Enhancements: - Added build-frontend target to build React web app - Modified build and build-release to depend on build-frontend - Added clean-all target to remove frontend artifacts - Now ensures web/dist/ exists before Go build embeds it Workflow Jobs: 1. build - Matrix build for all platforms with CGO support 2. docker - Build and push multi-arch Docker images 3. release - Create GitHub Release with binaries and notes This ensures both binaries and Docker images are published on release. --- .github/workflows/release.yml | 297 ++++++++++++++++++++++++---------- Makefile | 17 +- 2 files changed, 229 insertions(+), 85 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b73a59f..dd49ed2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,33 +1,54 @@ -name: Release +name: Build and Release on: + pull_request: + branches: + - main push: tags: - - 'v*' # Triggers on version tags like v1.0.0, v2.1.3, etc. + - "v*" # Triggers on version tags like v1.0.0 + workflow_dispatch: permissions: contents: write jobs: - release: - name: Create Release - runs-on: ubuntu-latest + build: + name: Build Binaries + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + goos: linux + goarch: amd64 + - os: ubuntu-latest + goos: linux + goarch: arm64 + - os: macos-latest + goos: darwin + goarch: amd64 + - os: macos-14 + goos: darwin + goarch: arm64 + - os: ubuntu-latest + goos: windows + goarch: amd64 + steps: - name: Checkout code uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v5 + uses: actions/setup-go@v4 with: - go-version: '1.21' + go-version: "1.23" - name: Set up Node.js uses: actions/setup-node@v4 with: - node-version: '18' - cache: 'yarn' + node-version: "18" + cache: "yarn" cache-dependency-path: web/yarn.lock - name: Install frontend dependencies @@ -40,99 +61,213 @@ jobs: cd web yarn build + - name: Install cross-compilation tools + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + sudo apt-get update + if [ "${{ matrix.goos }}" = "windows" ]; then + sudo apt-get install -y gcc-mingw-w64 + elif [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then + sudo apt-get install -y gcc-aarch64-linux-gnu + else + sudo apt-get install -y gcc-multilib + fi + elif [[ "${{ matrix.os }}" == macos* ]]; then + echo "Using native macOS compilation" + fi + + - name: Set up cross-compilation environment + run: | + if [ "${{ matrix.goos }}" = "windows" ]; then + echo "CC=x86_64-w64-mingw32-gcc" >> $GITHUB_ENV + elif [ "${{ matrix.goos }}" = "linux" ] && [ "${{ matrix.goarch }}" = "arm64" ]; then + echo "CC=aarch64-linux-gnu-gcc" >> $GITHUB_ENV + fi + - name: Get version from tag id: version run: | - # Remove 'v' prefix from tag - VERSION=${GITHUB_REF#refs/tags/v} + # Get version from tag or use git describe + if [[ "${{ github.ref }}" == refs/tags/* ]]; then + VERSION=${GITHUB_REF#refs/tags/v} + else + VERSION=$(git describe --tags --always 2>/dev/null || echo "dev") + fi + COMMIT=$(git rev-parse --short HEAD) + DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT + echo "commit=$COMMIT" >> $GITHUB_OUTPUT + echo "date=$DATE" >> $GITHUB_OUTPUT - - name: Build binaries + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: 1 run: | - # Build for multiple platforms - VERSION=${{ steps.version.outputs.version }} - COMMIT=$(git rev-parse --short HEAD) - DATE=$(date -u +"%Y-%m-%dT%H:%M:%SZ") - LDFLAGS="-X main.version=$VERSION -X main.commit=$COMMIT -X main.date=$DATE" + BINARY_NAME="vertex-${{ matrix.goos }}-${{ matrix.goarch }}" + if [ "${{ matrix.goos }}" = "windows" ]; then + BINARY_NAME="${BINARY_NAME}.exe" + fi + + LDFLAGS="-s -w -X main.version=${{ steps.version.outputs.version }} -X main.commit=${{ steps.version.outputs.commit }} -X main.date=${{ steps.version.outputs.date }}" + go build -ldflags="$LDFLAGS" -o "${BINARY_NAME}" + + if [[ "${{ matrix.os }}" == macos* ]]; then + shasum -a 256 "${BINARY_NAME}" > "${BINARY_NAME}.sha256" + else + sha256sum "${BINARY_NAME}" > "${BINARY_NAME}.sha256" + fi + + - name: Upload artifacts + if: github.event_name != 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: binaries-${{ matrix.goos }}-${{ matrix.goarch }} + path: | + vertex-${{ matrix.goos }}-${{ matrix.goarch }}* + + docker: + name: Build and Push Docker Images + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + needs: build + + steps: + - name: Checkout code + uses: actions/checkout@v4 - # Linux amd64 - echo "Building for Linux amd64..." - GOOS=linux GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-linux-amd64 . + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "18" + cache: "yarn" + cache-dependency-path: web/yarn.lock + + - name: Build frontend + run: | + cd web + yarn install --frozen-lockfile + yarn build + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login 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: ${{ secrets.DOCKERHUB_USERNAME }}/vertex + tags: | + type=ref,event=tag + type=raw,value=latest + type=raw,value={{version}} - # Linux arm64 - echo "Building for Linux arm64..." - GOOS=linux GOARCH=arm64 go build -ldflags="$LDFLAGS" -o vertex-linux-arm64 . + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + build-args: | + BUILDKIT_INLINE_CACHE=1 - # macOS amd64 - echo "Building for macOS amd64..." - GOOS=darwin GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-darwin-amd64 . + release: + name: Create Release + needs: [build, docker] + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') - # macOS arm64 - echo "Building for macOS arm64..." - GOOS=darwin GOARCH=arm64 go build -ldflags="$LDFLAGS" -o vertex-darwin-arm64 . + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 - # Windows amd64 - echo "Building for Windows amd64..." - GOOS=windows GOARCH=amd64 go build -ldflags="$LDFLAGS" -o vertex-windows-amd64.exe . + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + pattern: binaries-* + merge-multiple: true - - name: Create checksums + - name: List downloaded files run: | - sha256sum vertex-* > checksums.txt - cat checksums.txt + echo "Downloaded files:" + find . -name "vertex-*" -type f + find . -name "*.sha256" -type f - name: Generate release notes - id: release_notes run: | + # Get version from tag + VERSION=${GITHUB_REF#refs/tags/} + + echo "# Vertex Service Manager $VERSION" > release-notes.md + echo "" >> release-notes.md + # Get commits since last tag PREVIOUS_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "") if [ -n "$PREVIOUS_TAG" ]; then - echo "## Changes since $PREVIOUS_TAG" > release_notes.md - echo "" >> release_notes.md - git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" >> release_notes.md - else - echo "## Initial Release" > release_notes.md - echo "" >> release_notes.md - echo "First release of Vertex Service Manager" >> release_notes.md + echo "## Changes since $PREVIOUS_TAG" >> release-notes.md + echo "" >> release-notes.md + git log ${PREVIOUS_TAG}..HEAD --pretty=format:"- %s (%h)" >> release-notes.md + echo "" >> release-notes.md fi - echo "" >> release_notes.md - echo "## Installation" >> release_notes.md - echo "" >> release_notes.md - echo "Download the appropriate binary for your platform:" >> release_notes.md - echo "" >> release_notes.md - echo "- **Linux (amd64)**: \`vertex-linux-amd64\`" >> release_notes.md - echo "- **Linux (arm64)**: \`vertex-linux-arm64\`" >> release_notes.md - echo "- **macOS (Intel)**: \`vertex-darwin-amd64\`" >> release_notes.md - echo "- **macOS (Apple Silicon)**: \`vertex-darwin-arm64\`" >> release_notes.md - echo "- **Windows (amd64)**: \`vertex-windows-amd64.exe\`" >> release_notes.md - echo "" >> release_notes.md - echo "Make the binary executable and move it to your PATH:" >> release_notes.md - echo "\`\`\`bash" >> release_notes.md - echo "chmod +x vertex-*" >> release_notes.md - echo "sudo mv vertex-* /usr/local/bin/vertex" >> release_notes.md - echo "\`\`\`" >> release_notes.md - echo "" >> release_notes.md - echo "Verify the installation:" >> release_notes.md - echo "\`\`\`bash" >> release_notes.md - echo "vertex version" >> release_notes.md - echo "\`\`\`" >> release_notes.md - - cat release_notes.md + echo "" >> release-notes.md + echo "## 🚀 Features" >> release-notes.md + echo "- Complete microservice management platform" >> release-notes.md + echo "- Embedded React web interface" >> release-notes.md + echo "- Cross-platform support" >> release-notes.md + echo "- Real-time monitoring and logs" >> release-notes.md + echo "- Profile-based service management" >> release-notes.md + echo "- Environment variable management" >> release-notes.md + echo "" >> release-notes.md + echo "## 📦 Installation" >> release-notes.md + echo "" >> release-notes.md + echo "### Download Binary" >> release-notes.md + echo "" >> release-notes.md + echo "Download the appropriate binary for your platform:" >> release-notes.md + echo "" >> release-notes.md + echo "- **Linux (amd64)**: \`vertex-linux-amd64\`" >> release-notes.md + echo "- **Linux (arm64)**: \`vertex-linux-arm64\`" >> release-notes.md + echo "- **macOS (Intel)**: \`vertex-darwin-amd64\`" >> release-notes.md + echo "- **macOS (Apple Silicon)**: \`vertex-darwin-arm64\`" >> release-notes.md + echo "- **Windows (amd64)**: \`vertex-windows-amd64.exe\`" >> release-notes.md + echo "" >> release-notes.md + echo "\`\`\`bash" >> release-notes.md + echo "# Make executable and install" >> release-notes.md + echo "chmod +x vertex-*" >> release-notes.md + echo "sudo mv vertex-* /usr/local/bin/vertex" >> release-notes.md + echo "" >> release-notes.md + echo "# Verify installation" >> release-notes.md + echo "vertex version" >> release-notes.md + echo "\`\`\`" >> release-notes.md + echo "" >> release-notes.md + echo "### Docker" >> release-notes.md + echo "" >> release-notes.md + echo "\`\`\`bash" >> release-notes.md + echo "docker pull \${{ secrets.DOCKERHUB_USERNAME }}/vertex:$VERSION" >> release-notes.md + echo "# or" >> release-notes.md + echo "docker pull \${{ secrets.DOCKERHUB_USERNAME }}/vertex:latest" >> release-notes.md + echo "\`\`\`" >> release-notes.md + + cat release-notes.md - name: Create GitHub Release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 with: - name: Release ${{ steps.version.outputs.tag }} - body_path: release_notes.md + files: | + vertex-* + body_path: release-notes.md draft: false prerelease: false - files: | - vertex-linux-amd64 - vertex-linux-arm64 - vertex-darwin-amd64 - vertex-darwin-arm64 - vertex-windows-amd64.exe - checksums.txt - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Makefile b/Makefile index ddf3334..cbf0a48 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-release version install clean help +.PHONY: build build-release build-frontend version install clean clean-all help # Version information # Try to get version from git tag, fallback to dev @@ -28,12 +28,17 @@ help: ## Show this help message @echo 'Available targets:' @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST) -build: ## Build vertex with version information +build-frontend: ## Build the frontend web application + @echo "Building frontend..." + @cd web && yarn install --frozen-lockfile && yarn build + @echo "✓ Frontend built: web/dist/" + +build: build-frontend ## Build vertex with version information (includes frontend) @echo "Building Vertex $(VERSION) ($(COMMIT)) ..." @go build -ldflags="$(LDFLAGS)" -o vertex . @echo "✓ Build complete: ./vertex" -build-release: ## Build release version (set VERSION=x.x.x) +build-release: build-frontend ## Build release version (set VERSION=x.x.x) @if [ "$(VERSION)" = "dev" ]; then \ echo "Error: VERSION must be set for release builds"; \ echo "Usage: make build-release VERSION=1.0.0"; \ @@ -49,9 +54,13 @@ version: build ## Build and show version information install: build ## Build and install vertex @./vertex install -clean: ## Remove build artifacts +clean: ## Remove Go build artifacts @rm -f vertex vertex-* @echo "✓ Build artifacts removed" +clean-all: clean ## Remove all build artifacts (including frontend) + @rm -rf web/dist web/node_modules + @echo "✓ All build artifacts removed" + # Quick development build (alias) dev: build ## Alias for build From 28c604d60be8d1da3345afa73b381da9ad47cf68 Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Thu, 4 Dec 2025 05:13:41 +0300 Subject: [PATCH 4/6] feat: add git branch switching for services Implemented complete git branch management for services: Backend (Go): - Added GitBranch field to Service model - Created git.go with helper functions: * IsGitRepository() - Check if directory is a git repo * GetCurrentBranch() - Get active branch * GetBranches() - List all local branches * GetRemoteBranches() - List remote branches * HasUncommittedChanges() - Check for uncommitted work * SwitchBranch() - Switch to different branch with safety checks * GetGitInfo() - Comprehensive git repository information - Added API endpoints: * GET /api/services/{id}/git/info - Get git info * GET /api/services/{id}/git/branches - List all branches * POST /api/services/{id}/git/switch - Switch branch - Manager methods: GetGitInfo(), GetGitBranches(), SwitchGitBranch() - Auto-detect and update git branch on service load Frontend (React/TypeScript): - Created GitBranchSwitcher component with: * Branch dropdown selector * Current branch indicator * Switch button with confirmation * Safety check: prevents switching while service is running * Loading states and error handling - Integrated GitBranchSwitcher into ServiceCard - Added gitBranch field to Service type - Displays branch icon and current branch - Lists all local and remote branches Features: - Automatic branch detection on startup - Safety checks prevent switching with uncommitted changes - Prevents branch switch while service is running - Remote branch tracking (creates local branch if needed) - Comprehensive error handling and user feedback - Seamless integration with existing service management --- internal/handlers/service_handler.go | 110 +++++++++ internal/models/service.go | 1 + internal/services/database.go | 17 ++ internal/services/git.go | 218 ++++++++++++++++++ internal/services/manager.go | 120 ++++++++++ .../GitBranchSwitcher/GitBranchSwitcher.tsx | 191 +++++++++++++++ .../components/ServiceCard/ServiceCard.tsx | 13 ++ web/src/hooks/useServiceManagement.ts | 1 + web/src/types.ts | 1 + 9 files changed, 672 insertions(+) create mode 100644 internal/services/git.go create mode 100644 web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx diff --git a/internal/handlers/service_handler.go b/internal/handlers/service_handler.go index d59a47e..85603ef 100644 --- a/internal/handlers/service_handler.go +++ b/internal/handlers/service_handler.go @@ -48,6 +48,11 @@ func registerServiceRoutes(h *Handler, r *mux.Router) { r.HandleFunc("/api/services/{id}/wrapper/generate", h.generateWrapperHandler).Methods("POST") r.HandleFunc("/api/services/{id}/wrapper/repair", h.repairWrapperHandler).Methods("POST") + // Git operations + r.HandleFunc("/api/services/{id}/git/info", h.getGitInfoHandler).Methods("GET") + r.HandleFunc("/api/services/{id}/git/branches", h.getGitBranchesHandler).Methods("GET") + r.HandleFunc("/api/services/{id}/git/switch", h.switchGitBranchHandler).Methods("POST") + // Utility endpoints r.HandleFunc("/api/services/available-for-profile", h.getAvailableServicesForProfileHandler).Methods("GET") r.HandleFunc("/api/services/normalize-order", h.normalizeServiceOrderHandler).Methods("POST") @@ -1229,3 +1234,108 @@ func (h *Handler) repairWrapperHandler(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(response) } +// Git operation handlers + +func (h *Handler) getGitInfoHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + vars := mux.Vars(r) + serviceUUID := vars["id"] + + if serviceUUID == "" { + http.Error(w, "Service UUID is required", http.StatusBadRequest) + return + } + + _, exists := h.serviceManager.GetServiceByUUID(serviceUUID) + if !exists { + http.Error(w, "Service not found", http.StatusNotFound) + return + } + + gitInfo, err := h.serviceManager.GetGitInfo(serviceUUID) + if err != nil { + log.Printf("[ERROR] Failed to get git info for service %s: %v", serviceUUID, err) + http.Error(w, fmt.Sprintf("Failed to get git info: %v", err), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(gitInfo) +} + +func (h *Handler) getGitBranchesHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + vars := mux.Vars(r) + serviceUUID := vars["id"] + + if serviceUUID == "" { + http.Error(w, "Service UUID is required", http.StatusBadRequest) + return + } + + _, exists := h.serviceManager.GetServiceByUUID(serviceUUID) + if !exists { + http.Error(w, "Service not found", http.StatusNotFound) + return + } + + branches, err := h.serviceManager.GetGitBranches(serviceUUID) + if err != nil { + log.Printf("[ERROR] Failed to get git branches for service %s: %v", serviceUUID, err) + http.Error(w, fmt.Sprintf("Failed to get git branches: %v", err), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "branches": branches, + }) +} + +func (h *Handler) switchGitBranchHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Access-Control-Allow-Origin", "*") + + vars := mux.Vars(r) + serviceUUID := vars["id"] + + if serviceUUID == "" { + http.Error(w, "Service UUID is required", http.StatusBadRequest) + return + } + + _, exists := h.serviceManager.GetServiceByUUID(serviceUUID) + if !exists { + http.Error(w, "Service not found", http.StatusNotFound) + return + } + + var req struct { + Branch string `json:"branch"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if req.Branch == "" { + http.Error(w, "Branch name is required", http.StatusBadRequest) + return + } + + err := h.serviceManager.SwitchGitBranch(serviceUUID, req.Branch) + if err != nil { + log.Printf("[ERROR] Failed to switch git branch for service %s: %v", serviceUUID, err) + http.Error(w, fmt.Sprintf("Failed to switch branch: %v", err), http.StatusInternalServerError) + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "success", + "branch": req.Branch, + "message": fmt.Sprintf("Successfully switched to branch '%s'", req.Branch), + }) +} diff --git a/internal/models/service.go b/internal/models/service.go index d47bbc8..9b3fb46 100644 --- a/internal/models/service.go +++ b/internal/models/service.go @@ -25,6 +25,7 @@ type Service struct { IsEnabled bool `json:"isEnabled"` BuildSystem string `json:"buildSystem"` // "maven", "gradle", or "auto" VerboseLogging bool `json:"verboseLogging"` // Enable verbose/debug logging for build tools + GitBranch string `json:"gitBranch"` // Current git branch (if service is a git repo) EnvVars map[string]EnvVar `json:"envVars"` Cmd *exec.Cmd `json:"-"` Logs []LogEntry `json:"logs"` diff --git a/internal/services/database.go b/internal/services/database.go index 79bafa3..8a1637d 100644 --- a/internal/services/database.go +++ b/internal/services/database.go @@ -153,9 +153,26 @@ func (sm *Manager) loadServices(config models.Config) error { } } + // Update git branch information for all services + sm.updateAllGitBranches() + return nil } +// updateAllGitBranches updates git branch information for all loaded services +func (sm *Manager) updateAllGitBranches() { + sm.mutex.RLock() + serviceIDs := make([]string, 0, len(sm.services)) + for id := range sm.services { + serviceIDs = append(serviceIDs, id) + } + sm.mutex.RUnlock() + + for _, serviceID := range serviceIDs { + _ = sm.UpdateServiceGitBranch(serviceID) + } +} + func (sm *Manager) loadConfigurations() error { rows, err := sm.db.Query("SELECT id, name, services_json, is_default FROM configurations") if err != nil { diff --git a/internal/services/git.go b/internal/services/git.go new file mode 100644 index 0000000..2664b8c --- /dev/null +++ b/internal/services/git.go @@ -0,0 +1,218 @@ +package services + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" +) + +// GitInfo holds git repository information +type GitInfo struct { + IsGitRepo bool `json:"isGitRepo"` + CurrentBranch string `json:"currentBranch"` + Branches []string `json:"branches"` + HasUncommitted bool `json:"hasUncommitted"` +} + +// IsGitRepository checks if a directory is a git repository +func IsGitRepository(dir string) bool { + gitDir := filepath.Join(dir, ".git") + info, err := os.Stat(gitDir) + if err != nil { + return false + } + return info.IsDir() +} + +// GetCurrentBranch returns the current git branch +func GetCurrentBranch(dir string) (string, error) { + if !IsGitRepository(dir) { + return "", fmt.Errorf("not a git repository") + } + + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get current branch: %w", err) + } + + branch := strings.TrimSpace(string(output)) + return branch, nil +} + +// GetBranches returns all local branches +func GetBranches(dir string) ([]string, error) { + if !IsGitRepository(dir) { + return nil, fmt.Errorf("not a git repository") + } + + cmd := exec.Command("git", "branch", "--format=%(refname:short)") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get branches: %w", err) + } + + branches := []string{} + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" { + branches = append(branches, line) + } + } + + return branches, nil +} + +// GetRemoteBranches returns all remote branches +func GetRemoteBranches(dir string) ([]string, error) { + if !IsGitRepository(dir) { + return nil, fmt.Errorf("not a git repository") + } + + // Fetch latest from remote + fetchCmd := exec.Command("git", "fetch", "--all") + fetchCmd.Dir = dir + _ = fetchCmd.Run() // Ignore errors, we'll try to get branches anyway + + cmd := exec.Command("git", "branch", "-r", "--format=%(refname:short)") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return nil, fmt.Errorf("failed to get remote branches: %w", err) + } + + branches := []string{} + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line != "" && !strings.Contains(line, "HEAD") { + branches = append(branches, line) + } + } + + return branches, nil +} + +// HasUncommittedChanges checks if there are uncommitted changes +func HasUncommittedChanges(dir string) (bool, error) { + if !IsGitRepository(dir) { + return false, fmt.Errorf("not a git repository") + } + + cmd := exec.Command("git", "status", "--porcelain") + cmd.Dir = dir + output, err := cmd.Output() + if err != nil { + return false, fmt.Errorf("failed to check git status: %w", err) + } + + return len(strings.TrimSpace(string(output))) > 0, nil +} + +// SwitchBranch switches to a different branch +func SwitchBranch(dir, branch string) error { + if !IsGitRepository(dir) { + return fmt.Errorf("not a git repository") + } + + // Check for uncommitted changes + hasChanges, err := HasUncommittedChanges(dir) + if err != nil { + return err + } + + if hasChanges { + return fmt.Errorf("cannot switch branches: you have uncommitted changes. Please commit or stash them first") + } + + // Check if it's a remote branch that doesn't exist locally + if strings.HasPrefix(branch, "origin/") { + localBranch := strings.TrimPrefix(branch, "origin/") + + // Check if local branch exists + branches, err := GetBranches(dir) + if err != nil { + return err + } + + branchExists := false + for _, b := range branches { + if b == localBranch { + branchExists = true + break + } + } + + if !branchExists { + // Create and track the remote branch + cmd := exec.Command("git", "checkout", "-b", localBranch, branch) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to checkout remote branch: %s", string(output)) + } + return nil + } + + branch = localBranch + } + + // Switch to the branch + cmd := exec.Command("git", "checkout", branch) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to switch branch: %s", string(output)) + } + + return nil +} + +// GetGitInfo returns comprehensive git information for a directory +func GetGitInfo(dir string) (*GitInfo, error) { + info := &GitInfo{ + IsGitRepo: IsGitRepository(dir), + } + + if !info.IsGitRepo { + return info, nil + } + + // Get current branch + currentBranch, err := GetCurrentBranch(dir) + if err == nil { + info.CurrentBranch = currentBranch + } + + // Get all branches (local + remote) + localBranches, _ := GetBranches(dir) + remoteBranches, _ := GetRemoteBranches(dir) + + // Combine and deduplicate + branchMap := make(map[string]bool) + for _, b := range localBranches { + branchMap[b] = true + } + for _, b := range remoteBranches { + branchMap[b] = true + } + + branches := []string{} + for b := range branchMap { + branches = append(branches, b) + } + info.Branches = branches + + // Check for uncommitted changes + hasChanges, err := HasUncommittedChanges(dir) + if err == nil { + info.HasUncommitted = hasChanges + } + + return info, nil +} diff --git a/internal/services/manager.go b/internal/services/manager.go index 5f72a38..57ab3e7 100644 --- a/internal/services/manager.go +++ b/internal/services/manager.go @@ -1000,3 +1000,123 @@ func (sm *Manager) HasMavenWrapper(serviceDir string) bool { func (sm *Manager) HasGradleWrapper(serviceDir string) bool { return HasGradleWrapper(serviceDir) } + +// Git-related methods + +// GetGitInfo returns git information for a service +func (sm *Manager) GetGitInfo(serviceUUID string) (*GitInfo, error) { + sm.mutex.RLock() + service, exists := sm.services[serviceUUID] + sm.mutex.RUnlock() + + if !exists { + return nil, fmt.Errorf("service UUID %s not found", serviceUUID) + } + + return GetGitInfo(service.Dir) +} + +// GetGitBranches returns all branches (local and remote) for a service +func (sm *Manager) GetGitBranches(serviceUUID string) ([]string, error) { + sm.mutex.RLock() + service, exists := sm.services[serviceUUID] + sm.mutex.RUnlock() + + if !exists { + return nil, fmt.Errorf("service UUID %s not found", serviceUUID) + } + + if !IsGitRepository(service.Dir) { + return nil, fmt.Errorf("service is not a git repository") + } + + // Get local branches + localBranches, err := GetBranches(service.Dir) + if err != nil { + return nil, err + } + + // Get remote branches + remoteBranches, err := GetRemoteBranches(service.Dir) + if err != nil { + // If remote fetch fails, just return local branches + return localBranches, nil + } + + // Combine and deduplicate + branchMap := make(map[string]bool) + for _, b := range localBranches { + branchMap[b] = true + } + for _, b := range remoteBranches { + branchMap[b] = true + } + + branches := []string{} + for b := range branchMap { + branches = append(branches, b) + } + + return branches, nil +} + +// SwitchGitBranch switches a service to a different git branch +func (sm *Manager) SwitchGitBranch(serviceUUID, branch string) error { + sm.mutex.RLock() + service, exists := sm.services[serviceUUID] + sm.mutex.RUnlock() + + if !exists { + return fmt.Errorf("service UUID %s not found", serviceUUID) + } + + // Check if service is running + if service.Status == "running" { + return fmt.Errorf("cannot switch branches while service is running. Please stop the service first") + } + + // Switch branch + if err := SwitchBranch(service.Dir, branch); err != nil { + return err + } + + // Update the service's git branch info + currentBranch, err := GetCurrentBranch(service.Dir) + if err == nil { + sm.mutex.Lock() + service.GitBranch = currentBranch + sm.mutex.Unlock() + + // Broadcast update + sm.broadcastUpdate(service) + } + + log.Printf("[INFO] Successfully switched service %s (UUID: %s) to branch %s", service.Name, serviceUUID, branch) + return nil +} + +// UpdateServiceGitBranch updates the git branch information for a service +func (sm *Manager) UpdateServiceGitBranch(serviceUUID string) error { + sm.mutex.RLock() + service, exists := sm.services[serviceUUID] + sm.mutex.RUnlock() + + if !exists { + return fmt.Errorf("service UUID %s not found", serviceUUID) + } + + if !IsGitRepository(service.Dir) { + return nil + } + + currentBranch, err := GetCurrentBranch(service.Dir) + if err != nil { + return err + } + + sm.mutex.Lock() + service.GitBranch = currentBranch + sm.mutex.Unlock() + + return nil +} diff --git a/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx new file mode 100644 index 0000000..ff8ccee --- /dev/null +++ b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx @@ -0,0 +1,191 @@ +import { useState, useEffect } from "react"; +import { GitBranch, Check, Loader2 } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Button } from "@/components/ui/button"; +import { useToast, toast } from "@/components/ui/toast"; + +interface GitBranchSwitcherProps { + serviceId: string; + serviceName: string; + currentBranch: string; + isServiceRunning: boolean; +} + +export function GitBranchSwitcher({ + serviceId, + serviceName, + currentBranch, + isServiceRunning, +}: GitBranchSwitcherProps) { + const [branches, setBranches] = useState([]); + const [selectedBranch, setSelectedBranch] = useState(currentBranch); + const [isLoading, setIsLoading] = useState(false); + const [isSwitching, setIsSwitching] = useState(false); + const { addToast } = useToast(); + + useEffect(() => { + setSelectedBranch(currentBranch); + }, [currentBranch]); + + useEffect(() => { + fetchBranches(); + }, [serviceId]); + + const fetchBranches = async () => { + try { + setIsLoading(true); + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + + const response = await fetch(`/api/services/${serviceId}/git/branches`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + if (response.status === 404 || response.status === 500) { + // Service is not a git repository, silently skip + return; + } + throw new Error(`Failed to fetch branches: ${response.statusText}`); + } + + const data = await response.json(); + setBranches(data.branches || []); + } catch (error) { + // Silently ignore errors for non-git services + console.log(`Service ${serviceName} is not a git repository`); + } finally { + setIsLoading(false); + } + }; + + const handleSwitchBranch = async () => { + if (selectedBranch === currentBranch) { + return; + } + + if (isServiceRunning) { + addToast( + toast.error( + "Cannot switch branches", + `Please stop ${serviceName} before switching branches`, + ), + ); + return; + } + + try { + setIsSwitching(true); + const token = localStorage.getItem("authToken"); + if (!token) { + throw new Error("No authentication token"); + } + + const response = await fetch(`/api/services/${serviceId}/git/switch`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ branch: selectedBranch }), + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || `Failed to switch branch`); + } + + await response.json(); + addToast( + toast.success( + "Branch switched", + `${serviceName} is now on branch '${selectedBranch}'`, + ), + ); + + // Refresh the page to update the service info + window.location.reload(); + } catch (error) { + console.error("Failed to switch branch:", error); + addToast( + toast.error( + "Failed to switch branch", + error instanceof Error ? error.message : "Unknown error", + ), + ); + setSelectedBranch(currentBranch); // Reset to current branch + } finally { + setIsSwitching(false); + } + }; + + // Don't render if no branches or not a git repo + if (branches.length === 0 && !isLoading) { + return null; + } + + return ( +
+ + + {selectedBranch !== currentBranch && ( + + )} +
+ ); +} diff --git a/web/src/components/ServiceCard/ServiceCard.tsx b/web/src/components/ServiceCard/ServiceCard.tsx index 3530cda..16ddabe 100644 --- a/web/src/components/ServiceCard/ServiceCard.tsx +++ b/web/src/components/ServiceCard/ServiceCard.tsx @@ -26,6 +26,7 @@ import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Service } from "@/types"; import { useState, useRef, useEffect } from "react"; +import { GitBranchSwitcher } from "@/components/GitBranchSwitcher/GitBranchSwitcher"; interface ServiceCardProps { service: Service; @@ -232,6 +233,18 @@ export function ServiceCard({ {service.description}

)} + + {/* Git Branch Switcher */} + {service.gitBranch && ( +
+ +
+ )} diff --git a/web/src/hooks/useServiceManagement.ts b/web/src/hooks/useServiceManagement.ts index fc584f6..002da83 100644 --- a/web/src/hooks/useServiceManagement.ts +++ b/web/src/hooks/useServiceManagement.ts @@ -42,6 +42,7 @@ export function useServiceManagement(onServiceUpdated: () => void) { isEnabled: true, buildSystem: "auto", verboseLogging: false, + gitBranch: "", envVars: {}, logs: [], uptime: "", diff --git a/web/src/types.ts b/web/src/types.ts index a4d5d49..5de281a 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -57,6 +57,7 @@ export interface Service { isEnabled: boolean; buildSystem: string; // "maven", "gradle", or "auto" verboseLogging: boolean; // Enable verbose/debug logging for build tools + gitBranch: string; // Current git branch (if service is a git repo) envVars: { [key: string]: EnvVar }; logs: LogEntry[]; // Resource monitoring fields From 0887441aa6d5595d2c09b605375ad8a83274b28e Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Thu, 4 Dec 2025 11:13:38 +0300 Subject: [PATCH 5/6] feat: replace git branch dropdown with searchable dialog Improve git branch switching UX by replacing the fixed-width dropdown with a full modal dialog that includes search functionality. This provides better handling of long branch names and makes it easier to find specific branches. Changes: - Replace Select dropdown with Modal dialog component - Add search input with real-time filtering of branches - Display full branch names without wrapping using break-all - Show filtered results count during search - Improve visual hierarchy with larger clickable areas - Add "Click to switch" hint for non-current branches - Better warning display when service is running - Fix backend git operations to use full paths via getServiceProjectsDirectory() The dialog provides a much better user experience for repositories with many branches or long branch names. --- internal/services/manager.go | 47 +++- .../GitBranchSwitcher/GitBranchSwitcher.tsx | 215 +++++++++++++----- 2 files changed, 192 insertions(+), 70 deletions(-) diff --git a/internal/services/manager.go b/internal/services/manager.go index 57ab3e7..d897d69 100644 --- a/internal/services/manager.go +++ b/internal/services/manager.go @@ -1013,7 +1013,14 @@ func (sm *Manager) GetGitInfo(serviceUUID string) (*GitInfo, error) { return nil, fmt.Errorf("service UUID %s not found", serviceUUID) } - return GetGitInfo(service.Dir) + // Get the full service directory path + projectsDir := sm.getServiceProjectsDirectory(serviceUUID) + if projectsDir == "" { + projectsDir = sm.config.ProjectsDir + } + + fullPath := filepath.Join(projectsDir, service.Dir) + return GetGitInfo(fullPath) } // GetGitBranches returns all branches (local and remote) for a service @@ -1026,18 +1033,26 @@ func (sm *Manager) GetGitBranches(serviceUUID string) ([]string, error) { return nil, fmt.Errorf("service UUID %s not found", serviceUUID) } - if !IsGitRepository(service.Dir) { + // Get the full service directory path + projectsDir := sm.getServiceProjectsDirectory(serviceUUID) + if projectsDir == "" { + projectsDir = sm.config.ProjectsDir + } + + fullPath := filepath.Join(projectsDir, service.Dir) + + if !IsGitRepository(fullPath) { return nil, fmt.Errorf("service is not a git repository") } // Get local branches - localBranches, err := GetBranches(service.Dir) + localBranches, err := GetBranches(fullPath) if err != nil { return nil, err } // Get remote branches - remoteBranches, err := GetRemoteBranches(service.Dir) + remoteBranches, err := GetRemoteBranches(fullPath) if err != nil { // If remote fetch fails, just return local branches return localBranches, nil @@ -1075,13 +1090,21 @@ func (sm *Manager) SwitchGitBranch(serviceUUID, branch string) error { return fmt.Errorf("cannot switch branches while service is running. Please stop the service first") } + // Get the full service directory path + projectsDir := sm.getServiceProjectsDirectory(serviceUUID) + if projectsDir == "" { + projectsDir = sm.config.ProjectsDir + } + + fullPath := filepath.Join(projectsDir, service.Dir) + // Switch branch - if err := SwitchBranch(service.Dir, branch); err != nil { + if err := SwitchBranch(fullPath, branch); err != nil { return err } // Update the service's git branch info - currentBranch, err := GetCurrentBranch(service.Dir) + currentBranch, err := GetCurrentBranch(fullPath) if err == nil { sm.mutex.Lock() service.GitBranch = currentBranch @@ -1105,11 +1128,19 @@ func (sm *Manager) UpdateServiceGitBranch(serviceUUID string) error { return fmt.Errorf("service UUID %s not found", serviceUUID) } - if !IsGitRepository(service.Dir) { + // Get the full service directory path + projectsDir := sm.getServiceProjectsDirectory(serviceUUID) + if projectsDir == "" { + projectsDir = sm.config.ProjectsDir + } + + fullPath := filepath.Join(projectsDir, service.Dir) + + if !IsGitRepository(fullPath) { return nil } - currentBranch, err := GetCurrentBranch(service.Dir) + currentBranch, err := GetCurrentBranch(fullPath) if err != nil { return err } diff --git a/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx index ff8ccee..6df0d72 100644 --- a/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx +++ b/web/src/components/GitBranchSwitcher/GitBranchSwitcher.tsx @@ -1,13 +1,8 @@ -import { useState, useEffect } from "react"; -import { GitBranch, Check, Loader2 } from "lucide-react"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; +import { useState, useEffect, useMemo } from "react"; +import { GitBranch, Check, Loader2, Search } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Modal } from "@/components/ui/Modal"; import { useToast, toast } from "@/components/ui/toast"; interface GitBranchSwitcherProps { @@ -24,15 +19,12 @@ export function GitBranchSwitcher({ isServiceRunning, }: GitBranchSwitcherProps) { const [branches, setBranches] = useState([]); - const [selectedBranch, setSelectedBranch] = useState(currentBranch); const [isLoading, setIsLoading] = useState(false); const [isSwitching, setIsSwitching] = useState(false); + const [isModalOpen, setIsModalOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(""); const { addToast } = useToast(); - useEffect(() => { - setSelectedBranch(currentBranch); - }, [currentBranch]); - useEffect(() => { fetchBranches(); }, [serviceId]); @@ -70,8 +62,9 @@ export function GitBranchSwitcher({ } }; - const handleSwitchBranch = async () => { - if (selectedBranch === currentBranch) { + const handleSwitchBranch = async (branch: string) => { + if (branch === currentBranch) { + setIsModalOpen(false); return; } @@ -98,7 +91,7 @@ export function GitBranchSwitcher({ "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify({ branch: selectedBranch }), + body: JSON.stringify({ branch }), }); if (!response.ok) { @@ -110,10 +103,11 @@ export function GitBranchSwitcher({ addToast( toast.success( "Branch switched", - `${serviceName} is now on branch '${selectedBranch}'`, + `${serviceName} is now on branch '${branch}'`, ), ); + setIsModalOpen(false); // Refresh the page to update the service info window.location.reload(); } catch (error) { @@ -124,68 +118,165 @@ export function GitBranchSwitcher({ error instanceof Error ? error.message : "Unknown error", ), ); - setSelectedBranch(currentBranch); // Reset to current branch } finally { setIsSwitching(false); } }; + // Filter branches based on search query + const filteredBranches = useMemo(() => { + if (!searchQuery.trim()) { + return branches; + } + const query = searchQuery.toLowerCase(); + return branches.filter((branch) => + branch.toLowerCase().includes(query) + ); + }, [branches, searchQuery]); + // Don't render if no branches or not a git repo if (branches.length === 0 && !isLoading) { return null; } return ( -
- - setSearchQuery(e.target.value)} + className="pl-10" + autoFocus + /> +
+ {searchQuery && ( +
+ Found {filteredBranches.length} of {branches.length} branches +
+ )} + + + {/* Branch List */} +
+ {filteredBranches.length === 0 ? ( +
+ {searchQuery ? ( + <> + No branches found matching "{searchQuery}" + + ) : ( + "No branches available" )} - {branch}
- - ))} - - - {selectedBranch !== currentBranch && ( - + ); + })} +
+ )} + + + {/* Footer with actions */} + {isServiceRunning && ( +
+
+
+ ⚠️ +
+
+ Service is running +
+ Please stop {serviceName} before switching branches +
+
+
)} - - )} - + + + ); } From a3a3a1092c9720b402dd067b954161979cd4c08a Mon Sep 17 00:00:00 2001 From: Zachariah Ngonyani Date: Thu, 4 Dec 2025 11:27:07 +0300 Subject: [PATCH 6/6] fix: resolve ARM64 Docker build QEMU emulation issue Fix Alpine Linux apk trigger failures during ARM64 builds by using --no-scripts flag. This avoids the "execve: No such file or directory" error that occurs when running apk triggers under QEMU emulation. Changes: - Add --no-scripts flag to apk command - Manually run update-ca-certificates with error suppression - Ensures successful multi-arch builds (amd64 and arm64) --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 986aa3d..258afd1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -26,7 +26,9 @@ RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o vertex FROM alpine:latest # Install runtime dependencies -RUN apk --no-cache add ca-certificates sqlite +# Use --no-scripts to avoid trigger issues in QEMU ARM64 builds +RUN apk --no-cache --no-scripts add ca-certificates sqlite && \ + update-ca-certificates 2>/dev/null || true WORKDIR /app