diff --git a/.github/workflows/cerberus-integration.yml b/.github/workflows/cerberus-integration.yml index 3c6bc553..8a26fc50 100644 --- a/.github/workflows/cerberus-integration.yml +++ b/.github/workflows/cerberus-integration.yml @@ -9,6 +9,7 @@ on: description: 'Docker image tag to test (e.g., pr-123-abc1234, latest)' required: false type: string + pull_request: # Prevent race conditions when PR is updated mid-test # Cancels old test runs when new build completes with different SHA @@ -21,98 +22,14 @@ jobs: name: Cerberus Security Stack Integration runs-on: ubuntu-latest timeout-minutes: 20 - # Only run if docker-build.yml succeeded, or if manually triggered - if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && (github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success')) }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - # Determine the correct image tag based on trigger context - # For PRs: pr-{number}-{short-sha}, For non-PR: sha-{short-sha} - - name: Determine image tag - id: determine-tag - env: - EVENT: ${{ github.event.workflow_run.event || github.event_name }} - REF: ${{ github.event.workflow_run.head_branch || github.ref_name }} - SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - MANUAL_TAG: ${{ inputs.image_tag }} + - name: Build Docker image (Local) 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) - - # Use native pull_requests array (no API calls needed) - PR_NUM=$(echo '${{ toJson(github.event.workflow_run.pull_requests) }}' | jq -r '.[0].number // empty') - - if [[ "$EVENT" == "pull_request" || -n "$PR_NUM" ]]; then - - # Fallback for direct PR trigger - if [[ -z "$PR_NUM" || "$PR_NUM" == "null" ]]; then - PR_NUM="${{ github.event.number }}" - fi - - 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 - # Non-PR workflow_run uses short SHA tag (matches docker-build.yml) - echo "tag=sha-${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "source_type=sha" >> "$GITHUB_OUTPUT" - fi - - echo "sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "Determined image tag: $(grep tag= "$GITHUB_OUTPUT")" - - # Pull image from Docker Hub with retry logic - - 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="docker.io/wikid82/charon:${{ steps.determine-tag.outputs.tag }}" - echo "Pulling image: $IMAGE_NAME" - docker pull "$IMAGE_NAME" - docker tag "$IMAGE_NAME" charon:local - echo "✅ Successfully pulled from registry" - - # Validate image freshness by checking SHA label - - name: Validate image SHA - env: - SHA: ${{ steps.determine-tag.outputs.sha }} - run: | - LABEL_SHA=$(docker inspect charon:local --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' | cut -c1-7) - echo "Expected SHA: $SHA" - echo "Image SHA: $LABEL_SHA" - - if [[ "$LABEL_SHA" != "$SHA" ]]; then - echo "⚠️ WARNING: Image SHA mismatch!" - echo "Image may be stale. Proceeding with caution..." - else - echo "✅ Image SHA matches expected commit" - fi + echo "Building image locally for integration tests..." + docker build -t charon:local . + echo "✅ Successfully built charon:local" - name: Run Cerberus integration tests id: cerberus-test diff --git a/.github/workflows/ci-pipeline.yml b/.github/workflows/ci-pipeline.yml deleted file mode 100644 index d2a761ad..00000000 --- a/.github/workflows/ci-pipeline.yml +++ /dev/null @@ -1,1037 +0,0 @@ -name: CI Pipeline - -on: - pull_request: - workflow_dispatch: - inputs: - image_tag_override: - description: 'Optional image tag to use for build outputs' - required: false - type: string - run_coverage: - description: 'Run backend/frontend coverage jobs' - required: false - default: true - type: boolean - run_security_scans: - description: 'Run CodeQL, Trivy, and supply-chain checks' - required: false - default: true - type: boolean - run_integration: - description: 'Run integration test jobs' - required: false - default: true - type: boolean - run_e2e: - description: 'Run Playwright E2E tests' - required: false - default: true - type: boolean - -permissions: - contents: read - -env: - GO_VERSION: '1.25.7' - NODE_VERSION: '24.12.0' - GOTOOLCHAIN: auto - GHCR_REGISTRY: ghcr.io - DOCKERHUB_REGISTRY: docker.io - IMAGE_NAME: wikid82/charon - IS_FORK: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true && github.repository != github.event.pull_request.head.repo.full_name }} - -jobs: - lint: - name: Lint and Repo Health - runs-on: ubuntu-latest - permissions: - contents: read - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Repo health check - run: bash scripts/repo_health_check.sh - - - name: Run Hadolint - uses: hadolint/hadolint-action@2332a7b74a6de0dda2e2221d575162eba76ba5e5 # v3.3.0 - with: - dockerfile: Dockerfile - config: .hadolint.yaml - failure-threshold: warning - - - name: GORM Security Scanner - run: | - chmod +x scripts/scan-gorm-security.sh - ./scripts/scan-gorm-security.sh --check - - - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 - with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: backend/go.sum - - - name: Run golangci-lint (fast) - uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20 # v9.2.0 - with: - version: v2.8.0 - working-directory: backend - args: --config=.golangci-fast.yml --timeout=2m - - - name: Check frontend lockfile - id: frontend-lockfile - run: | - if [ -f frontend/package-lock.json ]; then - echo "present=true" >> "$GITHUB_OUTPUT" - else - echo "present=false" >> "$GITHUB_OUTPUT" - fi - - - name: Set up Node.js (with cache) - if: steps.frontend-lockfile.outputs.present == 'true' - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Set up Node.js - if: steps.frontend-lockfile.outputs.present != 'true' - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 - with: - node-version: ${{ env.NODE_VERSION }} - - - name: Install frontend dependencies - working-directory: frontend - run: npm ci - - - name: Run frontend lint - working-directory: frontend - run: npm run lint - - setup: - name: Setup - runs-on: ubuntu-latest - outputs: - input_run_integration: ${{ steps.normalize.outputs.run_integration }} - steps: - - name: Normalize integration input - id: normalize - run: | - if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then - if [ "${{ inputs.run_integration }}" = "false" ]; then - echo "run_integration=false" >> "$GITHUB_OUTPUT" - else - echo "run_integration=true" >> "$GITHUB_OUTPUT" - fi - else - echo "run_integration=true" >> "$GITHUB_OUTPUT" - fi - - build-image: - name: Build and Publish Image - runs-on: ubuntu-latest - needs: - - lint - - setup - concurrency: - group: ci-build-image-${{ github.workflow }}-${{ github.ref_name }} - cancel-in-progress: true - permissions: - contents: read - packages: write - outputs: - image_digest: ${{ steps.push.outputs.digest }} - image_ref: ${{ steps.outputs.outputs.image_ref_dockerhub }} - image_ref_dockerhub: ${{ steps.outputs.outputs.image_ref_dockerhub }} - image_ref_ghcr: ${{ steps.outputs.outputs.image_ref_ghcr }} - image_tag: ${{ steps.outputs.outputs.image_tag }} - push_image: ${{ steps.image-policy.outputs.push }} - image_pushed: ${{ steps.image-policy.outputs.push == 'true' && steps.push.outcome == 'success' }} - run_integration: ${{ needs.setup.outputs.input_run_integration == 'true' }} - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Normalize image name - run: | - IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]') - if [ -z "$IMAGE_NAME" ]; then - echo "::error::IMAGE_NAME is empty!" - exit 1 - fi - echo "IMAGE_NAME=${IMAGE_NAME}" >> "$GITHUB_ENV" - - - name: Determine image push policy - id: image-policy - run: | - PUSH_IMAGE=true - if [ "${{ github.event_name }}" = "pull_request" ]; then - if [ "${{ github.event.pull_request.head.repo.fork }}" = "true" ] && \ - [ "${{ github.repository }}" != "${{ github.event.pull_request.head.repo.full_name }}" ]; then - PUSH_IMAGE=false - fi - fi - echo "push=${PUSH_IMAGE}" >> "$GITHUB_OUTPUT" - - - name: Compute image tags - id: tags - env: - PR_HEAD_REF: ${{ github.head_ref }} - run: | - if [ -z "$IMAGE_NAME" ]; then - echo "::error::IMAGE_NAME is empty!" - exit 1 - fi - - SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7) - BRANCH_NAME="${{ github.ref_name }}" - if [[ "$BRANCH_NAME" == refs/heads/* ]]; then - BRANCH_NAME="${BRANCH_NAME#refs/heads/}" - fi - if [ "${{ github.event_name }}" = "pull_request" ]; then - BRANCH_NAME="${PR_HEAD_REF}" - if [[ "$BRANCH_NAME" == refs/heads/* ]]; then - BRANCH_NAME="${BRANCH_NAME#refs/heads/}" - fi - fi - - sanitize_tag() { - local raw="$1" - local max_len="$2" - local fallback="$3" - local sanitized - - sanitized=$(printf '%s' "$raw" | sed -E 's/[^A-Za-z0-9_.-]/-/g') - sanitized=$(printf '%s' "$sanitized" | sed -E 's/-+/-/g') - sanitized=$(printf '%s' "$sanitized" | sed -E 's/^[.-]+//') - sanitized=$(printf '%s' "$sanitized" | cut -c1-"$max_len") - - if [ -z "$sanitized" ]; then - sanitized="$fallback" - fi - - printf '%s' "$sanitized" - } - - DEFAULT_TAG="sha-${SHORT_SHA}" - if [ -n "${{ inputs.image_tag_override }}" ]; then - DEFAULT_TAG=$(sanitize_tag "${{ inputs.image_tag_override }}" 128 "sha-${SHORT_SHA}") - elif [ "${{ github.event_name }}" = "pull_request" ]; then - PR_NUMBER="${{ github.event.pull_request.number }}" - if [ -n "${PR_NUMBER}" ]; then - DEFAULT_TAG=$(sanitize_tag "pr-${PR_NUMBER}-${SHORT_SHA}" 128 "sha-${SHORT_SHA}") - else - DEFAULT_TAG=$(sanitize_tag "sha-${SHORT_SHA}" 128 "sha-${SHORT_SHA}") - fi - else - DEFAULT_TAG=$(sanitize_tag "${DEFAULT_TAG}" 128 "sha-${SHORT_SHA}") - fi - - if [ -z "$DEFAULT_TAG" ]; then - echo "::error::DEFAULT_TAG is empty!" - exit 1 - fi - - SANITIZED_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 128 "branch") - SANITIZED_SHORT_SHA=$(sanitize_tag "${SHORT_SHA}" 7 "sha") - BRANCH_TAG="${SANITIZED_BRANCH}" - BRANCH_SHA_TAG="${SANITIZED_BRANCH}-${SANITIZED_SHORT_SHA}" - if [ "${#SANITIZED_BRANCH}" -gt 120 ]; then - SANITIZED_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 120 "branch") - BRANCH_SHA_TAG="${SANITIZED_BRANCH}-${SANITIZED_SHORT_SHA}" - fi - - TAGS=() - TAGS+=("${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${DEFAULT_TAG}") - TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${DEFAULT_TAG}") - - if [ "${{ github.event_name }}" = "pull_request" ]; then - TAGS+=("${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${BRANCH_TAG}") - TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${BRANCH_TAG}") - else - TAGS+=("${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${BRANCH_SHA_TAG}") - TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${BRANCH_SHA_TAG}") - - case "${{ github.ref_name }}" in - feature/*) - TAGS+=("${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${BRANCH_TAG}") - TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${BRANCH_TAG}") - ;; - esac - fi - - if [ "${{ github.event_name }}" != "pull_request" ] && \ - { [ "${{ github.ref_name }}" = "main" ] || [ "${{ github.ref_name }}" = "development" ] || [ "${{ github.ref_name }}" = "nightly" ]; }; then - TAGS+=("${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${SANITIZED_SHORT_SHA}") - TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${SANITIZED_SHORT_SHA}") - fi - - if [ "${{ github.ref_name }}" = "main" ]; then - TAGS+=("${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:latest") - TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:latest") - fi - - if [ "${{ github.ref_name }}" = "development" ]; then - TAGS+=("${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:dev") - TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:dev") - fi - - if [ "${{ github.ref_name }}" = "nightly" ]; then - TAGS+=("${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly") - TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:nightly") - fi - - if [ ${#TAGS[@]} -eq 0 ]; then - echo "::error::No tags generated!" - exit 1 - fi - - for tag in "${TAGS[@]}"; do - if [ -z "$tag" ]; then - echo "::error::Generated tag is empty!" - exit 1 - fi - if [[ "$tag" =~ [[:space:]] ]]; then - echo "::error::Generated tag contains whitespace: $tag" - exit 1 - fi - done - - printf '%s\n' "${TAGS[@]}" - - { - echo "tags<> "$GITHUB_OUTPUT" - echo "image_tag=${DEFAULT_TAG}" >> "$GITHUB_OUTPUT" - - - name: Set up QEMU - uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0 - - - name: Log in to GitHub Container Registry - if: ${{ steps.image-policy.outputs.push == 'true' }} - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - with: - registry: ${{ env.GHCR_REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Log in to Docker Hub - if: steps.image-policy.outputs.push == 'true' - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - with: - registry: ${{ env.DOCKERHUB_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Echo generated tags - run: printf '%s\n' "${{ steps.tags.outputs.tags }}" - - - name: Debug tags context - run: | - echo "Tags: [${{ steps.tags.outputs.tags }}]" - - - name: Build and push Docker image - id: push - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64,linux/arm64 - push: ${{ steps.image-policy.outputs.push == 'true' }} - load: ${{ steps.image-policy.outputs.push != 'true' }} - tags: ${{ steps.tags.outputs.tags }} - cache-to: type=gha,mode=max - labels: | - org.opencontainers.image.revision=${{ github.sha }} - - - name: Build image for integration tests - id: export-image - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6 - with: - context: . - file: ./Dockerfile - platforms: linux/amd64 - outputs: type=docker,dest=/tmp/charon.tar - tags: charon:local - cache-from: type=gha - - - name: Upload image artifact - uses: actions/upload-artifact@v4 - with: - name: docker-image - path: /tmp/charon.tar - retention-days: 1 - - - name: Emit image outputs - id: outputs - env: - DIGEST: ${{ steps.push.outputs.digest }} - TAGS_RAW: ${{ steps.tags.outputs.tags }} - DEFAULT_TAG: ${{ steps.tags.outputs.image_tag }} - PUSH_IMAGE: ${{ steps.image-policy.outputs.push }} - PUSH_OUTCOME: ${{ steps.push.outcome }} - run: | - set -x - # sanitize digest - DIGEST=$(echo "$DIGEST" | xargs) - - echo "Debug: Input Digest: '$DIGEST'" - echo "Debug: Default Tag: '$DEFAULT_TAG'" - echo "Debug: Push Enabled: '$PUSH_IMAGE'" - echo "Debug: Push Outcome: '$PUSH_OUTCOME'" - - IMAGE_REF_DOCKERHUB="" - IMAGE_REF_GHCR="" - - if [ -n "$DIGEST" ]; then - echo "Digest available; using immutable refs." - IMAGE_REF_DOCKERHUB="${{ env.DOCKERHUB_REGISTRY }}/${IMAGE_NAME}@${DIGEST}" - IMAGE_REF_GHCR="${{ env.GHCR_REGISTRY }}/${IMAGE_NAME}@${DIGEST}" - else - echo "Digest empty or whitespace; scanning tag list." - echo "Debug: Tags Raw content:" - echo "$TAGS_RAW" - - DOCKERHUB_MATCH="" - GHCR_MATCH="" - - while IFS= read -r raw_line; do - line="${raw_line//$'\r'/}" - if [ -z "$line" ]; then - continue - fi - # trim whitespace - line=$(echo "$line" | xargs) - - if [ -z "$DOCKERHUB_MATCH" ] && [[ "$line" == "${{ env.DOCKERHUB_REGISTRY }}/${IMAGE_NAME}:${DEFAULT_TAG}" ]]; then - DOCKERHUB_MATCH="$line" - echo "Selected Docker Hub tag matching DEFAULT_TAG: $DOCKERHUB_MATCH" - fi - - if [ -z "$GHCR_MATCH" ] && [[ "$line" == "${{ env.GHCR_REGISTRY }}/${IMAGE_NAME}:${DEFAULT_TAG}" ]]; then - GHCR_MATCH="$line" - echo "Selected GHCR tag matching DEFAULT_TAG: $GHCR_MATCH" - fi - - if [ -z "$DOCKERHUB_MATCH" ] && [[ "$line" == "${{ env.DOCKERHUB_REGISTRY }}"/* ]]; then - DOCKERHUB_MATCH="$line" - echo "Selected first Docker Hub tag: $DOCKERHUB_MATCH" - fi - - if [ -z "$GHCR_MATCH" ] && [[ "$line" == "${{ env.GHCR_REGISTRY }}"/* ]]; then - GHCR_MATCH="$line" - echo "Selected first GHCR tag: $GHCR_MATCH" - fi - done <<< "$TAGS_RAW" - - if [ -z "$DOCKERHUB_MATCH" ] && [ -n "$DEFAULT_TAG" ]; then - DOCKERHUB_MATCH="${{ env.DOCKERHUB_REGISTRY }}/${IMAGE_NAME}:${DEFAULT_TAG}" - echo "No Docker Hub tag found; using computed DEFAULT_TAG fallback: $DOCKERHUB_MATCH" - fi - - if [ -z "$GHCR_MATCH" ] && [ -n "$DEFAULT_TAG" ]; then - GHCR_MATCH="${{ env.GHCR_REGISTRY }}/${IMAGE_NAME}:${DEFAULT_TAG}" - echo "No GHCR tag found; using computed DEFAULT_TAG fallback: $GHCR_MATCH" - fi - - IMAGE_REF_DOCKERHUB="$DOCKERHUB_MATCH" - IMAGE_REF_GHCR="$GHCR_MATCH" - fi - - IMAGE_REF_DOCKERHUB=$(echo "$IMAGE_REF_DOCKERHUB" | xargs) - IMAGE_REF_GHCR=$(echo "$IMAGE_REF_GHCR" | xargs) - - if [ -z "$IMAGE_REF_DOCKERHUB" ] && [ "$PUSH_IMAGE" = "true" ] && [ "$PUSH_OUTCOME" = "success" ]; then - echo "::error::Failed to resolve Docker Hub image ref after push. Digest='${DIGEST}', default_tag='${DEFAULT_TAG}', tags_present='${TAGS_RAW:+yes}'" - exit 1 - fi - - if [ "$PUSH_IMAGE" = "true" ] && [ "$PUSH_OUTCOME" = "success" ]; then - echo "Validating Docker Hub image ref: '${IMAGE_REF_DOCKERHUB}'" - if [ -z "$IMAGE_REF_DOCKERHUB" ]; then - echo "::error::Validated ref is empty!" - exit 1 - fi - docker manifest inspect "${IMAGE_REF_DOCKERHUB}" - fi - - { - echo "image_ref_dockerhub=${IMAGE_REF_DOCKERHUB}" - echo "image_ref_ghcr=${IMAGE_REF_GHCR}" - echo "image_tag=${DEFAULT_TAG}" - } >> "$GITHUB_OUTPUT" - - integration-cerberus: - name: Integration - Cerberus - runs-on: ubuntu-latest - needs: build-image - if: ${{ needs.build-image.outputs.run_integration == 'true' }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Download Docker image artifact - uses: actions/download-artifact@v4 - with: - name: docker-image - path: /tmp - - - name: Load Docker image - run: docker load -i /tmp/charon.tar - - - name: Verify local image - run: docker image inspect charon:local - - - name: Run Cerberus integration tests - run: | - chmod +x scripts/cerberus_integration.sh - scripts/cerberus_integration.sh - - integration-crowdsec: - name: Integration - CrowdSec - runs-on: ubuntu-latest - needs: build-image - if: ${{ needs.build-image.outputs.run_integration == 'true' }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Download Docker image artifact - uses: actions/download-artifact@v4 - with: - name: docker-image - path: /tmp - - - name: Load Docker image - run: docker load -i /tmp/charon.tar - - - name: Verify local image - run: docker image inspect charon:local - - - name: Run CrowdSec integration tests - run: | - chmod +x .github/skills/scripts/skill-runner.sh - .github/skills/scripts/skill-runner.sh integration-test-crowdsec - .github/skills/scripts/skill-runner.sh integration-test-crowdsec-startup - - integration-waf: - name: Integration - WAF - runs-on: ubuntu-latest - needs: build-image - if: ${{ needs.build-image.outputs.run_integration == 'true' }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Download Docker image artifact - uses: actions/download-artifact@v4 - with: - name: docker-image - path: /tmp - - - name: Load Docker image - run: docker load -i /tmp/charon.tar - - - name: Verify local image - run: docker image inspect charon:local - - - name: Run WAF integration tests - run: | - chmod +x scripts/coraza_integration.sh - scripts/coraza_integration.sh - - integration-ratelimit: - name: Integration - Rate Limit - runs-on: ubuntu-latest - needs: build-image - if: ${{ needs.build-image.outputs.run_integration == 'true' }} - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Download Docker image artifact - uses: actions/download-artifact@v4 - with: - name: docker-image - path: /tmp - - - name: Load Docker image - run: docker load -i /tmp/charon.tar - - - name: Verify local image - run: docker image inspect charon:local - - - name: Run rate limit integration tests - run: | - chmod +x scripts/rate_limit_integration.sh - scripts/rate_limit_integration.sh - - integration-gate: - name: Integration Gate - runs-on: ubuntu-latest - needs: - - build-image - - integration-cerberus - - integration-crowdsec - - integration-waf - - integration-ratelimit - if: ${{ needs.build-image.outputs.run_integration == 'true' }} - steps: - - name: Verify integration results - run: | - RESULTS=( - "integration-cerberus:${{ needs.integration-cerberus.result }}" - "integration-crowdsec:${{ needs.integration-crowdsec.result }}" - "integration-waf:${{ needs.integration-waf.result }}" - "integration-ratelimit:${{ needs.integration-ratelimit.result }}" - ) - - for ENTRY in "${RESULTS[@]}"; do - JOB_NAME="${ENTRY%%:*}" - RESULT="${ENTRY##*:}" - if [ "$RESULT" != "success" ]; then - echo "${JOB_NAME} failed: ${RESULT}" - exit 1 - fi - done - - e2e: - name: E2E Tests with Coverage - needs: - - build-image - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_e2e != false) && needs.build-image.result == 'success' && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - uses: ./.github/workflows/e2e-tests-split.yml - with: - browser: all - test_category: all - image_ref: ${{ needs.build-image.outputs.image_pushed == 'true' && needs.build-image.outputs.image_ref_dockerhub || '' }} - image_tag: charon:e2e-test - playwright_coverage: true - secrets: inherit - - e2e-gate: - name: E2E Gate - runs-on: ubuntu-latest - needs: - - e2e - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_e2e != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - steps: - - name: Verify E2E results - run: | - if [ "${{ needs.e2e.result }}" != "success" ]; then - echo "E2E tests failed: ${{ needs.e2e.result }}" - exit 1 - fi - - coverage-backend: - name: Coverage - Backend - runs-on: ubuntu-latest - needs: - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_coverage != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 0 - - - name: Set up Go - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 - with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: backend/go.sum - - - name: Run Go tests with coverage - env: - CGO_ENABLED: 1 - run: | - bash scripts/go-test-coverage.sh 2>&1 | tee backend/test-output.txt - exit "${PIPESTATUS[0]}" - - - name: Upload coverage artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: backend-coverage - path: backend/coverage.txt - retention-days: 1 - - coverage-frontend: - name: Coverage - Frontend - runs-on: ubuntu-latest - needs: - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_coverage != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - with: - fetch-depth: 0 - - - name: Set up Node.js - uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6 - with: - node-version: ${{ env.NODE_VERSION }} - cache: 'npm' - cache-dependency-path: frontend/package-lock.json - - - name: Install dependencies - working-directory: frontend - run: npm ci - - - name: Run frontend tests and coverage - run: | - bash scripts/frontend-test-coverage.sh 2>&1 | tee frontend/test-output.txt - exit "${PIPESTATUS[0]}" - - - name: Upload coverage artifact - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 - with: - name: frontend-coverage - path: frontend/coverage - retention-days: 1 - - coverage-gate: - name: Coverage Gate - runs-on: ubuntu-latest - needs: - - coverage-backend - - coverage-frontend - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_coverage != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - steps: - - name: Evaluate coverage results - run: | - RESULTS=( - "coverage-backend:${{ needs.coverage-backend.result }}" - "coverage-frontend:${{ needs.coverage-frontend.result }}" - ) - - for ENTRY in "${RESULTS[@]}"; do - JOB_NAME="${ENTRY%%:*}" - RESULT="${ENTRY##*:}" - if [ "$RESULT" != "success" ]; then - echo "${JOB_NAME} failed: ${RESULT}" - exit 1 - fi - done - - codecov-upload: - name: Codecov Upload - runs-on: ubuntu-latest - needs: - - coverage-gate - - e2e - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_coverage != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - steps: - - name: Checkout - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Download backend coverage artifact - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: backend-coverage - path: backend/ - - - name: Download frontend coverage artifact - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - name: frontend-coverage - path: frontend/coverage - - - name: Download E2E coverage artifact - if: needs.e2e.result != 'skipped' - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 - with: - pattern: e2e-coverage-* - path: coverage/e2e-shards - merge-multiple: false - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - flags: backend - files: backend/coverage.txt - fail_ci_if_error: false - - - name: Upload frontend coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - flags: frontend - files: frontend/coverage/lcov.info - fail_ci_if_error: false - - - name: Upload E2E coverage to Codecov - uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - flags: e2e - files: coverage/e2e-shards/**/lcov.info - fail_ci_if_error: false - - codecov-gate: - name: Codecov Gate - runs-on: ubuntu-latest - needs: - - codecov-upload - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_coverage != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') && needs.codecov-upload.result != 'skipped' - steps: - - name: Evaluate Codecov upload results - run: | - RESULTS=( - "codecov-upload:${{ needs.codecov-upload.result }}" - ) - - for ENTRY in "${RESULTS[@]}"; do - JOB_NAME="${ENTRY%%:*}" - RESULT="${ENTRY##*:}" - if [ "$RESULT" != "success" ]; then - echo "${JOB_NAME} failed: ${RESULT}" - exit 1 - fi - done - - security-codeql: - name: Security - CodeQL - runs-on: ubuntu-latest - needs: - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_security_scans != false) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - permissions: - contents: read - security-events: write - actions: read - pull-requests: read - strategy: - fail-fast: false - matrix: - language: ['go', 'javascript-typescript'] - steps: - - name: Checkout repository - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: Initialize CodeQL - uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 - with: - languages: ${{ matrix.language }} - config-file: ./.github/codeql/codeql-config.yml - - - name: Setup Go - if: matrix.language == 'go' - uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 - with: - go-version: ${{ env.GO_VERSION }} - cache-dependency-path: backend/go.sum - - - name: Autobuild - uses: github/codeql-action/autobuild@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 - with: - category: "/language:${{ matrix.language }}" - - security-trivy: - name: Security - Trivy Image Scan - runs-on: ubuntu-latest - needs: - - build-image - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_security_scans != false) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true) && needs.build-image.result == 'success' && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - permissions: - contents: read - security-events: write - steps: - - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - with: - registry: ${{ env.DOCKERHUB_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Run Trivy image scan (SARIF) - uses: aquasecurity/trivy-action@22438a435773de8c97dc0958cc0b823c45b064ac - with: - scan-type: image - image-ref: ${{ needs.build-image.outputs.image_ref_dockerhub }} - format: sarif - output: trivy-image-results.sarif - severity: 'CRITICAL,HIGH,MEDIUM' - continue-on-error: true - - - name: Upload Trivy SARIF to GitHub Security - uses: github/codeql-action/upload-sarif@b13d724d35ff0a814e21683638ed68ed34cf53d1 - with: - sarif_file: trivy-image-results.sarif - category: trivy-image - continue-on-error: true - - - name: Run Trivy image scan (fail on CRITICAL/HIGH) - uses: aquasecurity/trivy-action@22438a435773de8c97dc0958cc0b823c45b064ac - with: - scan-type: image - image-ref: ${{ needs.build-image.outputs.image_ref_dockerhub }} - format: table - severity: 'CRITICAL,HIGH' - exit-code: '1' - - security-supply-chain: - name: Security - Supply Chain - runs-on: ubuntu-latest - needs: - - build-image - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_security_scans != false) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true) && needs.build-image.result == 'success' && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - permissions: - contents: read - security-events: write - steps: - - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 - with: - registry: ${{ env.DOCKERHUB_REGISTRY }} - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Generate SBOM - uses: anchore/sbom-action@28d71544de8eaf1b958d335707167c5f783590ad # v0.22.2 - with: - image: ${{ needs.build-image.outputs.image_ref_dockerhub }} - format: cyclonedx-json - output-file: sbom.cyclonedx.json - - - name: Scan SBOM for vulnerabilities - uses: anchore/scan-action@7037fa011853d5a11690026fb85feee79f4c946c # v7.3.2 - with: - sbom: sbom.cyclonedx.json - fail-build: false - output-format: json - - security-gate: - name: Security Gate - runs-on: ubuntu-latest - needs: - - security-codeql - - security-trivy - - security-supply-chain - - integration-gate - if: always() && (github.event_name != 'workflow_dispatch' || inputs.run_security_scans != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') - steps: - - name: Verify security results - run: | - require_success_if_ran() { - local name="$1" - local result="$2" - local enabled="$3" - - if [ "$result" = "success" ]; then - return 0 - fi - - if [ "$result" = "skipped" ] && [ "$enabled" != "true" ]; then - return 0 - fi - - echo "${name} failed: ${result}" - exit 1 - } - - security_enabled="${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}" - if [ "$security_enabled" = "true" ]; then - require_success_if_ran "security-codeql" "${{ needs.security-codeql.result }}" "true" - require_success_if_ran "security-trivy" "${{ needs.security-trivy.result }}" "true" - require_success_if_ran "security-supply-chain" "${{ needs.security-supply-chain.result }}" "true" - fi - - pipeline-gate: - name: Pipeline Gate - runs-on: ubuntu-latest - needs: - - lint - - build-image - - integration-gate - - e2e-gate - - coverage-gate - - codecov-gate - - security-gate - if: always() - steps: - - name: Evaluate pipeline results - run: | - require_success_if_ran() { - local name="$1" - local result="$2" - local enabled="$3" - - if [ "$result" = "success" ]; then - return 0 - fi - - if [ "$result" = "skipped" ] && [ "$enabled" != "true" ]; then - return 0 - fi - - echo "${name} failed: ${result}" - exit 1 - } - - required_jobs_ran=0 - - require_success_if_ran "lint" "${{ needs.lint.result }}" "true" - if [ "${{ needs.lint.result }}" != "skipped" ]; then - required_jobs_ran=$((required_jobs_ran + 1)) - fi - - require_success_if_ran "build-image" "${{ needs.build-image.result }}" "true" - if [ "${{ needs.build-image.result }}" != "skipped" ]; then - required_jobs_ran=$((required_jobs_ran + 1)) - fi - - integration_enabled="${{ needs.build-image.outputs.run_integration == 'true' }}" - if [ "$integration_enabled" = "true" ]; then - require_success_if_ran "integration-gate" "${{ needs.integration-gate.result }}" "true" - if [ "${{ needs.integration-gate.result }}" != "skipped" ]; then - required_jobs_ran=$((required_jobs_ran + 1)) - fi - fi - - e2e_enabled="${{ (github.event_name != 'workflow_dispatch' || inputs.run_e2e != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') }}" - if [ "$e2e_enabled" = "true" ]; then - require_success_if_ran "e2e-gate" "${{ needs.e2e-gate.result }}" "true" - if [ "${{ needs.e2e-gate.result }}" != "skipped" ]; then - required_jobs_ran=$((required_jobs_ran + 1)) - fi - fi - - coverage_enabled="${{ (github.event_name != 'workflow_dispatch' || inputs.run_coverage != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') }}" - if [ "$coverage_enabled" = "true" ]; then - require_success_if_ran "coverage-gate" "${{ needs.coverage-gate.result }}" "true" - require_success_if_ran "codecov-gate" "${{ needs.codecov-gate.result }}" "true" - if [ "${{ needs.coverage-gate.result }}" != "skipped" ]; then - required_jobs_ran=$((required_jobs_ran + 1)) - fi - if [ "${{ needs.codecov-gate.result }}" != "skipped" ]; then - required_jobs_ran=$((required_jobs_ran + 1)) - fi - fi - - security_enabled="${{ (github.event_name != 'workflow_dispatch' || inputs.run_security_scans != false) && (needs.integration-gate.result == 'success' || needs.integration-gate.result == 'skipped') && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) }}" - if [ "$security_enabled" = "true" ]; then - require_success_if_ran "security-gate" "${{ needs.security-gate.result }}" "true" - if [ "${{ needs.security-gate.result }}" != "skipped" ]; then - required_jobs_ran=$((required_jobs_ran + 1)) - fi - fi - - if [ "$required_jobs_ran" -eq 0 ]; then - echo "No required stages were enabled; skipping pipeline gate." - exit 0 - fi diff --git a/.github/workflows/crowdsec-integration.yml b/.github/workflows/crowdsec-integration.yml index 6375d3cd..3e8409f3 100644 --- a/.github/workflows/crowdsec-integration.yml +++ b/.github/workflows/crowdsec-integration.yml @@ -9,6 +9,7 @@ on: description: 'Docker image tag to test (e.g., pr-123-abc1234, latest)' required: false type: string + pull_request: # Prevent race conditions when PR is updated mid-test # Cancels old test runs when new build completes with different SHA @@ -21,105 +22,14 @@ jobs: name: CrowdSec Bouncer Integration runs-on: ubuntu-latest timeout-minutes: 15 - # Only run if docker-build.yml succeeded, or if manually triggered - if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && (github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success')) }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - # Determine the correct image tag based on trigger context - # For PRs: pr-{number}-{short-sha}, For non-PR: sha-{short-sha} - - name: Determine image tag - id: determine-tag - env: - EVENT: ${{ github.event.workflow_run.event || github.event_name }} - REF: ${{ github.event.workflow_run.head_branch || github.ref_name }} - SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - MANUAL_TAG: ${{ inputs.image_tag }} + - name: Build Docker image (Local) run: | - # Manual trigger uses provided tag - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - if [[ -n "$MANUAL_TAG" ]]; then - TAG_VALUE="$MANUAL_TAG" - else - # Default to latest if no tag provided - TAG_VALUE="latest" - fi - { - echo "tag=${TAG_VALUE}" - echo "source_type=manual" - } >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Extract 7-character short SHA - SHORT_SHA=$(echo "$SHA" | cut -c1-7) - - # Use native pull_requests array (no API calls needed) - PR_NUM=$(echo '${{ toJson(github.event.workflow_run.pull_requests) }}' | jq -r '.[0].number // empty') - - if [[ "$EVENT" == "pull_request" || -n "$PR_NUM" ]]; then - - # Fallback for direct PR trigger - if [[ -z "$PR_NUM" || "$PR_NUM" == "null" ]]; then - PR_NUM="${{ github.event.number }}" - fi - - 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}" - echo "source_type=pr" - } >> "$GITHUB_OUTPUT" - else - # Non-PR workflow_run uses short SHA tag (matches docker-build.yml) - { - echo "tag=sha-${SHORT_SHA}" - echo "source_type=sha" - } >> "$GITHUB_OUTPUT" - fi - - echo "sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "Determined image tag: $(grep tag= "$GITHUB_OUTPUT")" - - # Pull image from Docker Hub with retry logic - - 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="docker.io/wikid82/charon:${{ steps.determine-tag.outputs.tag }}" - echo "Pulling image: $IMAGE_NAME" - docker pull "$IMAGE_NAME" - docker tag "$IMAGE_NAME" charon:local - echo "✅ Successfully pulled from registry" - - # Validate image freshness by checking SHA label - - name: Validate image SHA - env: - SHA: ${{ steps.determine-tag.outputs.sha }} - run: | - LABEL_SHA=$(docker inspect charon:local --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' | cut -c1-7) - echo "Expected SHA: $SHA" - echo "Image SHA: $LABEL_SHA" - - if [[ "$LABEL_SHA" != "$SHA" ]]; then - echo "⚠️ WARNING: Image SHA mismatch!" - echo "Image may be stale. Proceeding with caution..." - else - echo "✅ Image SHA matches expected commit" - fi + echo "Building image locally for integration tests..." + docker build -t charon:local . + echo "✅ Successfully built charon:local" - name: Run CrowdSec integration tests id: crowdsec-test diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index c9d17b60..4d1a1cc0 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -21,6 +21,14 @@ name: Docker Build, Publish & Test # See: docs/plans/current_spec.md (Section 4.1 - docker-build.yml changes) on: + push: + branches: + - main + - development + pull_request: + branches: + - main + - development workflow_dispatch: concurrency: diff --git a/.github/workflows/e2e-tests-split.yml b/.github/workflows/e2e-tests-split.yml index ec0e0607..a342d776 100644 --- a/.github/workflows/e2e-tests-split.yml +++ b/.github/workflows/e2e-tests-split.yml @@ -79,6 +79,10 @@ on: required: false default: false type: boolean + pull_request: + branches: + - main + - development env: NODE_VERSION: '20' @@ -86,6 +90,8 @@ env: 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' @@ -205,8 +211,8 @@ jobs: runs-on: ubuntu-latest needs: build if: | - (inputs.browser == 'chromium' || inputs.browser == 'all') && - (inputs.test_category == 'security' || inputs.test_category == 'all') + ((inputs.browser || 'all') == 'chromium' || (inputs.browser || 'all') == 'all') && + ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 30 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} @@ -344,7 +350,7 @@ jobs: retention-days: 14 - name: Upload Chromium Security coverage (if enabled) - if: always() && env.PLAYWRIGHT_COVERAGE == '1' + if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: e2e-coverage-chromium-security @@ -381,8 +387,8 @@ jobs: runs-on: ubuntu-latest needs: build if: | - (inputs.browser == 'firefox' || inputs.browser == 'all') && - (inputs.test_category == 'security' || inputs.test_category == 'all') + ((inputs.browser || 'all') == 'firefox' || (inputs.browser || 'all') == 'all') && + ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 30 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} @@ -528,7 +534,7 @@ jobs: retention-days: 14 - name: Upload Firefox Security coverage (if enabled) - if: always() && env.PLAYWRIGHT_COVERAGE == '1' + if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: e2e-coverage-firefox-security @@ -565,8 +571,8 @@ jobs: runs-on: ubuntu-latest needs: build if: | - (inputs.browser == 'webkit' || inputs.browser == 'all') && - (inputs.test_category == 'security' || inputs.test_category == 'all') + ((inputs.browser || 'all') == 'webkit' || (inputs.browser || 'all') == 'all') && + ((inputs.test_category || 'all') == 'security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 30 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} @@ -712,7 +718,7 @@ jobs: retention-days: 14 - name: Upload WebKit Security coverage (if enabled) - if: always() && env.PLAYWRIGHT_COVERAGE == '1' + if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: e2e-coverage-webkit-security @@ -756,8 +762,8 @@ jobs: runs-on: ubuntu-latest needs: build if: | - (inputs.browser == 'chromium' || inputs.browser == 'all') && - (inputs.test_category == 'non-security' || inputs.test_category == 'all') + ((inputs.browser || 'all') == 'chromium' || (inputs.browser || 'all') == 'all') && + ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 20 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} @@ -892,7 +898,7 @@ jobs: retention-days: 14 - name: Upload Chromium coverage (if enabled) - if: always() && env.PLAYWRIGHT_COVERAGE == '1' + if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: e2e-coverage-chromium-shard-${{ matrix.shard }} @@ -929,8 +935,8 @@ jobs: runs-on: ubuntu-latest needs: build if: | - (inputs.browser == 'firefox' || inputs.browser == 'all') && - (inputs.test_category == 'non-security' || inputs.test_category == 'all') + ((inputs.browser || 'all') == 'firefox' || (inputs.browser || 'all') == 'all') && + ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 20 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} @@ -1073,7 +1079,7 @@ jobs: retention-days: 14 - name: Upload Firefox coverage (if enabled) - if: always() && env.PLAYWRIGHT_COVERAGE == '1' + if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 with: name: e2e-coverage-firefox-shard-${{ matrix.shard }} @@ -1110,8 +1116,8 @@ jobs: runs-on: ubuntu-latest needs: build if: | - (inputs.browser == 'webkit' || inputs.browser == 'all') && - (inputs.test_category == 'non-security' || inputs.test_category == 'all') + ((inputs.browser || 'all') == 'webkit' || (inputs.browser || 'all') == 'all') && + ((inputs.test_category || 'all') == 'non-security' || (inputs.test_category || 'all') == 'all') timeout-minutes: 20 env: CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }} @@ -1254,7 +1260,7 @@ jobs: retention-days: 14 - name: Upload WebKit coverage (if enabled) - if: always() && env.PLAYWRIGHT_COVERAGE == '1' + if: always() && (inputs.playwright_coverage == 'true' || vars.PLAYWRIGHT_COVERAGE == '1') uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: e2e-coverage-webkit-shard-${{ matrix.shard }} diff --git a/.github/workflows/rate-limit-integration.yml b/.github/workflows/rate-limit-integration.yml index 1b093db2..805b45c2 100644 --- a/.github/workflows/rate-limit-integration.yml +++ b/.github/workflows/rate-limit-integration.yml @@ -9,6 +9,7 @@ on: description: 'Docker image tag to test (e.g., pr-123-abc1234, latest)' required: false type: string + pull_request: # Prevent race conditions when PR is updated mid-test # Cancels old test runs when new build completes with different SHA @@ -21,105 +22,14 @@ jobs: name: Rate Limiting Integration runs-on: ubuntu-latest timeout-minutes: 15 - # Only run if docker-build.yml succeeded, or if manually triggered - if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && (github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success')) }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - # Determine the correct image tag based on trigger context - # For PRs: pr-{number}-{short-sha}, For non-PR: sha-{short-sha} - - name: Determine image tag - id: determine-tag - env: - EVENT: ${{ github.event.workflow_run.event || github.event_name }} - REF: ${{ github.event.workflow_run.head_branch || github.ref_name }} - SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - MANUAL_TAG: ${{ inputs.image_tag }} + - name: Build Docker image (Local) run: | - # Manual trigger uses provided tag - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - if [[ -n "$MANUAL_TAG" ]]; then - TAG_VALUE="$MANUAL_TAG" - else - # Default to latest if no tag provided - TAG_VALUE="latest" - fi - { - echo "tag=${TAG_VALUE}" - echo "source_type=manual" - } >> "$GITHUB_OUTPUT" - exit 0 - fi - - # Extract 7-character short SHA - SHORT_SHA=$(echo "$SHA" | cut -c1-7) - - # Use native pull_requests array (no API calls needed) - PR_NUM=$(echo '${{ toJson(github.event.workflow_run.pull_requests) }}' | jq -r '.[0].number // empty') - - if [[ "$EVENT" == "pull_request" || -n "$PR_NUM" ]]; then - - # Fallback for direct PR trigger - if [[ -z "$PR_NUM" || "$PR_NUM" == "null" ]]; then - PR_NUM="${{ github.event.number }}" - fi - - 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}" - echo "source_type=pr" - } >> "$GITHUB_OUTPUT" - else - # Non-PR workflow_run uses short SHA tag (matches docker-build.yml) - { - echo "tag=sha-${SHORT_SHA}" - echo "source_type=sha" - } >> "$GITHUB_OUTPUT" - fi - - echo "sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "Determined image tag: $(grep tag= "$GITHUB_OUTPUT")" - - # Pull image from Docker Hub with retry logic - - 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="docker.io/wikid82/charon:${{ steps.determine-tag.outputs.tag }}" - echo "Pulling image: $IMAGE_NAME" - docker pull "$IMAGE_NAME" - docker tag "$IMAGE_NAME" charon:local - echo "✅ Successfully pulled from registry" - - # Validate image freshness by checking SHA label - - name: Validate image SHA - env: - SHA: ${{ steps.determine-tag.outputs.sha }} - run: | - LABEL_SHA=$(docker inspect charon:local --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' | cut -c1-7) - echo "Expected SHA: $SHA" - echo "Image SHA: $LABEL_SHA" - - if [[ "$LABEL_SHA" != "$SHA" ]]; then - echo "⚠️ WARNING: Image SHA mismatch!" - echo "Image may be stale. Proceeding with caution..." - else - echo "✅ Image SHA matches expected commit" - fi + echo "Building image locally for integration tests..." + docker build -t charon:local . + echo "✅ Successfully built charon:local" - name: Run rate limit integration tests id: ratelimit-test diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index b0732348..de94c39f 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -10,6 +10,7 @@ on: description: 'PR number to scan (optional)' required: false type: string + pull_request: concurrency: group: security-pr-${{ github.event.workflow_run.event || github.event_name }}-${{ github.event.workflow_run.head_branch || github.ref }} @@ -23,6 +24,7 @@ jobs: # Run for: manual dispatch, PR builds, or any push builds from docker-build if: >- github.event_name == 'workflow_dispatch' || + github.event_name == 'pull_request' || ((github.event.workflow_run.event == 'push' || github.event.workflow_run.pull_requests[0].number != null) && (github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success')) diff --git a/.github/workflows/supply-chain-pr.yml b/.github/workflows/supply-chain-pr.yml index e3d94dc1..06aac33e 100644 --- a/.github/workflows/supply-chain-pr.yml +++ b/.github/workflows/supply-chain-pr.yml @@ -9,6 +9,7 @@ on: description: "PR number to verify (optional, will auto-detect from workflow_run)" required: false type: string + pull_request: concurrency: group: supply-chain-pr-${{ github.event.workflow_run.event || github.event_name }}-${{ github.event.workflow_run.head_branch || github.ref }} @@ -28,6 +29,7 @@ jobs: # Run for: manual dispatch, or successful workflow_run triggered by push/PR if: > github.event_name == 'workflow_dispatch' || + github.event_name == 'pull_request' || (github.event_name == 'workflow_run' && (github.event.workflow_run.event == 'push' || github.event.workflow_run.pull_requests[0].number != null) && (github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success')) diff --git a/.github/workflows/waf-integration.yml b/.github/workflows/waf-integration.yml index 3d862d50..ee180253 100644 --- a/.github/workflows/waf-integration.yml +++ b/.github/workflows/waf-integration.yml @@ -9,6 +9,7 @@ on: description: 'Docker image tag to test (e.g., pr-123-abc1234, latest)' required: false type: string + pull_request: # Prevent race conditions when PR is updated mid-test # Cancels old test runs when new build completes with different SHA @@ -21,104 +22,14 @@ jobs: name: Coraza WAF Integration runs-on: ubuntu-latest timeout-minutes: 15 - # Only run if docker-build.yml succeeded, or if manually triggered - if: ${{ github.event_name == 'workflow_dispatch' || (github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success') }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - # Determine the correct image tag based on trigger context - # For PRs: pr-{number}-{short-sha}, For non-PR: sha-{short-sha} - - name: Determine image tag - id: determine-tag - env: - EVENT: ${{ github.event.workflow_run.event || github.event_name }} - REF: ${{ github.event.workflow_run.head_branch || github.ref_name }} - SHA: ${{ github.event.workflow_run.head_sha || github.sha }} - MANUAL_TAG: ${{ inputs.image_tag }} + - name: Build Docker image (Local) run: | - # Manual trigger uses provided tag - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - if [[ -n "$MANUAL_TAG" ]]; then - TAG_VALUE="$MANUAL_TAG" - else - # Default to latest if no tag provided - TAG_VALUE="latest" - fi - { - echo "tag=${TAG_VALUE}" - 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') - - # Fallback for direct PR trigger - if [[ -z "$PR_NUM" || "$PR_NUM" == "null" ]]; then - PR_NUM="${{ github.event.number }}" - fi - - 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}" - echo "source_type=pr" - } >> "$GITHUB_OUTPUT" - else - # Non-PR workflow_run uses short SHA tag (matches docker-build.yml) - { - echo "tag=sha-${SHORT_SHA}" - echo "source_type=sha" - } >> "$GITHUB_OUTPUT" - fi - - echo "sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" - echo "Determined image tag: $(grep tag= "$GITHUB_OUTPUT")" - - # Pull image from Docker Hub with retry logic - - 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="docker.io/wikid82/charon:${{ steps.determine-tag.outputs.tag }}" - echo "Pulling image: $IMAGE_NAME" - docker pull "$IMAGE_NAME" - docker tag "$IMAGE_NAME" charon:local - echo "✅ Successfully pulled from registry" - - # Validate image freshness by checking SHA label - - name: Validate image SHA - env: - SHA: ${{ steps.determine-tag.outputs.sha }} - run: | - LABEL_SHA=$(docker inspect charon:local --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' | cut -c1-7) - echo "Expected SHA: $SHA" - echo "Image SHA: $LABEL_SHA" - - if [[ "$LABEL_SHA" != "$SHA" ]]; then - echo "⚠️ WARNING: Image SHA mismatch!" - echo "Image may be stale. Proceeding with caution..." - else - echo "✅ Image SHA matches expected commit" - fi + echo "Building image locally for integration tests..." + docker build -t charon:local . + echo "✅ Successfully built charon:local" - name: Run WAF integration tests id: waf-test diff --git a/docs/plans/revert_ci_pipeline.md b/docs/plans/revert_ci_pipeline.md new file mode 100644 index 00000000..33e4a3d1 --- /dev/null +++ b/docs/plans/revert_ci_pipeline.md @@ -0,0 +1,237 @@ +--- +title: "Revert CI Pipeline Consolidation" +status: "draft" +scope: "ci/workflows, integration, e2e, security" +notes: Restore per-workflow pull_request triggers, retire ci-pipeline.yml, and reestablish self-contained image builds. +--- + +## 1. Introduction + +This plan dismantles the consolidated CI pipeline and restores individual +pull_request triggers for component workflows. The goal is to return to a +simple, independent workflow model where each integration or test workflow +runs on PRs without relying on a central pipeline or shared image artifacts. + +Objectives: + +- Identify workflows that had pull_request triggers removed or were merged + into ci-pipeline.yml. +- Restore per-workflow pull_request triggers for integration, E2E, and + build workflows. +- Delete ci-pipeline.yml as the required path to retire the consolidated + pipeline. +- Ensure each workflow is self-contained for image availability. + +## 2. Research Findings + +### 2.1 Current Consolidated Pipeline + +- [.github/workflows/ci-pipeline.yml](.github/workflows/ci-pipeline.yml) + runs on pull_request and bundles lint, image build, integration tests, + E2E, coverage, CodeQL, Trivy, supply-chain scans, and gates. +- The pipeline builds and uploads an image artifact for integration and + uses e2e-tests-split.yml via workflow_call. + +### 2.2 Integration Workflows (Current State) + +- [.github/workflows/cerberus-integration.yml](.github/workflows/cerberus-integration.yml): workflow_dispatch only. +- [.github/workflows/crowdsec-integration.yml](.github/workflows/crowdsec-integration.yml): workflow_dispatch only. +- [.github/workflows/waf-integration.yml](.github/workflows/waf-integration.yml): workflow_dispatch only. +- [.github/workflows/rate-limit-integration.yml](.github/workflows/rate-limit-integration.yml): workflow_dispatch only. +- Each workflow currently pulls a registry image and tags it as + charon:local. There is no pull_request trigger and no local build step. + +### 2.3 E2E Workflows (Current State) + +- [.github/workflows/e2e-tests-split.yml](.github/workflows/e2e-tests-split.yml): workflow_call + + workflow_dispatch only. No pull_request trigger. +- The build job can build an image locally when invoked directly, but + the file is currently only invoked by ci-pipeline.yml. + +### 2.4 Build Workflow (Current State) + +- [.github/workflows/docker-build.yml](.github/workflows/docker-build.yml): workflow_dispatch only. +- This workflow is designed to be the main build pipeline but is not + currently triggered by pull_request. + +### 2.5 Security Workflows (Current State) + +- [.github/workflows/security-pr.yml](.github/workflows/security-pr.yml): workflow_dispatch only. +- [.github/workflows/supply-chain-pr.yml](.github/workflows/supply-chain-pr.yml): workflow_dispatch only. +- [.github/workflows/codeql.yml](.github/workflows/codeql.yml): schedule + workflow_dispatch only. +- These workflows include logic for push and pull_request contexts but + their triggers do not include pull_request. + +### 2.6 Historical Reference + +- [.github/workflows/e2e-tests.yml.backup](.github/workflows/e2e-tests.yml.backup) and + [.github/workflows/e2e-tests-split.yml.backup](.github/workflows/e2e-tests-split.yml.backup) show prior pull_request + trigger patterns and path filters that can be restored. + +## 3. Technical Specifications + +### 3.1 Workflow Inventory and Trigger Restoration + +Target workflows to restore pull_request triggers: + +- [.github/workflows/docker-build.yml](.github/workflows/docker-build.yml) +- [.github/workflows/cerberus-integration.yml](.github/workflows/cerberus-integration.yml) +- [.github/workflows/crowdsec-integration.yml](.github/workflows/crowdsec-integration.yml) +- [.github/workflows/waf-integration.yml](.github/workflows/waf-integration.yml) +- [.github/workflows/rate-limit-integration.yml](.github/workflows/rate-limit-integration.yml) +- [.github/workflows/e2e-tests-split.yml](.github/workflows/e2e-tests-split.yml) +- [.github/workflows/security-pr.yml](.github/workflows/security-pr.yml) +- [.github/workflows/supply-chain-pr.yml](.github/workflows/supply-chain-pr.yml) +- [.github/workflows/codeql.yml](.github/workflows/codeql.yml) (decision point) + +Notes: + +- e2e-tests-split.yml should run directly on pull_request with the + internal build job enabled, not only via workflow_call. +- security-pr.yml and supply-chain-pr.yml must include pull_request + triggers so security coverage is not lost. +- codeql.yml needs a decision: re-enable pull_request in codeql.yml or + leave CodeQL in a separate PR workflow. The consolidated pipeline is + currently the only PR CodeQL path. + +### 3.2 ci-pipeline.yml Decommission Strategy + +Decision: + +- Option A (required): delete ci-pipeline.yml to fully end the + consolidated pipeline and avoid duplicate PR checks. + +### 3.3 Image Availability Strategy (Critical Challenge) + +Independent PR workflows cannot rely on a shared image from another +workflow unless using artifacts or a registry. The user wants to avoid +pipeline complexity. + +Required behavior for each integration workflow: + +- Restore the "Build Docker image (Local)" step in each integration + workflow, reverting any artifact handover dependency. +- Build a local Docker image within the workflow before tests run. +- Tag the image as charon:local for consistency with existing scripts. +- Avoid external registry dependency for PR builds. + +Impacted workflows: + +- cerberus-integration.yml +- crowdsec-integration.yml +- waf-integration.yml +- rate-limit-integration.yml + +E2E workflows: + +- e2e-tests-split.yml already supports building an image locally when + invoked directly. Ensure pull_request triggers route through this path + (not workflow_call). + +### 3.4 Pull Request Trigger Scope and Path Filters + +- Use branch filters consistent with prior backups and docker-build.yml + usage: main, development, feature/**, hotfix/**. +- Apply path filters for E2E to avoid unnecessary runs: + frontend/**, backend/**, tests/**, playwright.config.js, + .github/workflows/e2e-tests-split.yml. +- Integration workflows typically run on any backend/frontend changes. + Consider adding path filters if desired, but default to full PR runs + for parity with previous behavior. + +### 3.5 Dependency and Concurrency Rules + +- Remove workflow_run coupling to docker-build.yml for integration and + E2E workflows. Each workflow should be independently triggered by + pull_request. +- Keep job-level concurrency where it prevents duplicate runs on the + same PR, but avoid cross-workflow dependencies. + +## 4. Implementation Plan + +### Phase 1: Baseline Verification (Tests) + +- Confirm current CI behavior for PRs: identify which checks are now + only running via ci-pipeline.yml. +- Capture baseline PR check set from GitHub Actions UI for comparison + after restoration. + +### Phase 2: Restore PR Triggers (Core Workflows) + +- Add pull_request triggers to docker-build.yml with branches including + main and development. +- Add pull_request triggers to cerberus-integration.yml, + crowdsec-integration.yml, waf-integration.yml, and + rate-limit-integration.yml. +- Add pull_request triggers to e2e-tests-split.yml, using the backup + trigger block as the source of truth. + +### Phase 3: Make Integration Workflows Self-Contained + +- Restore the "Build Docker image (Local)" step in each integration + workflow and remove dependency on ci-pipeline.yml artifacts. +- Remove registry pull steps or make them optional for manual runs. +- Ensure test scripts continue to reference charon:local. + +### Phase 4: Security Workflow Triggers + +- Add pull_request triggers to security-pr.yml and supply-chain-pr.yml + as a mandatory requirement to preserve PR security coverage. +- Decide on CodeQL: either add pull_request to codeql.yml or create a + dedicated PR CodeQL workflow. If the pipeline is deleted, CodeQL must + have an alternative PR trigger. + +### Phase 5: Decommission ci-pipeline.yml + +- Delete ci-pipeline.yml. + +### Phase 6: Validation and Audit + +- Verify that PRs show the restored individual checks instead of a + single pipeline job. +- Confirm each integration workflow completes without relying on + registry or artifact inputs and includes the restored local build step. +- Validate E2E workflow runs directly on pull_request with build job + executed locally. +- Confirm security workflows run on pull_request. + +## 5. Acceptance Criteria (EARS) + +- WHEN a pull_request is opened or updated, THE SYSTEM SHALL trigger + docker-build.yml directly on pull_request for main and development. +- WHEN a pull_request is opened or updated, THE SYSTEM SHALL trigger + cerberus-integration.yml, crowdsec-integration.yml, waf-integration.yml, + and rate-limit-integration.yml on pull_request. +- WHEN an integration workflow runs on pull_request, THE SYSTEM SHALL + restore and run the "Build Docker image (Local)" step, build a local + Docker image, and tag it as charon:local before tests. +- WHEN a pull_request is opened or updated, THE SYSTEM SHALL trigger + e2e-tests-split.yml directly on pull_request without relying on + ci-pipeline.yml. +- WHEN the consolidated pipeline is retired, THE SYSTEM SHALL NOT run + ci-pipeline.yml on pull_request. +- WHEN a pull_request is opened or updated, THE SYSTEM SHALL run + security-pr.yml and supply-chain-pr.yml on pull_request. +- WHEN CodeQL is required for pull_request, THE SYSTEM SHALL run a + CodeQL workflow on pull_request independent of ci-pipeline.yml. + +## 6. Risks and Mitigations + +- Risk: PR checks increase in parallel count and runtime. + Mitigation: use path filters for E2E and consider optional filters + for integration workflows. +- Risk: Image build duplication increases CI cost. + Mitigation: keep builds scoped to workflows that need the image, and + avoid registry pushes for PR builds. +- Risk: Security scans or CodeQL no longer run on PR if triggers are + not restored. + Mitigation: explicitly re-enable PR triggers in security workflows + or add a dedicated PR security workflow. + +## 7. Confidence Score + +Confidence: 82 percent + +Rationale: The workflow inventory and trigger gaps are clear. The main +uncertainty is selecting the final CodeQL and security trigger model +once ci-pipeline.yml is removed.