Skip to content

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?

Pick one

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

One build. CI turns a commit into one artifact, such as an image. Every later stage uses that artifact; none rebuilds it.
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

three closed toolcases stand side by side on a pale background, the middle one lime and slightly in front, the outer two white with lavender edges; nothing is labelled.
An illustration to help it stick. The mechanism is in the sections above.

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?

Pick one

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?

Pick one

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?

Pick one

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?

Pick one

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?

Pick one

Go deeper

Pro tips
  1. 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 local nats:2.10-alpine image, --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
  2. Shard Playwright with the blob reporter, then join the shards' results with npx 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.

Checked against the tools and docs as of 2026-10-01.