GitHub Actions: your first real pipeline
How GitHub Actions turns an event into jobs on a runner: the YAML you need, the triggers that matter, jobs and steps, what a runner gives you, and checking a workflow before you push it.
By the end of this lesson you'll be able to
0 of 6 completeHow an event becomes a run
A workflow is a YAML file in .github/workflows/. Something happens in the repository (a push, a
pull request, a button press, a time of day), GitHub finds every workflow listening for that event,
and starts a run. Each run has jobs; each job gets a fresh runner machine and executes its
steps in order:
Every lesson in this course builds the same small app, shop-api: a Node.js service with a
/health endpoint, a /price endpoint that adds tax, and tests that use Node's built-in test runner.
No dependencies to install, so the pipeline is the only thing you're learning:
$ npm run lint && npm test ✔ adds 18% tax and rounds to cents ✔ rejects a negative amount ✔ GET /health answers ok ℹ tests 3 ℹ pass 3 ℹ fail 0
npm run lint && npm test&&run the next command only if this one worked
Here's its first pipeline. Save it as .github/workflows/ci.yml:
name: CI on: push: branches: [main] pull_request: workflow_dispatch: jobs: lint: runs-on: ubuntu-latest timeout-minutes: 5 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 - run: npm run lint test: needs: lint runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 22 - name: Run the tests run: npm test
The YAML you need
Workflows use a small part of YAML. Indentation is structure: two spaces, never tabs.
| You write | YAML means | Used for |
|---|---|---|
key: value | a mapping | runs-on: ubuntu-latest |
- item | a list item | each step |
[a, b] | a list on one line | branches: [main, release/*] |
run: | then indented lines | a multi-line string, newlines kept | scripts of several commands |
# text | a comment | explaining why |
- name: Build and smoke-test run: | npm run build node dist/server.js & sleep 2 && curl -fsS http://localhost:3000/health
Triggers: when a workflow runs
The on: block lists the events. These five cover almost every pipeline:
on: push: branches: [main] # pushes to main only paths-ignore: ['**.md'] # skip runs for documentation-only changes pull_request: # every PR, on open and on each new push to it branches: [main] # …targeting main workflow_dispatch: # a "Run workflow" button, and gh workflow run inputs: environment: type: choice options: [staging, production] schedule: - cron: '30 22 * * *' # 22:30 UTC = 04:00 IST, every day release: types: [published] # when a GitHub Release is published
| Event | Typical use |
|---|---|
pull_request | the checks that gate merging: lint, test, build |
push to main | build the artifact, deploy to staging |
workflow_dispatch | manual deploys, one-off maintenance |
schedule | nightly security scans, cleanup, dependency checks |
release | deploy to production (the Git course's Tags and releases lesson) |
schedule uses UTC, and scheduled runs can start several minutes late when GitHub is busy. They
also stop after 60 days without activity in a public repository. Start a dispatch workflow from the
terminal with gh workflow run ci.yml.
Jobs and steps
A job is a set of steps that run on one runner. Jobs run in parallel unless needs: says
otherwise:
jobs: lint: { runs-on: ubuntu-latest, steps: [...] } test: { needs: lint, runs-on: ubuntu-latest, steps: [...] } # waits for lint build: { needs: [lint, test], runs-on: ubuntu-latest, steps: [...] }
A step does one of two things:
| Step | Runs | Example |
|---|---|---|
uses: | a reusable action, configured with with: | uses: actions/checkout@v7 |
run: | shell commands on the runner (bash on Linux) | run: npm test |
Each step must succeed (exit code 0) for the next one to run. When one fails, the job fails, the rest of
its steps are skipped, and jobs that need it are skipped too.
Runners: where jobs run
A runner is the machine a job runs on. GitHub-hosted runners are fresh virtual machines, created for each job and destroyed after it:
runs-on: | Machine | Notes |
|---|---|---|
ubuntu-latest | Linux VM | the default choice: fastest to start, cheapest |
windows-latest | Windows VM | for Windows builds; costs more minutes than Linux on private repos |
macos-latest | macOS VM | for iOS and Mac builds; the most expensive minutes |
ubuntu-24.04 | pinned Linux image | use a fixed version when -latest moving would break you |
The images come with Git, Docker, Node, Python, Java, the cloud CLIs and much more preinstalled (the full list is linked below). Public repositories get free minutes; private ones get a monthly allowance on each plan. The Scaling pipelines lesson covers self-hosted runners, for when you need your own hardware or network access.
Check it before you push
A typo in a workflow is found only after you push, wait and read a red run. actionlint finds most of them in under a second, on your machine. Here it is on a deliberately broken workflow:
# .github/workflows/broken.yml on: push jobs: t: runs-on: ubuntu-latest steps: - run: echo ${{ github.event.pull_request.title }} - uses: actions/checkout@v3 needs: build
$ actionlint .github/workflows/broken.yml:3:3: job "t" needs job "build" which does not exist in this workflow [job-needs] .github/workflows/broken.yml:6:23: "github.event.pull_request.title" is potentially untrusted. avoid using it directly in inline scripts. instead, pass it through an environment variable. [expression] .github/workflows/broken.yml:7:15: the runner of "actions/checkout@v3" action is too old to run on GitHub Actions. update the action's version to fix this issue [action] $ echo $? 1
echo $?echoprint text
Three real bugs: a job that waits for one that doesn't exist, a script-injection hole (the Pipeline security lesson explains it), and an action too old to run. Messages are shortened here; actionlint adds a link to the docs for each.
It checks structure, needs:, expressions, action inputs, outdated actions and, with shellcheck installed,
the shell in your run: steps. Run it in a pre-commit hook (the Git course's Hooks lesson) and you'll
rarely see a broken workflow on GitHub.
Give shop-api its first pipeline
Create the shop-api app, add a CI workflow with a lint job and a test job that runs only after lint passes, check it with actionlint, then push it on a branch and open a pull request to see both checks run.
A pull request showing lint and test as passing checksReveal one safe solution
The whole app is two small files. src/app.js:
import { createServer } from 'node:http' export function priceWithTax(amount, rate = 0.18) { if (!Number.isFinite(amount) || amount < 0) throw new RangeError('amount must be a positive number') return Math.round(amount * (1 + rate) * 100) / 100 } export function createApp() { return createServer((req, res) => { const url = new URL(req.url, 'http://localhost') if (url.pathname === '/health') return send(res, 200, { status: 'ok', version: process.env.APP_VERSION ?? 'dev' }) if (url.pathname === '/price') { try { return send(res, 200, { total: priceWithTax(Number(url.searchParams.get('amount'))) }) } catch (err) { return send(res, 400, { error: err.message }) } } send(res, 404, { error: 'not found' }) }) } function send(res, status, body) { res.writeHead(status, { 'content-type': 'application/json' }) res.end(JSON.stringify(body)) }
src/server.js starts it, and test/app.test.js tests it:
// src/server.js import { createApp } from './app.js' const port = Number(process.env.PORT ?? 3000) createApp().listen(port, () => console.log(`shop-api listening on :${port}`))
// test/app.test.js import { test } from 'node:test' import assert from 'node:assert/strict' import { priceWithTax, createApp } from '../src/app.js' test('adds 18% tax and rounds to cents', () => { assert.equal(priceWithTax(100), 118) assert.equal(priceWithTax(9.99), 11.79) }) test('rejects a negative amount', () => { assert.throws(() => priceWithTax(-1), RangeError) }) test('GET /health answers ok', async () => { const server = createApp().listen(0) const { port } = server.address() const res = await fetch(`http://localhost:${port}/health`) assert.equal(res.status, 200) assert.equal((await res.json()).status, 'ok') server.close() })
$ mkdir -p shop-api/src shop-api/test shop-api/.github/workflows && cd shop-api && git init $ npm init -y $ npm pkg set type=module scripts.test="node --test" scripts.lint="node --check src/app.js && node --check src/server.js" $ npm run lint && npm test # after saving the three files above $ nano .github/workflows/ci.yml # the workflow from this lesson $ actionlint && echo "workflow OK" workflow OK $ git add . && git commit -m "ci: add lint and test workflow" $ gh repo create shop-api --private --source=. --push $ git switch -c test/ci && git commit --allow-empty -m "test: trigger CI" && git push -u origin test/ci $ gh pr create --fill && gh pr checks --watch
mkdir -p shop-api/src shop-api/test shop-api/.github/workflows && cd shop-api && git initmkdirmake a directory-pcreate missing parents, no error if it exists&&run the next command only if this one workedcdchange directorygitversion controlinitcreate a repository here
actionlint && echo "workflow OK"echoprint text
git add . && git commit -m "ci: add lint and test workflow"addstage changes.this foldercommitsave staged changes
git switch -c test/ci && git commit --allow-empty -m "test: trigger CI" && git push -u origin test/cipushupload commits
node --check checks one file per call, which is why the lint script runs it twice. gh pr checks --watch shows lint finishing first and test starting after it, because of needs: lint. Break a
test on purpose, push again, and watch the pull request turn red. Later lessons add to this same
repository.
Try it yourself
0 of 6 steps doneRun each task in your own account or terminal, then tick it off.
Quick check
Two jobs in one workflow have no needs between them. How do they run?
Official reading for this lesson
Or get every quiz answer right and it completes itself.