mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-03-30 14:45:52 +00:00
Problem 1 (Go staging timeout): Increased healthcheck from 60s to 120s to allow 50K+ packets to load into memory. Problem 2 (Node staging timeout): Added forced cleanup of stale containers, volumes, and ports before starting staging containers to prevent conflicts. Problem 3 (Proto validation WS timeout): Made WebSocket message capture non-blocking using timeout command. If no live packets are available, it now skips with a warning instead of failing the entire proto validation pipeline. Problem 4 (Playwright E2E failures): Added forced cleanup of stale server on port 13581 before starting test server, plus better diagnostics on failure. All health checks now include better logging (tail 50 instead of 30 lines) for debugging. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
660 lines
32 KiB
YAML
660 lines
32 KiB
YAML
name: Deploy
|
|
|
|
on:
|
|
push:
|
|
branches: [master]
|
|
paths-ignore:
|
|
- '**.md'
|
|
- 'LICENSE'
|
|
- '.gitignore'
|
|
- 'docs/**'
|
|
|
|
concurrency:
|
|
group: deploy
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
|
|
|
# Pipeline (TWO INDEPENDENT TRACKS):
|
|
# Track 1 (Node): node-test → build-node → deploy-node ──┐
|
|
# Track 2 (Go): go-test → build-go → deploy-go ──┼──→ publish
|
|
# └─ (both wait)
|
|
#
|
|
# Proto validation flow:
|
|
# 1. go-test job: verify .proto files compile (syntax check)
|
|
# 2. deploy-node job: capture fresh fixtures from prod, validate protos match actual API responses
|
|
|
|
jobs:
|
|
# ───────────────────────────────────────────────────────────────
|
|
# 1. Go Build & Test — compiles + tests Go modules, coverage badges
|
|
# ───────────────────────────────────────────────────────────────
|
|
go-test:
|
|
name: "✅ Go Build & Test"
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Go 1.22
|
|
uses: actions/setup-go@v5
|
|
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 (all .proto files compile)
|
|
run: |
|
|
set -e
|
|
echo "Installing protoc..."
|
|
sudo apt-get update -qq
|
|
sudo apt-get install -y protobuf-compiler
|
|
|
|
echo "Checking proto syntax..."
|
|
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: always()
|
|
run: |
|
|
mkdir -p .badges
|
|
|
|
# Parse server coverage
|
|
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
|
|
echo "Go server coverage: ${SERVER_COV}% (${SERVER_COLOR})"
|
|
|
|
# Parse ingestor coverage
|
|
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 ingestor coverage: ${INGESTOR_COV}% (${INGESTOR_COLOR})"
|
|
|
|
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: always()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: go-badges
|
|
path: .badges/go-*.json
|
|
retention-days: 1
|
|
if-no-files-found: ignore
|
|
|
|
# ───────────────────────────────────────────────────────────────
|
|
# 2. Node.js Tests — backend unit tests + Playwright E2E, coverage
|
|
# ───────────────────────────────────────────────────────────────
|
|
node-test:
|
|
name: "🧪 Node.js Tests"
|
|
runs-on: self-hosted
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 2
|
|
|
|
- name: Set up Node.js 22
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22'
|
|
|
|
- name: Install npm dependencies
|
|
run: npm ci --production=false
|
|
|
|
- name: Detect changed files
|
|
id: changes
|
|
run: |
|
|
BACKEND=$(git diff --name-only HEAD~1 | grep -cE '^(server|db|decoder|packet-store|server-helpers|iata-coords)\.js$' || true)
|
|
FRONTEND=$(git diff --name-only HEAD~1 | grep -cE '^public/' || true)
|
|
TESTS=$(git diff --name-only HEAD~1 | grep -cE '^test-|^tools/' || true)
|
|
CI=$(git diff --name-only HEAD~1 | grep -cE '\.github/|package\.json|test-all\.sh|scripts/' || true)
|
|
# If CI/test infra changed, run everything
|
|
if [ "$CI" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
|
|
# If test files changed, run everything
|
|
if [ "$TESTS" -gt 0 ]; then BACKEND=1; FRONTEND=1; fi
|
|
echo "backend=$([[ $BACKEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
|
|
echo "frontend=$([[ $FRONTEND -gt 0 ]] && echo true || echo false)" >> $GITHUB_OUTPUT
|
|
echo "Changes: backend=$BACKEND frontend=$FRONTEND tests=$TESTS ci=$CI"
|
|
|
|
- name: Run backend tests with coverage
|
|
if: steps.changes.outputs.backend == 'true'
|
|
run: |
|
|
npx c8 --reporter=text-summary --reporter=text sh test-all.sh 2>&1 | tee test-output.txt
|
|
|
|
TOTAL_PASS=$(grep -oP '\d+(?= passed)' test-output.txt | awk '{s+=$1} END {print s}')
|
|
TOTAL_FAIL=$(grep -oP '\d+(?= failed)' test-output.txt | awk '{s+=$1} END {print s}')
|
|
BE_COVERAGE=$(grep 'Statements' test-output.txt | tail -1 | grep -oP '[\d.]+(?=%)')
|
|
|
|
mkdir -p .badges
|
|
BE_COLOR="red"
|
|
[ "$(echo "$BE_COVERAGE > 60" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="yellow"
|
|
[ "$(echo "$BE_COVERAGE > 80" | bc -l 2>/dev/null)" = "1" ] && BE_COLOR="brightgreen"
|
|
echo "{\"schemaVersion\":1,\"label\":\"backend tests\",\"message\":\"${TOTAL_PASS} passed\",\"color\":\"brightgreen\"}" > .badges/backend-tests.json
|
|
echo "{\"schemaVersion\":1,\"label\":\"backend coverage\",\"message\":\"${BE_COVERAGE}%\",\"color\":\"${BE_COLOR}\"}" > .badges/backend-coverage.json
|
|
|
|
echo "## Backend: ${TOTAL_PASS} tests, ${BE_COVERAGE}% coverage" >> $GITHUB_STEP_SUMMARY
|
|
|
|
- name: Run backend tests (quick, no coverage)
|
|
if: steps.changes.outputs.backend == 'false'
|
|
run: npm run test:unit
|
|
|
|
- name: Install Playwright browser
|
|
if: steps.changes.outputs.frontend == 'true'
|
|
run: npx playwright install chromium --with-deps 2>/dev/null || true
|
|
|
|
- name: Instrument frontend JS for coverage
|
|
if: steps.changes.outputs.frontend == 'true'
|
|
run: sh scripts/instrument-frontend.sh
|
|
|
|
- name: Start instrumented test server on port 13581
|
|
if: steps.changes.outputs.frontend == 'true'
|
|
run: |
|
|
# Kill any stale server on 13581
|
|
fuser -k 13581/tcp 2>/dev/null || true
|
|
sleep 2
|
|
COVERAGE=1 PORT=13581 node server.js &
|
|
echo $! > .server.pid
|
|
echo "Server PID: $(cat .server.pid)"
|
|
# Health-check poll loop (up to 30s)
|
|
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"
|
|
echo "Last few lines from server logs:"
|
|
ps aux | grep "PORT=13581" || echo "No server process found"
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
- name: Run Playwright E2E tests
|
|
if: steps.changes.outputs.frontend == 'true'
|
|
run: BASE_URL=http://localhost:13581 node test-e2e-playwright.js 2>&1 | tee e2e-output.txt
|
|
|
|
- name: Collect frontend coverage report
|
|
if: always() && steps.changes.outputs.frontend == 'true'
|
|
run: |
|
|
BASE_URL=http://localhost:13581 node scripts/collect-frontend-coverage.js 2>&1 | tee fe-coverage-output.txt
|
|
|
|
E2E_PASS=$(grep -oP '[0-9]+(?=/)' e2e-output.txt | tail -1)
|
|
|
|
mkdir -p .badges
|
|
if [ -f .nyc_output/frontend-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\":\"frontend tests\",\"message\":\"${E2E_PASS:-0} E2E passed\",\"color\":\"brightgreen\"}" > .badges/frontend-tests.json
|
|
|
|
- name: Stop test server
|
|
if: always() && steps.changes.outputs.frontend == 'true'
|
|
run: |
|
|
if [ -f .server.pid ]; then
|
|
kill $(cat .server.pid) 2>/dev/null || true
|
|
rm -f .server.pid
|
|
echo "Server stopped"
|
|
fi
|
|
|
|
- name: Run frontend E2E (quick, no coverage)
|
|
if: steps.changes.outputs.frontend == 'false'
|
|
run: |
|
|
fuser -k 13581/tcp 2>/dev/null || true
|
|
PORT=13581 node server.js &
|
|
SERVER_PID=$!
|
|
sleep 5
|
|
BASE_URL=http://localhost:13581 node test-e2e-playwright.js || true
|
|
kill $SERVER_PID 2>/dev/null || true
|
|
|
|
- name: Upload Node.js test badges
|
|
if: always()
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: node-badges
|
|
path: .badges/
|
|
retention-days: 1
|
|
if-no-files-found: ignore
|
|
|
|
# ───────────────────────────────────────────────────────────────
|
|
# 3. Build Node Docker Image — Track 1
|
|
# ───────────────────────────────────────────────────────────────
|
|
build-node:
|
|
name: "🏗️ Build Node Docker Image"
|
|
needs: [node-test]
|
|
runs-on: self-hosted
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Node.js 22
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22'
|
|
|
|
- name: Validate JavaScript syntax
|
|
run: sh scripts/validate.sh
|
|
|
|
- name: Build Node.js Docker image
|
|
run: |
|
|
echo "${GITHUB_SHA::7}" > .git-commit
|
|
docker build -t meshcore-analyzer:latest .
|
|
echo "Built meshcore-analyzer:latest ($(git rev-parse --short HEAD))"
|
|
|
|
# ───────────────────────────────────────────────────────────────
|
|
# 4. Build Go Docker Image — Track 2
|
|
# ───────────────────────────────────────────────────────────────
|
|
build-go:
|
|
name: "🏗️ Build Go Docker Image"
|
|
needs: [go-test]
|
|
runs-on: self-hosted
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Set up Node.js 22
|
|
uses: actions/setup-node@v4
|
|
with:
|
|
node-version: '22'
|
|
|
|
- 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}" \
|
|
docker compose --profile staging-go build staging-go
|
|
echo "Built Go staging image"
|
|
|
|
# ───────────────────────────────────────────────────────────────
|
|
# 5. Deploy Node Staging — start on port 81, healthcheck, smoke test
|
|
# ───────────────────────────────────────────────────────────────
|
|
deploy-node:
|
|
name: "🚀 Deploy Node Staging"
|
|
needs: [build-node]
|
|
runs-on: self-hosted
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Prepare staging environment
|
|
run: |
|
|
set -e
|
|
|
|
# Source environment overrides from deploy dir or home
|
|
if [ -f /opt/meshcore-deploy/.env ]; then
|
|
set -a; source /opt/meshcore-deploy/.env; set +a
|
|
echo "Sourced /opt/meshcore-deploy/.env"
|
|
elif [ -f "$HOME/.env" ]; then
|
|
set -a; source "$HOME/.env"; set +a
|
|
echo "Sourced $HOME/.env"
|
|
else
|
|
echo "No .env found, using defaults"
|
|
fi
|
|
|
|
# Ensure data directories exist
|
|
STAGING_DIR="${STAGING_DATA_DIR:-$HOME/meshcore-staging-data}"
|
|
mkdir -p "${PROD_DATA_DIR:-$HOME/meshcore-data}" "$STAGING_DIR"
|
|
|
|
# Ensure staging has a Caddyfile (generate from template if missing)
|
|
if [ ! -f "$STAGING_DIR/Caddyfile" ]; then
|
|
cp docker/Caddyfile.staging "$STAGING_DIR/Caddyfile"
|
|
echo "Generated staging Caddyfile"
|
|
fi
|
|
|
|
# Ensure staging has a config.json (copy from prod if missing)
|
|
if [ ! -f "$STAGING_DIR/config.json" ]; then
|
|
PROD_DIR="${PROD_DATA_DIR:-$HOME/meshcore-data}"
|
|
if [ -f "$PROD_DIR/config.json" ]; then
|
|
cp "$PROD_DIR/config.json" "$STAGING_DIR/config.json"
|
|
elif [ -f /opt/meshcore-deploy/config.json ]; then
|
|
cp /opt/meshcore-deploy/config.json "$STAGING_DIR/config.json"
|
|
else
|
|
echo '{}' > "$STAGING_DIR/config.json"
|
|
echo "WARNING: No config.json found, created empty one"
|
|
fi
|
|
fi
|
|
|
|
# Copy compose file to deploy dir so manage.sh can use it
|
|
mkdir -p /opt/meshcore-deploy
|
|
cp docker-compose.yml /opt/meshcore-deploy/docker-compose.yml
|
|
|
|
- name: Start Node staging on port 81
|
|
run: |
|
|
# Force remove stale containers and volumes
|
|
docker rm -f meshcore-staging 2>/dev/null || true
|
|
docker volume prune -f 2>/dev/null || true
|
|
# Clean up stale ports
|
|
fuser -k 81/tcp 2>/dev/null || true
|
|
docker compose --profile staging up -d staging
|
|
|
|
- name: Healthcheck Node staging container
|
|
run: |
|
|
for i in $(seq 1 300); do
|
|
HEALTH=$(docker inspect meshcore-staging --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
if [ "$HEALTH" = "healthy" ]; then
|
|
echo "Node staging healthy after ${i}s"
|
|
break
|
|
fi
|
|
if [ "$i" -eq 300 ]; then
|
|
echo "Node staging failed health check after 300s"
|
|
docker logs meshcore-staging --tail 50
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
- name: Smoke test Node staging API
|
|
run: |
|
|
curl -f http://localhost:81/api/stats || exit 1
|
|
curl -f http://localhost:81/api/nodes || exit 1
|
|
echo "Node staging smoke tests passed ✅"
|
|
|
|
- name: 🔍 Validate API contract (protos vs prod fixtures)
|
|
run: |
|
|
set -e
|
|
echo "Refreshing Node fixtures from staging container..."
|
|
mkdir -p proto/testdata/node-fixtures
|
|
|
|
# ─── Simple endpoints (no parameters) ──────────────────────────
|
|
ENDPOINTS=(
|
|
"stats" "health" "perf" "nodes" "packets" "observers" "channels"
|
|
"analytics/rf" "analytics/topology" "analytics/channels"
|
|
"analytics/hash-sizes" "analytics/distance" "analytics/subpaths"
|
|
"config/theme" "config/regions" "config/client" "config/cache" "config/map"
|
|
"iata-coords"
|
|
)
|
|
|
|
for endpoint in "${ENDPOINTS[@]}"; do
|
|
fixture_name=$(echo "$endpoint" | tr '/' '-')
|
|
echo " Fetching $endpoint → ${fixture_name}.json"
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/$endpoint" \
|
|
> "proto/testdata/node-fixtures/${fixture_name}.json" 2>/dev/null || {
|
|
echo " ⚠ Failed to fetch $endpoint (container may not have data yet)"
|
|
}
|
|
done
|
|
|
|
# ─── Dynamic ID endpoints (require real data) ─────────────────
|
|
echo ""
|
|
echo "Fetching endpoints that require dynamic IDs..."
|
|
|
|
# Get a real pubkey from nodes
|
|
PUBKEY=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes?limit=1" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['nodes'][0]['public_key'] if d.get('nodes') and len(d['nodes']) > 0 else '')" 2>/dev/null || echo "")
|
|
|
|
if [ -n "$PUBKEY" ]; then
|
|
echo " Using pubkey: ${PUBKEY:0:16}..."
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/$PUBKEY" \
|
|
> "proto/testdata/node-fixtures/node-detail.json" 2>/dev/null && \
|
|
echo " ✓ node-detail.json"
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/$PUBKEY/health" \
|
|
> "proto/testdata/node-fixtures/node-health.json" 2>/dev/null && \
|
|
echo " ✓ node-health.json"
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/$PUBKEY/paths" \
|
|
> "proto/testdata/node-fixtures/node-paths.json" 2>/dev/null && \
|
|
echo " ✓ node-paths.json"
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/$PUBKEY/analytics" \
|
|
> "proto/testdata/node-fixtures/node-analytics.json" 2>/dev/null && \
|
|
echo " ✓ node-analytics.json"
|
|
else
|
|
echo " ⚠ No nodes available — skipping node detail endpoints"
|
|
fi
|
|
|
|
# Node search
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/search?q=repeater" \
|
|
> "proto/testdata/node-fixtures/node-search.json" 2>/dev/null && \
|
|
echo " ✓ node-search.json" || echo " ⚠ node-search failed"
|
|
|
|
# Bulk health
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/nodes/bulk-health" \
|
|
> "proto/testdata/node-fixtures/bulk-health.json" 2>/dev/null && \
|
|
echo " ✓ bulk-health.json" || echo " ⚠ bulk-health failed"
|
|
|
|
# Get a real hash from packets
|
|
HASH=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?limit=1" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['packets'][0]['hash'] if d.get('packets') and len(d['packets']) > 0 else '')" 2>/dev/null || echo "")
|
|
|
|
if [ -n "$HASH" ]; then
|
|
echo " Using hash: $HASH"
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/$HASH" \
|
|
> "proto/testdata/node-fixtures/packet-detail.json" 2>/dev/null && \
|
|
echo " ✓ packet-detail.json"
|
|
else
|
|
echo " ⚠ No packets available — skipping packet-detail"
|
|
fi
|
|
|
|
# ─── Per-type packet fixtures (one of each payload type) ──────
|
|
echo ""
|
|
echo "Fetching per-type packet fixtures..."
|
|
# payload_type: 0=REQ, 1=TXT_MSG, 4=ADVERT, 5=GRP_TXT
|
|
TYPES="0:req 1:txtmsg 4:advert 5:grptxt"
|
|
for entry in $TYPES; do
|
|
TYPE_NUM="${entry%%:*}"
|
|
TYPE_NAME="${entry##*:}"
|
|
TYPE_HASH=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?type=${TYPE_NUM}&limit=1" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); pkts=d.get('packets',[]); print(pkts[0]['hash'] if pkts else '')" 2>/dev/null || echo "")
|
|
if [ -n "$TYPE_HASH" ]; then
|
|
if [ "$TYPE_NAME" = "grptxt" ]; then
|
|
# Save first as decrypted (most common), then find an undecrypted one
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/$TYPE_HASH" \
|
|
> "proto/testdata/node-fixtures/packet-type-grptxt-decrypted.json" 2>/dev/null && \
|
|
echo " ✓ packet-type-grptxt-decrypted.json (hash: $TYPE_HASH)"
|
|
# Find a decryption_failed packet
|
|
UNDEC_HASH=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?type=5&limit=20" 2>/dev/null | python3 -c "import sys,json;d=json.load(sys.stdin);[print(p['hash']) or exit() for p in d.get('packets',[]) if 'decryption_failed' in p.get('decoded_json','') or 'no_key' in p.get('decoded_json','')]" 2>/dev/null || echo "")
|
|
if [ -n "$UNDEC_HASH" ]; then
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/$UNDEC_HASH" \
|
|
> "proto/testdata/node-fixtures/packet-type-grptxt-undecrypted.json" 2>/dev/null && \
|
|
echo " ✓ packet-type-grptxt-undecrypted.json (hash: $UNDEC_HASH)"
|
|
else
|
|
echo " ⚠ No undecrypted GRP_TXT found"
|
|
fi
|
|
else
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/$TYPE_HASH" \
|
|
> "proto/testdata/node-fixtures/packet-type-${TYPE_NAME}.json" 2>/dev/null && \
|
|
echo " ✓ packet-type-${TYPE_NAME}.json (hash: $TYPE_HASH)"
|
|
fi
|
|
else
|
|
echo " ⚠ No type=$TYPE_NUM ($TYPE_NAME) packets available"
|
|
fi
|
|
done
|
|
|
|
# Packet timestamps
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets/timestamps?since=2026-03-01T00:00:00Z" \
|
|
> "proto/testdata/node-fixtures/packet-timestamps.json" 2>/dev/null && \
|
|
echo " ✓ packet-timestamps.json" || echo " ⚠ packet-timestamps failed"
|
|
|
|
# Packets grouped and since
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?limit=5&groupByHash=true" \
|
|
> "proto/testdata/node-fixtures/packets-grouped.json" 2>/dev/null && \
|
|
echo " ✓ packets-grouped.json" || echo " ⚠ packets-grouped failed"
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/packets?limit=5&since=2026-03-01T00:00:00Z&groupByHash=true" \
|
|
> "proto/testdata/node-fixtures/packets-since.json" 2>/dev/null && \
|
|
echo " ✓ packets-since.json" || echo " ⚠ packets-since failed"
|
|
|
|
# Get a real observer ID
|
|
OBSID=$(docker exec meshcore-prod wget -qO- "http://localhost:3000/api/observers" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['id'] if d and len(d) > 0 else '')" 2>/dev/null || echo "")
|
|
|
|
if [ -n "$OBSID" ]; then
|
|
echo " Using observer ID: $OBSID"
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/observers/$OBSID" \
|
|
> "proto/testdata/node-fixtures/observer-detail.json" 2>/dev/null && \
|
|
echo " ✓ observer-detail.json"
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/observers/$OBSID/analytics" \
|
|
> "proto/testdata/node-fixtures/observer-analytics.json" 2>/dev/null && \
|
|
echo " ✓ observer-analytics.json"
|
|
else
|
|
echo " ⚠ No observers available — skipping observer detail endpoints"
|
|
fi
|
|
|
|
# Channel messages
|
|
docker exec meshcore-prod wget -qO- "http://localhost:3000/api/channels/public/messages?limit=5" \
|
|
> "proto/testdata/node-fixtures/channel-messages.json" 2>/dev/null && \
|
|
echo " ✓ channel-messages.json" || echo " ⚠ channel-messages failed"
|
|
|
|
# WebSocket message capture (capture one message if available)
|
|
# Non-blocking: if no live packets, skip with warning
|
|
echo " Capturing WebSocket message..."
|
|
if docker exec meshcore-prod timeout 5 node -e "
|
|
const WebSocket = require('ws');
|
|
const ws = new WebSocket('ws://localhost:3000');
|
|
ws.on('message', (data) => {
|
|
console.log(data);
|
|
ws.close();
|
|
process.exit(0);
|
|
});
|
|
ws.on('error', () => { process.exit(1); });
|
|
" > "proto/testdata/node-fixtures/websocket-message.json" 2>/dev/null; then
|
|
echo " ✓ websocket-message.json"
|
|
else
|
|
echo " ⚠ websocket-message failed (no live packets) — skipping"
|
|
fi
|
|
|
|
echo ""
|
|
echo "Running proto validator..."
|
|
python3 tools/validate-protos.py || {
|
|
echo "❌ Proto validation failed — API contract drift detected"
|
|
echo "This means a Node.js API response doesn't match the proto definition."
|
|
echo "Fix by updating the .proto files in proto/ to match the actual API responses."
|
|
exit 1
|
|
}
|
|
echo "✅ Proto validation passed — API contract is consistent"
|
|
|
|
# ───────────────────────────────────────────────────────────────
|
|
# 6. Deploy Go Staging — start on port 82, healthcheck, smoke test
|
|
# ───────────────────────────────────────────────────────────────
|
|
deploy-go:
|
|
name: "🚀 Deploy Go Staging"
|
|
needs: [build-go]
|
|
runs-on: self-hosted
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Start Go staging on port 82
|
|
run: |
|
|
# Force remove stale containers
|
|
docker rm -f meshcore-staging-go 2>/dev/null || true
|
|
# Clean up stale ports
|
|
fuser -k 82/tcp 2>/dev/null || true
|
|
docker compose --profile staging-go up -d staging-go
|
|
|
|
- name: Healthcheck Go staging container
|
|
run: |
|
|
for i in $(seq 1 120); do
|
|
HEALTH=$(docker inspect meshcore-staging-go --format '{{.State.Health.Status}}' 2>/dev/null || echo "starting")
|
|
if [ "$HEALTH" = "healthy" ]; then
|
|
echo "Go staging healthy after ${i}s"
|
|
break
|
|
fi
|
|
if [ "$i" -eq 120 ]; then
|
|
echo "Go staging failed health check after 120s"
|
|
docker logs meshcore-staging-go --tail 50
|
|
exit 1
|
|
fi
|
|
sleep 1
|
|
done
|
|
|
|
- name: Smoke test Go staging API
|
|
run: |
|
|
if curl -sf http://localhost:82/api/stats | grep -q engine; then
|
|
echo "Go staging verified — engine field present ✅"
|
|
else
|
|
echo "Go staging /api/stats did not return engine field"
|
|
exit 1
|
|
fi
|
|
|
|
# ───────────────────────────────────────────────────────────────
|
|
# 7. Publish Badges & Summary — waits for both tracks to complete
|
|
# ───────────────────────────────────────────────────────────────
|
|
publish:
|
|
name: "📝 Publish Badges & Summary"
|
|
needs: [deploy-node, deploy-go]
|
|
runs-on: self-hosted
|
|
steps:
|
|
- name: Checkout code
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Download Go coverage badges
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
name: go-badges
|
|
path: .badges/
|
|
|
|
- name: Download Node.js test badges
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@v4
|
|
with:
|
|
name: node-badges
|
|
path: .badges/
|
|
|
|
- name: Publish coverage badges to repo
|
|
continue-on-error: true
|
|
run: |
|
|
git config user.name "github-actions"
|
|
git config user.email "actions@github.com"
|
|
git remote set-url origin https://x-access-token:${{ github.token }}@github.com/${{ github.repository }}.git
|
|
git add .badges/ -f
|
|
git diff --cached --quiet || (git commit -m "ci: update test badges [skip ci]" && git push) || echo "Badge push failed"
|
|
|
|
- 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
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "**Node Staging:** http://<VM_HOST>:81" >> $GITHUB_STEP_SUMMARY
|
|
echo "**Go Staging:** http://<VM_HOST>:82" >> $GITHUB_STEP_SUMMARY
|
|
echo "" >> $GITHUB_STEP_SUMMARY
|
|
echo "To promote to production:" >> $GITHUB_STEP_SUMMARY
|
|
echo "\`\`\`bash" >> $GITHUB_STEP_SUMMARY
|
|
echo "ssh deploy@\$VM_HOST" >> $GITHUB_STEP_SUMMARY
|
|
echo "cd /opt/meshcore-deploy" >> $GITHUB_STEP_SUMMARY
|
|
echo "./manage.sh promote" >> $GITHUB_STEP_SUMMARY
|
|
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|