name: CI/CD Pipeline on: push: branches: [master] tags: ['v*'] pull_request: branches: [master] workflow_dispatch: permissions: contents: read packages: write concurrency: group: ci-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true STAGING_COMPOSE_FILE: docker-compose.staging.yml STAGING_SERVICE: staging-go STAGING_CONTAINER: corescope-staging-go # Pipeline (sequential, fail-fast): # go-test → e2e-test → build-and-publish → deploy → publish-badges # PRs stop after build-and-publish (no GHCR push). Master continues to deploy + badges. jobs: # ─────────────────────────────────────────────────────────────── # 1. Go Build & Test # ─────────────────────────────────────────────────────────────── go-test: name: "✅ Go Build & Test" runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 - name: Clean Go module cache run: rm -rf ~/go/pkg/mod 2>/dev/null || true - name: Set up Go 1.22 uses: actions/setup-go@v6 with: go-version: '1.22' cache-dependency-path: | cmd/server/go.sum cmd/ingestor/go.sum - name: Build and test Go server (with coverage) run: | set -e -o pipefail cd cmd/server go build . go test -coverprofile=server-coverage.out ./... 2>&1 | tee server-test.log echo "--- Go Server Coverage ---" go tool cover -func=server-coverage.out | tail -1 - name: Build and test Go ingestor (with coverage) run: | set -e -o pipefail cd cmd/ingestor go build . go test -coverprofile=ingestor-coverage.out ./... 2>&1 | tee ingestor-test.log echo "--- Go Ingestor Coverage ---" go tool cover -func=ingestor-coverage.out | tail -1 - name: Verify proto syntax run: | set -e sudo apt-get update -qq sudo apt-get install -y protobuf-compiler for proto in proto/*.proto; do echo " ✓ $(basename "$proto")" protoc --proto_path=proto --descriptor_set_out=/dev/null "$proto" done echo "✅ All .proto files are syntactically valid" - name: Generate Go coverage badges if: success() run: | mkdir -p .badges SERVER_COV="0" if [ -f cmd/server/server-coverage.out ]; then SERVER_COV=$(cd cmd/server && go tool cover -func=server-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)') fi SERVER_COLOR="red" if [ "$(echo "$SERVER_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="green" elif [ "$(echo "$SERVER_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then SERVER_COLOR="yellow"; fi echo "{\"schemaVersion\":1,\"label\":\"go server coverage\",\"message\":\"${SERVER_COV}%\",\"color\":\"${SERVER_COLOR}\"}" > .badges/go-server-coverage.json INGESTOR_COV="0" if [ -f cmd/ingestor/ingestor-coverage.out ]; then INGESTOR_COV=$(cd cmd/ingestor && go tool cover -func=ingestor-coverage.out | tail -1 | grep -oP '[\d.]+(?=%)') fi INGESTOR_COLOR="red" if [ "$(echo "$INGESTOR_COV >= 80" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="green" elif [ "$(echo "$INGESTOR_COV >= 60" | bc -l 2>/dev/null)" = "1" ]; then INGESTOR_COLOR="yellow"; fi echo "{\"schemaVersion\":1,\"label\":\"go ingestor coverage\",\"message\":\"${INGESTOR_COV}%\",\"color\":\"${INGESTOR_COLOR}\"}" > .badges/go-ingestor-coverage.json echo "## Go Coverage" >> $GITHUB_STEP_SUMMARY echo "| Module | Coverage |" >> $GITHUB_STEP_SUMMARY echo "|--------|----------|" >> $GITHUB_STEP_SUMMARY echo "| Server | ${SERVER_COV}% |" >> $GITHUB_STEP_SUMMARY echo "| Ingestor | ${INGESTOR_COV}% |" >> $GITHUB_STEP_SUMMARY - name: Upload Go coverage badges if: success() uses: actions/upload-artifact@v6 with: name: go-badges path: .badges/go-*.json retention-days: 1 if-no-files-found: ignore include-hidden-files: true # ─────────────────────────────────────────────────────────────── # 2. Playwright E2E Tests (against Go server with fixture DB) # ─────────────────────────────────────────────────────────────── e2e-test: name: "🎭 Playwright E2E Tests" needs: [go-test] runs-on: [self-hosted, Linux] defaults: run: shell: bash steps: - name: Checkout code uses: actions/checkout@v5 with: fetch-depth: 0 - name: Free disk space run: | # Prune old runner diagnostic logs (can accumulate 50MB+) find ~/actions-runner/_diag/ -name '*.log' -mtime +3 -delete 2>/dev/null || true # Show available disk space df -h / | tail -1 - name: Set up Node.js 22 uses: actions/setup-node@v5 with: node-version: '22' - name: Clean Go module cache run: rm -rf ~/go/pkg/mod 2>/dev/null || true - name: Set up Go 1.22 uses: actions/setup-go@v6 with: go-version: '1.22' cache-dependency-path: cmd/server/go.sum - name: Build Go server run: | cd cmd/server go build -o ../../corescope-server . echo "Go server built successfully" - name: Install npm dependencies run: npm ci --production=false - name: Install Playwright browser run: | npx playwright install chromium 2>/dev/null || true npx playwright install-deps chromium 2>/dev/null || true - name: Instrument frontend JS for coverage run: sh scripts/instrument-frontend.sh - name: Start Go server with fixture DB run: | fuser -k 13581/tcp 2>/dev/null || true sleep 1 ./corescope-server -port 13581 -db test-fixtures/e2e-fixture.db -public public-instrumented & echo $! > .server.pid for i in $(seq 1 30); do if curl -sf http://localhost:13581/api/stats > /dev/null 2>&1; then echo "Server ready after ${i}s" break fi if [ "$i" -eq 30 ]; then echo "Server failed to start within 30s" exit 1 fi sleep 1 done - name: Run Playwright E2E tests (fail-fast) run: | BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt - name: Collect frontend coverage (parallel) if: success() && github.event_name == 'push' run: | BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt || true - name: Generate frontend coverage badges if: success() run: | E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1 || echo "0") mkdir -p .badges if [ -f .nyc_output/frontend-coverage.json ] || [ -f .nyc_output/e2e-coverage.json ]; then npx nyc report --reporter=text-summary --reporter=text 2>&1 | tee fe-report.txt FE_COVERAGE=$(grep 'Statements' fe-report.txt | head -1 | grep -oP '[\d.]+(?=%)' || echo "0") FE_COVERAGE=${FE_COVERAGE:-0} FE_COLOR="red" [ "$(echo "$FE_COVERAGE > 50" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="yellow" [ "$(echo "$FE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && FE_COLOR="brightgreen" echo "{\"schemaVersion\":1,\"label\":\"frontend coverage\",\"message\":\"${FE_COVERAGE}%\",\"color\":\"${FE_COLOR}\"}" > .badges/frontend-coverage.json echo "## Frontend: ${FE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY fi echo "{\"schemaVersion\":1,\"label\":\"e2e tests\",\"message\":\"${E2E_PASS:-0} passed\",\"color\":\"brightgreen\"}" > .badges/e2e-tests.json - name: Stop test server if: always() run: | if [ -f .server.pid ]; then kill $(cat .server.pid) 2>/dev/null || true rm -f .server.pid fi - name: Upload E2E badges if: success() uses: actions/upload-artifact@v6 with: name: e2e-badges path: .badges/ retention-days: 1 if-no-files-found: ignore include-hidden-files: true # ─────────────────────────────────────────────────────────────── # 3. Build & Publish Docker Image # ─────────────────────────────────────────────────────────────── build-and-publish: name: "🏗️ Build & Publish Docker Image" needs: [e2e-test] runs-on: [self-hosted, meshcore-runner-2] steps: - name: Checkout code uses: actions/checkout@v5 - name: Free disk space run: | docker system prune -af 2>/dev/null || true docker builder prune -af 2>/dev/null || true df -h / - name: Compute build metadata id: meta run: | BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') GIT_COMMIT="${GITHUB_SHA::7}" if [[ "$GITHUB_REF" == refs/tags/v* ]]; then APP_VERSION="${GITHUB_REF#refs/tags/}" else APP_VERSION="edge" fi echo "build_time=$BUILD_TIME" >> "$GITHUB_OUTPUT" echo "git_commit=$GIT_COMMIT" >> "$GITHUB_OUTPUT" echo "app_version=$APP_VERSION" >> "$GITHUB_OUTPUT" echo "Build: version=$APP_VERSION commit=$GIT_COMMIT time=$BUILD_TIME" - name: Build Go Docker image (local staging) run: | GIT_COMMIT="${{ steps.meta.outputs.git_commit }}" \ APP_VERSION="${{ steps.meta.outputs.app_version }}" \ BUILD_TIME="${{ steps.meta.outputs.build_time }}" \ docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging build "$STAGING_SERVICE" echo "Built Go staging image ✅" - name: Set up Docker Buildx if: github.event_name == 'push' uses: docker/setup-buildx-action@v3 - name: Log in to GHCR if: github.event_name == 'push' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Extract Docker metadata if: github.event_name == 'push' id: docker-meta uses: docker/metadata-action@v5 with: images: ghcr.io/kpa-clawbot/corescope tags: | type=semver,pattern=v{{version}} type=semver,pattern=v{{major}}.{{minor}} type=semver,pattern=v{{major}} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=edge,branch=master - name: Build and push to GHCR if: github.event_name == 'push' uses: docker/build-push-action@v6 with: context: . push: true platforms: linux/amd64 tags: ${{ steps.docker-meta.outputs.tags }} labels: ${{ steps.docker-meta.outputs.labels }} build-args: | APP_VERSION=${{ steps.meta.outputs.app_version }} GIT_COMMIT=${{ steps.meta.outputs.git_commit }} BUILD_TIME=${{ steps.meta.outputs.build_time }} cache-from: type=gha cache-to: type=gha,mode=max # ─────────────────────────────────────────────────────────────── # 4. Deploy Staging (master only) # ─────────────────────────────────────────────────────────────── deploy: name: "🚀 Deploy Staging" if: github.event_name == 'push' needs: [build-and-publish] runs-on: [self-hosted, meshcore-runner-2] steps: - name: Checkout code uses: actions/checkout@v5 - name: Pull latest image from GHCR run: | # Try to pull the edge image from GHCR and tag for docker-compose compatibility if docker pull ghcr.io/kpa-clawbot/corescope:edge; then docker tag ghcr.io/kpa-clawbot/corescope:edge corescope-go:latest echo "Pulled and tagged GHCR edge image ✅" else echo "⚠️ GHCR pull failed — falling back to locally built image" fi - name: Deploy staging run: | # Stop old container and release memory docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging down --timeout 30 2>/dev/null || true # Wait for container to be fully gone and OS to reclaim memory (3GB limit) for i in $(seq 1 15); do if ! docker ps -a --format '{{.Names}}' | grep -q 'corescope-staging-go'; then break fi sleep 1 done sleep 5 # extra pause for OS memory reclaim # Ensure staging data dir exists (config.json lives here, no separate file mount) STAGING_DATA="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}" mkdir -p "$STAGING_DATA" # If no config exists, copy the example (CI doesn't have a real prod config) if [ ! -f "$STAGING_DATA/config.json" ]; then echo "Staging config missing — copying config.example.json" cp config.example.json "$STAGING_DATA/config.json" 2>/dev/null || true fi docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging up -d staging-go - name: Healthcheck staging container run: | for i in $(seq 1 120); do HEALTH=$(docker inspect corescope-staging-go --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting") if [ "$HEALTH" = "healthy" ]; then echo "Staging healthy after ${i}s" break fi if [ "$i" -eq 120 ]; then echo "Staging failed health check after 120s" docker logs corescope-staging-go --tail 50 exit 1 fi sleep 1 done - name: Smoke test staging API run: | if curl -sf http://localhost:82/api/stats | grep -q engine; then echo "Staging verified — engine field present ✅" else echo "Staging /api/stats did not return engine field" exit 1 fi - name: Clean up old Docker images if: always() run: | # Remove dangling images and images older than 24h (keeps current build) echo "--- Docker disk usage before cleanup ---" docker system df docker image prune -af --filter "until=24h" 2>/dev/null || true docker builder prune -f --keep-storage=1GB 2>/dev/null || true echo "--- Docker disk usage after cleanup ---" docker system df # ─────────────────────────────────────────────────────────────── # 5. Publish Badges & Summary (master only) # ─────────────────────────────────────────────────────────────── publish: name: "📝 Publish Badges & Summary" if: github.event_name == 'push' needs: [deploy] runs-on: [self-hosted, Linux] steps: - name: Checkout code uses: actions/checkout@v5 - name: Download Go coverage badges continue-on-error: true uses: actions/download-artifact@v6 with: name: go-badges path: .badges/ - name: Download E2E badges continue-on-error: true uses: actions/download-artifact@v6 with: name: e2e-badges path: .badges/ - name: Publish coverage badges to repo continue-on-error: true env: GH_TOKEN: ${{ secrets.BADGE_PUSH_TOKEN }} run: | # GITHUB_TOKEN cannot push to protected branches (required status checks). # Use admin PAT (BADGE_PUSH_TOKEN) via GitHub Contents API instead. for badge in .badges/*.json; do FILENAME=$(basename "$badge") FILEPATH=".badges/$FILENAME" CONTENT=$(base64 -w0 "$badge") CURRENT_SHA=$(gh api "repos/${{ github.repository }}/contents/$FILEPATH" --jq '.sha' 2>/dev/null || echo "") if [ -n "$CURRENT_SHA" ]; then gh api "repos/${{ github.repository }}/contents/$FILEPATH" \ -X PUT \ -f message="ci: update $FILENAME [skip ci]" \ -f content="$CONTENT" \ -f sha="$CURRENT_SHA" \ -f branch="master" \ --silent 2>&1 || echo "Failed to update $FILENAME" else gh api "repos/${{ github.repository }}/contents/$FILEPATH" \ -X PUT \ -f message="ci: update $FILENAME [skip ci]" \ -f content="$CONTENT" \ -f branch="master" \ --silent 2>&1 || echo "Failed to create $FILENAME" fi done echo "Badge publish complete" - name: Post deployment summary run: | echo "## Staging Deployed ✓" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Commit:** \`$(git rev-parse --short HEAD)\` — $(git log -1 --format=%s)" >> $GITHUB_STEP_SUMMARY