Add oci-archive output for registry-free multi-platform builds
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:
kos:
- repositories:
- ghcr.io/acme/app
platforms:
- linux/amd64
- linux/arm64
tags:
- "{{ .Version }}"
oci_archive: dist/app.oci.tarOn 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:
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.sarifThen 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:
ko build ./cmd/app \
--platform linux/amd64,linux/arm64 \
--push=false \
--oci-archive app.oci.tarThe 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:
The tarball is then written via tarball.MultiRefWriteToFile:
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:
A minimal implementation could reuse the existing OCI layout writer, then archive the layout directory:
- Create a temporary OCI layout directory.
- Write the build result using the existing layout publisher logic.
- Add
org.opencontainers.image.ref.nameto the top-level OCI descriptor for the built reference. - Tar the OCI layout directory to the requested destination.
- 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:
- https://github.com/google/go-containerregistry/issues/651
- https://github.com/google/go-containerregistry/issues/1756
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:
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=60sThe 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:
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.jsonThe annotation check returned:
{}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 NeverThat 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:
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-smokeThe 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:
{
"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:
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:latestThe top-level OCI layout descriptor still had no annotations:
jq '{top: [.manifests[].annotations]}' layout-image-annotation/index.json{
"top": [
null
]
}The annotation was added to the nested image index instead:
digest=$(jq -r '.manifests[0].digest | sub("sha256:"; "")' layout-image-annotation/index.json)
jq '{nestedIndexAnnotations: .annotations}' "layout-image-annotation/blobs/sha256/$digest"{
"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.
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.09MBPods 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 directorySo there is a compatibility tradeoff for multiple tags:
- one top-level descriptor per tag works well with kind/containerd
- Podman's
oci-archiveloader 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.
Source: ko-build/ko