By the end you'll know
- The parts every pipeline has, whatever the tool
- How to read a GitHub Actions workflow and a declarative Jenkinsfile
- Why every GitHub job starts by checking out the code, and Jenkins stages don't
- The default that's opposite in each tool: running side by side, or in order
- How the same ideas look in GitLab CI/CD
Where you are
Lesson 1 was the why: catch problems early, on a clean machine, through the same gates every time. This lesson opens the files that make it happen. You'll read GitHub Actions and Jenkins side by side, then run the same small pipeline in each: Jenkins in a Docker container on your own computer, and GitHub Actions in a throwaway repository.
You'll need Docker running, the GitHub CLI (gh) signed in, and about half an hour.
The parts every pipeline has
Strip away the tool names, and every pipeline file answers the same five questions:
- When does it run? The trigger: a push, a pull request, a tag, a schedule, or someone pressing a button.
- Where does it run? The machine: a fresh virtual machine from GitHub, or an agent you look after.
- What does it do? The steps: shell commands and ready-made pieces, like "check out the code".
- How are the steps grouped? Into jobs (GitHub) or stages (Jenkins), which show up as separate boxes in the tool's page.
- In what order? Which groups wait for which, and which run side by side. This is where the gates from Lesson 1 live.
Hold on to those five, because the rest of this lesson is the same five answers written two ways.
Couriers or one van
That difference explains most of what looks strange when you first compare the two files, so keep it in mind as you read them.
A GitHub Actions workflow
A workflow is a YAML file in .github/workflows/ in your repository. Here's a small one, shaped like CivicGrid's real pipeline but with echo in place of the real work, so you can see the structure without the detail. It's the one you'll run later:
name: First pipeline
on:
push:
branches: [main]
workflow_dispatch:
jobs:
where-am-i:
runs-on: ubuntu-24.04
steps:
- run: echo "Running on $(hostname) as $(whoami) in $(pwd)"
lint:
needs: where-am-i
runs-on: ubuntu-24.04
steps:
- run: echo "Lint would run here"
tests:
needs: where-am-i
runs-on: ubuntu-24.04
steps:
- run: echo "Tests would run here"
helm:
needs: where-am-i
runs-on: ubuntu-24.04
steps:
- run: echo "Helm check would run here"
build:
needs: [lint, tests, helm]
runs-on: ubuntu-24.04
steps:
- run: echo "Would build an image for run ${{ github.run_number }}"
Reading it against the five questions:
on:is when. This one runs on every push tomain, andworkflow_dispatchadds a Run workflow button in the Actions tab, so you can start it by hand.runs-on:is where, set on every job, because every job gets its own machine.ubuntu-24.04asks for a GitHub runner with that version of Ubuntu.steps:is what. Eachrun:is a shell command. The other kind of step,uses:, pulls in a ready-made action someone else wrote, which you'll see in a moment.jobs:is the grouping. Each key under it, likelintorbuild, is a job, and its name is what you'll see in GitHub's page.needs:is the order. Without it, jobs run side by side.lint,testsandhelmeach needwhere-am-i, so they wait for it, then run at the same time.buildneeds all three, so it's the gate.
${{ github.run_number }} is an expression: GitHub fills it in before the step runs. Here it's the run's number, which goes up by one each time the workflow runs.
The same parts in CivicGrid's real file
Here's how CivicGrid's ci.yml answers the same questions, trimmed to the lines that matter here:
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- run: pip install ruff
- run: ruff check app/
build-and-push:
runs-on: ubuntu-24.04
needs: [lint, test, helm-lint]
Two things to notice. The trigger has three parts: pushes to main, tags starting with v, and pull requests into main. And every job starts with uses: actions/checkout@v7. That's the courier problem: each job starts on an empty machine, so the first thing it does is fetch the code. @v7 pins the action to a version, the same idea as pinning a Docker image tag in the Docker track.
Each trigger leads to different results, and it's easy to lose track of which. Pick an event and see what the real file does with it:
A Jenkinsfile
A Jenkinsfile describes the same pipeline in Jenkins. It's written in a declarative syntax built on Groovy, a programming language that runs on Java, but you don't need to know Groovy to read or write it: it's mostly nested blocks in curly braces. Here's the same pipeline:
pipeline {
agent any
stages {
stage('Where am I') {
steps {
sh 'echo "Running on $(hostname) as $(whoami) in $(pwd)"'
}
}
stage('Checks') {
parallel {
stage('Lint') {
steps { echo 'Lint would run here' }
}
stage('Tests') {
steps { echo 'Tests would run here' }
}
stage('Helm') {
steps { echo 'Helm check would run here' }
}
}
}
stage('Build') {
steps {
echo "Would build an image for build ${env.BUILD_NUMBER}"
}
}
}
post {
success { echo 'Every stage passed' }
failure { echo 'Something failed, so later stages were skipped' }
}
}
The same five questions:
- When: not in this file at all. Jenkins can't see your repository by itself; something has to tell it a commit happened. In real setups that's a webhook from GitHub, or Jenkins checking the repository on a timer, or a multibranch pipeline that finds every branch and pull request on its own. For this lesson you'll press Build Now.
- Where:
agent anymeans "on any machine Jenkins has available". It's set once, at the top, for the whole pipeline: one van. - What:
stepsholds the commands.shruns a shell command andechoprints a message. - Grouping: each
stage('…')is a group with a name, which shows as a column in Jenkins' page. - Order: stages run one after another, top to bottom. To run some side by side, you wrap them in a
parallelblock, which is exactly whatChecksdoes.
post is something the GitHub file doesn't have: steps that run after everything else, depending on how it went. success runs only if everything passed, failure only if something failed. There's also always, which runs either way and is the usual place for cleanup.
Notice the strings, too. 'single quotes' are taken literally, while "double quotes" let Jenkins fill in values like ${env.BUILD_NUMBER}, the build's number. In the sh step, the outer single quotes mean Jenkins leaves $(hostname) alone, so the shell gets to run it. Mixing these up is one of the most common Jenkinsfile mistakes.
The default that's opposite
Put the two files next to each other and one difference stands out:
- In GitHub Actions, jobs run side by side unless
needs:says to wait. - In Jenkins, stages run in order unless a
parallelblock says otherwise.
Both pipelines above end up doing the same thing, but they get there from opposite ends. That matters when you move a pipeline from one tool to the other: forget a needs: when translating from Jenkins, and your build no longer waits for your tests. That's the "deploy that didn't wait" from Lesson 1's Spot the problem.
The other difference is the couriers and the van. GitHub jobs each start on a fresh machine, which is why each one checks out the code, and why handing a file from one job to the next takes an explicit upload and download, called an artifact. Jenkins stages share one workspace, a folder on the agent, so a file made in one stage is still there in the next. That's handy, and it's also how leftovers from one build can leak into the next if nothing cleans up, which is the "works on my machine" problem coming back in through the side door. Lesson 9 covers keeping agents clean.
Start Jenkins in Docker
Time to run both. Jenkins first. On your laptop, with Docker running:
docker run -d --name jenkins -p 127.0.0.1:8080:8080 -v jenkins_home:/var/jenkins_home --restart=on-failure jenkins/jenkins:lts-jdk21
jenkins/jenkins:lts-jdk21is the official image: the long-term support (LTS) release, running on Java 21.-v jenkins_home:/var/jenkins_homekeeps everything Jenkins stores, its settings, jobs and plugins, in a Docker volume, so it survives the container being removed.-p 127.0.0.1:8080:8080publishes the web page on port 8080, but only on your own computer. Without the127.0.0.1:part, anyone on your Wi-Fi could reach it. That's the firewall habit from the Networking track: don't open more than you need.
Jenkins' own instructions also publish port 50000, which is for agents on other machines to connect in. You don't have any yet, so it stays closed.
If the port is already taken
When I first ran this, Docker refused:
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint jenkins (03237f25d3127cff7508419bda290091048de71b00b2405d96ecef8097b5db3b): Bind for 0.0.0.0:8080 failed: port is already allocated
Only one thing at a time can listen on a port, and something on my laptop already had 8080. Two commands find out what:
docker ps --format '{{.Names}}\t{{.Ports}}' | grep 8080
lsof -nP -iTCP:8080 -sTCP:LISTEN
The first lists running containers and their ports; the second asks the operating system which program is listening on 8080. For me it was a WordPress container from another project, published as 0.0.0.0:8080->80, and lsof showed Docker holding the port on every network (*:8080). That second detail was its own small finding: anyone on the same Wi-Fi could have reached that site. I didn't need the project any more, so I removed it, which freed the port. If you need whatever is on 8080, adding 127.0.0.1: to its port line closes it to your network, and you can give Jenkins another port instead.
Either way, Docker had still created the jenkins container, just without starting it, so remove it before trying again. To use a different port, change only the number on your side; inside the container it's still 8080, and only the door into it changes:
docker rm jenkins
docker run -d --name jenkins -p 127.0.0.1:8090:8080 -v jenkins_home:/var/jenkins_home --restart=on-failure jenkins/jenkins:lts-jdk21
If you do this, use http://localhost:8090 everywhere this lesson says 8080.
Jenkins takes a minute to start the first time. It makes a one-time password to unlock the setup page, which you can read from inside the container:
docker exec jenkins cat /var/jenkins_home/secrets/initialAdminPassword
Treat that like any password: paste it into the setup page and nowhere else. Then open http://localhost:8080 and:
- Paste the password to unlock Jenkins.
- Choose Install suggested plugins, and wait while it installs them. These include the Pipeline plugins that read Jenkinsfiles, and Git.
- Create your admin user. Use a real password: this account can run any command inside the container.
- Keep the suggested Jenkins URL,
http://localhost:8080/, and finish.
Run your first Jenkins pipeline
- On the dashboard, choose New Item.
- Name it
first-pipeline, choose Pipeline, and select OK. - Scroll down to the Pipeline section. Leave Definition on Pipeline script, and paste the Jenkinsfile from above into the Script box.
- Select Save, then Build Now.
A build appears under Build History. Open it, then Console Output, to see everything it printed. The part to look for is the Where am I stage:
Here's the start and end of mine, with the stages in between left out:
Started by user Kwami
[Pipeline] Start of Pipeline
[Pipeline] node
Running on Jenkins
in /var/jenkins_home/workspace/first-pipeline
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Where am I)
[Pipeline] sh
+ hostname
+ whoami
+ pwd
+ echo Running on 77b2592d84f4 as jenkins in /var/jenkins_home/workspace/first-pipeline
Running on 77b2592d84f4 as jenkins in /var/jenkins_home/workspace/first-pipeline
[Pipeline] { (Build)
[Pipeline] echo
Would build an image for build 1
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Declarative: Post Actions)
[Pipeline] echo
Every stage passed
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
Finished: SUCCESS
Reading it from the top:
Running on Jenkinsnames the machine the build ran on.Jenkinshere is the built-in node: the controller itself, doing the work. That's fine for a first try, but real setups don't build on the controller, because a build can then reach everything Jenkins stores. Lesson 9 moves the work onto a separate agent./var/jenkins_home/workspace/first-pipelineis the workspace: a folder named after the job, which every stage shares. That's the van.- The lines starting with
+are the shell showing each command just before it runs. Jenkins turns this on forshsteps, and it's very handy when a step fails, because the last+line tells you which command it was on. 77b2592d84f4is the hostname, which inside a Docker container is the container's own short ID.jenkinsis the user the container runs as.Would build an image for build 1shows the double-quoted string at work: Jenkins filled in${env.BUILD_NUMBER}, and this was build 1.Declarative: Post Actionsis thepostblock, running itssuccesssection, andFinished: SUCCESSis the result for the whole build.
The part in between, which isn't shown, has the three checks starting under parallel as Branch: Lint, Branch: Tests and Branch: Helm, with their lines mixed together because they ran at the same time.
Pasting the script into the job is the quickest way to try something, but it isn't how real teams work: the pipeline lives in Jenkins, not in your repository, so it has no history and no review. In real setups you choose Pipeline script from SCM, short for source control management, and Jenkins reads the Jenkinsfile from the repository, just as GitHub reads .github/workflows. That's pipeline as code, and it's where Lesson 3 goes.
Run the same pipeline in GitHub Actions
Now the couriers. Make a throwaway private repository for this track's experiments, so nothing here goes near CivicGrid:
cd ~/Apps
gh repo create cicd-sandbox --private --clone
cd cicd-sandbox
mkdir -p .github/workflows
Now create the workflow file. This writes the workflow from above into .github/workflows/first.yml:
cat > .github/workflows/first.yml <<'EOF'
name: First pipeline
on:
push:
branches: [main]
workflow_dispatch:
jobs:
where-am-i:
runs-on: ubuntu-24.04
steps:
- run: echo "Running on $(hostname) as $(whoami) in $(pwd)"
lint:
needs: where-am-i
runs-on: ubuntu-24.04
steps:
- run: echo "Lint would run here"
tests:
needs: where-am-i
runs-on: ubuntu-24.04
steps:
- run: echo "Tests would run here"
helm:
needs: where-am-i
runs-on: ubuntu-24.04
steps:
- run: echo "Helm check would run here"
build:
needs: [lint, tests, helm]
runs-on: ubuntu-24.04
steps:
- run: echo "Would build an image for run ${{ github.run_number }}"
EOF
The quotes around 'EOF' stop your own shell from filling in $(hostname) and the others while writing the file, so they run later, on GitHub's machine. Check it with cat .github/workflows/first.yml, then commit and push it:
git add .github/workflows/first.yml
git commit -m "First pipeline"
git branch -M main
git push -u origin main
git branch -M main makes sure the branch is called main, whatever your Git calls new branches. The push starts the workflow. This pipeline is short, so give it about half a minute, then check on it and read what each job printed:
gh run list --limit 1
gh run view --log
gh run list shows the latest run and whether it's finished. gh run view --log asks which run you mean, then prints every job's log. If it says the run is still in progress, wait a few seconds and try again. I first tried gh run watch straight after the push, and it said there was nothing to watch: GitHub hadn't registered the run yet, and by the time I tried again it had already finished.
The log is long, because every job starts with a few dozen lines describing the machine it got. Here are the lines that matter, from my run:
where-am-i Set up job 2026-10-25T06:53:45.2069338Z Region: westus2
where-am-i Run echo "Running on $(hostname) as $(whoami) in $(pwd)" 2026-10-25T06:53:45.6611033Z Running on runnervmtai0g as runner in /home/runner/work/cicd-sandbox/cicd-sandbox
tests Set up job 2026-10-25T06:53:51.8227034Z Region: centralus
lint Set up job 2026-10-25T06:53:52.1248238Z Region: westus3
helm Set up job 2026-10-25T06:53:52.4089192Z Region: centralus
build Run echo "Would build an image for run 1" 2026-10-25T06:53:59.1362800Z Would build an image for run 1
Each line starts with the job, then the step, then a timestamp in UTC. Compare it with Jenkins:
- A different machine every time. Jenkins ran as
jenkins, inside its container, in a workspace named after the job. GitHub ranwhere-am-ias therunneruser onrunnervmtai0g, a virtual machine in Microsoft's Azure cloud, in thewestus2region. In this run,testsandhelmgot machines incentralusandlintone inwestus3. Each job set up its own machine, which is the couriers, made literal. - Side by side, then the gate.
where-am-ifinished at 06:53:45.tests,lintandhelmall started within a second of each other, about six seconds later, which is the time it takes to hand each job a fresh machine.buildstarted at 06:53:58, once all three had finished. - Expressions are filled in first. The build step's name already says
run 1: GitHub replaced${{ github.run_number }}before the step ran. In thewhere-am-istep's name,$(hostname)is still written out, because that's left for the shell on the runner. GitHub's${{ }}and the shell's$( )look alike, but happen at different times on different machines.
Two more lines in each job's setup are worth a look in your own log: shell: /usr/bin/bash -e {0}, which means each run: step stops at the first command that fails, and a block headed GITHUB_TOKEN Permissions, which for this run said Contents: read, Metadata: read and Packages: read. That token, and keeping it to the least it needs, is Lesson 5.
Same pipeline, different machinery.
Break it on purpose
Now watch the gates work in both tools, like the animation in Lesson 1.
In Jenkins, open first-pipeline, choose Configure, and change the Tests stage so it fails:
stage('Tests') {
steps { sh 'exit 1' }
}
exit 1 ends the shell with a failure code, which is how a failing test run looks to Jenkins. Save, then Build Now.
Here's the part of mine from the parallel block onwards:
[Pipeline] { (Checks)
[Pipeline] parallel
[Pipeline] { (Branch: Lint)
[Pipeline] { (Branch: Tests)
[Pipeline] { (Branch: Helm)
[Pipeline] stage
[Pipeline] { (Lint)
[Pipeline] stage
[Pipeline] { (Tests)
[Pipeline] stage
[Pipeline] { (Helm)
[Pipeline] echo
Lint would run here
[Pipeline] }
[Pipeline] sh
[Pipeline] echo
Helm check would run here
[Pipeline] }
[Pipeline] // stage
[Pipeline] // stage
[Pipeline] }
[Pipeline] }
+ exit 1
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
Failed in branch Tests
[Pipeline] // parallel
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Build)
Stage "Build" skipped due to earlier failure(s)
[Pipeline] getContext
[Pipeline] }
[Pipeline] // stage
[Pipeline] stage
[Pipeline] { (Declarative: Post Actions)
[Pipeline] echo
Something failed, so later stages were skipped
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // node
[Pipeline] End of Pipeline
ERROR: script returned exit code 1
Finished: FAILURE
The lines from the three branches are mixed together, because they really did run at the same time: Lint and Helm printed their messages while the Tests branch was still starting its sh step. Then, in order:
+ exit 1is the shell showing the command it ran, which is how you'd find the failing command in a real build.Failed in branch Testssays which part of the parallel block failed. Lint and Helm still finished normally, because nothing told them to stop.Stage "Build" skipped due to earlier failure(s)is the gate. Build was never run.Something failed, so later stages were skippedis thepostblock'sfailuresection, which only runs in a build like this one.ERROR: script returned exit code 1andFinished: FAILUREare the result. On the job page, this build shows in red.
In GitHub Actions, make the same change in first.yml:
tests:
needs: where-am-i
runs-on: ubuntu-24.04
steps:
- run: exit 1
git commit -am "Make the tests fail"
git push
sleep 30
gh run view
gh run view asks which run you mean; pick the newest, then View all jobs in this run. Mine said:
X main First pipeline · 40171041003
Triggered via push about 2 minutes ago
JOBS
✓ where-am-i in 3s (ID 121101742744)
X tests in 4s (ID 121101758135)
✓ Set up job
X Run exit 1
✓ Complete job
✓ helm in 3s (ID 121101758136)
✓ lint in 3s (ID 121101758152)
- build in 0s (ID 121101778491)
ANNOTATIONS
X Process completed with exit code 1.
tests: .github#5
To see what failed, try: gh run view 40171041003 --log-failed
The same story as Jenkins, told GitHub's way. tests failed at the Run exit 1 step, helm and lint passed on their own machines, and build has a - instead of a tick or a cross: it was skipped, in 0 seconds, because one of the jobs it needs failed. The suggested --log-failed is worth remembering: it prints only the logs of the steps that failed, which on a real pipeline saves scrolling through hundreds of lines.
In both tools, the other two checks still finish, because they don't depend on the tests, but the build never starts. That's the gate. Put both back how they were before moving on, and leave Jenkins running if you're carrying straight on to Lesson 3. If you're stopping, docker stop jenkins pauses it, and docker start jenkins brings it back with everything you set up.
The same ideas in GitLab CI/CD
GitLab's pipelines live in one file, .gitlab-ci.yml, at the top of the repository. It's YAML like GitHub's, and the same five questions map across all three tools:
| Idea | GitHub Actions | Jenkins | GitLab CI/CD |
|---|---|---|---|
| Pipeline file | .github/workflows/*.yml | Jenkinsfile | .gitlab-ci.yml |
| When it runs | on: | Webhooks, polling, multibranch | workflow: and rules: |
| A group of steps | Job | Stage | Job |
| Ordering | needs: | Stages run in order | stages: and needs: |
| Side by side | The default | parallel block | Jobs in the same stage |
| Where it runs | runs-on: a runner | agent | A runner, picked with tags: |
| A shell command | run: | sh | script: |
| A reusable piece | uses: an action | Plugins and shared libraries | include: |
| Only on main | if: github.ref == 'refs/heads/main' | when { branch 'main' } | rules: with $CI_COMMIT_BRANCH |
GitLab's model is a mix of the other two: jobs are grouped into named stages that run in order, like Jenkins, while the jobs inside one stage run side by side and each starts on a fresh machine, like GitHub. The last lesson in this track ends with CivicGrid's whole pipeline written as a .gitlab-ci.yml, so you can read one with confidence.
Spot the problem
Three real mistakes. Work out what's wrong before you read the explanation.
The missing file. A workflow has two jobs. build runs make and creates a file called app.tar.gz. upload has needs: build, and runs a command to upload app.tar.gz. The upload fails: no such file. Why? Couriers. upload ran on a brand new machine that never had the file. needs: only controls the order, not what carries over. The fix is to upload the file as an artifact at the end of build, and download it at the start of upload, or do both in one job.
The pipeline that never runs. A team copies a workflow from an older project. It has on: push: branches: [master]. They push to their repository several times, and nothing ever appears in the Actions tab. No errors either. What's going on? Their default branch is called main, so no push ever matches master, and GitHub has nothing to run. It's a quiet failure, which makes it easy to miss: always check the Actions tab after adding a workflow.
The translation that lost a gate. A team moves their pipeline from Jenkins to GitHub Actions. The Jenkinsfile had three stages in order: Test, Build and Deploy. They write three GitHub jobs with the same names and the same steps. A week later, a build with failing tests gets deployed. What did they miss? The opposite default. Jenkins ran the stages in order without being told; GitHub runs jobs side by side unless told otherwise. Without needs: test on Build and needs: build on Deploy, all three started at once.
Where you are now
You can read both files now. A GitHub workflow is YAML: on for when, runs-on for where, steps for what, and jobs that run side by side until needs says to wait, each on a fresh machine. A declarative Jenkinsfile is nested blocks: an agent, stages that run in order until a parallel block says otherwise, all sharing one workspace, and post for what happens after. You've run the same pipeline in both, and watched a failing test stop the build in each. Lesson 3 swaps the echo lines for real work: linting and tests against Postgres and Redis, in both tools.
Check yourself
In GitHub Actions, two jobs have no needs: line. How do they run?
In a declarative Jenkinsfile, how do stages run unless told otherwise?
Why does every job in CivicGrid's ci.yml start with actions/checkout?
What does a Jenkinsfile's post block with a failure section do?
A workflow has on: push: branches: [master], but the repo's branch is main. What happens on a push?
Why does the docker run command publish Jenkins on 127.0.0.1:8080, not just 8080?
Key terms
- Trigger (event): what starts a pipeline, like a push, a pull request, a tag or a schedule.
- Job: a group of steps in GitHub Actions, run on its own runner.
- Stage: a named group of steps in Jenkins; stages run in order by default.
- Step: one command or ready-made piece inside a job or stage.
- Action: a reusable piece of a GitHub workflow, pulled in with
uses:. needs:: makes a GitHub job wait for other jobs, and skip if they fail.parallel: a Jenkins block that runs the stages inside it side by side.- Agent: a machine that runs Jenkins builds;
agent anymeans any available one. - Workspace: the folder on a Jenkins agent where a pipeline's files live during a build.
- Artifact: a file saved from one job so a later job, or a person, can download it.
- Webhook: a message one service sends another when something happens, like GitHub telling Jenkins about a push.
- Pipeline as code: keeping the pipeline definition in the repository, with its history and review.
Go deeper
- GitHub's Workflow syntax for GitHub Actions (opens in a new tab), the full reference for every key in a workflow file, including
needsandif. - The Jenkins handbook's Pipeline Syntax (opens in a new tab), which lists every declarative section and directive, including the
whenconditions from the widget. - GitLab's CI/CD YAML syntax reference (opens in a new tab), for when a job description asks for GitLab.