Files
Charon/.github/workflows/ci-pipeline.yml

1014 lines
37 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
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.image-policy.outputs.push == 'true' && steps.push.outcome == 'success' }}
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)
DEFAULT_TAG="sha-${SHORT_SHA}"
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
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
if [ -z "$DEFAULT_TAG" ]; then
echo "::error::DEFAULT_TAG is empty!"
exit 1
fi
sanitize_tag() {
local raw="$1"
local max_len="$2"
local sanitized
sanitized=$(echo "$raw" | sed -E 's/[^A-Za-z0-9_.-]/-/g')
sanitized=$(echo "$sanitized" | sed -E 's/-+/-/g')
sanitized=$(echo "$sanitized" | sed -E 's/^[.-]+//')
sanitized=$(echo "$sanitized" | cut -c1-"$max_len")
if [ -z "$sanitized" ]; then
sanitized="sha-${SHORT_SHA}"
fi
echo "$sanitized"
}
DEFAULT_TAG=$(sanitize_tag "${DEFAULT_TAG}" 128)
SANITIZED_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 128)
SANITIZED_SHORT_SHA=$(sanitize_tag "${SHORT_SHA}" 7)
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_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<<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: 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 }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
- 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: 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.outputs.run_integration == 'true' }}
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.outputs.run_integration == 'true' }}
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.outputs.run_integration == 'true' }}
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
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