From 45d99ff0393c92e427d474fbb172fa9df8e19f26 Mon Sep 17 00:00:00 2001 From: Emma Thorpe Date: Mon, 24 Aug 2026 17:24:56 +0100 Subject: [PATCH] ci: build the image once instead of twice A pull request took roughly eleven minutes to go green, and the log shows one CACHED line in the whole run. The image was being built twice, in full. The test stage is built by the runner's docker daemon. The runtime stage was then built by docker/build-push-action, which runs under a buildx builder that setup-buildx-action creates in its own container with its own cache. The two share nothing, so the second build pulled the base image again, ran pip install again, and exported the layers again -- about four and a half minutes, plus another thirty-five seconds to boot buildkit. The comment above the test step claimed those layers were shared, which is what made this look reasonable. buildx earns that overhead when producing several architectures. This produces linux/amd64 only, by an explicit decision recorded in the workflow, so it earns nothing here. Use plain docker build against the same daemon that ran the tests, and push with docker push. The runtime stage is a strict prefix of the test stage, so every layer is a cache hit: measured at 1.3 seconds locally against roughly four and a half minutes in CI. The remaining time is the runner itself, which is slow in absolute terms -- pytest takes four seconds locally and a hundred and two in CI. That is not something the workflow can fix. --- .gitea/workflows/build-and-publish.yaml | 48 +++++++++++++++---------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/.gitea/workflows/build-and-publish.yaml b/.gitea/workflows/build-and-publish.yaml index 1ec040f..fa14cbd 100644 --- a/.gitea/workflows/build-and-publish.yaml +++ b/.gitea/workflows/build-and-publish.yaml @@ -45,7 +45,8 @@ jobs: # The suite runs inside the image, against the interpreter that ships, # rather than against whatever the runner happens to provide. A failing - # test fails the build. Layers are shared with the push build below. + # test fails the build. The runtime stage below is built from the same + # daemon afterwards, so its layers are already in cache. - name: Run the test suite inside the image run: docker build --target test -t music-curator:test . @@ -124,9 +125,6 @@ jobs: echo "release=${release}" >> "$GITHUB_OUTPUT" echo "Computed bump=${bump}, release=${release}, base=${base}" - - name: Set up Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - - name: Log in to the Gitea container registry if: github.event_name != 'pull_request' uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 @@ -135,21 +133,33 @@ jobs: username: ${{ github.repository_owner }} password: ${{ secrets.PACKAGES_TOKEN }} - - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 - with: - context: . - # Without this the last stage in the Dockerfile -- the test stage -- - # would be what gets published. - target: runtime - # The NAS is the only host this runs on. Building arm64 as well would - # mean emulating it under QEMU for no consumer. - platforms: linux/amd64 - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.version.outputs.tags }} - labels: | - org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }} - org.opencontainers.image.revision=${{ github.sha }} + # Plain `docker build` rather than buildx. buildx boots its own buildkit + # in a container with a cache of its own, so it shared nothing with the + # test build above and rebuilt the image from the base image up -- two + # full builds per run. It earns that cost when building for several + # platforms; this only ever targets the amd64 NAS, so it does not. + # + # `--target runtime` is a strict prefix of the test stage, so every layer + # is already in the daemon's cache and this resolves in seconds. + - name: Build the runtime image + run: | + set -euo pipefail + tags=() + while IFS= read -r tag; do + [ -n "$tag" ] && tags+=(-t "$tag") + done <<< "${{ steps.version.outputs.tags }}" + docker build --target runtime \ + --label "org.opencontainers.image.source=${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}" \ + --label "org.opencontainers.image.revision=${GITHUB_SHA}" \ + "${tags[@]}" . + + - name: Push + if: github.event_name != 'pull_request' + run: | + set -euo pipefail + while IFS= read -r tag; do + [ -n "$tag" ] && docker push "$tag" + done <<< "${{ steps.version.outputs.tags }}" # Record the release: write the computed version into pyproject.toml, then # commit and tag it, so the packaging metadata always matches the release -- 2.54.0