Packaging a FileSet

How to build a reproducible, single-layer OCI image out of arbitrary files — deterministic digest included — with standard tooling.

A FileSet ingredient (§7.4) packages arbitrary files — configuration bundles, scripts, certificates, offline documentation, datasets — as a standard OCI image. Being a plain image buys two consumption modes for free: mounting read-only in Kubernetes via image volumes, and extraction to disk. This guide is the normative packaging procedure until a built-in command ships (a recipe cook/tobby fileset pack packing step is identified for a later release). Section references (§) point into RECIPE-SPEC.md.

Two properties matter and this guide is built around them:

1. Stage the tree

Lay the files out under a staging directory exactly as they must appear relative to the image root (the same paths extract.paths patterns will match against):

mkdir -p rootfs/etc/pki/trust-anchors rootfs/opt/scripts
cp anchors/*.pem  rootfs/etc/pki/trust-anchors/
cp provision.sh   rootfs/opt/scripts/

Producer-side safety rules, mirroring what extractors enforce (§14.5):

2. Build a reproducible tar archive

Archive the tree with GNU tar, pinning every source of nondeterminism: entry order, timestamps, ownership, and extended attributes. On macOS, install GNU tar (brew install gnu-tar) and use gtar; bsdtar takes different flags and adds metadata of its own.

# Fix all timestamps to one instant; SOURCE_DATE_EPOCH is the
# reproducible-builds.org convention. Any fixed date works.
export SOURCE_DATE_EPOCH=1735689600   # 2025-01-01T00:00:00Z

tar \
  --format=pax \
  --sort=name \
  --mtime="@${SOURCE_DATE_EPOCH}" \
  --owner=0 --group=0 --numeric-owner \
  --pax-option=exthdr.name=%d/PaxHeaders/%f,delete=atime,delete=ctime \
  --no-xattrs \
  -C rootfs -cf fileset.tar .

# Deterministic compression: -n omits gzip's embedded name and timestamp.
gzip -n -9 < fileset.tar > fileset.tar.gz

Flag by flag: --sort=name fixes the entry order regardless of directory read order; --mtime pins modification times; --owner=0 --group=0 --numeric-owner stores uid/gid 0 with no user names; the --pax-option scrubs the per-entry atime/ctime pax headers and keeps header names stable; --no-xattrs keeps host-specific extended attributes out.

Audit the archive against the §14.5 rules before shipping it:

# Absolute paths or '..' components — MUST print nothing:
tar -tf fileset.tar | grep -E '^/|(^|/)\.\.(/|$)' || echo "paths OK"

# Review every link entry and its target — all targets internal:
tar -tvf fileset.tar | awk '$1 ~ /^l/'

3. Push as a single-layer OCI image

Publish the archive as an image with one layer (single-layer images keep the merged filesystem trivial — no whiteouts, one diff). Two standard tools do it; both produce a deterministic manifest, because neither embeds a timestamp.

With crane

crane append on the empty OCI base builds the config (with the layer’s diff ID) and manifest, and pushes in one step:

crane append \
  --oci-empty-base \
  -f fileset.tar.gz \
  -t registry.example.com/filesets/site-config:2.3.1

With oras

oras push uploads the layer as-is but needs a hand-written image config, since a mountable FileSet is a real image, not a custom artifact: runtimes require architecture, os, and the rootfs.diff_ids of the uncompressed layer.

DIFF_ID="sha256:$(sha256sum fileset.tar | awk '{print $1}')"
printf '{"architecture":"amd64","os":"linux","rootfs":{"type":"layers","diff_ids":["%s"]},"config":{}}' \
  "$DIFF_ID" > config.json

oras push registry.example.com/filesets/site-config:2.3.1 \
  --config config.json:application/vnd.oci.image.config.v1+json \
  fileset.tar.gz:application/vnd.oci.image.layer.v1.tar+gzip

(The two tools emit slightly different manifests — layer annotations, config details — so pick one and stick to it; determinism is per pipeline.)

4. Prove the digest is deterministic

Run your pipeline twice — stage, tar, gzip, push (to a scratch tag for the check) — and compare. Same inputs MUST give the same digest at every level:

# Layer level: rebuild and compare the archive bytes.
sha256sum fileset.tar.gz fileset-rebuild.tar.gz
# <same digest>  fileset.tar.gz
# <same digest>  fileset-rebuild.tar.gz

# Manifest level: push the rebuild elsewhere and compare manifest digests.
crane digest registry.example.com/filesets/site-config:2.3.1
crane digest registry.example.com/filesets/site-config:rebuild-check
# both print the same sha256:… — this is the digest the recipe will pin

If the digests differ, something nondeterministic crept in — unsorted entries, live mtimes, xattrs, gzip’s embedded timestamp, or different tool versions. Fix the pipeline before publishing anything.

5. Platforms

Platform-independent content SHOULD be published as a single-manifest image, exactly as built above (§7.4) — consumers then never need platform selection. If the content genuinely differs per platform, build one image per platform (matching architecture/os in each config), push them under staging tags, and combine them into an index; the ingredient’s digest then pins the index, and its platforms field selects what a destination requires:

crane index append \
  -m registry.example.com/filesets/site-config:2.3.1-linux-amd64 \
  -m registry.example.com/filesets/site-config:2.3.1-linux-arm64 \
  -t registry.example.com/filesets/site-config:2.3.1

6. Record it in a recipe

Pin the published image in the recipe by tag and digest:

DIGEST=$(crane digest registry.example.com/filesets/site-config:2.3.1)
- name: site-config
  kind: FileSet
  ref: registry.example.com/filesets/site-config
  version: 2.3.1
  digest: sha256:…            # the crane digest output
  extract:
    paths:                    # consumer hint (§7.4): what to materialize;
      - "etc/pki/trust-anchors/**"   # the image always travels in full
      - "opt/scripts/**"

recipe lint --profile cooked then accepts the ingredient as publication-ready.

7. Consume it

Mounted, no extraction — Kubernetes image volumes mount the image read-only straight from the runtime, referenced by the pinned digest:

volumes:
  - name: site-config
    image:
      reference: registry.example.com/filesets/site-config@sha256:…
containers:
  - name: app
    volumeMounts:
      - name: site-config
        mountPath: /etc/site-config
        readOnly: true

Extracted — implementations follow §7.4: select the platform manifest, apply layers in order with OCI whiteout handling, materialize the extract.paths selection, and enforce §14.5 while doing so. A one-layer FileSet makes this equivalent to unpacking the single tar. To inspect what a consumer would see:

crane export registry.example.com/filesets/site-config@"$DIGEST" - | tar -tv