GitHub Actions for beginners: automating your first CI/CD pipeline
GitHub Actions for beginners: automating your first CI/CD pipeline
CI/CD sounds like a wall, but it is really one idea: let a machine do the boring checks that used to be a checklist. Every push builds the project and runs the tests, so a broken change is caught in minutes, not at deploy time. GitHub Actions does all of this on GitHub's own infrastructure, and it lives right next to your code.
A workflow is just a YAML file
A workflow is an automated process, and each one is a plain YAML file stored in .github/workflows/. Drop a file named ci.yml in that folder and push, and GitHub starts paying attention to it. The file has three main parts: the trigger on, a set of jobs, and the steps inside each job.
Here is a minimal build-and-test workflow for a Python project:
name: build-and-test
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- run: pip install -r requirements.txt
- run: pytest
The on key decides when things run. push fires on new commits, pull_request runs every time a PR is opened or updated, and you can scope either to specific branches or tags. schedule takes POSIX cron syntax for nightly runs, and workflow_dispatch adds a manual "Run workflow" button in the UI. You can combine several triggers in one file.
Jobs, steps, and runners
Each job runs on a runner, the virtual machine that executes the steps. runs-on: ubuntu-latest picks a fresh GitHub-hosted Ubuntu machine. Every run gets a clean instance, which is why each job checks out the code itself with actions/checkout. Steps run top to bottom. uses pulls in a reusable action, while run executes a shell command directly. You can chain jobs with needs, but by default they run in parallel.
Two actions you will see everywhere are actions/checkout@v7, which fetches your repository onto the runner, and actions/setup-python@v7, which installs a specific Python version and adds it to the path. Pin actions to a major version tag like @v7 so updates are predictable without breaking your pipeline.
Pass values around safely
Steps can share data through outputs. The old way was echo ::set-output key=value, but GitHub deprecated that command in favour of writing to the $GITHUB_OUTPUT environment file. Write to $GITHUB_OUTPUT instead, then read it back with steps.<step_id>.outputs.<name>.
Anything you do not want in your repo, like an API token, belongs in a secret. Add it under repo settings, then reference it as ${{ secrets.MY_TOKEN }}. GitHub keeps secrets masked in the logs.
Make it fast, and know when to stop
Dependency installs are often the slowest step. The actions/cache action saves and restores folders like ~/.npm or your full pip install, keyed on a hash of your lockfile, so unchanged dependencies are not downloaded again.
Actions is not always the right tool. Long-running servers, or jobs that need heavy custom hardware, are better handled by self-hosted runners or your deployment platform. For public repos, standard runners are free, and GitHub Free plans include monthly minutes for private work, so you can experiment without spending. Start with one workflow, let it run on a couple of pushes, and grow from there.
