Ch. 16 · Git & CI/CD

CI Build Once and Promote the Same Artifact

Rebuilding for each environment means production runs bytes nobody tested. Promote one artifact by digest and inject config at deploy time.

~6 min readintermediateupdated Oct 7, 2026

A pipeline that runs npm run build in the staging job and again in the production job looks harmless. Same commit, same script, so surely the same result? Usually not. And when production breaks in a way staging didn’t, the first thing you discover is that you can’t say for certain what’s different, because the thing you tested no longer exists.

“Build once, promote” is the rule that fixes this: produce one artifact per commit, test that exact artifact, and move it through environments without rebuilding. Interviewers ask about it to check that you understand the difference between source code and the thing that actually runs.

Same commit, different bytes

Here’s a small build script of the kind many projects have. It copies the source, writes a build-info file, and packages the result:

#!/bin/sh
rm -rf dist && mkdir dist
cp src/app.js dist/
echo "{\"commit\":\"$(git rev-parse --short HEAD)\",\"builtAt\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" > dist/build-info.json
tar -czf app.tgz dist
shasum -a 256 app.tgz | cut -c1-16
Terminal

Run it twice on commit 76d3212, a second apart:

$ ./build.sh
5dbb171b626ed60b
$ ./build.sh
61da30db5b658c54
Text

Two different archives from one commit. Here the cause is a timestamp in a file plus the modification times that tar and gzip record, which is mild. In real pipelines the differences can be much bigger:

  • Dependency resolution. A range like ^4.18.0 without a lockfile, or an npm install that updates the lockfile, picks up whatever was published between the two jobs.
  • Base images and toolchains. FROM node:22 resolves to a different image after the upstream maintainers push a patch release. A runner image update can change the compiler.
  • Build-time configuration. Anything baked in during the build, such as an API URL or a feature switch, makes the artifacts different on purpose.

Reproducible builds are a real discipline (pinned toolchains, fixed timestamps via SOURCE_DATE_EPOCH, sorted file lists), and they’re worth having. But even with them, rebuilding in each environment adds work and risk for no benefit. Building once removes the question entirely.

What “promote” means in practice

The pipeline builds the artifact in one job, gives it an identity that can’t change, and every later stage refers to that identity.

For container images, the identity is the digest. A tag like api:1.5.0 or api:staging is a pointer that anyone with push access can move. A digest like api@sha256:9f2c... is the hash of the image manifest, so if the bytes change, the digest changes. A promotion step looks like this:

  1. Build on commit 76d3212, push registry.example.com/api:76d3212, and capture the digest the registry returns.
  2. Test by deploying that digest to a test environment and running integration tests against it.
  3. Promote to staging by deploying the same digest. Optionally add a tag such as api:staging for humans, but the deployment manifest references the digest.
  4. Promote to production with a gate (approval, a canary, a change window), again deploying the same digest.

For non-container artifacts the idea is the same: a JAR in Maven, a tarball in an artifact store, a static site bundle in object storage, each addressed by version and checksum. GitHub Actions jobs pass files between jobs with actions/upload-artifact and actions/download-artifact, and the deploy job should download the artifact the build job produced instead of checking out the code and building again.

A mistake interviewers listen for is “we tag the image prod and deploy prod.” If someone retags it, or a second pipeline overwrites it, production can change without anyone deploying anything on purpose. Tags are fine as labels. Deployments should pin digests.

Configuration has to move out of the build

If staging and production need different database URLs, API endpoints or log levels, and those are compiled in, you can’t promote one artifact. You’d need two. So the rule comes with a corollary: the artifact is environment-neutral, and the environment supplies configuration when it starts.

For a backend service that’s usually easy. Read DATABASE_URL from an environment variable, mount secrets from the platform’s secret store, and let the deployment for each environment set them.

Frontend bundles are where this bites. Tools like Vite replace import.meta.env.VITE_API_URL with a literal string at build time, and Create React App did the same with process.env.REACT_APP_*. A team that builds the frontend separately for staging and production because “the API URL is different” has quietly given up on build once. Common fixes:

  • Serve a small /config.json from each environment and fetch it before the app starts.
  • Use relative URLs (/api/...) and let the environment’s reverse proxy route them.
  • Have the container’s entrypoint write a window.__CONFIG__ script from environment variables before the web server starts.

Configuration then becomes something you can review and audit separately. If production fails and staging didn’t, the difference is in a short list of config values, not in an unknown set of build outputs.

Traceability: answering “what’s running right now?”

Building once also makes provenance cheap. For each artifact, record the commit SHA, the digest, the pipeline run that built it, and the test results. For each deployment, record which digest went to which environment and when. With that, three questions that are otherwise painful get one-line answers:

  • Which commit is in production? Look up the deployed digest, read its commit label.
  • Was this exact artifact tested? Check whether the same digest ran in the test stage.
  • What do we roll back to? The previous digest that was deployed to production, which still exists in the registry, unchanged.

Container labels (org.opencontainers.image.revision) and build attestations carry this metadata with the artifact. GitHub’s artifact attestations sign a statement linking the artifact’s digest to the workflow and commit that produced it, which lets a deploy gate refuse anything that didn’t come out of the official pipeline.

Where the rule needs care

  • Promotion between registries or accounts. Copying an image to a production registry keeps its digest if you copy the manifest (tools like crane copy or skopeo copy do this). Pulling, retagging and pushing usually does too, but rebuilding obviously doesn’t.
  • Platform-specific builds. A multi-architecture image has one index digest pointing at per-platform images. Promote the index.
  • Retention. If the registry garbage-collects untagged images after 30 days, your rollback target may vanish. Keep anything that was ever deployed to production.

Interview practice

A team’s pipeline has separate build-staging and build-prod jobs. Each checks out the release tag and runs docker build with --build-arg API_URL=.... A release passed every staging test, then failed in production with a TLS error from an HTTP client library. Investigation shows the production image had a newer patch version of that library. Explain how that happened and redesign the pipeline.

Answer

The two jobs built from the same source at different times. If the Dockerfile runs npm install without a lockfile, or uses a floating base image such as node:22, each build resolves dependencies afresh, and a patch release published between the staging and production builds got into one image and not the other. Staging tested image A; production ran image B. The build argument is also a problem: it guarantees the images differ even when dependencies don’t.

The redesign has one build job per commit. It uses a pinned base image and npm ci against the committed lockfile, builds an environment-neutral image, pushes it and records the digest. The API URL moves to an environment variable read at startup. Every later stage deploys that digest: integration tests, then staging, then production behind an approval. Deployment manifests reference image@sha256:..., not a tag. The pipeline records the commit, digest and deploy time for each environment, so rollback means redeploying the previous production digest. If the team wants extra assurance, the production gate can verify a build attestation for the digest before deploying.

Further reading

More in Git & CI/CD

read ✓Git & CI/CD · hard

CI Rollbacks and Data Compatibility

Rolling back code is easy; rolling back data isn't. Why a column rename breaks the last release, and how expand and contract keeps rollback safe.

~6 min readread →
read ✓Git & CI/CD · hard

CI Canary Releases and Decision Metrics

A canary is only as good as its pass/fail rule. Pick metrics, compare against a live baseline and wait for enough traffic before you promote.

~6 min readread →
read ✓Git & CI/CD · mid

CI Required Checks and Meaningful Validation

A green required check can hide a failed test. How pipes, skipped jobs and path filters fool branch protection, and how one gate job fixes it.

~6 min readread →
esc