Skip to main content
Logo
Overview
GitHub Actions vs GitLab CI vs CircleCI: Jobs, Steps, and Stages Explained

GitHub Actions vs GitLab CI vs CircleCI: Jobs, Steps, and Stages Explained

September 15, 2026
6 min read

I’ve been in consulting in some form or another since 2010. One thing nobody warns you about going into different customer’s environments: sometimes you don’t get to pick the CI tool (or any tooling for that matter.) The client already has one. Sometimes it’s GitHub Actions because that’s just where the code already lived. Sometimes it’s GitLab because someone five years ago wanted self-hosted runners and never looked back. I had one client running CircleCI purely because an early engineer had used it at a previous job and set it up before anyone else had an opinion.

So you end up learning three or four of these things reasonably well, not because you wanted to specialize in CI config, but because that’s what showed up on the ticket. After enough of that, you start noticing patterns within the tools and find they are mostly the same idea wearing different names. Once you see the shared shape, switching between them gets a lot less annoying.

The part that’s the same everywhere

Every one of these systems has the same four layers:

Something triggers a run: a push, a pull request, a tag, a cron schedule, someone clicking a button.

That triggers the whole run: this is the “pipeline” or the “workflow run,” depending on the tool.

Inside the run, units of work happen on their own machines. These are almost always called jobs, across all three tools.

Inside a job, individual commands run one after another. These are steps, or a script: list.

The two layers worth understanding are the job and the step, because everything else is just orchestration wrapped around them.

A step is one command inside a job. Steps run in order, and because they’re on the same machine, they share a filesystem. Step one runs npm install, step two can use those packages, no extra work required.

A job gets its own fresh machine or container. This is the one that trips people up when they’re new to a tool: jobs don’t share anything. You have to check out the code again in every single job, because as far as that job is concerned, nothing existed before it started. If you need to move a file from one job to another, you’re reaching for artifacts or a cache, not just assuming it’ll be there.

I’ve seen engineers spend an hour debugging why their test job “lost” a file that the build job created, before realizing the two jobs never shared a filesystem to begin with. It’s not a bug. It’s the whole point of running jobs in isolation.

Where the naming splits

ConceptGitHub ActionsGitLab CICircleCI
The whole run triggered by an eventWorkflow runPipelinePipeline
Layer that arranges jobsthe workflow file itselfthe pipeline itselfWorkflow
Grouping used for orderingnone, use needs:Stagenone, use requires:
Unit of work on its own machineJobJobJob
Commands inside the jobStepsscript: listSteps
Config file location.github/workflows/*.yml.gitlab-ci.yml.circleci/config.yml

Two things in that table have burned me personally.

“Workflow” doesn’t mean the same thing in GitHub and CircleCI. In GitHub, the workflow is the whole file, top to bottom. In CircleCI, a workflow is a layer inside the config that arranges jobs, which means CircleCI has an extra level of nesting: pipeline, then workflow, then jobs, then steps. I once spent a confusing twenty minutes on a call with a CircleCI-only client because I kept using “workflow” the GitHub way.

And “stage” is basically a GitLab thing. GitHub and CircleCI don’t have a stage concept at all.

Stages versus dependency graphs

This is the split that changes how you think about writing the config, not just what you call things.

GitLab has named stages. You declare an ordered list, build, test, deploy, whatever fits, and assign each job to one. Every job in a stage runs in parallel, and the next stage doesn’t start until the whole previous stage finishes. It reads almost like a checklist, which is nice when you’re new to the codebase and just want to know what happens and in what order.

GitHub Actions and CircleCI skip stages entirely. Instead, each job declares which other jobs it needs to finish first, needs: in GitHub, requires: in CircleCI, and that builds a dependency graph. A job can start the second its specific dependencies are done, without waiting on unrelated jobs to clear some stage that has nothing to do with it. On a big pipeline with a lot of independent jobs, that difference in wall-clock time adds up.

Worth knowing if you’re moving between the two models: GitLab added needs: too, so a job can now jump ahead of the normal stage order if its actual dependencies are satisfied earlier. The two approaches are drifting toward each other. GitLab still centers on stages by default, though, and that’s usually the mental model you’ll find in an existing GitLab config.

The same two-job pipeline, three ways

Build, then test, expressed in each tool:

GitHub Actions

name: CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm run build
test:
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npm test

GitLab CI

stages:
- build
- test
build-job:
stage: build
script:
- npm ci
- npm run build
test-job:
stage: test
script:
- npm ci
- npm test

CircleCI

version: 2.1
jobs:
build:
docker:
- image: cimg/node:20.0
steps:
- checkout
- run: npm ci
- run: npm run build
test:
docker:
- image: cimg/node:20.0
steps:
- checkout
- run: npm ci
- run: npm test
workflows:
build-and-test:
jobs:
- build
- test:
requires:
- build

Same shape all three times: checkout, install, run a command, wait for the first job before starting the second. GitHub says needs, CircleCI says requires, GitLab just puts you in a later stage. Once you’ve written this pattern in one tool, translating it to the other two is mostly a vocabulary lookup, not new thinking.

What helps when you land somewhere new

When I show up at a client and open their CI config for the first time, I don’t try to memorize the whole file. I look for three things: where the config file lives (tells me which tool I’m in before I even read it), whether jobs are isolated or sharing a runner (usually isolated, but self-hosted setups sometimes get creative), and how the ordering is expressed, stage or dependency graph.

None of that takes long to check once you know what you’re looking for, and that’s really the payoff of learning the shared model in the first place. The next client can be running a tool I’ve never touched, and I’m not starting from zero. I’m just mapping a job to a job, a step to a step, and figuring out where their stage or dependency graph lives. The vocabulary changes. The thing I’m looking for doesn’t.