GitHub Actions runs code in response to events in your repository. The moving parts are few; most of the difficulty people hit is vocabulary, so we will settle that first.
The hierarchy
Workflow a YAML file in .github/workflows/
└── triggered by an event (push, pull_request, schedule…)
└── Job runs on one runner; jobs are parallel by default
└── Step a command, or a reusable Action
└── runs: a shell command
└── uses: a packaged action
Two facts carry most of the consequences:
- Each job gets a fresh machine. Nothing on disk survives between jobs.
- Steps within a job share that machine, sequentially.
A complete first workflow
# .github/workflows/ci.yml
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'
cache: npm
- run: npm ci
- run: npm test
Commit that to .github/workflows/ci.yml, push, and it runs. Line by line:
name— what shows in the Actions tab. Optional.on— which events trigger it.jobs.test—testis an id you choose; other jobs refer to it.runs-on— which runner.ubuntu-latestis the cheapest and fastest.uses: actions/checkout@v4— clone the repo. Without this the runner has no code.with:— inputs to the action.run:— a shell command on the runner.
- Forgetting
actions/checkout. "npm: no such file or directory" — because the directory is empty. - Expecting files to persist between jobs. They do not. Use artifacts or run in one job.
- Putting the workflow on a feature branch and expecting it to run for
main. The workflow file must exist on the branch the event relates to.
Events
on:
push:
branches: [main, 'release/**']
paths: ['src/**', 'package.json']
tags: ['v*']
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
schedule:
- cron: '0 3 * * 1' # 03:00 UTC every Monday
workflow_dispatch: # manual, with inputs
inputs:
environment:
type: choice
options: [staging, production]
default: staging
release:
types: [published]
issue_comment:
types: [created]
Filters within one event are ANDed: branches and paths must both match.
push tests the commit as pushed. pull_request tests a simulated merge of your branch into the base — which is usually what you want, since that is what will actually land. A branch in the same repository fires both, so unfiltered workflows run twice per push. Restricting push to branches: [main] is the usual fix.
Steps: run and uses
steps:
# A shell command
- name: Run tests
run: npm test
# Multiple lines
- name: Build and verify
run: |
npm run build
ls -la dist/
test -f dist/index.js
# A different shell
- run: Get-ChildItem
shell: pwsh
# A packaged action
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# An action from any repository, or a Docker image
- uses: my-org/my-action@v1
- uses: docker://alpine:3.19
Contexts and expressions
${{ }} evaluates an expression. The contexts you will use constantly:
${{ github.ref }} # refs/heads/main
${{ github.ref_name }} # main
${{ github.sha }} # the commit
${{ github.event_name }} # push, pull_request…
${{ github.actor }} # who triggered it
${{ github.repository }} # owner/name
${{ github.run_number }} # incrementing per workflow
${{ secrets.MY_SECRET }}
${{ vars.MY_VARIABLE }} # non-secret config
${{ env.MY_ENV }}
${{ matrix.version }}
${{ needs.build.outputs.tag }}
${{ steps.meta.outputs.value }}
${{ runner.os }} # Linux, Windows, macOS
Useful functions: contains(), startsWith(), endsWith(), format(), join(), toJSON(), fromJSON(), hashFiles().
Conditionals
jobs:
deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Upload logs even if the build failed
if: always()
uses: actions/upload-artifact@v4
with:
name: logs
path: logs/
- name: Notify only on failure
if: failure()
run: ./scripts/alert.sh
Status functions: success() (the implicit default), failure(), always(), cancelled().
The if: key is already an expression context, so write if: github.ref == 'refs/heads/main', not if: ${{ github.ref == … }}. The braces form usually works but is redundant, and it silently misbehaves in some nested cases.