Files
meshcore-analyzer/.github/workflows/deploy.yml
Kpa-clawbot 11fee9526d Fix CI failures: increase Go health timeout to 120s, make WS capture non-blocking, clean stale ports/containers
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>
2026-03-28 00:57:18 -07:00

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