Skip to content

generate-source — labels-and-caching

Detail page of the generate-source recipe card.

All OCI LABEL directives are emitted at the end of the final stage, after the last USER directive. This is an intentional cache-efficiency choice driven by sdk/deploykit’s WriteLabels call being placed after WriteCandySteps + the final USER emission (relocated from charly/generate.go in #67).

Why it matters: the ai.opencharly.description LABEL is the most-volatile piece of image metadata — it changes every time a scenario is added, edited, or removed. If LABELs appeared BEFORE the RUN/COPY install steps, buildkit’s cache would invalidate at the first changed LABEL and every downstream instruction would rebuild from scratch. For a 138-step stack like immich-ml, that means re-running pnpm install, the Immich server build, geodata downloads — minutes to hours for a one-line scenario edit.

With LABELs at the end, a scenario/label edit only re-runs the LABEL instructions themselves (pure manifest metadata, no filesystem work). Measured: ~2 seconds of delta over a no-change rebuild baseline of ~24 seconds for filebrowser.

LABELs are safe to move because they have no functional dependency on subsequent instructions — they attach to the final image manifest and are read only via podman inspect (an unordered map). None of the runtime consumers (charly config, charly start, charly status, charly check live, charly shell, charly alias install) care about directive order.

writeJSONLabel in sdk/deploykit/write_labels.go (relocated from charly/generate.go in #67) routes every LABEL value through shellSingleQuote before emitting LABEL key=<quoted>. This is required because test/task commands often contain literal ' characters (e.g. awk '{print $1}', sed 's/foo/bar/') which JSON preserves verbatim. Without escaping, the embedded quote terminates the LABEL’s own surrounding quote and the rest of the JSON blob is parsed as key=value pairs — which typically fails with “can’t find = in …” on the JSON payload.

Each charly box build generate assigns a fresh CalVer timestamp and emits it as the default of ARG BASE_IMAGE=<registry>/<name>:<calver> at the top of every intermediate Containerfile. The ARG default appears in the Containerfile text but is NOT part of the cache key for subsequent RUN/COPY steps.

Concretely: podman/buildah resolves the FROM ${BASE_IMAGE} step first — turning the tag into an image SHA — then keys every later instruction off that SHA plus the instruction text plus COPY source content. If the SHA is the same as a cached build’s parent (because the upstream image’s content is unchanged), the whole rest of the build cache-hits. A CalVer bump on an unchanged upstream is free.

The ai.opencharly.version LABEL baked into each image is likewise NOT a per-build timestamp — it’s the content-derived EffectiveVersion (the image’s dedicated version:, else the highest layer version: across the chain; computed by deploykit.ComputeEffectiveVersions, called inline from charly/generate.go’s NewGenerator — K3 retired the former charly/effective_version.go wrapper). It is STABLE across builds when no layer changed, so it does not shift the image’s config → SHA and therefore does not cascade cache-misses to children via FROM. Only a real content change (a bumped layer version:) moves it.

Cache-miss only happens when something in the build input genuinely changes: the parent image’s content (different SHA resolved by the FROM), a layer’s scratch-stage content (COPY candy/<name>/ / source bytes), or an instruction’s text (package list, cmd body). See /charly-build:build “Cache Efficiency” for the full list.

Built images embed runtime metadata as labels (prefix: ai.opencharly.), making images self-describing for runtime commands (charly shell, charly start, charly config, charly alias install).

Label Type Example
ai.opencharly.version string content-derived EffectiveVersion (the image’s dedicated version:, else the highest layer version: across the chain — NOT the per-build tag), e.g. "2026.144.1443". Resolution prefers this label over the tag (sdk/kit/local_image.go, moved from charly/local_image.go in P12a); also the “is this an charly box?” presence sentinel read by ExtractMetadata
ai.opencharly.box string "openclaw"
ai.opencharly.registry string "ghcr.io/opencharly" (omitted if empty)
ai.opencharly.bootc string "true" (omitted if false)
ai.opencharly.uid / .gid string "1000"
ai.opencharly.user / .home string "user" / "/home/user"
ai.opencharly.port JSON ["18789:18789"]
ai.opencharly.volume JSON [{"name":"data","path":"/home/user/.openclaw"}]
ai.opencharly.alias JSON [{"name":"openclaw","command":"openclaw"}]
ai.opencharly.security JSON {"privileged":false,"cap_add":["SYS_PTRACE"]}
ai.opencharly.network string "host" (omitted if default)
ai.opencharly.env JSON ["KEY=VALUE"] runtime env vars
ai.opencharly.hook JSON lifecycle hooks config
ai.opencharly.route JSON [{"host":"app.localhost","port":8080}]
ai.opencharly.init string active init system name
ai.opencharly.init_def JSON build-resolved init contract — {entrypoint, fallback_entrypoint, management_tool, management_commands} — read label-first at deploy for the entrypoint + in-container service-management surface
ai.opencharly.service.<init> JSON service names per init system
ai.opencharly.env_candy JSON candy-level env vars (merged)
ai.opencharly.path_append JSON PATH append entries
ai.opencharly.platform.distro JSON ["arch"] distro identity (first match picks bootstrap/format templates)
ai.opencharly.platform.format JSON ["pac"] package formats installed (pac, rpm, deb, pixi, aur, …)
ai.opencharly.builder.use JSON {"aur":"arch-builder","pixi":"default-builder"} consumer-side routing: format → builder image
ai.opencharly.builder.provide JSON ["pac","aur"] producer-side capability: formats this image can build for others (builder images only)
ai.opencharly.port_proto JSON {"9222":"tcp"} port protocol overrides (non-http only)
ai.opencharly.port_relay JSON [9222] ports with socat relay
ai.opencharly.status string Effective status: working, testing, or broken (always emitted)
ai.opencharly.info string Aggregated status info from image + non-working layers (omitted if empty)
ai.opencharly.candy_version JSON {"chrome":"2026.83.1430"} candy name → CalVer (only versioned candies)
ai.opencharly.skill string Skill documentation URL (omitted if no skill exists)

Tunnel configuration is NOT an OCI label — it is a deploy-time concern carried in charly.yml only.

Volumes use short names in labels (prefix charly-<image>- added at runtime). Empty arrays are omitted. JSON built from sorted slices for cache stability. Runtime commands read OCI labels exclusively (via ExtractMetadata in charly/labels.go) plus charly.yml overlay — they never touch charly.yml at runtime. That’s why charly shell myimage works from any directory as long as the image is in local storage (if not, ExtractMetadata returns ErrImageNotLocal and the CLI suggests charly box pull). See /charly-image:image for the build/deploy boundary and /charly-build:pull for the sentinel pattern. Labels also include ai.opencharly.init for init system identification and ai.opencharly.service.<init> for per-init service lists.

Source: charly/labels.go, sdk/deploykit/write_labels.go (WriteLabels, relocated in #67).

Security configuration (security: in charly.yml) and environment variable injection (env:, env_file:) are runtime-only features. They affect container run arguments (--privileged, --cap-add, -e) but do not appear in generated Containerfiles.

Emission site Cache path Options
rpm.packages /var/cache/libdnf5 sharing=locked
deb.packages /var/cache/apt + /var/lib/apt sharing=locked
pac.packages + aur /var/cache/pacman/pkg sharing=locked
command: as root Distro-format caches (above) + /ctx bind to layer stage + any cache: (shared)
command: as non-root /tmp/npm-cache (UID-scoped) + /ctx bind to layer stage + any cache: (owned) uid=<UID>,gid=<GID>
download: /tmp/downloads (shared) — content-addressed: file fetched once, reused across builds + any cache:
task: cache: each declared path; ownership from the task run_as: (root → shared/locked, else uid/gid-owned) per-user
AUR builder stage /var/cache/pacman/pkg (shared) + /tmp/aur-srcdest + /tmp/aur-xdg-cache (owned: makepkg SRCDEST + yay clones) mixed
pixi builder stage /tmp/pixi-cache + /tmp/rattler-cache uid=<UID>,gid=<GID>
npm builder stage /tmp/npm-cache uid=<UID>,gid=<GID>
cargo inline /tmp/cargo-cache uid=<UID>,gid=<GID>

UID/GID in cache mounts are dynamic (from resolved image config). All non-root cache mounts use flat /tmp/<tool>-cache paths to avoid buildah permission issues with nested paths.

Download caching + the generic cache: modifier (emitDownload / taskCacheMounts)

Section titled “Download caching + the generic cache: modifier (emitDownload / taskCacheMounts)”

emitDownload (charly/tasks.go) writes every download: to a content-addressed file in the /tmp/downloads cache mount: __c=/tmp/downloads/$(printf %s "$url" | sha256sum | cut -c1-64), fetched only when absent ([ -s "$__c" ] ||), via curl -o "$__c.part" && mv -f "$__c.part" "$__c" (atomic rename — a partial/corrupt download is never reused), then the extractor reads "$__c". So a file fetched once survives an upstream layer cache-miss instead of re-downloading (the prior code declared the mount but streamed curl past it).

taskCacheMounts(t, img) (charly/tasks.go) renders a task’s layer-declared cache: paths as cache-mount flags, ownership derived from the task’s run_as: via resolveUserSpec (root → SharedCacheMount, else → OwnedCacheMount). Honored by emitCmd and emitDownload. Lets any task persist heavy downloads/build artifacts the same way package caches do (config-driven; the cache-USE logic lives in the charly.yml task body).

CacheMountDef.Owned + RenderCacheMountsAuto (cacheMountsAuto template func) let a single builder cache_mount list mix shared (root, e.g. pacman) and owned (user, e.g. makepkg SRCDEST / yay clones) entries — used by the aur builder in the embedded build vocabulary to cache AUR source downloads + clones across builds.