Integration, E2E, and security jobs were being skipped on PR builds because they required push_image == 'true'. Since the build succeeded and images were available, these jobs should run regardless of push policy. Changed conditions to depend on build success and image availability rather than registry push status. This allows comprehensive testing on all builds while still optimizing resource usage where needed.
773 lines
27 KiB
YAML
773 lines
27 KiB
YAML
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
|
|
|
|
build-image:
|
|
name: Build and Publish Image
|
|
runs-on: ubuntu-latest
|
|
needs: lint
|
|
concurrency:
|
|
group: ci-build-image-${{ github.workflow }}-${{ github.ref_name }}
|
|
cancel-in-progress: true
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
outputs:
|
|
image_digest: ${{ steps.build.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 }}
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- name: Normalize image name
|
|
run: |
|
|
IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
|
|
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: |
|
|
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
|
DEFAULT_TAG="sha-${SHORT_SHA}"
|
|
BRANCH_NAME="${{ github.ref_name }}"
|
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
BRANCH_NAME="${PR_HEAD_REF}"
|
|
fi
|
|
if [ -n "${{ inputs.image_tag_override }}" ]; then
|
|
DEFAULT_TAG="${{ inputs.image_tag_override }}"
|
|
elif [ "${{ github.event_name }}" = "pull_request" ]; then
|
|
PR_NUMBER="${{ github.event.pull_request.number }}"
|
|
if [ -n "${PR_NUMBER}" ]; then
|
|
DEFAULT_TAG="pr-${PR_NUMBER}-${SHORT_SHA}"
|
|
fi
|
|
fi
|
|
|
|
sanitize_tag() {
|
|
local raw="$1"
|
|
local max_len="$2"
|
|
|
|
local sanitized
|
|
sanitized=$(echo "$raw" | tr '[:upper:]' '[:lower:]')
|
|
sanitized=${sanitized//[^a-z0-9-]/-}
|
|
while [[ "$sanitized" == *"--"* ]]; do
|
|
sanitized=${sanitized//--/-}
|
|
done
|
|
sanitized=${sanitized##[^a-z0-9]*}
|
|
sanitized=${sanitized%%[^a-z0-9-]*}
|
|
|
|
if [ -z "$sanitized" ]; then
|
|
sanitized="branch"
|
|
fi
|
|
|
|
sanitized=$(echo "$sanitized" | cut -c1-"$max_len")
|
|
|
|
sanitized=${sanitized##[^a-z0-9]*}
|
|
if [ -z "$sanitized" ]; then
|
|
sanitized="branch"
|
|
fi
|
|
|
|
echo "$sanitized"
|
|
}
|
|
|
|
SANITIZED_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 128)
|
|
BRANCH_TAG="${SANITIZED_BRANCH}"
|
|
BRANCH_SHA_TAG="${SANITIZED_BRANCH}-$(sanitize_tag "${SHORT_SHA}" 7)"
|
|
if [ "${#SANITIZED_BRANCH}" -gt 120 ]; then
|
|
SANITIZED_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 120)
|
|
BRANCH_SHA_TAG="${SANITIZED_BRANCH}-${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 }}:${SHORT_SHA}")
|
|
TAGS+=("${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}:${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
|
|
|
|
{
|
|
echo "tags<<EOF"
|
|
printf '%s\n' "${TAGS[@]}"
|
|
echo "EOF"
|
|
} >> "$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: Build and push Docker image
|
|
id: build
|
|
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 }}
|
|
labels: |
|
|
org.opencontainers.image.revision=${{ github.sha }}
|
|
|
|
- name: Emit image outputs
|
|
id: outputs
|
|
run: |
|
|
DIGEST="${{ steps.build.outputs.digest }}"
|
|
if [ -z "${DIGEST}" ]; then
|
|
echo "image_ref_dockerhub=" >> "$GITHUB_OUTPUT"
|
|
echo "image_ref_ghcr=" >> "$GITHUB_OUTPUT"
|
|
else
|
|
IMAGE_REF_DOCKERHUB="${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}"
|
|
IMAGE_REF_GHCR="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}"
|
|
echo "image_ref_dockerhub=${IMAGE_REF_DOCKERHUB}" >> "$GITHUB_OUTPUT"
|
|
echo "image_ref_ghcr=${IMAGE_REF_GHCR}" >> "$GITHUB_OUTPUT"
|
|
echo "::add-mask::${IMAGE_REF_DOCKERHUB}"
|
|
echo "::add-mask::${IMAGE_REF_GHCR}"
|
|
fi
|
|
echo "image_tag=${{ steps.tags.outputs.image_tag }}" >> "$GITHUB_OUTPUT"
|
|
|
|
integration-cerberus:
|
|
name: Integration - Cerberus
|
|
runs-on: ubuntu-latest
|
|
needs: build-image
|
|
if: needs.build-image.result == 'success' && needs.build-image.outputs.image_ref_dockerhub != '' && (github.event_name != 'workflow_dispatch' || inputs.run_integration != false)
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- 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: Pull shared image
|
|
run: |
|
|
docker pull "${{ needs.build-image.outputs.image_ref_dockerhub }}"
|
|
docker tag "${{ needs.build-image.outputs.image_ref_dockerhub }}" 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.result == 'success' && needs.build-image.outputs.image_ref_dockerhub != '' && (github.event_name != 'workflow_dispatch' || inputs.run_integration != false)
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- 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: Pull shared image
|
|
run: |
|
|
docker pull "${{ needs.build-image.outputs.image_ref_dockerhub }}"
|
|
docker tag "${{ needs.build-image.outputs.image_ref_dockerhub }}" 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.result == 'success' && needs.build-image.outputs.image_ref_dockerhub != '' && (github.event_name != 'workflow_dispatch' || inputs.run_integration != false)
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- 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: Pull shared image
|
|
run: |
|
|
docker pull "${{ needs.build-image.outputs.image_ref_dockerhub }}"
|
|
docker tag "${{ needs.build-image.outputs.image_ref_dockerhub }}" 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.result == 'success' && needs.build-image.outputs.image_ref_dockerhub != '' && (github.event_name != 'workflow_dispatch' || inputs.run_integration != false)
|
|
steps:
|
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
|
|
|
- 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: Pull shared image
|
|
run: |
|
|
docker pull "${{ needs.build-image.outputs.image_ref_dockerhub }}"
|
|
docker tag "${{ needs.build-image.outputs.image_ref_dockerhub }}" 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
|
|
steps:
|
|
- name: Evaluate integration results
|
|
run: |
|
|
if [ "${{ inputs.run_integration }}" = "false" ]; then
|
|
echo "Integration stage skipped."
|
|
exit 0
|
|
fi
|
|
|
|
if [ "${{ needs.build-image.result }}" != "success" ] || [ "${{ needs.build-image.outputs.push_image }}" != "true" ]; then
|
|
echo "Integration stage skipped due to build-image state or push policy."
|
|
exit 0
|
|
fi
|
|
|
|
RESULTS=(
|
|
"${{ needs.integration-cerberus.result }}"
|
|
"${{ needs.integration-crowdsec.result }}"
|
|
"${{ needs.integration-waf.result }}"
|
|
"${{ needs.integration-ratelimit.result }}"
|
|
)
|
|
|
|
for RESULT in "${RESULTS[@]}"; do
|
|
if [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then
|
|
echo "Integration stage failed: $RESULT"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
e2e:
|
|
name: E2E Tests with Coverage
|
|
needs:
|
|
- build-image
|
|
- integration-gate
|
|
if: (github.event_name != 'workflow_dispatch' || inputs.run_e2e != false) && needs.build-image.result == 'success'
|
|
uses: ./.github/workflows/e2e-tests-split.yml
|
|
with:
|
|
browser: all
|
|
test_category: all
|
|
image_ref: ${{ needs.build-image.outputs.image_ref_dockerhub }}
|
|
image_tag: charon:e2e-test
|
|
playwright_coverage: true
|
|
secrets: inherit
|
|
|
|
coverage-backend:
|
|
name: Coverage - Backend
|
|
runs-on: ubuntu-latest
|
|
needs:
|
|
- build-image
|
|
- integration-gate
|
|
if: github.event_name != 'workflow_dispatch' || inputs.run_coverage != false
|
|
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:
|
|
- build-image
|
|
- integration-gate
|
|
if: github.event_name != 'workflow_dispatch' || inputs.run_coverage != false
|
|
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
|
|
- e2e
|
|
steps:
|
|
- name: Evaluate coverage results
|
|
run: |
|
|
if [ "${{ inputs.run_coverage }}" = "false" ]; then
|
|
echo "Coverage stage skipped."
|
|
exit 0
|
|
fi
|
|
|
|
RESULTS=(
|
|
"${{ needs.coverage-backend.result }}"
|
|
"${{ needs.coverage-frontend.result }}"
|
|
"${{ needs.e2e.result }}"
|
|
)
|
|
|
|
for RESULT in "${RESULTS[@]}"; do
|
|
if [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then
|
|
echo "Coverage stage failed: $RESULT"
|
|
exit 1
|
|
fi
|
|
done
|
|
|
|
codecov-upload:
|
|
name: Codecov Upload
|
|
runs-on: ubuntu-latest
|
|
needs:
|
|
- coverage-gate
|
|
if: github.event_name != 'workflow_dispatch' || inputs.run_coverage != false
|
|
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
|
|
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
|
|
steps:
|
|
- name: Evaluate Codecov upload results
|
|
run: |
|
|
if [ "${{ inputs.run_coverage }}" = "false" ]; then
|
|
echo "Codecov upload stage skipped."
|
|
exit 0
|
|
fi
|
|
|
|
if [ "${{ needs.codecov-upload.result }}" = "failure" ] || [ "${{ needs.codecov-upload.result }}" = "cancelled" ]; then
|
|
echo "Codecov upload failed: ${{ needs.codecov-upload.result }}"
|
|
exit 1
|
|
fi
|
|
|
|
security-codeql:
|
|
name: Security - CodeQL
|
|
runs-on: ubuntu-latest
|
|
needs:
|
|
- codecov-gate
|
|
if: (github.event_name != 'workflow_dispatch' || inputs.run_security_scans != false) && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork != true)
|
|
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
|
|
- codecov-gate
|
|
if: (github.event_name != 'workflow_dispatch' || inputs.run_security_scans != false) && needs.build-image.result == 'success'
|
|
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
|
|
- codecov-gate
|
|
if: (github.event_name != 'workflow_dispatch' || inputs.run_security_scans != false) && needs.build-image.result == 'success'
|
|
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
|
|
|
|
pipeline-gate:
|
|
name: Pipeline Gate
|
|
runs-on: ubuntu-latest
|
|
needs:
|
|
- lint
|
|
- build-image
|
|
- integration-gate
|
|
- coverage-gate
|
|
- codecov-gate
|
|
- security-codeql
|
|
- security-trivy
|
|
- security-supply-chain
|
|
steps:
|
|
- name: Evaluate pipeline results
|
|
run: |
|
|
RESULTS=(
|
|
"${{ needs.lint.result }}"
|
|
"${{ needs.build-image.result }}"
|
|
"${{ needs.integration-gate.result }}"
|
|
"${{ needs.coverage-gate.result }}"
|
|
"${{ needs.codecov-gate.result }}"
|
|
"${{ needs.security-codeql.result }}"
|
|
"${{ needs.security-trivy.result }}"
|
|
"${{ needs.security-supply-chain.result }}"
|
|
)
|
|
|
|
for RESULT in "${RESULTS[@]}"; do
|
|
if [ "$RESULT" = "failure" ] || [ "$RESULT" = "cancelled" ]; then
|
|
echo "Pipeline failed: $RESULT"
|
|
exit 1
|
|
fi
|
|
done
|