Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXXXIX · PipelinesPipelines

Stages and jobs — the two-level hierarchy and when each model applies

Intermediate⏱ ~22 mingit

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

Not yet marked complete on this device.

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

  1. 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.
  2. 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.
  3. Use stage names that are coarse-grained. validate, test, deploy; not ruff, pytest, terraform-plan. The stage name is the unit of ordering, not the unit of work.
  4. Treat needs: as load-bearing in the flat model. A missing edge is a race condition in the pipeline.
  5. 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 plan and terraform apply as 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

  1. 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?

  2. 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.

  3. 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.

  4. 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.