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:
- runner - GitLab's agent;
tags:on a job picks which runners may run it. - needs - "start this job as soon as those jobs finish", instead of waiting for the whole previous stage. Jobs linked by
needsform a DAG (directed acyclic graph: a set of arrows with no loops). - rules - a list of
if:tests deciding whether a job is in the pipeline. $CI_...- GitLab's predefined variables, plain environment variables ($CI_COMMIT_SHA= the commit,$CI_DEFAULT_BRANCH= usuallymain).- include / extends / !reference - GitLab's ways to reuse YAML from other files or other jobs.
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
- Script lines fail fast. Each line of
script:is run and checked; the first non-zero exit fails the job. The "green build, red tests" bug from earlier does not happen - but./mvnw verify | tee logstill hides a failure (the line's status istee's) unless the shell haspipefail. - rules are evaluated when the pipeline is created.
rules: - if:decides whether a job exists at all; a job whose rules match nothing is not in the pipeline (andneeds:on it is an error unlessneeds: optional: true). when: manualjobs are allowed to fail by default inrules-less jobs, and block the pipeline when defined insiderules. Protected environments with required approvals are the stricter, auditable form.- Cache vs artifacts: cache is best effort, shared between pipelines, for dependencies; artifacts are guaranteed, belong to one pipeline, and are the way files move between jobs.
- Variables: CI/CD variables can be masked (hidden in logs, needs 8+ characters), protected (only exposed on protected branches and tags - so a feature branch pipeline cannot read the prod token), and of type file (the variable holds a path to a temp file with the value - perfect for a kubeconfig).
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
- Translate an Azure Pipelines YAML into
.gitlab-ci.yml(stages, needs, rules, cache, artifacts, environments). - Name the differences that change behaviour: per-line fail-fast, rules at creation time, protected variables.
- Read a Jenkinsfile well enough to say what it builds and where it asks for approval.