Image builds: build providers, image registries and the image artifact
Included from Basic tier
Building a machine or container image is a change like any other: it consumes a commit and a credential, it produces something that will run in production, and an auditor will ask which run produced the image an environment is running. OpsChain models the two things a build needs as catalog records beside identity, secrets, model and transport providers, and lets actions reference them by name:
- an image build provider — where a build runs: a BuildKit daemon, Packer on a runner, an EC2 Image Builder pipeline account, an Azure Image Builder subscription (GCP Cloud Build is reserved until a content pack serves it);
- an image registry — where the result is pushed: any OCI registry (Harbor, Nexus, Quay, a self-hosted distribution), GitHub Container Registry, ECR, ACR, Artifact Registry, an Azure compute gallery, or an AWS account and region as the AMI store.
Enterprises have few build backends and many image repositories —
each application, asset or team publishes to its own — so the two are
separate records: the backend is shared, the registry is per team,
application or account, one record per credential (a registry whose
one credential covers many repositories is one record with repository
as the prefix; a step names the image beneath it). An action references
a record as image-build://<code> or image-registry://<code>; the
platform resolves the reference at step dispatch and hands the step a
short-lived credential, exactly as the github-app:// reference works
for GitHub (integrations-github).
Both record kinds are base platform — no entitlement at create. A
provider of a kind whose content pack is not entitled (the AWS and Azure
kinds ride packs:aws and packs:azure) is refused at dispatch, never at
create, so an administrator may prepare ahead of a licence change.
Registering backends and registries
# Build backends — shared
opschain admin image-build-providers create buildkit-shared \
--kind buildkit --credential-kind client_cert \
--endpoint tcp://buildkitd.runners.internal:1234 \
--credential-ref secret-vault://infra/buildkit/client-cert \
--display-name "Shared BuildKit (runner network)" \
--bind /projects
opschain admin image-build-providers create aws-imagebuilder-prod \
--kind aws_image_builder --region ap-southeast-2 \
--credential-kind assume_role \
--credential-ref secret-vault://aws/prod/imagebuilder-role \
--default distribution=ami
# Image registries — one per team / application / account, each with its own credential
opschain admin image-registries create acr-payments \
--kind acr --host limebankpayments.azurecr.io --repository payments \
--credential-kind service_principal \
--credential-ref secret-vault://payments/acr/push-sp \
--bind /projects/payments
opschain admin image-registries create ecr-platform \
--kind ecr --host 123456789012.dkr.ecr.ap-southeast-2.amazonaws.com --repository platform \
--credential-kind assume_role \
--credential-ref secret-vault://aws/platform/ecr-push-role
opschain admin image-registries create harbor-web \
--kind oci --host harbor.limebank.internal --repository web \
--credential-kind basic \
--credential-ref secret-vault://web/harbor/robot
# Bindings: who may use what, and the default where nothing is named
opschain admin image-registries bind ecr-platform /projects/platform
opschain admin image-registries bind harbor-web /projects/limebank/environments/dev/assets/web
No read ever carries a credential value: the record stores a
secret-vault:// reference, pinned at write time (an unresolvable
reference refuses by name), or holds an inline credential supplied
through --credential-env or --credential-prompt encrypted at rest on
the record itself — never written into a vault, so a server with no
secrets provider registered still dispatches with it. A reference write
clears the at-rest value and an inline write clears the reference. Reads
report credential_set; no response, audit row or error ever carries a
credential.
Credential forms per kind
The record declares what its credential holds (credential_kind), and the
form is validated against the kind at write:
| Record | Kind | Credential forms |
|---|---|---|
| build provider | buildkit | client_cert — the daemon's mTLS client certificate and key (PEM) |
| build provider | packer | service_principal_cert, service_principal, assume_role, access_key, service_account_key, client_cert — whatever the Packer template's builder needs |
| build provider | aws_image_builder | assume_role (a role ARN plus the credentials that assume it), access_key |
| build provider | azure_image_builder | service_principal_cert, service_principal |
| build provider | gcp_cloud_build | reserved: accepted on the record, refused at dispatch by name ("no installed pack serves this kind") |
| registry | oci | basic (user:password), token (a bearer token) |
| registry | ghcr | github_connection (names an existing GitHub connection whose App holds the packages write permission; the installation token is minted at dispatch) or token (a personal access token) |
| registry | ecr, ami | assume_role, access_key |
| registry | acr, gallery | service_principal, service_principal_cert |
| registry | gar | service_account_key, workload_identity (no secret: the server host's ambient identity) |
The ghcr kind's host is fixed to ghcr.io; its repository is the
GitHub owner or organisation. The GitHub-connection form needs the
integrations:github entitlement at dispatch, as every connection use
does.
Bindings: the allow-list and the default
A record is usable only under the estate subtrees it is bound to — an
unbound record is usable nowhere. A binding is one record and one path:
/ (the explicit estate-wide form) or any node path (a project, an
environment, an asset). Bindings are cheap and many: one registry per
team, application or account, each bound where it belongs. The create
verbs offer the first binding (--bind <path>), bind and unbind
manage the rest, and delete is refused by name while a binding
references the record.
A binding is scoped by subtree containment: it covers every node
beneath its path, nothing merges and nothing overrides it. It is the
administrator's allow-list plus the default for that subtree. The dev
account's registry bound to /projects/limebank/environments/dev cannot
be referenced from production even if an author types its code; the
refusal names the registry and the target.
Which record a step gets — the resolution order
For each of the build provider and the registry, dispatch resolves in this order, every candidate checked against the bindings covering the run's target:
- the step's
with:value (an action author's explicit choice); - the node's converged property
opschain.image.build_provider/opschain.image.registry(project → environment → asset, most specific wins — so a project sets its default and one asset overrides it); - the deepest binding covering the target (the administrator's default for that subtree);
- otherwise the step is refused by name.
Bindings and properties are different instruments. A property converges like every other property and chooses within the bound set; it cannot widen the set. Settings take no part: which registry an asset publishes to is a fact about the asset, so it lives in versioned, diffable properties that follow the node through promotion.
# properties on /projects/payments (the project's default registry and backend)
opschain:
image:
build_provider: image-build://buildkit-shared
registry: image-registry://acr-payments
# properties on /projects/payments/environments/prod/assets/ledger (one asset publishes elsewhere)
opschain:
image:
registry: image-registry://acr-payments-prod # a different record, with its own credential
A run against ledger in prod pushes to acr-payments-prod; every other
payments asset pushes to acr-payments; nobody edited the action. If a
property names a registry that is not bound to the run's target, dispatch
refuses by name. If no with: and no property names one and no binding
covers the target, the step refuses with "no image registry is bound for
/projects/… — bind one or set opschain.image.registry".
Credentials are never overridden. They belong to records and are addresses into the secrets provider; properties and inputs carry references to records, never secrets. "This project pushes with different credentials" is modelled as a different registry record bound to that project — one line for the administrator, one property for the project — which keeps every credential under custody, every use under a binding, and every push attributable to a named record on the audit trail.
The dispatch crossing: what the step sees
A with: value, an input value or a converged property that is, in its
entirety, image-build://<code> or image-registry://<code> is resolved
at step dispatch — never earlier, never on the runner — and the step
receives the backend's environment contract plus a short-lived
credential. The reference itself never reaches the step: an input that
carried image-build://buildkit-shared reaches the step as
OPSCHAIN_INPUT_PROVIDER=buildkit-shared, the record's code.
# .opschain/actions/web/build_image.yaml
action: build_image
description: Build the web service container image from this commit and record it as an image artifact.
inputs:
tag: { default: "${{ node.code }}-${{ run.id }}" }
provider: { default: "${{ properties.opschain.image.build_provider }}" } # a reference (image-build://…) from the node's converged properties
registry: { default: "${{ properties.opschain.image.registry }}" } # image-registry://…, likewise
steps:
- name: build
uses: buildkit:build # packs:common ships the verb (opschain docs packs-common)
with:
provider: ${{ inputs.provider }} # the dispatcher swaps each reference for the backend's / registry's contract
registry: ${{ inputs.registry }}
image: web # the registry record supplies host + repository; the step names the image and tags
context: .
dockerfile: deploy/Dockerfile
tags: ${{ inputs.tag }},latest
push: "true"
With a kind: image artifact the same action records what it built:
steps:
- name: build
uses: buildkit:build
with:
provider: ${{ inputs.provider }}
registry: ${{ inputs.registry }}
image: web
context: .
dockerfile: deploy/Dockerfile
tags: ${{ inputs.tag }},latest
sbom: "true"
artifacts:
- name: web_image
kind: image # a typed image record, not a file archive
sbom: sbom/web.spdx.json # optional file, stored with the record
The verb reads the contract below, runs the build, prints nothing
secret, and writes the image descriptor where the declaration expects it
(.opschain/image/web_image.json: the registry reference with tag and
the sha256: digest; the step learns the path as
OPSCHAIN_IMAGE_DESCRIPTOR, and the SBOM's as OPSCHAIN_IMAGE_SBOM). An
artifacts: declaration on a composed uses: step lands on the composed
action's last run: step, so the verb's build step is the one collected.
The entry records the image on the run's node with its digest, the
producing run and step, the sealed commit, and the provider and registry
codes (opschain docs artifacts, "Image artifacts").
A machine image through Packer records the same way, with the provider of
kind packer supplying the builder's credential and the template's
manifest post-processor supplying the artefact id — the digest takes
the id: form, since a gallery version or an AMI carries no content
digest:
steps:
- name: build
uses: packer:build # packs:common ships the verb
with:
provider: image-build://hosted-packer # kind packer; credential = the service principal's certificate
registry: image-registry://hosted-gallery
template: deploy/vm/image/hosted.pkr.hcl
vars: '{"image_version":"${{ inputs.image_version }}"}'
artifacts:
- name: hosted_appliance
kind: image # identifier = the gallery version id, digest id:<that id>
required: true
The build provider's contract
| Variable | Meaning |
|---|---|
IMAGE_BUILD_PROVIDER | The record's code. |
IMAGE_BUILD_KIND | buildkit, packer, aws_image_builder, azure_image_builder. |
IMAGE_BUILD_ENDPOINT | The record's endpoint (also BUILDKIT_HOST for the buildkit kind). |
IMAGE_BUILD_REGION | The record's region. |
IMAGE_BUILD_DEFAULT_<KEY> | One variable per defaults entry (IMAGE_BUILD_DEFAULT_REGISTRY, …_GALLERY, …_DISTRIBUTION, …_PROJECT). |
IMAGE_BUILD_CREDENTIAL_FILE | The path of the materialised credential file, where the form is a file (below). |
The credential is materialised in the form the backend's own tooling reads, so it works unchanged:
| Form | What the step gets |
|---|---|
client_cert | The PEM bundle at IMAGE_BUILD_CREDENTIAL_FILE. |
service_principal | AZURE_CLIENT_ID, AZURE_TENANT_ID and the secret as AZURE_CLIENT_SECRET. |
service_principal_cert | AZURE_CLIENT_ID, AZURE_TENANT_ID and the certificate at AZURE_CLIENT_CERTIFICATE_PATH (also IMAGE_BUILD_CREDENTIAL_FILE). |
access_key | AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN when the stored key carries one), AWS_REGION / AWS_DEFAULT_REGION. |
assume_role | An STS session minted at dispatch for the stored role: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_REGION. |
service_account_key | An access token minted at dispatch as GOOGLE_OAUTH_ACCESS_TOKEN, the key file at GOOGLE_APPLICATION_CREDENTIALS (also IMAGE_BUILD_CREDENTIAL_FILE), CLOUDSDK_CORE_PROJECT from the key's project. |
workload_identity | The server host's ambient access token as GOOGLE_OAUTH_ACCESS_TOKEN. |
The stored credential values: client_cert is a PEM bundle (certificate
and key); access_key is JSON {"access_key_id","secret_access_key"}
(optionally session_token, region); assume_role adds role_arn
and optionally external_id, session_name, duration_seconds;
service_principal is JSON {"client_id","client_secret","tenant_id"};
service_principal_cert is JSON {"client_id","tenant_id","certificate"}
with the certificate a PEM bundle; service_account_key is the service
account key file's JSON.
The registry's contract
| Variable | Meaning |
|---|---|
IMAGE_REGISTRY | The record's code. |
IMAGE_REGISTRY_KIND | oci, ghcr, ecr, acr, gar, gallery, ami. |
IMAGE_REGISTRY_HOST | The registry host. |
IMAGE_REGISTRY_REPOSITORY | The repository prefix; the step names the image beneath it. |
IMAGE_REGISTRY_CONFIG_FILE | A docker-style config.json holding the login for IMAGE_REGISTRY_HOST, written for the step's lifetime; also REGISTRY_AUTH_FILE (skopeo, podman), and its directory as DOCKER_CONFIG (docker, buildctl, crane). |
The login inside the config is minted at dispatch where the kind defines
one, and short-lived: an ECR authorisation token (ecr, from the
stored key or an STS session for assume_role), an ACR refresh token
exchanged from a service-principal token (acr), a Google access token
(gar, from the service-account key or the host's workload identity),
the GitHub connection's installation token (ghcr on the
github_connection form — the same just-in-time mint as github-app://,
needing the integrations:github entitlement), or the stored basic /
token credential (oci, ghcr with a token: a bare token logs in as
oauth2, or x-access-token on ghcr.io; a user:token value logs in
as that user). The machine-image kinds hold no docker login: ami
injects the AWS session variables and gallery the Azure service
principal, as a build provider of that cloud would.
A verb that both builds and pushes receives both contracts; a verb that only pushes receives the registry's alone.
Masking, lifetime and the runner
Every minted or stored value — the raw credential, the parsed parts, the minted tokens, the config file's content — joins the run's mask set before the job is offered, so no log line, output or event can carry it, and nothing is persisted: the server holds the values only for the spec build. The files live under the step's data-passing directory with owner-only permissions and go with it at cleanup. Materialising a credential file needs a runner at protocol 7 or later; an older runner is refused by name ("needs a credential file but the runner's protocol … predates credential files — update the runner") rather than offered the step without its credential.
The refusals, by name
Every refusal is the step's failure message and a
warn:image_build:refused event with a typed reason:
| Reason | Message |
|---|---|
unbound_target | no image registry is bound for /projects/… - bind one (opschain admin image-registries bind <code> <path>) or set opschain.image.registry |
not_bound | image registry "acr-prod" is not bound for /projects/… - bind it (opschain admin image-registries bind acr-prod <path>) or reference one bound to this target |
ambiguous_binding | several image build providers are bound at /projects/x for /projects/x/… (a, b) - name one in with: or set opschain.image.build_provider |
ambiguous_reference | with: names several image build providers (one, two) - name one |
malformed_reference | property opschain.image.registry value "…" is not a image-registry://<code> reference |
unknown | image build provider "ghost" is not registered - register it (opschain admin image-build-providers create) or re-point the reference |
disabled | image build provider "buildkit-off" is disabled - an administrator must enable it before a step can build against it |
reserved_kind | image build provider "gcp-later" is of kind gcp_cloud_build: no installed pack serves this kind |
pack_entitlement | image build provider "aws-prod" is of kind aws_image_builder, which needs the "packs:aws" entitlement - … |
unauthorised | run principal "svc-builder" is not authorised to use image registry "acr-payments" - grant read on /platform/image-registries/acr-payments |
no_credential, credential_unresolvable, credential, login, github_mint | the record holds no credential, its reference does not resolve, the value does not parse as its form, or the cloud refused the mint — each naming the record and the cause. |
A successful resolution records info:image_build:provider_used on the
run's chain — the record's code, kind, credential form and where the
reference came from (with: provider, property opschain.image.registry
or binding at /projects/payments) — and the step's log opens with the
same facts as system lines. Neither carries a credential.
The image artifact: build once, promote by reference
A machine image with EC2 Image Builder records the same way — the
backend's output resource id becomes the identifier and, since Image
Builder reports no content digest, the digest takes the id: form:
action: bake_ami
description: Run the Image Builder pipeline for this service's AMI recipe and record the AMI.
inputs:
recipe_version: { required: true }
provider: { default: "image-build://aws-imagebuilder-prod" }
steps:
- name: bake
uses: aws:imagebuilder_run # packs:aws ships the verb
with:
provider: ${{ inputs.provider }}
pipeline: web-base
recipe_version: ${{ inputs.recipe_version }}
registry: image-registry://ami-prod # kind ami: the account and region the AMI lands in
tags: '{"opschain_run":"${{ run.id }}","opschain_commit":"${{ node.content_sha }}"}' # a JSON object or KEY=VALUE lines — with: values are strings, so a quote-bearing value rides the input environment lane, never spliced
artifacts:
- name: web_base_ami
kind: image # identifier ami-…, digest id:ami-…
The same action with provider: image-build://aws-imagebuilder-dev runs
in the development account. Nothing else changes. aws:imagebuilder_run
(packs:aws) takes the pipeline by ARN or name, optional recipe_arn /
infrastructure_arn / distribution_arn overrides (a one-off build from
the pipeline's definition with those replaced — the pipeline is never
mutated), wait (default true) and wait_timeout, and on completion
records the AMI in the provider's region. Azure Image Builder records the
same way through azure:imagebuilder_run (packs:azure): the image
template by resource id or by name with resource_group, the run drained
to completion, the gallery image version the template distributed to as
the identifier (id:/subscriptions/…/galleries/…/versions/… as the
digest), under a provider of kind azure_image_builder and a registry of
kind gallery. Each pack's README carries the verb's full input table.
Deploying what was built is another change, gated by the same approval policy and change window as any production change, and it names the build by reference:
action: deploy
inputs:
image: { required: true } # artifact://<node path>/<code>@<digest>
steps:
- name: pull_record
uses: artifacts:resolve_image # verifies the artifact exists, is an image, and its digest matches
with:
image: ${{ inputs.image }}
- name: roll
run: scripts/deploy.sh "${{ steps.pull_record.outputs.identifier }}" "${{ steps.pull_record.outputs.digest }}"
opschain artifacts list --kind image --node /projects/limebank/environments/dev/assets/web
opschain changes create --target /projects/limebank/environments/prod/assets/web --action deploy \
--input image=artifact://projects/limebank/environments/dev/assets/web/web_image@sha256:9f3c…
The consuming run's chain records info:image_build:artifact_resolved
naming the artifact, so the build and the deploy are linked on the
ledger: the image record carries the producing run, step and commit; the
deploy step's record names the artifact it consumed.
The promotion-policy rule: core.image_provenance
A classified environment may require that what is deployed to it was
built under custody. The core metadata vocabulary carries
core.image_provenance (required | none; subtree-inherited and
protective, like core.criticality — opschain docs metadata). Set it
to required on an environment (or a project, or one asset) and every
artifacts:resolve_image step whose run targets that subtree is refused
by name unless the image record's provenance is complete:
- the record names a build provider (the producing step dispatched
with an
image-build://reference); - the record names an image registry (the producing step dispatched
with an
image-registry://reference); - the record carries a source commit, and it is the sealed commit of the run that produced it — a record whose producing run is not on this server, or whose commit differs from that run's, is refused.
opschain metadata set /projects/limebank/environments/prod core.image_provenance=required
artifacts:resolve_image: image web_image@sha256:9f3c… at /projects/limebank/environments/dev/assets/web does not satisfy core.image_provenance=required (set at /projects/limebank/environments/prod) for /projects/limebank/environments/prod/assets/web: the record names no image registry (the producing step dispatched without an image-registry:// reference)
The refusal is the step's failure message and a warn:image_build:refused
event with family image_artifact and reason provenance. none on
a node beneath lifts an inherited requirement for that subtree; a server
without the metadata plane (the local lane) classifies nothing and
requires nothing. Images built through buildkit:build, packer:build,
aws:imagebuilder_run and azure:imagebuilder_run with both references
dispatched satisfy the rule by construction; a descriptor written by a
hand-rolled script that names no provider or registry does not.
The approver's checklist
A change that produced or consumes a kind: image artifact shows the
image facts on its approval checklist (context.images on the checklist,
opschain approvals show <run-id>, the GUI inbox): the role (produced
by this run, or consumed through a named artifact://… input), the
artifact code and reference, identifier, digest, build provider,
registry, source commit and producing run — so a deploy is approved on
what was built, not on a tag (opschain docs resources/approval,
ApprovalContextImage).
A git push can build the dev image automatically through a trigger on the
info:git_remote:ref_moved event (opschain docs triggers):
subject: external
events: [info:git_remote:ref_moved]
scope_path: /projects/limebank
conditions:
- event.data.remote eq web-app
- event.data.ref eq refs/heads/main
- event.data.change in fast_forward,created
action_type: change
action_params:
parent_path: /projects/limebank/environments/dev/assets/web
action: build_image
run_as: svc-builder
owned_by: platform-team
Usage telemetry
The dispatch crossing counts every record it resolves on the provider
usage substrate (opschain docs provider-usage): a build provider's
builds (a build dispatched through it) and build_failures (a named
refusal at the crossing), a registry's pushes (an image artifact
uploaded naming the registry, with the image reference as the row's
detail) and push_failures (a named refusal at the crossing), on the
project of the run. build_seconds and bytes_pushed are registered
metrics no seam fills yet — the server sees neither the build's duration
nor the image's size today. opschain admin providers usage --kind image_build|image_registry --code <code> reads them; each record opens
its operational dashboard.
Authorisation
Administering the records needs the /admin updatable determination.
Using one at dispatch needs the run principal to hold read on the
record's rule path — /platform/image-build-providers/<code> or
/platform/image-registries/<code> — within the record's bindings; the
rule-path catalog (opschain docs resources/authorisation_policy) lists
both families.
Probing a record
opschain admin image-build-providers test <code> and opschain admin image-registries test <code> probe a record: the credential resolves and
parses as its declared form, the endpoint or registry answers; nothing is
built or pushed. The outcome lands on the record (last_test_*) and in
the audit trail, never the credential.
Audit
Every write is an audit event: api:image_build_providers:create /
update / destroy / test / bind / unbind, and
api:image_registries:* likewise. The payload carries the record's
facts and the credential_ref pointer only; a rotation carries
credential_rotated.
Related pages
opschain docs resources/image_build_provider— the build-provider data dictionary.opschain docs resources/image_registry— the registry data dictionary.opschain docs cli/admin-image-build-providersandopschain docs cli/admin-image-registries— the CLI manual.opschain docs artifacts— thekind: imagerecord, its descriptor contract and theartifacts:resolve_imagebuilt-in.opschain docs packs-common— thebuildkit:buildandpacker:buildverbs that consume these contracts and write the descriptor;aws:imagebuilder_runandazure:imagebuilder_runship inpacks:awsandpacks:azure(each pack's README).opschain docs metadata—core.image_provenance, the promotion-policy rule's classification.opschain docs resources/approval—ApprovalContextImage, the image facts on the approver's checklist.opschain docs integrations-github— thegithub-app://reference the dispatch crossing is modelled on, and the connection aghcrregistry pushes through.opschain docs secrets-providers— where the credential references resolve.opschain docs transport-providers— the sibling provider family whose bindings these mirror.