Skip to content

Sentry: bump getsentry/uptime-checker from 26.5.0 to 26.5.1 in /servi… #121

Sentry: bump getsentry/uptime-checker from 26.5.0 to 26.5.1 in /servi…

Sentry: bump getsentry/uptime-checker from 26.5.0 to 26.5.1 in /servi… #121

Workflow file for this run

# BeeCompose CI/CD Pipeline
# Docker Compose Testing with Parallel CVE Scanning and Service Testing
#
# This workflow validates all Docker Compose configurations and scans images for vulnerabilities.
# Both CVE scanning and service testing run in parallel (matrix strategy) for faster pipeline execution.
#
# Pipeline stages:
# 1. Lint - Docker Compose linting with DCLint (syntax + best practices)
# 2. Validate OCI - Check OCI compatibility for artifact publishing
# 3. Discover - Find all services and validate basic syntax
# 4. Extract Images - Collect Docker images for CVE scanning
# 5. CVE Scan - Security vulnerability scanning with Trivy (parallel per image)
# 6. CVE Summary - Aggregate scan results
# 7. Test Services - Parallel Docker Compose testing (max 8 concurrent)
# 8. Test Summary - Aggregate test results
# 9. Summary - Final pipeline results
name: CI/CD Pipeline
on:
push:
branches: [main, master, develop]
paths:
- 'services/**'
- 'scripts/**'
- '.github/workflows/**'
- '.github/scripts/**'
- '.dclintrc.yaml'
pull_request:
branches: [main, master]
paths:
- 'services/**'
- 'scripts/**'
- '.github/workflows/**'
- '.github/scripts/**'
- '.dclintrc.yaml'
workflow_dispatch:
inputs:
skip_cve_scan:
description: 'Skip CVE vulnerability scanning'
required: false
default: 'false'
type: boolean
specific_service:
description: 'Test specific service only (leave empty for all)'
required: false
default: ''
type: string
fail_on_high:
description: 'Fail on HIGH severity CVEs (not just CRITICAL)'
required: false
default: 'false'
type: boolean
env:
# Test configuration
TEST_TIMEOUT_MINUTES: 5
HEALTH_CHECK_RETRIES: 30
HEALTH_CHECK_INTERVAL: 10
# CVE scanning configuration
# Note: CVE scan generates reports but does not fail the pipeline
# This allows tracking vulnerabilities while not blocking deployments
CVE_FAIL_ON_CRITICAL: false
CVE_FAIL_ON_HIGH: false
# Prevent concurrent CI runs on the same branch
concurrency:
group: ci-cd-${{ github.ref }}
cancel-in-progress: true
# Default permissions (restrictive) - minimal access by default
permissions:
contents: read
jobs:
# ============================================================================
# Job 1: Lint Docker Compose Files
# ============================================================================
lint:
name: Lint Docker Compose
runs-on: ubuntu-latest
outputs:
lint_passed: ${{ steps.lint.outputs.passed }}
error_count: ${{ steps.lint.outputs.error_count }}
warning_count: ${{ steps.lint.outputs.warning_count }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Discover compose files to lint
id: find-files
run: |
set -euo pipefail
echo "=== Finding Docker Compose files ==="
# Find all docker-compose.yml files, excluding nested services
FILES=()
while IFS= read -r file; do
# Get the directory depth (services/name/docker-compose.yml = depth 3)
DEPTH=$(echo "$file" | tr '/' '\n' | wc -l | tr -d ' ')
if [[ "$DEPTH" -eq 3 ]]; then
FILES+=("$file")
fi
done < <(find services -name "docker-compose.yml" -type f | sort)
FILE_COUNT=${#FILES[@]}
echo "Found $FILE_COUNT Docker Compose files"
# Save file list for linting
printf '%s\n' "${FILES[@]}" > /tmp/compose-files.txt
echo "file_count=$FILE_COUNT" >> $GITHUB_OUTPUT
- name: Run DCLint
id: lint
run: |
set -uo pipefail
echo "=== Running Docker Compose Linter (DCLint) ==="
# Create results directory
mkdir -p lint-results
# Run DCLint using Docker image
# Mount workspace and run linter recursively on services directory
# Note: dclint takes paths directly, not a 'lint' subcommand
docker run --rm \
-v "${{ github.workspace }}:/app" \
zavoloklom/dclint:latest \
/app/services \
-r \
-c /app/.dclintrc.yaml \
-f stylish \
-o /app/lint-results/dclint-report.txt \
2>&1 | tee lint-results/dclint-output.txt || LINT_EXIT_CODE=$?
LINT_EXIT_CODE=${LINT_EXIT_CODE:-0}
# Parse results for summary
ERROR_COUNT=0
WARNING_COUNT=0
if [[ -f lint-results/dclint-report.txt ]] && [[ -s lint-results/dclint-report.txt ]]; then
# Count errors and warnings from output (match severity indicators)
ERROR_COUNT=$(grep -cE '\berror\b' lint-results/dclint-report.txt 2>/dev/null || true)
WARNING_COUNT=$(grep -cE '\bwarning\b' lint-results/dclint-report.txt 2>/dev/null || true)
# Ensure we have valid integers
ERROR_COUNT=${ERROR_COUNT:-0}
WARNING_COUNT=${WARNING_COUNT:-0}
# Strip any whitespace
ERROR_COUNT=$(echo "$ERROR_COUNT" | tr -d '[:space:]')
WARNING_COUNT=$(echo "$WARNING_COUNT" | tr -d '[:space:]')
fi
# Validate counts are integers, default to 0 if not
[[ "$ERROR_COUNT" =~ ^[0-9]+$ ]] || ERROR_COUNT=0
[[ "$WARNING_COUNT" =~ ^[0-9]+$ ]] || WARNING_COUNT=0
echo "error_count=${ERROR_COUNT}" >> $GITHUB_OUTPUT
echo "warning_count=${WARNING_COUNT}" >> $GITHUB_OUTPUT
# Display results
echo ""
echo "=== DCLint Results ==="
if [[ -f lint-results/dclint-report.txt ]]; then
cat lint-results/dclint-report.txt
elif [[ -f lint-results/dclint-output.txt ]]; then
cat lint-results/dclint-output.txt
fi
# Determine pass/fail
if [[ $LINT_EXIT_CODE -eq 0 ]]; then
echo ""
echo "All Docker Compose files passed linting!"
echo "passed=true" >> $GITHUB_OUTPUT
else
echo ""
echo "::error::Docker Compose linting failed with $ERROR_COUNT error(s) and $WARNING_COUNT warning(s)"
echo "passed=false" >> $GITHUB_OUTPUT
exit 1
fi
- name: Generate JSON report for CI integration
if: always()
run: |
# Generate JSON format report for potential downstream processing
docker run --rm \
-v "${{ github.workspace }}:/app" \
zavoloklom/dclint:latest \
/app/services \
-r \
-c /app/.dclintrc.yaml \
-f json \
-o /app/lint-results/dclint-report.json \
2>/dev/null || true
- name: Upload lint results
uses: actions/upload-artifact@v7
if: always()
with:
name: dclint-results
path: lint-results/
retention-days: 30
- name: Add lint summary to GitHub Actions
if: always()
run: |
echo "## Docker Compose Lint Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ steps.lint.outputs.passed }}" == "true" ]]; then
echo "✅ **All files passed linting**" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Linting failed**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- Errors: ${{ steps.lint.outputs.error_count }}" >> $GITHUB_STEP_SUMMARY
echo "- Warnings: ${{ steps.lint.outputs.warning_count }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "See the \`dclint-results\` artifact for detailed report." >> $GITHUB_STEP_SUMMARY
# ============================================================================
# Job 1b: Test bc CLI Helper
# ============================================================================
test-cli:
name: Test CLI Helper
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install shellcheck
run: sudo apt-get install -y shellcheck
- name: Run bc CLI tests
run: ./.github/scripts/test-bc-cli.sh
- name: Add CLI test summary
if: always()
run: |
echo "## bc CLI Tests" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "CLI helper script validated successfully." >> $GITHUB_STEP_SUMMARY
# ============================================================================
# Job 1c: Service Coverage Check
# ============================================================================
# Ensures all services in services/ are documented in README.md and scripts/bc
service-coverage:
name: Service Coverage
runs-on: ubuntu-latest
outputs:
coverage_passed: ${{ steps.coverage.outputs.passed }}
service_count: ${{ steps.coverage.outputs.service_count }}
readme_count: ${{ steps.coverage.outputs.readme_count }}
bc_count: ${{ steps.coverage.outputs.bc_count }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Check service coverage
id: coverage
run: |
set -euo pipefail
echo "=== Checking Service Coverage ==="
# Count services in each location
SERVICE_COUNT=$(find services -maxdepth 1 -mindepth 1 -type d | wc -l | tr -d ' ')
README_COUNT=$(grep -cE "^\| \[.*\]\(services/.*/README\.md\)" README.md 2>/dev/null || echo 0)
BC_COUNT=$(grep -cE "^\s*echo \"\s+[a-z0-9-]+\s+-" scripts/bc 2>/dev/null || echo 0)
echo "Service directories: $SERVICE_COUNT"
echo "README.md entries: $README_COUNT"
echo "scripts/bc entries: $BC_COUNT"
# Output counts
echo "service_count=$SERVICE_COUNT" >> $GITHUB_OUTPUT
echo "readme_count=$README_COUNT" >> $GITHUB_OUTPUT
echo "bc_count=$BC_COUNT" >> $GITHUB_OUTPUT
# Run the full coverage check script
if ./.github/scripts/check-service-coverage.sh; then
echo "passed=true" >> $GITHUB_OUTPUT
else
echo "passed=false" >> $GITHUB_OUTPUT
exit 1
fi
- name: Add coverage summary
if: always()
run: |
echo "## Service Coverage Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ steps.coverage.outputs.passed }}" == "true" ]]; then
echo "✅ **All services properly documented**" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Service coverage incomplete**" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Location | Count |" >> $GITHUB_STEP_SUMMARY
echo "|----------|-------|" >> $GITHUB_STEP_SUMMARY
echo "| Service Directories | ${{ steps.coverage.outputs.service_count }} |" >> $GITHUB_STEP_SUMMARY
echo "| README.md Table | ${{ steps.coverage.outputs.readme_count }} |" >> $GITHUB_STEP_SUMMARY
echo "| scripts/bc List | ${{ steps.coverage.outputs.bc_count }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "All services must be documented in README.md and scripts/bc." >> $GITHUB_STEP_SUMMARY
# ============================================================================
# Job 2: Validate OCI Compatibility
# ============================================================================
validate-oci:
name: Validate OCI Compatibility
runs-on: ubuntu-latest
needs: lint
outputs:
oci_compatible: ${{ steps.validate.outputs.compatible }}
bind_mount_count: ${{ steps.validate.outputs.bind_mount_count }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Check OCI compatibility
id: validate
run: |
set -uo pipefail
echo "=== Validating OCI Compatibility ==="
ERRORS=0
WARNINGS=0
BIND_MOUNTS=0
# Create results file
echo "# OCI Compatibility Report" > /tmp/oci-report.md
echo "" >> /tmp/oci-report.md
echo "| Service | Status | Issues |" >> /tmp/oci-report.md
echo "|---------|--------|--------|" >> /tmp/oci-report.md
for COMPOSE in services/*/docker-compose.yml; do
SERVICE=$(dirname "$COMPOSE" | xargs basename)
ISSUES=""
STATUS="✅ Pass"
# Load env files for variable substitution
ENV_FILE="services/${SERVICE}/.env"
EXAMPLE_FILE="services/${SERVICE}/.env.example"
set +u
if [[ -f "$ENV_FILE" ]]; then
set -a; source "$ENV_FILE" 2>/dev/null || true; set +a
fi
if [[ -f "$EXAMPLE_FILE" ]]; then
set -a; source "$EXAMPLE_FILE" 2>/dev/null || true; set +a
fi
set -u
# Check for bind mounts (except docker.sock which is allowed for traefik)
# Use awk to detect bind mounts and check if source is docker.sock
CONFIG=$(docker compose -f "$COMPOSE" config 2>/dev/null)
# Find bind mounts that are NOT docker.sock
# The config format has 'type: bind' followed by 'source: <path>' on next lines
BINDS=$(echo "$CONFIG" | awk '
/type: bind/ { in_bind=1; next }
in_bind && /source:/ {
if ($2 !~ /docker\.sock/) {
print $0
count++
}
in_bind=0
}
/^[^ ]/ { in_bind=0 }
END { exit (count > 0 ? 0 : 1) }
' 2>/dev/null) || true
if [[ -n "$BINDS" ]]; then
BIND_COUNT=$(echo "$BINDS" | wc -l | tr -d ' ')
BIND_MOUNTS=$((BIND_MOUNTS + BIND_COUNT))
ISSUES="${ISSUES}bind mounts ($BIND_COUNT); "
STATUS="❌ Fail"
ERRORS=$((ERRORS + 1))
echo "::error file=$COMPOSE::Contains $BIND_COUNT bind mount(s) - not OCI compatible"
fi
# Check for build directives
if grep -qE '^\s+build:' "$COMPOSE" 2>/dev/null; then
ISSUES="${ISSUES}build directive; "
STATUS="⚠️ Warn"
WARNINGS=$((WARNINGS + 1))
echo "::warning file=$COMPOSE::Contains build directive - images must be pre-built for OCI"
fi
# Check for local config file references (not in volumes section)
if grep -qE '^\s+-\s+\./[^/]+\.(yml|yaml|conf|env|toml):' "$COMPOSE" 2>/dev/null; then
ISSUES="${ISSUES}local config files; "
if [[ "$STATUS" != "❌ Fail" ]]; then
STATUS="⚠️ Warn"
fi
WARNINGS=$((WARNINGS + 1))
echo "::warning file=$COMPOSE::References local config files"
fi
# Default issues text
if [[ -z "$ISSUES" ]]; then
ISSUES="None"
fi
echo "| $SERVICE | $STATUS | $ISSUES |" >> /tmp/oci-report.md
done
# Summary
echo "" >> /tmp/oci-report.md
echo "## Summary" >> /tmp/oci-report.md
echo "" >> /tmp/oci-report.md
echo "- **Errors:** $ERRORS" >> /tmp/oci-report.md
echo "- **Warnings:** $WARNINGS" >> /tmp/oci-report.md
echo "- **Total bind mounts:** $BIND_MOUNTS" >> /tmp/oci-report.md
# Output results
echo "bind_mount_count=$BIND_MOUNTS" >> $GITHUB_OUTPUT
if [[ $ERRORS -eq 0 ]]; then
echo "compatible=true" >> $GITHUB_OUTPUT
echo ""
echo "All services are OCI compatible!"
else
echo "compatible=false" >> $GITHUB_OUTPUT
echo ""
echo "::error::$ERRORS service(s) have OCI compatibility issues"
exit 1
fi
echo ""
cat /tmp/oci-report.md
- name: Add OCI summary to GitHub Actions
if: always()
run: |
echo "## OCI Compatibility Check" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ steps.validate.outputs.compatible }}" == "true" ]]; then
echo "✅ **All services are OCI compatible**" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Some services have OCI compatibility issues**" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Bind mounts detected: ${{ steps.validate.outputs.bind_mount_count }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "Services can be deployed from OCI artifacts:" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo 'docker compose -f oci://ghcr.io/beevelop/<service>:<version> up -d' >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
# ============================================================================
# Job 3: Discover and Validate Services
# ============================================================================
discover:
name: Discover Services
runs-on: ubuntu-latest
needs: [lint, validate-oci]
outputs:
services: ${{ steps.discover.outputs.services }}
service_count: ${{ steps.discover.outputs.service_count }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Discover Docker Compose services
id: discover
run: |
set -euo pipefail
echo "=== Discovering Docker Compose services ==="
# Extract service directories using proper line-by-line reading
SERVICES=()
SKIPPED_SERVICES=()
while IFS= read -r file; do
SERVICE_DIR=$(dirname "$file")
SERVICE_NAME=$(basename "$SERVICE_DIR")
# Skip nested services (like traefik/whoami) - only include depth=2 (services/name)
DEPTH=$(echo "$SERVICE_DIR" | tr '/' '\n' | wc -l | tr -d ' ')
if [[ "$DEPTH" -eq 2 ]]; then
# Skip services with .ci-skip marker file
if [[ -f "${SERVICE_DIR}/.ci-skip" ]]; then
echo "Skipping ${SERVICE_NAME} (has .ci-skip marker)"
SKIPPED_SERVICES+=("$SERVICE_NAME")
else
SERVICES+=("$SERVICE_NAME")
fi
fi
done < <(find services -name "docker-compose.yml" -type f | sort)
if [[ ${#SKIPPED_SERVICES[@]} -gt 0 ]]; then
echo ""
echo "=== Skipped ${#SKIPPED_SERVICES[@]} services with .ci-skip marker ==="
printf '%s\n' "${SKIPPED_SERVICES[@]}"
fi
# Filter for specific service if requested
if [[ -n "${{ github.event.inputs.specific_service }}" ]]; then
SPECIFIC="${{ github.event.inputs.specific_service }}"
if printf '%s\n' "${SERVICES[@]}" | grep -q "^${SPECIFIC}$"; then
SERVICES=("$SPECIFIC")
echo "Filtering to specific service: $SPECIFIC"
else
echo "ERROR: Service '$SPECIFIC' not found!"
echo "Available services: ${SERVICES[*]}"
exit 1
fi
fi
# Convert to JSON array
SERVICES_JSON=$(printf '%s\n' "${SERVICES[@]}" | jq -R . | jq -s -c .)
SERVICE_COUNT=${#SERVICES[@]}
echo "services=$SERVICES_JSON" >> $GITHUB_OUTPUT
echo "service_count=$SERVICE_COUNT" >> $GITHUB_OUTPUT
echo ""
echo "=== Discovered $SERVICE_COUNT services ==="
echo "$SERVICES_JSON" | jq -r '.[]'
- name: Validate compose file syntax
run: |
set -euo pipefail
echo "=== Validating Docker Compose syntax ==="
ERRORS=0
for SERVICE in $(echo '${{ steps.discover.outputs.services }}' | jq -r '.[]'); do
COMPOSE_FILE="services/${SERVICE}/docker-compose.yml"
echo -n "Validating ${SERVICE}... "
if docker compose -f "$COMPOSE_FILE" config > /dev/null 2>&1; then
echo "OK"
else
echo "FAILED"
echo "--- Error details for ${SERVICE} ---"
docker compose -f "$COMPOSE_FILE" config 2>&1 || true
echo "---"
ERRORS=$((ERRORS + 1))
fi
done
if [[ $ERRORS -gt 0 ]]; then
echo ""
echo "ERROR: $ERRORS compose file(s) have syntax errors!"
exit 1
fi
echo ""
echo "All compose files validated successfully!"
# ============================================================================
# Job 4: Extract All Docker Images
# ============================================================================
extract-images:
name: Extract Docker Images
runs-on: ubuntu-latest
needs: discover
outputs:
images: ${{ steps.extract.outputs.images }}
image_count: ${{ steps.extract.outputs.image_count }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Extract unique images from all compose files
id: extract
run: |
set -uo pipefail
echo "=== Extracting Docker images from compose files ==="
# Create temp file for all images
> /tmp/all_images.txt
for SERVICE in $(echo '${{ needs.discover.outputs.services }}' | jq -r '.[]'); do
COMPOSE_FILE="services/${SERVICE}/docker-compose.yml"
ENV_FILE="services/${SERVICE}/.env"
EXAMPLE_FILE="services/${SERVICE}/.env.example"
echo "Processing ${SERVICE}..."
# Load environment variables if they exist
# Disable nounset temporarily as env files may contain $ chars in values
# (e.g., bcrypt hashes like $2y$10$...)
set +u
if [[ -f "$ENV_FILE" ]]; then
set -a
source "$ENV_FILE" 2>/dev/null || true
set +a
fi
# Also load example env for any missing vars
if [[ -f "$EXAMPLE_FILE" ]]; then
set -a
source "$EXAMPLE_FILE" 2>/dev/null || true
set +a
fi
set -u
# Extract images using docker compose config
# Use sed to trim leading/trailing whitespace from image names
IMAGES=$(docker compose -f "$COMPOSE_FILE" config 2>/dev/null | \
grep -E '^\s*image:' | \
sed 's/.*image://' | \
sed 's/^[[:space:]]*//' | \
sed 's/[[:space:]]*$//' | \
tr -d '"' || true)
if [[ -n "$IMAGES" ]]; then
echo "$IMAGES" >> /tmp/all_images.txt
fi
done
# Get unique images, remove empty lines, and trim any remaining whitespace
UNIQUE_IMAGES=$(cat /tmp/all_images.txt | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//' | sort -u | grep -v '^$' || true)
IMAGE_COUNT=$(echo "$UNIQUE_IMAGES" | grep -c . || echo "0")
# Convert to JSON array
IMAGES_JSON=$(echo "$UNIQUE_IMAGES" | jq -R . | jq -s -c .)
echo "images=$IMAGES_JSON" >> $GITHUB_OUTPUT
echo "image_count=$IMAGE_COUNT" >> $GITHUB_OUTPUT
echo ""
echo "=== Found $IMAGE_COUNT unique Docker images ==="
echo "$UNIQUE_IMAGES"
# ============================================================================
# Job 5: CVE Vulnerability Scanning (Parallel)
# ============================================================================
# Images are scanned in parallel using matrix strategy for faster pipeline execution.
# Each image runs in its own job, and results are aggregated in cve-scan-summary.
cve-scan:
name: CVE Scan - ${{ matrix.image }}
runs-on: ubuntu-latest
needs: extract-images
if: ${{ github.event.inputs.skip_cve_scan != 'true' && needs.extract-images.outputs.image_count != '0' }}
strategy:
fail-fast: false
max-parallel: 10
matrix:
image: ${{ fromJson(needs.extract-images.outputs.images) }}
steps:
- name: Install Trivy
uses: aquasecurity/trivy-action@master
with:
scan-type: 'image'
image-ref: ${{ matrix.image }}
format: 'json'
output: 'trivy-results.json'
severity: 'CRITICAL,HIGH,MEDIUM,LOW'
timeout: '10m'
continue-on-error: true
- name: Parse scan results
id: parse
run: |
set -uo pipefail
IMAGE="${{ matrix.image }}"
SAFE_NAME=$(echo "$IMAGE" | tr '/:' '__')
echo "=== Scan results for: $IMAGE ==="
if [[ -f trivy-results.json ]] && [[ -s trivy-results.json ]]; then
# Parse vulnerability counts
CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' trivy-results.json 2>/dev/null || echo 0)
HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' trivy-results.json 2>/dev/null || echo 0)
MEDIUM=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="MEDIUM")] | length' trivy-results.json 2>/dev/null || echo 0)
LOW=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="LOW")] | length' trivy-results.json 2>/dev/null || echo 0)
# Determine status
if [[ $CRITICAL -gt 0 ]]; then
STATUS="CRITICAL"
elif [[ $HIGH -gt 0 ]]; then
STATUS="HIGH"
else
STATUS="PASS"
fi
echo "Critical: $CRITICAL, High: $HIGH, Medium: $MEDIUM, Low: $LOW"
echo "Status: $STATUS"
# Export results
echo "critical=$CRITICAL" >> $GITHUB_OUTPUT
echo "high=$HIGH" >> $GITHUB_OUTPUT
echo "medium=$MEDIUM" >> $GITHUB_OUTPUT
echo "low=$LOW" >> $GITHUB_OUTPUT
echo "status=$STATUS" >> $GITHUB_OUTPUT
echo "scanned=true" >> $GITHUB_OUTPUT
# Rename for artifact upload
mv trivy-results.json "${SAFE_NAME}.json"
else
echo "WARNING: Scan failed or produced no results"
echo "critical=0" >> $GITHUB_OUTPUT
echo "high=0" >> $GITHUB_OUTPUT
echo "medium=0" >> $GITHUB_OUTPUT
echo "low=0" >> $GITHUB_OUTPUT
echo "status=SKIPPED" >> $GITHUB_OUTPUT
echo "scanned=false" >> $GITHUB_OUTPUT
# Create empty result file
echo '{"skipped": true, "image": "${{ matrix.image }}"}' > "${SAFE_NAME}.json"
fi
echo "safe_name=${SAFE_NAME}" >> $GITHUB_OUTPUT
- name: Upload individual scan result
uses: actions/upload-artifact@v7
with:
name: cve-scan-${{ steps.parse.outputs.safe_name }}
path: ${{ steps.parse.outputs.safe_name }}.json
retention-days: 30
# ============================================================================
# Job 5b: Aggregate CVE Scan Results
# ============================================================================
cve-scan-summary:
name: CVE Scan Summary
runs-on: ubuntu-latest
needs: [extract-images, cve-scan]
if: ${{ always() && needs.extract-images.result == 'success' && github.event.inputs.skip_cve_scan != 'true' }}
outputs:
critical_count: ${{ steps.summarize.outputs.critical_count }}
high_count: ${{ steps.summarize.outputs.high_count }}
scan_passed: ${{ steps.summarize.outputs.scan_passed }}
steps:
- name: Download all scan artifacts
uses: actions/download-artifact@v8
with:
pattern: cve-scan-*
path: scan-results/
merge-multiple: true
- name: Aggregate results
id: summarize
run: |
set -uo pipefail
echo "=== Aggregating CVE scan results ==="
TOTAL_CRITICAL=0
TOTAL_HIGH=0
TOTAL_MEDIUM=0
TOTAL_LOW=0
SCANNED=0
SKIPPED=0
# Create summary file
echo "# CVE Scan Results" > summary.md
echo "" >> summary.md
echo "| Image | Critical | High | Medium | Low | Status |" >> summary.md
echo "|-------|----------|------|--------|-----|--------|" >> summary.md
# Process each result file
for RESULT_FILE in scan-results/*.json; do
if [[ ! -f "$RESULT_FILE" ]]; then
continue
fi
# Extract image name from filename
FILENAME=$(basename "$RESULT_FILE" .json)
IMAGE_NAME=$(echo "$FILENAME" | tr '__' ':/')
# Check if skipped
if jq -e '.skipped' "$RESULT_FILE" > /dev/null 2>&1; then
echo "| \`$IMAGE_NAME\` | - | - | - | - | SKIPPED |" >> summary.md
SKIPPED=$((SKIPPED + 1))
continue
fi
# Parse counts
CRITICAL=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="CRITICAL")] | length' "$RESULT_FILE" 2>/dev/null || echo 0)
HIGH=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="HIGH")] | length' "$RESULT_FILE" 2>/dev/null || echo 0)
MEDIUM=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="MEDIUM")] | length' "$RESULT_FILE" 2>/dev/null || echo 0)
LOW=$(jq '[.Results[]?.Vulnerabilities[]? | select(.Severity=="LOW")] | length' "$RESULT_FILE" 2>/dev/null || echo 0)
# Update totals
TOTAL_CRITICAL=$((TOTAL_CRITICAL + CRITICAL))
TOTAL_HIGH=$((TOTAL_HIGH + HIGH))
TOTAL_MEDIUM=$((TOTAL_MEDIUM + MEDIUM))
TOTAL_LOW=$((TOTAL_LOW + LOW))
SCANNED=$((SCANNED + 1))
# Determine status
if [[ $CRITICAL -gt 0 ]]; then
STATUS="CRITICAL"
elif [[ $HIGH -gt 0 ]]; then
STATUS="HIGH"
else
STATUS="PASS"
fi
echo "| \`$IMAGE_NAME\` | $CRITICAL | $HIGH | $MEDIUM | $LOW | $STATUS |" >> summary.md
done
IMAGE_COUNT=${{ needs.extract-images.outputs.image_count }}
# Write totals to summary
echo "" >> summary.md
echo "## Summary" >> summary.md
echo "" >> summary.md
echo "- **Images Scanned:** $SCANNED / $IMAGE_COUNT" >> summary.md
echo "- **Images Skipped:** $SKIPPED" >> summary.md
echo "- **Total Critical:** $TOTAL_CRITICAL" >> summary.md
echo "- **Total High:** $TOTAL_HIGH" >> summary.md
echo "- **Total Medium:** $TOTAL_MEDIUM" >> summary.md
echo "- **Total Low:** $TOTAL_LOW" >> summary.md
echo ""
echo "=== CVE Scan Complete ==="
cat summary.md
# Export results
echo "critical_count=$TOTAL_CRITICAL" >> $GITHUB_OUTPUT
echo "high_count=$TOTAL_HIGH" >> $GITHUB_OUTPUT
# Report vulnerabilities but don't fail the pipeline
if [[ $TOTAL_CRITICAL -gt 0 ]]; then
echo "::warning::Found $TOTAL_CRITICAL CRITICAL vulnerabilities - see CVE scan report for details"
fi
if [[ $TOTAL_HIGH -gt 0 ]]; then
echo "::warning::Found $TOTAL_HIGH HIGH vulnerabilities - see CVE scan report for details"
fi
# Always pass - CVE scan is for reporting only
echo "scan_passed=true" >> $GITHUB_OUTPUT
echo ""
echo "CVE scan completed - report generated (non-blocking)"
- name: Upload aggregated results
uses: actions/upload-artifact@v7
with:
name: cve-scan-results
path: |
summary.md
scan-results/
retention-days: 30
- name: Add CVE summary to GitHub Actions
run: |
echo "## CVE Vulnerability Scan Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat summary.md >> $GITHUB_STEP_SUMMARY
# ============================================================================
# Job 6: Parallel Docker Compose Testing
# ============================================================================
# Services are tested in parallel using matrix strategy for faster pipeline execution.
# Each service runs in its own isolated job with unique project naming to prevent conflicts.
test-services:
name: Test - ${{ matrix.service }}
runs-on: ubuntu-latest
needs: [discover, cve-scan-summary]
# Run even if CVE scan is skipped, but not if it failed
if: ${{ always() && needs.discover.result == 'success' && (needs.cve-scan-summary.result == 'success' || needs.cve-scan-summary.result == 'skipped') }}
strategy:
fail-fast: false
max-parallel: 8
matrix:
service: ${{ fromJson(needs.discover.outputs.services) }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Test service
id: test
run: |
set -uo pipefail
SERVICE="${{ matrix.service }}"
SERVICE_DIR="services/${SERVICE}"
COMPOSE_FILE="${SERVICE_DIR}/docker-compose.yml"
# Use unique project name to isolate parallel tests
PROJECT_NAME="test-${SERVICE}-${{ github.run_id }}"
echo "=== Testing: $SERVICE ==="
echo "Project: $PROJECT_NAME"
# Create results directory
mkdir -p test-results
TEST_START=$(date +%s)
TEST_STATUS="PASSED"
TEST_DETAILS=""
# Load environment variables
set +u
if [[ -f "${SERVICE_DIR}/.env" ]]; then
set -a
source "${SERVICE_DIR}/.env" 2>/dev/null || true
set +a
fi
if [[ -f "${SERVICE_DIR}/.env.example" ]]; then
set -a
source "${SERVICE_DIR}/.env.example" 2>/dev/null || true
set +a
fi
set -u
# Set test-specific environment variables
export SERVICE_DOMAIN="${SERVICE}.test.local"
# Create isolated traefik network for this test
# Using project-specific name to avoid conflicts between parallel tests
TRAEFIK_NETWORK="${PROJECT_NAME}_traefik"
docker network create "$TRAEFIK_NETWORK" > /dev/null 2>&1 || true
# Also create the standard traefik_default network (some services expect it)
# Skip for traefik/traefik-tunnel services - they create their own network
if [[ "$SERVICE" != "traefik" && "$SERVICE" != "traefik-tunnel" ]]; then
docker network create traefik_default > /dev/null 2>&1 || true
fi
# Step 1: Pull images (quiet mode, suppress progress)
echo "Pulling images..."
if timeout 300 docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" pull --quiet 2>&1 | grep -v "Pulling\|Pull complete\|Already exists\|Waiting\|Downloading\|Extracting" || true; then
echo "Images ready"
else
echo "WARNING: Some images may have failed to pull"
fi
# Step 2: Start services (suppress verbose Creating/Started messages)
echo "Starting services..."
# Capture output and filter out the noisy status messages
UP_OUTPUT=$(timeout 120 docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" up -d --quiet-pull 2>&1) || UP_EXIT=$?
UP_EXIT=${UP_EXIT:-0}
# Only show errors or warnings, not the Creating/Created/Starting/Started noise
echo "$UP_OUTPUT" | grep -vE "Creating|Created|Starting|Started|Pulling|Pull complete" | grep -v "^$" || true
if [[ $UP_EXIT -eq 0 ]]; then
echo "Services started"
# Step 3: Wait for services to stabilize
sleep 10
# Step 4: Check container health
echo "Checking container status..."
CONTAINERS=$(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps -q 2>/dev/null | wc -l)
RUNNING=$(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps --status running -q 2>/dev/null | wc -l)
echo "Containers: $RUNNING running of $CONTAINERS defined"
if [[ "$RUNNING" -gt 0 ]]; then
# Show container status (compact format)
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps --format "table {{.Name}}\t{{.Status}}"
# Check each container is running (not restarting)
HEALTHY=true
for CONTAINER in $(docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" ps -q 2>/dev/null); do
STATE=$(docker inspect --format='{{.State.Status}}' "$CONTAINER" 2>/dev/null || echo "unknown")
RESTARTS=$(docker inspect --format='{{.RestartCount}}' "$CONTAINER" 2>/dev/null || echo "0")
if [[ "$STATE" != "running" ]] || [[ "$RESTARTS" -gt 2 ]]; then
CONTAINER_NAME=$(docker inspect --format='{{.Name}}' "$CONTAINER" 2>/dev/null | sed 's/^\///')
echo "::warning::Container $CONTAINER_NAME - State: $STATE, Restarts: $RESTARTS"
HEALTHY=false
fi
done
if [[ "$HEALTHY" == "true" ]]; then
TEST_DETAILS="All containers running"
else
TEST_STATUS="FAILED"
TEST_DETAILS="Container health issues"
fi
else
TEST_STATUS="FAILED"
TEST_DETAILS="No containers running"
fi
else
TEST_STATUS="FAILED"
TEST_DETAILS="Failed to start services"
fi
# Calculate duration
TEST_END=$(date +%s)
TEST_DURATION=$((TEST_END - TEST_START))
echo ""
echo "Result: $TEST_STATUS (${TEST_DURATION}s) - $TEST_DETAILS"
# Capture logs for failed tests
if [[ "$TEST_STATUS" == "FAILED" ]]; then
echo "--- Container logs ---"
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" logs --tail=50 2>&1 || true
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" logs --tail=100 > "test-results/${SERVICE}-logs.txt" 2>&1 || true
fi
# Cleanup (suppress output)
echo "Cleaning up..."
docker compose -p "$PROJECT_NAME" -f "$COMPOSE_FILE" down --volumes --remove-orphans > /dev/null 2>&1 || true
docker network rm "$TRAEFIK_NETWORK" > /dev/null 2>&1 || true
# Export results
echo "status=$TEST_STATUS" >> $GITHUB_OUTPUT
echo "duration=$TEST_DURATION" >> $GITHUB_OUTPUT
echo "details=$TEST_DETAILS" >> $GITHUB_OUTPUT
# Write result file for aggregation
echo "${SERVICE}|${TEST_STATUS}|${TEST_DURATION}|${TEST_DETAILS}" > "test-results/${SERVICE}-result.txt"
# Exit with failure if test failed
if [[ "$TEST_STATUS" == "FAILED" ]]; then
exit 1
fi
- name: Upload test result
uses: actions/upload-artifact@v7
if: always()
with:
name: test-result-${{ matrix.service }}
path: test-results/
retention-days: 30
# ============================================================================
# Job 6b: Aggregate Test Results
# ============================================================================
test-services-summary:
name: Test Summary
runs-on: ubuntu-latest
needs: [discover, test-services]
if: always()
outputs:
passed: ${{ steps.summarize.outputs.passed }}
failed: ${{ steps.summarize.outputs.failed }}
all_passed: ${{ steps.summarize.outputs.all_passed }}
steps:
- name: Download all test results
uses: actions/download-artifact@v8
with:
pattern: test-result-*
path: all-results/
merge-multiple: true
- name: Aggregate results
id: summarize
run: |
set -uo pipefail
echo "=== Aggregating test results ==="
PASSED=0
FAILED=0
FAILED_SERVICES=""
# Create summary
echo "# Docker Compose Test Results" > summary.md
echo "" >> summary.md
echo "| Service | Status | Duration | Details |" >> summary.md
echo "|---------|--------|----------|---------|" >> summary.md
# Process each result file
for RESULT_FILE in all-results/*-result.txt; do
if [[ ! -f "$RESULT_FILE" ]]; then
continue
fi
# Parse result: SERVICE|STATUS|DURATION|DETAILS
IFS='|' read -r SERVICE STATUS DURATION DETAILS < "$RESULT_FILE"
if [[ "$STATUS" == "PASSED" ]]; then
PASSED=$((PASSED + 1))
echo "| $SERVICE | PASS | ${DURATION}s | $DETAILS |" >> summary.md
else
FAILED=$((FAILED + 1))
FAILED_SERVICES="${FAILED_SERVICES} ${SERVICE}"
echo "| $SERVICE | FAIL | ${DURATION}s | $DETAILS |" >> summary.md
fi
done
SERVICE_COUNT=${{ needs.discover.outputs.service_count }}
# Write summary
echo "" >> summary.md
echo "## Summary" >> summary.md
echo "" >> summary.md
echo "- **Total Services:** $SERVICE_COUNT" >> summary.md
echo "- **Passed:** $PASSED" >> summary.md
echo "- **Failed:** $FAILED" >> summary.md
if [[ $FAILED -gt 0 ]]; then
echo "" >> summary.md
echo "### Failed Services" >> summary.md
echo "" >> summary.md
for SVC in $FAILED_SERVICES; do
echo "- $SVC" >> summary.md
done
fi
echo ""
echo "=== Test Results ==="
cat summary.md
# Export results
echo "passed=$PASSED" >> $GITHUB_OUTPUT
echo "failed=$FAILED" >> $GITHUB_OUTPUT
if [[ $FAILED -eq 0 ]]; then
echo "all_passed=true" >> $GITHUB_OUTPUT
else
echo "all_passed=false" >> $GITHUB_OUTPUT
echo "::error::$FAILED service(s) failed testing:$FAILED_SERVICES"
fi
- name: Upload aggregated results
uses: actions/upload-artifact@v7
with:
name: docker-compose-test-results
path: |
summary.md
all-results/
retention-days: 30
- name: Add test summary to GitHub Actions
run: |
echo "## Docker Compose Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
cat summary.md >> $GITHUB_STEP_SUMMARY
# ============================================================================
# Job 7: Final Summary
# ============================================================================
summary:
name: Pipeline Summary
runs-on: ubuntu-latest
needs: [lint, test-cli, service-coverage, validate-oci, discover, extract-images, cve-scan-summary, test-services-summary]
if: always()
steps:
- name: Generate summary
run: |
echo "# CI/CD Pipeline Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Jobs Status" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Lint Docker Compose | ${{ needs.lint.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Test CLI Helper | ${{ needs.test-cli.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Service Coverage | ${{ needs.service-coverage.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Validate OCI | ${{ needs.validate-oci.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Discover Services | ${{ needs.discover.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Extract Images | ${{ needs.extract-images.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| CVE Scan | ${{ needs.cve-scan-summary.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| Test Services | ${{ needs.test-services-summary.result }} |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "## Metrics" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Lint Errors:** ${{ needs.lint.outputs.error_count || 0 }}" >> $GITHUB_STEP_SUMMARY
echo "- **Lint Warnings:** ${{ needs.lint.outputs.warning_count || 0 }}" >> $GITHUB_STEP_SUMMARY
echo "- **Services Discovered:** ${{ needs.discover.outputs.service_count }}" >> $GITHUB_STEP_SUMMARY
echo "- **Service Coverage:** ${{ needs.service-coverage.outputs.readme_count }}/${{ needs.service-coverage.outputs.service_count }} README, ${{ needs.service-coverage.outputs.bc_count }}/${{ needs.service-coverage.outputs.service_count }} bc" >> $GITHUB_STEP_SUMMARY
echo "- **Unique Images:** ${{ needs.extract-images.outputs.image_count }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tests Passed:** ${{ needs.test-services-summary.outputs.passed || 0 }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tests Failed:** ${{ needs.test-services-summary.outputs.failed || 0 }}" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.cve-scan-summary.result }}" == "success" ]]; then
echo "- **Critical CVEs:** ${{ needs.cve-scan-summary.outputs.critical_count || 0 }} (informational)" >> $GITHUB_STEP_SUMMARY
echo "- **High CVEs:** ${{ needs.cve-scan-summary.outputs.high_count || 0 }} (informational)" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
# Determine overall status (CVE scan is informational, not blocking)
if [[ "${{ needs.lint.result }}" == "success" && "${{ needs.service-coverage.result }}" == "success" && "${{ needs.validate-oci.result }}" == "success" && "${{ needs.test-services-summary.outputs.all_passed }}" == "true" ]]; then
echo "## Result: PASSED" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.cve-scan-summary.outputs.critical_count }}" -gt 0 ]] 2>/dev/null; then
echo "" >> $GITHUB_STEP_SUMMARY
echo "> **Note:** CVE vulnerabilities detected - review the scan report artifact for details." >> $GITHUB_STEP_SUMMARY
fi
else
echo "## Result: FAILED" >> $GITHUB_STEP_SUMMARY
fi
- name: Check overall result
run: |
if [[ "${{ needs.lint.result }}" == "failure" ]]; then
echo "Pipeline failed: Docker Compose linting failed"
exit 1
fi
if [[ "${{ needs.test-cli.result }}" == "failure" ]]; then
echo "Pipeline failed: CLI helper tests failed"
exit 1
fi
if [[ "${{ needs.service-coverage.result }}" == "failure" ]]; then
echo "Pipeline failed: Service coverage check failed - ensure all services are in README.md and scripts/bc"
exit 1
fi
if [[ "${{ needs.validate-oci.result }}" == "failure" ]]; then
echo "Pipeline failed: OCI compatibility validation failed"
exit 1
fi
if [[ "${{ needs.test-services-summary.outputs.all_passed }}" != "true" ]]; then
echo "Pipeline failed: ${{ needs.test-services-summary.outputs.failed }} service test(s) failed"
exit 1
fi
# CVE scan is informational only - don't fail the pipeline
if [[ "${{ needs.cve-scan-summary.result }}" == "failure" ]]; then
echo "::warning::CVE scan job failed - check logs for details"
fi
echo "Pipeline completed successfully!"