Files
Charon/.github/workflows/docker-build.yml

774 lines
35 KiB
YAML

name: Docker Build, Publish & Test
# This workflow replaced .github/workflows/docker-publish.yml (deleted in commit f640524b on Dec 21, 2025)
# Enhancements over the previous workflow:
# - SBOM generation and attestation for supply chain security
# - CVE-2025-68156 verification for Caddy security patches
# - Enhanced PR handling with dedicated scanning
# - Improved workflow orchestration with supply-chain-verify.yml
#
# PHASE 1 OPTIMIZATION (February 2026):
# - PR images now pushed to GHCR registry (enables downstream workflow consumption)
# - Immutable PR tagging: pr-{number}-{short-sha} (prevents race conditions)
# - Feature branch tagging: {sanitized-branch-name}-{short-sha} (enables unique testing)
# - Tag sanitization per spec Section 3.2 (handles special chars, slashes, etc.)
# - Mandatory security scanning for PR images (blocks on CRITICAL/HIGH vulnerabilities)
# - Retry logic for registry pushes (3 attempts, 10s wait - handles transient failures)
# - Enhanced metadata labels for image freshness validation
# - Artifact upload retained as fallback during migration period
# - Reduced build timeout from 30min to 25min for faster feedback (with retry buffer)
#
# See: docs/plans/current_spec.md (Section 4.1 - docker-build.yml changes)
on:
push:
branches:
- main
- development
- 'feature/**'
# Note: Tags are handled by release-goreleaser.yml to avoid duplicate builds
pull_request:
branches:
- main
- development
- 'feature/**'
workflow_dispatch:
workflow_call:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GHCR_REGISTRY: ghcr.io
DOCKERHUB_REGISTRY: docker.io
IMAGE_NAME: wikid82/charon
jobs:
build-and-push:
env:
HAS_DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN != '' }}
runs-on: ubuntu-latest
timeout-minutes: 20 # Phase 1: Reduced timeout for faster feedback
permissions:
contents: read
packages: write
security-events: write
id-token: write # Required for SBOM attestation
attestations: write # Required for SBOM attestation
outputs:
skip_build: ${{ steps.skip.outputs.skip_build }}
digest: ${{ steps.build-and-push.outputs.digest }}
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 skip condition
id: skip
env:
ACTOR: ${{ github.actor }}
EVENT: ${{ github.event_name }}
HEAD_MSG: ${{ github.event.head_commit.message }}
REF: ${{ github.ref }}
HEAD_REF: ${{ github.head_ref }}
run: |
should_skip=false
pr_title=""
if [ "$EVENT" = "pull_request" ]; then
pr_title=$(jq -r '.pull_request.title' "$GITHUB_EVENT_PATH" 2>/dev/null || echo '')
fi
if [ "$ACTOR" = "renovate[bot]" ]; then should_skip=true; fi
if echo "$HEAD_MSG" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi
if echo "$HEAD_MSG" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi
if echo "$pr_title" | grep -Ei '^chore\(deps' >/dev/null 2>&1; then should_skip=true; fi
if echo "$pr_title" | grep -Ei '^chore:' >/dev/null 2>&1; then should_skip=true; fi
# Always build on feature branches to ensure artifacts for testing
# For PRs: github.ref is refs/pull/N/merge, so check github.head_ref instead
# For pushes: github.ref is refs/heads/branch-name
is_feature_push=false
if [[ "$REF" == refs/heads/feature/* ]]; then
should_skip=false
is_feature_push=true
echo "Force building on feature branch (push)"
elif [[ "$HEAD_REF" == feature/* ]]; then
should_skip=false
echo "Force building on feature branch (PR)"
fi
echo "skip_build=$should_skip" >> $GITHUB_OUTPUT
echo "is_feature_push=$is_feature_push" >> $GITHUB_OUTPUT
- name: Set up QEMU
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- name: Resolve Debian base image digest
if: steps.skip.outputs.skip_build != 'true'
id: caddy
run: |
docker pull debian:trixie-slim
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' debian:trixie-slim)
echo "image=$DIGEST" >> $GITHUB_OUTPUT
- name: Log in to GitHub Container Registry
if: steps.skip.outputs.skip_build != '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: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Phase 1: Compute sanitized feature branch tags with SHA suffix
# Implements tag sanitization per spec Section 3.2
# Format: {sanitized-branch-name}-{short-sha} (e.g., feature-dns-provider-abc1234)
- name: Compute feature branch tag
if: steps.skip.outputs.skip_build != 'true' && startsWith(github.ref, 'refs/heads/feature/')
id: feature-tag
run: |
BRANCH_NAME="${GITHUB_REF#refs/heads/}"
SHORT_SHA="$(echo ${{ github.sha }} | cut -c1-7)"
# Sanitization algorithm per spec Section 3.2:
# 1. Convert to lowercase
# 2. Replace '/' with '-'
# 3. Replace special characters with '-'
# 4. Remove leading/trailing '-'
# 5. Collapse consecutive '-'
# 6. Truncate to 121 chars (leave room for -{sha})
# 7. Append '-{short-sha}' for uniqueness
SANITIZED=$(echo "${BRANCH_NAME}" | \
tr '[:upper:]' '[:lower:]' | \
tr '/' '-' | \
sed 's/[^a-z0-9._-]/-/g' | \
sed 's/^-//; s/-$//' | \
sed 's/--*/-/g' | \
cut -c1-121)
FEATURE_TAG="${SANITIZED}-${SHORT_SHA}"
echo "tag=${FEATURE_TAG}" >> $GITHUB_OUTPUT
echo "📦 Computed feature branch tag: ${FEATURE_TAG}"
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
with:
images: |
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=dev,enable=${{ github.ref == 'refs/heads/development' }}
type=raw,value=${{ steps.feature-tag.outputs.tag }},enable=${{ startsWith(github.ref, 'refs/heads/feature/') && steps.feature-tag.outputs.tag != '' }}
type=raw,value=pr-${{ github.event.pull_request.number }}-{{sha}},enable=${{ github.event_name == 'pull_request' }},prefix=,suffix=
type=sha,format=short,enable=${{ github.event_name != 'pull_request' }}
flavor: |
latest=false
labels: |
org.opencontainers.image.revision=${{ github.sha }}
io.charon.pr.number=${{ github.event.pull_request.number }}
io.charon.build.timestamp=${{ github.event.repository.updated_at }}
io.charon.feature.branch=${{ steps.feature-tag.outputs.tag }}
# Phase 1 Optimization: Build once, test many
# - For PRs: Single-platform (amd64) + immutable tags (pr-{number}-{short-sha})
# - For feature branches: Single-platform + sanitized tags ({branch}-{short-sha})
# - For main/dev: Multi-platform (amd64, arm64) for production
# - Always push to registry (enables downstream workflow consumption)
# - Retry logic handles transient registry failures (3 attempts, 10s wait)
# See: docs/plans/current_spec.md Section 4.1
- name: Build and push Docker image (with retry)
if: steps.skip.outputs.skip_build != 'true'
id: build-and-push
uses: nick-fields/retry@7152eba30c6575329ac0576536151aca5a72780e # v3.0.0
with:
timeout_minutes: 25
max_attempts: 3
retry_wait_seconds: 10
retry_on: error
warning_on_retry: true
command: |
set -euo pipefail
echo "🔨 Building Docker image with retry logic..."
echo "Platform: ${{ (github.event_name == 'pull_request' || steps.skip.outputs.is_feature_push == 'true') && 'linux/amd64' || 'linux/amd64,linux/arm64' }}"
# Build tag arguments array from metadata output (properly quoted)
TAG_ARGS_ARRAY=()
while IFS= read -r tag; do
[[ -n "$tag" ]] && TAG_ARGS_ARRAY+=("--tag" "$tag")
done <<< "${{ steps.meta.outputs.tags }}"
# Build label arguments array from metadata output (properly quoted)
LABEL_ARGS_ARRAY=()
while IFS= read -r label; do
[[ -n "$label" ]] && LABEL_ARGS_ARRAY+=("--label" "$label")
done <<< "${{ steps.meta.outputs.labels }}"
# Build the complete command as an array (handles spaces in label values correctly)
BUILD_CMD=(
docker buildx build
--platform "${{ (github.event_name == 'pull_request' || steps.skip.outputs.is_feature_push == 'true') && 'linux/amd64' || 'linux/amd64,linux/arm64' }}"
--push
"${TAG_ARGS_ARRAY[@]}"
"${LABEL_ARGS_ARRAY[@]}"
--no-cache
--pull
--build-arg "VERSION=${{ steps.meta.outputs.version }}"
--build-arg "BUILD_DATE=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.created'] }}"
--build-arg "VCS_REF=${{ github.sha }}"
--build-arg "CADDY_IMAGE=${{ steps.caddy.outputs.image }}"
--iidfile /tmp/image-digest.txt
.
)
# Execute build
echo "Executing: ${BUILD_CMD[*]}"
"${BUILD_CMD[@]}"
# Extract digest for downstream jobs (format: sha256:xxxxx)
DIGEST=$(cat /tmp/image-digest.txt)
echo "digest=${DIGEST}" >> $GITHUB_OUTPUT
echo "✅ Build complete. Digest: ${DIGEST}"
# For PRs and feature branches, pull the image back locally for artifact creation
# This enables backward compatibility with workflows that use artifacts
if [[ "${{ github.event_name }}" == "pull_request" ]] || [[ "${{ steps.skip.outputs.is_feature_push }}" == "true" ]]; then
echo "📥 Pulling image back for artifact creation..."
FIRST_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -n1)
docker pull "${FIRST_TAG}"
echo "✅ Image pulled: ${FIRST_TAG}"
fi
# Critical Fix: Use exact tag from metadata instead of manual reconstruction
# WHY: docker/build-push-action with load:true applies the exact tags from
# docker/metadata-action. Manual reconstruction can cause mismatches due to:
# - Case sensitivity variations (owner name normalization)
# - Tag format differences in Buildx internal behavior
# - Registry prefix inconsistencies
#
# SOLUTION: Extract the first tag from metadata output (which is the PR tag)
# and use it directly with docker save. This guarantees we reference the
# exact image that was loaded into the local Docker daemon.
#
# VALIDATION: Added defensive checks to fail fast with diagnostics if:
# 1. No tag found in metadata output
# 2. Image doesn't exist locally after build
# 3. Artifact creation fails
- name: Save Docker Image as Artifact
if: success() && steps.skip.outputs.skip_build != 'true' && (github.event_name == 'pull_request' || steps.skip.outputs.is_feature_push == 'true')
run: |
# Extract the first tag from metadata action (PR tag)
IMAGE_TAG=$(echo "${{ steps.meta.outputs.tags }}" | head -n 1)
if [[ -z "${IMAGE_TAG}" ]]; then
echo "❌ ERROR: No image tag found in metadata output"
echo "Metadata tags output:"
echo "${{ steps.meta.outputs.tags }}"
exit 1
fi
echo "🔍 Detected image tag: ${IMAGE_TAG}"
# Verify the image exists locally
if ! docker image inspect "${IMAGE_TAG}" >/dev/null 2>&1; then
echo "❌ ERROR: Image ${IMAGE_TAG} not found locally"
echo "📋 Available images:"
docker images
exit 1
fi
# Save the image using the exact tag from metadata
echo "💾 Saving image: ${IMAGE_TAG}"
docker save "${IMAGE_TAG}" -o /tmp/charon-pr-image.tar
# Verify the artifact was created
echo "✅ Artifact created:"
ls -lh /tmp/charon-pr-image.tar
- name: Upload Image Artifact
if: success() && steps.skip.outputs.skip_build != 'true' && (github.event_name == 'pull_request' || steps.skip.outputs.is_feature_push == 'true')
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: ${{ github.event_name == 'pull_request' && format('pr-image-{0}', github.event.pull_request.number) || 'push-image' }}
path: /tmp/charon-pr-image.tar
retention-days: 1 # Only needed for workflow duration
- name: Verify Caddy Security Patches (CVE-2025-68156)
if: steps.skip.outputs.skip_build != 'true'
timeout-minutes: 2
continue-on-error: true
run: |
echo "🔍 Verifying Caddy binary contains patched expr-lang/expr@v1.17.7..."
echo ""
# Determine the image reference based on event type
if [ "${{ github.event_name }}" = "pull_request" ]; then
PR_NUM="${{ github.event.pull_request.number }}"
if [ -z "${PR_NUM}" ]; then
echo "❌ ERROR: Pull request number is empty"
exit 1
fi
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${PR_NUM}"
echo "Using PR image: $IMAGE_REF"
else
if [ -z "${{ steps.build-and-push.outputs.digest }}" ]; then
echo "❌ ERROR: Build digest is empty"
exit 1
fi
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}"
echo "Using digest: $IMAGE_REF"
fi
echo ""
echo "==> Caddy version:"
timeout 30s docker run --rm --pull=never $IMAGE_REF caddy version || echo "⚠️ Caddy version check timed out or failed"
echo ""
echo "==> Extracting Caddy binary for inspection..."
CONTAINER_ID=$(docker create --pull=never $IMAGE_REF)
docker cp ${CONTAINER_ID}:/usr/bin/caddy ./caddy_binary
docker rm ${CONTAINER_ID}
# Determine the image reference based on event type
if [ "${{ github.event_name }}" = "pull_request" ]; then
PR_NUM="${{ github.event.pull_request.number }}"
if [ -z "${PR_NUM}" ]; then
echo "❌ ERROR: Pull request number is empty"
exit 1
fi
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${PR_NUM}"
echo "Using PR image: $IMAGE_REF"
else
if [ -z "${{ steps.build-and-push.outputs.digest }}" ]; then
echo "❌ ERROR: Build digest is empty"
exit 1
fi
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}"
echo "Using digest: $IMAGE_REF"
fi
echo ""
echo "==> Checking if Go toolchain is available locally..."
if command -v go >/dev/null 2>&1; then
echo "✅ Go found locally, inspecting binary dependencies..."
go version -m ./caddy_binary > caddy_deps.txt
echo ""
echo "==> Searching for expr-lang/expr dependency:"
if grep -i "expr-lang/expr" caddy_deps.txt; then
EXPR_VERSION=$(grep "expr-lang/expr" caddy_deps.txt | awk '{print $3}')
echo ""
echo "✅ Found expr-lang/expr: $EXPR_VERSION"
# Check if version is v1.17.7 or higher (vulnerable version is v1.16.9)
if echo "$EXPR_VERSION" | grep -E "^v1\.(1[7-9]|[2-9][0-9])\.[0-9]+$" >/dev/null; then
echo "✅ PASS: expr-lang version $EXPR_VERSION is patched (>= v1.17.7)"
else
echo "⚠️ WARNING: expr-lang version $EXPR_VERSION may be vulnerable (< v1.17.7)"
echo "Expected: v1.17.7 or higher to mitigate CVE-2025-68156"
exit 1
fi
else
echo "⚠️ expr-lang/expr not found in binary dependencies"
echo "This could mean:"
echo " 1. The dependency was stripped/optimized out"
echo " 2. Caddy was built without the expression evaluator"
echo " 3. Binary inspection failed"
echo ""
echo "Displaying all dependencies for review:"
cat caddy_deps.txt
fi
else
echo "⚠️ Go toolchain not available in CI environment"
echo "Cannot inspect binary modules - skipping dependency verification"
echo "Note: Runtime image does not require Go as Caddy is a standalone binary"
fi
# Cleanup
rm -f ./caddy_binary caddy_deps.txt
echo ""
echo "==> Verification complete"
- name: Verify CrowdSec Security Patches (CVE-2025-68156)
if: success()
continue-on-error: true
run: |
echo "🔍 Verifying CrowdSec binaries contain patched expr-lang/expr@v1.17.7..."
echo ""
# Determine the image reference based on event type
if [ "${{ github.event_name }}" = "pull_request" ]; then
PR_NUM="${{ github.event.pull_request.number }}"
if [ -z "${PR_NUM}" ]; then
echo "❌ ERROR: Pull request number is empty"
exit 1
fi
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:pr-${PR_NUM}"
echo "Using PR image: $IMAGE_REF"
else
if [ -z "${{ steps.build-and-push.outputs.digest }}" ]; then
echo "❌ ERROR: Build digest is empty"
exit 1
fi
IMAGE_REF="${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}"
echo "Using digest: $IMAGE_REF"
fi
echo ""
echo "==> CrowdSec cscli version:"
timeout 30s docker run --rm --pull=never $IMAGE_REF cscli version || echo "⚠️ CrowdSec version check timed out or failed (may not be installed for this architecture)"
echo ""
echo "==> Extracting cscli binary for inspection..."
CONTAINER_ID=$(docker create --pull=never $IMAGE_REF)
docker cp ${CONTAINER_ID}:/usr/local/bin/cscli ./cscli_binary 2>/dev/null || {
echo "⚠️ cscli binary not found - CrowdSec may not be available for this architecture"
docker rm ${CONTAINER_ID}
exit 0
}
docker rm ${CONTAINER_ID}
echo ""
echo "==> Checking if Go toolchain is available locally..."
if command -v go >/dev/null 2>&1; then
echo "✅ Go found locally, inspecting binary dependencies..."
go version -m ./cscli_binary > cscli_deps.txt
echo ""
echo "==> Searching for expr-lang/expr dependency:"
if grep -i "expr-lang/expr" cscli_deps.txt; then
EXPR_VERSION=$(grep "expr-lang/expr" cscli_deps.txt | awk '{print $3}')
echo ""
echo "✅ Found expr-lang/expr: $EXPR_VERSION"
# Check if version is v1.17.7 or higher (vulnerable version is v1.17.2)
if echo "$EXPR_VERSION" | grep -E "^v1\.(1[7-9]|[2-9][0-9])\.[7-9][0-9]*$|^v1\.17\.([7-9]|[1-9][0-9]+)$" >/dev/null; then
echo "✅ PASS: expr-lang version $EXPR_VERSION is patched (>= v1.17.7)"
else
echo "❌ FAIL: expr-lang version $EXPR_VERSION is vulnerable (< v1.17.7)"
echo "⚠️ WARNING: expr-lang version $EXPR_VERSION may be vulnerable (< v1.17.7)"
echo "Expected: v1.17.7 or higher to mitigate CVE-2025-68156"
exit 1
fi
else
echo "⚠️ expr-lang/expr not found in binary dependencies"
echo "This could mean:"
echo " 1. The dependency was stripped/optimized out"
echo " 2. CrowdSec was built without the expression evaluator"
echo " 3. Binary inspection failed"
echo ""
echo "Displaying all dependencies for review:"
cat cscli_deps.txt
fi
else
echo "⚠️ Go toolchain not available in CI environment"
echo "Cannot inspect binary modules - skipping dependency verification"
echo "Note: Runtime image does not require Go as CrowdSec is a standalone binary"
fi
# Cleanup
rm -f ./cscli_binary cscli_deps.txt
echo ""
echo "==> CrowdSec verification complete"
- name: Run Trivy scan (table output)
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'table'
severity: 'CRITICAL,HIGH'
exit-code: '0'
continue-on-error: true
- name: Run Trivy vulnerability scanner (SARIF)
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
id: trivy
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
continue-on-error: true
- name: Check Trivy SARIF exists
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
id: trivy-check
run: |
if [ -f trivy-results.sarif ]; then
echo "exists=true" >> $GITHUB_OUTPUT
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
- name: Upload Trivy results
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
with:
sarif_file: 'trivy-results.sarif'
token: ${{ secrets.GITHUB_TOKEN }}
# Generate SBOM (Software Bill of Materials) for supply chain security
# Only for production builds (main/development) - feature branches use downstream supply-chain-pr.yml
- name: Generate SBOM
uses: anchore/sbom-action@deef08a0db64bfad603422135db61477b16cef56 # v0.22.1
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
with:
image: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: cyclonedx-json
output-file: sbom.cyclonedx.json
# Create verifiable attestation for the SBOM
- name: Attest SBOM
uses: actions/attest-sbom@4651f806c01d8637787e274ac3bdf724ef169f34 # v3.0.0
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
with:
subject-name: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}
subject-digest: ${{ steps.build-and-push.outputs.digest }}
sbom-path: sbom.cyclonedx.json
push-to-registry: true
# Install Cosign for keyless signing
- name: Install Cosign
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
# Sign GHCR image with keyless signing (Sigstore/Fulcio)
- name: Sign GHCR Image
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
run: |
echo "Signing GHCR image with keyless signing..."
cosign sign --yes ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
echo "✅ GHCR image signed successfully"
# Sign Docker Hub image with keyless signing (Sigstore/Fulcio)
- name: Sign Docker Hub Image
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
run: |
echo "Signing Docker Hub image with keyless signing..."
cosign sign --yes ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
echo "✅ Docker Hub image signed successfully"
# Attach SBOM to Docker Hub image
- name: Attach SBOM to Docker Hub
if: github.event_name != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
run: |
echo "Attaching SBOM to Docker Hub image..."
cosign attach sbom --sbom sbom.cyclonedx.json ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
echo "✅ SBOM attached to Docker Hub image"
- name: Create summary
if: steps.skip.outputs.skip_build != 'true'
run: |
echo "## 🎉 Docker Image Built Successfully!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Image Details" >> $GITHUB_STEP_SUMMARY
echo "- **GHCR**: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Docker Hub**: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "- **Tags**: " >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "${{ steps.meta.outputs.tags }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
scan-pr-image:
name: Security Scan PR Image
needs: build-and-push
if: needs.build-and-push.outputs.skip_build != 'true' && github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: read
packages: read
security-events: write
steps:
- name: Normalize image name
run: |
IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
- name: Determine PR image tag
id: pr-image
run: |
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
PR_TAG="pr-${{ github.event.pull_request.number }}-${SHORT_SHA}"
echo "tag=${PR_TAG}" >> $GITHUB_OUTPUT
echo "image_ref=${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${PR_TAG}" >> $GITHUB_OUTPUT
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Validate image freshness
run: |
echo "🔍 Validating image freshness for PR #${{ github.event.pull_request.number }}..."
echo "Expected SHA: ${{ github.sha }}"
echo "Image: ${{ steps.pr-image.outputs.image_ref }}"
# Pull image to inspect
docker pull "${{ steps.pr-image.outputs.image_ref }}"
# Extract commit SHA from image label
LABEL_SHA=$(docker inspect "${{ steps.pr-image.outputs.image_ref }}" \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}')
echo "Image label SHA: ${LABEL_SHA}"
if [[ "${LABEL_SHA}" != "${{ github.sha }}" ]]; then
echo "⚠️ WARNING: Image SHA mismatch!"
echo " Expected: ${{ github.sha }}"
echo " Got: ${LABEL_SHA}"
echo "Image may be stale. Failing scan."
exit 1
fi
echo "✅ Image freshness validated"
- name: Run Trivy scan on PR image (table output)
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ steps.pr-image.outputs.image_ref }}
format: 'table'
severity: 'CRITICAL,HIGH'
exit-code: '0'
- name: Run Trivy scan on PR image (SARIF - blocking)
id: trivy-scan
uses: aquasecurity/trivy-action@b6643a29fecd7f34b3597bc6acb0a98b03d33ff8 # 0.33.1
with:
image-ref: ${{ steps.pr-image.outputs.image_ref }}
format: 'sarif'
output: 'trivy-pr-results.sarif'
severity: 'CRITICAL,HIGH'
exit-code: '1' # Block merge if vulnerabilities found
- name: Upload Trivy scan results
if: always()
uses: github/codeql-action/upload-sarif@6bc82e05fd0ea64601dd4b465378bbcf57de0314 # v4.32.1
with:
sarif_file: 'trivy-pr-results.sarif'
category: 'docker-pr-image'
- name: Create scan summary
if: always()
run: |
echo "## 🔒 PR Image Security Scan" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: ${{ steps.pr-image.outputs.image_ref }}" >> $GITHUB_STEP_SUMMARY
echo "- **PR**: #${{ github.event.pull_request.number }}" >> $GITHUB_STEP_SUMMARY
echo "- **Commit**: ${{ github.sha }}" >> $GITHUB_STEP_SUMMARY
echo "- **Scan Status**: ${{ steps.trivy-scan.outcome == 'success' && '✅ No critical vulnerabilities' || '❌ Vulnerabilities detected' }}" >> $GITHUB_STEP_SUMMARY
test-image:
name: Test Docker Image
needs: build-and-push
runs-on: ubuntu-latest
if: needs.build-and-push.outputs.skip_build != 'true' && github.event_name != 'pull_request'
env:
# Required for security teardown in integration tests
CHARON_EMERGENCY_TOKEN: ${{ secrets.CHARON_EMERGENCY_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Normalize image name
run: |
raw="${{ github.repository_owner }}/${{ github.event.repository.name }}"
IMAGE_NAME=$(echo "$raw" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${IMAGE_NAME}" >> $GITHUB_ENV
- name: Determine image tag
id: tag
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "tag=latest" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == "refs/heads/development" ]]; then
echo "tag=dev" >> $GITHUB_OUTPUT
elif [[ "${{ github.ref }}" == refs/tags/v* ]]; then
echo "tag=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
else
echo "tag=sha-$(echo ${{ github.sha }} | cut -c1-7)" >> $GITHUB_OUTPUT
fi
- name: Log in to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Pull Docker image
run: docker pull ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
- name: Create Docker Network
run: docker network create charon-test-net
- name: Run Upstream Service (whoami)
run: |
docker run -d \
--name whoami \
--network charon-test-net \
traefik/whoami:latest@sha256:200689790a0a0ea48ca45992e0450bc26ccab5307375b41c84dfc4f2475937ab
- name: Run Charon Container
timeout-minutes: 3
run: |
docker run -d \
--name test-container \
--network charon-test-net \
-p 8080:8080 \
-p 80:80 \
${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}
# Wait for container to be healthy (max 3 minutes - Debian needs more startup time)
echo "Waiting for container to start..."
timeout 180s bash -c 'until docker exec test-container curl -sf http://localhost:8080/api/v1/health 2>/dev/null | grep -q "status"; do echo "Waiting..."; sleep 2; done' || {
echo "❌ Container failed to become healthy"
docker logs test-container
exit 1
}
echo "✅ Container is healthy"
- name: Run Integration Test
timeout-minutes: 5
run: ./scripts/integration-test.sh
- name: Check container logs
if: always()
run: docker logs test-container
- name: Stop container
if: always()
run: |
docker stop test-container whoami || true
docker rm test-container whoami || true
docker network rm charon-test-net || true
- name: Create test summary
if: always()
run: |
echo "## 🧪 Docker Image Test Results" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "- **Image**: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "- **Integration Test**: ${{ job.status == 'success' && '✅ Passed' || '❌ Failed' }}" >> $GITHUB_STEP_SUMMARY