Knowledgebase

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-on line 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 You have no reason to pick anything else. Most jobs live here.
ubuntu-24.04 Ubuntu 24.04, pinned latest moving under you would hurt; a build you want to reproduce next year.
ubuntu-22.04 Ubuntu 22.04, pinned 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-latest for 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-latest changes release under you. GitHub moves it a few months after each LTS ships; pin ubuntu-24.04 when a moving target would hurt.
  • You assume macos-latest is Intel. It has been Apple silicon since 2024, and an Intel build wants macos-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 group mapping. 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-latest and windows-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-on as a mapping: group names it, and labels beside 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-latest is 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 run step, or under defaults.run on the job or the workflow for every command. A uses step 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 -e without pipefail; name shell: bash and 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 bash on both, or write it twice.
  • You add set -e and nothing changes. GitHub already runs bash with -e; the flag you were missing is pipefail, and naming bash gets it.
  • cmd swallows an error. Only the last command's exit code counts there; check %ERRORLEVEL% after each line, or move to pwsh.
  • A #!/usr/bin/env python line at the top does nothing. The shell is a key, not a shebang; write shell: 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 a cd on every step, and the day you forget one.
  • Lives on a run step, or under defaults.run for 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 src and step two cannot find package.json. Every step starts back at the workspace; cd does not carry. Set the folder on the step, or once under defaults.run.
  • The folder is not there yet. The step fails before your first line runs, so a folder that actions/checkout or a build makes needs those steps first.
  • You set it on a uses step. An action ignores it; hand the action its own path or working-directory input, 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/src is 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