OnCallReady

Lesson 25.14 · CI/CD Pipelines & Helm · 12 min read

The same pipeline in GitLab CI (and what Jenkins looks like)

In plain words

Imagine two schools that play the same board game with different rule books. One calls the pieces "knights", the other "horses"; one says you lose your turn on a wrong move, the other lets you finish the turn first. Once you know the game, switching schools only means learning the new words and the few rules that really differ.

GitLab CI is the same game as Azure Pipelines with other words: .gitlab-ci.yml instead of azure-pipelines.yml, every top-level key is a job, needs: instead of dependsOn, rules: instead of condition, tags: picks a runner. The rules that really differ: each script: line fails the job immediately, and rules decide at pipeline creation whether a job exists. Jenkins is the older cousin with a Groovy Jenkinsfile.

The problem

Not every team uses Azure Pipelines. GitLab CI is the other system you will meet most (it comes with GitLab, the git server git.lab imitates), and job ads still mention Jenkins. The good news: the ideas are the same, only the words change. This lesson is a translation table plus the differences that bite.

What you need to know already: 25.4 (stages, jobs, steps, triggers, conditions), 25.7 (variables and secrets), 25.9 (templates and caching), 25.11 (environments and approvals).

One file: .gitlab-ci.yml

GitLab reads .gitlab-ci.yml from the commit, like Azure DevOps. The vocabulary maps almost one to one (read each row as "left in Azure Pipelines = right in GitLab"):

  Azure Pipelines                      GitLab CI
  -----------------------------------  ----------------------------------------------
  stages: - stage: Build               stages: [build, test, deploy]   (just names)
  jobs: - job: build                   build:            (every top-level key = a job)
    steps: - script: ...                 script: [...]
  dependsOn / condition                 needs: [job] (a DAG) / rules: - if:
  pool: oncall-lab                    tags: [oncall-lab]   (which runner)
  variables / variable groups           variables: / CI/CD variables (project, group)
  secret variable                       masked (and protected) variable
  $(Build.SourceVersion)                $CI_COMMIT_SHA   (plain environment variables)
  publish / download (artifacts)        artifacts: paths:  (downloaded by later jobs)
  Cache@2                               cache: key: files: [pom.xml]  paths: [...]
  template: / extends:                  include: + extends: / !reference
  deployment job + environment          environment: name: prod
  environment approval check            protected environment + deployment approval,
                                        or when: manual
  Build.BuildId                         CI_PIPELINE_ID / CI_JOB_ID

A few GitLab words from the table:

The pipeline, ported

Every top-level key that is not a GitLab keyword is a job. default: before_script: runs before every job's script. artifacts: expire_in says how long GitLab keeps them. docker login ... --password-stdin logs in to the registry reading the password from standard input, so it never appears on the command line.

stages: [build, package, deploy]

variables:
  VERSION: "1.5.1"

default:
  before_script:
    - echo "pipeline $CI_PIPELINE_ID on $CI_COMMIT_REF_NAME"

build:
  stage: build
  script:
    - ./mvnw -B verify -Dmaven.repo.local=.m2/repository
  cache:
    key:
      files: [pom.xml]          # key changes when pom.xml changes
    paths: [.m2/repository]
  artifacts:
    paths: [target/]            # handed to later jobs
    expire_in: 1 week

docker_image:
  stage: package
  needs: [build]                # start as soon as build finishes, gets its artifacts
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
  script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u "$CI_REGISTRY_USER" --password-stdin
    - docker build -t $CI_REGISTRY_IMAGE:$VERSION .
    - docker push $CI_REGISTRY_IMAGE:$VERSION

deploy_prod:
  stage: deploy
  needs: [docker_image]
  environment:
    name: prod
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual              # a person presses play
  script:
    - ./deploy.sh prod

Differences that bite

The runner log

The runner prints its version and name, the executor (how it runs jobs: shell = directly in a shell on the runner machine), the checkout, then each script line prefixed with $:

Running with gitlab-runner 18.4.0 (139a0ac0)
  on oncall-lab Kd3fT9qW, system ID: s_...
Preparing the "shell" executor
Using Shell (bash) executor...
...
Checking out 9c41e7a2 as detached HEAD (ref is main)...
Executing "step_script" stage of the job script
$ ./mvnw -B verify
...
Uploading artifacts for successful job
Job succeeded

A failing line ends with ERROR: Job failed: exit status 1. The lab runner uses the shell executor, so image: in a job is ignored; on a Docker or Kubernetes executor each job runs in that image.

One naming trap: image, services, variables, default, stages, include, workflow, cache and friends are global keywords, not job names. A job called image: is read as the default image for every job, so needs: [image] fails with 'deploy_dev' job needs 'image' job, but 'image' does not exist in the pipeline. Name it docker_image or package.

Jenkins and Nexus, for the job ads

Romanian banking ads still ask for them constantly. Jenkins is the older, self-hosted CI server; a Jenkinsfile in declarative syntax looks like this:

pipeline {
  agent { label 'maven' }
  stages {
    stage('Build') { steps { sh './mvnw -B verify' } }
    stage('Image') {
      when { branch 'main' }
      steps {
        withCredentials([usernamePassword(credentialsId: 'registry', usernameVariable: 'U', passwordVariable: 'P')]) {
          sh 'echo "$P" | docker login registry.lab -u "$U" --password-stdin'
          sh 'docker build -t registry.lab/pay/payments-api:1.5.1 . && docker push registry.lab/pay/payments-api:1.5.1'
        }
      }
    }
    stage('Prod') {
      input { message 'Deploy to prod?' }
      steps { sh './deploy.sh prod' }
    }
  }
}

Jenkins pipelines are written in Groovy (a programming language for the Java VM); sh runs a shell command, when { branch } is a condition, withCredentials injects a stored credential into one block. Same ideas: stages, an agent label, credentials injected into one block, a manual input gate. Nexus (Sonatype Nexus Repository) is the artifact repository many banks run: Maven artifacts, npm packages, and often Docker images too. Where this lab says "registry.lab", those shops say "Nexus"; the promotion rules are the same.

What you can now do

Why it helps

Job ads and interviews name tools you have not used. Being able to say "our Azure stage-with-approval is a protected environment in GitLab, and our variable group is a group-level CI/CD variable" shows you understand the concepts, not the vendor. If your next team runs GitLab, the port of payments-api's pipeline in this lesson is almost exactly what you would write in week one.

The differences are where real bugs live. A job named image: becomes the global default image, and needs: [image] fails. A when: manual job inside rules blocks the pipeline while outside rules it is allowed to fail. Protected variables are how you stop a feature branch from reading the prod token. And Jenkins plus Nexus still appear in many Romanian banking ads, so recognising a declarative Jenkinsfile matters.

FAQ

What is the difference between cache and artifacts in GitLab CI?

Cache is best effort and meant for dependencies (.m2, node_modules): shared across pipelines, keyed (for example on pom.xml), and may be missing. Artifacts are guaranteed outputs of a job, stored by GitLab for expire_in, and automatically downloaded by later jobs in the pipeline or those listed in needs:. Files that must move between jobs, such as the built jar, are artifacts.

What does needs: do compared with stages?

Without needs, a job waits for every job in all previous stages. With needs: [build], the job starts as soon as build finishes, regardless of stage order, and downloads only that job's artifacts. That turns the pipeline into a DAG and can cut total time a lot. If the needed job does not exist because its rules excluded it, the pipeline fails to create unless the need is marked optional: true.

What are protected and masked variables?

A masked variable is hidden in job logs; the value must meet requirements such as at least 8 characters. A protected variable is only exposed to pipelines running on protected branches or tags, so a feature branch pipeline cannot read the prod deploy token at all. Protected is the stronger control; masking only prevents accidental printing. File-type variables put the value in a temp file and give you its path, handy for a kubeconfig.

Why does a job with image: do nothing on the lab runner?

The lab runner uses the shell executor, which runs jobs directly on the host as the runner's user, so image: is ignored. The Docker and Kubernetes executors run each job in the specified image, which is the common setup. The executor matters for what tools exist, what state survives between jobs, and security: a shell executor shares the host with every job.

Do I still need to learn Jenkins?

Enough to read a Jenkinsfile and talk about it. Jenkins is still common in banks and older shops: a self-hosted server, plugins for everything, pipelines in Groovy with stages, agent labels, withCredentials blocks and input gates. The concepts map to what you know. Its operational burden (plugin upgrades, a stateful controller) is why many teams moved to GitLab, GitHub Actions or Azure DevOps.

In an interview Mid

Compare Azure Pipelines and GitLab CI. What changes when you move between them?

The ideas are the same - stages, jobs, an agent/runner, variables, artifacts, environments with approvals - only the words change. In .gitlab-ci.yml every top-level key that is not a keyword is a job; stages: is just a list of names; needs: starts a job as soon as its inputs finish; rules: - if: decides whether a job exists; predefined variables are $CI_... environment variables.

The differences that bite:

Jenkins: a Groovy Jenkinsfile with the same stages, agent label, withCredentials and an input gate.

Also asked: How do you prevent a feature-branch pipeline in GitLab from deploying to production? · Your GitLab job shows green but the Maven tests failed. What could cause it? · What is the difference between cache and artifacts in GitLab CI?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.