Skip to content

Commit ae2b391

Browse files
authored
Merge pull request #88 from askides/ci/native-arm-runners
ci: build each architecture on its own native runner
2 parents 1b262ac + a7e52f5 commit ae2b391

2 files changed

Lines changed: 121 additions & 27 deletions

File tree

.github/workflows/ci.yml

Lines changed: 120 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -118,8 +118,8 @@ jobs:
118118

119119
# The deployment path, which nothing else exercises. `pnpm build` covers the
120120
# app's own compilation, but the Dockerfile carries logic that only ever runs
121-
# here: the base stage's tzdata assertion, the `--filter web... --filter
122-
# tracker...` scoped installs, the `--prod` install, and the runner stage's
121+
# here: the base stage's tzdata assertion, the `--filter web...` scoped
122+
# install, the `--prod` install, and the runner stage's
123123
# hand-assembled node_modules copy. A dependency that only resolved because a
124124
# dev dependency hoisted it, or a file the runner stage forgets to copy, is
125125
# invisible to every step above and surfaces at deploy time.
@@ -172,30 +172,117 @@ jobs:
172172
with:
173173
token: ${{ secrets.GITHUB_TOKEN }}
174174

175-
# Publishing lives here, keyed off the job above's output, rather than in a
176-
# workflow triggered `on: release`. A release created with GITHUB_TOKEN does
177-
# not fire `release`, `create`, or tag `push` events — GitHub suppresses them
178-
# so workflows cannot trigger themselves — so the obvious wiring would simply
179-
# never run. Reading `release_created` in the same workflow avoids needing a
180-
# personal access token or a GitHub App just to break that loop.
175+
# One runner per architecture, each building natively.
176+
#
177+
# Both architectures used to be built on one amd64 runner with arm64 under
178+
# QEMU, and the emulated leg dominated everything: the same Dockerfile takes
179+
# 1m58s for amd64 alone and over 25 minutes once arm64 joins it — the app's
180+
# own `pnpm install` and Vite build, run through an instruction translator.
181+
# The repository is public, so `ubuntu-24.04-arm` is free, and the two legs
182+
# now run natively and concurrently instead.
183+
#
184+
# Neither leg tags anything. `push-by-digest` uploads an untagged image and
185+
# returns its digest, and the merge job below assembles those digests into
186+
# one tagged manifest list — a per-arch tag would otherwise be overwritten by
187+
# whichever leg finished last, leaving `latest` pointing at one architecture.
181188
publish:
182-
name: Publish
189+
name: Publish (${{ matrix.suffix }})
183190
if: github.event_name == 'push'
184191
needs: [release]
185-
runs-on: ubuntu-latest
192+
runs-on: ${{ matrix.runner }}
193+
194+
strategy:
195+
# One architecture failing should not cancel the other: knowing whether
196+
# the failure is arch-specific is most of the diagnosis.
197+
fail-fast: false
198+
matrix:
199+
include:
200+
- platform: linux/amd64
201+
runner: ubuntu-latest
202+
suffix: amd64
203+
- platform: linux/arm64
204+
runner: ubuntu-24.04-arm
205+
suffix: arm64
186206

187207
permissions:
188208
contents: read
189209
packages: write
190210

191211
steps:
192212
- uses: actions/checkout@v4
213+
- uses: docker/setup-buildx-action@v3
214+
215+
- uses: docker/login-action@v3
216+
with:
217+
registry: ghcr.io
218+
username: ${{ github.actor }}
219+
password: ${{ secrets.GITHUB_TOKEN }}
220+
221+
# Labels only. Tags are the merge job's business; this one carries
222+
# org.opencontainers.image.source, which links the package to the
223+
# repository and is what makes it inherit repository visibility.
224+
- id: meta
225+
uses: docker/metadata-action@v5
226+
with:
227+
images: ghcr.io/${{ github.repository }}
228+
229+
- id: build
230+
uses: docker/build-push-action@v6
231+
with:
232+
context: .
233+
platforms: ${{ matrix.platform }}
234+
labels: ${{ steps.meta.outputs.labels }}
235+
outputs:
236+
type=image,name=ghcr.io/${{ github.repository
237+
}},push-by-digest=true,name-canonical=true,push=true
238+
# Scoped per architecture. A shared scope would have the two legs
239+
# overwrite each other's layers, and every run would start cold.
240+
cache-from: type=gha,scope=publish-${{ matrix.suffix }}
241+
cache-to: type=gha,mode=max,scope=publish-${{ matrix.suffix }}
242+
243+
# The digest is passed to the merge job as an empty file named after it:
244+
# artifacts move files, and the name is the whole payload.
245+
- name: Export digest
246+
env:
247+
DIGEST: ${{ steps.build.outputs.digest }}
248+
run: |
249+
mkdir -p /tmp/digests
250+
touch "/tmp/digests/${DIGEST#sha256:}"
251+
252+
- uses: actions/upload-artifact@v4
253+
with:
254+
name: digests-${{ matrix.suffix }}
255+
path: /tmp/digests/*
256+
if-no-files-found: error
257+
retention-days: 1
258+
259+
# Assembles the per-architecture digests into one tagged manifest list, so a
260+
# single tag serves both architectures and Docker picks the right one.
261+
#
262+
# This is also where the tags are decided, keyed off the release job's output
263+
# rather than living in a workflow triggered `on: release`. A release created
264+
# with GITHUB_TOKEN does not fire `release`, `create`, or tag `push` events —
265+
# GitHub suppresses them so workflows cannot trigger themselves — so the
266+
# obvious wiring would simply never run. Reading `release_created` in the same
267+
# workflow avoids needing a personal access token or a GitHub App just to
268+
# break that loop.
269+
merge:
270+
name: Merge
271+
if: github.event_name == 'push'
272+
needs: [release, publish]
273+
runs-on: ubuntu-latest
274+
275+
permissions:
276+
contents: read
277+
packages: write
278+
279+
steps:
280+
- uses: actions/download-artifact@v4
281+
with:
282+
path: /tmp/digests
283+
pattern: digests-*
284+
merge-multiple: true
193285

194-
# linux/arm64 is emulated, so its install and build stages are slow on a
195-
# cold cache. If that becomes the bottleneck, the repository is public and
196-
# therefore has free ubuntu-24.04-arm runners: split into a per-platform
197-
# matrix that builds by digest and merge with `buildx imagetools create`.
198-
- uses: docker/setup-qemu-action@v3
199286
- uses: docker/setup-buildx-action@v3
200287

201288
- uses: docker/login-action@v3
@@ -222,14 +309,21 @@ jobs:
222309
type=semver,pattern={{major}},value=${{ needs.release.outputs.tag_name }},enable=${{ needs.release.outputs.release_created == 'true' }}
223310
type=raw,value=latest,enable=${{ needs.release.outputs.release_created == 'true' }}
224311
225-
- uses: docker/build-push-action@v6
226-
with:
227-
context: .
228-
platforms: linux/amd64,linux/arm64
229-
push: true
230-
tags: ${{ steps.meta.outputs.tags }}
231-
# Carries org.opencontainers.image.source, which is what links the
232-
# package to this repository and inherits its visibility.
233-
labels: ${{ steps.meta.outputs.labels }}
234-
cache-from: type=gha,scope=publish
235-
cache-to: type=gha,mode=max,scope=publish
312+
- name: Create manifest list and push
313+
working-directory: /tmp/digests
314+
env:
315+
IMAGE: ghcr.io/${{ github.repository }}
316+
run: |
317+
docker buildx imagetools create \
318+
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
319+
$(printf "$IMAGE@sha256:%s " *)
320+
321+
# Fails the job if the manifest list did not come out with both
322+
# architectures on it — the failure mode this job exists to prevent.
323+
- name: Inspect
324+
env:
325+
IMAGE: ghcr.io/${{ github.repository }}
326+
run: |
327+
docker buildx imagetools inspect "$IMAGE:edge"
328+
docker buildx imagetools inspect "$IMAGE:edge" --raw \
329+
| jq -e '[.manifests[].platform | select(.os != "unknown") | "\(.os)/\(.architecture)"] | sort == ["linux/amd64","linux/arm64"]'

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ FROM base AS deps
2727
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
2828
COPY apps/web/package.json apps/web/
2929
COPY packages/tracker/package.json packages/tracker/
30-
RUN pnpm install --frozen-lockfile --filter web... --filter tracker...
30+
RUN pnpm install --frozen-lockfile --filter web...
3131

3232
FROM deps AS build
3333
COPY . .

0 commit comments

Comments
 (0)