Runners and shells in GitHub Actions, where your steps actually run
The machine a job gets and the shell a command lands in. Hosted labels and what a minute of each costs, hosted against self-hosted, runner groups, the shell defaults, and the folder a command starts in.
Every job needs a machine, and every command needs a program to read it. This page is the runs-on line, and the shell and working-directory keys beside a command.
🖥️ Runner
The machine that runs a job. GitHub rents you one, or you bring your own.
- Reach for it when you write any job at all; a job with no machine never starts. The only question is which one, and the two tables below are the menu.
- Lives in the
runs-online of every job, as a label GitHub hosts or the labels of a machine you registered; the next term is that line.
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo "a machine nobody used before this job, gone when it ends"
The hosted labels worth knowing, with what a minute costs next to a Linux minute. Every one of them is free on a public repository.
| Label | What you get | A minute costs | Reach for it when |
|---|---|---|---|
ubuntu-latest |
Ubuntu, whichever release GitHub calls latest | 1× | You have no reason to pick anything else. Most jobs live here. |
ubuntu-24.04 |
Ubuntu 24.04, pinned | 1× | latest moving under you would hurt; a build you want to reproduce next year. |
ubuntu-22.04 |
Ubuntu 22.04, pinned | 1× | Something you build still needs the older toolchain. |
ubuntu-slim |
Ubuntu on one core with 5 GB, in a container | ⅓× | A tiny job that calls an API or posts a comment; it queues faster too. |
ubuntu-24.04-arm |
Ubuntu 24.04 on Arm | ⅘× | You ship Arm images or binaries, or want the cheaper Linux minute. |
windows-latest |
Windows Server, whichever release GitHub calls latest | 1.7× | The build needs Windows: MSBuild, .NET Framework, an installer. |
windows-2025 |
Windows Server 2025, pinned | 1.7× | The same reason as the Ubuntu pins. |
windows-2022 |
Windows Server 2022, pinned | 1.7× | A toolchain that has not moved to 2025 yet. |
windows-11-arm |
Windows 11 on Arm | 1.7× | Windows on Arm is what you ship. |
macos-latest |
macOS on Apple silicon, whichever release GitHub calls latest | 10× | iOS, macOS, or anything that needs Xcode; nothing else, at that price. |
macos-15 |
macOS 15 on Apple silicon, pinned | 10× | Your Xcode version wants a particular macOS. |
macos-14 |
macOS 14 on Apple silicon, pinned | 10× | An older Xcode still in use. |
macos-15-intel |
macOS 15 on an Intel chip | 10× | An Intel-only build; everything else runs on Apple silicon now. |
Hosted against self-hosted, the short version:
| What | GitHub-hosted | Self-hosted |
|---|---|---|
| Who patches it | GitHub, with a fresh image every week or two | You: the operating system, every tool on it, and the runner software |
| Who pays | GitHub's meter: free on a public repository, minutes on a private one | You, for the machine; GitHub charges nothing for the minutes |
| What is on it | A fresh machine for each job, wiped when the job ends | Whatever you installed, plus whatever the last job left behind |
| What it can reach | The internet, and nothing on your network | Your network, so a deploy can land on a box GitHub cannot see |
| On a public repository | Fine; every pull request gets its own throwaway machine | Never. A stranger's pull request runs on your machine, and what it leaves behind stays. |
Gets you in trouble when
- You expect job two to find what job one installed. Every job gets a fresh machine; hand things across with an artifact or an output.
- You pick
macos-latestfor a job that would run anywhere. A macOS minute costs ten Linux minutes, and the queue for one is longer. - A public repository names a self-hosted runner. Anyone can open a pull request, and it runs on your machine; keep those runners for private code.
ubuntu-latestchanges release under you. GitHub moves it a few months after each LTS ships; pinubuntu-24.04when a moving target would hurt.- You assume
macos-latestis Intel. It has been Apple silicon since 2024, and an Intel build wantsmacos-15-intel.
In the editor it is the list under Runs on on each job card: these labels in words, and A runner you host yourself for self-hosted.
🏷️ runs-on
The label that picks the machine.
- Reach for it when you write a job that runs steps; a job that calls a workflow leaves it to the called one. One label picks a hosted machine.
- Lives on the job, as one word, a list, or a
groupmapping. A list means every label at once, which is how you name one of your own machines.
jobs:
test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
steps:
- run: echo "one job, run once per machine"
deploy:
runs-on: [self-hosted, linux, x64]
steps:
- run: ./deploy.sh
Gets you in trouble when
- You list two hosted labels hoping for either. A list means all of them, and no machine is both
ubuntu-latestandwindows-latest; use a matrix, as above. - You type a label GitHub does not host. The job waits for a machine that never comes until it times out; the editor warns on a label it does not know.
- Your own machine's labels come without
self-hosted. Put it first, as GitHub asks; without it the editor reads each label as a hosted one and warns. - A larger runner has a name you chose. The editor cannot tell it from a typo and warns; that one warning is safe to wave through.
- You quote nothing around an expression in a list.
[self-hosted, "${{ inputs.os }}"]needs the quotes; a bare label does not.
In the editor it is Runs on on each job card: pick one GitHub hosts from the list, or type your own labels separated by commas.
👥 Runner groups
Naming a group of your own machines instead of listing labels.
- Reach for it when an organization has a fleet of self-hosted or larger runners and wants to say, in one place, which repositories may use which.
- Lives under
runs-onas a mapping:groupnames it, andlabelsbeside it narrows to the runners in the group that also carry every label.
jobs:
deploy:
runs-on:
group: build-runners
labels: [linux, gpu]
steps:
- run: ./deploy.sh
Gets you in trouble when
- The repository is not on the group's list. Nothing runs and nothing says why; the job just waits. An organization owner adds it in the group's settings.
- You put a hosted label in the group form. A group holds only larger runners and self-hosted ones;
ubuntu-latestis never in one. - You forget the default group exists. A runner registered without one lands in Default, so a job that names no group still finds it.
- A personal repository tries to make one. Groups belong to an organization on the Team plan, or to an enterprise; a lone repository gets the default group only.
In the editor a group under runs-on turns Runs on into Runner group and Labels within it; the Code tab writes the first group, and clearing it hands the labels back.
🐚 shell
Which program reads the lines you wrote in a step.
- Reach for it when the default is the wrong program for the lines: bash on Windows, PowerShell on Linux, Python for anything long, or bash named on purpose for its stricter flags.
- Lives on a
runstep, or underdefaults.runon the job or the workflow for every command. Ausesstep has no shell; the action brought its own.
steps:
- run: echo "bash, named, so a broken pipe fails the step"
shell: bash
- run: Write-Host "PowerShell, on any machine"
shell: pwsh
- run: print("Python, straight from the runner")
shell: python
A command naming no shell runs under bash on Linux and macOS, as bash -e, and under PowerShell, pwsh, on Windows.
The shells GitHub takes by name, as the editor offers them:
| Shell | What reads your lines | Good at |
|---|---|---|
bash |
Bash, and on Windows the one Git for Windows ships | Nearly everything. Named, it stops at the first failure, pipes included. |
pwsh |
PowerShell Core, on every runner | Windows-flavoured work that should run on Linux too. |
python |
The runner's python, reading the block as a script |
Anything too long for shell; no #! line needed. |
sh |
The Bourne shell | A script that must run where bash is missing, on a bare self-hosted box. |
cmd |
The Windows command prompt | Old batch files. It cannot fail fast; only the last line's result counts. |
powershell |
Windows PowerShell 5.1, on Windows runners only | A module that never made it to PowerShell Core. |
Name bash and GitHub runs it with pipefail: a failing command anywhere in a pipe fails the step. That is a good thing, and it surprises everyone once.
Gets you in trouble when
- A pipe fails in the middle and the step goes green. The unnamed default is
bash -ewithoutpipefail; nameshell: bashand the step fails, as it should. - A step runs on Ubuntu and dies on Windows. Nothing named a shell, so Windows read it with PowerShell; name
bashon both, or write it twice. - You add
set -eand nothing changes. GitHub already runs bash with-e; the flag you were missing ispipefail, and namingbashgets it. cmdswallows an error. Only the last command's exit code counts there; check%ERRORLEVEL%after each line, or move topwsh.- A
#!/usr/bin/env pythonline at the top does nothing. The shell is a key, not a shebang; writeshell: python. - You need a program the list lacks.
shell: perl {0}works,{0}standing where the script's file name goes, if the runner has perl.
In the editor it is Shell on a command step, with the list above in words, and the same field under What every command here runs under on the This workflow card.
📁 working-directory
The folder a command starts in.
- Reach for it when the code lives in a subfolder:
src/web,backend, one package of a monorepo. It saves acdon every step, and the day you forget one. - Lives on a
runstep, or underdefaults.runfor every command in the job or the workflow. A relative path starts at the checkout folder,GITHUB_WORKSPACE.
defaults:
run:
working-directory: src/web
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: npm ci
- run: npm run build
working-directory: src/web/packages/site
Why does my script say file not found? Nearly always this. Each step starts in the workspace again, and a cd in one step is forgotten by the next.
Gets you in trouble when
- Step one runs
cd srcand step two cannot findpackage.json. Every step starts back at the workspace;cddoes not carry. Set the folder on the step, or once underdefaults.run. - The folder is not there yet. The step fails before your first line runs, so a folder that
actions/checkoutor a build makes needs those steps first. - You set it on a
usesstep. An action ignores it; hand the action its ownpathorworking-directoryinput, if it has one. - The path is absolute and true on your laptop. The runner checks out to
/home/runner/work/<repo>/<repo>, so/Users/you/repo/srcis nobody's folder there.
In the editor it is Working directory on a command step, and the field beside Shell on the This workflow card, which writes defaults.run.working-directory.
📚 Straight from GitHub
- GitHub-hosted runners, every label and the hardware behind it, and what a minute costs.
- Self-hosted runners, and why a public repository must not use one.
- runs-on: one label, a list, or a group.
- Runner groups, and choosing runners in a group from a workflow.
- shell, with the table of what each one runs, and exit codes and error action preference for the fail-fast flags.
- working-directory, and defaults.run for setting both once.