Post
GitHub Actions from zero to hero: jobs, expressions, matrix and caching
Growing a workflow: job options, jobs that wait for each other, expressions and contexts, passing values between steps and jobs, matrix builds, caches and artifacts.
The first part ran a single test job on every push and pull request. A real pipeline has several jobs, conditions and files that move between them.
Jobs, steps and runners
A job with a few more options:
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: app
env:
NODE_ENV: test
steps:
- uses: actions/checkout@v7
- name: Install
run: npm ci
- name: Lint and test
run: |
npm run lint
npm test
timeout-minutesstops a stuck job. Set it everywhere: the default is 360 minutes, which a hung test happily uses up.defaults.run.working-directoryruns everyrunstep in a subfolder, handy in a monorepo.envat the job level is visible to every step (it can also be set per workflow or step).nameon a step is what the log shows instead of the command.run: |is a multi-line string. Each line runs in the same shell; on Linux that is Bash, stopping at the first failing command.
Every job starts on a clean machine, so files from one job don’t exist in the next unless passed along as an artifact (below). GitHub-hosted runners come with many tools preinstalled (Git, Docker, Node, Python…); the runner images repository lists them.
Jobs that depend on each other
Jobs run in parallel by default. needs makes a job wait:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm test
build:
needs: [lint, test]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build
build starts only after both lint and test succeed; if either fails, it is skipped. That’s a pipeline: checks in parallel, then build, then deploy.
Expressions and contexts
Anything inside ${{ }} is an expression, evaluated by GitHub before the step runs. Expressions read contexts, objects full of information about the run:
| Context | What it holds | Example |
|---|---|---|
github |
The event and the repository | github.ref_name, github.sha, github.actor |
env |
Environment variables you set | env.NODE_ENV |
vars |
Configuration variables from the settings | vars.APP_URL |
secrets |
Secrets from the settings | secrets.NPM_TOKEN |
matrix |
The current matrix values | matrix.node |
steps |
Outputs of earlier steps in the job | steps.version.outputs.tag |
needs |
Outputs and results of jobs this job needs | needs.build.outputs.image |
runner |
The machine | runner.os |
inputs |
workflow_dispatch or workflow_call inputs |
inputs.environment |
The most common use is if, which decides whether a job or step runs:
steps:
- name: Deploy
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: ./deploy.sh
- name: Upload test report
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-report
path: reports/
By default a step runs only if every earlier step succeeded. Status functions change that: failure() runs only after a failure, always() runs no matter what, cancelled() runs if the run was cancelled. Helpers include contains(), startsWith(), format(), toJSON() and hashFiles() (expressions reference).
Passing data between steps and jobs
A step can’t set a shell variable for the next step, because each runs in its own process. Instead, steps write to special files (workflow commands):
jobs:
version:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.v.outputs.tag }}
steps:
- id: v
run: echo "tag=build-$(date +%Y%m%d)-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- run: echo "LOG_LEVEL=debug" >> "$GITHUB_ENV"
- run: echo "Log level is $LOG_LEVEL"
- run: echo "### Built ${{ steps.v.outputs.tag }}" >> "$GITHUB_STEP_SUMMARY"
use:
needs: version
runs-on: ubuntu-latest
steps:
- run: echo "The tag is ${{ needs.version.outputs.tag }}"
$GITHUB_OUTPUT: a step with anidwritesname=value, and later steps readsteps.<id>.outputs.<name>.$GITHUB_ENV: sets an environment variable for the following steps of the job.- Job
outputs: expose a step output to other jobs, which read it throughneeds. $GITHUB_STEP_SUMMARY: Markdown shown on the run’s summary page, great for test results.
GitHub also sets variables like GITHUB_SHA, GITHUB_REF_NAME and GITHUB_REPOSITORY in every job.
Matrix builds
A matrix runs the same job with different values. To test a library on two operating systems and three Node versions:
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
node: [22, 24, 26]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
- run: npm ci
- run: npm test
By default, when one combination fails GitHub cancels the others (fail-fast: true); false lets all finish, so you see the full picture. include adds combinations, exclude removes some, and max-parallel limits how many run at once.
Caching and artifacts
Both store files, and they solve different problems:
A cache is an optimization: it makes the next run faster. The easiest way is the cache option of the setup actions (cache: npm in the first workflow). For anything else, use actions/cache directly:
- uses: actions/cache@v6
with:
path: ~/.cache/pip
key: pip-${{ runner.os }}-${{ hashFiles('requirements.txt') }}
restore-keys: |
pip-${{ runner.os }}-
The key changes whenever requirements.txt does, so a new dependency list gets a new cache; restore-keys falls back to the newest cache with the prefix. A cache can be evicted any time, so the workflow must work without it.
An artifact is an output of the run: a build, a test report, a log. A job uploads it, and a later job in the run, or a person on the run page, downloads it:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: npm ci && npm run build
- uses: actions/upload-artifact@v7
with:
name: site
path: dist/
retention-days: 7
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v8
with:
name: site
path: dist/
- run: ls dist/