Three apps, each with three or four deployable units. Two rings — preview and live. More than one Kubernetes cluster, run by different teams. The first version of that delivery was what it always is: a .gitlab-ci.yml copied from the last project, edited until green, and never the same twice. A year later no two pipelines agreed on what a branch meant, and a fix in one never reached the others.

What follows is the architecture that replaced it. Names, paths and numbers are illustrative; the shape is the thing.

The shape

ci-blueprintsblueprints/<app>/*.ymla library: it runs no pipeline of its ownorders.gitlab-ci.ymlbilling.gitlab-ci.ymlcatalog.gitlab-ci.ymlinclude · ref: v2.3.0app repositories — each pipeline is a thin includeorders pipeline (on push to candidate / main)imagesreleasedeliveryalertspushcontainer registryorders/api:<sha>docker push :sha, :ringfleet pipeline (runs only when triggered)applyset imagerollout statustriggerROLLOUT_SET · strategy: dependnorthcluster · namespace per ringsouthcluster · namespace per ringkubectl -npullDashed: a reference resolved at pipeline creation, not a run. Blue: the hand-off between the two pipelines.
Three kinds of repository, two pipelines. App repositories include the blueprint library; their pipeline bakes images and hands a rollout set to the fleet pipeline, which is the only thing that talks to the clusters.

There are three kinds of repository and only two pipelines:

RepositoryHoldsIts pipeline
platform/ci-blueprintsThe library: one folder per app, five YAML files eachNone. It is only ever included
orders, billing, …The application code and a .gitlab-ci.yml of a dozen linesBakes images, releases, hands off to the fleet pipeline, alerts
platform/fleetKubernetes manifests, <cluster>/<app>/<ring>/Runs only when triggered. Applies, sets images, waits for the rollout

Nothing in an app repository knows how to reach a cluster. Nothing in the fleet repository knows how to bake an image. The hand-off between them is a single JSON variable, and that boundary is what keeps both sides simple.

The consumer: a dozen lines

orders.gitlab-ci.yml raw
# orders/.gitlab-ci.yml — the whole file
include:
  - project: platform/ci-blueprints
    ref: v2.3.0            # a tag, never main
    file:
      - blueprints/orders/routine.yml
      - blueprints/orders/images.yml
      - blueprints/orders/release.yml
      - blueprints/orders/delivery.yml
      - blueprints/orders/alerts.yml

variables:
  SHIP_WORKER: "true"      # units opt in one at a time

The ref is a tag. main is for trying a change in one app before you cut v2.4.0 and move the others. The file list is explicit on purpose: the names are a contract. Adding a sixth file means every consumer adds a line; renaming one means every consumer breaks — so files are added, never renamed.

The SHIP_* flags are how a new unit, or a new library version, rolls in one piece at a time. A unit whose flag is off has no job at all, not a skipped one.

From five files to a pipeline

the library (blueprints/orders/)routine.ymlstages · branch→ring · retryimages.yml.bake hidden jobrelease.ymldigest, re-tag on maindelivery.yml.deliver hidden jobalerts.ymlalert on failure / successthe consumerorders/.gitlab-ci.ymlinclude:project: ci-blueprintsref: v2.3.0file:- routine.yml- images.yml- release.yml- delivery.yml- alerts.ymlvariables:SHIP_WORKER: "true"four lines of include,one variable block —nothing else.includethe pipeline that materialisesimages · release · delivery · alertscandidate → RING=previewmain → RING=livebake_apiUNIT=apibake_webUNIT=webbake_workerUNIT=workerparallel; worker only if SHIP_WORKERreleasecrane · dotenvone job, all unitsdeliver_northCLUSTER=northdeliver_southCLUSTER=southone trigger per clusteralert_failurewhen: on_failureexpands to× 3 units× 2 clustersDashed: a file pulled in by include. Dashed job: created only when its ship flag is set.
How five blueprint files become a concrete pipeline. Hidden jobs in the library are extended into one job per unit and one per cluster; the consumer's file only lists includes and flags.

routine.yml: what a branch means

routine.yml raw
# blueprints/orders/routine.yml — what a branch means, for every consumer
stages: [images, release, delivery, alerts]

workflow:
  rules:
    - if: '$CI_COMMIT_BRANCH == "candidate"'
      variables: { RING: preview }
    - if: '$CI_COMMIT_BRANCH == "main"'
      variables: { RING: live }
    - when: never              # anything else creates no pipeline

# Infrastructure retries only. script_failure is deliberately
# absent: a real defect would run three times and look flaky.
default:
  retry:
    max: 2
    when:
      - runner_system_failure
      - stuck_or_timeout_failure
      - api_failure
      - scheduler_failure

# Shell library, pulled into jobs with `!reference [.lib, shell]`.
.lib:
  shell: |
    # probe: 0 = present · 1 = absent · 2 = unknown (caller must stop)
    probe() {   # $1=namespace $2=kind $3=name
      for n in 1 2 3; do
        out=$(kubectl -n "$1" get "$2" "$3" -o name 2>&1 >/dev/null) \
          && return 0
        case "$out" in *NotFound*) return 1 ;; esac
        sleep $((n * 5))
      done
      echo "unknown: $2/$3 in $1" >&2; return 2
    }
    attempt() {
      for n in 1 2 3; do "$@" && return 0; sleep $((n * 5)); done
      return 1
    }

Three decisions live here and nowhere else. A branch maps to exactly one ring, and a branch that maps to nothing creates no pipeline — the when: never at the end of the workflow: block is not decoration. Retries cover infrastructure and not code: adding script_failure would rerun a genuinely broken bake three times and hide the defect behind a green badge on the fourth. And the shell library is shared with !reference, so the retry with backoff and the three-state probe are written once. That third state matters: a network flap during kubectl get used to look exactly like “absent”, and a job would carry on with the wrong assumption. Not being able to tell is its own answer, and the caller stops on it.

images.yml: one hidden job, one concrete job per unit

images.yml raw
# blueprints/orders/images.yml — bake one image per unit
.bake:
  stage: images
  image: docker:27
  services: [docker:27-dind]
  rules:
    - if: '$RING == "preview"'          # the live ring never bakes
  variables:
    IMAGE: $CI_REGISTRY_IMAGE/$UNIT
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" \
        --password-stdin "$CI_REGISTRY"
    - docker build -t "$IMAGE:$CI_COMMIT_SHA" \
        --build-arg RING="$RING" \
        -f "$SRC/Dockerfile" "$SRC"
    - docker push "$IMAGE:$CI_COMMIT_SHA"
    - docker tag "$IMAGE:$CI_COMMIT_SHA" "$IMAGE:$RING"
    - docker push "$IMAGE:$RING"

bake_api:
  extends: .bake
  variables: { UNIT: api, SRC: services/api }

bake_web:
  extends: .bake
  variables: { UNIT: web, SRC: web }

bake_worker:
  extends: .bake
  variables: { UNIT: worker, SRC: services/worker }
  rules:
    - if: '$RING == "preview" && $SHIP_WORKER == "true"'

The hidden job does the work; each concrete job is two variables. They run in parallel, since nothing in bake_web depends on bake_api. Every image is pushed twice: as :<sha>, which is what will be rolled out and never moves, and as :preview, a floating pointer to “what the preview ring runs now” that the release step will read.

delivery.yml: the hand-off

delivery.yml raw
# blueprints/orders/delivery.yml — hand the rollout set to the fleet
.deliver:
  stage: delivery
  rules:
    - if: '$RING =~ /^(preview|live)$/'
  trigger:
    project: platform/fleet
    strategy: depend          # wait for the child; inherit its result
    forward: { pipeline_variables: true }
  variables:
    APP: orders
    RING: $RING
    ORIGIN_SHA: $CI_COMMIT_SHA
    ROLLOUT_SET: |
      [
        {"unit":"api",    "container":"api",    "image":"$API_IMAGE"},
        {"unit":"web",    "container":"web",    "image":"$WEB_IMAGE"},
        {"unit":"worker", "container":"worker", "image":"$WORKER_IMAGE"}
      ]

deliver_north: { extends: .deliver, variables: { CLUSTER: north } }
deliver_south: { extends: .deliver, variables: { CLUSTER: south } }

strategy: depend makes the parent wait for the child and fail if it fails — the app pipeline’s badge tells the truth about the rollout, not just the bake. One delivery job per cluster is the whole multi-cluster story: same fleet repository, same variables, different CLUSTER.

ROLLOUT_SET is the contract across the boundary: which unit, which container name inside the pod, which image. The fleet side needs nothing else to update a deployment.

Live is a release, not a rebuild

branch candidate → preview ringbranch main → live ringbake3 unitspush:<sha> · :previewtriggerset :<sha>roll outpreviewno bakestage skippedreleasecrane tagtriggerset @digestroll outlivesame digest:preview → :liveThe live ring never bakes. It releases the image that already ran in preview, by digest, and rolls that out.
Preview bakes; live releases. The main branch re-tags the preview digest and rolls it out — the bytes that ran in preview are the bytes that go live.
release.yml raw
# blueprints/orders/release.yml
# On main it re-tags preview as live; on candidate it only resolves digests.
release:
  stage: release
  image: gcr.io/go-containerregistry/crane:debug
  script:
    - |
      for unit in api web worker; do
        img="$CI_REGISTRY_IMAGE/$unit"
        if [ "$RING" = "live" ]; then
          digest=$(crane digest "$img:preview")   # what preview runs now
          crane tag "$img@$digest" live           # same bytes, new name
        else
          digest=$(crane digest "$img:$CI_COMMIT_SHA")
        fi
        key=$(echo "$unit" | tr a-z A-Z)
        echo "${key}_IMAGE=$img@$digest" >> images.env
      done
  artifacts:
    reports: { dotenv: images.env }   # *_IMAGE reach the delivery jobs

The main branch skips the bake stage entirely. Rebuilding from the same commit would probably produce the same image; releasing by digest produces the same image by definition. The dotenv report is how the digests reach the delivery jobs — GitLab expands $API_IMAGE inside ROLLOUT_SET before it hands the variable to the child pipeline.

The fleet pipeline

Manifests live per cluster, per app, per ring, with a common/ folder for the ConfigMap and Secret every unit reads. Namespaces follow one convention, <app>-<ring>, so nobody has to declare them:

platform/fleet/
└── north/
    └── orders/
        ├── common/
        │   ├── preview/      configmap.yaml  secret.yaml
        │   └── live/
        ├── preview/          api.yaml  web.yaml  worker.yaml
        └── live/
fleet.gitlab-ci.yml raw
# platform/fleet/.gitlab-ci.yml — the only pipeline that touches a cluster
workflow:
  rules:
    - if: '$CI_PIPELINE_SOURCE == "pipeline"'   # an app pipeline fired it
    - if: '$CI_PIPELINE_SOURCE == "web"'        # or someone pressed Run
    - when: never

rollout:
  stage: rollout
  image: bitnami/kubectl:1.31
  resource_group: $CLUSTER/$APP/$RING   # one rollout at a time per target
  script:
    - |
      set -euo pipefail
      # one file-type variable per cluster: KUBECONFIG_NORTH, ...
      eval "export KUBECONFIG=\$KUBECONFIG_$(echo "$CLUSTER" | tr a-z A-Z)"
      NS="$APP-$RING"                       # namespaces follow one convention
      DIR="$CLUSTER/$APP/$RING"

      kubectl -n "$NS" apply -f "$CLUSTER/$APP/common/$RING/"
      kubectl -n "$NS" apply -f "$DIR/"

      echo "$ROLLOUT_SET" \
        | jq -c '.[] | select(.image | test("@sha256"))' \
        | while read -r entry; do
            unit=$(jq -r .unit <<<"$entry")
            container=$(jq -r .container <<<"$entry")
            image=$(jq -r .image <<<"$entry")
            kubectl -n "$NS" set image deployment \
              -l "app=$APP,unit=$unit" "$container=$image"
          done

      for d in $(kubectl -n "$NS" get deploy -l "app=$APP" -o name); do
        kubectl -n "$NS" rollout status "$d" --timeout=10m || {
          kubectl -n "$NS" describe "$d"
          kubectl -n "$NS" logs "$d" --all-containers --tail=100 || true
          exit 1
        }
      done

Four things are doing the real work here.

It only runs when triggered. A push to the fleet repository changes files and nothing else; the cluster changes when an app pipeline says so, with a rollout set attached.

apply and set image do not fight. The versioned manifest says image: …/api:preview; the live object ends up with …/api@sha256:…. The next apply looks like it should revert that — it does not. Client-side apply patches only the fields that changed between the last-applied manifest and the new one, and the image line is identical in both, so the digest set by the pipeline survives. Break that premise by editing the tag in the manifest and you get a second rollout per delivery.

resource_group serialises per target. Two pipelines for the same app and ring queue instead of racing. The group key includes the app so that orders and billing on the same cluster still roll out in parallel.

A failed rollout explains itself. describe and the last hundred lines of logs go into the job output before it exits red — the person who opens the job at 2 a.m. should not need cluster access to see why.

Telling someone

alerts.yml raw
# blueprints/orders/alerts.yml
alert_failure:
  stage: alerts
  image: alpine:3.20
  rules:
    - when: on_failure
  script:
    - apk add --no-cache curl jq >/dev/null
    - |
      [ -n "${SLACK_WEBHOOK_URL:-}" ] \
        || { echo "no SLACK_WEBHOOK_URL; skipping"; exit 0; }
      title=":red_circle: orders — failed on $CI_COMMIT_REF_NAME"
      msg=$(printf '%s' "$CI_COMMIT_MESSAGE" | head -c 300)
      PAYLOAD=$(jq -n --arg title "$title ($RING)" \
        --arg who "${GITLAB_USER_NAME:-?}" --arg sha "$CI_COMMIT_SHORT_SHA" \
        --arg msg "$msg" --arg url "$CI_PIPELINE_URL" '{
          blocks: [
            { type: "header",
              text: { type: "plain_text", text: $title } },
            { type: "section", fields: [
                { type: "mrkdwn", text: ("*Author*\n" + $who) },
                { type: "mrkdwn", text: ("*Commit*\n`" + $sha + "`") } ] },
            { type: "section",
              text: { type: "mrkdwn", text: $msg } },
            { type: "actions", elements: [
                { type: "button", url: $url,
                  text: { type: "plain_text", text: "Open pipeline" } } ] }
          ] }')
      curl -sS -o /dev/null -w "slack %{http_code}\n" -X POST \
           -H 'Content-type: application/json' \
           -d "$PAYLOAD" "$SLACK_WEBHOOK_URL"

On failure, always: branch, ring, author, commit, one button. On success, one line from the rollout job with the digest that went live:

alert-success.sh raw
# end of the fleet rollout job — success only; a failure exited above
digest=$(kubectl -n "$NS" get deploy -l "app=$APP,unit=api" \
  -o jsonpath='{.items[0].spec.template.spec.containers[0].image}' \
  | sed 's/.*@sha256://' | head -c 12)
jq -n --arg t ":large_green_circle: orders → $RING on $CLUSTER" \
      --arg d "api @ $digest…" --arg u "$CI_JOB_URL" \
  '{blocks:[{type:"section",text:{type:"mrkdwn",
     text:("*"+$t+"*\n"+$d+"  <"+$u+"|job>")}}]}' \
  | curl -sS -o /dev/null -X POST -H 'Content-type: application/json' \
         -d @- "$SLACK_WEBHOOK_URL" || true

The webhook URL is a masked, protected CI/CD variable on the consumer project; a project without one simply logs that it skipped the alert.

Where each setting lives

SettingWhereWhy there
Branch → ringroutine.yml in the libraryOne truth for every app
Registry credentialsGitLab’s own CI_REGISTRY_*Never typed anywhere
Kubeconfig per clusterFile-type variable on platform/fleet, protectedOnly the fleet pipeline can reach a cluster
SLACK_WEBHOOK_URLMasked variable on each consumerEach app owns its channel
SHIP_<UNIT>variables: in the consumer’s fileVisible in the diff that turns a unit on
Library versionref: in the consumer’s includeUpgrades are a reviewed commit, not a surprise

What bit us, so it does not bite you

SymptomCauseRule
Rollout green, image unchangedNew unit added to images.yml and ROLLOUT_SET but the manifest lacks the unit: label, so set image -l matched nothingAdding a unit touches three places: bake job, rollout-set entry, manifest label
Five units rolling out one after anotherresource_group without the app in its keyThe key names exactly what must not overlap
A failed command, no error, job continuesOUT=$(cmd 2>&1) under set -e: the assignment swallows the exit statusUse the command as the condition of an if, capture output inside
A flaky bake “fixed” by retryscript_failure in default.retryRetry infrastructure, never code
A step skipped because a Deployment “was absent” — it was notNetwork flap indistinguishable from NotFoundThree states, stop on the third
Clone of a 30 MB repo to run kubectl applyDefault GIT_STRATEGY on a job that never reads the treeGIT_STRATEGY: none when the working tree is not needed

None of this is exotic GitLab. It is include, hidden jobs, workflow: rules, one multi-project trigger and a JSON variable — arranged so that adding the fourth app is a folder in the library and a dozen lines in the app, and so that the live ring only ever runs bytes that preview already ran.