Add oci-archive output for registry-free multi-platform builds
- Dominant language
- Go
- Stars
- 8.5k
- Forks
- 447
- PR merge metrics
- No merged PRs in 30d
Description
## 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:
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:
```sh
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`:
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:
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:
-
-
-
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":
Podman also documents `podman load` as loading from local tar archives and local OCI or Docker directory layouts:
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:
```sh
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`:
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:
```sh
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:`. A pod using `registry.access.redhat.com/ubi10/ubi-minimal:latest` with `imagePullPolicy=Never` failed with:
```text
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:
```sh
KO_DOCKER_REPO=localhost/ko-layout-smoke \
go run github.com/google/ko@v0.19.1 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:`, 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:
```sh
KO_DOCKER_REPO=localhost/ko-layout-smoke \
go run github.com/google/ko@v0.19.1 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:
```sh
jq '{top: [.manifests[].annotations]}' layout-image-annotation/index.json
```
```json
{
"top": [
null
]
}
```
The annotation was added to the nested image index instead:
```sh
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`.
```sh
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:
```text
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`:
```text
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.
Contributor guide
Research direction
Start with pkg/publish/layout.go and pkg/publish/tarball.go to understand the existing OCI layout and Docker-style tarball publishers, then trace the CLI option wiring for --oci-layout-path and --tarball. Done means a separate --oci-archive output creates a tar archive containing the multi-platform OCI layout, preserves the image reference annotation, and retains the existing publisher behavior.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- build-system, cli
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100