Skip to content

Commit 9b6a70a

Browse files
authored
ci: native ARM runners for multi-arch Docker builds
Replaces QEMU emulation of arm64 Docker builds with native `ubuntu-24.04-arm` runners. Both `main.yml` (publishes `:main`) and `release.yml` (publishes `:version` + `:latest`) now use the standard docker/build-push-action multi-platform pattern: per-arch builds run in parallel with native runners, push by digest only, then a per-image manifest job joins the digests into the final multi-arch tag. Wire shape for consumers is identical — `docker pull ghcr.io/opendecree/decree:main` still resolves to a multi-arch manifest pointing at both arches. Cache strategy: per-arch buildcache tags (`:buildcache-amd64`, `:buildcache-arm64`) replace the previous single `:buildcache`. Mixing arches under one tag confused `cache-from` when only one arch's layers were wanted. The old tag is no longer written; on the first merge after this lands, both arches build from scratch and warm up the new caches. `ci.yml` is unaffected — its tools-image pipeline is single-arch (amd64 runners only) with its own `decree-tools:buildcache`. Validation note: the new workflows only run on push-to-main (main.yml) and `v*` tag-push (release.yml), so the CI on this PR exercised neither. The first real merge and the next release are the smoke tests; revert is straightforward (revert just the workflow file) if anything breaks. Closes #9.
1 parent 4a9ca52 commit 9b6a70a

2 files changed

Lines changed: 181 additions & 39 deletions

File tree

.github/workflows/main.yml

Lines changed: 92 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
# Runs on every push to main — publishes bleeding-edge Docker images and
22
# deploys documentation when docs/ changed. Canonical writer for the
33
# `:buildcache` registry cache consumed by ci.yml and release.yml.
4+
#
5+
# Multi-arch images are built natively per arch (amd64 on ubuntu-latest,
6+
# arm64 on ubuntu-24.04-arm) instead of via QEMU emulation — issue #9.
7+
# Each per-arch build pushes by digest only, then a manifest job per
8+
# image joins the digests into a single :main multi-arch tag.
49

510
name: Main
611

@@ -17,18 +22,28 @@ env:
1722
REGISTRY: ghcr.io
1823

1924
jobs:
20-
# Builds multi-arch server + CLI images tagged :main and populates the
21-
# :buildcache tag that all other workflows consume.
22-
images:
23-
name: Push bleeding-edge images
24-
runs-on: ubuntu-latest
25+
# Per-arch image builds. 2 images × 2 architectures = 4 jobs running in
26+
# parallel. Each pushes by digest only (push-by-digest=true) so the tag
27+
# remains free for the manifest job to claim.
28+
build:
29+
name: Build ${{ matrix.image }} (${{ matrix.platform }})
30+
runs-on: ${{ matrix.runner }}
2531
strategy:
32+
fail-fast: false
2633
matrix:
27-
include:
28-
- image: decree
29-
dockerfile: build/Dockerfile
30-
- image: decree-cli
31-
dockerfile: build/Dockerfile.decree
34+
image:
35+
- { name: decree, dockerfile: build/Dockerfile }
36+
- { name: decree-cli, dockerfile: build/Dockerfile.decree }
37+
arch:
38+
- { platform: linux/amd64, runner: ubuntu-latest, suffix: amd64 }
39+
- { platform: linux/arm64, runner: ubuntu-24.04-arm, suffix: arm64 }
40+
# Flatten the 2D matrix manually so each job can address image+arch
41+
# via the unified `matrix.image` and `matrix.platform` tuple keys.
42+
# GHA's strategy.matrix already produces the cross product; we just
43+
# alias the nested fields for readability inline.
44+
env:
45+
IMAGE_REF: ${{ format('ghcr.io/{0}/{1}', github.repository_owner, matrix.image.name) }}
46+
PLATFORM_TAG: ${{ matrix.arch.platform }}
3247
steps:
3348
- name: Checkout
3449
uses: actions/checkout@v6
@@ -45,19 +60,79 @@ jobs:
4560
username: ${{ github.actor }}
4661
password: ${{ secrets.GITHUB_TOKEN }}
4762

48-
- name: Build and push
63+
- name: Build and push by digest
64+
id: build
4965
uses: docker/build-push-action@v7
5066
with:
5167
context: .
52-
file: ${{ matrix.dockerfile }}
53-
push: true
54-
platforms: linux/amd64,linux/arm64
68+
file: ${{ matrix.image.dockerfile }}
69+
platforms: ${{ matrix.arch.platform }}
5570
build-args: |
5671
VERSION=main
5772
COMMIT=${{ github.sha }}
58-
tags: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:main
59-
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:buildcache
60-
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:buildcache,mode=max
73+
# Per-arch buildcache tags so each architecture's layers stay
74+
# cleanly separated. Mixing them under a single tag confuses
75+
# cache-from when a downstream workflow only wants one arch.
76+
cache-from: type=registry,ref=${{ env.IMAGE_REF }}:buildcache-${{ matrix.arch.suffix }}
77+
cache-to: type=registry,ref=${{ env.IMAGE_REF }}:buildcache-${{ matrix.arch.suffix }},mode=max
78+
# push-by-digest pushes the image WITHOUT a tag, returning only
79+
# the manifest digest. The manifest job collects the digests
80+
# and assembles the final multi-arch tag.
81+
outputs: type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=true
82+
83+
- name: Export digest
84+
run: |
85+
mkdir -p /tmp/digests
86+
digest="${{ steps.build.outputs.digest }}"
87+
touch "/tmp/digests/${digest#sha256:}"
88+
89+
- name: Upload digest artifact
90+
uses: actions/upload-artifact@v4
91+
with:
92+
name: digests-${{ matrix.image.name }}-${{ matrix.arch.suffix }}
93+
path: /tmp/digests/*
94+
if-no-files-found: error
95+
retention-days: 1
96+
97+
# One manifest job per image. Combines the per-arch digests into a
98+
# single multi-arch :main tag, the same shape consumers see today.
99+
manifest:
100+
name: Manifest ${{ matrix.image }}
101+
runs-on: ubuntu-latest
102+
needs: build
103+
strategy:
104+
fail-fast: false
105+
matrix:
106+
image: [decree, decree-cli]
107+
env:
108+
IMAGE_REF: ${{ format('ghcr.io/{0}/{1}', github.repository_owner, matrix.image) }}
109+
steps:
110+
- name: Download digest artifacts
111+
uses: actions/download-artifact@v4
112+
with:
113+
path: /tmp/digests
114+
pattern: digests-${{ matrix.image }}-*
115+
merge-multiple: true
116+
117+
- name: Set up Docker Buildx
118+
uses: docker/setup-buildx-action@v4
119+
120+
- name: Log in to ghcr.io
121+
uses: docker/login-action@v4
122+
with:
123+
registry: ${{ env.REGISTRY }}
124+
username: ${{ github.actor }}
125+
password: ${{ secrets.GITHUB_TOKEN }}
126+
127+
- name: Create multi-arch manifest
128+
working-directory: /tmp/digests
129+
run: |
130+
docker buildx imagetools create \
131+
--tag "${{ env.IMAGE_REF }}:main" \
132+
$(printf '${{ env.IMAGE_REF }}@sha256:%s ' *)
133+
134+
- name: Inspect manifest
135+
run: docker buildx imagetools inspect "${{ env.IMAGE_REF }}:main"
61136

62137
# Rebuilds and deploys the MkDocs site to GitHub Pages whenever docs/ changes.
63138
docs:

.github/workflows/release.yml

Lines changed: 89 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -200,20 +200,25 @@ jobs:
200200
push: true
201201
push_disable_create: true
202202

203-
# Builds multi-arch release images tagged :version and :latest, reusing the
204-
# :buildcache layers populated by main.yml.
205-
images:
206-
name: Build and push images
207-
runs-on: ubuntu-latest
203+
# Per-arch image builds. Each matrix entry runs natively on its own
204+
# architecture (amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm)
205+
# rather than going through QEMU emulation — issue #9. push-by-digest
206+
# is used so the manifest job downstream can claim the version + latest
207+
# tags after both arches finish.
208+
build:
209+
name: Build ${{ matrix.image.name }} (${{ matrix.arch.platform }})
210+
runs-on: ${{ matrix.arch.runner }}
208211
strategy:
212+
fail-fast: false
209213
matrix:
210-
include:
211-
- image: decree
212-
dockerfile: build/Dockerfile
213-
context: .
214-
- image: decree-cli
215-
dockerfile: build/Dockerfile.decree
216-
context: .
214+
image:
215+
- { name: decree, dockerfile: build/Dockerfile }
216+
- { name: decree-cli, dockerfile: build/Dockerfile.decree }
217+
arch:
218+
- { platform: linux/amd64, runner: ubuntu-latest, suffix: amd64 }
219+
- { platform: linux/arm64, runner: ubuntu-24.04-arm, suffix: arm64 }
220+
env:
221+
IMAGE_REF: ${{ format('ghcr.io/{0}/{1}', github.repository_owner, matrix.image.name) }}
217222
steps:
218223
- name: Checkout
219224
uses: actions/checkout@v6
@@ -236,18 +241,80 @@ jobs:
236241
VERSION=${GITHUB_REF_NAME#v}
237242
echo "version=$VERSION" >> $GITHUB_OUTPUT
238243
239-
- name: Build and push
244+
- name: Build and push by digest
245+
id: build
240246
uses: docker/build-push-action@v7
241247
with:
242-
context: ${{ matrix.context }}
243-
file: ${{ matrix.dockerfile }}
244-
push: true
245-
platforms: linux/amd64,linux/arm64
248+
context: .
249+
file: ${{ matrix.image.dockerfile }}
250+
platforms: ${{ matrix.arch.platform }}
246251
build-args: |
247252
VERSION=${{ steps.meta.outputs.version }}
248253
COMMIT=${{ github.sha }}
249-
tags: |
250-
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:${{ steps.meta.outputs.version }}
251-
${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:latest
252-
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:buildcache
253-
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ matrix.image }}:buildcache,mode=max
254+
# Per-arch buildcache tags shared with main.yml. Reading the
255+
# arm64 cache from an amd64 builder (or vice versa) wastes
256+
# network round-trips, so we keep them split.
257+
cache-from: type=registry,ref=${{ env.IMAGE_REF }}:buildcache-${{ matrix.arch.suffix }}
258+
cache-to: type=registry,ref=${{ env.IMAGE_REF }}:buildcache-${{ matrix.arch.suffix }},mode=max
259+
outputs: type=image,name=${{ env.IMAGE_REF }},push-by-digest=true,name-canonical=true,push=true
260+
261+
- name: Export digest
262+
run: |
263+
mkdir -p /tmp/digests
264+
digest="${{ steps.build.outputs.digest }}"
265+
touch "/tmp/digests/${digest#sha256:}"
266+
267+
- name: Upload digest artifact
268+
uses: actions/upload-artifact@v4
269+
with:
270+
name: digests-${{ matrix.image.name }}-${{ matrix.arch.suffix }}
271+
path: /tmp/digests/*
272+
if-no-files-found: error
273+
retention-days: 1
274+
275+
# One manifest job per image. Joins the per-arch digests into the
276+
# final :version + :latest multi-arch tags consumers pull.
277+
images:
278+
name: Manifest ${{ matrix.image }}
279+
runs-on: ubuntu-latest
280+
needs: build
281+
strategy:
282+
fail-fast: false
283+
matrix:
284+
image: [decree, decree-cli]
285+
env:
286+
IMAGE_REF: ${{ format('ghcr.io/{0}/{1}', github.repository_owner, matrix.image) }}
287+
steps:
288+
- name: Download digest artifacts
289+
uses: actions/download-artifact@v4
290+
with:
291+
path: /tmp/digests
292+
pattern: digests-${{ matrix.image }}-*
293+
merge-multiple: true
294+
295+
- name: Set up Docker Buildx
296+
uses: docker/setup-buildx-action@v4
297+
298+
- name: Log in to ghcr.io
299+
uses: docker/login-action@v4
300+
with:
301+
registry: ${{ env.REGISTRY }}
302+
username: ${{ github.actor }}
303+
password: ${{ secrets.GITHUB_TOKEN }}
304+
305+
- name: Extract version
306+
id: meta
307+
run: |
308+
VERSION=${GITHUB_REF_NAME#v}
309+
echo "version=$VERSION" >> $GITHUB_OUTPUT
310+
311+
- name: Create multi-arch manifest
312+
working-directory: /tmp/digests
313+
run: |
314+
docker buildx imagetools create \
315+
--tag "${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}" \
316+
--tag "${{ env.IMAGE_REF }}:latest" \
317+
$(printf '${{ env.IMAGE_REF }}@sha256:%s ' *)
318+
319+
- name: Inspect manifest
320+
run: docker buildx imagetools inspect "${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}"

0 commit comments

Comments
 (0)