Artifact Promotion Across Environments

Artifact promotion is the discipline of advancing one already-built artifact — the exact same bytes, identified by the exact same content digest — through a sequence of increasingly production-like environments (typically test → staging → production), without ever rebuilding it. When a container image with digest sha256:abc… passes its test-environment gate, promotion means that identical digest is re-referenced in staging, and later in production; it is never recompiled, re-packaged, or re-tagged onto fresh bytes. This is the operational realization of the load-bearing continuous-delivery rule that a build is done once and then promoted, not rebuilt, as it moves through the deployment pipeline (Fowler, DeploymentPipeline). Everything that must differ between environments — database URLs, feature flags, secrets, replica counts — is configuration injected at deploy time, never baked into the artifact at build time (12factor.net/config). Promotion is what lets the green light you earned in test actually mean something in prod: you are shipping the thing you tested, byte-for-byte.

This note owns the mechanism of re-referencing the same digest across environments. It embodies Build Once Promote the Artifact (the principle) and is the concrete machinery behind Environment Promotion (the dev→prod ladder). It depends on Immutable Artifact Versioning — promotion is only meaningful if the version being promoted can never mutate underneath you. The deployment strategy the promoted artifact then feeds (canary, blue-green, rolling) belongs to Site Reliability Engineering MOC and is cross-linked, not re-taught here.

Mental Model — One Artifact, Many Environments

The central mental shift is to stop thinking of “the build for staging” and “the build for prod” as separate things. There is one artifact. Environments are not different builds; they are different places the same build runs, each supplying its own configuration. The digest is the identity that threads through all of them.

flowchart LR
    SRC["Source commit<br/>git sha 7fd1a60"] --> BUILD["Build ONCE<br/>compile + package"]
    BUILD --> IMG["Immutable artifact<br/>sha256:abc123…"]
    IMG --> REG[("OCI registry<br/>stores blob by digest")]

    REG -->|"same digest"| TEST["test env<br/>+ test config"]
    REG -->|"same digest"| STAGE["staging env<br/>+ staging config"]
    REG -->|"same digest"| PROD["production env<br/>+ prod config"]

    TEST -->|"gate passes"| G1{"promote?"}
    G1 -->|yes| STAGE
    STAGE -->|"gate passes"| G2{"promote?"}
    G2 -->|yes| PROD

    style IMG fill:#2d5,stroke:#083,color:#000
    style BUILD fill:#fd6,stroke:#a80,color:#000

What it shows and the insight to take: the artifact sha256:abc123… is produced exactly once (the yellow node) and stored in the registry by its digest (the green node). Every environment pulls that same digest. Promotion is the act of allowing the next environment to reference it, gated by the previous environment’s success. Configuration enters sideways at each environment — it is never part of the artifact. The failure this design eliminates: if you rebuilt for prod, the prod binary would be a different set of bytes than the one that passed staging, and every test result you collected upstream would be, strictly speaking, about a different artifact.

The reason this matters so much is subtle: a rebuild is never guaranteed to be identical even from the same source. A transitively-pinned dependency can publish a new patch, a base image tag like python:3.12 can move, a timestamp or build-host difference can change bytes, a toolchain can update. Unless your build is bit-for-bit reproducible (see Build Reproducibility), “rebuild from the same commit” does not give you “the same artifact.” Promotion sidesteps the entire question by never rebuilding — the tested bytes are the shipped bytes, so reproducibility is not even required for the guarantee to hold.

Mechanical Walk-through — How a Digest Passes Test and Is Re-Referenced in Prod

Concretely, here is the life of one artifact through promotion, step by step.

  1. Build and publish, once. A CI job checks out commit 7fd1a60, builds the image, and pushes it to a registry. The registry stores the manifest and its layers as content-addressed blobs; the push returns the digest, a SHA-256 hash of the image manifest. Per the OCI Distribution Spec, a blob addressed by digest is provably immutable — the same digest served by any conformant registry is byte-identical, because the digest is the hash of the content (OCI Distribution Spec). At this moment the artifact’s permanent identity, sha256:abc123…, exists.

  2. Deploy to test by digest. The pipeline deploys the digest (not a floating tag) into the test environment, layering in test configuration — a test database URL, mocked third-party endpoints, verbose logging. The deployment manifest pins image: registry.example.com/app@sha256:abc123…. Automated acceptance tests run against this running instance.

  3. The gate. If the test stage passes, the artifact is eligible for promotion. Nothing is rebuilt. “Passing” is recorded against the digest — this is where a build’s metadata (test results, and increasingly a provenance attestation) accumulates about that specific digest.

  4. Promote to staging. Promotion re-references the identical digest in the staging environment. Depending on the promotion model (below), this is either a re-deploy of the same digest with staging config, or a copy of the artifact into a staging repository — but in both cases the bytes and digest are unchanged. Staging config (a staging DB, real-but-sandboxed integrations) is injected.

  5. Promote to production. After the staging gate (often a human approval — Google Cloud Deploy models production targets with requireApproval: true, notifying approvers via Pub/Sub and requiring an approver role before the rollout advances (Cloud Deploy, Promote a release)), the same digest is deployed to production with production config. The bytes running in prod are, verifiably, the bytes that passed test and staging.

The crucial invariant across steps 2–5: the artifact reference is a digest, and it is constant. The only things that change are (a) which environment references it and (b) what configuration that environment injects.

The Two Promotion Models — Promotion Pipelines vs Promotion Repositories

There are two established ways to implement “advance the same artifact,” and they differ in where the artifact physically lives as it is promoted.

Model A — Promotion pipeline (one registry, re-reference by digest)

The artifact stays in one registry. Promotion is purely a matter of the deployment layer pointing successive environments at the same digest. The registry is a passive store; the pipeline (or a GitOps reconciler) is the thing that “promotes” by advancing which environment’s manifest pins the digest. Optionally, a moving tag per environment (app:staging, app:prod) is repointed to the promoted digest as a human-readable convenience — but the authority is always the digest the tag currently resolves to.

flowchart TD
    REG[("Single registry<br/>app@sha256:abc…")]
    REG --> D1["test deployment<br/>pins @sha256:abc…"]
    REG --> D2["staging deployment<br/>pins @sha256:abc…"]
    REG --> D3["prod deployment<br/>pins @sha256:abc…"]
    PIPE["Promotion pipeline<br/>advances the pinned digest<br/>into the next env manifest"]
    PIPE -.controls.-> D1
    PIPE -.controls.-> D2
    PIPE -.controls.-> D3

Caption: one physical copy of the artifact; promotion advances which environments are allowed to reference it. This is the model GitOps naturally expresses — promotion is a commit that updates the staging (then prod) environment’s manifest to the digest that passed the previous stage. It is the leaner model: no artifact copying, one source of truth for bytes.

Model B — Promotion repository (copy the same bytes into a higher-maturity repo)

The artifact is copied from a lower-maturity repository into a higher-maturity one as it is promoted: e.g. docker-devdocker-stagingdocker-release, or a Maven libs-snapshot-locallibs-release-local. The bytes and digest are preserved by the copy (a digest-preserving copy, not a rebuild), but the artifact’s location signals its maturity. Access controls differ per repo — production deployers may only be allowed to pull from docker-release, so a raw dev build physically cannot reach prod without passing through promotion.

flowchart LR
    DEV[("dev repo<br/>docker-dev")] -->|"copy same bytes<br/>digest preserved"| STG[("staging repo<br/>docker-staging")]
    STG -->|"copy same bytes<br/>digest preserved"| REL[("release repo<br/>docker-release")]
    REL --> PRODPULL["prod pulls ONLY<br/>from release repo"]

Caption: the digest is invariant across the copies; the repository encodes the promotion stage and enforces a hard boundary via per-repo permissions. This model shines when you want a security/governance boundary — “prod can only run artifacts that reached the release repo” — and when different retention or scanning policies apply per maturity tier.

When to choose which

DimensionPromotion pipeline (Model A)Promotion repository (Model B)
Physical copies of the artifactOneOne per maturity tier (digest-preserved copies)
What signals “promoted”Which env manifest pins the digestWhich repository holds the bytes
Access boundaryEnforced at deploy/RBAC layerEnforced at registry permissions (prod pulls release-repo only)
Fits GitOps naturallyYes — promotion = a Git commit changing the digestPartially — still need a copy step + manifest update
Storage overheadMinimalDuplicated storage (dedup helps but metadata multiplies)
Governance story“The pipeline is trusted”“The release repo is the enforced gate”
Typical homesKubernetes + GitOps (ArgoCD)Artifactory/Nexus, regulated environments

Neither is “correct” universally. Model A is simpler and dominant in cloud-native GitOps shops; Model B gives a stronger, registry-enforced boundary favored where governance/compliance wants an artifact to physically prove it graduated. They compose: a promotion repository can still be driven by a promotion pipeline.

Configuration Is Injected at Deploy, Never Baked at Build

The other half of “build once” is “configure per-deploy.” The twelve-factor methodology states the rule sharply: an app’s config is “everything that is likely to vary between deploys,” and “config varies substantially across deploys, code does not” (12factor.net/config). If environment-specific values were compiled into the artifact, you would need a different artifact per environment — which is exactly the rebuild-per-environment anti-pattern promotion exists to kill.

flowchart TD
    ART["Immutable artifact<br/>sha256:abc… (identical everywhere)"]
    ART --> RUN1["running in test"]
    ART --> RUN2["running in staging"]
    ART --> RUN3["running in prod"]
    C1["test config<br/>DB=test.db<br/>FLAG_X=off"] -.injected at deploy.-> RUN1
    C2["staging config<br/>DB=stg.db<br/>FLAG_X=on"] -.injected at deploy.-> RUN2
    C3["prod config<br/>DB=prod.db<br/>SECRET from vault"] -.injected at deploy.-> RUN3

Caption: the artifact node is byte-identical in all three; configuration is a separate input supplied at deploy time via environment variables, mounted ConfigMaps/Secrets, or a config service. The insight: config is a deploy-time concern, so the same promoted digest can run anywhere. Practically this means environment variables (DATABASE_URL), Kubernetes ConfigMaps and Secrets mounted into the pod, or a fetched-at-startup config document. What it must not mean: an if ENV == "prod" baked into the binary, a config.prod.json copied in at build, or a per-environment image tag built from per-environment Dockerfiles.

Worked Example — Promotion by Digest in a Pipeline

A minimal promotion job. The build stage publishes once and captures the digest; promotion stages only reference it.

# --- build once: publish and capture the immutable digest ---
build:
  script:
    - docker build -t registry.example.com/app:$CI_COMMIT_SHA .
    - docker push registry.example.com/app:$CI_COMMIT_SHA
    # Capture the DIGEST the registry assigned — this is the artifact's identity
    - DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' \
        registry.example.com/app:$CI_COMMIT_SHA)
    - echo "IMAGE_DIGEST=$DIGEST" >> build.env      # e.g. app@sha256:abc123…
  artifacts:
    reports: { dotenv: build.env }                  # pass DIGEST downstream
 
deploy_test:
  script:
    # Deploy the DIGEST (not a tag) with TEST config injected at deploy
    - helm upgrade app ./chart --set image=$IMAGE_DIGEST \
        --values values-test.yaml                   # test config, not baked in
 
deploy_staging:
  when: manual                                      # gate: promote only if test passed
  script:
    - helm upgrade app ./chart --set image=$IMAGE_DIGEST \
        --values values-staging.yaml                # SAME digest, staging config
 
deploy_prod:
  when: manual                                      # human approval gate
  script:
    - helm upgrade app ./chart --set image=$IMAGE_DIGEST \
        --values values-prod.yaml                   # SAME digest, prod config

Line-by-line commentary. The build job pushes and then reads back the RepoDigest — the crucial line, because it captures the content digest the registry computed, not the mutable :$CI_COMMIT_SHA tag. That digest is exported via a dotenv artifact so every downstream stage inherits $IMAGE_DIGEST. Each deploy_* job then deploys the same $IMAGE_DIGEST and differs only in its --values file — values-test.yaml, values-staging.yaml, values-prod.yaml carry the configuration, never a different image. deploy_staging and deploy_prod are when: manual, encoding the promotion gates: an operator promotes the already-built, already-tested digest forward. Nowhere after the first job does docker build appear — that is the whole point.

In a GitOps variant, deploy_prod is not a helm upgrade at all; it is a commit to the prod environment’s manifest repository changing the pinned digest, which ArgoCD then reconciles. The promotion is a Git change of the digest reference — auditable, revertible, and pull-based (see Push-Based versus Pull-Based Delivery).

Failure Modes and How to Diagnose Them

  • Promoting a tag instead of a digest. If prod pins app:staging (a mutable tag) rather than app@sha256:…, someone repointing app:staging later silently changes what prod will pull on its next restart. Symptom: prod pods on different nodes running different bytes after a rolling restart, with no deploy having occurred. Fix: pin digests everywhere; treat tags as human-readable aliases only. See Immutable Artifact Versioning.
  • Rebuilding “from the same commit” for prod. The rebuild pulls a floating base image or a freshly-published transitive dependency, producing different bytes than staging tested. Symptom: “works in staging, breaks in prod” with an identical commit SHA. Fix: never rebuild; promote the tested digest. If a rebuild is unavoidable, require Build Reproducibility and verify the digest matches.
  • Config baked into the artifact. A config.prod.yaml copied in at build forces a separate prod image, reintroducing the variance promotion removes. Symptom: per-environment image tags, a Dockerfile.prod. Fix: externalize config to deploy-time env/ConfigMaps (12factor config).
  • Digest exists in test registry but not reachable from prod. In Model B, prod can only pull from the release repo; if promotion’s copy step was skipped, the deploy fails to pull. Symptom: ImagePullBackOff / manifest-unknown in prod despite a green staging. Fix: ensure the promotion copy ran and the release-repo pull credentials are present.
  • Promotion without an accompanying provenance/metadata trail. Bytes promote fine, but nobody can prove what was tested. Fix: attach test results and a provenance attestation to the digest so promotion carries evidence, not just bytes.

Alternatives and When (Not) to Choose Them

  • Rebuild-per-environment (the anti-pattern): compile separately for each stage. Chosen historically when config was compiled-in or when no artifact registry existed. It throws away the pipeline’s core guarantee and should be replaced by promotion in essentially all modern pipelines.
  • Branch-per-environment (developstagingmaster, each triggering its own build): conflates promotion with branching. Each branch builds its own artifact, so prod runs bytes staging never saw. Trunk-based development plus digest promotion is the modern replacement (see Trunk-Based Development).
  • latest-tag deployment: deploying app:latest to every environment. Maximally mutable — you have no idea what bytes are where. Only defensible for throwaway local dev.
  • Full reproducible-rebuild-and-verify: rebuild per environment but verify the digest matches the tested one. Achievable with bit-for-bit reproducible builds, but it is strictly more work than promotion for the same guarantee — promotion is the pragmatic default.

Production Notes

Real delivery platforms encode promotion as a first-class concept. Google Cloud Deploy models a delivery pipeline as an ordered progression of targets; you “promote an existing release to the next target,” the same release advancing dev→staging→prod, with requireApproval gating sensitive targets and rejection blocking a rollout “unless re-promoted” (Cloud Deploy, Promote a release) — a textbook promotion pipeline. GitOps tools (ArgoCD, Flux) implement Model A promotion as a digest change committed to an environment’s manifest repo, reconciled into the cluster; promotion becomes a reviewable, revertible Git operation. Artifact-repository managers (Artifactory, Nexus) implement Model B, copying a build’s artifacts from a snapshot/dev repository into a release repository as the promotion action, with per-repo RBAC enforcing that production consumers pull only from the release tier.

Uncertain

Verify: the specific JFrog Artifactory “build promotion between repositories” API mechanics (whether it copies vs moves, and digest-preservation guarantees). Reason: the JFrog documentation page entered a redirect loop and could not be fetched during this note’s research; the Model-B description here is the well-established general pattern, not JFrog-specific verified behavior. To resolve: fetch the current JFrog Artifactory “Promote Builds” / build-promotion REST API docs and confirm copy-vs-move semantics and that the artifact SHA is preserved. #uncertain

The through-line for interviews and design reviews: the digest is the identity, promotion advances references to it, and config is a deploy-time input. If you can articulate why rebuilding for prod breaks the guarantee that a green staging gate is supposed to give you, you understand promotion.

See Also