CI/CD pipelines, build once and promote everywhere
Build each commit once, check and promote that exact digest, and add each environment's config only when it starts.
Predict first
Before you read: predict
ExamHall's admin-console API ships through the log below. CI pushes each build of main under the tag examhall/api:staging; the staging environment runs its smoke tests on the image it deployed.
10:02 ci build commit 3f2c1, push examhall/api:staging
10:05 staging deploy examhall/api:staging
10:20 staging smoke tests on the deployed image: 14 passed
10:31 ci build commit 7b9e0, push examhall/api:staging
10:36 release deploy production examhall/api:staging
Which image does production run after 10:36?
The 30-second version
CI builds each commit once into an immutable artifact named by its digest, a hash of its bytes. Every later stage checks and deploys that digest; a tag is a movable name. Each environment adds its config and secrets when it starts the artifact. Cheap, often-failing checks run first and gate slower ones; long suites are sharded.
Build each commit once, check and promote that exact digest, and add each environment's config only when it starts.
How it works
Step 1 of 10
In the kitchen
- the cut and its stamper, made once → one build of the commit (step 1)
Analogy: A vinyl pressing plant
A record is cut once, and a metal stamper is made from the cut. A test pressing from that stamper is played and approved, and the release is pressed from the same stamper. The matrix number etched by the run-out groove names the cut each copy came from.
| At the plant | In CI/CD |
|---|---|
| the cut and its stamper, made once | one build of the commit (step 1) |
| the approved test pressing | checks that passed on that digest (step 7) |
| the matrix number in the run-out groove | the digest (steps 2–3) |
| an order for "the current single" | deploying a tag (step 3) |
| a second lacquer cut for export | a rebuild for one environment (step 4) |
| each country's price sticker, put on the sleeve at distribution | config added when the artifact starts (step 5) |
| a pressing checked by eye for warps before anyone plays a side | the cheap gate first (step 8) |
| test pressings shared among several listeners | shards, done when the longest record ends (step 10) |
In real terms: CI builds once and records the digest; each stage checks that digest; each deploy starts name@digest with its own environment's settings.
Breaks down when: a matrix number is etched by hand and names a cut, while a digest is computed from every byte. A stamper wears out; an artifact never wears. A sticker can be peeled off the sleeve, but a secret written into an image stays in it.
Contrasting case: a bakery bakes fresh loaves for each shop every morning, and nobody expects a shop's loaf to be the one tasted yesterday. Rebuilding per environment treats software like bread.
Examples, run for real
4.1 ExamHall EXH-2080: production ran a digest staging never saw
EXH-2080: staging deployed CI's build of the admin-console API, and the release job rebuilt the same commit for production. MR !2380 builds once and promotes the digest that passed staging. The platform mounts each environment's own admin.env file at /etc/examhall/, and the API reads it at boot.
4.1 release job rebuilds (before) staging runs sha256:686048351a1f… · production runs sha256:b8d9ec0f4aa2… · smoke tests ran on sha256:686048351a1f… · production's digest tested: no
4.1 build once, promote (MR !2380) staging runs sha256:686048351a1f… · production runs sha256:686048351a1f… · smoke tests ran on sha256:686048351a1f… · production's digest tested: yes
4.1 settings at boot (MR !2380) staging reads admin-db.staging.internal · production reads admin-db.production.internal · from the mounted /etc/examhall/admin.env · settings in the image: no
Before, production ran a rebuild that no test had met (step 4). After, it runs the digest the smoke tests passed, and its settings arrive as a file at boot, never inside the image (steps 1, 5, 7).
4.2 Two builds of one unchanged Dockerfile (examples-docker.mjs, real Docker)
The same Dockerfile and files, built five times on this machine; an image's id here is its digest:
4.2 build 1 (defaults) RUN step from cache: no
4.2 build 2 (defaults again) RUN step from cache: yes · same image id as build 1: no
4.2 build 3 (--provenance=false) RUN step from cache: yes
4.2 build 4 (--provenance=false again) RUN step from cache: yes · same image id as build 3: yes
4.2 build 5 (--provenance=false --no-cache) RUN step from cache: no · same image id as build 3: no
4.2 base image nats:2.10-alpine unchanged (no pull): yes
BuildKit attaches a provenance record holding the build's timestamps by default, so even a fully cached rebuild gets a new digest. Without that record, an uncached rebuild still differs (step 4).
4.3 Proof domain, e-commerce checkout: gating the slow stage
The checkout service's stage 1 runs types (3 min), lint (1) and unit tests (6) in parallel; stage 2 runs e2e (15) and integration (8).
4.3 lint fails · all five at once red after 1 min · runner minutes 33
4.3 lint fails · stage 1 gates stage 2 red after 1 min · runner minutes 10
4.3 green commit · all five at once green after 15 min · runner minutes 33
4.3 green commit · stage 1 gates stage 2 green after 21 min · runner minutes 33
A commit that fails lint turns red just as soon either way, but gating spends under a third of the runner minutes. A green commit waits for both stages (step 8).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 5
In last night's run of the checkout service's workflow below, smoke-staging failed on a broken coupon page, and the same run still deployed that digest to production. Every other job succeeded.
jobs:
build:
runs-on: ubuntu-latest
deploy-staging:
needs: build
deploy-production:
needs: build
smoke-staging:
needs: deploy-staging
Which change stops a digest whose staging smoke tests failed from reaching production?
Question 2 of 5
A ticketing API's pipeline runs its five checks one after another on one runner and stops at the first failure. The checks take: integration tests 9 min, lint 1 min, e2e tests 18 min, unit tests 4 min, type check 2 min. Last month 17 runs failed, each on exactly one check: lint 5, e2e tests 2, unit tests 6, type check 1, integration tests 3.
Which order would have given those 17 runs their red result in the fewest minutes in total?
Question 3 of 5
A video-streaming site's Playwright suite has five spec files. Last week they took, in minutes: 6, 6, 6, 6, 6. CI now runs the e2e stage on 4 shards that start together; the splitter gives each shard whole files, balancing them by those timings.
Leaving out setup, how long does the e2e stage take?
Question 4 of 5
A catalog API is built once in CI, with the pipeline's staging settings loaded, and the digest that passes staging is promoted to production. The app takes its database from DATABASE_URL; staging and production each have their own database and password. The Dockerfile:
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci
RUN npm run build
CMD ["node", "dist/server.js"]
Where should DATABASE_URL be set, so that one digest runs in both environments, each on its own database, and the image holds no password?
Question 5 of 5
A chat service's GitHub Actions deploy job signs in to its cloud account with an access key saved as a repository secret. A security review asks that nothing stored in GitHub, or in the image, could be copied and used later to reach the cloud account.
Which change meets that?
Go deeper
Pro tips
- Start a container by its digest,
name@sha256:…, and refuse to pull: what runs is then exactly that image or nothing. When: you promote an image from one environment to the next (steps 3, 7). Verify (RAN,tips.mjs, on the localnats:2.10-alpineimage,--pull=never):tip1 docker run --pull=never nats@sha256:b83efa…b927 -> exit 0 · nats-server: v2.10.x tip1 docker run --pull=never nats@sha256:b83efa…b920 -> exit 125 · refused: no such image on this machine - Shard Playwright with the
blobreporter, then join the shards' results withnpx playwright merge-reports, so one report covers the whole stage. When: a sharded e2e stage leaves one report per shard (step 10).
Source
All fetched 2026-10-01 with curl; vendor docs can change, so each is valid as of 2026-10-01.
- The Twelve-Factor App, III. Config: https://12factor.net/config. "An app's config is everything that is likely to vary between deploys (staging, production, developer environments, etc)"; "Env vars are easy to change between deploys without changing any code".
- The Twelve-Factor App, V. Build, release, run: https://12factor.net/build-release-run. "The release stage takes the build produced by the build stage and combines it with the deploy's current config." Supports steps 1 and 5.
- GitHub Docs, OpenID Connect: https://docs.github.com/en/actions/concepts/security/openid-connect. "No cloud secrets: You won't need to duplicate your cloud credentials as long-lived GitHub secrets"; the token "is only valid for a single job, and then automatically expires".
- GitHub Docs, Secrets: https://docs.github.com/en/actions/concepts/security/secrets. "GitHub is then able to decrypt it so that it can be injected into the workflow runtime"; log redaction "is not guaranteed".
- GitHub Docs, Using jobs in a workflow and Workflow syntax (
jobs.<job_id>.needs): https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-jobs and https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax. "Use jobs.<job_id>.needs to identify any jobs that must complete successfully before this job will run." The model's scheduler implements this rule. - Docker docs, Build secrets: https://docs.docker.com/build/building/secrets/. "Build arguments and environment variables are inappropriate for passing secrets to your build, because they persist in the final image."
- Docker docs, docker image pull ("Pull an image by digest (immutable identifier)"): https://docs.docker.com/reference/cli/docker/image/pull/. Pinning by digest "guarantee[s] that the image you're using is always the same". Supports steps 2–3 and tip 1.
- Docker docs, SLSA provenance attestations: https://docs.docker.com/build/metadata/attestations/slsa-provenance/. Min mode, the default, includes "Build timestamps".
- Docker docs, Reproducible builds with GitHub Actions: https://docs.docker.com/build/ci/github-actions/reproducible-builds/.
SOURCE_DATE_EPOCHmakes "the timestamps in the image index, config, and file metadata reflect the specified Unix time". Supports step 4. - OCI Image Format, Descriptors (Digests): https://raw.githubusercontent.com/opencontainers/image-spec/main/descriptor.md. "one can verify content from an insecure source by recalculating the digest independently". Supports step 2.
- Playwright docs, Sharding: https://playwright.dev/docs/test-sharding. Without
fullyParallel, "entire test files are assigned to shards"; merging blob reports. - Martin Fowler, Continuous Integration: https://martinfowler.com/articles/continuousIntegration.html. The commit build "has to be done quickly"; a second stage runs slower end-to-end tests.
Related lessons
Checked against the tools and docs as of 2026-10-01.