# E2E Tests Workflow (Reorganized: Security Isolation + Parallel Sharding) # # Architecture: 15 Total Jobs # - 3 Security Enforcement Jobs (1 shard per browser, serial execution, 30min timeout) # - 12 Non-Security Jobs (4 shards per browser, parallel execution, 20min timeout) # # Problem Solved: Cross-shard contamination from security middleware state changes # Solution: Isolate security enforcement tests in dedicated jobs with Cerberus enabled, # run all other tests with Cerberus OFF to prevent ACL/rate limit interference # # See docs/implementation/E2E_TEST_REORGANIZATION_IMPLEMENTATION.md for full details name: 'E2E Tests' on: workflow_call: inputs: browser: description: 'Browser to test' required: false default: 'all' type: string test_category: description: 'Test category' required: false default: 'all' type: string image_ref: description: 'Image reference (digest) to test, e.g. docker.io/wikid82/charon@sha256:...' required: false type: string image_tag: description: 'Local image tag for compose usage (default: charon:e2e-test)' required: false type: string playwright_coverage: description: 'Enable Playwright coverage (V8)' required: false default: false type: boolean secrets: CHARON_EMERGENCY_TOKEN: required: false DOCKERHUB_USERNAME: required: false DOCKERHUB_TOKEN: required: false workflow_dispatch: inputs: browser: description: 'Browser to test' required: false default: 'all' type: choice options: - chromium - firefox - webkit - all test_category: description: 'Test category' required: false default: 'all' type: choice options: - all - security - non-security image_ref: description: 'Image reference (digest) to test, e.g. docker.io/wikid82/charon@sha256:...' required: false type: string image_tag: description: 'Local image tag for compose usage (default: charon:e2e-test)' required: false type: string playwright_coverage: description: 'Enable Playwright coverage (V8)' required: false default: false type: boolean pull_request: env: NODE_VERSION: '20' GO_VERSION: '1.26.2' GOTOOLCHAIN: auto DOCKERHUB_REGISTRY: docker.io IMAGE_NAME: ${{ github.repository_owner }}/charon E2E_BROWSER: ${{ inputs.browser || 'all' }} E2E_TEST_CATEGORY: ${{ inputs.test_category || 'all' }} PLAYWRIGHT_COVERAGE: ${{ (inputs.playwright_coverage && '1') || (vars.PLAYWRIGHT_COVERAGE || '0') }} DEBUG: 'charon:*,charon-test:*' PLAYWRIGHT_DEBUG: '1' CI_LOG_LEVEL: 'verbose' concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: # Prepare application image once, share across all browser jobs build: name: Prepare Application Image runs-on: ubuntu-latest outputs: image_source: ${{ steps.resolve-image.outputs.image_source }} image_ref: ${{ steps.resolve-image.outputs.image_ref }} image_tag: ${{ steps.resolve-image.outputs.image_tag }} image_digest: ${{ steps.resolve-image.outputs.image_digest != '' && steps.resolve-image.outputs.image_digest || steps.build-image.outputs.digest }} steps: - name: Resolve image inputs id: resolve-image run: | IMAGE_REF="${{ inputs.image_ref }}" IMAGE_TAG="${{ inputs.image_tag || 'charon:e2e-test' }}" if [ -n "$IMAGE_REF" ]; then { echo "image_source=registry" echo "image_ref=$IMAGE_REF" echo "image_tag=$IMAGE_TAG" if [[ "$IMAGE_REF" == *@* ]]; then echo "image_digest=${IMAGE_REF#*@}" else echo "image_digest=" fi } >> "$GITHUB_OUTPUT" exit 0 fi { echo "image_source=build" echo "image_ref=" echo "image_tag=$IMAGE_TAG" echo "image_digest=" } >> "$GITHUB_OUTPUT" - name: Checkout repository if: steps.resolve-image.outputs.image_source == 'build' uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.sha }} - name: Set up Go if: steps.resolve-image.outputs.image_source == 'build' uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: ${{ env.GO_VERSION }} cache: true cache-dependency-path: backend/go.sum - name: Set up Node.js if: steps.resolve-image.outputs.image_source == 'build' uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Cache npm dependencies if: steps.resolve-image.outputs.image_source == 'build' uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5 with: path: ~/.npm key: npm-${{ hashFiles('package-lock.json') }} restore-keys: npm- - name: Install dependencies if: steps.resolve-image.outputs.image_source == 'build' run: npm ci - name: Set up Docker Buildx if: steps.resolve-image.outputs.image_source == 'build' uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 - name: Build Docker image id: build-image if: steps.resolve-image.outputs.image_source == 'build' uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7 with: context: . file: ./Dockerfile push: false load: true tags: ${{ steps.resolve-image.outputs.image_tag }} cache-from: type=gha cache-to: type=gha,mode=max - name: Save Docker image if: steps.resolve-image.outputs.image_source == 'build' run: docker save ${{ steps.resolve-image.outputs.image_tag }} -o charon-e2e-image.tar - name: Upload Docker image artifact if: steps.resolve-image.outputs.image_source == 'build' uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: docker-image path: charon-e2e-image.tar retention-days: 1 # ================================================================================== # SECURITY ENFORCEMENT TESTS (3 jobs: 1 per browser, serial execution) # ================================================================================== # These tests enable Cerberus middleware and verify security enforcement # Run serially to avoid cross-test contamination from global state changes # ================================================================================== e2e-chromium-security: name: E2E Chromium (Security Enforcement) runs-on: ubuntu-latest needs: build if: | ((inputs.browser || 'all') == 'chromium' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "true" # Cerberus ON for enforcement tests CHARON_E2E_IMAGE_TAG: ${{ needs.build.outputs.image_tag }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.sha }} - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Log in to Docker Hub if: needs.build.outputs.image_source == 'registry' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Pull shared Docker image if: needs.build.outputs.image_source == 'registry' run: | docker pull "${{ needs.build.outputs.image_ref }}" docker tag "${{ needs.build.outputs.image_ref }}" "${{ needs.build.outputs.image_tag }}" docker images | grep charon - name: Download Docker image artifact if: needs.build.outputs.image_source == 'build' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ "$TOKEN_LENGTH" -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image artifact if: needs.build.outputs.image_source == 'build' run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV" - name: Start test environment (Security Tests Profile) run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for Chromium security enforcement tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium run: | echo "📦 Installing Chromium..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Run Chromium Security Enforcement Tests run: | set -euo pipefail STATUS=0 echo "════════════════════════════════════════════" echo "Chromium Security Enforcement Tests" echo "Cerberus: ENABLED" echo "Execution: SERIAL (no sharding)" echo "Start Time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" echo "════════════════════════════════════════════" SHARD_START=$(date +%s) echo "SHARD_START=$SHARD_START" >> "$GITHUB_ENV" npx playwright test \ --project=chromium \ --output=playwright-output/security-chromium \ tests/security-enforcement/ \ tests/security/ \ tests/integration/multi-feature-workflows.spec.ts || STATUS=$? SHARD_END=$(date +%s) echo "SHARD_END=$SHARD_END" >> "$GITHUB_ENV" SHARD_DURATION=$((SHARD_END - SHARD_START)) echo "════════════════════════════════════════════" echo "Chromium Security Complete | Duration: ${SHARD_DURATION}s" echo "════════════════════════════════════════════" echo "PLAYWRIGHT_STATUS=$STATUS" >> "$GITHUB_ENV" exit "$STATUS" env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true - name: Upload HTML report (Chromium Security) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-chromium-security path: playwright-report/ retention-days: 14 - name: Upload Chromium Security coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-coverage-chromium-security path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: traces-chromium-security path: test-results/**/*.zip retention-days: 7 - name: Collect diagnostics if: always() run: | mkdir -p diagnostics uptime > diagnostics/uptime.txt free -m > diagnostics/free-m.txt df -h > diagnostics/df-h.txt ps aux > diagnostics/ps-aux.txt docker ps -a > diagnostics/docker-ps.txt || true docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true - name: Upload diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-diagnostics-chromium-security path: diagnostics/ retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-chromium-security.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: docker-logs-chromium-security path: docker-logs-chromium-security.txt retention-days: 7 - name: Cleanup if: always() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true e2e-firefox-security: name: E2E Firefox (Security Enforcement) runs-on: ubuntu-latest needs: build if: | ((inputs.browser || 'all') == 'firefox' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "true" # Cerberus ON for enforcement tests CHARON_E2E_IMAGE_TAG: ${{ needs.build.outputs.image_tag }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.sha }} - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Log in to Docker Hub if: needs.build.outputs.image_source == 'registry' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Pull shared Docker image if: needs.build.outputs.image_source == 'registry' run: | docker pull "${{ needs.build.outputs.image_ref }}" docker tag "${{ needs.build.outputs.image_ref }}" "${{ needs.build.outputs.image_tag }}" docker images | grep charon - name: Download Docker image artifact if: needs.build.outputs.image_source == 'build' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ "$TOKEN_LENGTH" -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image artifact if: needs.build.outputs.image_source == 'build' run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV" - name: Start test environment (Security Tests Profile) run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for Firefox security enforcement tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium (required by security-tests dependency) run: | echo "📦 Installing Chromium (required by security-tests dependency)..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Install Playwright Firefox run: | echo "📦 Installing Firefox..." npx playwright install --with-deps firefox EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Run Firefox Security Enforcement Tests run: | set -euo pipefail STATUS=0 echo "════════════════════════════════════════════" echo "Firefox Security Enforcement Tests" echo "Cerberus: ENABLED" echo "Execution: SERIAL (no sharding)" echo "Start Time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" echo "════════════════════════════════════════════" SHARD_START=$(date +%s) echo "SHARD_START=$SHARD_START" >> "$GITHUB_ENV" npx playwright test \ --project=firefox \ --output=playwright-output/security-firefox \ tests/security-enforcement/ \ tests/security/ \ tests/integration/multi-feature-workflows.spec.ts || STATUS=$? SHARD_END=$(date +%s) echo "SHARD_END=$SHARD_END" >> "$GITHUB_ENV" SHARD_DURATION=$((SHARD_END - SHARD_START)) echo "════════════════════════════════════════════" echo "Firefox Security Complete | Duration: ${SHARD_DURATION}s" echo "════════════════════════════════════════════" echo "PLAYWRIGHT_STATUS=$STATUS" >> "$GITHUB_ENV" exit "$STATUS" env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true - name: Upload HTML report (Firefox Security) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-firefox-security path: playwright-report/ retention-days: 14 - name: Upload Firefox Security coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-coverage-firefox-security path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: traces-firefox-security path: test-results/**/*.zip retention-days: 7 - name: Collect diagnostics if: always() run: | mkdir -p diagnostics uptime > diagnostics/uptime.txt free -m > diagnostics/free-m.txt df -h > diagnostics/df-h.txt ps aux > diagnostics/ps-aux.txt docker ps -a > diagnostics/docker-ps.txt || true docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true - name: Upload diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-diagnostics-firefox-security path: diagnostics/ retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-firefox-security.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: docker-logs-firefox-security path: docker-logs-firefox-security.txt retention-days: 7 - name: Cleanup if: always() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true e2e-webkit-security: name: E2E WebKit (Security Enforcement) runs-on: ubuntu-latest needs: build if: | ((inputs.browser || 'all') == 'webkit' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "true" # Cerberus ON for enforcement tests CHARON_E2E_IMAGE_TAG: ${{ needs.build.outputs.image_tag }} steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.sha }} - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Log in to Docker Hub if: needs.build.outputs.image_source == 'registry' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Pull shared Docker image if: needs.build.outputs.image_source == 'registry' run: | docker pull "${{ needs.build.outputs.image_ref }}" docker tag "${{ needs.build.outputs.image_ref }}" "${{ needs.build.outputs.image_tag }}" docker images | grep charon - name: Download Docker image artifact if: needs.build.outputs.image_source == 'build' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: docker-image - name: Validate Emergency Token Configuration run: | echo "🔐 Validating emergency token configuration..." if [ -z "$CHARON_EMERGENCY_TOKEN" ]; then echo "::error title=Missing Secret::CHARON_EMERGENCY_TOKEN secret not configured" exit 1 fi TOKEN_LENGTH=${#CHARON_EMERGENCY_TOKEN} if [ "$TOKEN_LENGTH" -lt 64 ]; then echo "::error title=Invalid Token Length::CHARON_EMERGENCY_TOKEN must be at least 64 characters" exit 1 fi MASKED_TOKEN="${CHARON_EMERGENCY_TOKEN:0:8}...${CHARON_EMERGENCY_TOKEN: -4}" echo "::notice::Emergency token validated (length: $TOKEN_LENGTH, preview: $MASKED_TOKEN)" env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - name: Load Docker image artifact if: needs.build.outputs.image_source == 'build' run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV" - name: Start test environment (Security Tests Profile) run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d echo "✅ Container started for WebKit security enforcement tests" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium (required by security-tests dependency) run: | echo "📦 Installing Chromium (required by security-tests dependency)..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Install Playwright WebKit run: | echo "📦 Installing WebKit..." npx playwright install --with-deps webkit EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Run WebKit Security Enforcement Tests run: | set -euo pipefail STATUS=0 echo "════════════════════════════════════════════" echo "WebKit Security Enforcement Tests" echo "Cerberus: ENABLED" echo "Execution: SERIAL (no sharding)" echo "Start Time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" echo "════════════════════════════════════════════" SHARD_START=$(date +%s) echo "SHARD_START=$SHARD_START" >> "$GITHUB_ENV" npx playwright test \ --project=webkit \ --output=playwright-output/security-webkit \ tests/security-enforcement/ \ tests/security/ \ tests/integration/multi-feature-workflows.spec.ts || STATUS=$? SHARD_END=$(date +%s) echo "SHARD_END=$SHARD_END" >> "$GITHUB_ENV" SHARD_DURATION=$((SHARD_END - SHARD_START)) echo "════════════════════════════════════════════" echo "WebKit Security Complete | Duration: ${SHARD_DURATION}s" echo "════════════════════════════════════════════" echo "PLAYWRIGHT_STATUS=$STATUS" >> "$GITHUB_ENV" exit "$STATUS" env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true - name: Upload HTML report (WebKit Security) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-webkit-security path: playwright-report/ retention-days: 14 - name: Upload WebKit Security coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-coverage-webkit-security path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: traces-webkit-security path: test-results/**/*.zip retention-days: 7 - name: Collect diagnostics if: always() run: | mkdir -p diagnostics uptime > diagnostics/uptime.txt free -m > diagnostics/free-m.txt df -h > diagnostics/df-h.txt ps aux > diagnostics/ps-aux.txt docker ps -a > diagnostics/docker-ps.txt || true docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true - name: Upload diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-diagnostics-webkit-security path: diagnostics/ retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-webkit-security.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: docker-logs-webkit-security path: docker-logs-webkit-security.txt retention-days: 7 - name: Cleanup if: always() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true # ================================================================================== # NON-SECURITY TESTS (12 jobs: 4 shards × 3 browsers, parallel execution) # ==================================================================================================== # These tests run with Cerberus DISABLED to prevent ACL/rate limit interference # Sharded for performance: 4 shards per browser for faster execution # ================================================================================== e2e-chromium: name: E2E Chromium (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) runs-on: ubuntu-latest needs: build if: | ((inputs.browser || 'all') == 'chromium' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "false" # Cerberus OFF for non-security tests CHARON_E2E_IMAGE_TAG: ${{ needs.build.outputs.image_tag }} strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] total-shards: [4] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.sha }} - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Preflight disk diagnostics (before cleanup) run: | echo "Disk usage before cleanup" df -h docker system df || true - name: Preflight cleanup (best effort) run: | echo "Best-effort cleanup for CI runner" docker system prune -af || true rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true rm -f docker-logs-*.txt charon-e2e-image.tar || true - name: Preflight disk diagnostics and threshold gate run: | set -euo pipefail MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024)) echo "Disk usage after cleanup" df -h docker system df || true WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}" FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}') FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}') echo "Free bytes on /: $FREE_ROOT_BYTES" echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES" if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B" exit 42 fi - name: Log in to Docker Hub if: needs.build.outputs.image_source == 'registry' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Pull shared Docker image if: needs.build.outputs.image_source == 'registry' run: | docker pull "${{ needs.build.outputs.image_ref }}" docker tag "${{ needs.build.outputs.image_ref }}" "${{ needs.build.outputs.image_tag }}" docker images | grep charon - name: Download Docker image artifact if: needs.build.outputs.image_source == 'build' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: docker-image - name: Load Docker image artifact if: needs.build.outputs.image_source == 'build' run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV" - name: Start test environment (Non-Security Profile) run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml up -d echo "✅ Container started for Chromium non-security tests (Cerberus OFF)" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium run: | echo "📦 Installing Chromium..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Run Chromium Non-Security Tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) run: | set -euo pipefail STATUS=0 echo "════════════════════════════════════════════" echo "Chromium Non-Security Tests - Shard ${{ matrix.shard }}/${{ matrix.total-shards }}" echo "Cerberus: DISABLED" echo "Execution: PARALLEL (sharded)" echo "Start Time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" echo "════════════════════════════════════════════" SHARD_START=$(date +%s) echo "SHARD_START=$SHARD_START" >> "$GITHUB_ENV" npx playwright test \ --project=chromium \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ --output=playwright-output/chromium-shard-${{ matrix.shard }} \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ tests/integration \ tests/manual-dns-provider.spec.ts \ tests/monitoring \ tests/settings \ tests/tasks || STATUS=$? SHARD_END=$(date +%s) echo "SHARD_END=$SHARD_END" >> "$GITHUB_ENV" SHARD_DURATION=$((SHARD_END - SHARD_START)) echo "════════════════════════════════════════════" echo "Chromium Shard ${{ matrix.shard }} Complete | Duration: ${SHARD_DURATION}s" echo "════════════════════════════════════════════" echo "PLAYWRIGHT_STATUS=$STATUS" >> "$GITHUB_ENV" exit "$STATUS" env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true TEST_WORKER_INDEX: ${{ matrix.shard }} - name: Upload HTML report (Chromium shard ${{ matrix.shard }}) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-chromium-shard-${{ matrix.shard }} path: playwright-report/ retention-days: 14 - name: Upload Playwright output (Chromium shard ${{ matrix.shard }}) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-output-chromium-shard-${{ matrix.shard }} path: playwright-output/chromium-shard-${{ matrix.shard }}/ retention-days: 7 - name: Upload Chromium coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-coverage-chromium-shard-${{ matrix.shard }} path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: traces-chromium-shard-${{ matrix.shard }} path: test-results/**/*.zip retention-days: 7 - name: Collect diagnostics if: always() run: | mkdir -p diagnostics uptime > diagnostics/uptime.txt free -m > diagnostics/free-m.txt df -h > diagnostics/df-h.txt ps aux > diagnostics/ps-aux.txt docker ps -a > diagnostics/docker-ps.txt || true docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true - name: Upload diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-diagnostics-chromium-shard-${{ matrix.shard }} path: diagnostics/ retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-chromium-shard-${{ matrix.shard }}.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: docker-logs-chromium-shard-${{ matrix.shard }} path: docker-logs-chromium-shard-${{ matrix.shard }}.txt retention-days: 7 - name: Cleanup if: always() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true e2e-firefox: name: E2E Firefox (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) runs-on: ubuntu-latest needs: build if: | ((inputs.browser || 'all') == 'firefox' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "false" # Cerberus OFF for non-security tests CHARON_E2E_IMAGE_TAG: ${{ needs.build.outputs.image_tag }} strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] total-shards: [4] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.sha }} - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Preflight disk diagnostics (before cleanup) run: | echo "Disk usage before cleanup" df -h docker system df || true - name: Preflight cleanup (best effort) run: | echo "Best-effort cleanup for CI runner" docker system prune -af || true rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true rm -f docker-logs-*.txt charon-e2e-image.tar || true - name: Preflight disk diagnostics and threshold gate run: | set -euo pipefail MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024)) echo "Disk usage after cleanup" df -h docker system df || true WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}" FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}') FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}') echo "Free bytes on /: $FREE_ROOT_BYTES" echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES" if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B" exit 42 fi - name: Log in to Docker Hub if: needs.build.outputs.image_source == 'registry' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Pull shared Docker image if: needs.build.outputs.image_source == 'registry' run: | docker pull "${{ needs.build.outputs.image_ref }}" docker tag "${{ needs.build.outputs.image_ref }}" "${{ needs.build.outputs.image_tag }}" docker images | grep charon - name: Download Docker image artifact if: needs.build.outputs.image_source == 'build' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: docker-image - name: Load Docker image artifact if: needs.build.outputs.image_source == 'build' run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV" - name: Start test environment (Non-Security Profile) run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml up -d echo "✅ Container started for Firefox non-security tests (Cerberus OFF)" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium (required by security-tests dependency) run: | echo "📦 Installing Chromium (required by security-tests dependency)..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Install Playwright Firefox run: | echo "📦 Installing Firefox..." npx playwright install --with-deps firefox EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Run Firefox Non-Security Tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) run: | set -euo pipefail STATUS=0 echo "════════════════════════════════════════════" echo "Firefox Non-Security Tests - Shard ${{ matrix.shard }}/${{ matrix.total-shards }}" echo "Cerberus: DISABLED" echo "Execution: PARALLEL (sharded)" echo "Start Time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" echo "════════════════════════════════════════════" SHARD_START=$(date +%s) echo "SHARD_START=$SHARD_START" >> "$GITHUB_ENV" npx playwright test \ --project=firefox \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ --output=playwright-output/firefox-shard-${{ matrix.shard }} \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ tests/integration \ tests/manual-dns-provider.spec.ts \ tests/monitoring \ tests/settings \ tests/tasks || STATUS=$? SHARD_END=$(date +%s) echo "SHARD_END=$SHARD_END" >> "$GITHUB_ENV" SHARD_DURATION=$((SHARD_END - SHARD_START)) echo "════════════════════════════════════════════" echo "Firefox Shard ${{ matrix.shard }} Complete | Duration: ${SHARD_DURATION}s" echo "════════════════════════════════════════════" echo "PLAYWRIGHT_STATUS=$STATUS" >> "$GITHUB_ENV" exit "$STATUS" env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true TEST_WORKER_INDEX: ${{ matrix.shard }} - name: Upload HTML report (Firefox shard ${{ matrix.shard }}) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-report-firefox-shard-${{ matrix.shard }} path: playwright-report/ retention-days: 14 - name: Upload Playwright output (Firefox shard ${{ matrix.shard }}) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: playwright-output-firefox-shard-${{ matrix.shard }} path: playwright-output/firefox-shard-${{ matrix.shard }}/ retention-days: 7 - name: Upload Firefox coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-coverage-firefox-shard-${{ matrix.shard }} path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: traces-firefox-shard-${{ matrix.shard }} path: test-results/**/*.zip retention-days: 7 - name: Collect diagnostics if: always() run: | mkdir -p diagnostics uptime > diagnostics/uptime.txt free -m > diagnostics/free-m.txt df -h > diagnostics/df-h.txt ps aux > diagnostics/ps-aux.txt docker ps -a > diagnostics/docker-ps.txt || true docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true - name: Upload diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: e2e-diagnostics-firefox-shard-${{ matrix.shard }} path: diagnostics/ retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-firefox-shard-${{ matrix.shard }}.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: docker-logs-firefox-shard-${{ matrix.shard }} path: docker-logs-firefox-shard-${{ matrix.shard }}.txt retention-days: 7 - name: Cleanup if: always() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true e2e-webkit: name: E2E WebKit (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) runs-on: ubuntu-latest needs: build if: | ((inputs.browser || 'all') == 'webkit' || (inputs.browser || 'all') == 'all') && ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 60 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} CHARON_EMERGENCY_SERVER_ENABLED: "true" CHARON_SECURITY_TESTS_ENABLED: "false" # Cerberus OFF for non-security tests CHARON_E2E_IMAGE_TAG: ${{ needs.build.outputs.image_tag }} strategy: fail-fast: false matrix: shard: [1, 2, 3, 4] total-shards: [4] steps: - name: Checkout repository uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 with: ref: ${{ github.sha }} - name: Set up Node.js uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6 with: node-version: ${{ env.NODE_VERSION }} cache: 'npm' - name: Preflight disk diagnostics (before cleanup) run: | echo "Disk usage before cleanup" df -h docker system df || true - name: Preflight cleanup (best effort) run: | echo "Best-effort cleanup for CI runner" docker system prune -af || true rm -rf playwright-report playwright-output coverage/e2e test-results diagnostics || true rm -f docker-logs-*.txt charon-e2e-image.tar || true - name: Preflight disk diagnostics and threshold gate run: | set -euo pipefail MIN_FREE_BYTES=$((5 * 1024 * 1024 * 1024)) echo "Disk usage after cleanup" df -h docker system df || true WORKSPACE_PATH="${GITHUB_WORKSPACE:-$PWD}" FREE_ROOT_BYTES=$(df -PB1 / | awk 'NR==2 {print $4}') FREE_WORKSPACE_BYTES=$(df -PB1 "$WORKSPACE_PATH" | awk 'NR==2 {print $4}') echo "Free bytes on /: $FREE_ROOT_BYTES" echo "Free bytes on workspace ($WORKSPACE_PATH): $FREE_WORKSPACE_BYTES" if [ "$FREE_ROOT_BYTES" -lt "$MIN_FREE_BYTES" ] || [ "$FREE_WORKSPACE_BYTES" -lt "$MIN_FREE_BYTES" ]; then echo "::error::[CI_DISK_PRESSURE] Insufficient free disk after cleanup. Required >= 5GiB on both / and workspace. root=${FREE_ROOT_BYTES}B workspace=${FREE_WORKSPACE_BYTES}B" exit 42 fi - name: Log in to Docker Hub if: needs.build.outputs.image_source == 'registry' uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.DOCKERHUB_REGISTRY }} username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Pull shared Docker image if: needs.build.outputs.image_source == 'registry' run: | docker pull "${{ needs.build.outputs.image_ref }}" docker tag "${{ needs.build.outputs.image_ref }}" "${{ needs.build.outputs.image_tag }}" docker images | grep charon - name: Download Docker image artifact if: needs.build.outputs.image_source == 'build' uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8 with: name: docker-image - name: Load Docker image artifact if: needs.build.outputs.image_source == 'build' run: | docker load -i charon-e2e-image.tar docker images | grep charon - name: Generate ephemeral encryption key run: echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> "$GITHUB_ENV" - name: Start test environment (Non-Security Profile) run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml up -d echo "✅ Container started for WebKit non-security tests (Cerberus OFF)" - name: Wait for service health run: | echo "⏳ Waiting for Charon to be healthy..." MAX_ATTEMPTS=30 ATTEMPT=0 while [[ ${ATTEMPT} -lt ${MAX_ATTEMPTS} ]]; do ATTEMPT=$((ATTEMPT + 1)) echo "Attempt ${ATTEMPT}/${MAX_ATTEMPTS}..." if curl -sf http://127.0.0.1:8080/api/v1/health > /dev/null 2>&1; then echo "✅ Charon is healthy!" curl -s http://127.0.0.1:8080/api/v1/health | jq . exit 0 fi sleep 2 done echo "❌ Health check failed" docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs exit 1 - name: Install dependencies run: npm ci - name: Install Playwright Chromium (required by security-tests dependency) run: | echo "📦 Installing Chromium (required by security-tests dependency)..." npx playwright install --with-deps chromium EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Install Playwright WebKit run: | echo "📦 Installing WebKit..." npx playwright install --with-deps webkit EXIT_CODE=$? echo "✅ Install command completed (exit code: $EXIT_CODE)" exit "$EXIT_CODE" - name: Run WebKit Non-Security Tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) run: | set -euo pipefail STATUS=0 echo "════════════════════════════════════════════" echo "WebKit Non-Security Tests - Shard ${{ matrix.shard }}/${{ matrix.total-shards }}" echo "Cerberus: DISABLED" echo "Execution: PARALLEL (sharded)" echo "Start Time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" echo "════════════════════════════════════════════" SHARD_START=$(date +%s) echo "SHARD_START=$SHARD_START" >> "$GITHUB_ENV" npx playwright test \ --project=webkit \ --shard=${{ matrix.shard }}/${{ matrix.total-shards }} \ --output=playwright-output/webkit-shard-${{ matrix.shard }} \ tests/core \ tests/dns-provider-crud.spec.ts \ tests/dns-provider-types.spec.ts \ tests/integration \ tests/manual-dns-provider.spec.ts \ tests/monitoring \ tests/settings \ tests/tasks || STATUS=$? SHARD_END=$(date +%s) echo "SHARD_END=$SHARD_END" >> "$GITHUB_ENV" SHARD_DURATION=$((SHARD_END - SHARD_START)) echo "════════════════════════════════════════════" echo "WebKit Shard ${{ matrix.shard }} Complete | Duration: ${SHARD_DURATION}s" echo "════════════════════════════════════════════" echo "PLAYWRIGHT_STATUS=$STATUS" >> "$GITHUB_ENV" exit "$STATUS" env: PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 CI: true TEST_WORKER_INDEX: ${{ matrix.shard }} - name: Upload HTML report (WebKit shard ${{ matrix.shard }}) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: playwright-report-webkit-shard-${{ matrix.shard }} path: playwright-report/ retention-days: 14 - name: Upload Playwright output (WebKit shard ${{ matrix.shard }}) if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: playwright-output-webkit-shard-${{ matrix.shard }} path: playwright-output/webkit-shard-${{ matrix.shard }}/ retention-days: 7 - name: Upload WebKit coverage (if enabled) if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: e2e-coverage-webkit-shard-${{ matrix.shard }} path: coverage/e2e/ retention-days: 7 - name: Upload test traces on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: traces-webkit-shard-${{ matrix.shard }} path: test-results/**/*.zip retention-days: 7 - name: Collect diagnostics if: always() run: | mkdir -p diagnostics uptime > diagnostics/uptime.txt free -m > diagnostics/free-m.txt df -h > diagnostics/df-h.txt ps aux > diagnostics/ps-aux.txt docker ps -a > diagnostics/docker-ps.txt || true docker logs --tail 500 charon-e2e > diagnostics/docker-charon-e2e.log 2>&1 || true - name: Upload diagnostics if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: e2e-diagnostics-webkit-shard-${{ matrix.shard }} path: diagnostics/ retention-days: 7 - name: Collect Docker logs on failure if: failure() run: | docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-webkit-shard-${{ matrix.shard }}.txt 2>&1 - name: Upload Docker logs on failure if: failure() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: docker-logs-webkit-shard-${{ matrix.shard }} path: docker-logs-webkit-shard-${{ matrix.shard }}.txt retention-days: 7 - name: Cleanup if: always() run: docker compose -f .docker/compose/docker-compose.playwright-ci.yml down -v 2>/dev/null || true # Test summary job test-summary: name: E2E Test Summary runs-on: ubuntu-latest needs: [e2e-chromium-security, e2e-firefox-security, e2e-webkit-security, e2e-chromium, e2e-firefox, e2e-webkit] if: always() steps: - name: Generate job summary run: | { echo "## 📊 E2E Test Results (Split: Security + Sharded)" echo "" echo "### Architecture: 15 Total Jobs" echo "" echo "#### Security Enforcement (3 jobs)" echo "| Browser | Status | Shards | Timeout | Cerberus |" echo "|---------|--------|--------|---------|----------|" echo "| Chromium | ${{ needs.e2e-chromium-security.result }} | 1 | 30min | ON |" echo "| Firefox | ${{ needs.e2e-firefox-security.result }} | 1 | 30min | ON |" echo "| WebKit | ${{ needs.e2e-webkit-security.result }} | 1 | 30min | ON |" echo "" echo "#### Non-Security Tests (12 jobs)" echo "| Browser | Status | Shards | Timeout | Cerberus |" echo "|---------|--------|--------|---------|----------|" echo "| Chromium | ${{ needs.e2e-chromium.result }} | 4 | 20min | OFF |" echo "| Firefox | ${{ needs.e2e-firefox.result }} | 4 | 20min | OFF |" echo "| WebKit | ${{ needs.e2e-webkit.result }} | 4 | 20min | OFF |" echo "" echo "### Benefits" echo "" echo "- ✅ **Isolation:** Security tests run independently without ACL/rate limit interference" echo "- ✅ **Performance:** Non-security tests sharded 4-way for faster execution" echo "- ✅ **Reliability:** Cerberus OFF by default prevents cross-shard contamination" echo "- ✅ **Clarity:** Separate artifacts for security vs non-security test results" } >> "$GITHUB_STEP_SUMMARY" # Final status check e2e-results: name: E2E Test Results (Final) runs-on: ubuntu-latest needs: [e2e-chromium-security, e2e-firefox-security, e2e-webkit-security, e2e-chromium, e2e-firefox, e2e-webkit] if: always() steps: - name: Check test results uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 env: EFFECTIVE_BROWSER: ${{ inputs.browser || 'all' }} EFFECTIVE_CATEGORY: ${{ inputs.test_category || 'all' }} NEEDS_JSON: ${{ toJson(needs) }} with: script: | const needs = JSON.parse(process.env.NEEDS_JSON || '{}'); const effectiveBrowser = process.env.EFFECTIVE_BROWSER || 'all'; const effectiveCategory = process.env.EFFECTIVE_CATEGORY || 'all'; const shouldRunSecurity = effectiveCategory === 'security' || effectiveCategory === 'all'; const shouldRunNonSecurity = effectiveCategory === 'non-security' || effectiveCategory === 'all'; const shouldRun = { chromiumSecurity: (effectiveBrowser === 'chromium' || effectiveBrowser === 'all') && shouldRunSecurity, firefoxSecurity: (effectiveBrowser === 'firefox' || effectiveBrowser === 'all') && shouldRunSecurity, webkitSecurity: (effectiveBrowser === 'webkit' || effectiveBrowser === 'all') && shouldRunSecurity, chromium: (effectiveBrowser === 'chromium' || effectiveBrowser === 'all') && shouldRunNonSecurity, firefox: (effectiveBrowser === 'firefox' || effectiveBrowser === 'all') && shouldRunNonSecurity, webkit: (effectiveBrowser === 'webkit' || effectiveBrowser === 'all') && shouldRunNonSecurity, }; const results = { chromiumSecurity: needs['e2e-chromium-security']?.result || 'skipped', firefoxSecurity: needs['e2e-firefox-security']?.result || 'skipped', webkitSecurity: needs['e2e-webkit-security']?.result || 'skipped', chromium: needs['e2e-chromium']?.result || 'skipped', firefox: needs['e2e-firefox']?.result || 'skipped', webkit: needs['e2e-webkit']?.result || 'skipped', }; core.info('Security Enforcement Results:'); core.info(` Chromium Security: ${results.chromiumSecurity}`); core.info(` Firefox Security: ${results.firefoxSecurity}`); core.info(` WebKit Security: ${results.webkitSecurity}`); core.info(''); core.info('Non-Security Results:'); core.info(` Chromium: ${results.chromium}`); core.info(` Firefox: ${results.firefox}`); core.info(` WebKit: ${results.webkit}`); const failures = []; const invalidResults = new Set(['skipped', 'failure', 'cancelled']); const labels = { chromiumSecurity: 'Chromium Security', firefoxSecurity: 'Firefox Security', webkitSecurity: 'WebKit Security', chromium: 'Chromium', firefox: 'Firefox', webkit: 'WebKit', }; for (const [key, shouldRunJob] of Object.entries(shouldRun)) { const result = results[key]; if (shouldRunJob && invalidResults.has(result)) { failures.push(`${labels[key]} expected to run but result was ${result}`); } } if (failures.length > 0) { core.error('One or more expected browser jobs did not succeed:'); failures.forEach((failure) => core.error(`- ${failure}`)); core.setFailed('Expected E2E jobs did not complete successfully.'); } else { core.info('All expected browser tests succeeded'); }