Git, CI/CD & GitOpsXXXIX · PipelinesPipelines
Stages and jobs — the two-level hierarchy and when each model applies
What you'll learn
- Define a job and a stage in both GitHub Actions and GitLab CI
- Identify when a flat jobs-only model is sufficient
- Identify when stages must be used to enforce ordering
- Distinguish a stage from a DAG node
- Choose between the GitHub Actions and GitLab CI models for a given workload
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A CI pipeline has two units of work: the job and the stage.
The two vendors in this course model them differently. GitHub
Actions treats the job as the primary unit and the stage as an
implicit concept you derive by looking at needs: edges.
GitLab CI treats the stage as the primary unit and the job as
a member of a stage. The two models are not interchangeable;
the choice affects how the pipeline expresses ordering, how
parallelism is declared, and how failure propagates.
The job is the unit of work
flowchart LR
J1["job: lint\nruns-on: ubuntu-latest"]
J2["job: plan\nruns-on: ubuntu-latest"]
J3["job: scan\nruns-on: ubuntu-latest"]
A job is one execution on one runner. It has its own
runs-on, its own steps, its own environment variables, its
own secrets, and its own artifacts. The job boundary is the
isolation boundary: two jobs cannot share memory, filesystem,
or processes; they communicate only through artifacts and
outputs.
jobs:
lint:
runs-on: ubuntu-latest
steps:
- run: ruff check .
plan:
runs-on: ubuntu-latest
steps:
- run: terraform plan
scan:
runs-on: ubuntu-latest
steps:
- run: trivy fs .
Three jobs, three independent runners, no implicit ordering.
Without a needs: clause, the three run in parallel.
GitHub Actions: the flat jobs model
In GitHub Actions, ordering is declared with the needs:
clause. A job that lists another job in its needs: array
waits for that job to complete before starting:
flowchart LR
L["lint"] --> P["plan"]
L --> S["scan"]
P --> A["apply"]
jobs:
lint:
runs-on: ubuntu-latest
plan:
runs-on: ubuntu-latest
needs: [lint]
scan:
runs-on: ubuntu-latest
needs: [lint]
apply:
runs-on: ubuntu-latest
needs: [plan, scan]
environment: production
The DAG is implicit in the needs: graph. There is no
stages: keyword; “stage” is what the diagram looks like
when you group jobs by their topological depth. lint is
stage 1, plan and scan are stage 2, apply is stage 3.
GitLab CI: the stages model
GitLab CI inverts the emphasis. The stages: keyword is
declared at the top of the file, and each job declares which
stage it belongs to. Within a stage, all jobs run in
parallel; across stages, stages run sequentially:
flowchart LR
subgraph S1["stage: validate"]
L["lint"]
SA["sast"]
end
subgraph S2["stage: test"]
T1["unit"]
T2["integration"]
end
subgraph S3["stage: deploy"]
D1["staging"]
D2["production"]
end
S1 --> S2
S2 --> S3
stages:
- validate
- test
- deploy
lint:
stage: validate
script: ruff check .
sast:
stage: validate
script: semgrep ci
unit:
stage: test
script: pytest
integration:
stage: test
script: pytest --integration
deploy-staging:
stage: deploy
script: deploy.sh staging
deploy-production:
stage: deploy
script: deploy.sh production
when: manual
The DAG is constrained by the stage order. A job in test
cannot depend on a job in deploy; the system rejects the
dependency as a cycle.
When each model applies
The choice is not stylistic. The dependency structure of the pipeline determines which model expresses it without forcing the team into contortions.
flowchart TB
Q{"What is the\ndependency structure?"}
Q -->|"Linear, all jobs in groups"| A["Stages model"]
Q -->|"DAG, parallel branches"| B["Flat jobs with needs"]
Q -->|"Mixed"| C["Either, with care"]
- Linear pipelines (build → test → package → deploy) fit the stages model cleanly. The four steps are four stages, each job belongs to its stage, and the order is declared once.
- DAG pipelines (one job fans out to many; many jobs
fan in to one) fit the flat jobs model. The
needs:graph expresses the diamond topology directly; a stages model would force the team to either over-constrain the parallelism or invent artificial stages. - Mixed pipelines (a linear trunk with parallel
branches at one point) can use either. The trade-off is
whether the parallel branches are declared as stage
members (forcing the next stage to wait for both) or as
needs:peers (allowing finer control).
Failure propagation
The two models propagate failure differently:
flowchart TB
subgraph GA["GitHub Actions"]
G1["lint fails"] --> G2["plan, scan skipped"]
G2 --> G3["apply skipped"]
end
subgraph GL["GitLab CI"]
L1["lint fails"] --> L2["test stage skipped"]
L2 --> L3["deploy stage skipped"]
end
In GitHub Actions, when a job fails, every job that
transitively depends on it is skipped. The skip is computed
by walking the needs: graph.
In GitLab CI, when a job fails, every job in subsequent
stages is skipped. The skip is computed by stage order, not
by needs: edges. A job with needs: to a job in the same
stage still runs; a job with needs: to a job in a later
stage is rejected as a cycle.
Production discipline
- Choose the model that matches the dependency graph, not
the team familiarity. A diamond-shaped pipeline on
GitLab CI fights the stages model; force
needs:to express it or move the pipeline to GitHub Actions. - Document the stage boundaries. In a stages model, the boundaries are the team’s coarse-grained contract; in a flat model, document which jobs fan out and which fan in.
- Use stage names that are coarse-grained.
validate,test,deploy; notruff,pytest,terraform-plan. The stage name is the unit of ordering, not the unit of work. - Treat
needs:as load-bearing in the flat model. A missing edge is a race condition in the pipeline. - Do not invent artificial stages to express parallelism
in a flat model. Use
needs:; stages are not the only way to group.
Cross-course references
- Ansible for Production Sysadmins - Part XXXVII (RepoArch) covers the same trade-off in AWX job templates: a flat workflow graph versus a staged playbook run.
- Terraform for Production Sysadmins - Parts XII (PlanApply)
and XIII (Modules) treat
terraform planandterraform applyas distinct stages in the same way a CI pipeline does; the DAG is the same. - Linux for Production Sysadmins - Part XXX (BootProc) applies the stages model to systemd unit ordering: linear ordering with parallelisable sub-steps.
Quiz
Knowledge check · 4 questions
Q1. A team needs a pipeline where `unit-tests` and `integration-tests` both depend on `build`, but `integration-tests` additionally depends on `unit-tests`. Which model expresses this DAG most cleanly?
Q2. In GitLab CI, a job in `stage: test` that declares `needs: [a-job-in-stage-deploy]` is accepted and runs after the deploy stage completes.
Q3. Name the two units of work in a CI pipeline and identify which keyword in GitHub Actions replaces the role of stages in GitLab CI.
Q4. Diagnose why a parallel branch in a stages pipeline is blocked by an unrelated slow job and recommend a model redesign.
Team T's GitLab CI pipeline has three stages: validate, test, deploy. The `validate` stage contains `lint` (runs in 30 seconds) and `sast` (runs in 12 minutes). The `test` stage contains `unit` and `integration`. Engineers notice that `unit` does not start until `sast` finishes, even though `unit` only depends on `lint`. The pipeline takes 12 minutes minimum, regardless of what changed.
Passing score: 75%. Answers are checked in this browser.