Hands-On CI: Building Your First GitHub Actions Workflow
In our previous post on Continuous Integration in DevOps we laid out the “what” and “why” of CI. Today we are taking you through a deep-dive into the “how,” with step-by-step instructions for setting up a linting pipeline in GitHub Actions. Along the way we’ll explain why teams need CI, how it differs from local tools like pre-commit, and exactly what happens when a linter like Ruff runs on every push. If you haven’t already, you might also find our guide to Why Your Team Needs pre-commit a useful companion.
CI workflows are a powerful tool to improve code quality. Let's cover exactly how they work, and how to add them to your team’s repos.
Why you should use CI pipelines
A CI pipeline automates repetitive checks, including linting, tests, and security scans, every time code changes. That means you catch style errors, bugs and misconfigurations before they make it into your shared branches. Developers get fast feedback on pull requests rather than waiting until someone notices a stray typo, or until production monitors go off. That leads to higher code quality, fewer review cycles, and more confidence when merging.
Linting in CI also enforces a single source of truth. Even if each developer has a local setup, machines vary. A pipeline ensures everyone’s code passes the same checks on the same environment. It’s one of the foundations any modern team needs before scaling review processes or adding deployment gates.
Local hooks versus CI
You might already use pre-commit or similar tools in your local workflow. Those let you catch and fix issues before you even commit. They’re fantastic for shifting quality checks left, and reducing friction in code reviews. But they still depend on each developer having the right configuration, and remembering to run them.
A CI pipeline, by contrast, lives in the cloud. It runs on every push or pull request, no matter who made the change or what machine they used. That gives your team a safety net: even if someone forgets to install hooks, the pipeline will catch issues. Pre-commit and CI aren’t mutually exclusive; think of them as complementary. Pre-commit gives rapid, local feedback. CI gives consistent, team-wide enforcement.
How GitHub Actions and Git workflows work
GitHub Actions is a platform for defining automated workflows that run on Git events, such as push, pull_request, release, schedule, and more. Workflows live in your repo under .github/workflows/
. Each workflow is a YAML file that declares:
Trigger events (for example,
push
orpull_request
)Jobs, which run on virtual runners like Ubuntu or Windows
Steps within each job, such as checking out code, installing dependencies, or running scripts
By defining a workflow called ci.yml
that listens on every push
, you’ll have linting kick off whenever anyone pushes new commits to any branch. You can also restrict it to pull requests only, or specific branches (for example main
).
What is a linter? Introducing Ruff
A linter analyzes your source code for style violations, potential bugs, dead code, type errors and more. Ruff is a fast, Python-focused linter written in Rust. It runs in milliseconds even on large codebases and can enforce rules from tools like Flake8, PyLint, isort and more, all in one pass. Using Ruff in CI means you get all of those checks with minimal overhead.
Ruff isn’t the only option (black, flake8 and pylint are popular too), but its speed and broad rule support make it ideal for CI. You can customize Ruff’s behavior via a ruff.toml
file, enabling or disabling rules, setting line-length, ignoring paths, and so on.
Creating your first GitHub Action
Below is a complete ci.yml
you can drop into .github/workflows/ci.yml
in your repo. It runs on every push, checks out your code, sets up Python 3.10, installs Ruff, and runs ruff check .
on the entire repo.
name: CI Pipeline
on:
push:
branches:
- '**'
jobs:
lint:
name: Lint with Ruff
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Install Ruff
run: pip install ruff
- name: Run Ruff
run: ruff check --output-format=github .
Let's go line-by-line through this ci.yml
file to help you understand exactly how the workflow works:
name: CI Pipeline
This sets the display name of your workflow. That’s what you’ll see in the Actions tab. You can pick any name you like, but keeping it descriptive helps your team understand its purpose.
on:
The on
section tells GitHub which events trigger your workflow. Here we use:
on:
push:
branches:
- '**'
push
: The workflow runs on everypush
event.branches: ['**']
:**
is a glob that matches any branch name. That means every time you push to any branch, this workflow runs. You could narrow this to specific branches likemain
ordevelop
, or add apull_request
trigger if you prefer.
jobs:
Workflows can have multiple jobs in parallel or sequence. Our ci.yml
defines a single job called lint
:
jobs:
lint:
name: Lint with Ruff
runs-on: ubuntu-latest
steps:
...
lint
: The job’s identifier. You use this in the UI and in dependencies if you have multiple jobs.name: Lint with Ruff
: A human-friendly name for the job, shown in the Actions UI.runs-on: ubuntu-latest
: The virtual machine image to run on.ubuntu-latest
gives you an up-to-date Ubuntu instance. GitHub also supportswindows-latest
ormacos-latest
if you need those environments.
steps:
Steps are executed in order:
Check out code
- name: Check out code uses: actions/checkout@v4
This step clones your repository into the runner so subsequent steps have access to your files.
Set up Python
- name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.10'
This action installs the specified Python version. Pinning to a version ensures your runner uses the same interpreter that your project requires.
Install Ruff
- name: Install Ruff run: pip install ruff
Installs Ruff from PyPI. You could pin a specific version (for example
pip install ruff==0.0.365
) or add it to yourrequirements.txt
if you manage dependencies there.Run Ruff
- name: Run Ruff run: ruff check --output-format=github .
Executes Ruff against your entire codebase (
.
). If Ruff finds any errors or warnings, it exits with a non-zero status, causing the job (and the workflow) to fail. You’ll then see a red❌ on your commit or PR, and detailed output in the logs.When you run a GitHub Action and specify
—-output-format=github
with Ruff, any linting issues found will be displayed directly in the GitHub interface as annotations rather than just appearing in the logs. This makes any needed changes easier to see and understand right in GitHub.
That covers every bit of the ci.yml
. With this breakdown you should feel confident customizing names, triggers, runners, or steps to fit your project’s needs.
Seeing it all in action
Imagine you’re a developer ready to add a new feature. You’ve created a feature branch off main
and written some code in src/new_feature.py
. After committing locally, you push your branch:
git push origin feature/new-awesome
On GitHub, you open a pull request targeting main
. Instantly, the CI pipeline springs to life:
Automatic Runner Launch
In the PR UI under “Checks”, you see “Lint with Ruff” queued and then running. A spinner indicates progress.Checkout and Setup
The Actions runner checks out your branch and sets up Python 3.10. This happens in seconds, and you see logs like:actions/checkout@v4 actions/setup-python@v5 with python-version=3.10
Installing and Running Ruff
Next, the pipeline installs Ruff. In the logs you’ll notice:Collecting ruff ... Successfully installed ruff-0.x.x
Then Ruff checks your code:
> ruff check src/new_feature.py src/new_feature.py:27:5 F821 undefined name 'foo' src/new_feature.py:50:100 E501 line too long (120 > 88 characters)
Immediate Feedback
The PR page updates with a red ❌ next to “Lint with Ruff”. You click the “Details” link, review the error messages, then switch back to your IDE.Fix and Re-run
Back in your local branch you fix the issues:# Correct the undefined name and wrap long lines git commit -am "Fix undefined name and wrap lines" git push
GitHub automatically re-runs the CI job. Now you see a green ✅ and “All checks have passed” in the PR.
6. Merge with Confidence
With a green build, reviewers know your code meets the team’s standards. You merge your pull request into `main`, confident that style and quality remain consistent.
Throughout this process, developers get fast, clear feedback without manual intervention. The CI pipeline enforces rules consistently, prevents style drift, and keeps your `main` branch healthy.
Why CI workflows make your team happier
Fast feedback: Developers know right away if they introduce a style error or bug pattern.
Consistent enforcement: No one can sneak code past your style guide.
Reduced review toil: Reviewers focus on design and logic, not formatting.
Clear documentation: Your config files (`ci.yml`, `ruff.toml`) serve as living documentation of your team’s standards.
By combining pre-commit locally with a CI pipeline in GitHub Actions, you shift quality checks left and keep them there. That leads to fewer review cycles, faster merges, and more time writing features.
If you’re curious about taking this further, check out our deep-dive on Microservices and DevOps: A Perfect Match or our guide to Continuous Deployment. Or if you’re ready to get started with CI, you can install the free Zumbro GitHub app to apply DevOps principles to improve your code quality with automations that help your team define, apply, and enforce standards. Get started free here: