Skip to content

chore(deps): update release-drafter/release-drafter digest to eada3c9 #3511

chore(deps): update release-drafter/release-drafter digest to eada3c9

chore(deps): update release-drafter/release-drafter digest to eada3c9 #3511

Workflow file for this run

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)
# - Stable PR tagging: pr-{number} (freshness gate prevents stale scans)
# - 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:
pull_request:
push:
branches: [main, development]
workflow_dispatch:
workflow_run:
workflows: ["Docker Lint"]
types: [completed]
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.event.workflow_run.head_branch || github.ref_name }}
cancel-in-progress: true
permissions:
contents: read
env:
GHCR_REGISTRY: ghcr.io
DOCKERHUB_REGISTRY: docker.io
IMAGE_NAME: wikid82/charon
TRIVY_SARIF_CATEGORY: .github/workflows/docker-build.yml:trivy-image-scan
TRIGGER_EVENT: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.event || github.event_name }}
TRIGGER_HEAD_BRANCH: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }}
TRIGGER_HEAD_SHA: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.sha }}
TRIGGER_REF: ${{ github.event_name == 'workflow_run' && format('refs/heads/{0}', github.event.workflow_run.head_branch) || github.ref }}
TRIGGER_HEAD_REF: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.head_ref }}
TRIGGER_PR_NUMBER: ${{ github.event_name == 'pull_request' && format('{0}', github.event.pull_request.number) || '' }}
TRIGGER_ACTOR: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.actor.login || github.actor }}
TRIGGER_BASE_REF: ${{ github.event_name == 'workflow_run' && '' || github.base_ref }}
jobs:
build-and-push:
if: ${{ github.event_name != 'workflow_run' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.name == 'Docker Lint' && github.event.workflow_run.path == '.github/workflows/docker-lint.yml') }}
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ env.TRIGGER_HEAD_SHA }}
- 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: ${{ env.TRIGGER_ACTOR }}
EVENT: ${{ env.TRIGGER_EVENT }}
REF: ${{ env.TRIGGER_REF }}
HEAD_REF: ${{ env.TRIGGER_HEAD_REF }}
PR_NUMBER: ${{ env.TRIGGER_PR_NUMBER }}
REPO: ${{ github.repository }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
should_skip=false
pr_title=""
force_refresh=false
changed_files=""
head_msg=$(git log -1 --pretty=%s)
if [ "$EVENT" = "pull_request" ] && [ -n "$PR_NUMBER" ]; then
pr_title=$(curl -sS \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}" | jq -r '.title // empty')
files_url="https://api.github.com/repos/${REPO}/pulls/${PR_NUMBER}/files?per_page=100&page=1"
while [ -n "$files_url" ]; do
response_file=$(mktemp)
headers_file=$(mktemp)
if ! curl -fsS \
-H "Authorization: Bearer ${GH_TOKEN}" \
-H "Accept: application/vnd.github+json" \
-D "$headers_file" \
"$files_url" > "$response_file"; then
echo "Failed to fetch changed files from GitHub API, forcing refresh"
force_refresh=true
rm -f "$response_file" "$headers_file"
break
fi
page_files=$(jq -r '.[].filename // empty' "$response_file")
if [ -n "$page_files" ]; then
if [ -n "$changed_files" ]; then
changed_files="${changed_files}\n${page_files}"
else
changed_files="${page_files}"
fi
fi
files_url=$(awk 'BEGIN{IGNORECASE=1} /^link:/ {print}' "$headers_file" \
| sed -n 's/.*<\([^>]*\)>; rel="next".*/\1/p' | head -n 1)
rm -f "$response_file" "$headers_file"
done
else
changed_files=$(git diff --name-only HEAD~1 HEAD 2>/dev/null || true)
fi
if echo "$changed_files" | grep -E '^(Dockerfile|\.github/workflows/(docker-build|docker-lint|security-weekly-rebuild|supply-chain-verify|supply-chain-pr|nightly-build)\.yml|trivy\.yaml|\.grype\.yaml)$' >/dev/null 2>&1; then
force_refresh=true
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
if [[ "$force_refresh" == "true" ]]; then
should_skip=false
echo "Force refresh enabled due to Docker/security workflow changes"
fi
# Always build on feature branches to ensure artifacts for testing
# For PRs: use HEAD_REF (actual source branch)
# For pushes: use REF (refs/heads/branch-name)
is_feature_push=false
if [[ "$EVENT" != "pull_request" && "$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"
echo "is_feature_push=$is_feature_push"
echo "force_refresh=$force_refresh"
} >> "$GITHUB_OUTPUT"
- name: Set up QEMU
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0
- name: Set up Docker Buildx
if: steps.skip.outputs.skip_build != 'true'
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
- name: Resolve Alpine base image digest
if: steps.skip.outputs.skip_build != 'true'
id: alpine
run: |
ALPINE_TAG=$(grep -m1 'ARG ALPINE_IMAGE=' Dockerfile | sed 's/ARG ALPINE_IMAGE=alpine://' | cut -d'@' -f1)
docker pull "alpine:${ALPINE_TAG}"
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "alpine:${ALPINE_TAG}")
echo "image=$DIGEST" >> "$GITHUB_OUTPUT"
- name: Log in to GitHub Container Registry
if: steps.skip.outputs.skip_build != 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Docker Hub
if: steps.skip.outputs.skip_build != 'true' && env.HAS_DOCKERHUB_TOKEN == 'true'
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Compute branch tags
if: steps.skip.outputs.skip_build != 'true'
id: branch-tags
run: |
if [[ "$TRIGGER_EVENT" == "pull_request" ]]; then
BRANCH_NAME="${TRIGGER_HEAD_REF}"
else
BRANCH_NAME="${TRIGGER_REF#refs/heads/}"
fi
SHORT_SHA="$(echo "${{ env.TRIGGER_HEAD_SHA }}" | cut -c1-7)"
sanitize_tag() {
local raw="$1"
local max_len="$2"
local sanitized
sanitized=$(echo "$raw" | tr '[:upper:]' '[:lower:]')
sanitized=${sanitized//[^a-z0-9-]/-}
while [[ "$sanitized" == *"--"* ]]; do
sanitized=${sanitized//--/-}
done
sanitized=${sanitized##[^a-z0-9]*}
sanitized=${sanitized%%[^a-z0-9-]*}
if [ -z "$sanitized" ]; then
sanitized="branch"
fi
sanitized=$(echo "$sanitized" | cut -c1-"$max_len")
sanitized=${sanitized##[^a-z0-9]*}
if [ -z "$sanitized" ]; then
sanitized="branch"
fi
echo "$sanitized"
}
SANITIZED_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 128)
BASE_BRANCH=$(sanitize_tag "${BRANCH_NAME}" 120)
BRANCH_SHA_TAG="${BASE_BRANCH}-${SHORT_SHA}"
if [[ "$TRIGGER_EVENT" == "pull_request" ]]; then
if [[ "$BRANCH_NAME" == feature/* ]]; then
echo "pr_feature_branch_sha_tag=${BRANCH_SHA_TAG}" >> "$GITHUB_OUTPUT"
fi
else
echo "branch_sha_tag=${BRANCH_SHA_TAG}" >> "$GITHUB_OUTPUT"
if [[ "$TRIGGER_REF" == refs/heads/feature/* ]]; then
echo "feature_branch_tag=${SANITIZED_BRANCH}" >> "$GITHUB_OUTPUT"
echo "feature_branch_sha_tag=${BRANCH_SHA_TAG}" >> "$GITHUB_OUTPUT"
fi
fi
- name: Resolve security scan image reference (contract)
if: steps.skip.outputs.skip_build != 'true'
id: pr_image_ref_output
run: |
set -euo pipefail
sanitize_branch() {
local raw_branch="$1"
local sanitized_branch
sanitized_branch=$(echo "${raw_branch}" | tr '[:upper:]' '[:lower:]')
sanitized_branch=${sanitized_branch//[^a-z0-9-]/-}
while [[ "${sanitized_branch}" == *"--"* ]]; do
sanitized_branch=${sanitized_branch//--/-}
done
sanitized_branch=${sanitized_branch##[^a-z0-9]*}
sanitized_branch=${sanitized_branch%%[^a-z0-9-]*}
if [[ -z "${sanitized_branch}" ]]; then
sanitized_branch="branch"
fi
echo "${sanitized_branch}"
}
IMAGE_TAG=""
if [[ "${TRIGGER_EVENT}" == "pull_request" ]]; then
PR_NUMBER="${TRIGGER_PR_NUMBER}"
if [[ "${GITHUB_EVENT_NAME}" == "workflow_run" ]]; then
mapfile -t WORKFLOW_RUN_PR_NUMBERS < <(jq -r '.workflow_run.pull_requests[].number // empty' "$GITHUB_EVENT_PATH")
if [[ "${#WORKFLOW_RUN_PR_NUMBERS[@]}" != "1" ]]; then
echo "❌ ERROR: Expected exactly one workflow_run pull request number, found ${#WORKFLOW_RUN_PR_NUMBERS[@]}"
exit 1
fi
PR_NUMBER="${WORKFLOW_RUN_PR_NUMBERS[0]}"
fi
if [[ -z "${PR_NUMBER}" || "${PR_NUMBER}" == "null" ]]; then
echo "❌ ERROR: Missing pull request number for stable security scan tag"
exit 1
fi
IMAGE_TAG="pr-${PR_NUMBER}"
elif [[ "${TRIGGER_REF}" == refs/heads/nightly ]]; then
IMAGE_TAG="nightly"
elif [[ "${TRIGGER_REF}" == refs/heads/main ]]; then
IMAGE_TAG="main"
elif [[ "${TRIGGER_REF}" == refs/heads/development ]]; then
IMAGE_TAG="development"
else
BRANCH_NAME="${TRIGGER_HEAD_BRANCH:-${TRIGGER_REF#refs/heads/}}"
IMAGE_TAG="branch-$(sanitize_branch "${BRANCH_NAME}")"
fi
IMAGE_REF="${GHCR_REGISTRY}/${IMAGE_NAME}:${IMAGE_TAG}"
echo "image_tag=${IMAGE_TAG}" >> "$GITHUB_OUTPUT"
echo "image_ref=${IMAGE_REF}" >> "$GITHUB_OUTPUT"
echo "Resolved security scan image reference: ${IMAGE_REF}"
{
echo "## PR Image Reference Diagnostics"
echo ""
echo "- Image tag: ${IMAGE_TAG}"
echo "- Emitted image_ref: ${IMAGE_REF}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.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=${{ env.TRIGGER_REF == 'refs/heads/main' }}
type=raw,value=dev,enable=${{ env.TRIGGER_REF == 'refs/heads/development' }}
type=raw,value=nightly,enable=${{ env.TRIGGER_REF == 'refs/heads/nightly' }}
type=raw,value=beta,enable=${{ env.TRIGGER_EVENT == 'pull_request' && env.TRIGGER_BASE_REF == 'development' }}
type=raw,value=${{ steps.pr_image_ref_output.outputs.image_tag }},enable=${{ steps.pr_image_ref_output.outputs.image_tag != '' }}
type=raw,value=${{ steps.branch-tags.outputs.feature_branch_tag }},enable=${{ env.TRIGGER_EVENT != 'pull_request' && startsWith(env.TRIGGER_REF, 'refs/heads/feature/') && steps.branch-tags.outputs.feature_branch_tag != '' }}
type=raw,value=${{ steps.branch-tags.outputs.branch_sha_tag }},enable=${{ env.TRIGGER_EVENT != 'pull_request' && steps.branch-tags.outputs.branch_sha_tag != '' }}
type=sha,format=short,prefix=,suffix=,enable=${{ env.TRIGGER_EVENT != 'pull_request' && (env.TRIGGER_REF == 'refs/heads/main' || env.TRIGGER_REF == 'refs/heads/development' || env.TRIGGER_REF == 'refs/heads/nightly') }}
flavor: |
latest=false
labels: |
org.opencontainers.image.revision=${{ env.TRIGGER_HEAD_SHA }}
io.charon.pr.number=${{ env.TRIGGER_PR_NUMBER }}
io.charon.build.timestamp=${{ github.event.repository.updated_at }}
io.charon.feature.branch=${{ steps.branch-tags.outputs.feature_branch_tag }}
- name: Assert PR output contract
if: steps.skip.outputs.skip_build != 'true' && env.TRIGGER_EVENT == 'pull_request'
run: |
IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}"
if [[ -z "${IMAGE_REF}" ]]; then
echo "❌ ERROR: image_ref output contract violated (empty output)"
exit 1
fi
# Phase 1 Optimization: Build once, test many
# - For PRs: Multi-platform (amd64, arm64) + stable security-scan tag (pr-{number})
# - For feature branches: Multi-platform (amd64, arm64) + sanitized tags ({branch}-{short-sha})
# - For main/dev/nightly: 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@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.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..."
PLATFORMS="linux/amd64,linux/arm64"
echo "Platform: ${PLATFORMS}"
# 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 "${PLATFORMS}"
--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=${{ env.TRIGGER_HEAD_SHA }}"
--build-arg "ALPINE_IMAGE=${{ steps.alpine.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 only, pull the image back locally for artifact creation
# Feature branches now build multi-platform and cannot be loaded locally
# This enables backward compatibility with workflows that use artifacts
if [[ "${{ env.TRIGGER_EVENT }}" == "pull_request" ]]; then
echo "📥 Pulling image back for artifact creation..."
PR_IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}"
if [[ -z "${PR_IMAGE_REF}" ]]; then
echo "❌ ERROR: image_ref output contract violated during pull-back"
exit 1
fi
docker pull "${PR_IMAGE_REF}"
echo "✅ Image pulled: ${PR_IMAGE_REF}"
fi
# Use the stable security scan reference so the saved artifact matches the
# image that the PR scan job will pull and validate.
- name: Save Docker Image as Artifact
if: success() && steps.skip.outputs.skip_build != 'true' && env.TRIGGER_EVENT == 'pull_request'
run: |
IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}"
if [[ -z "${IMAGE_REF}" ]]; then
echo "❌ ERROR: image_ref output contract violated (empty image ref)"
exit 1
fi
echo "🔍 Detected image ref: ${IMAGE_REF}"
# Verify the image exists locally
if ! docker image inspect "${IMAGE_REF}" >/dev/null 2>&1; then
echo "❌ ERROR: Image ${IMAGE_REF} not found locally"
echo "📋 Available images:"
docker images
exit 1
fi
# Save the image using the exact tag from metadata
echo "💾 Saving image: ${IMAGE_REF}"
docker save "${IMAGE_REF}" -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' && env.TRIGGER_EVENT == 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ env.TRIGGER_EVENT == 'pull_request' && format('pr-image-{0}', env.TRIGGER_PR_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 [ "${{ env.TRIGGER_EVENT }}" = "pull_request" ]; then
IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}"
if [ -z "${IMAGE_REF}" ]; then
echo "❌ ERROR: Failed to load PR image reference from image_ref output"
exit 1
fi
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 [ "${{ env.TRIGGER_EVENT }}" = "pull_request" ]; then
IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}"
if [ -z "${IMAGE_REF}" ]; then
echo "❌ ERROR: Failed to load PR image reference from image_ref output"
exit 1
fi
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 [ "${{ env.TRIGGER_EVENT }}" = "pull_request" ]; then
IMAGE_REF="${{ steps.pr_image_ref_output.outputs.image_ref }}"
if [ -z "${IMAGE_REF}" ]; then
echo "❌ ERROR: Failed to load PR image reference from image_ref output"
exit 1
fi
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: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'table'
severity: 'CRITICAL,HIGH'
exit-code: '0'
version: 'v0.72.0'
trivyignores: '.trivyignore'
continue-on-error: true
- name: Run Trivy vulnerability scanner (SARIF)
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
id: trivy
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
format: 'sarif'
output: 'trivy-results.sarif'
severity: 'CRITICAL,HIGH'
version: 'v0.72.0'
trivyignores: '.trivyignore'
continue-on-error: true
- name: Check Trivy SARIF exists
if: env.TRIGGER_EVENT != '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: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
sarif_file: 'trivy-results.sarif'
category: ${{ env.TRIVY_SARIF_CATEGORY }}
token: ${{ secrets.GITHUB_TOKEN }}
- name: Record Trivy scan traceability
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.trivy-check.outputs.exists == 'true'
env:
IMAGE_REF: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}
run: |
CADDY_VERSION=$(docker run --rm --pull=never "${IMAGE_REF}" caddy version 2>/dev/null || echo "unknown")
{
echo "## Trivy SARIF Traceability"
echo ""
echo "- Category: ${{ env.TRIVY_SARIF_CATEGORY }}"
echo "- Image: ${IMAGE_REF}"
echo "- Digest: ${{ steps.build-and-push.outputs.digest }}"
echo "- Caddy version: ${CADDY_VERSION}"
} >> "$GITHUB_STEP_SUMMARY"
# 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@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0
if: env.TRIGGER_EVENT != '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
syft-version: v1.45.1
# Create verifiable attestation for the SBOM
# (actions/attest-sbom is deprecated; actions/attest supports sbom-path natively)
# GitHub's OIDC token endpoint occasionally fails transiently
# ("CI: no tokens available"), so retry once before failing the build
- name: Attest SBOM
id: attest-sbom
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
continue-on-error: 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
- name: Attest SBOM (retry)
if: steps.attest-sbom.outcome == 'failure'
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4.2.0
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: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2
# Sign GHCR image with keyless signing (Sigstore/Fulcio)
# Retry up to 3 times to handle transient Fulcio/Rekor INTERNAL_ERROR (HTTP/2 stream errors)
- name: Sign GHCR Image
if: env.TRIGGER_EVENT != 'pull_request' && steps.skip.outputs.skip_build != 'true' && steps.skip.outputs.is_feature_push != 'true'
run: |
echo "Signing GHCR image with keyless signing..."
for attempt in 1 2 3; do
if cosign sign --yes ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}; then
echo "✅ GHCR image signed successfully"
break
fi
if [ "$attempt" -eq 3 ]; then
echo "❌ GHCR signing failed after 3 attempts"
exit 1
fi
echo "⚠️ Attempt $attempt failed — retrying in 15s..."
sleep 15
done
# Sign Docker Hub image with keyless signing (Sigstore/Fulcio)
# Retry up to 3 times to handle transient Fulcio/Rekor INTERNAL_ERROR (HTTP/2 stream errors)
- name: Sign Docker Hub Image
if: env.TRIGGER_EVENT != '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..."
for attempt in 1 2 3; do
if cosign sign --yes ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build-and-push.outputs.digest }}; then
echo "✅ Docker Hub image signed successfully"
break
fi
if [ "$attempt" -eq 3 ]; then
echo "❌ Docker Hub signing failed after 3 attempts"
exit 1
fi
echo "⚠️ Attempt $attempt failed — retrying in 15s..."
sleep 15
done
# Attach SBOM to Docker Hub image
- name: Attach SBOM to Docker Hub
if: env.TRIGGER_EVENT != '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!"
echo ""
echo "### 📦 Image Details"
echo "- **GHCR**: ${{ env.GHCR_REGISTRY }}/${{ env.IMAGE_NAME }}"
echo "- **Docker Hub**: ${{ env.DOCKERHUB_REGISTRY }}/${{ env.IMAGE_NAME }}"
echo "- **Tags**: "
echo '```'
echo "${{ steps.meta.outputs.tags }}"
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' && needs.build-and-push.result == 'success' && github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
packages: read
security-events: write
steps:
- name: Checkout repository for Trivy ignore rules
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- name: Normalize image name
run: |
IMAGE_NAME=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
echo "IMAGE_NAME=${IMAGE_NAME}" >> "$GITHUB_ENV"
- name: Load PR image reference
id: pr-image
run: |
DIGEST="${{ needs.build-and-push.outputs.digest }}"
if [[ -z "$DIGEST" ]]; then
echo "❌ ERROR: build-and-push digest output is empty; cannot proceed without immutable reference"
exit 1
fi
if ! [[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
echo "❌ ERROR: Invalid digest format (expected sha256:<64 hex chars>): ${DIGEST}"
exit 1
fi
IMAGE_REF="ghcr.io/${IMAGE_NAME}@${DIGEST}"
echo "digest=${DIGEST}" >> "$GITHUB_OUTPUT"
echo "image_ref=${IMAGE_REF}" >> "$GITHUB_OUTPUT"
echo "Using immutable digest reference: ${IMAGE_REF}"
- name: Log in to GitHub Container Registry
uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0
with:
registry: ${{ env.GHCR_REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Inspect PR image metadata
id: image-metadata
run: |
echo "🔍 Inspecting image metadata for PR #${{ env.TRIGGER_PR_NUMBER }}..."
echo "Image: ${{ steps.pr-image.outputs.image_ref }}"
# Pull the immutable digest reference to inspect labels.
docker pull "${{ steps.pr-image.outputs.image_ref }}"
LABEL_SHA=$(docker inspect "${{ steps.pr-image.outputs.image_ref }}" \
--format '{{index .Config.Labels "org.opencontainers.image.revision"}}')
echo "Image label SHA: ${LABEL_SHA}"
echo "label_sha=${LABEL_SHA}" >> "$GITHUB_OUTPUT"
- name: Validate image freshness
run: |
LABEL_SHA="${{ steps.image-metadata.outputs.label_sha }}"
if [[ -z "${LABEL_SHA}" ]]; then
echo "❌ ERROR: Missing image revision label"
exit 1
fi
if [[ "${LABEL_SHA}" != "${{ env.TRIGGER_HEAD_SHA }}" ]]; then
echo "❌ ERROR: Image SHA mismatch!"
echo " Expected: ${{ env.TRIGGER_HEAD_SHA }}"
echo " Got: ${LABEL_SHA}"
echo "Image may be stale. Failing closed."
exit 1
fi
echo "✅ Image freshness validated"
- name: Run Trivy scan on PR image (table output)
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ steps.pr-image.outputs.image_ref }}
format: 'table'
scanners: 'vuln'
trivyignores: '.trivyignore'
severity: 'CRITICAL,HIGH'
exit-code: '0'
version: 'v0.72.0'
- name: Run Trivy scan on PR image (SARIF - blocking)
id: trivy-scan
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
with:
image-ref: ${{ steps.pr-image.outputs.image_ref }}
format: 'sarif'
output: 'trivy-pr-results.sarif'
scanners: 'vuln'
trivyignores: '.trivyignore'
severity: 'CRITICAL,HIGH'
# Keep scanning strict for CRITICAL/HIGH; fail is enforced explicitly
# at the end so SARIF upload and summaries still run.
exit-code: '1'
version: 'v0.72.0'
continue-on-error: true
- name: Check Trivy PR SARIF exists
if: always()
id: trivy-pr-check
run: |
if [ -f trivy-pr-results.sarif ]; then
echo "exists=true" >> "$GITHUB_OUTPUT"
else
echo "exists=false" >> "$GITHUB_OUTPUT"
fi
- name: Upload Trivy scan results
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
uses: github/codeql-action/upload-sarif@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
sarif_file: 'trivy-pr-results.sarif'
category: ${{ env.TRIVY_SARIF_CATEGORY }}
- name: Record PR Trivy scan traceability
if: always() && steps.trivy-pr-check.outputs.exists == 'true'
env:
IMAGE_REF: ${{ steps.pr-image.outputs.image_ref }}
run: |
CADDY_VERSION="unknown"
CADDY_VERSION_SOURCE="fallback-not-available"
# Prefer absolute entrypoint to avoid shell/command resolution differences.
if CADDY_VERSION=$(timeout 30s docker run --rm --pull=never --entrypoint /usr/bin/caddy "${IMAGE_REF}" version 2>/dev/null); then
CADDY_VERSION_SOURCE="--entrypoint /usr/bin/caddy"
elif CADDY_VERSION=$(timeout 30s docker run --rm --pull=never --entrypoint caddy "${IMAGE_REF}" version 2>/dev/null); then
CADDY_VERSION_SOURCE="--entrypoint caddy"
fi
{
echo "## PR Trivy SARIF Traceability"
echo ""
echo "- Category: ${{ env.TRIVY_SARIF_CATEGORY }}"
echo "- Image: ${IMAGE_REF}"
echo "- Digest: ${IMAGE_REF#*@}"
echo "- Caddy version: ${CADDY_VERSION}"
echo "- Caddy version source: ${CADDY_VERSION_SOURCE}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Create scan summary
if: always()
run: |
{
echo "## 🔒 PR Image Security Scan"
echo ""
echo "- **Image**: ${{ steps.pr-image.outputs.image_ref }}"
echo "- **Image Digest**: ${{ steps.pr-image.outputs.digest }}"
echo "- **Image Revision Label SHA**: ${{ steps.image-metadata.outputs.label_sha || 'missing' }}"
echo "- **PR**: #${{ env.TRIGGER_PR_NUMBER }}"
echo "- **Commit**: ${{ env.TRIGGER_HEAD_SHA }}"
echo "- **Scan Status**: ${{ steps.trivy-scan.outcome == 'success' && '✅ No critical vulnerabilities' || '❌ Vulnerabilities detected' }}"
} >> "$GITHUB_STEP_SUMMARY"
- name: Diagnose unsuppressed PR Trivy blockers
if: always()
continue-on-error: true
run: |
SARIF_PATH="trivy-pr-results.sarif"
FALLBACK_REASON=""
FINDINGS_COUNT="0"
UNIQUE_IDS_CSV="none"
PARSER_EXIT_CODE=""
PARSER_HINT=""
echo "Unsuppressed HIGH/CRITICAL findings (from ${SARIF_PATH}):"
if [[ ! -f "${SARIF_PATH}" ]]; then
FALLBACK_REASON="file missing"
elif [[ ! -r "${SARIF_PATH}" ]]; then
FALLBACK_REASON="file unreadable"
elif ! jq -e '.' "${SARIF_PATH}" >/dev/null 2>&1; then
FALLBACK_REASON="invalid JSON"
elif ! jq -e '(.runs | type) == "array"' "${SARIF_PATH}" >/dev/null 2>&1; then
FALLBACK_REASON="unexpected schema"
else
PARSER_ERR_FILE=$(mktemp)
if IDS_JSON=$(jq -c '
[
.runs[]? as $run
| (($run.results // [])[]?) as $result
| select((($result.suppressions // []) | length) == 0)
| {
id: (
$result.ruleId
// ($result.rule // {} | .id)
// (
if ($result.ruleIndex != null and (($run.tool.driver.rules? // null) | type) == "array") then
($run.tool.driver.rules[$result.ruleIndex].id // empty)
else
empty
end
)
// "unknown"
)
}
]
' "${SARIF_PATH}" 2>"${PARSER_ERR_FILE}"); then
FINDINGS_COUNT=$(echo "${IDS_JSON}" | jq 'length' 2>/dev/null || echo "0")
UNIQUE_IDS_CSV=$(echo "${IDS_JSON}" | jq -r '[.[].id | select(. != "" and . != null)] | unique | join(", ")' 2>/dev/null || echo "none")
if [[ -z "${UNIQUE_IDS_CSV}" ]]; then
UNIQUE_IDS_CSV="none"
fi
if [[ "${FINDINGS_COUNT}" -gt 0 ]]; then
if ! jq -r '
.runs[]? as $run
| (($run.results // [])[]?) as $result
| select((($result.suppressions // []) | length) == 0)
| "- \((
$result.ruleId
// ($result.rule // {} | .id)
// (
if ($result.ruleIndex != null and (($run.tool.driver.rules? // null) | type) == \"array\") then
($run.tool.driver.rules[$result.ruleIndex].id // \"unknown\")
else
\"unknown\"
end
)
)) | package: \((
($result.message.text // \"\")
| (try capture(\"(?i)(?:Package|PkgName|Pkg|Library)\\\\s*[:=]\\\\s*`?(?<pkg>[A-Za-z0-9._+:+-]+)`?\").pkg catch \"n/a\")
))"
' "${SARIF_PATH}"; then
echo "- unable to render parsed findings"
fi
else
echo "- none"
fi
else
FALLBACK_REASON="parser command failure"
PARSER_EXIT_CODE="$?"
PARSER_HINT=$(head -n 1 "${PARSER_ERR_FILE}" | tr -d '\r' || true)
if [[ -z "${PARSER_HINT}" ]]; then
PARSER_HINT="no parser stderr available"
fi
fi
rm -f "${PARSER_ERR_FILE}"
fi
{
echo "## PR Trivy Unsuppressed Blockers"
echo ""
if [[ -n "${FALLBACK_REASON}" ]]; then
echo "- Diagnostics status: fallback"
echo "- Fallback reason: ${FALLBACK_REASON}"
if [[ "${FALLBACK_REASON}" == "parser command failure" ]]; then
echo "- Parser exit code: ${PARSER_EXIT_CODE:-unknown}"
echo "- Parser hint: ${PARSER_HINT}"
fi
echo "- Count: unknown"
echo "- Blocker IDs: unknown"
else
echo "- Diagnostics status: parsed"
echo "- Count: ${FINDINGS_COUNT}"
if [[ "${FINDINGS_COUNT}" -gt 0 ]]; then
echo "- Blocker IDs: ${UNIQUE_IDS_CSV}"
else
echo "- Blocker IDs: none"
fi
fi
} >> "$GITHUB_STEP_SUMMARY"
- name: Enforce PR Trivy security gate
if: always()
run: |
if [[ "${{ steps.trivy-scan.outcome }}" != "success" ]]; then
echo "❌ Blocking merge: PR image has CRITICAL/HIGH vulnerabilities or scan failed"
exit 1
fi