Skip to content

Continuous Integration with GitHub Actions

6 min readGitHub Actions & CI/CD · Continuous Integration

Continuous integration is automatically validating changes whenever developers integrate code.

The definition is unglamorous and the discipline is the point. CI is what makes a shared branch trustworthy: every change is built, linted, tested and scanned before anyone relies on it, and the result is visible on the pull request that proposed it.

This cluster builds that pipeline eight times, for eight real project types.

Start with Python CI

The stacks differ; the shape does not.

Checkout
Set up the toolchain
Restore cached dependencies
Install what is missing
Lint / format check
Build
Test
Upload artifacts (reports, binaries)
Report status to the pull request

Every lesson works through that sequence for its ecosystem, and the differences are instructive. Some toolchains cache dependencies natively through their setup action; others need explicit cache keys. Some have a meaningful build step; others do not. Terraform and Ansible have no tests in the usual sense at all — their CI validates configuration rather than exercising code.

This cluster assumes the Fundamentals cluster. Specifically:

You should be able toCovered in
Write a workflow from scratchYour First Workflow
Filter triggers by branch and pathEvents and Triggers
Structure work across dependent jobsJobs
Use expressions and contextsContexts and Expressions
Set permissions explicitlyLeast-Privilege Permissions

You also need working knowledge of at least one of the ecosystems covered. These lessons teach CI for a stack, not the stack itself.

By the end of this cluster you should be able to:

  • Build a complete CI pipeline for your project’s language, from checkout to artifacts.
  • Cache dependencies correctly, with keys that invalidate when they should.
  • Use a matrix to test across language versions and operating systems without a combinatorial explosion.
  • Publish test reports and build outputs as artifacts a reviewer can actually retrieve.
  • Wire CI results into merge policy as required status checks.
  • Recognise the CI patterns that are unsafe on pull requests from forks — particularly around registry credentials and infrastructure state.
  1. Lesson 1: 01. Python CIBuild a Python CI pipeline — version matrix, pip caching, ruff, mypy, pytest with coverage, artifacts that survive failures, and a stable required check.Beginner → Intermediate13 min read
  2. Lesson 2: 02. Node.js CIBuild a Node.js CI pipeline — npm ci and lockfiles, dependency caching, version matrices, lint, type check, test with coverage, and a production build artifact.Beginner → Intermediate12 min read
  3. Lesson 3: 03. Go CIBuild a Go CI pipeline — gofmt enforcement, go vet, race-detector tests with coverage, and cross-compiled binaries produced from a single runner.Beginner → Intermediate11 min read
  4. Lesson 4: 04. Java CIBuild a Java CI pipeline with GitHub Actions — JDK matrices, Temurin distributions, Maven and Gradle dependency caching, test reports and packaged artifacts.Beginner → Intermediate12 min read
  5. Lesson 5: 05. .NET CI.NET CI on GitHub Actions: SDK version matrix, NuGet caching, restore, build, test with coverage and artifacts — a complete pipeline you can copy, with every step explained.Beginner → Intermediate12 min read
  6. Lesson 6: 06. Docker CIBuild container images in CI with GitHub Actions — Buildx, layer caching, multi-platform builds, tag derivation, and why a pull request must never push an image.Intermediate13 min read
  7. Lesson 7: 07. Terraform CIRun Terraform in CI with GitHub Actions — fmt, validate and plan on pull requests, OIDC read-only credentials, plan output handling, and why apply does not belong here.Intermediate13 min read
  8. Lesson 8: 08. Ansible CIValidate Ansible playbooks in CI with GitHub Actions — yamllint, ansible-lint, syntax checks, collection installs, and molecule tests that never touch production.Intermediate12 min read

The lessons are independent. Read the one matching your stack; the others are useful mainly for contrast.

If you work inStart atNotable for
PythonPython CIVersion matrices, pip caching, coverage
JavaScript / TypeScriptNode.js CILockfiles, npm ci, package managers
GoGo CIVet, race detector, cross-compilation
JavaJava CIMaven and Gradle, JDK matrices
.NET.NET CIRestore/build/test separation, NuGet caching
ContainersDocker CIBuildx, layer caching, multi-platform
TerraformTerraform CIPlan on pull requests, and why apply is not here
AnsibleAnsible CILint, syntax check, Molecule

The two infrastructure lessons are worth reading even if you use neither, because they are where CI’s usual assumptions break down: there is no artifact, the “tests” mutate real systems if you are not careful, and the pull request feedback is a plan rather than a pass or fail.

Worth naming, because “we have CI” covers a wide range of quality.

It is fast enough to wait for. Under ten minutes for the common path. Beyond that, people stop watching, start batching changes, and the feedback loop the pipeline exists to provide stops functioning.

It fails for one reason at a time. Lint and test as separate parallel jobs means both results arrive together. Combined, a formatting error hides every test result, and you fix one thing at a time across several pushes.

Its failures are legible. A failing test should say which test and why, near the top. Five hundred lines of dependency resolution before the actual error is a pipeline that technically works.

It is deterministic. A test that fails one run in twenty trains everyone to rerun rather than investigate, and the first real failure gets rerun too.

It runs the same thing locally. If CI runs make test and a developer can run make test, the pipeline is reproducible. If CI runs forty lines of inline shell, it is not.

Those five properties matter more than which actions you use, and the lessons in this cluster are written to produce them.

Every pipeline here caches dependencies, because the alternative is paying the download cost on every run of every branch forever.

Most setup actions now cache natively:

- uses: actions/setup-node@v7
with:
node-version: "22"
cache: npm
- uses: actions/setup-python@v7
with:
python-version: "3.13"
cache: pip

That is one line and it is preferable to configuring actions/cache by hand, because the action knows where its ecosystem stores things and what the cache key should depend on.

Where you do need explicit control — a build cache, a tool that no setup action covers — Caching covers keys, restore keys and invalidation. The critical property to remember is that a cache may vanish at any time: a pipeline that fails on a cache miss is broken, not optimised.

A workflow’s result becomes useful when it gates merging.

Each job reports a status check named after it. Making that check required — through branch protection or a ruleset — means a pull request cannot merge until it passes.

Two consequences worth internalising now:

The check name is the job name. Renaming a job renames the check, and the old name stays required while never reporting again. Every pull request then blocks on something that no longer exists.

Required checks must be able to run. A check gated behind a path filter will not report on pull requests that do not touch those paths — so those pull requests wait forever. Either do not require it, or have it report a trivially successful result when skipped.

If the repository uses a merge queue, workflows must also handle the merge_group event or the queue stalls.

The eight pipelines share a shape, and each contributes something the others do not:

Python introduces version matrices and coverage reporting.

Node.js covers lockfiles, and why npm ci differs from npm install in CI.

Go covers vet, the race detector, and cross-compiling from one runner.

Java covers two build systems — Maven and Gradle — with different caching characteristics.

.NET covers the restore/build/test separation and why it matters for caching.

Docker covers Buildx, layer caching, multi-platform images, and the rule about never pushing from an untrusted pull request.

Terraform covers planning on pull requests, and why apply deliberately does not appear here.

Ansible covers linting and syntax checking where there is no artifact and no conventional test suite.

Reading the one for your stack is sufficient. Reading the Docker and Terraform lessons as well is worth the time regardless, because they are where CI’s usual assumptions stop holding — one produces an artifact that must not escape, the other has no artifact at all.

CI runs on every pull request, including from strangers. That is the point and it is also the risk.

Three rules apply to every pipeline in this cluster:

Pull request workflows get no secrets by default. Workflows triggered by pull_request from a fork run with a read-only token and no access to repository secrets. This is deliberate — otherwise anyone could open a pull request that printed your credentials.

Never push anything from an untrusted pull request. Building a container image on a pull request is fine. Pushing it to a registry is not, because the pull request’s contents decide what gets pushed.

Treat pull request metadata as attacker-controlled. A branch name or pull request title interpolated into a shell command is a script injection. Workflow Security covers this properly, and every lesson here follows its guidance.

Each lesson builds a workflow for a small but realistic project rather than a hello-world. That means dependency files, a test suite, and the awkward parts — a lockfile that must be respected, a coverage report that should survive a failure, a matrix that should not run six times what it needs to run once.

The workflows are meant to be adapted rather than copied wholesale. Where a choice is a matter of taste rather than correctness, the lesson says so.

Begin: Python CI with GitHub Actions