Split multi-arch Docker build into parallel jobs

- Modify Dockerfile to build single architecture based on TARGETARCH instead of cross-compiling both targets in one run
- Replace single build-image job with matrix job (amd64, arm64)
- Add finalize-image job that creates multi-arch manifests using `docker buildx imagetools create` and signs the final images
- Each architecture gets its own build cache

This enables parallel builds of each architecture, reducing total build time by running both simultaneously rather than sequentially.
This commit is contained in:
Quentin Gliech
2026-02-05 12:02:45 +01:00
committed by Quentin Gliech
parent 750de33486
commit c2dc7c11a9
3 changed files with 296 additions and 70 deletions
+280 -53
View File
@@ -207,25 +207,17 @@ jobs:
name: mas-cli-x86_64-linux
path: mas-cli-x86_64-linux.tar.gz
build-image:
name: Build and push Docker image
compute-image-meta:
name: Compute Docker image metadata
if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow'
runs-on: ubuntu-24.04
outputs:
metadata: ${{ steps.metadata.outputs.result }}
permissions:
contents: read
packages: write
id-token: write
needs:
- compute-version
env:
VERGEN_GIT_DESCRIBE: ${{ needs.compute-version.outputs.describe }}
SOURCE_DATE_EPOCH: ${{ needs.compute-version.outputs.timestamp }}
outputs:
regular-json: ${{ steps.meta.outputs.json }}
debug-json: ${{ steps.meta-debug.outputs.json }}
steps:
- name: Docker meta
@@ -247,6 +239,15 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
# GitHub's license detection (via Licensee) can only report a single
# SPDX identifier and still emits the legacy `AGPL-3.0` form, which
# `metadata-action` would otherwise propagate as-is. Override it with
# the project's actual dual-license SPDX expression so the image
# advertises both halves of the dual licensing.
labels: |
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
annotations: |
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
- name: Docker meta (debug variant)
id: meta-debug
@@ -266,10 +267,63 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha
labels: |
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
annotations: |
org.opencontainers.image.licenses=AGPL-3.0-only OR LicenseRef-Element-Commercial
- name: Setup Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
# Stage the labels bake files under predictable names (the metadata-action
# writes them under a random temp dir with the same base name) and ship
# them to `build-image` as an artifact. We deliberately only pass through
# `bake-file-labels` (not `bake-file-annotations`): the per-arch images
# then carry the same config labels as today, while annotations are only
# applied at the index level in `finalize-image` — matching `:latest`'s
# shape and sidestepping `metadata-action`'s empty-value annotations
# which would otherwise trip `docker buildx imagetools create`.
- name: Stage bake files
env:
REGULAR_FILE: ${{ steps.meta.outputs.bake-file-labels }}
DEBUG_FILE: ${{ steps.meta-debug.outputs.bake-file-labels }}
run: |
mkdir -p /tmp/bake
cp "$REGULAR_FILE" /tmp/bake/regular.json
cp "$DEBUG_FILE" /tmp/bake/debug.json
- name: Upload bake files
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: bake-files
path: /tmp/bake/
retention-days: 1
build-image:
name: Build Docker image (${{ matrix.arch }})
if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow'
runs-on: ubuntu-24.04
permissions:
contents: read
packages: write
id-token: write
needs:
- compute-version
- compute-image-meta
strategy:
fail-fast: false
matrix:
arch: [amd64, arm64]
env:
VERGEN_GIT_DESCRIBE: ${{ needs.compute-version.outputs.describe }}
SOURCE_DATE_EPOCH: ${{ needs.compute-version.outputs.timestamp }}
# Comma-separated list of registries to push each per-arch image to.
# The oci-push registry is only included on `push` events because the
# Tailscale + Vault login below requires the right OIDC token.
IMAGES: ghcr.io/element-hq/matrix-authentication-service${{ github.event_name == 'push' && ',oci-push.vpn.infra.element.io/matrix-authentication-service' || '' }}
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
with:
@@ -323,7 +377,13 @@ jobs:
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }}
- name: Build and push
- name: Download bake files
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: bake-files
path: /tmp/bake
- name: Build and push by digest
id: bake
uses: docker/bake-action@a66e1c87e2eca0503c343edf1d208c716d54b8a8 # v7.1.0
env:
@@ -337,35 +397,185 @@ jobs:
with:
files: |
./docker-bake.hcl
cwd://${{ steps.meta.outputs.bake-file }}
cwd://${{ steps.meta-debug.outputs.bake-file }}
cwd:///tmp/bake/regular.json
cwd:///tmp/bake/debug.json
set: |
base.output=type=image,push=true
base.cache-from=type=registry,ref=${{ env.BUILDCACHE }}:buildcache
base.cache-to=type=registry,ref=${{ env.BUILDCACHE }}:buildcache,mode=max
*.platform=linux/${{ matrix.arch }}
*.output=type=image,"name=${{ env.IMAGES }}",push-by-digest=true,name-canonical=true,push=true
*.cache-from=type=registry,ref=${{ env.BUILDCACHE }}:buildcache-${{ matrix.arch }}
*.cache-to=type=registry,ref=${{ env.BUILDCACHE }}:buildcache-${{ matrix.arch }},mode=max
- name: Transform bake output
# This transforms the output to an object which looks like this:
# { regular: { digest: "…", tags: ["…", "…"] }, debug: { digest: "…", tags: ["…"] }, … }
# We use github-script rather than shelling out to jq because the bake
# metadata can exceed the shell ARG_MAX limit when expanded.
id: metadata
# We use github-script rather than shelling out to jq because the bake
# metadata can exceed the shell ARG_MAX limit when inherited as an env
# var by an exec'd jq.
- name: Export digests
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
STEPS_BAKE_OUTPUTS_METADATA: ${{ steps.bake.outputs.metadata }}
ARCH: ${{ matrix.arch }}
with:
script: |
const fs = require('node:fs');
const path = require('node:path');
const bakeOutput = JSON.parse(process.env.STEPS_BAKE_OUTPUTS_METADATA);
const metadata = {};
for (const [key, value] of Object.entries(bakeOutput)) {
if (value && typeof value === 'object' && ('containerimage.digest' in value)) {
metadata[key] = {
digest: value['containerimage.digest'],
tags: value['image.name'].split(','),
};
const arch = process.env.ARCH;
fs.mkdirSync('/tmp/digests', { recursive: true });
for (const target of ['regular', 'debug']) {
const digest = bakeOutput[target]?.['containerimage.digest'];
if (!digest) {
throw new Error(`Missing containerimage.digest for target ${target}`);
}
fs.writeFileSync(path.join('/tmp/digests', `${target}-${arch}`), digest);
}
return metadata;
- name: Upload digests
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: digests-${{ matrix.arch }}
path: /tmp/digests/*
retention-days: 1
finalize-image:
name: Create multi-arch manifests
if: github.event_name == 'push' || github.event.label.name == 'Z-Build-Workflow'
runs-on: ubuntu-24.04
needs:
- build-image
- compute-image-meta
outputs:
metadata: ${{ steps.output.outputs.metadata }}
permissions:
contents: read
packages: write
id-token: write
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
pattern: digests-*
path: /tmp/digests
merge-multiple: true
- name: Setup Cosign
uses: sigstore/cosign-installer@cad07c2e89fa2edd6e2d7bab4c1aa38e53f76003 # v4.1.1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
# See `build-image` for why the Element OCI Registry login is gated on
# `push` events.
- name: Tailscale
if: github.event_name == 'push'
uses: tailscale/github-action@53acf823325fe9ca47f4cdaa951f90b4b0de5bb9 # v4.1.1
with:
oauth-client-id: ${{ secrets.TS_OAUTH_CLIENT_ID }}
audience: ${{ secrets.TS_AUDIENCE }}
tags: tag:github-actions
- name: Compute vault jwt role name
id: vault-jwt-role
if: github.event_name == 'push'
run: |
echo "role_name=github_service_management_$( echo "${{ github.repository }}" | sed -r 's|[/-]|_|g')" | tee -a "$GITHUB_OUTPUT"
- name: Get team registry token
id: import-secrets
if: github.event_name == 'push'
uses: hashicorp/vault-action@4c06c5ccf5c0761b6029f56cfb1dcf5565918a3b # v3.4.0
with:
url: https://vault.infra.ci.i.element.dev
role: ${{ steps.vault-jwt-role.outputs.role_name }}
path: service-management/github-actions
jwtGithubAudience: https://vault.infra.ci.i.element.dev
method: jwt
secrets: |
services/backend-repositories/secret/data/oci.element.io username | OCI_USERNAME ;
services/backend-repositories/secret/data/oci.element.io password | OCI_PASSWORD ;
- name: Login to Element OCI Registry
if: github.event_name == 'push'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: oci-push.vpn.infra.element.io
username: ${{ steps.import-secrets.outputs.OCI_USERNAME }}
password: ${{ steps.import-secrets.outputs.OCI_PASSWORD }}
- name: Create regular manifest
env:
META_JSON: ${{ needs.compute-image-meta.outputs.regular-json }}
run: |
REGULAR_AMD64=$(cat /tmp/digests/regular-amd64)
REGULAR_ARM64=$(cat /tmp/digests/regular-arm64)
# Build `-t TAG` and `--annotation index:NAME=VALUE` args into a bash
# array so that values containing spaces survive shell word-splitting.
# We keep the `index:` prefix so the annotations land on the index
# manifest itself rather than getting applied at the default manifest
# level. Empty-valued entries are filtered as a safety net (the bake
# step's annotation file is already pre-filtered upstream).
declare -a ARGS=()
while IFS= read -r tag; do
ARGS+=(-t "$tag")
done < <(jq -r '.tags[]' <<< "$META_JSON")
while IFS= read -r annotation; do
ARGS+=(--annotation "$annotation")
done < <(jq -r '
.annotations
| map(select(startswith("index:")))
| map(select(endswith("=") | not))
| .[]
' <<< "$META_JSON")
docker buildx imagetools create \
"${ARGS[@]}" \
"ghcr.io/element-hq/matrix-authentication-service@$REGULAR_AMD64" \
"ghcr.io/element-hq/matrix-authentication-service@$REGULAR_ARM64"
- name: Create debug manifest
env:
META_DEBUG_JSON: ${{ needs.compute-image-meta.outputs.debug-json }}
run: |
DEBUG_AMD64=$(cat /tmp/digests/debug-amd64)
DEBUG_ARM64=$(cat /tmp/digests/debug-arm64)
declare -a ARGS=()
while IFS= read -r tag; do
ARGS+=(-t "$tag")
done < <(jq -r '.tags[]' <<< "$META_DEBUG_JSON")
while IFS= read -r annotation; do
ARGS+=(--annotation "$annotation")
done < <(jq -r '
.annotations
| map(select(startswith("index:")))
| map(select(endswith("=") | not))
| .[]
' <<< "$META_DEBUG_JSON")
docker buildx imagetools create \
"${ARGS[@]}" \
"ghcr.io/element-hq/matrix-authentication-service@$DEBUG_AMD64" \
"ghcr.io/element-hq/matrix-authentication-service@$DEBUG_ARM64"
- name: Get manifest digests
id: manifests
env:
META_JSON: ${{ needs.compute-image-meta.outputs.regular-json }}
META_DEBUG_JSON: ${{ needs.compute-image-meta.outputs.debug-json }}
run: |
# Inspect the manifest list under the first tag to retrieve its digest
REGULAR_TAG=$(jq -r '.tags[0]' <<< "$META_JSON")
DEBUG_TAG=$(jq -r '.tags[0]' <<< "$META_DEBUG_JSON")
REGULAR_DIGEST=$(docker buildx imagetools inspect "$REGULAR_TAG" --format '{{ json . }}' | jq -r '.manifest.digest')
DEBUG_DIGEST=$(docker buildx imagetools inspect "$DEBUG_TAG" --format '{{ json . }}' | jq -r '.manifest.digest')
echo "regular=$REGULAR_DIGEST" >> $GITHUB_OUTPUT
echo "debug=$DEBUG_DIGEST" >> $GITHUB_OUTPUT
- name: Sign the images with GitHub Actions provided token
# Only sign on tags and on commits on main branch
@@ -374,8 +584,8 @@ jobs:
&& (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/main')
env:
REGULAR_DIGEST: ${{ steps.metadata.outputs.result && fromJSON(steps.metadata.outputs.result).regular.digest }}
DEBUG_DIGEST: ${{ steps.metadata.outputs.result && fromJSON(steps.metadata.outputs.result).debug.digest }}
REGULAR_DIGEST: ${{ steps.manifests.outputs.regular }}
DEBUG_DIGEST: ${{ steps.manifests.outputs.debug }}
run: |-
cosign sign --yes \
@@ -385,13 +595,30 @@ jobs:
"oci-push.vpn.infra.element.io/matrix-authentication-service@$REGULAR_DIGEST" \
"oci-push.vpn.infra.element.io/matrix-authentication-service@$DEBUG_DIGEST"
- name: Output metadata
id: output
env:
REGULAR_DIGEST: ${{ steps.manifests.outputs.regular }}
DEBUG_DIGEST: ${{ steps.manifests.outputs.debug }}
META_JSON: ${{ needs.compute-image-meta.outputs.regular-json }}
META_DEBUG_JSON: ${{ needs.compute-image-meta.outputs.debug-json }}
run: |
echo 'metadata<<EOF' >> $GITHUB_OUTPUT
jq -nc \
--arg regular_digest "$REGULAR_DIGEST" \
--arg debug_digest "$DEBUG_DIGEST" \
--argjson regular_tags "$(jq '.tags' <<< "$META_JSON")" \
--argjson debug_tags "$(jq '.tags' <<< "$META_DEBUG_JSON")" \
'{regular: {digest: $regular_digest, tags: $regular_tags}, debug: {digest: $debug_digest, tags: $debug_tags}}' >> $GITHUB_OUTPUT
echo 'EOF' >> $GITHUB_OUTPUT
release:
name: Release
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-24.04
needs:
- assemble-archives
- build-image
- finalize-image
steps:
- name: Download the artifacts from the previous job
@@ -412,12 +639,12 @@ jobs:
- Digest:
```
ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).regular.digest }}
oci.element.io/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).regular.digest }}
ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).regular.digest }}
oci.element.io/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).regular.digest }}
```
- Tags:
```
${{ join(fromJSON(needs.build-image.outputs.metadata).regular.tags, '
${{ join(fromJSON(needs.finalize-image.outputs.metadata).regular.tags, '
') }}
```
@@ -425,12 +652,12 @@ jobs:
- Digest:
```
ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).debug.digest }}
oci.element.io/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).debug.digest }}
ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).debug.digest }}
oci.element.io/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).debug.digest }}
```
- Tags:
```
${{ join(fromJSON(needs.build-image.outputs.metadata).debug.tags, '
${{ join(fromJSON(needs.finalize-image.outputs.metadata).debug.tags, '
') }}
```
@@ -446,7 +673,7 @@ jobs:
needs:
- assemble-archives
- build-image
- finalize-image
permissions:
contents: write
@@ -492,12 +719,12 @@ jobs:
- Digest:
```
ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).regular.digest }}
oci.element.io/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).regular.digest }}
ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).regular.digest }}
oci.element.io/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).regular.digest }}
```
- Tags:
```
${{ join(fromJSON(needs.build-image.outputs.metadata).regular.tags, '
${{ join(fromJSON(needs.finalize-image.outputs.metadata).regular.tags, '
') }}
```
@@ -505,12 +732,12 @@ jobs:
- Digest:
```
ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).debug.digest }}
oci.element.io/matrix-authentication-service@${{ fromJSON(needs.build-image.outputs.metadata).debug.digest }}
ghcr.io/element-hq/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).debug.digest }}
oci.element.io/matrix-authentication-service@${{ fromJSON(needs.finalize-image.outputs.metadata).debug.digest }}
```
- Tags:
```
${{ join(fromJSON(needs.build-image.outputs.metadata).debug.tags, '
${{ join(fromJSON(needs.finalize-image.outputs.metadata).debug.tags, '
') }}
```
@@ -526,7 +753,7 @@ jobs:
if: github.event_name == 'pull_request' && github.event.label.name == 'Z-Build-Workflow'
needs:
- build-image
- finalize-image
permissions:
contents: read
@@ -543,7 +770,7 @@ jobs:
- name: Remove label and comment
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env:
BUILD_IMAGE_MANIFEST: ${{ needs.build-image.outputs.metadata }}
BUILD_IMAGE_MANIFEST: ${{ needs.finalize-image.outputs.metadata }}
with:
script: |
const script = require('./.github/scripts/cleanup-pr.cjs');
+13 -10
View File
@@ -6,9 +6,9 @@
# Please see LICENSE files in the repository root for full details.
# Builds a minimal image with the binary only. It is multi-arch capable,
# cross-building to aarch64 and x86_64. When cross-compiling, Docker sets two
# cross-building to aarch64 or x86_64. When cross-compiling, Docker sets two
# implicit BUILDARG: BUILDPLATFORM being the host platform and TARGETPLATFORM
# being the platform being built.
# being the platform being built. Each architecture is built separately.
# The Debian version and version name must be in sync
ARG DEBIAN_VERSION=13
@@ -119,20 +119,25 @@ ENV SQLX_OFFLINE=true
ARG VERGEN_GIT_DESCRIBE
ENV VERGEN_GIT_DESCRIBE=${VERGEN_GIT_DESCRIBE}
ARG TARGETARCH
# Network access: cargo auditable needs it
RUN --network=default \
--mount=type=cache,target=/root/.cargo/registry \
--mount=type=cache,target=/app/target \
RUST_TARGET=$(case "${TARGETARCH}" in \
amd64) echo "x86_64-unknown-linux-gnu" ;; \
arm64) echo "aarch64-unknown-linux-gnu" ;; \
*) echo "unsupported architecture: ${TARGETARCH}" >&2; exit 1 ;; \
esac) && \
cargo auditable build \
--locked \
--release \
--bin mas-cli \
--no-default-features \
--features docker \
--target x86_64-unknown-linux-gnu \
--target aarch64-unknown-linux-gnu \
&& mv "target/x86_64-unknown-linux-gnu/release/mas-cli" /usr/local/bin/mas-cli-amd64 \
&& mv "target/aarch64-unknown-linux-gnu/release/mas-cli" /usr/local/bin/mas-cli-arm64
--target "${RUST_TARGET}" \
&& mv "target/${RUST_TARGET}/release/mas-cli" /usr/local/bin/mas-cli
#######################################
## Prepare /usr/local/share/mas-cli/ ##
@@ -149,8 +154,7 @@ COPY ./translations/ /share/translations
##################################
FROM gcr.io/distroless/cc-debian${DEBIAN_VERSION}:debug-nonroot AS debug
ARG TARGETARCH
COPY --from=builder /usr/local/bin/mas-cli-${TARGETARCH} /usr/local/bin/mas-cli
COPY --from=builder /usr/local/bin/mas-cli /usr/local/bin/mas-cli
COPY --from=share /share /usr/local/share/mas-cli
WORKDIR /
@@ -161,8 +165,7 @@ ENTRYPOINT ["/usr/local/bin/mas-cli"]
###################
FROM gcr.io/distroless/cc-debian${DEBIAN_VERSION}:nonroot
ARG TARGETARCH
COPY --from=builder /usr/local/bin/mas-cli-${TARGETARCH} /usr/local/bin/mas-cli
COPY --from=builder /usr/local/bin/mas-cli /usr/local/bin/mas-cli
COPY --from=share /share /usr/local/share/mas-cli
WORKDIR /
+3 -7
View File
@@ -1,3 +1,4 @@
# Copyright 2025, 2026 Element Creations Ltd.
# Copyright 2025 New Vector Ltd.
#
# SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
@@ -15,8 +16,8 @@ group "default" { targets = ["regular", "debug"] }
target "docker-metadata-action" {}
target "docker-metadata-action-debug" {}
// This sets the platforms and is further extended by GitHub Actions to set the
// output and the cache locations
// This is extended by GitHub Actions to set the output, cache locations,
// and platforms (one architecture per job for parallel builds)
target "base" {
args = {
// This is set so that when we use a git context, the .git directory is
@@ -26,11 +27,6 @@ target "base" {
// Pass down the version from an external git describe source
VERGEN_GIT_DESCRIBE = "${VERGEN_GIT_DESCRIBE}"
}
platforms = [
"linux/amd64",
"linux/arm64",
]
}
target "regular" {