Skip to main content

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:

TermMeans
WorkflowOne YAML file in .github/workflows/
Trigger (on:)What starts it — a push, a pull request, a schedule, a button
JobA set of steps that share one runner. Jobs run in parallel by default.
StepOne thing: either run: (a shell command) or uses: (someone else's step)
RunnerThe temporary machine
ActionA 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@v4 clones 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.
  • @v4 pins 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:

  1. Each run: block runs with set -e behaviour by default, so the block stops at the first failing command.
  2. 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:

  1. Which job is red? Not "the workflow failed" — which job.
  2. Which step inside it? Expand it. The log is grouped by step.
  3. What was the last command before the error, and what was its exit code? The line Error: Process completed with exit code N is at the bottom of the failing step.
  4. Is the failure yours or the environment's? A test assertion is yours. Cannot connect to the Docker daemon or 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:

  1. The file is not in .github/workflows/. It must be exactly that path, at the repository root.
  2. A path filter excluded your change. You edited a file the workflow does not watch.
  3. A branch filter excluded your branch. branches: [main] and you are on a feature branch.
  4. 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:

FileWhat it does
tests.ymlBackend JUnit; frontend typecheck, lint and Vitest
playwright.ymlEnd-to-end browser tests against a real stack
deploy.ymlBuild images, deploy to staging, then promote to production
promote.ymlManually promote a tag to production
rollback.ymlManually roll back
docs.ymlBuild and publish this documentation site
codeql.ymlStatic security analysis
gitleaks.ymlScan 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 are run: or uses:.
  • actions/checkout@v4 is 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.