CI/CD Pipelines¶
A GitHub Actions workflow or a Jenkinsfile looks, superficially, like a build tool — it has steps, it runs in order, some steps depend on others finishing first. It isn't one. GitHub Actions and Jenkins are orchestration: they decide when and where to run commands (which event triggers it, which runner or agent executes it, what runs in parallel across machines, what artifacts move between jobs) and they call out to the actual build tool — Maven, Gradle, MSBuild, npm — to do the part that turns source into an artifact. Confusing the two layers is the single most common structural mistake in CI configuration, and it's worth stating the boundary plainly before looking at either tool.
The dependency graph is real — it's just made of jobs, not files¶
A GitHub Actions workflow's needs key is the same idea as Make's
prerequisites or Gradle's dependsOn, one level up — instead of "this file
depends on that file," it's "this job depends on that job's job finishing
first, potentially on an entirely different machine":
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: mvn package
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: mvn test
deploy:
needs: [build, test]
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
test and deploy waiting on build looks identical in shape to a
Makefile's target graph, but the mechanism is fundamentally different: each
job is a fresh, isolated environment (a new container or VM) with no shared
filesystem by default. mvn package in the build job produces a jar on
that runner's disk — and it's gone the moment the job ends, unless
explicitly carried forward.
Artifacts and caches solve a problem Make never had¶
Because jobs run on genuinely separate machines, anything one job produces that a later job needs has to move explicitly:
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: mvn package
- uses: actions/upload-artifact@v4
with:
name: app-jar
path: target/*.jar
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: app-jar
- run: ./deploy.sh app.jar
upload-artifact/download-artifact is the CI-level equivalent of a build
tool's own output directory — except it has to be spelled out explicitly,
because there's no shared disk connecting the two jobs the way there's a
shared target/ directory between two phases in the same local Maven
invocation. This is also where a subtle failure mode lives: a workflow that
runs mvn package twice — once in build, redundantly again in test
because someone forgot the artifact already exists — isn't just wasting
time, it's a build tool re-resolving dependencies and recompiling on a
fresh runner with a cold cache, often taking as long as the original build
did.
Dependency caching (actions/cache, or Gradle/Maven's own remote cache
support) exists to blunt exactly this cost — restoring a previously saved
~/.m2/repository or ~/.gradle/caches onto a fresh runner so a cold CI
machine doesn't re-download the entire dependency tree from the internet on
every single run. It's solving the same "avoid redoing work that's already
been done" problem Make and Gradle solve locally, just one layer up, across
runners that share nothing by default.
The anti-pattern: build logic leaking into the pipeline¶
The failure mode worth naming explicitly: conditional logic, version
bumping, or packaging steps that belong in the pom.xml, build.gradle,
or .csproj instead get written directly into workflow YAML or a
Jenkinsfile's Groovy — because it's faster to add an if to a pipeline
step than to properly express it in the build tool's own model. This works
right up until someone tries to run the build locally to reproduce a CI
failure, and discovers the actual packaging step only exists inside CI,
never runs on a laptop, and is untestable outside of pushing a commit and
waiting for a runner. A build that only works inside its pipeline isn't
reproducible — the entire point of a pipeline is to automate running the
same build a developer can run locally, not to be the build.
# Anti-pattern: version logic that only exists in CI
- run: |
if [ "$GITHUB_REF" == "refs/heads/main" ]; then
mvn versions:set -DnewVersion=1.0.${{ github.run_number }}
fi
mvn package
The fix isn't "don't do versioning in CI" — it's keeping the decision (what triggered this, which branch, which run) in the pipeline, while the mechanism (how a version number actually gets applied to the POM) stays in a Maven plugin invocation that a developer could run with the same inputs locally and get the identical result.
Matrix builds: the orchestration layer's version of a task graph fan-out¶
jobs:
test:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
java: [17, 21]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
- run: mvn test
This expands into six independent jobs (3 OSes × 2 Java versions), each
running the identical mvn test invocation with a different environment —
a fan-out no build tool covered in this book handles on its own, because it
isn't a build concern, it's a "run this build N times under different
conditions and report on all of them" concern. That's the clearest
one-sentence description of what CI orchestration actually adds on top of
a build tool: not a better build, but running the same build repeatedly,
in parallel, across environments a single machine couldn't represent at
once, and aggregating the results.
Jenkins vs. GitHub Actions: same job, different distribution model¶
Jenkins predates GitHub Actions by over a decade and runs on
self-hosted agents you provision and maintain; a Jenkinsfile is Groovy,
giving it the same real-language flexibility (and the same
readability cost) that Gradle's DSL has over Maven's XML. GitHub Actions
trades that self-hosting requirement for GitHub-managed runners and a
large marketplace of reusable uses: steps, at the cost of being
inherently tied to GitHub as the source host. Neither difference changes
the underlying model this chapter is about — both are orchestrating calls
out to Maven, Gradle, MSBuild, or npm to do the actual building, and both
have the identical failure mode available if build logic gets written into
the orchestration layer instead of the build tool it's supposed to be
invoking.