Skip to content

Commit 24bbd9f

Browse files
committed
Add reusable workflow for native BuildKit build in GHA
This PR fundamentally changes how our images are built. The usage of the Builder container is dropped in favor of "native" build using BuildKit with docker/build-push-action. Dockerfiles are now the single source of truth for all labels and build arguments - the build metadata (version, date, architecture, repository) is passed via --build-arg and consumed directly in the Dockerfile's LABEL instruction, removing the need for external label injection. Build caching uses GitHub Actions cache as the primary backend, with inline cache metadata embedded in pushed images as a fallback for cache reuse across git refs (since GHA cache is scoped per branch/tag). Registry images are verified with cosign before being used as cache sources. Images are compressed with zstd (level 9) instead of gzip, reducing image size and improving pull times on registries and runtimes that support it. Multi-arch support is handled by building per-architecture images in parallel on native runners (amd64 on ubuntu-24.04, aarch64 on ubuntu-24.04-arm), then combining them into a single manifest list using docker buildx imagetools. Thanks to the caching, the builder workflow now also runs on push to the master branch, keeping the GHA cache warm for release builds without adding significant CI cost. A reference implementation is in home-assistant/docker-base#347.
1 parent 7f1fbbc commit 24bbd9f

File tree

3 files changed

+543
-0
lines changed

3 files changed

+543
-0
lines changed
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
name: Build image
2+
description: Build, push, and optionally sign a single-arch container image
3+
4+
inputs:
5+
arch:
6+
description: Architecture to build (e.g., "amd64")
7+
required: true
8+
platform:
9+
description: Platform string (e.g., "linux/amd64")
10+
required: true
11+
image:
12+
description: Full image name without tag (e.g., "ghcr.io/home-assistant/amd64-base")
13+
required: true
14+
image_tag:
15+
description: Main image tag (e.g., "3.23")
16+
required: true
17+
image_extra_tags:
18+
description: Additional tags, one per line
19+
required: false
20+
default: ""
21+
context:
22+
description: Build context (usually the directory with Dockerfile)
23+
required: true
24+
file:
25+
description: Dockerfile path (defaults to "Dockerfile" in the context directory)
26+
required: false
27+
default: ""
28+
version:
29+
description: Image version label
30+
required: true
31+
build_args:
32+
description: Additional build arguments (key=value format, one per line)
33+
required: false
34+
default: ""
35+
push:
36+
description: Whether to push images to registry
37+
required: false
38+
default: "false"
39+
cache_scope:
40+
description: Scope for build cache sharing (defaults to architecture, set if building multiple images from a single repo)
41+
required: false
42+
default: ""
43+
cache_image_tag:
44+
description: Tag of the image containing BuildKit inline cache metadata
45+
required: false
46+
default: "latest"
47+
docker_registry:
48+
description: Docker registry (e.g., "ghcr.io")
49+
required: false
50+
default: "ghcr.io"
51+
docker_username:
52+
description: Username for Docker registry (defaults to repository owner)
53+
required: false
54+
default: ${{ github.repository_owner }}
55+
docker_password:
56+
description: Password for Docker registry (use secrets.GITHUB_TOKEN for GHCR)
57+
required: true
58+
cosign:
59+
description: Whether to sign images with Cosign
60+
required: false
61+
default: "true"
62+
cosign_identity:
63+
description: Certificate identity regexp for verifying cache images (defaults to current repo pattern)
64+
required: false
65+
default: ""
66+
cosign_issuer:
67+
description: Certificate OIDC issuer regexp for all cosign verification
68+
required: false
69+
default: "https://token.actions.githubusercontent.com"
70+
verify_base:
71+
description: Base image reference to verify with cosign before building
72+
required: false
73+
default: ""
74+
cosign_base_identity:
75+
description: Certificate identity regexp for verifying the base (FROM) image
76+
required: false
77+
default: ""
78+
cosign_base_issuer:
79+
description: Certificate OIDC issuer regexp for base image verification (defaults to cosign_issuer)
80+
required: false
81+
default: ""
82+
83+
outputs:
84+
digest:
85+
description: Image digest from the build
86+
value: ${{ steps.build.outputs.digest }}
87+
88+
runs:
89+
using: composite
90+
steps:
91+
- name: Login to Docker Registry
92+
if: inputs.push == 'true'
93+
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
94+
with:
95+
registry: ${{ inputs.docker_registry }}
96+
username: ${{ inputs.docker_username }}
97+
password: ${{ inputs.docker_password }}
98+
99+
- name: Set up Docker Buildx
100+
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
101+
102+
- name: Install Cosign
103+
if: inputs.cosign == 'true' || inputs.verify_base != ''
104+
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
105+
with:
106+
cosign-release: "v2.5.3"
107+
108+
- name: Verify cache image ${{ inputs.image }}:${{ inputs.cache_image_tag }}
109+
if: inputs.cosign == 'true'
110+
id: verify_cache
111+
uses: ./.github/actions/cosign-verify
112+
with:
113+
image: ${{ inputs.image }}:${{ inputs.cache_image_tag }}
114+
cosign_identity: ${{ inputs.cosign_identity || env.DEFAULT_COSIGN_IDENTITY }}
115+
cosign_issuer: ${{ inputs.cosign_issuer }}
116+
allow_failure: true
117+
env:
118+
DEFAULT_COSIGN_IDENTITY: https://github.com/${{ github.repository }}/.*
119+
120+
- name: Verify base image
121+
if: inputs.push == 'true' && inputs.verify_base != '' && inputs.cosign_base_identity != ''
122+
uses: ./.github/actions/cosign-verify
123+
with:
124+
image: ${{ inputs.verify_base }}
125+
cosign_identity: ${{ inputs.cosign_base_identity }}
126+
cosign_issuer: ${{ inputs.cosign_base_issuer || inputs.cosign_issuer }}
127+
allow_failure: false
128+
129+
- name: Set build options
130+
id: options
131+
shell: bash
132+
env:
133+
IMAGE: ${{ inputs.image }}
134+
IMAGE_TAG: ${{ inputs.image_tag }}
135+
IMAGE_EXTRA_TAGS: ${{ inputs.image_extra_tags }}
136+
PUSH: ${{ inputs.push }}
137+
VERSION: ${{ inputs.version }}
138+
ARCH: ${{ inputs.arch }}
139+
GITHUB_REPOSITORY: ${{ github.repository }}
140+
BUILD_ARGS_INPUT: ${{ inputs.build_args }}
141+
COSIGN_ENABLED: ${{ inputs.cosign }}
142+
CACHE_SCOPE: ${{ inputs.cache_scope }}
143+
CACHE_VERIFIED: ${{ steps.verify_cache.outputs.verified }}
144+
FILE_INPUT: ${{ inputs.file }}
145+
CONTEXT: ${{ inputs.context }}
146+
run: |
147+
tags=()
148+
tags+=("${IMAGE}:${IMAGE_TAG}")
149+
while IFS= read -r tag; do
150+
[[ -n "$tag" ]] && tags+=("${IMAGE}:${tag}")
151+
done <<< "${IMAGE_EXTRA_TAGS}"
152+
153+
{
154+
echo "tags<<EOF"
155+
printf '%s\n' "${tags[@]}"
156+
echo "EOF"
157+
} >> "$GITHUB_OUTPUT"
158+
159+
build_date="$(date --rfc-3339=seconds --utc)"
160+
161+
build_args=()
162+
build_args+=("BUILD_VERSION=${VERSION}")
163+
build_args+=("BUILD_ARCH=${ARCH}")
164+
build_args+=("BUILD_DATE=${build_date}")
165+
build_args+=("BUILD_REPOSITORY=https://github.com/${GITHUB_REPOSITORY}")
166+
while IFS= read -r line; do
167+
[[ -n "$line" ]] && build_args+=("$line")
168+
done <<< "${BUILD_ARGS_INPUT}"
169+
{
170+
echo "build_args<<EOF"
171+
printf '%s\n' "${build_args[@]}"
172+
echo "EOF"
173+
} >> "$GITHUB_OUTPUT"
174+
175+
if [[ "${PUSH}" == "true" ]]; then
176+
echo output="type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true" >> "$GITHUB_OUTPUT"
177+
else
178+
echo output="type=docker" >> "$GITHUB_OUTPUT"
179+
fi
180+
181+
if [[ -z "${CACHE_SCOPE}" ]]; then
182+
cache_scope="${ARCH}"
183+
else
184+
cache_scope="${ARCH}-${CACHE_SCOPE}"
185+
fi
186+
echo cache_scope="${cache_scope}" >> "$GITHUB_OUTPUT"
187+
188+
cache_from=("type=gha,scope=${cache_scope}")
189+
if [[ "${COSIGN_ENABLED}" != "true" ]] || [[ "${CACHE_VERIFIED}" == "true" ]]; then
190+
cache_from+=("type=registry,ref=${IMAGE}:${IMAGE_TAG}")
191+
fi
192+
{
193+
echo "cache_from<<EOF"
194+
printf '%s\n' "${cache_from[@]}"
195+
echo "EOF"
196+
} >> "$GITHUB_OUTPUT"
197+
198+
if [ -z "${FILE_INPUT}" ]; then
199+
echo file="${CONTEXT}/Dockerfile" >> "$GITHUB_OUTPUT"
200+
else
201+
echo file="${FILE_INPUT}" >> "$GITHUB_OUTPUT"
202+
fi
203+
204+
- name: Build image
205+
id: build
206+
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
207+
with:
208+
context: ${{ env.CONTEXT }} # zizmor: ignore[template-injection]
209+
file: ${{ steps.options.outputs.file }}
210+
platforms: ${{ inputs.platform }}
211+
pull: true
212+
push: ${{ inputs.push == 'true' }}
213+
load: ${{ inputs.push != 'true' }}
214+
build-args: ${{ steps.options.outputs.build_args }}
215+
tags: ${{ steps.options.outputs.tags }}
216+
outputs: ${{ steps.options.outputs.output }}
217+
cache-to: |
218+
type=inline
219+
type=gha,mode=max,scope=${{ steps.options.outputs.cache_scope }}
220+
cache-from: ${{ steps.options.outputs.cache_from }}
221+
222+
env:
223+
CONTEXT: ${{ inputs.context }}
224+
225+
- name: Sign per-arch image
226+
if: inputs.push == 'true' && inputs.cosign == 'true'
227+
shell: bash
228+
env:
229+
IMAGE_REF: ${{ inputs.image }}@${{ steps.build.outputs.digest }}
230+
run: |
231+
echo "::group::Signing image: ${IMAGE_REF}"
232+
233+
for i in {1..5}; do
234+
if cosign sign --yes "${IMAGE_REF}"; then
235+
echo "Signed: ${IMAGE_REF}"
236+
exit 0
237+
fi
238+
echo "Signing attempt ${i} failed, retrying..."
239+
sleep $((2 ** i))
240+
done
241+
242+
echo "::endgroup::"
243+
244+
echo "::error::Failed to sign image ${IMAGE_REF}"
245+
exit 1
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
name: Verify a signature on the supplied container image
2+
description: |
3+
Verify Cosign signature of a container image.
4+
Requires Cosign to be installed in the runner environment.
5+
6+
inputs:
7+
image:
8+
description: Container image reference to verify
9+
cosign_identity:
10+
description: Certificate identity regexp for verifying cache images (defaults to current repo pattern)
11+
required: false
12+
default: ""
13+
cosign_issuer:
14+
description: Certificate OIDC issuer regexp for all cosign verification
15+
allow_failure:
16+
description: Whether to allow failure of this step (defaults to false). Only shows a warning if verification fails.
17+
required: false
18+
default: "false"
19+
20+
outputs:
21+
verified:
22+
description: Whether the image was successfully verified with Cosign
23+
value: ${{ steps.verify.outputs.verified }}
24+
25+
runs:
26+
using: composite
27+
steps:
28+
- name: Verify the image
29+
id: verify
30+
shell: bash
31+
env:
32+
IMAGE_REF: ${{ inputs.image }}
33+
COSIGN_IDENTITY: ${{ inputs.cosign_identity }}
34+
COSIGN_ISSUER: ${{ inputs.cosign_issuer }}
35+
ALLOW_FAILURE: ${{ inputs.allow_failure }}
36+
run: |
37+
echo "::group::Verifying image: ${IMAGE_REF}"
38+
39+
for i in {1..5}; do
40+
if cosign verify \
41+
--certificate-identity-regexp "${COSIGN_IDENTITY}" \
42+
--certificate-oidc-issuer-regexp "${COSIGN_ISSUER}" \
43+
"${IMAGE_REF}"; then
44+
echo "Image verified: ${IMAGE_REF}"
45+
echo "verified=true" >> "$GITHUB_OUTPUT"
46+
exit 0
47+
fi
48+
echo "Verification attempt ${i} failed, retrying..."
49+
sleep $((2 ** i))
50+
done
51+
52+
echo "::endgroup::"
53+
54+
echo "verified=false" >> "$GITHUB_OUTPUT"
55+
56+
if [[ "${ALLOW_FAILURE}" == "true" ]]; then
57+
echo "::warning::Image verification failed for ${IMAGE_REF}, ignoring"
58+
else
59+
echo "::error::Image verification failed for ${IMAGE_REF}"
60+
exit 1
61+
fi

0 commit comments

Comments
 (0)