#1709·ko

Add oci-archive output for registry-free multi-platform builds

Author: dronenbCreated Jul 15, 2026Updated Jul 15, 2026

Summary

ko currently supports:

  • single-platform Docker archive output with --tarball
  • multi-platform OCI layout directory output with --oci-layout-path

It would be useful to add an oci-archive output format: a single-file tar archive containing an OCI image layout. This would provide a portable, registry-free artifact for multi-platform builds without overloading the existing Docker-style --tarball behavior.

Use Case

Some release pipelines need to build a container image artifact in one step and consume or publish it in a later step, without requiring Docker or immediate registry access.

For single-platform images, --tarball works well. For multi-platform images, --oci-layout-path preserves the image index, but it writes a directory rather than a single portable file.

One concrete CI workflow would be:

  • On pull requests, build a multi-platform image as an OCI archive without pushing to a registry.
  • Scan that archive directly with tools that accept OCI archives.
  • Optionally load the archive into kind/containerd for integration tests.
  • On merge or release, publish the image/index to an OCI registry.

This is useful because pull-request validation often should not require registry credentials, Docker, or publishing untrusted PR images to a shared registry.

This is also a direct fit for quay/clair-action, whose image-path input expects an image saved on disk following the OCI Image Spec. The README explicitly notes that this means using podman save --format oci-archive:

https://github.com/quay/clair-action/blob/main/README.md#customizing

This also affects GoReleaser users. GoReleaser has a closed issue requesting tarball output from its ko integration:

https://github.com/goreleaser/goreleaser/issues/4177

That request asks for a kos[].tarball option so GoReleaser can create an image artifact without requiring Docker. However, ko's current Docker-style tarball publisher is single-platform only, so exposing that directly in GoReleaser would not solve the Docker-free multi-platform workflow. A first-class ko oci-archive output would give tools like GoReleaser a better primitive to expose: a single portable artifact that preserves multi-platform image indexes and can be scanned or loaded without pushing to a registry.

An example GoReleaser configuration could eventually look like:

yaml
kos:
  - repositories:
      - ghcr.io/acme/app
    platforms:
      - linux/amd64
      - linux/arm64
    tags:
      - "{{ .Version }}"
    oci_archive: dist/app.oci.tar

On pull requests or dry runs, GoReleaser could build dist/app.oci.tar for scanning and integration tests without registry credentials or Docker. On merge or release, GoReleaser could publish the image/index to an OCI registry using the configured repositories.

With first-class oci-archive output, a workflow could look like:

yaml
jobs:
  pr-image-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Build OCI archive without pushing
        run: |
          ko build ./cmd/app \
            --platform linux/amd64,linux/arm64 \
            --push=false \
            --oci-archive dist/app.oci.tar

      - name: Scan OCI archive
        uses: quay/clair-action@main
        with:
          image-path: dist/app.oci.tar
          format: sarif
          output: clair-results.sarif

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        with:
          sarif_file: clair-results.sarif

Then a merge or release workflow could publish the same image reference to a registry, either by rebuilding from source with registry credentials or, in the future, by publishing from the OCI archive artifact.

An oci-archive output would fill that gap:

bash
ko build ./cmd/app \
  --platform linux/amd64,linux/arm64 \
  --push=false \
  --oci-archive app.oci.tar

The resulting app.oci.tar would contain an OCI image layout, including oci-layout, index.json, and blobs/, preserving the multi-platform image index.

Why Not Extend --tarball?

--tarball currently uses Docker-save-style tarball semantics. The current tarball publisher only supports v1.Image, not v1.ImageIndex.

The source comment in pkg/publish/tarball.go states that there is no way to write an index to a tarball, and the implementation downcasts the build result to v1.Image:

https://github.com/ko-build/ko/blob/e388f65a1f036f19703b8aff13e1aa5521bc6988/pkg/publish/tarball.go#L54-L58

The tarball is then written via tarball.MultiRefWriteToFile:

https://github.com/ko-build/ko/blob/e388f65a1f036f19703b8aff13e1aa5521bc6988/pkg/publish/tarball.go#L95-L98

Because Docker archive and OCI layout archive are different formats, a separate oci-archive option would preserve backwards compatibility and avoid changing the meaning of --tarball.

Existing Building Blocks

ko already has an OCI layout publisher that supports both image indexes and images:

https://github.com/ko-build/ko/blob/e388f65a1f036f19703b8aff13e1aa5521bc6988/pkg/publish/layout.go#L39-L74

A minimal implementation could reuse the existing OCI layout writer, then archive the layout directory:

  1. Create a temporary OCI layout directory.
  2. Write the build result using the existing layout publisher logic.
  3. Add org.opencontainers.image.ref.name to the top-level OCI descriptor for the built reference.
  4. Tar the OCI layout directory to the requested destination.
  5. Return the same digest/reference behavior as the layout publisher.

This could potentially be implemented entirely in ko first, without requiring new go-containerregistry APIs.

Related Issues And Context

Prior ko issues confirm that multi-platform Docker-style tarball output is currently unsupported:

Related go-containerregistry discussion about OCI layouts, tarballs, Docker archive semantics, and image indexes:

Podman/Skopeo already use the oci-archive terminology for tar archives containing OCI layouts, so using that name would align with existing container tooling vocabulary.

Podman documents podman save --format oci-archive as producing "a tar archive using the OCI Image Format":

https://docs.podman.io/en/latest/markdown/podman-save.1.html#format-format

Podman also documents podman load as loading from local tar archives and local OCI or Docker directory layouts:

https://docs.podman.io/en/latest/markdown/podman-load.1.html#input-i-input

I also smoke-tested this workflow locally with Skopeo v1.23.0, Podman v6.0.1, and kind v0.32.0 using the multi-platform registry.access.redhat.com/ubi10/ubi-minimal:latest image:

bash
skopeo --insecure-policy copy --multi-arch=all \
  docker://registry.access.redhat.com/ubi10/ubi-minimal:latest \
  oci-archive:ubi10-minimal.oci.tar:registry.access.redhat.com/ubi10/ubi-minimal:latest

podman load -i ubi10-minimal.oci.tar

kind load image-archive ubi10-minimal.oci.tar --name ubi-oci-archive-test

kubectl run ubi-annotated \
  --image=registry.access.redhat.com/ubi10/ubi-minimal:latest \
  --image-pull-policy=Never \
  --restart=Never \
  --command -- sleep 30

kubectl wait --for=condition=Ready pod/ubi-annotated --timeout=60s

The archive contained a multi-platform index for linux/amd64, linux/arm64/v8, linux/s390x, and linux/ppc64le. podman load restored the image tag, kind load image-archive imported the archive into the kind node, and the pod became Ready using imagePullPolicy=Never.

This appears to work because current kind streams the archive directly to containerd with ctr images import --all-platforms --digests rather than shelling out to docker load:

https://github.com/kubernetes-sigs/kind/blob/v0.32.0/pkg/cluster/nodeutils/util.go#L80-L90

For compatibility with kind/containerd workflows, the archive should preserve a usable image reference, for example via the OCI descriptor annotation org.opencontainers.image.ref.name, so that Kubernetes manifests can refer to the loaded image by name/tag instead of only by digest.

I tested this by extracting the OCI archive, removing the top-level org.opencontainers.image.ref.name annotation from the OCI layout, and re-tarring it:

bash
mkdir -p layout
tar -xf ubi10-minimal.oci.tar -C layout

jq '.manifests[].annotations' layout/index.json

jq -r '.manifests[].digest | sub("sha256:"; "")' layout/index.json |
  while read digest; do
    jq 'if .manifests then [.manifests[].platform] else empty end' \
      "layout/blobs/sha256/$digest"
  done

cp -R layout no-ref-layout

jq 'del(.manifests[].annotations."org.opencontainers.image.ref.name")' \
  no-ref-layout/index.json \
  > no-ref-layout/index.json.tmp

mv no-ref-layout/index.json.tmp no-ref-layout/index.json

tar -cf ubi10-minimal-no-ref.oci.tar -C no-ref-layout .

jq '.manifests[].annotations' no-ref-layout/index.json

The annotation check returned:

json
{}

kind load image-archive still imported the content, but containerd assigned it a synthetic name like docker.io/library/import-2026-07-15:<none>. A pod using registry.access.redhat.com/ubi10/ubi-minimal:latest with imagePullPolicy=Never failed with:

ErrImageNeverPull
Container image "registry.access.redhat.com/ubi10/ubi-minimal:latest" is not present with pull policy of Never

That suggests an oci-archive publisher should include org.opencontainers.image.ref.name for the built image reference if kind/containerd loading is an intended use case.

I also smoke-tested the likely implementation approach directly with ko v0.19.1:

bash
KO_DOCKER_REPO=localhost/ko-layout-smoke \
  go run github.com/google/[email protected] build . \
  --platform linux/amd64,linux/arm64 \
  --push=false \
  --oci-layout-path ./layout \
  --sbom=none

tar -cf layout.tar -C ./layout .
kind load image-archive layout.tar --name ko-layout-smoke

The tarred ko layout imported successfully into kind, and the layout preserved a multi-platform index for linux/amd64 and linux/arm64. However, ko's current --oci-layout-path output did not include a top-level org.opencontainers.image.ref.name annotation. Containerd therefore imported it under a synthetic name like docker.io/library/import-2026-07-15:<none>, and a pod using localhost/ko-layout-smoke:latest with imagePullPolicy=Never failed with ErrImageNeverPull.

After adding this annotation to the top-level descriptor in index.json and re-tarring the layout:

json
{
  "org.opencontainers.image.ref.name": "localhost/ko-layout-smoke:latest"
}

kind load image-archive layout-annotated.tar imported the image under localhost/ko-layout-smoke:latest, and a pod using the image with imagePullPolicy=Never completed successfully.

I also tested whether ko's existing --image-annotation flag can set this annotation in the place kind/containerd needs:

bash
KO_DOCKER_REPO=localhost/ko-layout-smoke \
  go run github.com/google/[email protected] build . \
  --platform linux/amd64,linux/arm64 \
  --push=false \
  --oci-layout-path ./layout-image-annotation \
  --sbom=none \
  --image-annotation org.opencontainers.image.ref.name=localhost/ko-layout-smoke:latest

The top-level OCI layout descriptor still had no annotations:

bash
jq '{top: [.manifests[].annotations]}' layout-image-annotation/index.json
json
{
  "top": [
    null
  ]
}

The annotation was added to the nested image index instead:

bash
digest=$(jq -r '.manifests[0].digest | sub("sha256:"; "")' layout-image-annotation/index.json)
jq '{nestedIndexAnnotations: .annotations}' "layout-image-annotation/blobs/sha256/$digest"
json
{
  "nestedIndexAnnotations": {
    "org.opencontainers.image.base.digest": "sha256:f52ef239baeb97773f4641258652f7b837116fa3b71e7be4e5d2c039dd42899d",
    "org.opencontainers.image.base.name": "cgr.dev/chainguard/static:latest",
    "org.opencontainers.image.ref.name": "localhost/ko-layout-smoke:latest"
  }
}

So --image-annotation is not sufficient for this import use case. An oci-archive publisher likely needs to set org.opencontainers.image.ref.name on the top-level OCI layout descriptor, for example by passing layout annotations when appending the image or index to the layout.

Multiple Tags

I also tested what happens if an OCI layout archive has multiple top-level descriptors pointing to the same digest, each with a different org.opencontainers.image.ref.name annotation. This models a ko build with multiple tags such as latest and stable.

bash
cp -R layout layout-multi-ref

jq '.manifests = [
  (.manifests[0] | .annotations = {"org.opencontainers.image.ref.name":"localhost/ko-layout-smoke:latest"}),
  (.manifests[0] | .annotations = {"org.opencontainers.image.ref.name":"localhost/ko-layout-smoke:stable"})
]' layout-multi-ref/index.json > layout-multi-ref/index.json.tmp

mv layout-multi-ref/index.json.tmp layout-multi-ref/index.json

tar -cf layout-multi-ref.tar -C layout-multi-ref .

Kind/containerd accepted this archive and imported both tags, both pointing at the same image ID:

localhost/ko-layout-smoke  latest  83ac692030a19  2.09MB
localhost/ko-layout-smoke  stable  83ac692030a19  2.09MB

Pods using either tag with imagePullPolicy=Never completed successfully.

However, Podman v6.0.1 rejected the same archive on podman load -i layout-multi-ref.tar:

Error: unable to load image: payload does not match any of the supported image formats:
 * oci: open /var/tmp/libpod-images-load.tar3956958393/index.json: not a directory
 * oci-archive: loading index: more than one image in oci, choose an image
 * docker-archive: loading tar component "manifest.json": file does not exist
 * dir: reading version file "/var/tmp/libpod-images-load.tar3956958393/version": open /var/tmp/libpod-images-load.tar3956958393/version: not a directory

So there is a compatibility tradeoff for multiple tags:

  • one top-level descriptor per tag works well with kind/containerd
  • Podman's oci-archive loader rejects multiple top-level descriptors and expects a single image to be selected

If oci-archive supports multiple tags, ko may need a design decision here: either emit a single canonical ref for maximum Podman compatibility, or emit one descriptor per tag for containerd/kind tag fidelity. Another option would be to expose an option for which ref should be written to org.opencontainers.image.ref.name.

Expected Outcome

Users can run a multi-platform ko build and produce a single portable OCI archive without Docker and without pushing to a registry immediately.