From cc7ed5f565feea9e869cd11e4df54c411c87fdb9 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Thu, 12 Nov 2026 02:43:33 -0300 Subject: [PATCH] ci: the BuildKit cache in GitHub's registry, which the KVM fleet can read compat compiled QEMU and the kernel cold on every run. runs-on points ACTIONS_CACHE_URL and ACTIONS_RESULTS_URL at its own cache, so `type=gha` on the fleet read a store nobody writes: six compat logs read, no cached QEMU layer, ~4390 ninja steps and ~2345 kernel CCs in each. A cold QEMU build reinstalls qemu/apk.lock from Alpine's live mirror, and once the mirror stopped serving what it pins compat failed on main four times running (from 2026-11-09), and both release attempts on 2026-11-12 failed at it. The Actions cache was also over its 10 GB (10.36 GB, 142 entries, 2026-11-12) and evicting least recently used. The caches are now cache:qemu, cache:kernel, cache:e2fsprogs and cache:image under ghcr.io/spin-stack/spin-machine, written by main's lanes and read by pull requests, compat and the release with a docker login. The ghaction-github-runtime step has nothing left to do and goes. The first push to main compiles cold once, to fill the registry. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/compat.yml | 20 ++++++++++----- .github/workflows/image.yml | 23 +++++++++++------ .github/workflows/kernel.yml | 21 ++++++++++------ .github/workflows/qemu.yml | 47 ++++++++++++----------------------- .github/workflows/release.yml | 27 +++++++++++++------- Taskfile.yml | 45 +++++++++++++++++---------------- qemu/Taskfile.yml | 2 +- 7 files changed, 101 insertions(+), 84 deletions(-) diff --git a/.github/workflows/compat.yml b/.github/workflows/compat.yml index f8e3dc9..3c47cac 100644 --- a/.github/workflows/compat.yml +++ b/.github/workflows/compat.yml @@ -9,8 +9,8 @@ name: Compat # On the KVM fleet (spin-stack/ci), because it boots: the newest release is unpacked as the machine # before, and the machine here is that tree with this change's QEMU, firmware and kernel built over # it - the base image is the release's, since a checkpoint resumes with the disks it was saved -# with. The builds read the BuildKit cache main's lanes export (CACHE_BACKEND=gha), so a change -# that leaves QEMU and the kernel alone compiles neither. +# with. The builds read the BuildKit cache main's lanes export (CACHE_BACKEND=registry), so a +# change that leaves QEMU and the kernel alone compiles neither. # # The test holds the promise only where it is made: when the two fingerprints differ the contract # moved on purpose, the test says so and passes, and the release notes say it for the fleet. @@ -39,6 +39,8 @@ on: permissions: contents: read + # The BuildKit cache main's lanes write (Taskfile.yml, CACHE_REGISTRY). + packages: read # Per ref and never cancelled: a run on main is a commit somebody needs the answer for, and a # release's waits on it. Called from release.yml it is the release's job and takes its group. @@ -71,17 +73,23 @@ jobs: - name: A BuildKit that reads the lanes' cache run: go tool task boot:buildkit - # The tokens `type=gha` needs in a `run:` step; qemu.yml says why. - - uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + # The cache is one of this repository's packages, and reading it takes a login: without one + # BuildKit carries on without the cache and compiles QEMU and the kernel cold. + - name: Log in to GitHub Packages + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} # The guests' vsock transport, loaded and made this user's: a module on the fleet's kernel # that nothing loads before the first QEMU opens /dev/vhost-vsock. - name: Load vhost_vsock run: go tool task vsock - # Reads the lanes' scopes and writes none back: main's lanes keep them current. + # Reads the lanes' caches and writes none back: main's lanes keep them current. - name: Build this change's QEMU and kernel, and the tools - run: go tool task qemu:build kernel:build tools CACHE_BACKEND=gha CACHE_EXPORT=false + run: go tool task qemu:build kernel:build tools CACHE_BACKEND=registry CACHE_EXPORT=false # The newest release, checked against its sha256 and its signature, as the machine before; # this change's machine is the same tree with what was just built in place of its own. The diff --git a/.github/workflows/image.yml b/.github/workflows/image.yml index d82a02f..8f66dc8 100644 --- a/.github/workflows/image.yml +++ b/.github/workflows/image.yml @@ -31,6 +31,8 @@ on: permissions: contents: read + # The BuildKit cache: main writes it, everything else reads it (Taskfile.yml, CACHE_REGISTRY). + packages: write concurrency: group: image-${{ github.head_ref || github.ref_name }} @@ -84,20 +86,25 @@ jobs: cache: true # A builder of its own (the docker-container driver): Docker's default one cannot export its - # cache. What it reads and writes is GitHub's cache, in the scopes Taskfile.yml decides - # (CACHE_BACKEND=gha), so the runner itself keeps nothing between runs. + # cache. What it reads and writes is the cache image in GitHub's registry that Taskfile.yml + # names (CACHE_BACKEND=registry), so the runner itself keeps nothing between runs. - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - # Without this, `--cache-to type=gha` below is a silent no-op: the tokens BuildKit - # needs reach JavaScript actions and not `run:` steps. See the long note in qemu.yml. - - name: Expose the Actions cache to buildx - uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + # The cache is one of this repository's packages, and reading it takes a login as much as + # writing it does. Without one BuildKit says it could not import the cache, carries on + # without it, and the build passes at the cold time. + - name: Log in to GitHub Packages + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} # The image is made and checked with the static e2fsprogs this repository builds, not # the build container's, and image/build.sh stops when it is not there. - name: Build e2fsprogs env: - CACHE_BACKEND: gha + CACHE_BACKEND: registry CACHE_EXPORT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} run: go tool task e2fsprogs:build @@ -113,7 +120,7 @@ jobs: # and a cache entry large enough to push others out of the repository's 10 GB. - name: Build the base image env: - CACHE_BACKEND: gha + CACHE_BACKEND: registry CACHE_EXPORT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} run: go tool task image:build diff --git a/.github/workflows/kernel.yml b/.github/workflows/kernel.yml index 8c5506f..643ced5 100644 --- a/.github/workflows/kernel.yml +++ b/.github/workflows/kernel.yml @@ -28,6 +28,8 @@ on: permissions: contents: read + # The BuildKit cache: main writes it, everything else reads it (Taskfile.yml, CACHE_REGISTRY). + packages: write concurrency: group: kernel-${{ github.head_ref || github.ref_name }} @@ -80,21 +82,26 @@ jobs: cache: true # A builder of its own (the docker-container driver): Docker's default one cannot export its - # cache. What it reads and writes is GitHub's cache, in the scopes Taskfile.yml decides - # (CACHE_BACKEND=gha), so the runner itself keeps nothing between runs. + # cache. What it reads and writes is the cache image in GitHub's registry that Taskfile.yml + # names (CACHE_BACKEND=registry), so the runner itself keeps nothing between runs. - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - # Without this, `--cache-to type=gha` below is a silent no-op: the tokens BuildKit - # needs reach JavaScript actions and not `run:` steps. See the long note in qemu.yml. - - name: Expose the Actions cache to buildx - uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + # The cache is one of this repository's packages, and reading it takes a login as much as + # writing it does. Without one BuildKit says it could not import the cache, carries on + # without it, and the build passes at the cold time. + - name: Log in to GitHub Packages + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} # Ends in `task kernel:verify`, which opens the ELF and checks the Xen PVH notes # survived the strip. Without them QEMU has no entry point into this kernel and the # failure is a VM that does not start, in whichever lane boots one next. - name: Build the kernel env: - CACHE_BACKEND: gha + CACHE_BACKEND: registry CACHE_EXPORT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} run: go tool task kernel:build KERNEL_NPROC=4 diff --git a/.github/workflows/qemu.yml b/.github/workflows/qemu.yml index 2e58433..1ef200f 100644 --- a/.github/workflows/qemu.yml +++ b/.github/workflows/qemu.yml @@ -91,27 +91,19 @@ jobs: cache: true # A builder of its own (the docker-container driver): Docker's default one cannot export its - # cache. What it reads and writes is GitHub's cache, in the scopes Taskfile.yml decides - # (CACHE_BACKEND=gha), so the runner itself keeps nothing between runs. + # cache. What it reads and writes is the cache image in GitHub's registry that Taskfile.yml + # names (CACHE_BACKEND=registry), so the runner itself keeps nothing between runs. - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - # What makes `--cache-to type=gha` do anything at all. - # - # BuildKit writes to GitHub's cache service with $ACTIONS_RUNTIME_TOKEN and - # $ACTIONS_RESULTS_URL, and those are handed to JavaScript actions, not to `run:` - # steps. Docker's documentation is explicit — "If you invoke the `docker buildx` - # command manually from an inline step, then the variables must be manually exposed" - # — and names this action for it. Every build here runs through `task`, which is a - # `run:` step. - # - # Without it the flags are accepted, nothing is stored, and nothing says so: the - # builds pass and take the cold time, every run. That failure is invisible in a log. - # Where it shows is `gh api repos/{owner}/{repo}/actions/cache/usage` — a repository - # that builds QEMU and a kernel and reports a handful of megabytes is caching neither. - # - # Remove this step and nothing breaks; everything just gets slow again. - - name: Expose the Actions cache to buildx - uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + # The cache is one of this repository's packages, and reading it takes a login as much as + # writing it does. Without one BuildKit says it could not import the cache, carries on + # without it, and the build passes at the cold time. + - name: Log in to GitHub Packages + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} # The pinned version comes from versions.yaml, never from a literal here: two places # to bump is how CI and production end up on different QEMUs. @@ -121,7 +113,7 @@ jobs: - name: Build QEMU and verify what came out env: - CACHE_BACKEND: gha + CACHE_BACKEND: registry CACHE_EXPORT: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} run: go tool task qemu:build QEMU_JOBS=4 @@ -166,20 +158,13 @@ jobs: # Publishing is for main only: a fork's token cannot write packages, and an unreviewed # branch - a pull request, or one dispatched to build a QEMU for the lab - has no - # business claiming the version tag. - - name: Log in to GitHub Packages - if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' - uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - + # business claiming the version tag. The login is the one made for the cache. + # # The runtime image, tagged with the pinned QEMU version: what `task qemu:fetch` pulls, # so CI's argument check runs main's QEMU in seconds instead of compiling it. The # binaries are static, so the image around them carries nothing they need. It reads the - # builder stage the step above compiled from the `qemu` scope, and writes no cache: that - # scope holds these layers already. + # builder stage the step above compiled from the `qemu` cache, and writes no cache: that + # cache holds these layers already. - name: Publish the runtime image if: github.ref == 'refs/heads/main' && github.event_name != 'pull_request' run: go tool task qemu:publish diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 70736ff..4dd309d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,6 +28,9 @@ permissions: # Keyless signing: cosign asks Sigstore for a short-lived certificate bound to this workflow at # this ref, which is what a machine that takes the release holds its files to. id-token: write + # The BuildKit cache main's lanes write (Taskfile.yml, CACHE_REGISTRY), read here and by compat, + # which a called workflow can be granted no more than its caller holds. + packages: read # One release at a time. The version is decided at the start of a run by counting the tags # already taken today and pushed at the end, so two runs at once both generate .01 and the @@ -71,18 +74,24 @@ jobs: go-version-file: go.mod cache: true - # What main's lanes already compiled is read, not compiled again: the BuildKit scopes are the + # What main's lanes already compiled is read, not compiled again: the BuildKit caches are the # lanes' (CACHE_BACKEND in Taskfile.yml); a cold release compiles QEMU and the kernel, each # tens of minutes, which the ceiling above is for. + # # A builder of its own (the docker-container driver): Docker's default one cannot export its - # cache. What it reads and writes is GitHub's cache, in the scopes Taskfile.yml decides - # (CACHE_BACKEND=gha), so the runner itself keeps nothing between runs. + # cache. What it reads and writes is the cache image in GitHub's registry that Taskfile.yml + # names (CACHE_BACKEND=registry), so the runner itself keeps nothing between runs. - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - # Without this, `--cache-to type=gha` below is a silent no-op: the tokens BuildKit - # needs reach JavaScript actions and not `run:` steps. See the long note in qemu.yml. - - name: Expose the Actions cache to buildx - uses: crazy-max/ghaction-github-runtime@04d248b84655b509d8c44dc1d6f990c879747487 # v4.0.0 + # The cache is one of this repository's packages, and reading it takes a login as much as + # writing it does. Without one BuildKit says it could not import the cache, carries on + # without it, and the build passes at the cold time. + - name: Log in to GitHub Packages + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} # Two sources, in order: what the caller typed, or a new one for today. The generated form is deliberately not `date +%s` or a commit # hash — it has to be readable, sortable, and the same shape as one typed by hand. @@ -103,11 +112,11 @@ jobs: - name: Build the release env: VERSION: ${{ steps.version.outputs.version }} - # The same scopes the per-artefact workflows write on main, decided in + # The same caches the per-artefact workflows write on main, decided in # Taskfile.yml. That is the whole point of them being named there: a release # reads what the lane on main already paid to compile, instead of starting cold # because a workflow file spelled the scope differently. - CACHE_BACKEND: gha + CACHE_BACKEND: registry # Reads what main's lanes exported and writes nothing back: a release is built once, and # the next one reads main's newer cache, not this. See CACHE_EXPORT in Taskfile.yml. CACHE_EXPORT: "false" diff --git a/Taskfile.yml b/Taskfile.yml index 85be316..ca41328 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -76,41 +76,42 @@ vars: KERNEL_ARCH: x86_64 # Where BuildKit reads and writes its cache: a directory under the repository for a - # developer, GitHub's cache service in CI. One name to switch — `CACHE_BACKEND=gha` — and - # every scope below is decided here, next to the builds they belong to, rather than as - # cache strings pasted into workflow files. - # - # The scopes are why this is a var and not a string in a workflow. `type=gha` defaults to - # the scope `buildkit`, and Docker's own documentation says what that means for a - # repository with more than one build: "each build will overwrite the cache of the - # previous, leaving only the final cache." The names have to be distinct, and they have to - # be the *same* names in the per-artefact workflows and in the release, or a release warms + # developer, a cache image in GitHub's registry in CI. One name to switch - + # `CACHE_BACKEND=registry` - and every cache below is decided here, next to the builds they + # belong to, rather than as cache strings pasted into workflow files. They have to be the + # *same* names in the per-artefact workflows, in compat and in the release, or a release warms # nothing that the lane on main already paid for. # - # `gha` needs one thing this file cannot give it — see the note in - # .github/workflows/qemu.yml. The variables that authorise a write to the cache service - # are not in a plain `run:` step's environment, and without them `--cache-to type=gha` is - # accepted, stores nothing, and says nothing. + # The registry and not GitHub's Actions cache (`type=gha`), which this was until 2026-11-12, + # for two reasons. The fleet's runners (compat) are not GitHub's: runs-on points + # ACTIONS_CACHE_URL and ACTIONS_RESULTS_URL at a cache of its own, so `type=gha` there read a + # store nobody wrote, and compat compiled QEMU and the kernel cold on every run - six runs + # read, not one cached QEMU layer - and a cold QEMU build fails outright once Alpine's mirror + # stops serving what qemu/apk.lock pins. And the Actions cache is 10 GB a repository, evicted + # least recently used: it stood at 10.36 GB that day, so a lane's layers could go between a + # merge and the release that needed them. The registry is read the same way from both kinds + # of runner, with a `docker login` and the job's token, and is not evicted. + CACHE_REGISTRY: ghcr.io/spin-stack/spin-machine/cache CACHE_BACKEND: '{{.CACHE_BACKEND | default "local"}}' # Whether a build writes its cache back, and only CI's main needs it to: a pull request's - # build, a release and compat read main's scopes, and what they compiled nobody reads after. + # build, a release and compat read main's caches, and what they compiled nobody reads after. # Exporting cost 88 s of "preparing build cache for export" on a kernel build (2026-09-30), # on every PR and dispatch that warmed nothing a release reads. So the lanes export on a push # to main, and nothing else does. CACHE_EXPORT: '{{.CACHE_EXPORT | default "true"}}' BUILDKIT_CACHE_DIR: '{{.ROOT_DIR}}/.cache/buildkit' - QEMU_CACHE_FROM: '{{if eq .CACHE_BACKEND "gha"}}type=gha,scope=qemu{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/qemu{{end}}' - QEMU_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "gha"}}type=gha,scope=qemu,mode=max{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/qemu,mode=max,compression=zstd{{end}}{{end}}' - KERNEL_CACHE_FROM: '{{if eq .CACHE_BACKEND "gha"}}type=gha,scope=kernel{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/kernel{{end}}' - KERNEL_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "gha"}}type=gha,scope=kernel,mode=max{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/kernel,mode=max,compression=zstd{{end}}{{end}}' - E2FSPROGS_CACHE_FROM: '{{if eq .CACHE_BACKEND "gha"}}type=gha,scope=e2fsprogs{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/e2fsprogs{{end}}' - E2FSPROGS_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "gha"}}type=gha,scope=e2fsprogs,mode=max{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/e2fsprogs,mode=max,compression=zstd{{end}}{{end}}' + QEMU_CACHE_FROM: '{{if eq .CACHE_BACKEND "registry"}}type=registry,ref={{.CACHE_REGISTRY}}:qemu{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/qemu{{end}}' + QEMU_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "registry"}}type=registry,ref={{.CACHE_REGISTRY}}:qemu,mode=max,image-manifest=true,oci-mediatypes=true{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/qemu,mode=max,compression=zstd{{end}}{{end}}' + KERNEL_CACHE_FROM: '{{if eq .CACHE_BACKEND "registry"}}type=registry,ref={{.CACHE_REGISTRY}}:kernel{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/kernel{{end}}' + KERNEL_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "registry"}}type=registry,ref={{.CACHE_REGISTRY}}:kernel,mode=max,image-manifest=true,oci-mediatypes=true{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/kernel,mode=max,compression=zstd{{end}}{{end}}' + E2FSPROGS_CACHE_FROM: '{{if eq .CACHE_BACKEND "registry"}}type=registry,ref={{.CACHE_REGISTRY}}:e2fsprogs{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/e2fsprogs{{end}}' + E2FSPROGS_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "registry"}}type=registry,ref={{.CACHE_REGISTRY}}:e2fsprogs,mode=max,image-manifest=true,oci-mediatypes=true{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/e2fsprogs,mode=max,compression=zstd{{end}}{{end}}' # The container that builds the base image. Small — mkosi, qemu-utils — and cached like # the rest: uncached, every CI run reinstalls it from the archive before any image work # starts. - IMAGE_CACHE_FROM: '{{if eq .CACHE_BACKEND "gha"}}type=gha,scope=image{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/image{{end}}' - IMAGE_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "gha"}}type=gha,scope=image,mode=max{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/image,mode=max,compression=zstd{{end}}{{end}}' + IMAGE_CACHE_FROM: '{{if eq .CACHE_BACKEND "registry"}}type=registry,ref={{.CACHE_REGISTRY}}:image{{else}}type=local,src={{.BUILDKIT_CACHE_DIR}}/image{{end}}' + IMAGE_CACHE_TO: '{{if ne .CACHE_EXPORT "false"}}--cache-to {{if eq .CACHE_BACKEND "registry"}}type=registry,ref={{.CACHE_REGISTRY}}:image,mode=max,image-manifest=true,oci-mediatypes=true{{else}}type=local,dest={{.BUILDKIT_CACHE_DIR}}/image,mode=max,compression=zstd{{end}}{{end}}' # mkosi's package cache, which is not a BuildKit cache because mkosi does not run under # BuildKit (see image/Dockerfile). A rebuild of the base image is ~1.5 GB of apt. diff --git a/qemu/Taskfile.yml b/qemu/Taskfile.yml index 5749d85..d01c2ea 100644 --- a/qemu/Taskfile.yml +++ b/qemu/Taskfile.yml @@ -115,7 +115,7 @@ tasks: --file qemu/Dockerfile \ --target runtime \ --platform linux/amd64 \ - --cache-from type=gha,scope=qemu \ + --cache-from type=registry,ref={{.CACHE_REGISTRY}}:qemu \ $({{.VERSIONS}} args alpine qemu debian debian-snapshot qboot) \ --build-arg JOBS=4 \ --label org.opencontainers.image.source=https://github.com/${repo} \