15 — Your first workflow
Read this first: this lesson gets a GitHub Actions workflow running, on purpose breaks it, and teaches you to read the failure. The mechanism is simpler than it looks: GitHub rents you a computer, checks out your code, and runs shell commands. Everything else is detail.
Time: about 50 minutes. Assumes lesson 14 and a GitHub account.
The mental model
When you push, GitHub starts a fresh virtual machine — a runner — clones your repository onto it, runs the commands you listed, and throws the machine away.
That is it. A workflow is a YAML file describing shell commands to run on a temporary computer. Nothing in it is beyond you now: the commands are lesson 01, the failure rules are lesson 03, and the syntax is lesson 14.
The vocabulary, once:
| Term | Means |
|---|---|
| Workflow | One YAML file in .github/workflows/ |
Trigger (on:) | What starts it — a push, a pull request, a schedule, a button |
| Job | A set of steps that share one runner. Jobs run in parallel by default. |
| Step | One thing: either run: (a shell command) or uses: (someone else's step) |
| Runner | The temporary machine |
| Action | A reusable step published by someone, referenced with uses: |
Your first workflow
In any repository, create .github/workflows/hello.yml:
name: Hello
on:
push:
workflow_dispatch:
jobs:
greet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Say hello
run: echo "Hello from a computer I do not own"
- name: Look around
run: |
pwd
ls -la
whoami
Commit and push. Open the Actions tab and watch it run.
Points worth noticing:
workflow_dispatch:adds a "Run workflow" button. Add it to everything; being able to re-run on demand is worth the one line.actions/checkout@v4clones your repository. Without it the runner has your workflow file and nothing else — the code is not there by default. Forgetting it is the classic first mistake.@v4pins a version. Unpinned actions change under you.run: |is a shell script, and the runner's shell is bash.
Break it on purpose: push it broken
Predict: this step exits non-zero. Does the job go red? Do later steps run?
- name: This will fail
run: |
echo "about to fail"
ls /definitely/not/here
echo "do you see this?"
Push it, then read the log.
about to fail
ls: cannot access '/definitely/not/here': No such file or directory
Error: Process completed with exit code 2.
You do not see the third line, and the job is red. Two things are happening, both from lesson 03:
- Each
run:block runs withset -ebehaviour by default, so the block stops at the first failing command. - A step fails if its exit code is non-zero. That is the entire pass/fail mechanism. When someone asks "why is CI red", the answerable question is "which command exited non-zero".
By default a failed step stops the job. To carry on anyway — deliberately — there is
continue-on-error: true, which you should treat as documented debt rather than a fix. The real
tests.yml uses it on the frontend lint step and writes the
exact size of the debt in a comment next to it, along with the condition for removing it. That is
the honest way to use it.
Reading a failed run
The habit to build, in order:
- Which job is red? Not "the workflow failed" — which job.
- Which step inside it? Expand it. The log is grouped by step.
- What was the last command before the error, and what was its exit code? The line
Error: Process completed with exit code Nis at the bottom of the failing step. - Is the failure yours or the environment's? A test assertion is yours.
Cannot connect to the Docker daemonor a registry timeout is not.
That last distinction matters more than it sounds. The real tests.yml has a step that exists only
because of it: a Docker Hub rate limit used to surface as ExceptionInInitializerError and
NoClassDefFoundError — Java errors that look exactly like a code bug and are nothing of the sort.
The fix was to pull the image explicitly first, with a retry loop, so the real cause announces
itself:
- name: Pre-pull Testcontainers images
run: |
for attempt in 1 2 3; do
if docker pull --quiet postgres:16-alpine; then
exit 0
fi
echo "::warning::docker pull failed (attempt $attempt/3); retrying in $((attempt * 15))s"
sleep $((attempt * 15))
done
echo "::error::could not pull postgres:16-alpine after 3 attempts"
exit 1
Two things to take from that snippet. $((attempt * 15)) is shell arithmetic, giving 15s, 30s, 45s
backoff. And ::warning:: / ::error:: are workflow commands — printing a line in that format
makes GitHub surface it in the run summary. They are the cheapest way to make a failure explain
itself.
Triggers worth knowing
on:
push:
branches: [main]
pull_request:
schedule:
- cron: '30 3 * * 1' # 03:30 UTC every Monday
workflow_dispatch:
inputs:
tag:
description: 'Image tag to deploy'
required: false
default: ''
Path filters keep a workflow from running when nothing it cares about changed:
on:
push:
paths:
- 'docs/**'
- '!docs/archive/**'
! negates. This repository's docs workflow uses exactly this, and repeats the identical filter list
under both push: and pull_request: rather than sharing it with a YAML anchor — see
lesson 14 for why.
Note also which trigger is absent from tests.yml: there is no
push:. It runs on pull requests and when another workflow calls it. That is deliberate — without it
a merge would run the whole suite twice, once for the merge commit and once for the deploy.
Break it on purpose: the workflow that never runs
Predict: you fix the failing step, push, and… nothing happens. No run appears. Why might that be?
The usual causes, in order of frequency:
- The file is not in
.github/workflows/. It must be exactly that path, at the repository root. - A path filter excluded your change. You edited a file the workflow does not watch.
- A branch filter excluded your branch.
branches: [main]and you are on a feature branch. - The YAML is invalid, so GitHub never registered it. This one does show up — under the Actions tab, as a workflow with a red banner rather than a run.
Where this shows up in MotorPH
There are eight workflows in .github/workflows/. You can now read the shape of all of them:
| File | What it does |
|---|---|
tests.yml | Backend JUnit; frontend typecheck, lint and Vitest |
playwright.yml | End-to-end browser tests against a real stack |
deploy.yml | Build images, deploy to staging, then promote to production |
promote.yml | Manually promote a tag to production |
rollback.yml | Manually roll back |
docs.yml | Build and publish this documentation site |
codeql.yml | Static security analysis |
gitleaks.yml | Scan for committed secrets |
Open tests.yml now. It is 101 lines and you have the vocabulary for all of it.
Recap
- A workflow is shell commands on a rented computer.
on:starts it, jobs hold steps, steps arerun:oruses:. actions/checkout@v4is not automatic — without it the runner has no code.- A step is red when a command exits non-zero. Debugging CI is finding which command, and deciding whether the cause is your code or the environment.
::error::and::warning::make a failure explain itself; use them when a cause would otherwise be misread.
Next: 20 — The big picture, which puts the whole MotorPH pipeline together.