diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 77ee7326..9a6b8987 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -5,12 +5,16 @@ on: branches: - main - development + - feature/** + - hotfix/** paths: - 'backend/**' pull_request: branches: - main - development + - feature/** + - hotfix/** paths: - 'backend/**' workflow_dispatch: diff --git a/.github/workflows/e2e-tests-split.yml.backup b/.github/workflows/e2e-tests-split.yml.backup new file mode 100644 index 00000000..a655fe80 --- /dev/null +++ b/.github/workflows/e2e-tests-split.yml.backup @@ -0,0 +1,1170 @@ +# 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 (Split - Security + Sharded)' + +on: + workflow_run: + workflows: ["Docker Build, Publish & Test"] + types: [completed] + branches: [main, development, 'feature/**', 'hotfix/**'] + pull_request: + branches: [main, development, 'feature/**', 'hotfix/**'] + paths: + - 'frontend/**' + - 'backend/**' + - 'tests/**' + - 'playwright.config.js' + - '.github/workflows/e2e-tests-split.yml' + 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 + +env: + NODE_VERSION: '20' + GO_VERSION: '1.25.6' + GOTOOLCHAIN: auto + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository_owner }}/charon + PLAYWRIGHT_COVERAGE: ${{ vars.PLAYWRIGHT_COVERAGE || '0' }} + DEBUG: 'charon:*,charon-test:*' + PLAYWRIGHT_DEBUG: '1' + CI_LOG_LEVEL: 'verbose' + +concurrency: + group: e2e-split-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + # Build application once, share across all browser jobs + build: + name: Build Application + runs-on: ubuntu-latest + outputs: + image_digest: ${{ steps.build-image.outputs.digest }} + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: ${{ env.GO_VERSION }} + cache: true + cache-dependency-path: backend/go.sum + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Cache npm dependencies + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 + with: + path: ~/.npm + key: npm-${{ hashFiles('package-lock.json') }} + restore-keys: npm- + + - name: Install dependencies + run: npm ci + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Build Docker image + id: build-image + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 + with: + context: . + file: ./Dockerfile + push: false + load: true + tags: charon:e2e-test + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Save Docker image + run: docker save charon:e2e-test -o charon-e2e-image.tar + + - name: Upload Docker image artifact + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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: | + (github.event_name != 'workflow_dispatch') || + (github.event.inputs.browser == 'chromium' || github.event.inputs.browser == 'all') && + (github.event.inputs.test_category == 'security' || github.event.inputs.test_category == 'all') + timeout-minutes: 30 + 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: charon:e2e-test + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Download Docker image + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + 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 + 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: | + 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 \ + tests/security-enforcement/ + + 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 "════════════════════════════════════════════" + env: + PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 + CI: true + + - name: Upload HTML report (Chromium Security) + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: playwright-report-chromium-security + path: playwright-report/ + retention-days: 14 + + - name: Upload Chromium Security coverage (if enabled) + if: always() && env.PLAYWRIGHT_COVERAGE == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: e2e-coverage-chromium-security + path: coverage/e2e/ + retention-days: 7 + + - name: Upload test traces on failure + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: traces-chromium-security + path: test-results/**/*.zip + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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: | + (github.event_name != 'workflow_dispatch') || + (github.event.inputs.browser == 'firefox' || github.event.inputs.browser == 'all') && + (github.event.inputs.test_category == 'security' || github.event.inputs.test_category == 'all') + timeout-minutes: 30 + 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: charon:e2e-test + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Download Docker image + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + 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 + 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: | + 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 \ + tests/security-enforcement/ + + 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 "════════════════════════════════════════════" + env: + PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 + CI: true + + - name: Upload HTML report (Firefox Security) + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: playwright-report-firefox-security + path: playwright-report/ + retention-days: 14 + + - name: Upload Firefox Security coverage (if enabled) + if: always() && env.PLAYWRIGHT_COVERAGE == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: e2e-coverage-firefox-security + path: coverage/e2e/ + retention-days: 7 + + - name: Upload test traces on failure + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: traces-firefox-security + path: test-results/**/*.zip + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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: | + (github.event_name != 'workflow_dispatch') || + (github.event.inputs.browser == 'webkit' || github.event.inputs.browser == 'all') && + (github.event.inputs.test_category == 'security' || github.event.inputs.test_category == 'all') + timeout-minutes: 30 + 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: charon:e2e-test + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Download Docker image + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + 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 + 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: | + 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 \ + tests/security-enforcement/ + + 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 "════════════════════════════════════════════" + env: + PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 + CI: true + + - name: Upload HTML report (WebKit Security) + if: always() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: playwright-report-webkit-security + path: playwright-report/ + retention-days: 14 + + - name: Upload WebKit Security coverage (if enabled) + if: always() && env.PLAYWRIGHT_COVERAGE == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: e2e-coverage-webkit-security + path: coverage/e2e/ + retention-days: 7 + + - name: Upload test traces on failure + if: failure() + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: traces-webkit-security + path: test-results/**/*.zip + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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: | + (github.event_name != 'workflow_dispatch') || + (github.event.inputs.browser == 'chromium' || github.event.inputs.browser == 'all') && + (github.event.inputs.test_category == 'non-security' || github.event.inputs.test_category == 'all') + timeout-minutes: 20 + 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: charon:e2e-test + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + total-shards: [4] + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Download Docker image + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: docker-image + + - name: Load Docker image + 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: | + 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 }} \ + tests/core \ + tests/dns-provider-crud.spec.ts \ + tests/dns-provider-types.spec.ts \ + tests/emergency-server \ + tests/integration \ + tests/manual-dns-provider.spec.ts \ + tests/monitoring \ + tests/security \ + tests/settings \ + tests/tasks + + 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 "════════════════════════════════════════════" + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: playwright-report-chromium-shard-${{ matrix.shard }} + path: playwright-report/ + retention-days: 14 + + - name: Upload Chromium coverage (if enabled) + if: always() && env.PLAYWRIGHT_COVERAGE == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: traces-chromium-shard-${{ matrix.shard }} + path: test-results/**/*.zip + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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: | + (github.event_name != 'workflow_dispatch') || + (github.event.inputs.browser == 'firefox' || github.event.inputs.browser == 'all') && + (github.event.inputs.test_category == 'non-security' || github.event.inputs.test_category == 'all') + timeout-minutes: 20 + 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: charon:e2e-test + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + total-shards: [4] + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Download Docker image + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: docker-image + + - name: Load Docker image + 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 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: | + 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 }} \ + tests/core \ + tests/dns-provider-crud.spec.ts \ + tests/dns-provider-types.spec.ts \ + tests/emergency-server \ + tests/integration \ + tests/manual-dns-provider.spec.ts \ + tests/monitoring \ + tests/security \ + tests/settings \ + tests/tasks + + 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 "════════════════════════════════════════════" + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: playwright-report-firefox-shard-${{ matrix.shard }} + path: playwright-report/ + retention-days: 14 + + - name: Upload Firefox coverage (if enabled) + if: always() && env.PLAYWRIGHT_COVERAGE == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: traces-firefox-shard-${{ matrix.shard }} + path: test-results/**/*.zip + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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: | + (github.event_name != 'workflow_dispatch') || + (github.event.inputs.browser == 'webkit' || github.event.inputs.browser == 'all') && + (github.event.inputs.test_category == 'non-security' || github.event.inputs.test_category == 'all') + timeout-minutes: 20 + 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: charon:e2e-test + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] + total-shards: [4] + + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Node.js + uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'npm' + + - name: Download Docker image + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: docker-image + + - name: Load Docker image + 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 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: | + 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 }} \ + tests/core \ + tests/dns-provider-crud.spec.ts \ + tests/dns-provider-types.spec.ts \ + tests/emergency-server \ + tests/integration \ + tests/manual-dns-provider.spec.ts \ + tests/monitoring \ + tests/security \ + tests/settings \ + tests/tasks + + 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 "════════════════════════════════════════════" + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: playwright-report-webkit-shard-${{ matrix.shard }} + path: playwright-report/ + retention-days: 14 + + - name: Upload WebKit coverage (if enabled) + if: always() && env.PLAYWRIGHT_COVERAGE == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: traces-webkit-shard-${{ matrix.shard }} + path: test-results/**/*.zip + 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@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + 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)" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Architecture: 15 Total Jobs" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "#### Security Enforcement (3 jobs)" >> $GITHUB_STEP_SUMMARY + echo "| Browser | Status | Shards | Timeout | Cerberus |" >> $GITHUB_STEP_SUMMARY + echo "|---------|--------|--------|---------|----------|" >> $GITHUB_STEP_SUMMARY + echo "| Chromium | ${{ needs.e2e-chromium-security.result }} | 1 | 30min | ON |" >> $GITHUB_STEP_SUMMARY + echo "| Firefox | ${{ needs.e2e-firefox-security.result }} | 1 | 30min | ON |" >> $GITHUB_STEP_SUMMARY + echo "| WebKit | ${{ needs.e2e-webkit-security.result }} | 1 | 30min | ON |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "#### Non-Security Tests (12 jobs)" >> $GITHUB_STEP_SUMMARY + echo "| Browser | Status | Shards | Timeout | Cerberus |" >> $GITHUB_STEP_SUMMARY + echo "|---------|--------|--------|---------|----------|" >> $GITHUB_STEP_SUMMARY + echo "| Chromium | ${{ needs.e2e-chromium.result }} | 4 | 20min | OFF |" >> $GITHUB_STEP_SUMMARY + echo "| Firefox | ${{ needs.e2e-firefox.result }} | 4 | 20min | OFF |" >> $GITHUB_STEP_SUMMARY + echo "| WebKit | ${{ needs.e2e-webkit.result }} | 4 | 20min | OFF |" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Benefits" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "- ✅ **Isolation:** Security tests run independently without ACL/rate limit interference" >> $GITHUB_STEP_SUMMARY + echo "- ✅ **Performance:** Non-security tests sharded 4-way for faster execution" >> $GITHUB_STEP_SUMMARY + echo "- ✅ **Reliability:** Cerberus OFF by default prevents cross-shard contamination" >> $GITHUB_STEP_SUMMARY + 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 + run: | + CHROMIUM_SEC="${{ needs.e2e-chromium-security.result }}" + FIREFOX_SEC="${{ needs.e2e-firefox-security.result }}" + WEBKIT_SEC="${{ needs.e2e-webkit-security.result }}" + CHROMIUM="${{ needs.e2e-chromium.result }}" + FIREFOX="${{ needs.e2e-firefox.result }}" + WEBKIT="${{ needs.e2e-webkit.result }}" + + echo "Security Enforcement Results:" + echo " Chromium Security: $CHROMIUM_SEC" + echo " Firefox Security: $FIREFOX_SEC" + echo " WebKit Security: $WEBKIT_SEC" + echo "" + echo "Non-Security Results:" + echo " Chromium: $CHROMIUM" + echo " Firefox: $FIREFOX" + echo " WebKit: $WEBKIT" + + # Allow skipped jobs (workflow_dispatch with specific browser/category) + if [[ "$CHROMIUM_SEC" == "skipped" ]]; then CHROMIUM_SEC="success"; fi + if [[ "$FIREFOX_SEC" == "skipped" ]]; then FIREFOX_SEC="success"; fi + if [[ "$WEBKIT_SEC" == "skipped" ]]; then WEBKIT_SEC="success"; fi + if [[ "$CHROMIUM" == "skipped" ]]; then CHROMIUM="success"; fi + if [[ "$FIREFOX" == "skipped" ]]; then FIREFOX="success"; fi + if [[ "$WEBKIT" == "skipped" ]]; then WEBKIT="success"; fi + + if [[ "$CHROMIUM_SEC" == "success" && "$FIREFOX_SEC" == "success" && "$WEBKIT_SEC" == "success" && \ + "$CHROMIUM" == "success" && "$FIREFOX" == "success" && "$WEBKIT" == "success" ]]; then + echo "✅ All browser tests passed or were skipped" + exit 0 + else + echo "❌ One or more browser tests failed" + exit 1 + fi diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml deleted file mode 100644 index ebb8a09c..00000000 --- a/.github/workflows/e2e-tests.yml +++ /dev/null @@ -1,705 +0,0 @@ -# E2E Tests Workflow -# Runs Playwright E2E tests with sharding for faster execution -# and collects frontend code coverage via @bgotink/playwright-coverage -# -# Phase 4: Build Once, Test Many - Use registry image instead of building -# This workflow now waits for docker-build.yml to complete and pulls the built image -# -# Test Execution Architecture: -# - Parallel Sharding: Tests split across 4 shards for speed -# - Per-Shard HTML Reports: Each shard generates its own HTML report -# - No Merging Needed: Smaller reports are easier to debug -# - Trace Collection: Failure traces captured for debugging -# -# Coverage Architecture: -# - Backend: Docker container at 127.0.0.1:8080 (API) -# - Frontend: Vite dev server at 127.0.0.1:3000 (serves source files) -# - Tests hit Vite, which proxies API calls to Docker -# - V8 coverage maps directly to source files for accurate reporting -# - Coverage disabled by default (requires PLAYWRIGHT_COVERAGE=1) -# - NOTE: Coverage mode uses Vite dev server, not registry image -# -# Triggers: -# - workflow_run after docker-build.yml completes (standard mode) -# - Manual dispatch with browser/image selection -# -# Jobs: -# 1. e2e-tests: Run tests in parallel shards, upload per-shard HTML reports -# 2. test-summary: Generate summary with links to shard reports -# 3. comment-results: Post test results as PR comment -# 4. upload-coverage: Merge and upload E2E coverage to Codecov (if enabled) -# 5. e2e-results: Status check to block merge on failure - -name: E2E Tests - -on: - workflow_run: - workflows: ["Docker Build, Publish & Test"] - types: [completed] - branches: [main, development, 'feature/**', 'hotfix/**'] # Explicit branch filter prevents unexpected triggers - - workflow_dispatch: - inputs: - image_tag: - description: 'Docker image tag to test (e.g., pr-123-abc1234, latest)' - required: false - type: string - browser: - description: 'Browser to test' - required: false - default: 'chromium' - type: choice - options: - - chromium - - firefox - - webkit - - all - -env: - NODE_VERSION: '20' - GO_VERSION: '1.25.6' - GOTOOLCHAIN: auto - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository_owner }}/charon - PLAYWRIGHT_COVERAGE: ${{ vars.PLAYWRIGHT_COVERAGE || '0' }} - # Enhanced debugging environment variables - DEBUG: 'charon:*,charon-test:*' - PLAYWRIGHT_DEBUG: '1' - CI_LOG_LEVEL: 'verbose' - -# Prevent race conditions when PR is updated mid-test -# Cancels old test runs when new build completes with different SHA -concurrency: - group: e2e-${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.ref }}-${{ github.event.workflow_run.head_sha || github.sha }} - cancel-in-progress: true - -jobs: - # Run tests in parallel shards against registry image - e2e-tests: - name: E2E ${{ matrix.browser }} (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) - runs-on: ubuntu-latest - timeout-minutes: 30 - # Only run if docker-build.yml succeeded, or if manually triggered - if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} - env: - # Required for security teardown (emergency reset fallback when ACL blocks API) - CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} - # Enable security-focused endpoints and test gating - CHARON_EMERGENCY_SERVER_ENABLED: "true" - CHARON_SECURITY_TESTS_ENABLED: "true" - strategy: - fail-fast: false - matrix: - shard: [1, 2, 3, 4] - total-shards: [4] - browser: [chromium, firefox, webkit] - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - # Determine the correct image tag based on trigger context - # For PRs: pr-{number}-{sha}, For branches: {sanitized-branch}-{sha} - - name: Determine image tag - id: image - env: - EVENT: ${{ github.event.workflow_run.event }} - REF: ${{ github.event.workflow_run.head_branch }} - SHA: ${{ github.event.workflow_run.head_sha }} - MANUAL_TAG: ${{ inputs.image_tag }} - run: | - # Manual trigger uses provided tag - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - if [[ -n "$MANUAL_TAG" ]]; then - echo "tag=${MANUAL_TAG}" >> $GITHUB_OUTPUT - else - # Default to latest if no tag provided - echo "tag=latest" >> $GITHUB_OUTPUT - fi - echo "source_type=manual" >> $GITHUB_OUTPUT - exit 0 - fi - - # Extract 7-character short SHA - SHORT_SHA=$(echo "$SHA" | cut -c1-7) - - if [[ "$EVENT" == "pull_request" ]]; then - # Use native pull_requests array (no API calls needed) - PR_NUM=$(echo '${{ toJson(github.event.workflow_run.pull_requests) }}' | jq -r '.[0].number') - - if [[ -z "$PR_NUM" || "$PR_NUM" == "null" ]]; then - echo "❌ ERROR: Could not determine PR number" - echo "Event: $EVENT" - echo "Ref: $REF" - echo "SHA: $SHA" - echo "Pull Requests JSON: ${{ toJson(github.event.workflow_run.pull_requests) }}" - exit 1 - fi - - # Immutable tag with SHA suffix prevents race conditions - echo "tag=pr-${PR_NUM}-${SHORT_SHA}" >> $GITHUB_OUTPUT - echo "source_type=pr" >> $GITHUB_OUTPUT - else - # Branch push: sanitize branch name and append SHA - # Sanitization: lowercase, replace / with -, remove special chars - SANITIZED=$(echo "$REF" | \ - tr '[:upper:]' '[:lower:]' | \ - tr '/' '-' | \ - sed 's/[^a-z0-9-._]/-/g' | \ - sed 's/^-//; s/-$//' | \ - sed 's/--*/-/g' | \ - cut -c1-121) # Leave room for -SHORT_SHA (7 chars) - - echo "tag=${SANITIZED}-${SHORT_SHA}" >> $GITHUB_OUTPUT - echo "source_type=branch" >> $GITHUB_OUTPUT - fi - - echo "sha=${SHORT_SHA}" >> $GITHUB_OUTPUT - echo "Determined image tag: $(cat $GITHUB_OUTPUT | grep tag=)" - - # Pull image from registry with retry logic (dual-source strategy) - # Try registry first (fast), fallback to artifact if registry fails - - name: Pull Docker image from registry - id: pull_image - uses: nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08 # v3 - with: - timeout_minutes: 5 - max_attempts: 3 - retry_wait_seconds: 10 - command: | - IMAGE_NAME="ghcr.io/${{ github.repository_owner }}/charon:${{ steps.image.outputs.tag }}" - echo "Pulling image: $IMAGE_NAME" - docker pull "$IMAGE_NAME" - docker tag "$IMAGE_NAME" charon:e2e-test - echo "✅ Successfully pulled from registry" - continue-on-error: true - - # Fallback: Download artifact if registry pull failed - - name: Fallback to artifact download - if: steps.pull_image.outcome == 'failure' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - SHA: ${{ steps.image.outputs.sha }} - run: | - echo "âš ī¸ Registry pull failed, falling back to artifact..." - - # Determine artifact name based on source type - if [[ "${{ steps.image.outputs.source_type }}" == "pr" ]]; then - PR_NUM=$(echo '${{ toJson(github.event.workflow_run.pull_requests) }}' | jq -r '.[0].number') - ARTIFACT_NAME="pr-image-${PR_NUM}" - else - ARTIFACT_NAME="push-image" - fi - - echo "Downloading artifact: $ARTIFACT_NAME" - gh run run download ${{ github.event.workflow_run.id }} \ - --name "$ARTIFACT_NAME" \ - --dir /tmp/docker-image || { - echo "❌ ERROR: Artifact download failed!" - echo "Available artifacts:" - gh run view ${{ github.event.workflow_run.id }} --json artifacts --jq '.artifacts[].name' - exit 1 - } - - docker load < /tmp/docker-image/charon-image.tar - docker tag $(docker images --format "{{.Repository}}:{{.Tag}}" | head -1) charon:e2e-test - echo "✅ Successfully loaded from artifact" - - # Validate image freshness by checking SHA label - - name: Validate image SHA - env: - SHA: ${{ steps.image.outputs.sha }} - run: | - LABEL_SHA=$(docker inspect charon:e2e-test --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' | cut -c1-7 || echo "unknown") - echo "Expected SHA: $SHA" - echo "Image SHA: $LABEL_SHA" - - if [[ "$LABEL_SHA" != "$SHA" && "$LABEL_SHA" != "unknown" ]]; then - echo "âš ī¸ WARNING: Image SHA mismatch!" - echo "Image may be stale. Proceeding with caution..." - elif [[ "$LABEL_SHA" == "unknown" ]]; then - echo "â„šī¸ INFO: Could not determine image SHA from labels (artifact source)" - else - echo "✅ Image SHA matches expected commit" - fi - - - 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 in repository settings" - echo "::error::Navigate to: Repository Settings → Secrets and Variables → Actions" - echo "::error::Create secret: CHARON_EMERGENCY_TOKEN" - echo "::error::Generate value with: openssl rand -hex 32" - echo "::error::See docs/github-setup.md for detailed instructions" - 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 (current: $TOKEN_LENGTH)" - echo "::error::Generate new token with: openssl rand -hex 32" - exit 1 - fi - - # Mask token in output (show first 8 chars only) - 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: Generate ephemeral encryption key - run: | - # Generate a unique, ephemeral encryption key for this CI run - # Key is 32 bytes, base64-encoded as required by CHARON_ENCRYPTION_KEY - echo "CHARON_ENCRYPTION_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV - echo "✅ Generated ephemeral encryption key for E2E tests" - - - name: Start test environment - run: | - # Use docker-compose.playwright-ci.yml for CI (no .env file, uses GitHub Secrets) - # Note: Using pre-pulled/pre-built image (charon:e2e-test) - no rebuild needed - docker compose -f .docker/compose/docker-compose.playwright-ci.yml --profile security-tests up -d - echo "✅ Container started via docker-compose.playwright-ci.yml" - - - 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: Clean Playwright browser cache - run: rm -rf ~/.cache/ms-playwright - - - - name: Cache Playwright browsers - id: playwright-cache - uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5 - with: - path: ~/.cache/ms-playwright - # Use exact match only - no restore-keys fallback - # This ensures we don't restore stale browsers when Playwright version changes - key: playwright-${{ matrix.browser }}-${{ hashFiles('package-lock.json') }} - - - name: Install & verify Playwright browsers - run: | - npx playwright install --with-deps --force - - set -euo pipefail - - echo "đŸŽ¯ Playwright CLI version" - npx playwright --version || true - - echo "🔍 Showing Playwright cache root (if present)" - ls -la ~/.cache/ms-playwright || true - - echo "đŸ“Ĩ Install or verify browser: ${{ matrix.browser }}" - - # Install when cache miss, otherwise verify the expected executables exist - if [[ "${{ steps.playwright-cache.outputs.cache-hit }}" != "true" ]]; then - echo "đŸ“Ĩ Cache miss - downloading ${{ matrix.browser }} browser..." - npx playwright install --with-deps ${{ matrix.browser }} - else - echo "✅ Cache hit - verifying ${{ matrix.browser }} browser files..." - fi - - # Look for the browser-specific headless shell executable(s) - case "${{ matrix.browser }}" in - chromium) - EXPECTED_PATTERN="chrome-headless-shell*" - ;; - firefox) - EXPECTED_PATTERN="firefox*" - ;; - webkit) - EXPECTED_PATTERN="webkit*" - ;; - *) - EXPECTED_PATTERN="*" - ;; - esac - - echo "Searching for expected files (pattern=$EXPECTED_PATTERN)..." - find ~/.cache/ms-playwright -maxdepth 4 -type f -name "$EXPECTED_PATTERN" -print || true - - # Attempt to derive the exact executable path Playwright will use - echo "Attempting to resolve Playwright's executable path via Node API (best-effort)" - node -e "try{ const pw = require('playwright'); const b = pw['${{ matrix.browser }}']; console.log('exePath:', b.executablePath ? b.executablePath() : 'n/a'); }catch(e){ console.error('node-check-failed', e.message); process.exit(0); }" || true - - # If the expected binary is missing, force reinstall - MISSING_COUNT=$(find ~/.cache/ms-playwright -maxdepth 4 -type f -name "$EXPECTED_PATTERN" | wc -l || true) - if [[ "$MISSING_COUNT" -lt 1 ]]; then - echo "âš ī¸ Expected Playwright browser executable not found (count=$MISSING_COUNT). Forcing reinstall..." - npx playwright install --with-deps ${{ matrix.browser }} --force - fi - - echo "Post-install: show cache contents (top 5 lines)" - find ~/.cache/ms-playwright -maxdepth 3 -printf '%p\n' | head -40 || true - - # Final sanity check: try a headless launch via a tiny Node script (browser-specific args, retry without args) - echo "🔁 Verifying browser can be launched (headless)" - node -e "(async()=>{ try{ const pw=require('playwright'); const name='${{ matrix.browser }}'; const browser = pw[name]; const argsMap = { chromium: ['--no-sandbox'], firefox: ['--no-sandbox'], webkit: [] }; const args = argsMap[name] || []; - // First attempt: launch with recommended args for this browser - try { - console.log('attempt-launch', name, 'args', JSON.stringify(args)); - const b = await browser.launch({ headless: true, args }); - await b.close(); - console.log('launch-ok', 'argsUsed', JSON.stringify(args)); - process.exit(0); - } catch (err) { - console.warn('launch-with-args-failed', err && err.message); - if (args.length) { - // Retry without args (some browsers reject unknown flags) - console.log('retrying-without-args'); - const b2 = await browser.launch({ headless: true }); - await b2.close(); - console.log('launch-ok-no-args'); - process.exit(0); - } - throw err; - } - } catch (e) { console.error('launch-failed', e && e.message); process.exit(2); } })()" || (echo '❌ Browser launch verification failed' && exit 1) - - echo "✅ Playwright ${{ matrix.browser }} ready and verified" - - - name: Run E2E tests (Shard ${{ matrix.shard }}/${{ matrix.total-shards }}) - run: | - echo "════════════════════════════════════════════════════════════" - echo "E2E Test Shard ${{ matrix.shard }}/${{ matrix.total-shards }}" - echo "Browser: ${{ matrix.browser }}" - echo "Start Time: $(date -u +'%Y-%m-%dT%H:%M:%SZ')" - echo "" - echo "Reporter: HTML (per-shard reports)" - echo "Output: playwright-report/ directory" - echo "════════════════════════════════════════════════════════════" - - # Capture start time for performance budget tracking - SHARD_START=$(date +%s) - echo "SHARD_START=$SHARD_START" >> $GITHUB_ENV - - npx playwright test \ - --project=${{ matrix.browser }} \ - --shard=${{ matrix.shard }}/${{ matrix.total-shards }} - - # Capture end time for performance budget tracking - SHARD_END=$(date +%s) - echo "SHARD_END=$SHARD_END" >> $GITHUB_ENV - - SHARD_DURATION=$((SHARD_END - SHARD_START)) - - echo "" - echo "════════════════════════════════════════════════════════════" - echo "Shard ${{ matrix.shard }} Complete | Duration: ${SHARD_DURATION}s" - echo "════════════════════════════════════════════════════════════" - env: - # Test directly against Docker container (no coverage) - PLAYWRIGHT_BASE_URL: http://127.0.0.1:8080 - CI: true - TEST_WORKER_INDEX: ${{ matrix.shard }} - - - name: Verify shard performance budget - if: always() - run: | - # Calculate shard execution time - SHARD_DURATION=$((SHARD_END - SHARD_START)) - MAX_DURATION=900 # 15 minutes - - echo "📊 Performance Budget Check" - echo " Shard Duration: ${SHARD_DURATION}s" - echo " Budget Limit: ${MAX_DURATION}s" - echo " Utilization: $((SHARD_DURATION * 100 / MAX_DURATION))%" - - # Fail if shard exceeded performance budget - if [[ $SHARD_DURATION -gt $MAX_DURATION ]]; then - echo "::error::Shard exceeded performance budget: ${SHARD_DURATION}s > ${MAX_DURATION}s" - echo "::error::This likely indicates feature flag polling regression or API bottleneck" - echo "::error::Review test logs and consider optimizing wait helpers or API calls" - exit 1 - fi - - echo "✅ Shard completed within budget: ${SHARD_DURATION}s" - - - name: Upload HTML report (per-shard) - if: always() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: playwright-report-${{ matrix.browser }}-shard-${{ matrix.shard }} - path: playwright-report/ - retention-days: 14 - - - name: Upload test traces on failure - if: failure() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: traces-${{ matrix.browser }}-shard-${{ matrix.shard }} - path: test-results/**/*.zip - retention-days: 7 - - - name: Collect Docker logs on failure - if: failure() - run: | - echo "📋 Container logs:" - docker compose -f .docker/compose/docker-compose.playwright-ci.yml logs > docker-logs-${{ matrix.browser }}-shard-${{ matrix.shard }}.txt 2>&1 - - - name: Upload Docker logs on failure - if: failure() - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: docker-logs-${{ matrix.browser }}-shard-${{ matrix.shard }} - path: docker-logs-${{ matrix.browser }}-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 - - # Summarize test results from all shards (no merging needed) - test-summary: - name: E2E Test Summary - runs-on: ubuntu-latest - needs: e2e-tests - if: always() - - steps: - - name: Generate job summary with per-shard links - run: | - echo "## 📊 E2E Test Results" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Per-Shard HTML Reports" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "Each shard generates its own HTML report for easier debugging:" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "| Browser | Shards | HTML Reports | Traces (on failure) |" >> $GITHUB_STEP_SUMMARY - echo "|---------|--------|--------------|---------------------|" >> $GITHUB_STEP_SUMMARY - echo "| Chromium | 1-4 | \`playwright-report-chromium-shard-{1..4}\` | \`traces-chromium-shard-{1..4}\` |" >> $GITHUB_STEP_SUMMARY - echo "| Firefox | 1-4 | \`playwright-report-firefox-shard-{1..4}\` | \`traces-firefox-shard-{1..4}\` |" >> $GITHUB_STEP_SUMMARY - echo "| WebKit | 1-4 | \`playwright-report-webkit-shard-{1..4}\` | \`traces-webkit-shard-{1..4}\` |" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### How to View Reports" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "1. Download the shard HTML report artifact (zip file)" >> $GITHUB_STEP_SUMMARY - echo "2. Extract and open \`index.html\` in your browser" >> $GITHUB_STEP_SUMMARY - echo "3. Or run: \`npx playwright show-report path/to/extracted-folder\`" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "### Debugging Tips" >> $GITHUB_STEP_SUMMARY - echo "" >> $GITHUB_STEP_SUMMARY - echo "- **Failed tests?** Download the shard report that failed. Each shard has a focused subset of tests." >> $GITHUB_STEP_SUMMARY - echo "- **Traces**: Available in trace artifacts (only on failure)" >> $GITHUB_STEP_SUMMARY - echo "- **Docker Logs**: Backend errors available in docker-logs-shard-N artifacts" >> $GITHUB_STEP_SUMMARY - echo "- **Local repro**: \`npx playwright test --grep=\"test name\"\`" >> $GITHUB_STEP_SUMMARY - - # Comment on PR with results (only for workflow_run triggered by PR) - comment-results: - name: Comment Test Results - runs-on: ubuntu-latest - needs: [e2e-tests, test-summary] - # Only comment if triggered by workflow_run from a pull_request event - if: ${{ always() && github.event_name == 'workflow_run' && github.event.workflow_run.event == 'pull_request' }} - permissions: - pull-requests: write - - steps: - - name: Determine test status - id: status - run: | - if [[ "${{ needs.e2e-tests.result }}" == "success" ]]; then - echo "emoji=✅" >> $GITHUB_OUTPUT - echo "status=PASSED" >> $GITHUB_OUTPUT - echo "message=All E2E tests passed!" >> $GITHUB_OUTPUT - elif [[ "${{ needs.e2e-tests.result }}" == "failure" ]]; then - echo "emoji=❌" >> $GITHUB_OUTPUT - echo "status=FAILED" >> $GITHUB_OUTPUT - echo "message=Some E2E tests failed. Check artifacts for per-shard reports." >> $GITHUB_OUTPUT - else - echo "emoji=âš ī¸" >> $GITHUB_OUTPUT - echo "status=UNKNOWN" >> $GITHUB_OUTPUT - echo "message=E2E tests did not complete successfully." >> $GITHUB_OUTPUT - fi - - - name: Get PR number - id: pr - run: | - PR_NUM=$(echo '${{ toJson(github.event.workflow_run.pull_requests) }}' | jq -r '.[0].number') - if [[ -z "$PR_NUM" || "$PR_NUM" == "null" ]]; then - echo "âš ī¸ Could not determine PR number, skipping comment" - echo "skip=true" >> $GITHUB_OUTPUT - else - echo "number=$PR_NUM" >> $GITHUB_OUTPUT - echo "skip=false" >> $GITHUB_OUTPUT - fi - - - name: Comment on PR - if: steps.pr.outputs.skip != 'true' - uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8 - with: - script: | - const emoji = '${{ steps.status.outputs.emoji }}'; - const status = '${{ steps.status.outputs.status }}'; - const message = '${{ steps.status.outputs.message }}'; - const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const prNumber = parseInt('${{ steps.pr.outputs.number }}'); - - const body = `## ${emoji} E2E Test Results: ${status} - - ${message} - - | Metric | Result | - |--------|--------| - | Browsers | Chromium, Firefox, WebKit | - | Shards per Browser | 4 | - | Total Jobs | 12 | - | Status | ${status} | - - **Per-Shard HTML Reports** (easier to debug): - - \`playwright-report-{browser}-shard-{1..4}\` (12 total artifacts) - - Trace artifacts: \`traces-{browser}-shard-{N}\` - - [📊 View workflow run & download reports](${runUrl}) - - --- - 🤖 This comment was automatically generated by the E2E Tests workflow.`; - - // Find existing comment - const { data: comments } = await github.rest.issues.listComments({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - }); - - const botComment = comments.find(comment => - comment.user.type === 'Bot' && - comment.body.includes('E2E Test Results') - ); - - if (botComment) { - await github.rest.issues.updateComment({ - owner: context.repo.owner, - repo: context.repo.repo, - comment_id: botComment.id, - body: body - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: body - }); - } - - # Upload merged E2E coverage to Codecov - upload-coverage: - name: Upload E2E Coverage - runs-on: ubuntu-latest - needs: e2e-tests - # Coverage is only produced when PLAYWRIGHT_COVERAGE=1 (requires Vite dev server) - if: vars.PLAYWRIGHT_COVERAGE == '1' - - - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - - - name: Download all coverage artifacts - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: e2e-coverage-* - path: all-coverage - merge-multiple: false - - - name: Merge LCOV coverage files - run: | - # Install lcov for merging - sudo apt-get update && sudo apt-get install -y lcov - - # Create merged coverage directory - mkdir -p coverage/e2e-merged - - # Find all lcov.info files and merge them - LCOV_FILES=$(find all-coverage -name "lcov.info" -type f) - - if [[ -n "$LCOV_FILES" ]]; then - # Build merge command - MERGE_ARGS="" - for file in $LCOV_FILES; do - MERGE_ARGS="$MERGE_ARGS -a $file" - done - - lcov $MERGE_ARGS -o coverage/e2e-merged/lcov.info - echo "✅ Merged $(echo "$LCOV_FILES" | wc -w) coverage files" - else - echo "âš ī¸ No coverage files found to merge" - exit 0 - fi - - - name: Upload E2E coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - files: ./coverage/e2e-merged/lcov.info - flags: e2e - name: e2e-coverage - fail_ci_if_error: false - - - name: Upload merged coverage artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 - with: - name: e2e-coverage-merged - path: coverage/e2e-merged/ - retention-days: 30 - - # Final status check - blocks merge if tests fail - e2e-results: - name: E2E Test Results - runs-on: ubuntu-latest - needs: e2e-tests - if: always() - - steps: - - name: Check test results - run: | - if [[ "${{ needs.e2e-tests.result }}" == "success" ]]; then - echo "✅ All E2E tests passed" - exit 0 - elif [[ "${{ needs.e2e-tests.result }}" == "skipped" ]]; then - echo "â­ī¸ E2E tests were skipped" - exit 0 - else - echo "❌ E2E tests failed or were cancelled" - echo "Result: ${{ needs.e2e-tests.result }}" - exit 1 - fi diff --git a/docs/plans/propagation_workflow_update.md b/docs/plans/propagation_workflow_update.md new file mode 100644 index 00000000..e1ce93e2 --- /dev/null +++ b/docs/plans/propagation_workflow_update.md @@ -0,0 +1,117 @@ +# Plan: Refine Propagation Workflow to Enforce Strict Hierarchy (Pittsburgh Model) + +## 1. Introduction +This plan outlines the update of the `.github/workflows/propagate-changes.yml` workflow. The goal is to enforce a strict hierarchical propagation strategy ("The Pittsburgh Model") where changes flow downstream from `main` to `development`, and then from `development` to leaf branches (`feature/*`, `hotfix/*`). This explicitly prevents "loop-backs" and direct updates from `main` to feature branches. + +## 2. Methodology & Rules +**The Pittsburgh Model (Strict Hierarchy):** + +1. **Rule 1 (The Ohio River)**: `main` **ONLY** propagates to `development`. + - *Logic*: `main` is the stable release branch. Changes here (hotfixes, releases) must flow into `development` first. + - *Constraint*: `main` must **NEVER** propagate directly to `feature/*` or `hotfix/*`. + +2. **Rule 2 (The Point)**: `development` is the **ONLY** branch that propagates to leaf branches. + - *Logic*: `development` is the source of truth for active work. It aggregates `main` changes plus ongoing development. + - *Targets*: `feature/*` and `hotfix/*`. + +3. **Rule 3 (Loop Prevention)**: Determine the "source" PR to prevent re-propagation. + - *Problem*: When `feature/A` merges into `development`, we must not open a PR from `development` back to `feature/A`. + - *Mechanism*: Identify the source branch of the commit triggering the workflow and exclude it from targets. + +## 3. Workflow Design + +### 3.1. Branching Strategy Logic + +| Trigger Branch | Source | Target(s) | Logic | +| :--- | :--- | :--- | :--- | +| `main` | `main` | `development` | Create PR `main` -> `development` | +| `development` | `development` | `feature/*`, `hotfix/*` | Create PR `development` -> `[leaf]` (Excluding changes source) | +| `feature/*` | - | - | No action (Triggers CI only) | +| `hotfix/*` | - | - | No action (Triggers CI only) | + +### 3.2. Logic Updates Needed + +**A. Strict Main Enforcement** +- Current logic likely does this, but we will explicitly verify `if (currentBranch === 'main') { propagate('development'); }` and nothing else. + +**B. Development Distribution & Hotfix Inclusion** +- Update the branch listing logic to find both `feature/*` AND `hotfix/*` branches. +- Current code only looks for `feature/*`. + +**C. Loop Prevention (The "Source Branch" Check)** +- **Trigger**: Script runs on push to `development`. +- **Action**: + 1. Retrieve the Pull Request associated with the commit sha using the GitHub API. + 2. If a merged PR exists for this commit, extract the source branch name (`head.ref`). + 3. Exclude this source branch from the list of propagation targets. + +### 3.3. Technical Implementation Details +- **File**: `.github/workflows/propagate-changes.yml` +- **Action**: `actions/github-script` + +**Pseudo-Code Update:** +```javascript +// 1. Get current branch +const branch = context.ref.replace('refs/heads/', ''); + +// 2. Rule 1: Main -> Development +if (branch === 'main') { + await createPR('main', 'development'); + return; +} + +// 3. Rule 2: Development -> Leafs +if (branch === 'development') { + // 3a. Identify Source (Rule 3 Loop Prevention) + // NOTE: This runs on push, so context.sha is the commit sha. + let excludedBranch = null; + try { + const prs = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: context.sha, + }); + // Find the PR that was merged + const mergedPr = prs.data.find(pr => pr.merged_at); + if (mergedPr) { + excludedBranch = mergedPr.head.ref; + core.info(`Commit derived from merged PR #${mergedPr.number} (Source: ${excludedBranch}). Skipping back-propagation.`); + } + } catch (e) { + core.info('Could not check associated PRs: ' + e.message); + } + + // 3b. Find Targets + const branches = await github.paginate(github.rest.repos.listBranches, { + owner: context.repo.owner, + repo: context.repo.repo, + }); + + const targets = branches + .map(b => b.name) + .filter(b => (b.startsWith('feature/') || b.startsWith('hotfix/'))) + .filter(b => b !== excludedBranch); // Exclude source + + // 3c. Propagate + core.info(`Propagating to ${targets.length} branches: ${targets.join(', ')}`); + for (const target of targets) { + await createPR('development', target); + } +} +``` + +## 4. Implementation Steps + +1. **Refactor `main` logic**: Ensure it returns immediately after propagating to `development` to prevent any fall-through. +2. **Update `development` logic**: + - Add `hotfix/` to the filter regex. + - Implement the `listPullRequestsAssociatedWithCommit` call to identify the exclusion. + - Apply the exclusion to the target list. +3. **Verify Hierarchy**: + - Confirm no path exists for `main` -> `feature/*`. + +## 5. Acceptance Criteria +- [ ] Push to `main` creates a PR ONLY to `development`. +- [ ] Push to `development` creates PRs to all downstream `feature/*` AND `hotfix/*` branches. +- [ ] Push to `development` (caused by merge of `feature/A`) does **NOT** create a PR back to `feature/A`. +- [ ] A hotfix merged to `main` flows: `main` -> `development`, then `development` -> `hotfix/active-work` (if any exist).