If you've ever pushed code and wished tests just ran themselves, or merged a PR and wished it deployed on its own, that's what GitHub Actions is for. It has a reputation for being confusing mostly because of YAML indentation, not because the underlying ideas are hard — there are really only a handful of concepts, and once they click the rest is just vocabulary.
What GitHub Actions actually is
GitHub Actions runs code in response to things that happen in your repository — a push, a pull request, someone clicking a button, a schedule. You describe what to run in a YAML file, GitHub spins up a fresh virtual machine, runs it, and throws the machine away when it's done. That's the whole model: event happens → GitHub runs your YAML on a disposable machine.
Where workflows live, and the shape of one
Every workflow is a YAML file under .github/workflows/ in your repo — the filename doesn't matter, GitHub picks up anything there. A workflow has three parts:
name: CI # shows up in the Actions tab
on: ... # what triggers this workflow
jobs: # what actually runs
some-job-id:
runs-on: ubuntu-latest
steps:
- ...name is cosmetic. on and jobs are where the actual behavior lives, and they're worth understanding one at a time.
Events: what starts a run
on lists the events that trigger the workflow. The ones you'll reach for constantly:
push— runs on every push to the branches you specify (or all branches, if you don't filter).pull_request— runs when a PR is opened or updated. This is what you want for "run tests before merging."workflow_dispatch— adds a "Run workflow" button in the GitHub UI, for anything you want to trigger manually.schedule— runs on a cron schedule, e.g.cron: "0 6 * * *"for every day at 6am UTC. GitHub Actions cron is always 5 fields — minute, hour, day, month, weekday.
You can list several: a workflow with on: [push, pull_request] runs on both. A push that doesn't match any branch filter, or a workflow file with no matching trigger at all, simply never runs — there's no error, it just silently doesn't fire, which is a common source of "why didn't my workflow run" confusion.
Jobs and runners
A workflow has one or more jobs, each running on its own fresh runner — a clean virtual machine that exists only for that job and is discarded afterward. runs-on: ubuntu-latest is the default choice for most projects; windows-latest and macos-latest exist too.
By default, every job in a workflow starts at the same time and runs in parallel — jobs don't know about each other unless you connect them with needs:, which makes one job wait for another to finish first. Because each job gets its own throwaway machine, nothing a job installs or writes to disk carries over to another job automatically — that isolation is a feature, not a bug, but it surprises people who expect state to persist between jobs the way it would in a single script.
Steps: actions vs. run commands
Inside a job, steps run one after another, top to bottom, on the same machine — so state does carry over between steps within a job. Each step is one of two things:
run:— a shell command, exactly like typing it in a terminal.uses:— a pre-built action from GitHub's Marketplace (or your own repo), referenced asowner/repo@ref.
The single most common beginner surprise: a fresh runner doesn't have your code on it by default. The very first step in almost every job is uses: actions/checkout@v4, which clones your repository onto the runner. Forget it, and every later step that expects your files to be there fails in confusing ways.
A complete beginner example
A workflow that installs dependencies and runs tests on every push and pull request:
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- run: npm ci
- run: npm testRead top to bottom: check out the code, install Node 20, install exact dependency versions (npm ci, not npm install, for reproducible builds), run the test suite. actions/checkout and actions/setup-node are both maintained by GitHub itself — pinning to a version tag like @v4 is completely normal for getting started; pinning to a full commit SHA is a more advanced hardening step worth knowing about once you're comfortable with the basics.
Secrets: don't hardcode credentials
A workflow that deploys somewhere or calls an API needs credentials, and those never belong typed directly into the YAML file — anyone who can read the file (and, if the repo is public, that's everyone) can read a hardcoded value. Add credentials under your repo's Settings → Secrets and variables → Actions, then reference one as ${{ secrets.MY_TOKEN }}. GitHub automatically masks any registered secret value that shows up in a log, and the value is never visible in the workflow file itself.
Mistakes worth avoiding early
- YAML indentation. YAML uses indentation to mean structure, the same way Python does — two spaces off and a step silently belongs to the wrong job, or a mapping key becomes a value. There's no compiler catching this for you ahead of time; the workflow just behaves wrong.
- Forgetting actions/checkout. Covered above, but common enough to repeat: no checkout step means no files.
- Assuming jobs share state. Two jobs in the same workflow are two separate machines. If job B needs something job A built, you need
needs:plus an artifact upload/download step — it doesn't happen automatically. - Wrong branch filters.
on: push: branches: [main]only fires on pushes tomain— pushing to a feature branch triggers nothing, which is correct but catches people off guard the first time.
Try it yourself
Once you've got a workflow written, GitHub Actions Validator catches the structural mistakes above (a missing trigger, a job with no runs-on, a broken needs: reference) before you push, and GitHub Actions Workflow Visualizer shows the actual job order your workflow will run in, which is a great way to confirm your mental model of parallel jobs matches reality. When you're ready to think about security, GitHub Actions Linter and GitHub Actions Secrets Checker cover the practices in this post's last section in a lot more depth. All four run entirely in your browser.