diff --git a/.github/workflows/test-check-if-latest-lts-release.yml b/.github/workflows/test-check-if-latest-lts-release.yml index 98c00df..8785fca 100644 --- a/.github/workflows/test-check-if-latest-lts-release.yml +++ b/.github/workflows/test-check-if-latest-lts-release.yml @@ -24,11 +24,11 @@ jobs: with: hz_version: 5.5.0 - - name: Run check-if-latest-lts-release 5.5.8 - id: check-if-latest-lts-release-5_5_8 + - name: Run check-if-latest-lts-release 5.5.9 + id: check-if-latest-lts-release-5_5_9 uses: ./docker-actions/check-if-latest-lts-release with: - hz_version: 5.5.8 + hz_version: 5.5.9 - name: Run check-if-latest-lts-release 5.6.0 id: check-if-latest-lts-release-5_6_0 @@ -51,7 +51,7 @@ jobs: } assert_equals "false" "${{ steps.check-if-latest-lts-release-5_5_0.outputs.is_latest_lts }}" - assert_equals "true" "${{ steps.check-if-latest-lts-release-5_5_8.outputs.is_latest_lts }}" + assert_equals "true" "${{ steps.check-if-latest-lts-release-5_5_9.outputs.is_latest_lts }}" assert_equals "false" "${{ steps.check-if-latest-lts-release-5_6_0.outputs.is_latest_lts }}" assert_eq 0 "$TESTS_RESULT" "All tests should pass" diff --git a/.github/workflows/test-verify-docker-reproducibility.yml b/.github/workflows/test-verify-docker-reproducibility.yml new file mode 100644 index 0000000..2667c9c --- /dev/null +++ b/.github/workflows/test-verify-docker-reproducibility.yml @@ -0,0 +1,20 @@ +name: Test verify-docker-reproducibility action + +on: + workflow_call: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Test scripts + run: ./verify-docker-reproducibility/test_scripts.sh + + - name: Run verify-docker-reproducibility + uses: ./verify-docker-reproducibility + with: + dockerfile: verify-docker-reproducibility/test-data/reproducible.Dockerfile + extra-args: verify-docker-reproducibility/test-data diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a272581..1508ca6 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -57,6 +57,9 @@ jobs: uses: ./.github/workflows/test-slack-notification.yml secrets: inherit + test-verify-docker-reproducibility: + uses: ./.github/workflows/test-verify-docker-reproducibility.yml + assert-all-jobs-succeeded: runs-on: ubuntu-latest needs: @@ -74,6 +77,7 @@ jobs: - test-resolve-editions - test-setup-maven-snapshot-internal - test-slack-notification + - test-verify-docker-reproducibility if: always() steps: - name: Check all jobs succeeded diff --git a/verify-docker-reproducibility/action.yml b/verify-docker-reproducibility/action.yml new file mode 100644 index 0000000..129ca19 --- /dev/null +++ b/verify-docker-reproducibility/action.yml @@ -0,0 +1,23 @@ +name: Verify Docker reproducibility +description: Verifies Docker image build reproducibility by building twice and comparing layer digests + +inputs: + dockerfile: + description: Path to the Dockerfile + required: true + extra-args: + description: Extra arguments passed to docker buildx build (e.g. context path, --output flags) + required: false + default: "" + +runs: + using: "composite" + steps: + - shell: bash + run: | + ${GITHUB_ACTION_PATH}/verify-docker-reproducibility.sh \ + "${DOCKERFILE}" \ + ${EXTRA_ARGS} + env: + DOCKERFILE: ${{ inputs.dockerfile }} + EXTRA_ARGS: ${{ inputs.extra-args }} diff --git a/verify-docker-reproducibility/test-data/non-reproducible.Dockerfile b/verify-docker-reproducibility/test-data/non-reproducible.Dockerfile new file mode 100644 index 0000000..caeae7e --- /dev/null +++ b/verify-docker-reproducibility/test-data/non-reproducible.Dockerfile @@ -0,0 +1,4 @@ +FROM alpine:3:20 +RUN echo "built-at: $(date +%s%N)" > /build-timestamp \ + && sleep 1 +USER nobody diff --git a/verify-docker-reproducibility/test-data/reproducible.Dockerfile b/verify-docker-reproducibility/test-data/reproducible.Dockerfile new file mode 100644 index 0000000..e64422c --- /dev/null +++ b/verify-docker-reproducibility/test-data/reproducible.Dockerfile @@ -0,0 +1,2 @@ +FROM alpine:3.20 +USER nobody diff --git a/verify-docker-reproducibility/test_scripts.sh b/verify-docker-reproducibility/test_scripts.sh new file mode 100755 index 0000000..deb6bf2 --- /dev/null +++ b/verify-docker-reproducibility/test_scripts.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + +find "$SCRIPT_DIR" -name "*_tests.sh" -print0 | xargs -0 -n1 bash diff --git a/verify-docker-reproducibility/verify-docker-reproducibility.sh b/verify-docker-reproducibility/verify-docker-reproducibility.sh new file mode 100755 index 0000000..450b35d --- /dev/null +++ b/verify-docker-reproducibility/verify-docker-reproducibility.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash + +set -o errexit -o nounset -o pipefail ${RUNNER_DEBUG:+-x} + +# shellcheck source=../.github/scripts/logging.functions.sh +source .github/scripts/logging.functions.sh + +readonly RANDOM_SUFFIX="$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')" +readonly TAG_A="repro-check-a-${RANDOM_SUFFIX}" +readonly TAG_B="repro-check-b-${RANDOM_SUFFIX}" + +cleanup() { + docker rmi "${TAG_A}" "${TAG_B}" 2>/dev/null + return 0 +} +trap cleanup EXIT + +if [[ "$#" -lt 1 ]]; then + echo "Verifies Docker image build reproducibility by building twice and comparing layer digests." + echo "Exits 0 if all layers are identical across both builds, 1 if any differ." + echo "" + echo "Usage: $0 [extra buildx build args...]" + echo "" + echo "Examples:" + echo " $0 hazelcast-oss/Dockerfile hazelcast-oss/" + echo " $0 hazelcast-oss/Dockerfile --output type=docker,rewrite-timestamp=true hazelcast-oss/" + exit 1 +fi +readonly dockerfile="${1:?Error: is required}" +shift + +# Add --load if user didn't provide --output +load_flag="--load" +for arg in "$@"; do + case "${arg}" in + --output|--output=*) load_flag="" ;; + *) ;; + esac +done + +build_image() { + local tag="$1" + shift + echo "==> Building image '${tag}'..." + docker buildx build \ + --no-cache \ + ${load_flag} \ + -f "${dockerfile}" \ + -t "${tag}" \ + "$@" + return $? +} + +get_layers() { + local tag="$1" + docker inspect --format '{{json .RootFS.Layers}}' "${tag}" + return $? +} + +get_digest() { + local layers="$1" + local index="$2" + echo "${layers}" | jq --raw-output ".[${index}] // empty" + return $? +} + +build_image "${TAG_A}" "$@" +echo "" +build_image "${TAG_B}" "$@" +echo "" + +layers_a=$(get_layers "${TAG_A}") +layers_b=$(get_layers "${TAG_B}") + +layer_count_a=$(echo "${layers_a}" | jq 'length') +layer_count_b=$(echo "${layers_b}" | jq 'length') + +echo "=== Layer Reproducibility Report ===" +echo "" + +if [[ "${layer_count_a}" -ne "${layer_count_b}" ]]; then + echowarning "WARNING: Layer count mismatch (${layer_count_a} vs ${layer_count_b})" + echo "" +fi + +layer_max_count=$(( layer_count_a > layer_count_b ? layer_count_a : layer_count_b )) +has_diff=false +for layerIndex in $(seq 0 $((layer_max_count - 1))); do + layer_digest_a=$(get_digest "${layers_a}" "${layerIndex}") + layer_digest_b=$(get_digest "${layers_b}" "${layerIndex}") + if [[ -z "${layer_digest_a}" ]]; then + echo " Layer $((layerIndex + 1))/${layer_max_count}: ONLY IN B" + echo " B: ${layer_digest_b}" + has_diff=true + elif [[ -z "${layer_digest_b}" ]]; then + echo " Layer $((layerIndex + 1))/${layer_max_count}: ONLY IN A" + echo " A: ${layer_digest_a}" + has_diff=true + elif [[ "${layer_digest_a}" == "${layer_digest_b}" ]]; then + echo " Layer $((layerIndex + 1))/${layer_max_count}: MATCH" + else + echo " Layer $((layerIndex + 1))/${layer_max_count}: DIFFER" + echo " A: ${layer_digest_a}" + echo " B: ${layer_digest_b}" + has_diff=true + fi +done + +echo "" +if [[ "${has_diff}" == "true" ]]; then + echoerr "RESULT: FAIL - some layers differ between builds" + exit 1 +fi + +echo "RESULT: PASS - all ${layer_count_a} layers are identical" diff --git a/verify-docker-reproducibility/verify-docker-reproducibility_tests.sh b/verify-docker-reproducibility/verify-docker-reproducibility_tests.sh new file mode 100755 index 0000000..7702d6e --- /dev/null +++ b/verify-docker-reproducibility/verify-docker-reproducibility_tests.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -eu ${RUNNER_DEBUG:+-x} + +SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" + +# Source the latest version of assert.sh unit testing library and include in current shell +source /dev/stdin <<< "$(curl --silent https://raw.githubusercontent.com/hazelcast/assert.sh/main/assert.sh)" + +TESTS_RESULT=0 + +log_header "Tests for verify-docker-reproducibility" + +log_header "Should exit 1 when no arguments provided" +"$SCRIPT_DIR"/verify-docker-reproducibility.sh && true +actual_exit_code=$? +assert_eq 1 "$actual_exit_code" "Should exit 1 when no args given" && log_success "Exits 1 with no args" || TESTS_RESULT=$? + +log_header "Should pass for a reproducible image" +"$SCRIPT_DIR"/verify-docker-reproducibility.sh "$SCRIPT_DIR/test-data/reproducible.Dockerfile" "$SCRIPT_DIR/test-data" && true +actual_exit_code=$? +assert_eq 0 "$actual_exit_code" "Should exit 0 for reproducible build" && log_success "Reproducible build passes" || TESTS_RESULT=$? + +log_header "Should fail for a non-reproducible image" +"$SCRIPT_DIR"/verify-docker-reproducibility.sh "$SCRIPT_DIR/test-data/non-reproducible.Dockerfile" "$SCRIPT_DIR/test-data" && true +actual_exit_code=$? +assert_eq 1 "$actual_exit_code" "Should exit 1 for non-reproducible build" && log_success "Non-reproducible build fails" || TESTS_RESULT=$? + +assert_eq 0 "$TESTS_RESULT" "All tests should pass"