Skip to content

Nightly Integration #106

Nightly Integration

Nightly Integration #106

name: Nightly Integration
on:
schedule:
- cron: '0 3 * * *' # 3 AM UTC daily
workflow_dispatch: # Manual trigger
permissions:
contents: read
jobs:
integration:
name: Full-Stack Integration
runs-on: ubuntu-latest
services:
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
steps:
# ── Checkout all servers ─────────────────────────────────────
- name: Checkout cycles-server
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: runcycles/cycles-server
path: cycles-server
- name: Checkout cycles-server-admin
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: runcycles/cycles-server-admin
path: cycles-server-admin
- name: Checkout cycles-server-events
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: runcycles/cycles-server-events
path: cycles-server-events
# ── Build all servers ──────────────────────────────────────────
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Build protocol server
working-directory: cycles-server
run: mvn -B package -DskipTests --file cycles-protocol-service/pom.xml
- name: Build admin server
working-directory: cycles-server-admin
run: mvn -B package -DskipTests --file cycles-admin-service/pom.xml
- name: Build event server
working-directory: cycles-server-events
run: mvn -B package -DskipTests
# ── Start all servers ──────────────────────────────────────────
- name: Start protocol server (7878)
working-directory: cycles-server
run: |
java -Dredis.host=localhost -Dredis.port=6379 -Dredis.password= \
-jar cycles-protocol-service/cycles-protocol-service-api/target/*.jar &
for i in $(seq 1 30); do
curl -sf http://localhost:7878/actuator/health && break
sleep 1
done
- name: Start admin server (7979)
working-directory: cycles-server-admin
run: |
java -Dredis.host=localhost -Dredis.port=6379 -Dredis.password= \
-Dadmin.api-key=test-admin-key -Dserver.port=7979 \
-jar cycles-admin-service/cycles-admin-service-api/target/*.jar &
for i in $(seq 1 30); do
curl -sf http://localhost:7979/actuator/health && break
sleep 1
done
- name: Start event server (7980)
working-directory: cycles-server-events
run: |
java -Dredis.host=localhost -Dredis.port=6379 -Dredis.password= \
-Dwebhook.secret.encryption-key= \
-jar target/*.jar &
for i in $(seq 1 30); do
curl -sf http://localhost:7980/actuator/health && break
sleep 1
done
# ── Provision test data via admin API ──────────────────────────
- name: Provision tenant, API key, and budget
run: |
ADMIN_URL=http://localhost:7979
ADMIN_KEY=test-admin-key
# Create tenant
curl -s -X POST $ADMIN_URL/v1/admin/tenants \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{"tenant_id":"integration-test","name":"Integration Test Tenant"}'
echo ""
echo "Tenant created"
# Create API key
KEY_RESPONSE=$(curl -s -X POST $ADMIN_URL/v1/admin/api-keys \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{"tenant_id":"integration-test","name":"integration-key","permissions":["admin:read","admin:write"]}')
echo "Key response: $KEY_RESPONSE"
API_KEY=$(echo "$KEY_RESPONSE" | jq -r '.key_secret')
if [ -z "$API_KEY" ] || [ "$API_KEY" = "null" ]; then
echo "Failed to extract API key"
exit 1
fi
echo "API key created: ${API_KEY:0:14}..."
# Export for subsequent steps
echo "CYCLES_API_KEY=$API_KEY" >> $GITHUB_ENV
# Create budget ($1.00 = 100M USD_MICROCENTS)
BUDGET_RESPONSE=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST $ADMIN_URL/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{"scope":"tenant:integration-test","unit":"USD_MICROCENTS","allocated":{"unit":"USD_MICROCENTS","amount":100000000}}')
echo "Budget response: $BUDGET_RESPONSE"
echo "$BUDGET_RESPONSE" | grep -q "HTTP_CODE:2" || { echo "USD_MICROCENTS budget creation failed"; exit 1; }
# Create TOKENS budget (for Rust client tests that use Amount::tokens())
TOKENS_BUDGET=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST $ADMIN_URL/v1/admin/budgets \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $API_KEY" \
-d '{"scope":"tenant:integration-test","unit":"TOKENS","allocated":{"unit":"TOKENS","amount":1000000}}')
echo "Tokens budget response: $TOKENS_BUDGET"
echo "$TOKENS_BUDGET" | grep -q "HTTP_CODE:2" || { echo "TOKENS budget creation failed"; exit 1; }
# ── Spring Boot demo app ────────────────────────────────────────
- name: Checkout Spring Boot starter
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: runcycles/cycles-spring-boot-starter
path: cycles-spring-boot-starter
- name: Build and start Spring Boot demo app (7955)
working-directory: cycles-spring-boot-starter
run: |
mvn -B package -DskipTests --file cycles-client-java-spring/pom.xml
mvn -B package -DskipTests --file cycles-demo-client-java-spring/pom.xml
java -Dcycles.base-url=http://localhost:7878 \
-Dcycles.api-key=$CYCLES_API_KEY \
-Dcycles.tenant=integration-test \
-Dcycles.workspace=default \
-Dcycles.app=demo \
-jar cycles-demo-client-java-spring/target/*.jar &
for i in $(seq 1 30); do
curl -sf http://localhost:7955/actuator/health && break
sleep 1
done
- name: Run Spring Boot integration tests
run: |
echo "=== @Cycles annotation (minimal) ==="
RESULT=$(curl -sf -X POST "http://localhost:7955/api/demo/annotation/minimal?input=hello")
echo "$RESULT" | jq .
echo "$RESULT" | jq -e '.result' || { echo "Minimal annotation failed"; exit 1; }
echo "=== Programmatic client (reserve-commit) ==="
RESULT=$(curl -sf -X POST "http://localhost:7955/api/demo/client/reserve-commit?amount=5000")
echo "$RESULT" | jq .
echo "$RESULT" | jq -e '.result.reservationId' || { echo "Reserve-commit failed"; exit 1; }
echo "=== Programmatic client (decide) ==="
RESULT=$(curl -sf -X POST "http://localhost:7955/api/demo/client/decide?amount=1000")
echo "$RESULT" | jq .
echo "$RESULT" | jq -e '.result.response.body.decision' || { echo "Decide failed"; exit 1; }
echo "=== Balance query ==="
RESULT=$(curl -sf "http://localhost:7955/api/demo/client/balances")
echo "$RESULT" | jq .
echo "=== LLM endpoint (annotation + metrics) ==="
RESULT=$(curl -sf -X POST "http://localhost:7955/api/llm/generate?prompt=hello&tokens=100")
echo "$RESULT"
[ -n "$RESULT" ] || { echo "LLM generate failed"; exit 1; }
echo "Spring Boot integration tests passed"
# ── Python integration tests ───────────────────────────────────
- name: Checkout Python client
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: runcycles/cycles-client-python
path: cycles-client-python
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.12'
- name: Run Python integration tests
working-directory: cycles-client-python
env:
CYCLES_BASE_URL: http://localhost:7878
CYCLES_TENANT: integration-test
run: |
pip install -e ".[dev]" requests
pytest tests/integration/ -v
# ── TypeScript integration tests ───────────────────────────────
- name: Checkout TypeScript client
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: runcycles/cycles-client-typescript
path: cycles-client-typescript
- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 22
- name: Run TypeScript integration tests
working-directory: cycles-client-typescript
env:
CYCLES_BASE_URL: http://localhost:7878
CYCLES_TENANT: integration-test
run: |
npm ci
npx vitest run tests/integration/
# ── Event server integration tests ─────────────────────────────
- name: Start webhook receiver
run: |
python3 -c '
from http.server import HTTPServer, BaseHTTPRequestHandler
import json, sys
class Handler(BaseHTTPRequestHandler):
received = []
def do_POST(self):
length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(length).decode()
Handler.received.append({
"body": body,
"signature": self.headers.get("X-Cycles-Signature", ""),
"event_type": self.headers.get("X-Cycles-Event-Type", ""),
})
self.send_response(200)
self.end_headers()
self.wfile.write(b"OK")
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(json.dumps(Handler.received).encode())
def log_message(self, *a): pass
HTTPServer(("", 9999), Handler).serve_forever()
' &
sleep 1
curl -sf http://localhost:9999/ > /dev/null
echo "Webhook receiver running on :9999"
- name: Run event server integration tests
run: |
ADMIN_URL=http://localhost:7979
ADMIN_KEY=test-admin-key
echo "=== Allow HTTP webhook URLs for CI ==="
curl -s -X PUT $ADMIN_URL/v1/admin/config/webhook-security \
-H "Content-Type: application/json" \
-H "X-Admin-API-Key: $ADMIN_KEY" \
-d '{"allow_http": true, "blocked_cidr_ranges": []}' | jq .
echo "=== Create webhook subscription ==="
WEBHOOK_RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST $ADMIN_URL/v1/webhooks \
-H "Content-Type: application/json" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY" \
-d '{
"name": "nightly-test-webhook",
"url": "http://localhost:9999/webhook",
"event_types": ["budget.created", "reservation.denied", "tenant.created"],
"signing_secret": "nightly-test-secret"
}')
echo "Webhook response: $WEBHOOK_RESP"
echo "$WEBHOOK_RESP" | grep -q "HTTP_CODE:2" || { echo "Webhook creation failed"; exit 1; }
SUB_ID=$(echo "$WEBHOOK_RESP" | head -1 | jq -r '.subscription.subscription_id')
echo "Subscription ID: $SUB_ID"
echo "=== Test webhook delivery (end-to-end via event server) ==="
TEST_RESP=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "$ADMIN_URL/v1/webhooks/$SUB_ID/test" \
-H "X-Cycles-API-Key: $CYCLES_API_KEY")
echo "Test response: $TEST_RESP"
echo "$TEST_RESP" | grep -q "HTTP_CODE:2" || { echo "Webhook test failed"; exit 1; }
echo "$TEST_RESP" | head -1 | jq -e '.success == true' || { echo "Webhook test reported failure"; exit 1; }
echo "=== Verify webhook receiver got the delivery ==="
sleep 3
RECEIVED=$(curl -s http://localhost:9999/)
echo "Received webhooks: $RECEIVED"
COUNT=$(echo "$RECEIVED" | jq 'length')
[ "$COUNT" -ge 1 ] || { echo "No webhooks received by receiver"; exit 1; }
echo "$RECEIVED" | jq -e '.[0].signature | startswith("sha256=")' || { echo "Missing HMAC signature"; exit 1; }
echo "=== Verify event server health and metrics ==="
curl -sf http://localhost:7980/actuator/health | jq .
curl -sf http://localhost:7980/actuator/prometheus | grep -c "cycles_" || true
echo "Event server integration tests passed"
# ── Rust integration tests ──────────────────────────────────────
- name: Checkout Rust client
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: runcycles/cycles-client-rust
path: cycles-client-rust
- name: Install Rust
uses: dtolnay/rust-toolchain@3c5f7ea28cd621ae0bf5283f0e981fb97b8a7af9 # stable @ 2026-05-02
with:
toolchain: stable
- name: Run Rust live integration tests
working-directory: cycles-client-rust
env:
CYCLES_BASE_URL: http://localhost:7878
CYCLES_TENANT: integration-test
run: cargo test --test live_server_test -- --ignored