name: CASAN CI Gate on: push: branches: [main, develop, "feature/**"] pull_request: branches: [main] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true # All run steps execute inside (the app directory). # actions/* steps still reference $GITHUB_WORKSPACE root, so paths in `with:` blocks # must include prefix. jobs: # ────────────────────────────────────────────────────────────────────────── # Job 1 — Frontend unit tests (fast gate, ~1 min) # Runs on: web/Gitea VPS runner (161.33.139.73) — light job, no Docker build # ────────────────────────────────────────────────────────────────────────── frontend-tests: name: "Frontend Tests (H3 gate)" runs-on: ubuntu-latest # A stalled self-hosted runner must fail the gate visibly instead of keeping # every dependent security/deploy job queued indefinitely. timeout-minutes: 15 defaults: run: working-directory: . steps: - name: Checkout uses: actions/checkout@v3 - name: Setup Node.js 20 uses: actions/setup-node@v3 with: node-version: "20" - name: Install frontend dependencies run: npm ci -w @ainative-okr/frontend - name: Run Vitest (16 tests) run: npm test -w @ainative-okr/frontend # ────────────────────────────────────────────────────────────────────────── # Job 2 — CASAN Security Gate + Vault KMS signing (H4/H5/H2/H6/H7) # Runs on: web/Gitea VPS runner (161.33.139.73) — after frontend-tests to # prevent OOM (1 GB RAM VPS, sequential jobs via capacity: 1) # ────────────────────────────────────────────────────────────────────────── security-gate: name: "CASAN Security Gate + Vault KMS (H4/H5)" runs-on: ubuntu-latest needs: [frontend-tests] timeout-minutes: 30 defaults: run: working-directory: . env: VAULT_ADDR: "http://vault:8200" VAULT_TOKEN: ${{ secrets.VAULT_TOKEN }} steps: - name: Checkout uses: actions/checkout@v3 - name: Setup Node.js 20 uses: actions/setup-node@v3 with: node-version: "20" - name: Install test tools run: | apt-get update -qq 2>/dev/null && \ apt-get install -y -qq jq openssl python3 python-is-python3 uuid-runtime curl 2>/dev/null || true command -v python >/dev/null 2>&1 || \ ln -sf "$(command -v python3)" /usr/local/bin/python echo "python: $(python --version)" echo "jq: $(jq --version)" echo "openssl: $(openssl version)" - name: Vault KMS — enable transit + pre-create keys run: | if curl -sf "$VAULT_ADDR/v1/sys/health" >/dev/null 2>&1; then bash packages/casan-harness/scripts/bash/vault-kms.sh enable-transit bash packages/casan-harness/scripts/bash/vault-kms.sh ensure-key casan-policy-key bash packages/casan-harness/scripts/bash/vault-kms.sh ensure-key casan-audit-key echo "VAULT_KMS_READY" else echo "VAULT_KMS_SKIP (unreachable — will use local-file fallback)" fi - name: Run CASAN4 harness tests (35 tests) run: bash packages/casan-harness/tests/run-casan4-harness-tests.sh - name: Run Core Local Assurance Viewer contracts run: python3 packages/casan-harness/tests/local-viewer-tests.py - name: Prove visual reports from the clean Core release artifact run: bash packages/casan-harness/tests/core-local-viewer-artifact-tests.sh - name: Prove managed and vendored Core adoption run: bash packages/casan-devkit/tests/hybrid-install-tests.sh - name: Run adversarial harness tests (44 tests) run: bash packages/casan-harness/tests/adversarial-harness-tests.sh - name: Sign audit chain head via Vault KMS (H5) run: bash packages/casan-harness/scripts/bash/sign-audit-head.sh - name: Sign policy bundle via Vault KMS (H5) run: bash packages/casan-harness/scripts/bash/sign-policy-bundle.sh sign - name: Verify audit chain (anchor=signed expected in CI) run: bash packages/casan-harness/scripts/bash/verify-audit-chain.sh - name: Security gate — aggregate verdict (PASS=11 FAIL=0 SKIP=0) run: bash packages/casan-harness/scripts/bash/security-gate.sh - name: Upload test evidence if: always() uses: actions/upload-artifact@v3 with: name: casan-evidence-${{ github.run_number }} path: | docs/output/casan/evidence/harness-test-report.md docs/output/casan/evidence/ retention-days: 14 # ────────────────────────────────────────────────────────────────────────── # Job 3 — Build Docker images + deploy to /opt/webapps/okr (main only) # # Runs on: DEDICATED CI RUNNER (161.33.149.243, label: ci-runner) # Heavy Docker builds do NOT run on the web/Gitea VPS. # # Architecture: # CI runner (161.33.149.243) Web VPS (161.33.139.73) # ───────────────────────── ──────────────────────────────── # docker build okr-backend /opt/webapps/okr/ # docker build okr-frontend ──► docker-compose.yml # docker save | gzip | ssh ──► docker load # docker compose up -d # # DB: Oracle MySQL HeatWave (10.0.1.254:3306, webapp_db) # Credentials live on web VPS: /opt/webapps/webapp-mysql.env # # Required Gitea secrets: # DEPLOY_SSH_KEY — ed25519 private key for ubuntu@161.33.139.73 # JWT_SECRET — app JWT signing secret # # One-time CI runner setup: # ssh ubuntu@161.33.149.243 # RUNNER_TOKEN= bash scripts/setup-ci-runner.sh # ────────────────────────────────────────────────────────────────────────── deploy-okr: name: "Build & Deploy OKR → /opt/webapps/okr" runs-on: [ci-runner] needs: [frontend-tests, security-gate] if: github.ref == 'refs/heads/main' && github.event_name == 'push' timeout-minutes: 35 defaults: run: working-directory: . env: JWT_SECRET: ${{ secrets.JWT_SECRET }} WEB_VPS: "ubuntu@161.33.139.73" APP_DIR: "/opt/webapps/okr" steps: - name: Checkout uses: actions/checkout@v3 - name: Setup SSH for web VPS deploy env: DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} run: | mkdir -p ~/.ssh printf '%s\n' "$DEPLOY_SSH_KEY" | tr -d '\r' > ~/.ssh/deploy_key chmod 600 ~/.ssh/deploy_key ssh-keyscan -H 161.33.139.73 >> ~/.ssh/known_hosts 2>/dev/null echo "SSH ready: $(ssh-keygen -l -f ~/.ssh/deploy_key 2>&1)" - name: Ensure docker access on web VPS (self-heal group membership) run: | # The deploy user must be in the 'docker' group to reach # /var/run/docker.sock (root:docker, mode 660). Idempotent; self-heals # a rebuilt web VPS. Each subsequent step opens a fresh SSH session, so # the new group membership takes effect without a reboot. ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS ' if id -nG "$USER" | tr " " "\n" | grep -qx docker; then echo "docker group: already a member" else echo "docker group: adding $USER" sudo usermod -aG docker "$USER" fi # docker compose reads env_file client-side as this user, so the # root-managed DB secret must be group-readable by the deploy user. MYSQL_ENV=/opt/webapps/webapp-mysql.env if [ -f "$MYSQL_ENV" ] && ! [ -r "$MYSQL_ENV" ]; then echo "mysql env: granting docker-group read" sudo chgrp docker "$MYSQL_ENV" && sudo chmod 640 "$MYSQL_ENV" else echo "mysql env: readable (or absent)" fi ' # Prove a NEW session can reach the docker daemon before streaming ~GBs. ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \ 'docker version --format "server={{.Server.Version}}"' - name: Build backend image (node:20-slim + Prisma MySQL) run: docker build -t okr-backend:latest -f Dockerfile.backend . - name: Build frontend image (nginx + React SPA, VITE_API_BASE_URL=/api/v1) run: | docker build \ --build-arg VITE_API_BASE_URL=/api/v1 \ -t okr-frontend:latest \ -f Dockerfile.frontend \ . - name: Transfer images to web VPS run: | echo "Streaming images to web VPS (this may take ~1 min)..." docker save okr-backend:latest okr-frontend:latest | \ gzip | \ ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \ 'docker load' - name: Prepare app directory + docker-compose on web VPS run: | ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \ "mkdir -p $APP_DIR" scp -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no \ docker-compose.prod.yml $WEB_VPS:$APP_DIR/docker-compose.yml - name: Write app secrets on web VPS run: | ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \ "printf 'JWT_SECRET=%s\nFRONTEND_ORIGIN=http://161.33.139.73\n' '${JWT_SECRET}' \ > $APP_DIR/.env.app && chmod 600 $APP_DIR/.env.app" - name: Deploy containers on web VPS run: | ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS \ "cd $APP_DIR && docker compose up -d --remove-orphans --pull never" - name: Health check run: | echo "Waiting 30s for containers to initialise (includes Prisma migrate + seed)..." sleep 30 ssh -i ~/.ssh/deploy_key -o StrictHostKeyChecking=no $WEB_VPS ' echo "=== Containers ===" && docker compose -f /opt/webapps/okr/docker-compose.yml ps && echo "" && echo "=== HTTP check ===" && if curl -fsS -o /dev/null -w "HTTP %{http_code}" http://localhost; then echo "" echo "DEPLOY_OK http://161.33.139.73" else echo "DEPLOY_WARN — backend logs:" docker logs okr-backend --tail 30 || true docker logs okr-frontend --tail 20 || true fi '