Skip to main content
Version: v5.7.0

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:

RecordKindCredential forms
build providerbuildkitclient_cert — the daemon's mTLS client certificate and key (PEM)
build providerpackerservice_principal_cert, service_principal, assume_role, access_key, service_account_key, client_cert — whatever the Packer template's builder needs
build provideraws_image_builderassume_role (a role ARN plus the credentials that assume it), access_key
build providerazure_image_builderservice_principal_cert, service_principal
build providergcp_cloud_buildreserved: accepted on the record, refused at dispatch by name ("no installed pack serves this kind")
registryocibasic (user:password), token (a bearer token)
registryghcrgithub_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)
registryecr, amiassume_role, access_key
registryacr, galleryservice_principal, service_principal_cert
registrygarservice_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:

  1. the step's with: value (an action author's explicit choice);
  2. 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);
  3. the deepest binding covering the target (the administrator's default for that subtree);
  4. 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​

VariableMeaning
IMAGE_BUILD_PROVIDERThe record's code.
IMAGE_BUILD_KINDbuildkit, packer, aws_image_builder, azure_image_builder.
IMAGE_BUILD_ENDPOINTThe record's endpoint (also BUILDKIT_HOST for the buildkit kind).
IMAGE_BUILD_REGIONThe record's region.
IMAGE_BUILD_DEFAULT_<KEY>One variable per defaults entry (IMAGE_BUILD_DEFAULT_REGISTRY, …_GALLERY, …_DISTRIBUTION, …_PROJECT).
IMAGE_BUILD_CREDENTIAL_FILEThe 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:

FormWhat the step gets
client_certThe PEM bundle at IMAGE_BUILD_CREDENTIAL_FILE.
service_principalAZURE_CLIENT_ID, AZURE_TENANT_ID and the secret as AZURE_CLIENT_SECRET.
service_principal_certAZURE_CLIENT_ID, AZURE_TENANT_ID and the certificate at AZURE_CLIENT_CERTIFICATE_PATH (also IMAGE_BUILD_CREDENTIAL_FILE).
access_keyAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY (and AWS_SESSION_TOKEN when the stored key carries one), AWS_REGION / AWS_DEFAULT_REGION.
assume_roleAn STS session minted at dispatch for the stored role: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_REGION.
service_account_keyAn 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_identityThe 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​

VariableMeaning
IMAGE_REGISTRYThe record's code.
IMAGE_REGISTRY_KINDoci, ghcr, ecr, acr, gar, gallery, ami.
IMAGE_REGISTRY_HOSTThe registry host.
IMAGE_REGISTRY_REPOSITORYThe repository prefix; the step names the image beneath it.
IMAGE_REGISTRY_CONFIG_FILEA 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:

ReasonMessage
unbound_targetno image registry is bound for /projects/… - bind one (opschain admin image-registries bind <code> <path>) or set opschain.image.registry
not_boundimage 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_bindingseveral image build providers are bound at /projects/x for /projects/x/… (a, b) - name one in with: or set opschain.image.build_provider
ambiguous_referencewith: names several image build providers (one, two) - name one
malformed_referenceproperty opschain.image.registry value "…" is not a image-registry://<code> reference
unknownimage build provider "ghost" is not registered - register it (opschain admin image-build-providers create) or re-point the reference
disabledimage build provider "buildkit-off" is disabled - an administrator must enable it before a step can build against it
reserved_kindimage build provider "gcp-later" is of kind gcp_cloud_build: no installed pack serves this kind
pack_entitlementimage build provider "aws-prod" is of kind aws_image_builder, which needs the "packs:aws" entitlement - …
unauthorisedrun 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_mintthe 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.

  • 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-providers and opschain docs cli/admin-image-registries — the CLI manual.
  • opschain docs artifacts — the kind: image record, its descriptor contract and the artifacts:resolve_image built-in.
  • opschain docs packs-common — the buildkit:build and packer:build verbs that consume these contracts and write the descriptor; aws:imagebuilder_run and azure:imagebuilder_run ship in packs:aws and packs: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 — the github-app:// reference the dispatch crossing is modelled on, and the connection a ghcr registry pushes through.
  • opschain docs secrets-providers — where the credential references resolve.
  • opschain docs transport-providers — the sibling provider family whose bindings these mirror.