From 26ed550f33e13d357ba97cb72bf4b6cdb76b3d15 Mon Sep 17 00:00:00 2001 From: Esteve Fernandez Date: Thu, 12 Mar 2026 11:28:32 +0000 Subject: [PATCH] feat: skip builds when base image digest unchanged Adds skip_if_base_unchanged input to conditionally skip builds when the base image digest has not changed. --- action.yml | 36 ++++++++++++++++ check_base_image.sh | 100 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100755 check_base_image.sh diff --git a/action.yml b/action.yml index 2303e03..7b3f479 100644 --- a/action.yml +++ b/action.yml @@ -131,6 +131,13 @@ inputs: Set to true verify the cosign signature for the Blue-Build cli required: false default: "false" + skip_if_base_unchanged: + description: | + When set to 'true', the build will be skipped if the base image digest has not changed since the last run. + The base image digest is cached using a key derived from the OS, recipe file hash, and base image reference. + Requires 'yq' and 'skopeo' to be available on the runner. + required: false + default: "false" runs: using: "composite" @@ -292,6 +299,34 @@ runs: fi echo "recipe_path=${RECIPE_PATH}" >> "${GITHUB_OUTPUT}" + - name: Restore base image digest cache + if: ${{ inputs.skip_if_base_unchanged == 'true' }} + uses: actions/cache@cdf6c1fa76f9f475f3d7449005a359c84ca0f306 # v5.0.3 + with: + path: .base-image-digest + key: ${{ runner.os }}-base-digest-${{ hashFiles(steps.build_vars.outputs.recipe_path) }}-${{ steps.build_vars.outputs.recipe_path }} + restore-keys: | + ${{ runner.os }}-base-digest-${{ hashFiles(steps.build_vars.outputs.recipe_path) }}- + ${{ runner.os }}-base-digest- + + - name: Check base image digest + id: check_base_digest + if: ${{ inputs.skip_if_base_unchanged == 'true' }} + shell: bash + env: + RECIPE_PATH: ${{ steps.build_vars.outputs.recipe_path }} + github_action_path: ${{ github.action_path }} + run: | + set +e + bash "${github_action_path}/check_base_image.sh" "${RECIPE_PATH}" + EXIT_CODE=$? + set -e + if [ "${EXIT_CODE}" -eq 0 ]; then + echo "should_skip_build=true" >> "${GITHUB_OUTPUT}" + else + echo "should_skip_build=false" >> "${GITHUB_OUTPUT}" + fi + - name: Install BlueBuild shell: bash env: @@ -318,6 +353,7 @@ runs: # blue-build/cli does the heavy lifting - name: Build Image + if: ${{ steps.check_base_digest.outputs.should_skip_build != 'true' }} shell: bash working-directory: ${{ inputs.working_directory }} env: diff --git a/check_base_image.sh b/check_base_image.sh new file mode 100755 index 0000000..54dd4d3 --- /dev/null +++ b/check_base_image.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# check_base_image.sh - Check if the base image digest has changed +# +# Usage: check_base_image.sh +# +# Exits 0 if the base image digest is unchanged (skip build) +# Exits 1 if the digest has changed, is unavailable, or no cache exists (proceed with build) +# +# Cache file: .base-image-digest (contains the last known digest string) + +set -uo pipefail + +RECIPE_PATH="${1:-}" +CACHE_FILE=".base-image-digest" + +# --- Argument validation --- +if [[ -z "${RECIPE_PATH}" ]]; then + echo "Error: recipe path argument is required." >&2 + echo "Usage: check_base_image.sh " >&2 + exit 1 +fi + +if [[ ! -f "${RECIPE_PATH}" ]]; then + echo "Warning: recipe file not found at '${RECIPE_PATH}'. Proceeding with build." >&2 + exit 1 +fi + +parse_yaml_field() { + local field="${1}" + local file="${2}" + # mikefarah/yq v4 syntax + yq e ".\"${field}\" // \"\"" "${file}" 2>/dev/null +} + +# --- Parse recipe fields --- +echo "Checking base image for recipe: ${RECIPE_PATH}" + +BASE_IMAGE="$(parse_yaml_field "base-image" "${RECIPE_PATH}")" +IMAGE_VERSION="$(parse_yaml_field "image-version" "${RECIPE_PATH}")" + +if [[ -z "${BASE_IMAGE}" ]]; then + echo "Warning: 'base-image' field not found in recipe. Proceeding with build." >&2 + exit 1 +fi + +if [[ -z "${IMAGE_VERSION}" ]]; then + echo "Warning: 'image-version' field not found in recipe. Proceeding with build." >&2 + exit 1 +fi + +IMAGE_REF="${BASE_IMAGE}:${IMAGE_VERSION}" +echo "Base image reference: ${IMAGE_REF}" + +# --- Get current digest with 30s timeout --- +# Use skopeo to query the image digest directly from the registry without pulling the image +get_image_digest() { + local image="${1}" + timeout 30s skopeo inspect --format '{{.Digest}}' --retry-times 2 "docker://${image}" 2>/dev/null +} + +echo "Fetching current digest for ${IMAGE_REF} ..." +CURRENT_DIGEST="" + +if ! CURRENT_DIGEST="$(get_image_digest "${IMAGE_REF}")"; then + echo "Warning: Failed to fetch manifest for '${IMAGE_REF}' (timeout or error). Proceeding with build." >&2 + exit 1 +fi + +if [[ -z "${CURRENT_DIGEST}" ]]; then + echo "Warning: Could not extract digest from manifest for '${IMAGE_REF}'. Proceeding with build." >&2 + exit 1 +fi + +echo "Current digest: ${CURRENT_DIGEST}" + +# --- Compare with cached digest --- +if [[ ! -f "${CACHE_FILE}" ]]; then + echo "No cache file found (${CACHE_FILE}). Saving digest and proceeding with build." + printf '%s\n' "${CURRENT_DIGEST}" >"${CACHE_FILE}" + exit 1 +fi + +CACHED_DIGEST="$(cat "${CACHE_FILE}" 2>/dev/null || true)" + +if [[ -z "${CACHED_DIGEST}" ]]; then + echo "Cache file is empty. Saving digest and proceeding with build." + printf '%s\n' "${CURRENT_DIGEST}" >"${CACHE_FILE}" + exit 1 +fi + +echo "Cached digest: ${CACHED_DIGEST}" + +if [[ "${CURRENT_DIGEST}" == "${CACHED_DIGEST}" ]]; then + echo "Base image is unchanged. Skipping build." + exit 0 +else + echo "Base image has changed! Updating cache and proceeding with build." + printf '%s\n' "${CURRENT_DIGEST}" >"${CACHE_FILE}" + exit 1 +fi