ko-build / ko-build/ko

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

Open
#1,709 0 comments 0 reactions 0 assignees View on GitHub
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.