This is part one of three. It covers everything you need to do real work with GitHub Actions, not a teaser. By the end you can write, read, debug, and ship a production CI/CD pipeline. Mid-level and Senior take the same topics further; nothing here is thrown away.
Each section ends with a Try it task. Do them as you go. They take a few minutes each, and the concepts stick only once you have watched your own run go green.
What is GitHub Actions?
GitHub Actions is GitHub's built-in automation platform. You describe work you want done in a text file, commit that file to your repository, and GitHub runs the work for you on a computer it creates on demand.
Compare that to what it replaces.
Before this existed, automating a project meant running a separate machine (a Jenkins server, a TeamCity box, something in a cupboard) keeping it patched, giving it credentials to reach your code, and hoping the person who configured it three years ago left notes. Actions removes that entire layer. There is no server to maintain, because GitHub provisions a fresh machine for each piece of work and destroys it afterwards.
Two consequences of that design explain most of what follows. Notice them now rather than discovering them later.
The instructions live inside your repository. That means your automation is versioned, branched, and reviewed exactly like your source code. If a pipeline breaks, git log tells you who changed it and why. You can propose a change to it in a pull request and have a colleague review it before it takes effect. This sounds administrative; in practice it is the single biggest reason teams stop being afraid of their build system.
Every run starts on a clean machine. There is no leftover state from last time, no "it works on the build server because someone installed something there in 2019". This is a feature, it makes runs reproducible, but it is also the biggest source of confusion for newcomers, and we will come back to it repeatedly.
What people use it for:
Run tests automatically
Every push and pull request gets built and tested without anyone remembering to do it.
Deploy on merge
Code that passed its checks ships itself, to a server, a registry, or a package index.
Scheduled jobs
Nightly builds, weekly dependency checks, database backups, cleanup tasks.
Repository chores
Label issues, greet new contributors, close stale threads, publish release notes.
To follow along you need a free GitHub account, the ability to make a commit, and a repository. Any repository, in any language, and a brand-new empty one is the best place to experiment. Public repositories get unlimited free minutes, so a playground costs you nothing.
- Create a public repository called
actions-playground. It can be empty. - Open its Actions tab. GitHub will offer you starter workflows. Do not pick one yet, just look.
- Open any popular open-source repository you use and click its Actions tab.
What are CI and CD?
These two abbreviations appear in every conversation about Actions, so be precise about them. They name practices rather than tools, and Actions is one way to implement them.
The problem they solve
Imagine three people working on the same project. Every time someone finishes a change, somebody has to remember to run the tests, check the formatting, build the application, and put it on a server. Nobody enjoys that, so it gets skipped when people are busy. Broken code reaches the main branch on a Friday and nobody notices until Monday. Someone deploys from their laptop, and their laptop happens to have a different library version than the server, so it works locally and breaks in production.
Every one of those failures has the same root cause: a human being is doing the repetitive work. Humans are excellent at judgement and terrible at performing the same twelve steps identically four hundred times.
Continuous Integration
CI means every change is automatically built and tested the moment it appears.
The word "integration" is about merging work together. Historically, developers would work in isolation for weeks and then merge, a painful event because everyone's changes conflicted at once. Continuous integration means merging small changes frequently and verifying each one immediately. If someone breaks the project, they find out in four minutes rather than four days, while the change is still fresh in their mind and small enough to understand.
In practice, CI is a machine that on every push: fetches your code, installs dependencies, runs the linter, runs the type checker, runs the tests, and reports pass or fail.
Continuous Delivery and Continuous Deployment
CD means that once a change has passed all of its checks, shipping it is also automatic.
Confusingly, CD stands for two related things:
| Term | Means | Human involvement |
|---|---|---|
| Continuous Delivery | Every passing change is automatically packaged and made ready to release | A person clicks "deploy" |
| Continuous Deployment | Every passing change is automatically released to production | None |
Most teams practise continuous delivery and describe it as continuous deployment. The distinction matters in interviews, and it matters when you design a pipeline: adding a human approval gate turns one into the other, and Actions supports both.
What this looks like in practice
Put together, CI/CD is a machine that watches your repository and does the boring, critical work identically every single time. The benefits compound in ways that are hard to appreciate until you have worked without them:
| Without CI/CD | With CI/CD |
|---|---|
| "Did you run the tests?" | The tests ran, and the report is on the commit |
Broken main discovered days later |
Broken main discovered in minutes |
| Deploys are a scary manual ritual | Deploys are a merge |
| "It works on my machine" | It works on a clean machine, every time |
| Release notes written from memory | Release notes generated from commits |
| Only one person knows how to ship | The pipeline is the documentation |
- Pick a project you work on. Write down every manual step between "I finished the code" and "it is running for users".
- Mark each step CI (proving the change is safe) or CD (shipping it).
- Mark the ones that are identical every time. Those are the ones a machine should do.
Where workflow files live, and why the path is fixed
Instructions for Actions go in files called workflows, and the location is exact. Get it wrong and GitHub ignores the file: no error, nothing in the Actions tab, no hint that anything is missing.
my-project/
├── .github/
│ └── workflows/ ← must be exactly this
│ ├── ci.yml ← becomes active on commit
│ └── release.yml ← so does this
├── src/
└── README.md
Any file in that folder ending .yml or .yaml becomes an active workflow the moment it is committed. The leading dot on .github is intentional: on Linux and macOS it makes the folder hidden, which is why you may not see it in a file browser.
.github/workflow/ (missing the s) · github/workflows/ (missing the dot) · ci.yml in the repository root. All three do nothing at all, and none of them produce an error message.
- In your playground repository, click Add file → Create new file.
- Type the filename as
.github/workflows/notes.ymland note how GitHub turns each slash into a folder as you type. - Put a single comment in the file:
# scratch space. Commit it.
YAML in five minutes, for workflow authors
Workflow files are written in YAML, a text format designed to be read by people rather than to be efficient for machines. Almost everything you will write is covered by six rules.
| Rule | Looks like | Means |
|---|---|---|
| Key and value | name: CI |
Setting name is CI |
| Two-space indent nests | jobs: then build: |
build belongs to jobs |
| Dash makes a list item | - run: npm test |
One item in a list |
| Hash is a comment | # runs on PRs |
Ignored by the machine |
| Quote version numbers | '3.11' not 3.11 |
Unquoted becomes the number 3.1 |
| ` | ` keeps a multi-line block | `run: |
Indentation replaces the braces and brackets you might know from JSON. Because whitespace carries meaning, two rules are absolute: use spaces, never tabs, and keep every item at the same level in the same column.
name: Example # a key with a text value
on: push # another key/value pair
jobs: # a key whose value is a nested structure
build: # two more spaces, so build belongs to jobs
runs-on: ubuntu-latest
steps: # a key whose value is a list
- run: echo one # list item
- run: | # list item with a multi-line value
echo two
echo three
.editorconfig for exactly this in the Tips & Tricks section.
- Open the
notes.ymlyou created and write theExampleblock above into it, by hand rather than pasting. - Deliberately break it: remove two spaces from in front of
runs-on. Commit. - Check the Actions tab, then fix the indentation and commit again.
Your first workflow
Enough theory. Create .github/workflows/hello.yml. You can do this entirely in the browser via Add file → Create new file.
name: My First Workflow # label shown in the Actions tab
on: push # the trigger: run on every push
jobs:
greet: # job id — you choose this name
runs-on: ubuntu-latest # ask GitHub for a Linux machine
steps:
- name: Say hello
run: echo "Hello from a machine that did not exist a minute ago"
- name: Show run info
run: |
echo "OS : ${{ runner.os }}"
echo "Repo : ${{ github.repository }}"
echo "Pushed by : ${{ github.actor }}"
Commit it. Committing is itself a push, so the workflow triggers immediately. Open the Actions tab and watch it run.
Now let me walk through every line, because this small file contains the entire conceptual model.
name: My First Workflow is a human label. Omit it and GitHub shows the file path instead, which is why unnamed workflows make the Actions tab hard to read.
on: push is the trigger, the doorbell. This says "run whenever commits reach any branch". Without an on: key a workflow can never run at all, which is why your notes.yml did nothing.
jobs: opens the list of work. greet: is a job id that you invent; it identifies this job in the interface and in any dependency you declare later.
runs-on: ubuntu-latest asks GitHub for a machine. This is per job, not per workflow, a deliberate design choice we unpack in the next section.
steps: is the ordered list of things to do on that machine. Each - name: / run: pair is one step: name is the label in the log, run is a shell command executed on the runner.
The | in the second step means "the following indented block is one multi-line string", so those three lines run as a small shell script within a single step.
${{ ... }} is an expression: GitHub substitutes a real value there before the command runs. runner.os becomes Linux; github.repository becomes your-name/your-repo.
What happened while you were watching
push event describing the branch, the commit, and who pushed it..github/workflows/ and found one whose on: matched.- Commit the workflow above, then open the run and expand both steps to read their output.
- Add a third step
- run: dateand find its output in the next run. - Change
on: pushtoon: workflow_dispatch, commit, then use the Run workflow button in the Actions tab. - Break it on purpose with
- run: exit 1. Watch the red cross appear, open the log, then fix it.
The six building blocks, nested
Every workflow you will ever read is built from six nested concepts. Getting this hierarchy solid is worth more than memorising a hundred YAML keys.
pushA branch or tag movedpull_requestOpened, synchronised, reopenedschedule · workflow_dispatchCron, or a buttontestSteps run in order, sharing one filesystemlintStarts at the same time as testdeployneeds: [test, lint], the only way to orderrun a command, or uses an actionThe line that matters: steps share a machine, jobs do not. Everything else in this guide follows from that one fact: runs-on belongs to a job, parallelism is free, and handing files between jobs needs artifacts.
An event is something that happened in your repository. A workflow is a file saying which events it cares about and what to do. A job is a named group of work running on one machine. A step is a single task inside a job. A runner is the machine itself. An action is a reusable step someone has already written and published.
The relationship that trips up almost everyone is between jobs and steps, so let me be explicit:
Steps share everything
- Same machine, same filesystem
- Run in the order written
- Files one step creates, the next can read
- Installed tools persist between them
- A failure stops the rest of the job
Jobs share nothing
- Each gets its own fresh machine
- Run at the same time by default
- Files do not cross between them
- No shared installed tools
- Ordered only by
needs
This is why runs-on is a property of a job rather than the workflow: each job is asking for its own computer. It is also why splitting a pipeline into jobs is a real decision with real consequences rather than tidiness. Two jobs run in parallel and finish sooner, but they cannot hand files to each other without extra machinery.
- Add a step that creates a file:
- run: echo "hello" > note.txt. - Add another step after it:
- run: cat note.txt. Commit and confirm it works. - Now move the
catstep into a second job with its ownruns-on, and run it again.
Triggers: what starts a workflow
on: decides when a workflow runs, and it is where a lot of a pipeline's usefulness comes from. These are the triggers worth knowing on day one.
| Trigger | Fires when | Use it for |
|---|---|---|
push |
Commits reach a branch or tag | Test every change |
pull_request |
A PR opens, updates, or reopens | Check before merging |
workflow_dispatch |
You click Run workflow | Manual runs, deploys |
schedule |
A cron time matches (UTC) | Nightly jobs, cleanups |
release |
You publish a release | Build and attach downloads |
issues, issue_comment |
Issue activity | Triage bots |
You can list several at once, and you can narrow each one so it only fires in the situations you care about:
on:
push:
branches: [main] # only main, not every branch
tags: ['v*'] # and any v-prefixed tag
paths-ignore: ['docs/**', '**/*.md'] # but not doc-only changes
pull_request:
branches: [main] # PRs targeting main
types: [opened, synchronize, reopened]
schedule:
- cron: '0 3 * * *' # 03:00 UTC daily
workflow_dispatch: # and a manual button
The five cron fields are minute hour day-of-month month day-of-week, so '0 3 * * *' reads as "minute 0 of hour 3, any day, any month, any weekday". Two things surprise people: schedules are always UTC, never your local time, and they only run from your repository's default branch, so testing one on a feature branch will drive you mad.
Manual runs with inputs
workflow_dispatch can ask for values, which turns a workflow into a small internal tool:
on:
workflow_dispatch:
inputs:
environment:
description: Where to deploy
type: choice
options: [staging, production]
default: staging
dry-run:
description: Print actions without doing them
type: boolean
default: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- run: |
echo "target : ${{ inputs.environment }}"
echo "dry run: ${{ inputs.dry-run }}"
Inputs support string, boolean, choice, and environment types, and you read them through the inputs context.
on: before reading anything else. It is nearly always a branches filter you forgot, a file in the wrong folder, or a schedule you are waiting for in the wrong timezone. When something does not run, the trigger is the suspect, not the steps.
- Change your workflow's trigger to
branches: [main], then push a commit to a new branch calledtest-filter. - Confirm nothing runs. Then open a pull request from that branch and add
pull_request:to the trigger list. - Add the
workflow_dispatchblock with theenvironmentchoice input, and run it manually twice, once for each option.
Jobs: parallel by default, ordered with needs
Jobs run at the same time unless you say otherwise. needs creates ordering.
jobs:
lint: # lint and test start together
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: ruff check .
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pytest -q
deploy:
needs: [lint, test] # waits for BOTH to succeed
runs-on: ubuntu-latest
steps:
- run: ./deploy.sh
needs builds a dependency graph. A job with no needs starts immediately; a job with needs waits for every listed job to succeed. If any of them fails, the dependent job is skipped rather than failed, a distinction that matters when you read a run summary.
The practical effect is that pipeline shape is a design choice. Three independent checks as three jobs finish in the time of the slowest one; the same three as steps in one job take the sum of all three. But the parallel version needs artifacts to move files between stages, and the sequential version does not.
- Write a workflow with three jobs (
a,b, andc) each runningsleep 20and echoing its name. - Run it and note the total wall-clock time in the run summary.
- Now add
needs: atobandneeds: btoc. Run again and compare.
needs costs you.
Steps: run a command versus uses an action
Every step is either a shell command or a prebuilt component. That is the entire taxonomy.
run |
uses |
|
|---|---|---|
| Is | A shell command on the runner | A published, reusable action |
| Written | - run: pytest -q |
- uses: actions/checkout@v4 |
| Configured by | Arguments | A with: block |
| For | What you already type in a terminal | Setup, caching, uploads, cloud logins |
A single step is one or the other. Putting run and uses in the same step is an error, and a favourite interview trick question.
steps:
- uses: actions/checkout@v4 # action, no configuration
- uses: actions/setup-node@v4 # action, configured
with:
node-version: '20'
- name: Install # plain command with a label
run: npm ci
- name: Test with options # multi-line, custom shell, working dir
working-directory: ./backend
shell: bash
env:
CI: 'true'
run: |
npm run lint
npm test -- --coverage
Those step options solve real problems, so learn them now. working-directory runs the command somewhere other than the repository root. shell picks the interpreter: bash, sh, pwsh, python. env sets variables for that step alone. name is what appears in the log, which is the difference between a readable run and a wall of truncated commands.
actions/checkout is the action that clones it. If you see "no such file or directory" for a file you can plainly see on GitHub, this is why, and it is why almost every workflow's first step is that line.
- Write a workflow whose only step is
- run: ls -la. Commit and read the output. - Now add
- uses: actions/checkout@v4as the step before it, and run again. - Add a step with
working-directory: .githubrunningls -la.
.github only. You have now seen, rather than read, why checkout exists.
Actions and the Marketplace
An action is a packaged step someone published so you do not have to write it. There are tens of thousands, but you will use these constantly:
actions/checkout
Clones your repository onto the runner. First step of nearly every job.
actions/setup-*
Installs a language version. setup-node, setup-python, setup-java, setup-go.
actions/cache
Reuses downloaded dependencies between runs, so later runs are much faster.
actions/upload-artifact
Saves files off the machine before it is destroyed, so you can download them.
actions/download-artifact
Pulls those files into a later job.
actions/github-script
Runs JavaScript against the GitHub API: comment on a PR, add a label.
An action is referenced as owner/repo@ref, and the ref decides what code you execute:
| Reference | Meaning | When |
|---|---|---|
actions/checkout@v4 |
Major-version tag, moves with patches | Default for official actions |
actions/checkout@v4.1.7 |
One exact release | You want reproducibility |
actions/checkout@8f4b7f8… |
An exact commit | Third-party actions |
actions/checkout@main |
A branch | Almost never |
@main means "whatever is on that branch the instant my job starts", a remote-code-execution surface aimed at your own repository. Use a tag at minimum, and a commit SHA if the action is not from a name you recognise. Senior covers the full supply-chain picture.
- Open the Marketplace and find an action that posts a comment on a pull request.
- Read its README: note the
with:inputs it accepts and thepermissionsit asks for. - Click through to its repository and find the commit SHA of its latest release.
Runners: the machine each job gets
runs-on picks the machine. Three labels cover nearly everything: ubuntu-latest, windows-latest, and macos-latest. Ubuntu is the fastest and cheapest, and the default choice unless you have a specific reason otherwise.
GitHub's Linux runners come with a generous set of preinstalled tools: Git, Docker, several versions of Python and Node, common build toolchains, and the gh CLI. You often need less setup than you expect.
| GitHub-hosted | Self-hosted | |
|---|---|---|
| Who runs it | GitHub | You |
| Lifetime | New per job, then destroyed | Persistent |
| Cost | Free on public repos | Your hardware |
| Pick it | Almost always | GPU, licence, private network |
The distinction matters even though you will not need self-hosted runners for a while: they are machines you own and register yourself, for when you need specific hardware such as a GPU, a paid licence, or access to a private network. Senior covers when that is justified and the risks it introduces.
- Add a step that prints versions:
python3 --version; node --version; docker --version; git --version. - Run it on
ubuntu-latest. - Change
runs-ontowindows-latestand run again. You will needshell: bashfor the same commands to work.
shell: exists.
Expressions and contexts: reading the run
Expressions live inside ${{ }} and read from contexts, read-only objects describing the current run. This is how a workflow becomes aware of its own circumstances.
| Context | Gives you | Examples |
|---|---|---|
github |
Event and repo data | github.ref, github.sha, github.actor, github.event_name, github.repository |
runner |
The machine | runner.os, runner.temp |
env |
Variables you set | env.LOG_LEVEL |
secrets |
Encrypted secrets | secrets.GITHUB_TOKEN |
inputs |
workflow_dispatch inputs |
inputs.environment |
steps |
Earlier step outputs | steps.build.outputs.tag |
needs |
Earlier job outputs | needs.build.outputs.version |
matrix |
The current combination | matrix.python |
- name: Print useful context values
run: |
echo "ref : ${{ github.ref }}" # refs/heads/main
echo "sha : ${{ github.sha }}"
echo "event : ${{ github.event_name }}" # push / pull_request
echo "actor : ${{ github.actor }}"
echo "run number : ${{ github.run_number }}"
echo "workspace : ${{ github.workspace }}"
These are substituted before the command runs, which changes how you read them: by the time your shell sees the line, ${{ github.ref }} has already become literal text. That is why they work in any part of a workflow file, not just inside run.
- Add the printing step above to your workflow and run it from a branch.
- Note what
github.refprints. Write it down. - Now open a pull request and run it again. Compare
github.refandgithub.event_name.
refs/heads/your-branch on a push, but refs/pull/N/merge on a pull request. Knowing that github.ref is the full ref, and that it differs by event, prevents the single most common condition bug.
Conditions with if
if: decides whether a step or a whole job runs. Inside if: the ${{ }} wrapper is optional, because the value is already treated as an expression.
jobs:
deploy:
if: github.ref == 'refs/heads/main' # job-level condition
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Only on pull requests
if: github.event_name == 'pull_request'
run: ./pr-comment.sh
- name: Only for version tags
if: startsWith(github.ref, 'refs/tags/v')
run: ./publish.sh
- name: Run even if an earlier step failed
if: always()
run: ./collect-logs.sh
- name: Only when something failed
if: failure()
run: ./notify-team.sh
| You want | Write |
|---|---|
Only on main |
if: github.ref == 'refs/heads/main' |
| Only on pull requests | if: github.event_name == 'pull_request' |
| Only on a version tag | if: startsWith(github.ref, 'refs/tags/v') |
| Skip a bot | if: github.actor != 'dependabot[bot]' |
| Even after a failure | if: always() |
| Only after a failure | if: failure() |
The four status functions (success(), failure(), cancelled(), always()) only make sense inside if:. success() is the implicit default on every step, which is why a failed step stops the ones after it.
github.ref is the full ref: refs/heads/main, not main. Comparing it to 'main' silently never matches, and nothing in the log explains why. When a condition misbehaves, print the value first: - run: echo "${{ github.ref }}".
- Add a step with the wrong condition on purpose:
if: github.ref == 'main'. Push to main and watch it skip. - Fix it to
refs/heads/mainand watch it run. - Add a step with
if: failure()and a step above it that runsexit 1.
failure() step runs only in the red run, which is the pattern you will use for notifications.
Environment variables, and their three scopes
Variables have three scopes, and the most specific one wins:
env:
APP_NAME: checkout-api # every job, every step
jobs:
test:
runs-on: ubuntu-latest
env:
LOG_LEVEL: debug # every step in this job
steps:
- run: ./run.sh
env:
LOG_LEVEL: trace # this step only — wins
The complication is that each run step is a new shell process, so export does not survive to the next step. GitHub solves this with special files whose paths are provided as environment variables. Appending a line to $GITHUB_ENV sets a variable for every later step in the same job:
- name: Compute a version and share it
run: echo "VERSION=1.4.${{ github.run_number }}" >> "$GITHUB_ENV"
- name: Use it in a later step
run: echo "building $VERSION"
There is also $GITHUB_STEP_SUMMARY, which accepts Markdown and renders it on the run page. It is the cheapest reporting you will ever add, and it means nobody has to download a file to see three numbers:
- name: Publish a summary
run: |
{
echo "### Test results"
echo "| metric | value |"
echo "|---|---|"
echo "| tests | 412 |"
echo "| coverage | 91% |"
} >> "$GITHUB_STEP_SUMMARY"
$GITHUB_ENV is not readable in the step that wrote it, only from the next step onwards. Neither file crosses a job boundary; that needs job outputs, which come later on this page.
- Write a step that does
export FOO=barand a second step that echoes$FOO. Confirm it is empty. - Change the first step to
echo "FOO=bar" >> "$GITHUB_ENV"and run again. - Now try echoing
$FOOin the same step that wrote it, and note that it is still empty. - Add the step-summary block and look at the bottom of the run page.
$GITHUB_ENV version prints bar from the next step onwards but not in the writing step, and your run page now has a rendered table on it. Those three behaviours explain most "my variable is empty" confusion.
Secrets, and what they cannot protect
Secrets are encrypted values you set in Settings → Secrets and variables → Actions. You read them with ${{ secrets.NAME }}, and once stored you cannot read them back in the interface, only overwrite them.
- name: Deploy
env:
API_TOKEN: ${{ secrets.API_TOKEN }} # pass as data
run: ./deploy.sh # script reads "$API_TOKEN"
GITHUB_TOKEN is provided automatically for every run, no setup at all, and can act on the repository itself. It is minted per job and expires when the job ends:
permissions:
contents: read
pull-requests: write
steps:
- uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'Build passed ✅'
})
That permissions: block matters now even though Senior covers it properly: it controls what the automatic token is allowed to do, and declaring it narrows the token rather than widening it.
echo a secret. GitHub masks known values in logs, but that is a safety net, not a strategy. It cannot mask a value you transformed. Pass secrets through
env, not into the command line. Interpolating one directly into a shell makes it far easier to leak through an error message or set -x. Fork pull requests get no secrets, by design. That is not a bug to work around.
- Add a repository secret called
TEST_SECRETwith the valuehunter2. - Write a step that checks it is set without printing it:
if [ -z "$TEST_SECRET" ]; then echo "missing"; exit 1; fi; echo "present", passing it viaenv. - Now deliberately try
echo "$TEST_SECRET"and look at the log.
***, so masking worked. Then try echo "$TEST_SECRET" | base64 and watch the masking fail, which is why "never print it" is the actual rule.
Caching: making the second run fast
The runner is new every time, which means your dependencies download every time. On a real project that is often most of the run. Caching fixes it, and for mainstream languages it is a single line:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip # ← the whole thing
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm # same idea; also 'yarn', 'pnpm'
The mechanism underneath is a key: a string that identifies the cached content. The setup actions build one from your lockfile, so it changes exactly when your dependencies change. For anything the setup actions do not know about, you write the key yourself:
- uses: actions/cache@v4
with:
path: ~/.cache/my-tool
key: ${{ runner.os }}-mytool-${{ hashFiles('tool.lock') }}
restore-keys: |
${{ runner.os }}-mytool-
hashFiles produces a stable hash of the matched files. restore-keys is a fallback prefix tried when the exact key misses, giving a partial hit that is still much better than nothing. Mid-level goes considerably deeper on key design, because getting it wrong is the difference between a cache that helps and one that silently does nothing.
- Add a real dependency install to your workflow: a
requirements.txtwith a few packages, or apackage.json. - Run it once without
cache:and note the install step's duration. - Add
cache: pip(ornpm), then run twice. Compare the install duration on the second run.
Artifacts: files that outlive the runner
The machine is destroyed when the job ends, so anything you want to keep must be uploaded before that happens.
- name: Run tests
run: pytest -q --junitxml=reports/junit.xml --cov --cov-report=html
- name: Keep the reports
if: always() # ← save them even when tests FAIL
uses: actions/upload-artifact@v4
with:
name: test-reports
path: |
reports/
htmlcov/
retention-days: 7
Artifacts appear as downloadable files at the bottom of the run page. The if: always() is the important part: without it, a failing test stops the job before the upload runs, so you lose the report exactly when you need it most.
Cache and artifacts are easy to confuse, so keep this distinction:
Artifact = data you need
- Test reports, build output, logs
- You download it, or a later job does
- If it is missing, something is broken
- Named and retained for a set period
Cache = a shortcut
- Downloaded dependencies
- Reused by the next run
- If it is missing, the run is just slower
- Keyed and restored automatically
One sentence for interviews: a cache is an optimisation you must be able to lose; an artifact is data you cannot.
- Make a step that writes a file:
mkdir -p reports && date > reports/when.txt. - Upload
reports/as an artifact, run, then download the zip from the run page. - Now add
- run: exit 1before the upload step and run again. - Add
if: always()to the upload and run once more.
if: always() exists.
Passing data between jobs, deliberately
Jobs are separate machines, so nothing crosses automatically. Strings travel as outputs; files travel as artifacts.
jobs:
build:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.meta.outputs.version }} # declare the output
steps:
- uses: actions/checkout@v4
- id: meta # the step needs an id
run: echo "version=1.4.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
- run: make build # produces dist/
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ./deploy.sh --version "${{ needs.build.outputs.version }}"
Three things have to line up for a job output to work, and missing any one produces an empty value with no error: the step needs an id, it must write to $GITHUB_OUTPUT, and the job must declare outputs: mapping a name to that step's output. Then the dependent job reads it via the needs context.
- Build the two-job example above, but have
buildjust create a text file rather than a real build. - Confirm
deployprints the version and can read the downloaded file. - Now remove the
outputs:block from thebuildjob and run again.
Matrix: testing several versions at once
Copying a job four times to test four language versions is the wrong answer. A matrix generates them for you:
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false # let every version report
matrix:
python: ['3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- run: pip install -r requirements.txt
- run: pytest -q
That produces three parallel jobs, each with matrix.python set to a different value. Add a second axis and they multiply:
matrix:
os: [ubuntu-latest, windows-latest]
python: ['3.11', '3.12'] # 2 × 2 = four jobs
runs-on: ${{ matrix.os }}
Understand fail-fast now. It defaults to true, which cancels every sibling job as soon as one fails. That is right when you want a fast red signal and wrong when you are diagnosing, because a single failing cell cancelling the rest hides the pattern you need to see.
- Set up the three-Python matrix and run it. Note how the job names in the Actions tab include the version.
- Make the test fail on one version only:
- run: if [ "${{ matrix.python }}" = "3.10" ]; then exit 1; fi. - Run it with the default
fail-fast, then withfail-fast: false.
Guards every workflow should have from day one
Two small settings prevent a surprising amount of pain.
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15 # default ceiling is SIX HOURS
steps:
- uses: actions/checkout@v4
- name: Optional check
continue-on-error: true # a failure here does not fail the job
run: ./experimental-lint.sh
| Guard | Why |
|---|---|
timeout-minutes |
A hung job otherwise burns up to six hours of your allowance |
continue-on-error |
Lets a non-critical step fail without failing the build |
if: always() on uploads |
Diagnostics survive a red run |
A paths-ignore filter |
Documentation changes do not run the full suite |
The timeout is the one people skip and later regret. The default is six hours per job, and a workflow waiting on something that will never arrive consumes your entire monthly allowance before anyone notices.
- Add
timeout-minutes: 1to a job and give it a step that runssleep 120. - Watch what the run looks like when the timeout fires.
- Add a step with
continue-on-error: truerunningexit 1, followed by a normal step.
Reading a failed run, in order
Debugging is a skill, and it has an order.
- Click the red job, then the red stepGitHub expands the failure for you automatically.
- Scroll up, not downLogs end with a tool's summary. The real cause is usually well above it.
- Run the command on your own machineIf it fails there too, the workflow is innocent and you are debugging your project.
- Print what you are unsure about
pwd,ls -la,echo "$VAR",python --version. A step is just a shell. - Use "Re-run failed jobs"Retries only the red job instead of the whole pipeline.
- Re-run with debug loggingThe Re-run menu has a checkbox for far more detail.
Four traps account for most beginner failures, and all four have the same root cause: a new machine each job, a new shell each step:
| Symptom | Cause | Fix |
|---|---|---|
| "File not found" for a file in your repo | No checkout | Add actions/checkout@v4 |
The deploy job cannot find dist/ |
Different machine | Upload/download an artifact |
cd in one step has no effect on the next |
New shell per step | working-directory: on the step |
| A variable is empty in the next step | export dies with the step |
echo "K=v" >> "$GITHUB_ENV" |
# Does not work
- run: export VERSION=1.2.3
- run: echo "$VERSION" # empty
# Works
- run: echo "VERSION=1.2.3" >> "$GITHUB_ENV"
- run: echo "$VERSION" # 1.2.3
# Does not work
- run: cd frontend
- run: npm ci
# Works
- run: npm ci
working-directory: frontend
- Reproduce all four traps in one workflow deliberately, one job at a time.
- For each, read the actual error message before fixing it.
- Then re-run one failed job only, and re-run it again with debug logging enabled.
A complete pipeline
Everything above, in one file. Nothing here is new. Read it as a whole and you should recognise every line.
name: CI
on:
push:
branches: [main]
pull_request:
paths-ignore: ['docs/**', '**/*.md']
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python: ['3.11', '3.12']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
cache: pip
- name: Install dependencies
run: pip install -r requirements.txt
- name: Lint
run: ruff check .
- name: Test
run: pytest -q --junitxml=reports/junit-${{ matrix.python }}.xml
- name: Keep the report
if: always()
uses: actions/upload-artifact@v4
with:
name: reports-${{ matrix.python }}
path: reports/
retention-days: 7
build:
needs: test
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
version: ${{ steps.meta.outputs.version }}
steps:
- uses: actions/checkout@v4
- id: meta
run: echo "version=1.4.${{ github.run_number }}" >> "$GITHUB_OUTPUT"
- run: make build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- name: Deploy
env:
DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
run: ./deploy.sh --version "${{ needs.build.outputs.version }}"
- name: Report
run: echo "Deployed ${{ needs.build.outputs.version }}" >> "$GITHUB_STEP_SUMMARY"
The same shape for a JavaScript project, where only the setup action and the commands change, which is the point:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
- run: npm ci
- run: npm run lint
- run: npm test -- --coverage
- run: npm run build
- Take this pipeline into a project you work on, adapting the commands to your language.
- Get it green. Then deliberately break a test and confirm the report artifact still uploads.
- Push to a branch and confirm
buildanddeployare skipped, then merge to main and watch all three run. - Add the status badge below to your README.
Show it off: a status badge

The badge reflects the latest run on the default branch and links to the workflow's history. Add ?branch=develop to point it at a different branch.
- Add the badge to your README with your own owner, repo, and workflow filename.
- Break the build on purpose and refresh the README to watch it turn red.
What you can now do, and what comes next
You can write a workflow from scratch, choose triggers precisely, run jobs in parallel and order them, use and version Marketplace actions, branch on context values, manage environment variables and secrets, cache dependencies, keep artifacts, move data between jobs, test a matrix of versions, guard against hung jobs, and debug a red run. That is a working practitioner's toolkit, enough to own CI on a real project.
| Can you… | |
|---|---|
| Name the exact folder workflows live in? | .github/workflows/ |
| Explain CI versus CD? | Prove it is safe · ship what passed |
Say why actions/checkout is needed? |
The runner starts empty |
| Explain why jobs cannot share files? | Separate machines |
Explain why cd does not persist? |
New shell per step |
| Make a second run faster? | cache: on the setup action |
| Keep a report from a failed run? | upload-artifact + if: always() |
Deploy only from main? |
if: github.ref == 'refs/heads/main' |
| Pass a string between jobs? | Job outputs + needs |
| Stop a hung job? | timeout-minutes |
Mid-level takes every one of those topics further: the full context and function reference, cache key design and its two failure modes, artifact retention and collisions, matrix include/exclude/dynamic generation, plus the machinery you have not met yet: service containers for real databases, reusable workflows and composite actions, environments with approvals, and concurrency control.
Senior then covers what you own when CI/CD is your responsibility: the trust model and script injection, least-privilege permissions, OIDC instead of stored cloud keys, supply-chain controls, container builds, authoring your own actions, self-hosted runners, and running Actions as a platform across many repositories.