Mert Özen Further With Every Line
DevOps GitHub Actions — #14

GitHub Actions Runners: Hosted Images, the ubuntu-latest Migration, ARM, Larger Runners and Self-Hosted

Mert Özen Nov 8, 2026 9 min 6 views
GitHub Actions Runners: Hosted Images, the ubuntu-latest Migration, ARM, Larger Runners and Self-Hosted

Pin the runner label in release and deploy pipelines: ubuntu-latest moves from 24.04 to 26.04 between October 19 and November 19, 2026. The hosted image map, arm64 and larger runners, actionlint's label check, why a label in act is only a Docker image mapping, ephemeral self-hosted runners, the minimum version rule, ARC, 2026 prices and how multi-cloud affects IP allowlists.

Version note: The examples in this post were run with act 0.2.97 (runner image catthehacker/ubuntu:act-24.04, arm64), actionlint 1.7.12 and Docker 29.2. GitHub-hosted images, self-hosted runner registration and ARC (Actions Runner Controller) were not run on this machine; labels, dates and prices were checked against the GitHub changelog and documentation as of November 2026.

Guess First

Bookstore's CI (Continuous Integration) workflow was green at the start of October. In the first week of November, the same run, re-run without a single new commit, turns red; the error is a system tool called during the build that can't be found. Nothing changed in the code, the workflow or the dependencies. What changed?

Second question: to save money you installed a self-hosted runner on a Linux machine in the office and attached it to a public repository. Whose code can run on that runner?

runs-on and Labels: Where Does a Job Go?

Every job runs on a runner and says which runner it wants with runs-on. The value is a label or a list of labels; with a list, the runner must have all of them:

jobs:
  build:
    runs-on: ubuntu-24.04
  build-arm:
    runs-on: ubuntu-24.04-arm
  build-on-our-machine:
    runs-on: [self-hosted, linux, arm64, bookstore-build]
  build-large:
    runs-on:
      group: bookstore-large
      labels: [bookstore-linux-8core]

On GitHub-hosted runners a label selects an image and a machine type; each job starts in a clean VM (virtual machine), and the VM is deleted when the job ends. On a self-hosted runner you assign the labels; self-hosted, operating system and architecture labels are added automatically. Larger runners are defined at the organization level, their name and label are whatever the admin chooses (for actionlint you have to add it to the configuration too), and a runner group limits which repositories can use them.

actionlint knows the hosted labels and treats everything else as a typo. For your own runner labels you need a configuration file in the repository:

self-hosted-runner:
  labels:
    - bookstore-build
    - bookstore-linux-8core
    - ubuntu-26.04
    - ubuntu-26.04-arm

We'll get to what ubuntu-26.04 is doing in that list in a moment.

GitHub-Hosted Images: The November 2026 Map

Hosted runner labels and the images -latest points to today:

LabelImageNote
ubuntu-latestUbuntu 24.04 → 26.04Migration October 19 – November 19, 2026
ubuntu-24.04, ubuntu-26.04, ubuntu-22.04Fixed versionsAppend -arm for arm64
ubuntu-slimLightweight Linux runner1 vCPU, 5 GB memory
windows-latestWindows Server 2025windows-2025-vs2026 with Visual Studio 2026 also exists
windows-11-armWindows 11 arm64
macos-latestmacOS 26, arm64macos-26-intel for Intel

Standard Linux and Windows runners come with 4 vCPU (virtual processor cores) and 16 GB RAM (Random Access Memory) on public repositories and 2 vCPU and 8 GB RAM on private ones; both have a 14 GB SSD (Solid State Drive). Sometimes the answer to why the same workflow runs faster in a public fork is that simple.

arm64 and Larger Runners

arm64 runners are a natural option for .NET: the SDK (Software Development Kit) and runtime fully support arm64, and Bookstore's build and test steps run without any change. When producing multi-arch container images, building each architecture's image on its own runner and combining them in a manifest is much faster than QEMU (Quick Emulator) emulation. Larger runners are for work that needs more CPU (Central Processing Unit) cores, memory, a GPU (Graphics Processing Unit) or a static IP (Internet Protocol) address; they are available on Team and Enterprise plans at a higher per-minute price. A static IP is what makes the "open the server only to CI" restriction from the previous post possible.

The ubuntu-latest Migration and Pinning

The answer to the first question is here. Ubuntu 26.04 hosted runners became GA (General Availability) on September 17, 2026, and ubuntu-latest is moving gradually from 24.04 to 26.04 between October 19 and November 19, 2026. Gradually means one run in a given week may land on 24.04 and the next on 26.04. The new image comes with Ubuntu 26.04.1 LTS (Long Term Support), a 7.0 kernel and systemd 259; the list of installed tools changes as well. If your workflow depends, even indirectly, on a system tool or a specific version of it, it breaks without any code change. "Latest" is not a version; it is a lack of commitment.

So pin the label in release and deploy pipelines: runs-on: ubuntu-24.04. To try the new image in a controlled way, add a row to a matrix and watch the result; when you're ready, move the pin forward deliberately. -latest only fits jobs where you say "this must also work on the newest environment" and where breaking is a useful signal.

The tools are a step behind here too. When I asked actionlint 1.7.12 about ubuntu-26.04, it didn't know it, and it correctly rejected the retired ubuntu-20.04:

.github/workflows/labels.yml:7:43: label "ubuntu-26.04" is unknown. available labels are "windows-latest", "windows-latest-8-cores", "windows-2025", "windows-2025-vs2026", "windows-2022", "windows-11-arm", "ubuntu-slim", "ubuntu-latest", ..., "ubuntu-24.04", "ubuntu-24.04-arm", "ubuntu-22.04", "ubuntu-22.04-arm", "macos-latest", ..., "macos-26", "macos-15-intel", ... if it is a custom label for self-hosted runner, set list of labels in actionlint.yaml config file [runner-label]
.github/workflows/labels.yml:7:162: label "ubuntu-20.04" is unknown. ...
.github/workflows/labels.yml:12:42: label "bookstore-build" is unknown. ...

The ubuntu-20.04 image was fully removed on April 15, 2025; a workflow still on that label finds no runner on GitHub to run on. With the .github/actionlint.yaml file above introducing the custom labels and the not-yet-known new labels, the lint passed cleanly. Don't forget to delete those two lines once a newer actionlint learns about 26.04.

In act, a Label Is an Image Mapping

In act there is no GitHub image behind a label; there is whatever Docker image you mapped with -P. I mapped the same image to two labels and ran this workflow:

name: runner-info

on: workflow_dispatch

jobs:
  info:
    strategy:
      matrix:
        os: [ubuntu-24.04, ubuntu-26.04]
    runs-on: ${{ matrix.os }}
    steps:
      - name: Describe the runner
        run: |
          . /etc/os-release
          echo "label=${{ matrix.os }} os=$PRETTY_NAME arch=$RUNNER_ARCH"
          echo "ImageOS=${ImageOS:-unset} ImageVersion=${ImageVersion:-unset}"
          echo "cpus=$(nproc) mem=$(free -g | awk '/Mem:/{print $2}')G"
          dotnet --list-sdks | tail -1 || echo "no dotnet"
act workflow_dispatch -W .github/workflows/runner-info.yml -P ubuntu-26.04=catthehacker/ubuntu:act-24.04

[runner-info/info-2]   | label=ubuntu-26.04 os=Ubuntu 24.04.4 LTS arch=ARM64
[runner-info/info-2]   | ImageOS=ubuntu26 ImageVersion=unset
[runner-info/info-1]   | label=ubuntu-24.04 os=Ubuntu 24.04.4 LTS arch=ARM64
[runner-info/info-1]   | ImageOS=ubuntu26 ImageVersion=unset
[runner-info/info-2]   | cpus=10 mem=7G
[runner-info/info-1]   | /var/run/act/workflow/0: line 6: dotnet: command not found
[runner-info/info-1]   | cpus=10 mem=7G
[runner-info/info-2]   | /var/run/act/workflow/0: line 6: dotnet: command not found

There are four lessons. The job labeled ubuntu-26.04 actually ran on Ubuntu 24.04, because that is how I mapped the image. The ImageOS variable is ubuntu26 in both; in act this value does not reflect reality, and ImageVersion is not there at all. CPU and memory are the Docker Desktop VM's values, not a hosted runner's. And the .NET SDK that ships preinstalled on hosted images is missing here; the same difference we saw with az in post 10. act is great for testing workflow logic, not for testing image-dependent behavior. You can only confirm an image-related problem on GitHub's own runner, or by reading the image definitions in the runner-images repository.

Self-Hosted Runners: When, How and Where Not

A self-hosted runner is GitHub's runner application running on your own machine. The usual reasons: access to a resource on the internal network (a database, an artifact store, a deploy target), special hardware, keeping large caches on the machine, or cost. It is set up with a short-lived registration token taken from the repository or organization settings:

./config.sh --url https://github.com/octo-org/bookstore --token "$REGISTRATION_TOKEN" \
  --name build-01 --labels bookstore-build --ephemeral --unattended
./run.sh

Since it needs a token, I did not run these commands. --ephemeral is the most important flag: the runner takes a single job and then removes its registration automatically. On a persistent runner, files, processes, Docker images and credentials left behind by one job stay for the next; you use an ephemeral runner together with automation that restarts it in a clean VM or container every time.

The answer to the second question is in GitHub's own warning: don't use self-hosted runners with public repositories. Anyone can open a PR (pull request) from a fork, and the PR's code runs on your machine, on your network; there is no guarantee the runner is cleaned between jobs. Every attack surface from post 13 becomes a physical machine here.

There is also the maintenance load. From 2026 on, GitHub enforces a minimum version for self-hosted runners: registration needs at least 2.329.0, and a runner that is not updated within 30 days of a new release stops getting jobs; on Enterprise Cloud this enforcement started on September 29, 2026. Actions running on Node 24 (checkout@v5 and later) already require at least 2.327.1. If you turned off auto-update, tie this to a maintenance schedule.

ARC on Kubernetes and the Scale Set Client

The built-in way to scale self-hosted runners is ARC: a controller running on Kubernetes that manages runner scale sets and spins ephemeral runner pods up and down based on the number of pending jobs. On the workflow side you write the scale set's name in runs-on. ARC 0.15.0, released in September 2026, brought in-place patch upgrades and a configurable termination grace period. For those not on Kubernetes, the scale set client, in public preview since February 2026, offers the same scaling API (Application Programming Interface) as a standalone Go module, for people who want to write their own VM automation. We cover Kubernetes itself in the upcoming Kubernetes series.

Cost: 2026 Prices and Multi-Cloud

On January 1, 2026, hosted runner prices dropped by up to 39 percent depending on machine type; a Linux 2-core minute costs $0.006, and the new prices include a $0.002 per-minute platform charge. The same charge was planned for self-hosted runner minutes on March 1, 2026; GitHub postponed it on December 15, 2025 without giving a new date, so self-hosted minutes are currently free. Monthly included usage by plan: Free $12 (about 2,000 Linux minutes), Pro and Team $18 (about 3,000), Enterprise Cloud $300 (about 50,000). Standard hosted runners are free for public repositories.

One more change arrived at the end of October 2026: eligible standard 2- and 4-core Linux runners can now run on AWS (Amazon Web Services) in addition to Azure. It is on by default for Free, Pro and Team, and opt-in for Enterprise Cloud. The direct impact is on IP allowlists: if you grant access to your server or database only from Azure or Microsoft IP ranges, a runner running on AWS is left at the door. Sync your allowlist from the actions list at https://api.github.com/meta, or use a larger runner with a static IP.

In Production

Pin the runner like code. Deploy pipelines should run on a specific image version; image upgrades should come through a PR, tried out in a matrix.

Don't rely on the image; install things yourself. Take the .NET SDK from setup-dotnet and global.json, not from the image; Node from setup-node. Tools on the image can change with every migration; the version you pinned does not.

Run self-hosted runners ephemeral and isolated. On a persistent runner, exposing the Docker socket to jobs means exposing the machine's root.

Measure cost. Minute usage on the billing screen breaks down by machine type; macOS and larger runner minutes cost several times more than Linux. Question which jobs really need macOS or 8 cores. We discuss measuring and speed in detail in the next post.

Common Pitfalls

A build that breaks because it trusted ubuntu-latest. This is exactly what is happening in October–November 2026; pin your release pipelines.

Staying on a retired label. A workflow written with ubuntu-20.04 or windows-2019 no longer finds a hosted runner to run on; actionlint catches this while you write the YAML (YAML Ain't Markup Language) file.

A self-hosted runner on a public repository. Code from a fork's PR runs on your network.

Taking act output as evidence about the image. In act a label is just a name for the Docker image you chose.

Writing an IP allowlist once and forgetting it. GitHub's runner IP ranges change and now span more than one cloud.

Turning off self-hosted runner updates and forgetting about it. When the 30-day window passes, the runner silently stops taking jobs and jobs wait in the queue.

A Small Experiment

★☆☆ Run the runner-info workflow in your own public test repository with ubuntu-latest, ubuntu-24.04 and ubuntu-26.04; compare the ImageOS, ImageVersion and dotnet --list-sdks output with the act results.

★★☆ Run Bookstore's API CI on a ubuntu-24.04 and ubuntu-24.04-arm matrix and write the build and test durations for both architectures to the job summary.

★★★ Write a small script that starts an ephemeral self-hosted runner in a Docker container, deletes the container when the job ends and starts a new one (take the registration token only for your own test repository, short-lived). Confirm in the repository settings that the runner really removed its registration after the job.

We've seen where the runner runs and what it costs. In the next post we look at speeding up and debugging the same pipeline: debug logging, where act helps locally, cache key design, artifact retention and reading the billing screen.