This lesson builds one workflow from nothing, explains every line, then deliberately breaks it so you see what each failure looks like.
Use a repository you are free to destroy. A public one is best — Actions minutes are free on public repositories, so nothing here costs anything.
Step 1 — A repository to work in
Section titled “Step 1 — A repository to work in”gh repo create actions-practice --public --add-readme --clonecd actions-practiceStep 2 — The directory that matters
Section titled “Step 2 — The directory that matters”GitHub looks for workflows in exactly one place:
mkdir -p .github/workflowsStep 3 — Write the workflow
Section titled “Step 3 — Write the workflow”cat > .github/workflows/hello.yml <<'YAML'name: Hello
on: push: branches: [main]
permissions: contents: read
jobs: greet: runs-on: ubuntu-latest steps: - name: Check out the repository uses: actions/checkout@v7
- name: Show where we are run: | echo "Repository: $GITHUB_REPOSITORY" echo "Commit: $GITHUB_SHA" echo "Branch: $GITHUB_REF_NAME" ls -laYAMLEvery line
Section titled “Every line”name: Hello — what appears in the Actions tab and in gh run list. Optional; without it GitHub
uses the file path, which is less readable.
on: — the trigger. This one runs on pushes to main and nothing else. A push to another branch
produces no run at all.
permissions: contents: read — what the automatically-provided GITHUB_TOKEN may do. Read
access to the repository and nothing else. Omitting this block accepts the repository default, which
may be considerably broader.
jobs: — the jobs this workflow contains. This has one.
greet: — the job’s ID. It appears in needs: references and becomes the status check name,
so it is worth choosing deliberately.
runs-on: ubuntu-latest — which runner. A fresh GitHub-hosted Ubuntu virtual machine, created
for this job and destroyed afterwards.
steps: — what the job does, in order. Each step is either uses: or run:.
uses: actions/checkout@v7 — run an action. This one clones your repository onto the runner.
Without it the runner has no copy of your code.
run: | — run shell commands. The | is YAML for “a multi-line string, newlines preserved”, so
each line is a separate command.
The $GITHUB_* values are default environment variables GitHub sets on every runner. There are
dozens; these three are the ones you reach for first.
Step 4 — Push it
Section titled “Step 4 — Push it”git add .github/workflows/hello.ymlgit commit -m "Add a first workflow"git pushWhat it doesCommits the workflow file and pushes it to main, which is the event the workflow listens for.
Why we run itThe push both delivers the workflow and triggers it. A workflow only runs from a branch where the file exists, which is why the very first push is what starts it.
Expected resultOrdinary push output. The run starts within a few seconds.
Step 5 — Watch it run
Section titled “Step 5 — Watch it run”gh run listSTATUS TITLE WORKFLOW BRANCH EVENT ID ELAPSED AGE✓ Add a first workflow Hello main push 18273645192 12s 1mgh run watchgh run view --logThe log shows more than your two steps. GitHub adds setup and teardown around them — provisioning the runner, and cleaning up afterwards. Your steps appear between, named as you named them, which is why naming them is worth the line.
Step 6 — Break it, deliberately
Section titled “Step 6 — Break it, deliberately”Four failures, each teaching something. Cause them on purpose now rather than accidentally later.
The missing checkout
Section titled “The missing checkout”Remove the actions/checkout step and push.
ls: cannot access 'README.md': No such file or directoryThe ls still runs — the runner exists. Your files do not, because nothing put them there. This is
the most common first-workflow failure, and its message never mentions checkout.
The indentation error
Section titled “The indentation error”Change runs-on to align with jobs: rather than under greet::
jobs: greet: runs-on: ubuntu-latest # wrong levelInvalid workflow fileYou have an error in your yaml syntax on line 11YAML indentation is structural. Two spaces versus none changes which key a value belongs to.
The wrong branch
Section titled “The wrong branch”Change the trigger to branches: [production] and push to main.
Nothing happens. No run, no error, no indication. The workflow file is valid and simply does not match the event — a silence that is correct behaviour and reads exactly like a broken configuration.
The failing command
Section titled “The failing command”Add - run: exit 1 and push.
The job fails, the run is marked failed, and:
gh run view --log-failedshows only the failing step. A step exiting non-zero fails its job and, unless configured otherwise, skips the remaining steps.
Step 7 — Add a second trigger
Section titled “Step 7 — Add a second trigger”Real workflows usually run on pull requests, not just pushes:
on: push: branches: [main] pull_request: branches: [main] workflow_dispatch:Three triggers now: pushes to main, pull requests targeting main, and manual runs.
workflow_dispatch adds a button, and makes the workflow runnable from the CLI:
gh workflow run hello.ymlsleep 5gh run list --workflow hello.yml --limit 1Step 8 — Make it do something real
Section titled “Step 8 — Make it do something real”Replace the greeting with an actual check:
name: CI
on: pull_request: branches: [main] push: branches: [main]
permissions: contents: read
concurrency: group: ci-${{ github.ref }} cancel-in-progress: true
jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7
- name: Verify the README is not empty run: | if [ ! -s README.md ]; then echo "::error file=README.md::README.md is empty" exit 1 fi echo "README.md has $(wc -l < README.md) lines"Two additions worth understanding.
concurrency cancels a previous run when you push again to the same ref. On an active branch this
stops you paying for results nobody will read. Note that this pattern is right for CI and wrong for
deployments, where cancelling half-finished work is worse than queueing.
::error file=README.md:: is a workflow command — output GitHub interprets rather than just
prints. This one creates an annotation attached to that file, which appears on the pull request next
to the line rather than buried in a log.
Step 9 — See it on a pull request
Section titled “Step 9 — See it on a pull request”git switch -c empty-readme> README.mdto empty the file- Commit and push, then
gh pr create --fill gh pr checks --watch- Observe the failure, and the annotation on the Files changed tab
- Restore the README, push again, and watch the check turn green
That loop — push, check runs, result appears on the pull request — is continuous integration. The CI cluster makes it substantial; the mechanism is exactly what you just built.
To make it binding, mark the check required through
branch protection or a
ruleset. The check’s name is the job’s name — check
here — which is why renaming a job silently breaks a required check.
Reading the run in detail
Section titled “Reading the run in detail”The run page and gh run view show more structure than the log implies, and knowing the layers makes
debugging much faster.
gh run view --json displayTitle,status,conclusion,event,headBranch,jobs \ --jq '{ title: .displayTitle, event: .event, branch: .headBranch, conclusion: .conclusion, jobs: [.jobs[] | {name, conclusion, steps: [.steps[] | {name, conclusion}]}] }'{ "branch": "main", "conclusion": "success", "event": "push", "jobs": [ { "conclusion": "success", "name": "check", "steps": [ { "conclusion": "success", "name": "Set up job" }, { "conclusion": "success", "name": "Run actions/checkout@v7" }, { "conclusion": "success", "name": "Verify the README is not empty" }, { "conclusion": "success", "name": "Post Run actions/checkout@v7" }, { "conclusion": "success", "name": "Complete job" } ] } ], "title": "Add a first workflow"}Four things there are not yours.
“Set up job” provisions the runner, resolves the actions your steps reference, and prepares the
environment. Failures here are usually a missing action or an invalid runs-on label.
“Post Run …” steps are actions’ cleanup phases. Many actions register a post step that runs after
your job’s steps finish — actions/checkout removes the credentials it configured, and
actions/cache saves the cache here rather than when you called it. A cache that “did not save”
usually failed in its post step, which is easy to miss because it appears after everything you wrote.
“Complete job” finalises and reports the conclusion.
The event field is worth noticing too: the same workflow behaves differently on push and
pull_request, particularly around available secrets and the token’s permissions, and this is where
you confirm which one you are looking at.
The three shells
Section titled “The three shells”run: executes in a shell, and which shell depends on the runner unless you say.
- run: echo "default shell for this runner"
- shell: bash run: echo "explicitly bash"
- shell: pwsh run: Write-Output "PowerShell Core"
- shell: python run: | import os print(os.environ["GITHUB_REPOSITORY"])On Linux and macOS the default is bash; on Windows it is PowerShell. A workflow with a matrix
spanning both will run the same run: block through two different shells, which is a genuine source
of confusion — echo "$VAR" and echo "$env:VAR" are not interchangeable.
Being explicit with shell: bash makes a workflow behave identically everywhere, at the cost of
requiring bash to exist. On GitHub-hosted Windows runners it does.
Multi-step and multi-job
Section titled “Multi-step and multi-job”Real workflows have several steps and often several jobs. The distinction matters because of what is and is not shared.
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - run: mkdir -p dist && echo "built" > dist/output.txt - run: cat dist/output.txt # works — same runner
verify: runs-on: ubuntu-latest needs: build steps: - run: cat dist/output.txt # fails — different runnerSteps within a job share a filesystem and a runner. Anything one step writes, the next can read.
Jobs do not share anything. verify gets its own fresh machine. It has no dist/, no checkout,
and no memory of build — even though needs: build made it wait.
Moving files between jobs requires artifacts; moving values requires outputs. This is the single most common structural misunderstanding after the missing checkout, and it produces the same confusing “no such file” message.
Where to put the logic
Section titled “Where to put the logic”A workflow that inlines forty lines of shell is harder to test, harder to review and impossible to run locally. The better arrangement keeps the logic in a script:
- run: ./scripts/check-readme.sh#!/usr/bin/env bashset -euo pipefail
if [ ! -s README.md ]; then echo "::error file=README.md::README.md is empty" exit 1fiecho "README.md has $(wc -l < README.md) lines"Three benefits, all of which compound as the pipeline grows. It runs locally, so you can debug without pushing. It is reviewable by people who do not read workflow YAML. And it is portable — if you ever leave GitHub, the logic moves and only the trigger needs rewriting.
The workflow’s job is to decide when something runs, on what machine, with what permissions. The script’s job is what actually happens.
Common mistakes
Section titled “Common mistakes”Wrong directory. .github/workflows/, exactly.
No checkout. The runner starts empty.
Tabs in YAML. Invalid. Spaces only.
Expecting a run on the wrong branch. A non-matching trigger produces silence.
Editing the workflow on a branch and expecting main to change. Workflows run from the ref that
triggered them.
Deployment steps in a workflow that runs on every branch. Filter the trigger.
Exercise
Section titled “Exercise”- Build the workflow from Step 8 in a disposable repository.
- Cause each of the four failures in Step 6 and read the message each produces.
- Add a second job that does not declare
needs:and confirm both start simultaneously. - Add
needs: checkto it and confirm it now waits. - Add
workflow_dispatchand trigger it withgh workflow run. - Delete the repository when finished.
Keeping the workflow honest
Section titled “Keeping the workflow honest”Three habits worth establishing on your first workflow rather than retrofitting later.
Set permissions explicitly, even when contents: read is all you need. It documents intent and
it means a future step that needs more has to say so.
Set timeout-minutes on any job that could hang. The default is the platform maximum, which is
long enough that a hung job wastes a great deal before anyone notices.
Add concurrency on anything triggered by pushes to a branch. Without it, three pushes in five
minutes produce three runs, two of whose results nobody will read.
permissions: contents: read
concurrency: group: ci-${{ github.ref }} cancel-in-progress: true
jobs: check: runs-on: ubuntu-latest timeout-minutes: 10Six lines, added once, that prevent three separate categories of waste and one category of risk.
What you learned
Section titled “What you learned”- Workflows live in
.github/workflows/; any other path is silently ignored. - The runner starts empty —
actions/checkoutis what puts your code on it. - YAML indentation is structural, and tabs are invalid.
- A non-matching trigger produces no run and no error.
gh run view --log-failedis the fastest route to a failure’s cause.- Workflow commands like
::error file=…::produce annotations rather than log lines. - A job’s ID is its status check name, which is what makes it requirable.
Adding a status badge
Section titled “Adding a status badge”A workflow’s current state can be embedded in the README:
The badge reflects the most recent run of that workflow on the default branch, or on the branch you name. It is a small thing and it is the first signal a visitor to a repository gets about whether the project is healthy.
Making the workflow a required check
Section titled “Making the workflow a required check”A green check is advisory until policy makes it binding.
- Run the workflow at least once, so GitHub knows the check exists.
- Add a ruleset or branch protection rule on the default branch.
- Require the status check whose name matches your job ID —
checkin the example above, not the workflow’sname. - Open a pull request that fails the check and confirm the merge button is disabled.
The name is the thing people get wrong. The required check is named after the job, not the workflow — and renaming a job silently breaks the requirement, leaving every pull request blocked on a check that will never report again.
gh api "repos/OWNER/REPO/branches/main/protection/required_status_checks" --jq '.contexts'gh api "repos/OWNER/REPO/commits/main/check-runs" --jq '.check_runs[].name'Comparing those two lists is how you confirm a required check actually corresponds to something that runs.
Where to go from here
Section titled “Where to go from here”The workflow you have built is the shape of every CI pipeline in this pillar. What changes as it grows:
More steps — installing a toolchain, restoring cached dependencies, running a real test suite.
More jobs — lint, test and build in parallel rather than one sequence.
A matrix — the same job across several language versions or operating systems.
Artifacts — preserving reports and binaries beyond the run.
Conditions — deploying only from the default branch, only on tags, only after approval.
Each of those is a lesson in this pillar, and each is an addition to what you already have rather than a different structure. The CI cluster builds it out for eight real project types.
What you now have
Section titled “What you now have”A workflow that runs on pull requests and pushes, checks something real, reports annotations, and can be made a required check. That is a working CI pipeline — small, and structurally identical to a large one.
The rest of this cluster explains the pieces you used: the YAML it is written in, the triggers that started it, the jobs and steps it contains, and the action that checked out your code.
Keep the repository. Every exercise in this cluster extends it, and having one place where all the concepts are demonstrated together is worth more than the individual examples.
Before moving on
Section titled “Before moving on”Confirm you can do these four things without looking them up, because the rest of the pillar assumes them:
Create a workflow file in the right directory. Trigger it and find its run. Read a failure with
gh run view --log-failed. And explain why a job that does not check out the repository cannot see
your code.
If any of those is uncertain, the exercise above is worth repeating — it costs ten minutes and everything after this builds on it.