Build Archive
The build phase produces a build archive: a self-contained bundle holding the image tarball(s) in OCI layout, the synthesized templates, and a manifest of contents. The archive is the currency between build and deploy. Everything downstream references it; nothing downstream rebuilds.
Deferred publish, promote by digest
Section titled “Deferred publish, promote by digest”The container lives in the archive until deploy time, when the target environment is known. Publish then promotes the image bytes to that environment, and the apply step references the resulting digest.
This model fits environments where the registry is not known — or not wanted — until deploy. It also keeps unpublished images out of a registry entirely when a target can consume them directly.
Publish is an interface with backends
Section titled “Publish is an interface with backends”“Make the image available at the deploy target” has more than one implementation. The publish family is an interface, and the backend is chosen by config:
| Backend | Mechanism | Registry in the path? |
|---|---|---|
publish-image | promote the bytes into the environment registry (ECR/ACR/Artifact Registry); the target pulls | yes |
load-image-on-host | copy the tarball to the host and docker load | no |
load-image-on-host is genuinely registry-free: build → archive → host → the host’s local Docker store, no pull. It suits a single host, air-gapped, or no-registry case. publish-image suits a fleet, where hosts share cached layers pulled once.
Backend selection is per environment
Section titled “Backend selection is per environment”The backend is selectable per environment, not just per component. The deciding factor is whether the target hosts can reach the artifact store to pull the tarball. A locked-down prod with direct archive access can use load-image-on-host while dev pulls from ECR — the same component either way. Multiple images generally favor a registry (layer dedup, transfer once); single-host or air-gapped favors load-on-host.
Choosing between them is environment config, never a pipeline fork, so the decision stops multiplying pipelines.
SBOM: format-agnostic, generated at build time
Section titled “SBOM: format-agnostic, generated at build time”A build-producing component can generate a Software Bill of Materials for its artifact via the generate-sbom capability. SBOMs are becoming a baseline expectation for procurement under EO 14028, CISA guidance, and the EU CRA, and generating one at build time (rather than after release, or from source alone) is the recommended workflow — it captures exactly what got built, not an approximation of it.
Two formats are first-class: SPDX (application/spdx+json) and CycloneDX (application/vnd.cyclonedx+json). Chant stores the SBOM as an opaque document — { format, mediaType, bytes } — and every consumer (the build ledger, chant components status) keys off mediaType, never a hardcoded format. The project default is SPDX, set via chant.config.ts’s sbom.format; BuildKit natively emits SPDX attestations for images, so that’s the path of least resistance for the common case. A component can override the default for itself in its build.sbom.format, and either can be overridden per generate-sbom step.
Generation is artifact-type-keyed through an injectable SbomGenerator, mirroring how CloudExecutor keeps the AWS-leaf capabilities testable: an image asks BuildKit’s --sbom attestation or a syft scan; a JAR is scanned with syft/cyclonedx-maven; a zip or Lambda package is scanned with syft; a bare source directory with syft/cdxgen. Nothing about generate-sbom is docker-only.
The archive is the SBOM’s universal home — a sbom-kind manifest entry, content-addressed and linked to its subject artifact’s digest. This is what makes SBOM lookup work for every artifact type and for the registry-less load-image-on-host path: no registry required to read a build’s SBOM back. For image artifacts published to a registry, the SBOM can additionally be projected as an OCI referrer (oras discover <digest>/cosign tree) — a convenience on top of the archive-carried copy, not a replacement for it. chant components status surfaces whichever is available, by digest, preferring the archive.
Native, hermetic generation from lockfiles
Section titled “Native, hermetic generation from lockfiles”createLockfileSbomGenerator (./components/verbs/lockfile-sbom-generator.ts) is a real, pure-TypeScript SbomGenerator backend — no syft, no docker buildx, no cyclonedx-maven, no network. It parses a declared-dependency manifest already on disk — Node’s package-lock.json (lockfile v1–v3) or a JVM pom.xml’s top-level <dependencies> — and writes a real SPDX-2.3 or CycloneDX-1.5 JSON document via the native BOM writer (./components/verbs/bom-writer.ts), both to the archive manifest and to disk as sbom.<format>.json.
Deep scanning with external tools
Section titled “Deep scanning with external tools”createToolSbomGenerator (./components/verbs/tool-sbom-generator.ts) is the deep-scan SbomGenerator backend the section above defers to: it shells out to a real external scanner, selected by artifact type, through an injectable ProcessRunner (./components/verbs/process-runner.ts) — the SBOM-and-referrer-tooling analogue of CloudExecutor. It is opt-in, never the process-wide default; construct createGenerateSbomCapability(createToolSbomGenerator()) to use it in place of the hermetic lockfile backend.
| Artifact type | Primary tool | Fallback |
|---|---|---|
image | BuildKit’s native docker buildx build --sbom=true attestation, when a Dockerfile sits next to the archived tarball and docker is available | syft scan of the saved tarball |
jar | cyclonedx-maven (org.cyclonedx:cyclonedx-maven-plugin), when a pom.xml sits next to the jar and mvn is available | syft scan of the jar |
zip | syft scan of the zip/Lambda package | — |
dir | syft scan of the source directory | cdxgen |
Every method checks the tool’s availability first (command -v <tool>) and throws ToolNotAvailableError with an actionable install hint if it’s missing, rather than returning an empty or silently wrong SBOM. Required tools: syft, docker buildx (bundled with recent Docker), mvn + the cyclonedx-maven-plugin, and/or cdxgen — install whichever your components’ artifact types need.
Tests never invoke a real tool: MockProcessRunner (./components/verbs/__tests__/mock-process-runner.ts) scripts tool availability and canned command output, so tool-sbom-generator.test.ts asserts on the exact command constructed per artifact type with no live syft/buildx/mvn/cdxgen/network in CI.
Config-BOM: IaC as a first-class artifact
Section titled “Config-BOM: IaC as a first-class artifact”A config-only or infra component (no build phase — e.g. a DynamoDB table, an EMR cluster definition) has no image/jar/zip to hand generate-sbom, but it does have a synthesized template. Chant treats that synthesized template as a first-class archive artifact — a template-kind manifest entry with its own content digest, peer to image/asset — and the extract-config-bom capability (./components/verbs/config-bom.ts) walks it structurally to produce a config-BOM: every declared resource, every nested stack/module reference (AWS::CloudFormation::Stack and equivalents), and every external artifact a resource’s properties reference (a container image digest, an AMI id, a lexicon version string).
The config-BOM is emitted through the same native SPDX/CycloneDX writer generate-sbom uses, and recorded in the archive manifest the same way — an sbom-kind entry linked to the template’s digest via subjectDigest, distinguished from a software SBOM only by bomKind: "config". Every existing SBOM consumer (findSbomForSubject, the build ledger) keeps working unmodified; findConfigBomForSubject is the config-BOM-specific lookup. This corrects an earlier assumption: a config-only/infra component is not artifact-less, and therefore not BOM-less either — its BOM is just the config kind rather than the software kind.
A component that composes neither generate-sbom nor extract-config-bom simply has no BOM step to run — skipping is structural (no step composed), never a special case either capability needs to detect.
Per-artifact reproducibility and provenance
Section titled “Per-artifact reproducibility and provenance”Every image/template/asset entry in a build archive manifest carries its own reproducibility record and an optional provenance link (./components/verbs/reproducibility.ts). This is deliberately per artifact, not per component: a component that builds both a container image and a synthesized template has two different true answers, and collapsing them into one flag would misrepresent one of the two.
basis | When | Verify by |
|---|---|---|
deterministic-synthesis | A template entry — chant’s own synthesized IaC output | verifyBy: "re-synth" — re-run chant build and compare digests |
best-effort | An image/asset entry — built by an external tool (docker build, a JVM/zip packager) | none claimed, absent a real reproducible-build attestation |
addArchiveEntry assigns the kind-appropriate default automatically; an explicit reproducibility on the entry always wins. sbom-kind entries carry no reproducibility of their own — they describe another artifact rather than being one.
provenance records the source ref/commit that produced an artifact’s digest ({ sourceRef, artifactDigest }). Both docker-build and addArchiveTemplate accept an optional sourceRef and record it automatically; omit it and no provenance link is recorded, rather than a fabricated one.
Component-level BOM aggregation
Section titled “Component-level BOM aggregation”A component that produces more than one leaf BOM — a software SBOM for its image plus a config-BOM for its synthesized template, say — has more than one document describing “what’s in this component.” aggregateComponentBom (./components/verbs/component-bom.ts) composes every leaf BOM a component’s archive carries into one component-level BOM, reusing the same native SPDX/CycloneDX writer rather than hand-rolling a second one.
- Single-artifact component (1:1). One leaf BOM becomes the component BOM directly — still a valid, standalone document, just with nothing to nest.
- Multi-artifact component (a real assembly). Two or more leaves compose into a genuine assembly: CycloneDX gets nested
metadata.component.components, acompositionsentry (aggregate: "incomplete"— this document composes exactly the leaves it was given, not a closed-world claim), anddependenciesedges from the root to each leaf’s own root. SPDX gets aCONTAINSrelationship from the root package to each leaf’s own root package, plus that leaf’s ownDEPENDS_ONedges, namespaced so packages from different leaves never collide even when same-named.
Packages are never flattened across leaves — a reader can always tell which packages came from the software SBOM versus the config-BOM. A release-level rollup across multiple components is a natural next step, noted here but deferred.
chant components status’s JSON output surfaces a componentBom summary (leaf count, combined package count, whether the leaves compose 1:1 or as a real assembly) alongside each build’s own reproducibility — both keyed by the recorded digest, the same join key every other status field uses.
Real OCI referrer discovery and publish-time attach
Section titled “Real OCI referrer discovery and publish-time attach”Two more real backends complete the loop between the archive-carried SBOM and a registry’s own referrers API:
createOrasReferrerLookup (./lifecycle/oras-referrer-lookup.ts) is a real ReferrerLookup — the consume side buildLedgerEntries (./lifecycle/build-ledger.ts) reads from. It shells out to oras discover --format json <repo>@<digest> (again through the shared ProcessRunner) and classifies each returned manifest into a sbom/provenance/signature referrer by its artifact/media type. The process-wide default remains noopReferrerLookup (reports no referrers, no network) for the same reason the hermetic SBOM backend isn’t the SBOM default either — a project opts into the registry-backed lookup explicitly: buildLedgerEntries(manifest, createOrasReferrerLookup({ repo })). Requires oras on PATH.
Publish-time attach. publish-image accepts optional sbom/componentBom fields (wired from a prior generate-sbom/aggregateComponentBom step’s output) and, when supplied, attaches each as an OCI referrer on the just-pushed image digest via oras attach — the write side of the same referrers API createOrasReferrerLookup reads back. This is additive: the archive-carried copy remains the SBOM’s universal home regardless, and load-image-on-host (registry-less) never attempts an attach — there is no registry to attach to.
Attach is best-effort by design: a missing oras binary or a failed oras attach invocation is reported back on the capability’s output (referrerAttach: { attached: false, reason }) rather than thrown, since the image itself has already published successfully by the time this step runs. A component that wants the attach to be a hard requirement can check referrerAttach.attached itself and fail its own composition on false.
Read next
Section titled “Read next”- Capabilities — the
publishfamily and its backends. - Supply-Chain Attestations — signing the published digest and gating a deploy on
verify, on top of the SBOM/provenance material this page covers. - Orchestration — how publish and apply run, and where they run.