@@ -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"]'
0 commit comments