Writing Your First GitHub Action
Most teams start with GitHub Actions by copy-pasting a workflow from another repo and hoping it still applies. That works until the day it doesn't, and you need to actually understand what triggers a run, what a job and a step are, and how to pass data between them. This walks through building a real workflow from nothing — running tests on every pull request and publishing a package on tagged releases.
The anatomy of a workflow
A workflow is a YAML file in .github/workflows/. It has three core pieces: triggers (on) decide when it runs, jobs are groups of steps that run on their own virtual machine, and steps are the individual commands or actions inside a job.
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
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 test
actions/checkout@v4 clones your repo into the runner — without it, the runner is an empty VM with no access to your code. actions/setup-node installs a specific Node version. npm ci (not npm install) is important in CI: it installs exactly what's in package-lock.json and fails if the lockfile is out of sync, instead of silently updating it.
Running jobs in parallel with a matrix
If you need to test against multiple Node versions or operating systems, a matrix expands one job definition into several parallel runs:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm test
This runs three independent jobs, one per Node version, all in parallel. ${{ matrix.node-version }} is GitHub Actions' expression syntax — it interpolates the current matrix value into the step.
Caching dependencies
Every job starts on a fresh VM, so npm ci re-downloads every package on every run unless you cache them:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
setup-node's built-in cache option handles this for npm, yarn, and pnpm — it keys the cache to your lockfile hash, so a cache hit only happens when dependencies haven't changed.
Passing data between steps
Steps in the same job share a filesystem but not variables — to pass a value from one step to the next, you write to a special output file:
steps:
- id: get_version
run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
- run: echo "Building version ${{ steps.get_version.outputs.version }}"
The id: get_version gives the step a name you can reference later as steps.get_version.outputs.version. This pattern — read something, write it to $GITHUB_OUTPUT, reference it downstream — is how most non-trivial workflows move data around.
Secrets
Never hardcode credentials in a workflow file — it's committed to the repo and visible to anyone with read access. Store them under repo Settings → Secrets and variables → Actions, and reference them via the secrets context:
- name: Publish package
run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Secrets are masked in logs automatically — if a step accidentally prints one, GitHub replaces it with *** in the output.
Triggering only on tagged releases
Combine on.push.tags with a conditional job to publish only when you push a version tag, while still running tests on every push:
name: Release
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Pushing a tag like v1.2.0 runs this workflow; a regular commit to main doesn't, since the trigger is scoped to tags, not branches.
if: conditions for finer control
Sometimes a trigger alone isn't precise enough. if: lets you gate a step or job on an expression:
- name: Notify on failure
if: failure()
run: echo "Build failed on ${{ github.ref }}"
failure() is one of several built-in functions (alongside success(), always(), cancelled()) that check the outcome of previous steps in the job — useful for sending a notification only when something breaks, without a separate workflow.
Debugging a workflow
Two things save the most time when a workflow misbehaves:
act— runs GitHub Actions workflows locally in Docker, so you can iterate without pushing a commit for every typo.workflow_dispatchtrigger — addon: workflow_dispatchto any workflow to get a "Run workflow" button in the GitHub UI, so you can re-run it manually without a new push:
on:
workflow_dispatch:
push:
branches: [main]
Takeaway
A GitHub Actions workflow is just YAML describing triggers, jobs, and steps, run on ephemeral VMs that start with nothing but what actions/checkout gives them. Once you can read on:, jobs:, steps:, and the ${{ }} expression syntax fluently, every workflow you find online stops looking like magic and starts looking like a config file you can actually modify.