From a7e52f5e9f4cd2bb9aaa3afd8fccfef3985c3b78 Mon Sep 17 00:00:00 2001 From: Renato Date: Wed, 5 Aug 2026 10:08:44 +0200 Subject: [PATCH] ci: build each architecture on its own native runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The publish job built linux/amd64 and linux/arm64 together on one amd64 runner, which meant the arm64 half ran under QEMU — and it was not a small tax. The same Dockerfile finishes in 1m58s for amd64 alone and had not finished after 25 minutes once arm64 joined it, because what gets emulated is `pnpm install` and the Vite build, instruction by instruction. The repository is public, so ubuntu-24.04-arm runners are free. Each architecture now builds natively on its own runner, concurrently. Neither leg tags anything. push-by-digest uploads an untagged image and returns a digest; a merge job assembles the two digests into one tagged manifest list. Tagging per leg would have let whichever finished last overwrite the other, leaving `latest` pointing at a single architecture. The merge job inspects the result and fails if both are not on it, since that is the failure this shape exists to prevent. Cache scopes are per architecture: sharing one would have the two legs evict each other's layers and start cold every run. Also drops `--filter tracker...` from the Dockerfile's install. tracker is a workspace dependency of web, so `--filter web...` already selects it — verified by building and booting the image, which serves /signin with a 200 and carries the same 5114-byte tracker.js at the same 590MB. --- .github/workflows/ci.yml | 146 ++++++++++++++++++++++++++++++++------- Dockerfile | 2 +- 2 files changed, 121 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d56da8..0a64e88 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,8 +118,8 @@ jobs: # The deployment path, which nothing else exercises. `pnpm build` covers the # app's own compilation, but the Dockerfile carries logic that only ever runs - # here: the base stage's tzdata assertion, the `--filter web... --filter - # tracker...` scoped installs, the `--prod` install, and the runner stage's + # here: the base stage's tzdata assertion, the `--filter web...` scoped + # install, the `--prod` install, and the runner stage's # hand-assembled node_modules copy. A dependency that only resolved because a # dev dependency hoisted it, or a file the runner stage forgets to copy, is # invisible to every step above and surfaces at deploy time. @@ -172,17 +172,37 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} - # Publishing lives here, keyed off the job above's output, rather than in a - # workflow triggered `on: release`. A release created with GITHUB_TOKEN does - # not fire `release`, `create`, or tag `push` events — GitHub suppresses them - # so workflows cannot trigger themselves — so the obvious wiring would simply - # never run. Reading `release_created` in the same workflow avoids needing a - # personal access token or a GitHub App just to break that loop. + # One runner per architecture, each building natively. + # + # Both architectures used to be built on one amd64 runner with arm64 under + # QEMU, and the emulated leg dominated everything: the same Dockerfile takes + # 1m58s for amd64 alone and over 25 minutes once arm64 joins it — the app's + # own `pnpm install` and Vite build, run through an instruction translator. + # The repository is public, so `ubuntu-24.04-arm` is free, and the two legs + # now run natively and concurrently instead. + # + # Neither leg tags anything. `push-by-digest` uploads an untagged image and + # returns its digest, and the merge job below assembles those digests into + # one tagged manifest list — a per-arch tag would otherwise be overwritten by + # whichever leg finished last, leaving `latest` pointing at one architecture. publish: - name: Publish + name: Publish (${{ matrix.suffix }}) if: github.event_name == 'push' needs: [release] - runs-on: ubuntu-latest + runs-on: ${{ matrix.runner }} + + strategy: + # One architecture failing should not cancel the other: knowing whether + # the failure is arch-specific is most of the diagnosis. + fail-fast: false + matrix: + include: + - platform: linux/amd64 + runner: ubuntu-latest + suffix: amd64 + - platform: linux/arm64 + runner: ubuntu-24.04-arm + suffix: arm64 permissions: contents: read @@ -190,12 +210,79 @@ jobs: steps: - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Labels only. Tags are the merge job's business; this one carries + # org.opencontainers.image.source, which links the package to the + # repository and is what makes it inherit repository visibility. + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + + - id: build + uses: docker/build-push-action@v6 + with: + context: . + platforms: ${{ matrix.platform }} + labels: ${{ steps.meta.outputs.labels }} + outputs: + type=image,name=ghcr.io/${{ github.repository + }},push-by-digest=true,name-canonical=true,push=true + # Scoped per architecture. A shared scope would have the two legs + # overwrite each other's layers, and every run would start cold. + cache-from: type=gha,scope=publish-${{ matrix.suffix }} + cache-to: type=gha,mode=max,scope=publish-${{ matrix.suffix }} + + # The digest is passed to the merge job as an empty file named after it: + # artifacts move files, and the name is the whole payload. + - name: Export digest + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.suffix }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + # Assembles the per-architecture digests into one tagged manifest list, so a + # single tag serves both architectures and Docker picks the right one. + # + # This is also where the tags are decided, keyed off the release job's output + # rather than living in a workflow triggered `on: release`. A release created + # with GITHUB_TOKEN does not fire `release`, `create`, or tag `push` events — + # GitHub suppresses them so workflows cannot trigger themselves — so the + # obvious wiring would simply never run. Reading `release_created` in the same + # workflow avoids needing a personal access token or a GitHub App just to + # break that loop. + merge: + name: Merge + if: github.event_name == 'push' + needs: [release, publish] + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - uses: actions/download-artifact@v4 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true - # linux/arm64 is emulated, so its install and build stages are slow on a - # cold cache. If that becomes the bottleneck, the repository is public and - # therefore has free ubuntu-24.04-arm runners: split into a per-platform - # matrix that builds by digest and merge with `buildx imagetools create`. - - uses: docker/setup-qemu-action@v3 - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 @@ -222,14 +309,21 @@ jobs: type=semver,pattern={{major}},value=${{ needs.release.outputs.tag_name }},enable=${{ needs.release.outputs.release_created == 'true' }} type=raw,value=latest,enable=${{ needs.release.outputs.release_created == 'true' }} - - uses: docker/build-push-action@v6 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - # Carries org.opencontainers.image.source, which is what links the - # package to this repository and inherits its visibility. - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha,scope=publish - cache-to: type=gha,mode=max,scope=publish + - name: Create manifest list and push + working-directory: /tmp/digests + env: + IMAGE: ghcr.io/${{ github.repository }} + run: | + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "$IMAGE@sha256:%s " *) + + # Fails the job if the manifest list did not come out with both + # architectures on it — the failure mode this job exists to prevent. + - name: Inspect + env: + IMAGE: ghcr.io/${{ github.repository }} + run: | + docker buildx imagetools inspect "$IMAGE:edge" + docker buildx imagetools inspect "$IMAGE:edge" --raw \ + | jq -e '[.manifests[].platform | select(.os != "unknown") | "\(.os)/\(.architecture)"] | sort == ["linux/amd64","linux/arm64"]' diff --git a/Dockerfile b/Dockerfile index 768505c..fe839dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ FROM base AS deps COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ COPY apps/web/package.json apps/web/ COPY packages/tracker/package.json packages/tracker/ -RUN pnpm install --frozen-lockfile --filter web... --filter tracker... +RUN pnpm install --frozen-lockfile --filter web... FROM deps AS build COPY . .