Image promotion
Promoting an image means changing which tag an environment runs, without editing the workload that runs it. In konfig.ts a workload never hard-codes an image string. It asks the dependency graph for the image of a named app, and a separate build module provides it. Promotion then comes down to changing one value in images.json, re-rendering, and committing.
The images.json contract
images.json maps each environment to a record of app name to image value. @konfig.ts/core defines the shape as an Effect Schema and decodes it strictly, so an unknown key is an error rather than a silently ignored field:
export const EnvImages = Schema.Record(Schema.String, Schema.String)export const ImagesConfig = Schema.Struct({ envs: Schema.Record(Schema.String, EnvImages)})The file lives at <konfig.json dir>/<root>/images.json. Only konfig set reads and writes it. konfig build never opens the file; your environment entry reads it, as shown further down.
The values are opaque strings. The schema does not parse them, so each project picks a convention. The konfig set help text suggests full references such as ghcr.io/<org>/<app>:<sha>. The example’s build modules take the registry and the tag separately, so the convention shown here is tag-only, with the registry passed alongside. The example project itself ships no images.json and passes literal tags.
{ "envs": { "prod": { "api": "1.0.0", "worker": "1.0.0" }, "staging": { "api": "sha-3f2a9c1", "worker": "sha-3f2a9c1" } }}To read the file yourself, @konfig.ts/core exports these helpers:
| Helper | Behaviour |
|---|---|
decodeImagesSync(u) / decodeImagesEffect(u) | Strict decode of the parsed JSON |
imagesFor({ cfg, env }) | Env record, throws ImagesEnvMissing |
lookupEnvEffect({ cfg, env }) | Same, as an Effect failing with ImagesEnvMissing |
requireImage({ e, app, envName }) | One value, throws ImagesAppMissing |
requireImageEffect(...) | Same, as an Effect failing with ImagesAppMissing |
lookupEnv({ cfg, env }) | Env record or undefined |
The two errors are named, so a missing environment or app fails the build with a clear message instead of rendering a workload with image: undefined. ImagesEnvMissing carries the environment name; ImagesAppMissing carries the environment and the app name.
How images reach a workload
A build module is an anchor in the dependency graph. It emits no manifests; its only job is to provide the image for one app. It does that with Dep.provideImage({ app, registry, tag }), which assembles ${registry}/${app}:${tag} and brands the string as the image of that app, so the type checker can tell the api image apart from the worker image.
export const defineApiBuild = <const Name extends string>(
opts: {
readonly name: Application.LiteralName<Name>
readonly source: Application.ArgoSource
} & BuildOpts
) =>
Application.define({
name: opts.name,
namespace: "app",
source: opts.source,
provides: Dep.provideImage({ app: "api", registry: opts.registry, tag: opts.tag }),
build: () => []
})The workload module asks for the image with yield* Dep.Image("api") inside its build function and passes the result to Container.define.
export const defineApi = Application.module({
namespace: "app",
build: ({ name, namespace }, opts: ApiOpts) =>
Effect.gen(function*() {
const ghcrRef = yield* Dep.Secret("ghcr-pull")
const apiImage = yield* Dep.Image("api")If no module in the list you pass to AppOfApps.fromModules provides the image for api, the environment fails to compile. The type error is reported through the _konfig_unsatisfied hint; see Compile-time catches.
Reading images.json in the env entry
The example passes literal tags to defineApiBuild so it renders offline. To drive the tags from images.json, read the file in the environment entry and hand the value to the build module. The entry is imported once by konfig build, so a synchronous read at module top level is fine here.
import { decodeImagesSync, imagesFor, requireImage } from "@konfig.ts/core"import { readFileSync } from "node:fs"// src and defineApiBuild as in the prod.ts shown in the Argo CD guide
const images = imagesFor({ cfg: decodeImagesSync(JSON.parse(readFileSync(new URL("../../images.json", import.meta.url), "utf8"))), env: "prod"})
const apiBuild = defineApiBuild({ name: "api-build", source: src("api-build"), registry: "ghcr.io/example", tag: requireImage({ e: images, app: "api", envName: "prod" })})A missing prod block throws ImagesEnvMissing; a missing api key throws ImagesAppMissing. Both are thrown while konfig build imports the entry. The CLI reports them as an EnvLoadError whose cause is the named error, and no manifest is written.
Promoting from CI
konfig set <env> <app> <image> updates one value in images.json. It reads the file, validates it against the schema, replaces one existing app key under one existing environment, and writes the file back tab-indented. It is deliberately strict, so a typo cannot silently create a new environment or a stray key:
- An environment that is not already in the file is refused with
SetUnknownEnv. - A missing file is refused with
ImagesFileError; the command never creates it. - An app key that is not already under that environment is refused with
SetUnknownApp, unless you pass--createto add it on purpose.
-
Build and push the image in your CI job; capture the tag (a git SHA works well).
-
Update the manifest source and re-render.
Terminal window konfig set staging api sha-${GITHUB_SHA::7}konfig build staging -
Commit both
images.jsonand the rendered manifests, then push. Argo CD picks up the change on its next refresh. The example’s output directory is.generated/manifests; use whatever directory yourkonfig.jsonand Argo CD root path point at.Terminal window git add images.json .generated/manifests/staginggit commit -m "promote api to sha-${GITHUB_SHA::7} (staging)"git push -
Promote to production by repeating with
prod, ideally from a job that requires approval.
Next: Dockerfiles covers producing the images that end up in images.json.