The Problem
Most teams adopt GitHub Actions by copying a .yml file from a tutorial, tweaking it until the green checkmark appears, and never touching it again. Then a deploy fires on the wrong branch, a secret leaks into a log, or the test job runs before the build finishes. GitHub Actions is not complicated — but the pieces (events, jobs, steps, runners, actions) only make sense once you see how they connect.
The mental model is a chain: an event triggers a workflow, the workflow runs one or more jobs, and each job runs an ordered list of steps on a runner. Everything else — matrix builds, secrets, dependencies — is a refinement of that chain.
Where Workflows Live
A workflow is a single YAML file in .github/workflows/. You can have many of them, and GitHub evaluates each one independently against incoming events.
.github/
└── workflows/
├── ci.yml # build + test on every push and PR
├── deploy.yml # deploy on push to main
└── nightly.yml # scheduled jobs
The file name is cosmetic — the name: field inside controls what shows up in the Actions tab. GitHub picks up any .yml or .yaml file in that folder automatically.
Events: What Triggers a Workflow
The on: key declares which events start a run. The most common ones:
| Event | Fires when |
|---|---|
| <code class="inline-code">push</code> | Commits are pushed to a branch or tag |
| <code class="inline-code">pull_request</code> | A PR is opened, updated, or reopened |
| <code class="inline-code">workflow_dispatch</code> | Someone clicks "Run workflow" manually |
| <code class="inline-code">schedule</code> | A cron expression matches (UTC) |
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch: # manual button in the UI
schedule:
- cron: "0 2 *" # every day at 02:00 UTC
Scope your triggers tightly. A workflow that runs on push to every branch burns minutes and clutters the Actions tab. Most CI should run on PRs and on main.
Jobs, Steps, and Runners
A job is a unit of work that runs on a fresh runner — a VM GitHub spins up for you. Within a job, steps run sequentially and share the same filesystem. Across jobs, there is no shared disk unless you explicitly pass artifacts.
jobs:
build:
runs-on: ubuntu-latest # the runner
steps:
- uses: actions/checkout@v4 # an action (reusable step)
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci # a shell command
- run: npm run build
Two kinds of steps exist:
uses:runs a published action — a reusable, versioned package (always pin to a tag like@v4).run:executes a shell command directly on the runner.
Matrix Builds
A matrix runs the same job across multiple variable combinations in parallel — ideal for testing across language versions or operating systems.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci
- run: npm test
This single block produces four parallel runs (2 OSes × 2 Node versions). fail-fast: false lets every combination finish even if one fails, so you see the full picture.
Secrets and Environment Variables
Never hard-code tokens. Store them in repository or environment secrets and reference them with the secrets context. Set non-sensitive config with env.
jobs:
deploy:
runs-on: ubuntu-latest
env:
NODE_ENV: production # non-sensitive config
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Secrets are masked in logs automatically, but only if you reference them through the secrets context — a token echoed from your own script can still leak.
Job Dependencies with <code class="inline-code">needs</code>
By default, jobs run in parallel. Use needs: to force an order so deploy only happens after tests pass.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
test:
needs: build # waits for build to succeed
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
deploy:
needs: test # waits for test to succeed
if: github.ref == 'refs/heads/main' # only on main
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ./deploy.sh
The if: condition on deploy is the guardrail that stops a feature branch from shipping to production. needs: plus if: together give you a safe, ordered build → test → deploy pipeline.
Common Mistakes to Avoid
- Triggering on every branch. Scope
pushtomainand letpull_requestcover the rest, or you will waste minutes and money. - Using unpinned actions.
actions/checkout@maincan change under you. Pin to a tag (@v4) or a commit SHA for supply-chain safety. - Expecting state to persist across jobs. Each job gets a clean runner. Pass data with
actions/upload-artifact/download-artifact, not by assuming shared disk. - Putting deploy and build in one job. Split them so you can require tests to pass and gate deploy behind a branch condition.
- Echoing secrets in scripts. Only the
secretscontext is auto-masked. Printing a token yourself defeats that.
FAQ
What is the difference between a job and a step?
A job is a group of steps that runs on its own fresh runner; steps are the individual commands or actions inside a job. Steps in the same job share a filesystem and run in order, while jobs run in parallel unless linked with needs:.
When should I use <code class="inline-code">uses:</code> versus <code class="inline-code">run:</code>?
Use uses: to pull in a published, reusable action like actions/checkout or actions/setup-node, and use run: for your own shell commands. Actions save you from reinventing common setup logic and are versioned, so prefer them for standard tasks.
How do I make one job wait for another?
Add needs: <job-id> to the dependent job. It will only start after the named job (or jobs) completes successfully, which is how you build an ordered build → test → deploy chain.
Are secrets safe to use in workflows?
Yes, when referenced through the secrets context — GitHub masks those values in logs. Risk comes from printing them yourself or passing them to untrusted code, especially in workflows triggered by pull requests from forks.