name: CI/CD Pipeline on: push: branches: [master] pull_request: branches: [master] workflow_dispatch: 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 → deploy → publish # PRs stop after build. Master continues to deploy + publish. 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 Docker Image # ─────────────────────────────────────────────────────────────── build: name: "🏗️ Build Docker Image" needs: [e2e-test] runs-on: [self-hosted, meshcore-vm] steps: - name: Checkout code uses: actions/checkout@v5 - name: Set up Node.js 22 uses: actions/setup-node@v5 with: node-version: '22' - name: Free disk space run: | docker system prune -af 2>/dev/null || true docker builder prune -af 2>/dev/null || true df -h / - name: Build Go Docker image run: | echo "${GITHUB_SHA::7}" > .git-commit APP_VERSION=$(node -p "require('./package.json').version") \ GIT_COMMIT="${GITHUB_SHA::7}" \ APP_VERSION=$(grep -oP 'APP_VERSION:-\K[^}]+' docker-compose.yml | head -1 || echo "3.0.0") GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ') export APP_VERSION GIT_COMMIT BUILD_TIME docker compose -f "$STAGING_COMPOSE_FILE" -p corescope-staging build "$STAGING_SERVICE" echo "Built Go staging image ✅" # ─────────────────────────────────────────────────────────────── # 4. Deploy Staging (master only) # ─────────────────────────────────────────────────────────────── deploy: name: "🚀 Deploy Staging" if: github.event_name == 'push' needs: [build] runs-on: [self-hosted, meshcore-vm] steps: - name: Checkout code uses: actions/checkout@v5 - 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