name: Docker Build, Scan & Publish on: workflow_call: inputs: images: description: > JSON array of images to build. Each item: { name, context, dockerfile, target, cache_ref } required: true type: string checkout_repo: description: > Optional external repo to checkout (owner/name). If set, repo is checked out into checkout_path. required: false type: string default: "" checkout_ref: description: > Optional git ref (branch/tag/SHA) for checkout_repo. Defaults to repo default branch. required: false type: string default: "" checkout_path: description: > Path to checkout checkout_repo into. required: false type: string default: "external-src" checkout_fetch_depth: description: > Fetch depth for checkout_repo (0 = full history). required: false type: string default: "0" registry_host: required: true type: string build_args: required: false type: string default: "" description: > Multiline build args, one per line: KEY=VALUE (values may include spaces) extra_tags: description: > Optional extra tags to apply to each image. Accepts JSON array (e.g. ["latest", "main"]) or newline-separated list. required: false type: string default: "" skip_if_tag_exists: description: > If true, skip build when all images already have sha- tags. required: false type: string default: "false" env: description: > Multiline env vars, one per line: KEY=VALUE required: false type: string default: "" trivy_severity: required: false type: string default: "CRITICAL" secrets: registry_user: required: true registry_password: required: true ci_token: required: true ssh_private_key: required: false ssh_known_hosts: required: false jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Checkout external repository if: ${{ inputs.checkout_repo != '' }} uses: actions/checkout@v4 with: repository: ${{ inputs.checkout_repo }} ref: ${{ inputs.checkout_ref }} server-url: ${{ github.server_url }} token: ${{ secrets.ci_token }} path: ${{ inputs.checkout_path }} fetch-depth: ${{ inputs.checkout_fetch_depth }} - name: Load env vars if: ${{ inputs.env != '' }} run: | while IFS= read -r line; do [ -z "$line" ] && continue case "$line" in \#*) continue;; esac if [[ "$line" != *=* ]]; then echo "Invalid env line: $line" >&2 exit 1 fi echo "$line" >> "$GITHUB_ENV" done <<< "${{ inputs.env }}" - name: Start ssh-agent if: ${{ secrets.ssh_private_key != '' }} uses: webfactory/ssh-agent@v0.9.0 with: ssh-private-key: ${{ secrets.ssh_private_key }} - name: Add SSH known hosts if: ${{ secrets.ssh_known_hosts != '' }} run: | mkdir -p ~/.ssh printf '%s\n' "${{ secrets.ssh_known_hosts }}" >> ~/.ssh/known_hosts chmod 644 ~/.ssh/known_hosts - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Install Trivy run: | curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin trivy --version - name: Login to registry uses: docker/login-action@v3 with: registry: ${{ inputs.registry_host }} username: ${{ secrets.registry_user }} password: ${{ secrets.registry_password }} - name: Build, scan and push images env: IMAGES: ${{ inputs.images }} BUILD_ARGS: ${{ inputs.build_args }} EXTRA_TAGS_INPUT: ${{ inputs.extra_tags }} CHECKOUT_REPO: ${{ inputs.checkout_repo }} CHECKOUT_PATH: ${{ inputs.checkout_path }} SKIP_IF_TAG_EXISTS: ${{ inputs.skip_if_tag_exists }} CI_TOKEN: ${{ secrets.ci_token }} TRIVY_SEVERITY: ${{ inputs.trivy_severity }} run: | set -euo pipefail if ! echo "$IMAGES" | jq -e . >/dev/null; then echo "inputs.images must be valid JSON" >&2 exit 1 fi if ! echo "$IMAGES" | jq -e 'type == "array"' >/dev/null; then echo "inputs.images must be a JSON array" >&2 exit 1 fi SSH_FLAGS=() if [ -n "${SSH_AUTH_SOCK:-}" ]; then SSH_FLAGS+=(--ssh default) fi SSH_BAKE_JSON="null" if [ -n "${SSH_AUTH_SOCK:-}" ]; then SSH_BAKE_JSON='["default"]' fi BAKE_ALLOW_FLAGS=() if [ -n "${SSH_AUTH_SOCK:-}" ]; then BAKE_ALLOW_FLAGS+=(--allow=ssh) fi RAW_REF="${{ github.ref }}" SHA_FULL="${{ github.sha }}" SOURCE_SHA_FULL="$SHA_FULL" if [ -n "$CHECKOUT_REPO" ] && [ -d "$CHECKOUT_PATH/.git" ]; then SOURCE_SHA_FULL=$(git -C "$CHECKOUT_PATH" rev-parse HEAD) fi SHA_FULL="$SOURCE_SHA_FULL" SHA_SHORT="${SHA_FULL:0:12}" truthy() { case "${1,,}" in 1|true|yes|y|on) return 0;; *) return 1;; esac } if truthy "$SKIP_IF_TAG_EXISTS"; then missing=0 while read -r img; do IMAGE_NAME=$(echo "$img" | jq -r '.name') FULL_IMAGE="${{ inputs.registry_host }}/$IMAGE_NAME" if docker manifest inspect "$FULL_IMAGE:sha-$SHA_SHORT" >/dev/null 2>&1; then echo "==== Found existing tag $FULL_IMAGE:sha-$SHA_SHORT ====" else echo "==== Missing tag $FULL_IMAGE:sha-$SHA_SHORT (will build) ====" missing=1 fi done < <(echo "$IMAGES" | jq -c '.[]') if [ "$missing" -eq 0 ]; then echo "All images already have sha-$SHA_SHORT tag. Skipping build." exit 0 fi fi VERSION_TAGS=() if [[ "$RAW_REF" =~ ^refs/tags/v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then VERSION_TAGS+=("v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}") VERSION_TAGS+=("v${BASH_REMATCH[1]}.${BASH_REMATCH[2]}") VERSION_TAGS+=("v${BASH_REMATCH[1]}") VERSION_TAGS+=("latest") fi EXTRA_TAGS=() if [ -n "$EXTRA_TAGS_INPUT" ]; then if echo "$EXTRA_TAGS_INPUT" | jq -e . >/dev/null 2>&1; then if ! echo "$EXTRA_TAGS_INPUT" | jq -e 'type == "array"' >/dev/null 2>&1; then echo "inputs.extra_tags must be a JSON array" >&2 exit 1 fi mapfile -t EXTRA_TAGS < <(echo "$EXTRA_TAGS_INPUT" | jq -r '.[]') else while IFS= read -r line; do trimmed="${line#"${line%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" [ -z "$trimmed" ] && continue case "$trimmed" in \#*) continue;; esac EXTRA_TAGS+=("$trimmed") done <<< "$EXTRA_TAGS_INPUT" fi fi BUILD_ARG_FLAGS=() BUILD_ARGS_JSON="{}" if [ -n "$BUILD_ARGS" ]; then while IFS= read -r line; do trimmed="${line#"${line%%[![:space:]]*}"}" trimmed="${trimmed%"${trimmed##*[![:space:]]}"}" [ -z "$trimmed" ] && continue case "$trimmed" in \#*) continue;; esac if [[ "$trimmed" != *=* ]]; then echo "Invalid build arg: $trimmed" >&2 exit 1 fi BUILD_ARG_FLAGS+=(--build-arg "$trimmed") key="${trimmed%%=*}" val="${trimmed#*=}" BUILD_ARGS_JSON=$(jq --arg k "$key" --arg v "$val" '. + {($k): $v}' <<<"$BUILD_ARGS_JSON") done <<< "$BUILD_ARGS" fi normalize_path() { local p="$1" while [[ "$p" == ./* ]]; do p="${p#./}" done if [ "$p" != "/" ]; then p="${p%/}" fi printf '%s' "$p" } while read -r group; do GROUP_COUNT=$(echo "$group" | jq 'length') CONTEXT=$(echo "$group" | jq -r '.[0].context') DOCKERFILE=$(echo "$group" | jq -r '.[0].dockerfile') context_norm="$(normalize_path "$CONTEXT")" dockerfile_norm="$(normalize_path "$DOCKERFILE")" DOCKERFILE_FOR_BAKE="$DOCKERFILE" DOCKERFILE_FOR_BUILD="$DOCKERFILE" if [ -n "$context_norm" ] && [ "$context_norm" != "." ]; then if [[ "$dockerfile_norm" == "$context_norm/"* ]]; then DOCKERFILE_FOR_BAKE="${dockerfile_norm#$context_norm/}" DOCKERFILE_FOR_BUILD="$dockerfile_norm" elif [[ "$dockerfile_norm" != /* ]]; then DOCKERFILE_FOR_BUILD="${context_norm}/${dockerfile_norm}" fi fi if [ "$GROUP_COUNT" -gt 1 ]; then echo "==== Building $GROUP_COUNT images from $DOCKERFILE (bake) ====" CACHE_REF_SUFFIXES=$(echo "$group" | jq -c 'map(.cache_ref // empty) | map(select(length > 0)) | unique') CACHE_REF_SUFFIX=$(echo "$CACHE_REF_SUFFIXES" | jq -r '.[0] // empty') UNIQUE_CACHE_COUNT=$(echo "$CACHE_REF_SUFFIXES" | jq -r 'length') if [ "$UNIQUE_CACHE_COUNT" -gt 1 ]; then echo "Warning: multiple cache_ref values for the same dockerfile. Using $CACHE_REF_SUFFIX." >&2 fi CACHE_REF="" if [ -n "$CACHE_REF_SUFFIX" ]; then CACHE_REF="${{ inputs.registry_host }}/$CACHE_REF_SUFFIX" fi TARGETS_JSON="{}" TARGET_NAMES=() while read -r entry; do IDX=$(echo "$entry" | jq -r '.key') IMG=$(echo "$entry" | jq -c '.value') IMAGE_NAME=$(echo "$IMG" | jq -r '.name') TARGET=$(echo "$IMG" | jq -r '.target // empty') FULL_IMAGE="${{ inputs.registry_host }}/${IMAGE_NAME}" TAGS=() TAGS+=("$FULL_IMAGE:sha-$SHA_SHORT") for ver in "${VERSION_TAGS[@]}"; do TAGS+=("$FULL_IMAGE:$ver") done for extra in "${EXTRA_TAGS[@]}"; do TAGS+=("$FULL_IMAGE:$extra") done TAGS_JSON=$(printf '%s\n' "${TAGS[@]}" | jq -R . | jq -s .) TARGET_NAME="img_${IDX}" TARGET_NAMES+=("$TARGET_NAME") TARGET_OBJ=$(jq -n \ --arg context "$CONTEXT" \ --arg dockerfile "$DOCKERFILE_FOR_BAKE" \ --arg target "$TARGET" \ --argjson tags "$TAGS_JSON" \ --argjson args "$BUILD_ARGS_JSON" \ --argjson ssh "$SSH_BAKE_JSON" \ --arg cache_ref "$CACHE_REF" \ '{ context: $context, dockerfile: $dockerfile, tags: $tags, args: $args } + (if ($ssh != null) then {ssh: $ssh} else {} end) + (if ($target != "" and $target != "null") then {target: $target} else {} end) + (if ($cache_ref != "") then {"cache-from": ["type=registry,ref=" + $cache_ref], "cache-to": ["type=registry,ref=" + $cache_ref + ",mode=max"]} else {} end)') TARGETS_JSON=$(jq -n --arg name "$TARGET_NAME" --argjson target "$TARGET_OBJ" --argjson targets "$TARGETS_JSON" '$targets + {($name): $target}') done < <(echo "$group" | jq -c 'to_entries[]') GROUP_TARGETS_JSON=$(printf '%s\n' "${TARGET_NAMES[@]}" | jq -R . | jq -s .) BAKE_JSON=$(jq -n --argjson targets "$TARGETS_JSON" --argjson group_targets "$GROUP_TARGETS_JSON" '{target:$targets, group:{default:{targets:$group_targets}}}') BAKE_FILE=$(mktemp) echo "$BAKE_JSON" > "$BAKE_FILE" docker buildx bake --file "$BAKE_FILE" --push "${BAKE_ALLOW_FLAGS[@]}" rm -f "$BAKE_FILE" while read -r img; do IMAGE_NAME=$(echo "$img" | jq -r '.name') FULL_IMAGE="${{ inputs.registry_host }}/${IMAGE_NAME}" echo "==== Trivy scan for $FULL_IMAGE ====" trivy image \ --severity "$TRIVY_SEVERITY" \ --exit-code 1 \ "$FULL_IMAGE:sha-$SHA_SHORT" done < <(echo "$group" | jq -c '.[]') else img=$(echo "$group" | jq -c '.[0]') IMAGE_NAME=$(echo "$img" | jq -r '.name') FULL_IMAGE="${{ inputs.registry_host }}/${IMAGE_NAME}" CACHE_REF_SUFFIX=$(echo "$img" | jq -r '.cache_ref // empty') CONTEXT=$(echo "$img" | jq -r '.context') DOCKERFILE=$(echo "$img" | jq -r '.dockerfile') TARGET=$(echo "$img" | jq -r '.target // empty') TAGS=() TAGS+=("$FULL_IMAGE:sha-$SHA_SHORT") for ver in "${VERSION_TAGS[@]}"; do TAGS+=("$FULL_IMAGE:$ver") done for extra in "${EXTRA_TAGS[@]}"; do TAGS+=("$FULL_IMAGE:$extra") done TAG_ARGS=() for tag in "${TAGS[@]}"; do TAG_ARGS+=(--tag "$tag") done TARGET_FLAGS=() if [ -n "$TARGET" ] && [ "$TARGET" != "null" ]; then TARGET_FLAGS+=(--target "$TARGET") fi CACHE_FLAGS=() if [ -n "$CACHE_REF_SUFFIX" ]; then CACHE_REF="${{ inputs.registry_host }}/${CACHE_REF_SUFFIX}" CACHE_FLAGS+=(--cache-from "type=registry,ref=$CACHE_REF") CACHE_FLAGS+=(--cache-to "type=registry,ref=$CACHE_REF,mode=max") fi echo "==== Building $FULL_IMAGE ====" docker buildx build \ --file "$DOCKERFILE_FOR_BUILD" \ "${TARGET_FLAGS[@]}" \ "${CACHE_FLAGS[@]}" \ "${SSH_FLAGS[@]}" \ --push \ "${TAG_ARGS[@]}" \ "${BUILD_ARG_FLAGS[@]}" \ "$CONTEXT" echo "==== Trivy scan for $FULL_IMAGE ====" trivy image \ --severity "$TRIVY_SEVERITY" \ --exit-code 1 \ "${TAGS[0]}" fi done < <(echo "$IMAGES" | jq -c 'sort_by(.context, .dockerfile) | group_by([.context, .dockerfile])[]')