1+ # Builds the multi-arch image and publishes to GHCR (+ DockerHub if configured).
2+ #
3+ # Triggered by git tag creation, which publishes to the same docker tag.
4+ # Each platform is built on its own runner in parallel and pushed by digest;
5+ # a final merge job stitches the digests into one manifest list per registry,
6+ # then attaches provenance + SBOM attestations. arm64 builds natively on an
7+ # arm runner; the 32-bit glibc variants (arm/v7, 386) are emulated via QEMU.
8+
19name : 🐳 Docker
210
311on :
@@ -13,172 +21,256 @@ on:
1321 tags :
1422 - ' *'
1523
16- permissions :
17- contents : read
18- packages : write
19- id-token : write
20- attestations : write
21-
2224concurrency :
2325 group : docker-${{ github.workflow }}-${{ github.ref }}
2426 cancel-in-progress : true
2527
28+ permissions :
29+ contents : read
30+
2631env :
2732 IMAGE_NAME : adguardian
2833 DOCKER_USER : lissy93
2934 GHCR_REGISTRY : ghcr.io
3035 DOCKERHUB_REGISTRY : docker.io
3136
3237jobs :
33- docker :
38+ # Resolve the tag + target registries once, shared by every build leg + merge
39+ prepare :
40+ name : 🧭 Prepare
3441 runs-on : ubuntu-latest
35-
42+ timeout-minutes : 5
43+ outputs :
44+ tag : ${{ steps.meta.outputs.tag }}
45+ ghcr_image : ${{ steps.meta.outputs.ghcr_image }}
46+ publish_images : ${{ steps.meta.outputs.publish_images }}
47+ dockerhub_enabled : ${{ steps.meta.outputs.dockerhub_enabled }}
3648 steps :
37- - name : Checkout
38- uses : actions/checkout@v6
39- with :
40- persist-credentials : false
41-
4249 - name : Compute image metadata
4350 id : meta
4451 shell : bash
4552 env :
53+ EVENT_NAME : ${{ github.event_name }}
4654 MANUAL_TAG : ${{ inputs.tag }}
4755 DOCKERHUB_PASSWORD : ${{ secrets.DOCKERHUB_PASSWORD }}
4856 run : |
49- if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
50- if [[ -n "${MANUAL_TAG}" ]]; then
51- TAG="${MANUAL_TAG}"
52- else
53- TAG="latest"
54- fi
55- elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
57+ set -euo pipefail
58+ if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then
59+ if [[ -n "$MANUAL_TAG" ]]; then TAG="$MANUAL_TAG"; else TAG="latest"; fi
60+ elif [[ "$GITHUB_REF" == refs/tags/* ]]; then
5661 TAG="${GITHUB_REF#refs/tags/}"
5762 else
5863 TAG="latest"
5964 fi
6065
61- TAG="$(echo "${ TAG} " | sed -E 's/[^A-Za-z0-9_.-]+/-/g; s/^[.-]+//' | cut -c1-128)"
62- if [[ -z "${ TAG} " ]]; then
63- echo "Computed Docker tag is empty" >&2
66+ TAG="$(echo "$TAG" | sed -E 's/[^A-Za-z0-9_.-]+/-/g; s/^[.-]+//' | cut -c1-128)"
67+ if [[ -z "$TAG" ]]; then
68+ echo "::error:: Computed Docker tag is empty"
6469 exit 1
6570 fi
6671
6772 GHCR_IMAGE="${GHCR_REGISTRY}/${DOCKER_USER}/${IMAGE_NAME}"
68- PUBLISH_IMAGES="${GHCR_IMAGE}"
69- if [[ -n "${DOCKERHUB_PASSWORD}" ]]; then
70- DOCKERHUB_IMAGE="${DOCKERHUB_REGISTRY}/${DOCKER_USER}/${IMAGE_NAME}"
71- PUBLISH_IMAGES="${PUBLISH_IMAGES},${DOCKERHUB_IMAGE}"
72- echo "dockerhub_enabled=true" >> "$GITHUB_OUTPUT"
73- else
74- echo "dockerhub_enabled=false" >> "$GITHUB_OUTPUT"
73+ PUBLISH_IMAGES="$GHCR_IMAGE"
74+ DOCKERHUB_ENABLED="false"
75+ if [[ -n "$DOCKERHUB_PASSWORD" ]]; then
76+ PUBLISH_IMAGES="${PUBLISH_IMAGES},${DOCKERHUB_REGISTRY}/${DOCKER_USER}/${IMAGE_NAME}"
77+ DOCKERHUB_ENABLED="true"
7578 fi
7679
77- echo "TAG=${TAG}" >> "$GITHUB_ENV"
78- echo "tag=${ TAG}" >> "$GITHUB_OUTPUT "
79- echo "GHCR_IMAGE=${ GHCR_IMAGE}" >> "$GITHUB_ENV "
80- echo "ghcr_image=${GHCR_IMAGE}" >> "$GITHUB_OUTPUT "
81- echo "PUBLISH_IMAGES=${PUBLISH_IMAGES}" >> "$GITHUB_ENV "
82- echo "publish_images=${PUBLISH_IMAGES}" >> "$GITHUB_OUTPUT"
80+ {
81+ echo "tag=$TAG"
82+ echo "ghcr_image=$ GHCR_IMAGE"
83+ echo "publish_images=$PUBLISH_IMAGES "
84+ echo "dockerhub_enabled=$DOCKERHUB_ENABLED "
85+ } >> "$GITHUB_OUTPUT"
8386
84- - name : Set up QEMU
87+ # Build each platform on its own runner, in parallel, pushing by digest
88+ build :
89+ name : 🏗️ Build ${{ matrix.platform }}
90+ needs : prepare
91+ runs-on : ${{ matrix.runner }}
92+ timeout-minutes : 60
93+ permissions :
94+ contents : read
95+ packages : write
96+ strategy :
97+ fail-fast : false
98+ matrix :
99+ include :
100+ # 64-bit: ultra-light scratch/musl image (Dockerfile)
101+ - platform : linux/amd64
102+ runner : ubuntu-latest
103+ dockerfile : ./Dockerfile
104+ qemu : false
105+ - platform : linux/arm64
106+ runner : ubuntu-24.04-arm # native arm64 — no emulation
107+ dockerfile : ./Dockerfile
108+ qemu : false
109+ # 32-bit: glibc/Debian image (Dockerfile.full); emulated, but parallel
110+ - platform : linux/arm/v7
111+ runner : ubuntu-latest
112+ dockerfile : ./Dockerfile.full
113+ qemu : true
114+ - platform : linux/386
115+ runner : ubuntu-latest
116+ dockerfile : ./Dockerfile.full
117+ qemu : true
118+ steps :
119+ - name : 🛎️ Checkout
120+ uses : actions/checkout@v6
121+ with :
122+ persist-credentials : false
123+
124+ - name : 🏷️ Prepare platform name
125+ env :
126+ PLATFORM : ${{ matrix.platform }}
127+ run : echo "PLATFORM_PAIR=${PLATFORM//\//-}" >> "$GITHUB_ENV"
128+
129+ - name : 🧰 Set up QEMU
130+ if : matrix.qemu
85131 uses : docker/setup-qemu-action@v4
86132
87- - name : Set up Docker Buildx
133+ - name : 🧱 Set up Docker Buildx
88134 uses : docker/setup-buildx-action@v4
89135
90- - name : Login to GitHub Container Registry
136+ - name : 🔑 Login to GitHub Container Registry
91137 uses : docker/login-action@v4
92138 with :
93139 registry : ${{ env.GHCR_REGISTRY }}
94140 username : ${{ github.actor }}
95141 password : ${{ secrets.GITHUB_TOKEN }}
96142
97- - name : Login to DockerHub
98- if : steps.meta .outputs.dockerhub_enabled == 'true'
143+ - name : 🔑 Login to DockerHub
144+ if : needs.prepare .outputs.dockerhub_enabled == 'true'
99145 uses : docker/login-action@v4
100146 with :
101147 registry : ${{ env.DOCKERHUB_REGISTRY }}
102148 username : ${{ env.DOCKER_USER }}
103149 password : ${{ secrets.DOCKERHUB_PASSWORD }}
104150
105- # Build the ultra-lightweight from scratch version for 64-bit targets
106- - name : Build lightweight image (amd64, arm64)
107- id : light
151+ - name : 🏗️ Build and push by digest
152+ id : build
108153 uses : docker/build-push-action@v7
109154 with :
110155 context : .
111- file : ./Dockerfile
112- platforms : linux/amd64,linux/arm64
156+ file : ${{ matrix.dockerfile }}
157+ platforms : ${{ matrix.platform }}
113158 provenance : false
114159 sbom : false
115- cache-from : type=gha,scope=light
116- cache-to : type=gha,mode=max,scope=light
117- outputs : type=image,"name=${{ steps.meta .outputs.publish_images }}",push-by-digest=true,name-canonical=true,push=true
160+ cache-from : type=gha,scope=${{ env.PLATFORM_PAIR }}
161+ cache-to : type=gha,mode=max,scope=${{ env.PLATFORM_PAIR }}
162+ outputs : type=image,"name=${{ needs.prepare .outputs.publish_images }}",push-by-digest=true,name-canonical=true,push=true
118163
119- # Build the fat (Debian glibc) image for everything else
120- - name : Build full image (armv7, 386)
121- id : full
122- uses : docker/build-push-action@v7
164+ - name : 📤 Export digest
165+ env :
166+ DIGEST : ${{ steps.build.outputs.digest }}
167+ run : |
168+ set -euo pipefail
169+ mkdir -p /tmp/digests
170+ touch "/tmp/digests/${DIGEST#sha256:}"
171+
172+ - name : ⬆️ Upload digest
173+ uses : actions/upload-artifact@v7
123174 with :
124- context : .
125- file : ./Dockerfile.full
126- platforms : linux/arm/v7,linux/386
127- provenance : false
128- sbom : false
129- cache-from : type=gha,scope=full
130- cache-to : type=gha,mode=max,scope=full
131- outputs : type=image,"name=${{ steps.meta.outputs.publish_images }}",push-by-digest=true,name-canonical=true,push=true
175+ name : digests-${{ env.PLATFORM_PAIR }}
176+ path : /tmp/digests/*
177+ if-no-files-found : error
178+ retention-days : 1
132179
133- # Merge the variants into single manifest
134- - name : Create combined manifest list
135- id : manifest
180+ # Combine the per-platform digests into one multi-arch manifest per registry
181+ merge :
182+ name : 🧩 Merge manifest
183+ needs : [prepare, build]
184+ runs-on : ubuntu-latest
185+ timeout-minutes : 15
186+ permissions :
187+ contents : read
188+ packages : write
189+ id-token : write
190+ attestations : write
191+ steps :
192+ - name : 📥 Download digests
193+ uses : actions/download-artifact@v8
194+ with :
195+ path : /tmp/digests
196+ pattern : digests-*
197+ merge-multiple : true
198+
199+ - name : 🧱 Set up Docker Buildx
200+ uses : docker/setup-buildx-action@v4
201+
202+ - name : 🔑 Login to GitHub Container Registry
203+ uses : docker/login-action@v4
204+ with :
205+ registry : ${{ env.GHCR_REGISTRY }}
206+ username : ${{ github.actor }}
207+ password : ${{ secrets.GITHUB_TOKEN }}
208+
209+ - name : 🔑 Login to DockerHub
210+ if : needs.prepare.outputs.dockerhub_enabled == 'true'
211+ uses : docker/login-action@v4
212+ with :
213+ registry : ${{ env.DOCKERHUB_REGISTRY }}
214+ username : ${{ env.DOCKER_USER }}
215+ password : ${{ secrets.DOCKERHUB_PASSWORD }}
216+
217+ - name : 🧩 Create combined manifest list
136218 shell : bash
137219 env :
138- LIGHT_DIGEST : ${{ steps.light .outputs.digest }}
139- FULL_DIGEST : ${{ steps.full .outputs.digest }}
220+ TAG : ${{ needs.prepare .outputs.tag }}
221+ PUBLISH_IMAGES : ${{ needs.prepare .outputs.publish_images }}
140222 run : |
141- IFS=',' read -ra IMAGES <<< "${PUBLISH_IMAGES}"
223+ set -euo pipefail
224+ cd /tmp/digests
225+ shopt -s nullglob
226+ DIGEST_FILES=(*)
227+ if [ ${#DIGEST_FILES[@]} -eq 0 ]; then
228+ echo "::error::No digests found to merge"
229+ exit 1
230+ fi
231+ IFS=',' read -ra IMAGES <<< "$PUBLISH_IMAGES"
142232 for IMG in "${IMAGES[@]}"; do
143- docker buildx imagetools create -t "${IMG}:${TAG}" \
144- "${IMG}@${LIGHT_DIGEST}" \
145- "${IMG}@${FULL_DIGEST}"
233+ REFS=()
234+ for f in "${DIGEST_FILES[@]}"; do
235+ REFS+=("${IMG}@sha256:${f}")
236+ done
237+ docker buildx imagetools create -t "${IMG}:${TAG}" "${REFS[@]}"
146238 done
147- GHCR_DIGEST=$(docker buildx imagetools inspect "${GHCR_IMAGE}:${TAG}" --format '{{.Manifest.Digest}}')
148- echo "ghcr_digest=${GHCR_DIGEST}" >> "$GITHUB_OUTPUT"
149239
150- - name : Inspect published manifests
240+ - name : 🔎 Inspect and capture GHCR digest
241+ id : digest
151242 shell : bash
243+ env :
244+ GHCR_IMAGE : ${{ needs.prepare.outputs.ghcr_image }}
245+ TAG : ${{ needs.prepare.outputs.tag }}
152246 run : |
153- IFS=',' read -ra IMAGES <<< "${PUBLISH_IMAGES}"
154- for IMG in "${IMAGES[@]}"; do
155- echo "::group::${IMG}:${TAG}"
156- docker buildx imagetools inspect "${IMG}:${TAG}"
157- echo "::endgroup::"
158- done
247+ set -euo pipefail
248+ GHCR_DIGEST=$(docker buildx imagetools inspect "${GHCR_IMAGE}:${TAG}" --format '{{.Manifest.Digest}}')
249+ echo "ghcr_digest=${GHCR_DIGEST}" >> "$GITHUB_OUTPUT"
250+ docker buildx imagetools inspect "${GHCR_IMAGE}:${TAG}"
159251
160- - name : Attest build provenance
252+ - name : 🪪 Attest build provenance
161253 uses : actions/attest-build-provenance@v4
162254 continue-on-error : true
163255 with :
164- subject-name : ${{ steps.meta .outputs.ghcr_image }}
165- subject-digest : ${{ steps.manifest .outputs.ghcr_digest }}
256+ subject-name : ${{ needs.prepare .outputs.ghcr_image }}
257+ subject-digest : ${{ steps.digest .outputs.ghcr_digest }}
166258 push-to-registry : true
167259
168- - name : Generate SBOM
260+ - name : 📋 Generate SBOM
169261 uses : anchore/sbom-action@v0.24.0
170262 continue-on-error : true
171263 with :
172- image : ${{ steps.meta .outputs.ghcr_image }}@${{ steps.manifest .outputs.ghcr_digest }}
264+ image : ${{ needs.prepare .outputs.ghcr_image }}@${{ steps.digest .outputs.ghcr_digest }}
173265 format : spdx-json
174266 output-file : sbom.spdx.json
175267
176- - name : Attest SBOM
268+ - name : 🪪 Attest SBOM
177269 uses : actions/attest@v4
178270 continue-on-error : true
179271 with :
180- subject-name : ${{ steps.meta .outputs.ghcr_image }}
181- subject-digest : ${{ steps.manifest .outputs.ghcr_digest }}
272+ subject-name : ${{ needs.prepare .outputs.ghcr_image }}
273+ subject-digest : ${{ steps.digest .outputs.ghcr_digest }}
182274 predicate-type : https://spdx.dev/Document
183275 predicate-path : sbom.spdx.json
184276 push-to-registry : true
0 commit comments