Skip to content

Commit 9c5bba8

Browse files
authored
Add verify-docker-reproducibility action (#48)
Verifies Docker image build reproducibility by building twice and comparing layer digests
1 parent 91fea14 commit 9c5bba8

File tree

9 files changed

+206
-4
lines changed

9 files changed

+206
-4
lines changed

.github/workflows/test-check-if-latest-lts-release.yml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,11 @@ jobs:
2424
with:
2525
hz_version: 5.5.0
2626

27-
- name: Run check-if-latest-lts-release 5.5.8
28-
id: check-if-latest-lts-release-5_5_8
27+
- name: Run check-if-latest-lts-release 5.5.9
28+
id: check-if-latest-lts-release-5_5_9
2929
uses: ./docker-actions/check-if-latest-lts-release
3030
with:
31-
hz_version: 5.5.8
31+
hz_version: 5.5.9
3232

3333
- name: Run check-if-latest-lts-release 5.6.0
3434
id: check-if-latest-lts-release-5_6_0
@@ -51,7 +51,7 @@ jobs:
5151
}
5252
5353
assert_equals "false" "${{ steps.check-if-latest-lts-release-5_5_0.outputs.is_latest_lts }}"
54-
assert_equals "true" "${{ steps.check-if-latest-lts-release-5_5_8.outputs.is_latest_lts }}"
54+
assert_equals "true" "${{ steps.check-if-latest-lts-release-5_5_9.outputs.is_latest_lts }}"
5555
assert_equals "false" "${{ steps.check-if-latest-lts-release-5_6_0.outputs.is_latest_lts }}"
5656
5757
assert_eq 0 "$TESTS_RESULT" "All tests should pass"
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
name: Test verify-docker-reproducibility action
2+
3+
on:
4+
workflow_call:
5+
6+
jobs:
7+
test:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- name: Checkout repository
11+
uses: actions/checkout@v6
12+
13+
- name: Test scripts
14+
run: ./verify-docker-reproducibility/test_scripts.sh
15+
16+
- name: Run verify-docker-reproducibility
17+
uses: ./verify-docker-reproducibility
18+
with:
19+
dockerfile: verify-docker-reproducibility/test-data/reproducible.Dockerfile
20+
extra-args: verify-docker-reproducibility/test-data

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,9 @@ jobs:
5757
uses: ./.github/workflows/test-slack-notification.yml
5858
secrets: inherit
5959

60+
test-verify-docker-reproducibility:
61+
uses: ./.github/workflows/test-verify-docker-reproducibility.yml
62+
6063
assert-all-jobs-succeeded:
6164
runs-on: ubuntu-latest
6265
needs:
@@ -74,6 +77,7 @@ jobs:
7477
- test-resolve-editions
7578
- test-setup-maven-snapshot-internal
7679
- test-slack-notification
80+
- test-verify-docker-reproducibility
7781
if: always()
7882
steps:
7983
- name: Check all jobs succeeded
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
name: Verify Docker reproducibility
2+
description: Verifies Docker image build reproducibility by building twice and comparing layer digests
3+
4+
inputs:
5+
dockerfile:
6+
description: Path to the Dockerfile
7+
required: true
8+
extra-args:
9+
description: Extra arguments passed to docker buildx build (e.g. context path, --output flags)
10+
required: false
11+
default: ""
12+
13+
runs:
14+
using: "composite"
15+
steps:
16+
- shell: bash
17+
run: |
18+
${GITHUB_ACTION_PATH}/verify-docker-reproducibility.sh \
19+
"${DOCKERFILE}" \
20+
${EXTRA_ARGS}
21+
env:
22+
DOCKERFILE: ${{ inputs.dockerfile }}
23+
EXTRA_ARGS: ${{ inputs.extra-args }}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
FROM alpine:3:20
2+
RUN echo "built-at: $(date +%s%N)" > /build-timestamp \
3+
&& sleep 1
4+
USER nobody
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
FROM alpine:3.20
2+
USER nobody
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env bash
2+
3+
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
4+
5+
find "$SCRIPT_DIR" -name "*_tests.sh" -print0 | xargs -0 -n1 bash
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
#!/usr/bin/env bash
2+
3+
set -o errexit -o nounset -o pipefail ${RUNNER_DEBUG:+-x}
4+
5+
# shellcheck source=../.github/scripts/logging.functions.sh
6+
source .github/scripts/logging.functions.sh
7+
8+
readonly RANDOM_SUFFIX="$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')"
9+
readonly TAG_A="repro-check-a-${RANDOM_SUFFIX}"
10+
readonly TAG_B="repro-check-b-${RANDOM_SUFFIX}"
11+
12+
cleanup() {
13+
docker rmi "${TAG_A}" "${TAG_B}" 2>/dev/null
14+
return 0
15+
}
16+
trap cleanup EXIT
17+
18+
if [[ "$#" -lt 1 ]]; then
19+
echo "Verifies Docker image build reproducibility by building twice and comparing layer digests."
20+
echo "Exits 0 if all layers are identical across both builds, 1 if any differ."
21+
echo ""
22+
echo "Usage: $0 <dockerfile> [extra buildx build args...]"
23+
echo ""
24+
echo "Examples:"
25+
echo " $0 hazelcast-oss/Dockerfile hazelcast-oss/"
26+
echo " $0 hazelcast-oss/Dockerfile --output type=docker,rewrite-timestamp=true hazelcast-oss/"
27+
exit 1
28+
fi
29+
readonly dockerfile="${1:?Error: <dockerfile> is required}"
30+
shift
31+
32+
# Add --load if user didn't provide --output
33+
load_flag="--load"
34+
for arg in "$@"; do
35+
case "${arg}" in
36+
--output|--output=*) load_flag="" ;;
37+
*) ;;
38+
esac
39+
done
40+
41+
build_image() {
42+
local tag="$1"
43+
shift
44+
echo "==> Building image '${tag}'..."
45+
docker buildx build \
46+
--no-cache \
47+
${load_flag} \
48+
-f "${dockerfile}" \
49+
-t "${tag}" \
50+
"$@"
51+
return $?
52+
}
53+
54+
get_layers() {
55+
local tag="$1"
56+
docker inspect --format '{{json .RootFS.Layers}}' "${tag}"
57+
return $?
58+
}
59+
60+
get_digest() {
61+
local layers="$1"
62+
local index="$2"
63+
echo "${layers}" | jq --raw-output ".[${index}] // empty"
64+
return $?
65+
}
66+
67+
build_image "${TAG_A}" "$@"
68+
echo ""
69+
build_image "${TAG_B}" "$@"
70+
echo ""
71+
72+
layers_a=$(get_layers "${TAG_A}")
73+
layers_b=$(get_layers "${TAG_B}")
74+
75+
layer_count_a=$(echo "${layers_a}" | jq 'length')
76+
layer_count_b=$(echo "${layers_b}" | jq 'length')
77+
78+
echo "=== Layer Reproducibility Report ==="
79+
echo ""
80+
81+
if [[ "${layer_count_a}" -ne "${layer_count_b}" ]]; then
82+
echowarning "WARNING: Layer count mismatch (${layer_count_a} vs ${layer_count_b})"
83+
echo ""
84+
fi
85+
86+
layer_max_count=$(( layer_count_a > layer_count_b ? layer_count_a : layer_count_b ))
87+
has_diff=false
88+
for layerIndex in $(seq 0 $((layer_max_count - 1))); do
89+
layer_digest_a=$(get_digest "${layers_a}" "${layerIndex}")
90+
layer_digest_b=$(get_digest "${layers_b}" "${layerIndex}")
91+
if [[ -z "${layer_digest_a}" ]]; then
92+
echo " Layer $((layerIndex + 1))/${layer_max_count}: ONLY IN B"
93+
echo " B: ${layer_digest_b}"
94+
has_diff=true
95+
elif [[ -z "${layer_digest_b}" ]]; then
96+
echo " Layer $((layerIndex + 1))/${layer_max_count}: ONLY IN A"
97+
echo " A: ${layer_digest_a}"
98+
has_diff=true
99+
elif [[ "${layer_digest_a}" == "${layer_digest_b}" ]]; then
100+
echo " Layer $((layerIndex + 1))/${layer_max_count}: MATCH"
101+
else
102+
echo " Layer $((layerIndex + 1))/${layer_max_count}: DIFFER"
103+
echo " A: ${layer_digest_a}"
104+
echo " B: ${layer_digest_b}"
105+
has_diff=true
106+
fi
107+
done
108+
109+
echo ""
110+
if [[ "${has_diff}" == "true" ]]; then
111+
echoerr "RESULT: FAIL - some layers differ between builds"
112+
exit 1
113+
fi
114+
115+
echo "RESULT: PASS - all ${layer_count_a} layers are identical"
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env bash
2+
3+
set -eu ${RUNNER_DEBUG:+-x}
4+
5+
SCRIPT_DIR="$(dirname "$(readlink -f "$0")")"
6+
7+
# Source the latest version of assert.sh unit testing library and include in current shell
8+
source /dev/stdin <<< "$(curl --silent https://raw.githubusercontent.com/hazelcast/assert.sh/main/assert.sh)"
9+
10+
TESTS_RESULT=0
11+
12+
log_header "Tests for verify-docker-reproducibility"
13+
14+
log_header "Should exit 1 when no arguments provided"
15+
"$SCRIPT_DIR"/verify-docker-reproducibility.sh && true
16+
actual_exit_code=$?
17+
assert_eq 1 "$actual_exit_code" "Should exit 1 when no args given" && log_success "Exits 1 with no args" || TESTS_RESULT=$?
18+
19+
log_header "Should pass for a reproducible image"
20+
"$SCRIPT_DIR"/verify-docker-reproducibility.sh "$SCRIPT_DIR/test-data/reproducible.Dockerfile" "$SCRIPT_DIR/test-data" && true
21+
actual_exit_code=$?
22+
assert_eq 0 "$actual_exit_code" "Should exit 0 for reproducible build" && log_success "Reproducible build passes" || TESTS_RESULT=$?
23+
24+
log_header "Should fail for a non-reproducible image"
25+
"$SCRIPT_DIR"/verify-docker-reproducibility.sh "$SCRIPT_DIR/test-data/non-reproducible.Dockerfile" "$SCRIPT_DIR/test-data" && true
26+
actual_exit_code=$?
27+
assert_eq 1 "$actual_exit_code" "Should exit 1 for non-reproducible build" && log_success "Non-reproducible build fails" || TESTS_RESULT=$?
28+
29+
assert_eq 0 "$TESTS_RESULT" "All tests should pass"

0 commit comments

Comments
 (0)