Automation

What Breaks When GitHub Actions ubuntu-latest Moves to Ubuntu 26.04

Any job with runs-on: ubuntu-latest is scheduled to run on a GitHub Actions Ubuntu 26.04 runner by November 19, 2026, whether or not you touch the workflow. You can pin ubuntu-24.04 to keep the old image, but most repositories need only a handful of fixes, each with a specific error you can search for.

Original content from computingforgeeks.com - post 176128

We ran the same test workflows on both images and found eleven things that behave differently, from a RAM-backed /tmp that stops a regular user at about 6.2 GiB to sudo -E being silently ignored. Below is each error with the fix that worked on the runner. We checked every result in October 2026 on GitHub-hosted ubuntu-24.04 and ubuntu-26.04 runners, the second reporting Ubuntu 26.04.1 LTS.

When does ubuntu-latest switch to Ubuntu 26.04?

GitHub started moving the x64 ubuntu-latest label on October 19, 2026 and plans to finish by November 19, 2026, according to the announcement in runner-images issue #14748. The rollout is gradual, so two runs of the same workflow on the same day can land on different images. Do not read one green run as proof that your repository has already migrated. GitHub does not publish the order in which repositories move, so plan as if yours is next and test before the label decides for you.

On October 20 our ubuntu-latest jobs still got Ubuntu 24.04 in three separate runs. Every one of them carried a notice annotation on the run summary, and that notice is the first sign most teams will see. The two errors above it come from the guard step in the next section:

GitHub Actions run summary showing the ubuntu-latest Ubuntu 26 migration notice and an image guard error

The notice is informational and never fails a job, which is why it is easy to scroll past. The change covers these labels and products:

  • ubuntu-latest on GitHub-hosted x64 runners moves from Ubuntu 24.04 to Ubuntu 26.04.
  • Azure DevOps is listed as affected in the same announcement, so vmImage: ubuntu-latest pipelines move too.
  • Arm jobs stay where you put them, because Arm labels are always explicit, and ubuntu-26.04-arm already reports ImageOS=ubuntu26-arm64.
  • Explicit x64 labels such as ubuntu-24.04 keep their image.

If every workflow in your organization already names an explicit label, nothing moves until you decide it should. Search for ubuntu-latest across your repositories first to see how much of your CI the rollout reaches.

How do you tell which image a job got?

Every GitHub-hosted runner exports two environment variables that name its image, so one echo in a job answers the question. During the rollout the label alone no longer tells you, and a failing step on a mixed fleet is easy to misread as a flaky test. Check these three values in the job log:

  • ImageOS reads ubuntu24 on the old image and ubuntu26 on the new one.
  • ImageVersion shows the image build, such as 20261018.339.1.
  • /etc/os-release reports Ubuntu 24.04.5 LTS or Ubuntu 26.04.1 LTS as its PRETTY_NAME.

Log the build as well as the OS. Our 26.04 jobs got two different image builds on the same afternoon, and when a fix works in one run and not the next, a changed image build is the first thing to rule out.

This is the guard workflow behind the screenshot above. It stops the job with a readable error when the image is not Ubuntu 24.04, which is handy on repositories you are not ready to migrate yet:

name: image-guard

on:
  push:
  workflow_dispatch:

jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, ubuntu-26.04]
    runs-on: ${{ matrix.os }}
    steps:
      - name: Fail if the runner image is not Ubuntu 24.04
        run: |
          echo "Runner image: $ImageOS $ImageVersion"
          if [ "$ImageOS" != "ubuntu24" ]; then
            echo "::error::Expected Ubuntu 24.04, got $ImageOS ($ImageVersion). Pin runs-on or fix the job."
            exit 1
          fi

The ubuntu-latest leg printed Runner image: ubuntu24 20261018.339.1 and passed. The ubuntu-26.04 leg printed its image and failed with the error annotation:

Runner image: ubuntu26 20261012.163.1
##[error]Expected Ubuntu 24.04, got ubuntu26 (20261012.163.1). Pin runs-on or fix the job.
##[error]Process completed with exit code 1.

For a real repository, drop the matrix and keep only the step, with your own runs-on. Remove the guard once the job passes on both images, because leaving it in after the migration fails every build for the wrong reason.

How do you pin ubuntu-24.04 or test Ubuntu 26.04 first?

Change runs-on: ubuntu-latest to runs-on: ubuntu-24.04 and the job keeps the old image, with no other edits. That is the fastest way to turn a red pipeline green today, but it only buys time: a pinned label is retired on GitHub’s schedule, not yours, and the fixes below will still be waiting.

    runs-on: ubuntu-24.04

The better move is to run both images side by side for a week or two. A matrix over the two labels shows every difference in one run, and fail-fast: false keeps the 24.04 leg going when 26.04 fails, which is the whole point of comparing them. This is the job header our test workflows used:

jobs:
  breakers:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-24.04, ubuntu-26.04]
    runs-on: ${{ matrix.os }}

Once the 26.04 leg is green, switch runs-on back to ubuntu-latest. If you would rather choose the date of the next LTS jump yourself, keep ubuntu-26.04 explicit instead.

What changed between the two runner images?

The table lists what our inventory job printed on each image on October 20, 2026. Read it as a map of where to look, since a row only matters if your build calls that tool. Most of the breakage below traces back to five rows: sudo, /tmp, Java, MySQL and the compilers.

Itemubuntu-24.04ubuntu-26.04
Operating systemUbuntu 24.04.5 LTSUbuntu 26.04.1 LTS
sudosudo 1.9.15p5sudo-rs 0.2.13
ls, date, sort, ddGNU coreutils 9.4uutils coreutils 0.10.0
/tmpon the root disktmpfs, 7.8G, usrquota (fix merged, see below)
Default Java17.0.20.125.0.4.1
Preinstalled JDKs8, 11, 17, 21, 2511, 17, 21, 25
GCC (default C standard)13.3.0 (gnu17)15.2.0 (gnu23)
CMake3.31.64.4.4
Python3.12.33.14.4
Node.js22.23.324.21.0
MySQL server8.0.468.4.11
PostgreSQL client16.1518.6
OpenSSL3.0.133.5.5
Docker / Compose28.0.4 / 2.38.229.4.2 / 5.1.3
Helm3.22.14.3.0
Maven3.10.03.9.15
conda, hg, pulumiinstalledcommand not found

One row contradicts GitHub’s own announcement. Its software table says Java 17 stays the default on both images, but on ubuntu-26.04 java -version reports 25 and JAVA_HOME points at /usr/lib/jvm/temurin-25-jdk-amd64. Trust the runner over the table. If you want the same jump on servers, our Ubuntu 26.04 vs 24.04 comparison covers the release differences outside CI.

Which errors show up on GitHub Actions Ubuntu 26.04 runners?

The first group is system changes, so they break jobs whatever language you build. Where the runner printed an error, the heading below is its exact text, and each fix ran on ubuntu-26.04 before it went on this page.

“dd: IO error: Disk quota exceeded” when writing to /tmp

On ubuntu-26.04, /tmp is a RAM-backed tmpfs with a per-user quota, not a directory on the 145 GB root disk. Anything that stages big files there fails partway through, and the quota bites before the 7.8 GiB the mount reports: a 12,000 MiB dd as the runner user stopped at 6,356 MiB. Our 9,000 MiB write failed the same way, while the same write to $RUNNER_TEMP completed on both images. Go builds, Docker image exports and large test fixtures are the usual victims (issue #14777). This is what findmnt -no FSTYPE,SIZE,OPTIONS /tmp prints on the new image:

tmpfs  7.8G rw,nosuid,nodev,size=8183324k,nr_inodes=1048576,inode64,usrquota

The cleanest fix sends temporary files to the runner’s work disk for the rest of the job. Add this step before the build:

      - name: Send temp files to the runner disk
        run: echo "TMPDIR=$RUNNER_TEMP" >> "$GITHUB_ENV"

After that step, mktemp, Python’s tempfile and Go’s build directory all landed under /home/runner/work/_temp. Putting TMPDIR: ${{ runner.temp }} in the job-level env: looks simpler, but we tried it and GitHub rejected the workflow file before any job started, because the runner context does not exist at that level.

Some tools ignore TMPDIR and write to /tmp by name. For those, take the tmpfs away. Masking the unit and lazily unmounting it put /tmp back on the root disk, and the 9,000 MiB write then succeeded:

sudo systemctl mask tmp.mount
sudo umount -l /tmp || true

Make this the first step of the job. Files that earlier steps wrote to /tmp disappear from view after the unmount, which is why the TMPDIR route is the gentler one. Both lines are safe on ubuntu-24.04 too: systemd says the unit does not exist and proceeds, and || true absorbs the “not mounted” error, so the same step can stay in a matrix job that covers both images.

GitHub has already merged the image change that keeps /tmp on the root disk (PR #14814, October 20), but the image our jobs got on October 20 (build 20261012.163.1) still mounted the tmpfs. Once a newer build rolls out, the mask and unmount lines do nothing and the TMPDIR step stays harmless, so there is no reason to wait before adding them.

“sudo: preserving the entire environment is not supported, ‘-E’ is ignored”

sudo on ubuntu-26.04 is sudo-rs, the Rust rewrite, and it does not implement sudo -E. It prints that warning, runs the command with the normal reset environment and lets the step carry on. That is worse than a failure: a sudo -E make install that relies on an exported proxy variable or build setting quietly does something different, and the job may still go green. We only caught it because our test echoed the variable back; a normal build log shows nothing but that one warning line among hundreds.

We captured both of these surprises in a single job step on a 26.04 runner. CFG_VAR was exported, yet the command under sudo -E saw it as unset, and the /tmp write hit the quota:

Ubuntu 26.04 GitHub Actions runner showing sudo-rs ignoring sudo -E and a disk quota exceeded error on /tmp

Name the variables you need instead of passing everything. With CFG_VAR=hello exported and /opt/cfgtools/bin prepended to PATH, both lines printed hello /opt/cfgtools/bin on both images, even with Ubuntu’s secure_path set in sudoers. The runner’s passwordless sudo rule allows variables named on the command line, which a locked-down server may not:

sudo --preserve-env=CFG_VAR,PATH sh -c 'echo "with --preserve-env: ${CFG_VAR:-unset} ${PATH%%:*}"'
sudo env CFG_VAR="$CFG_VAR" PATH="$PATH" sh -c 'echo "with env: ${CFG_VAR:-unset} ${PATH%%:*}"'

A third-party script that hardcodes sudo -E is harder to fix. The original sudo is still installed on the image as /usr/bin/sudo.ws, at a lower alternatives priority than sudo-rs. Switch to it in an early step. The test keeps the line from failing on ubuntu-24.04, where sudo is a plain binary with no alternatives group:

if [ -x /usr/bin/sudo.ws ]; then sudo update-alternatives --set sudo /usr/bin/sudo.ws; fi

After the switch, sudo --version reported Sudo version 1.9.17p2 on 26.04 and sudo -E passed the variable through again, while the 24.04 leg skipped the line and kept its own sudo. Our sudo-rs guide covers the other places where it behaves differently from the classic sudo.

“sanitize_string: invalid characters” from adduser

adduser on ubuntu-26.04 exits 255. The image ships nvm’s test suite inside /etc/skel/.nvm/test, and the newer adduser rejects the odd characters in those file names while copying the skeleton into the new home (issue #14797). The account is half made when it stops, because the user exists but never joins the users group. Whatever later step logs in as that user or relies on its groups then fails with a different error, far away from the real cause, so check the adduser exit code directly:

sanitize_string: invalid characters in './.nvm/test/fast/Aliases/Running 'nvm alias Ë^BaliasnameË^C' should list but one alias' at /usr/share/perl5/Debian/AdduserCommon.pm line 163, <$findpipe> line 607.
adduser exit=255

Delete the test files before you create users. They are nvm’s own fixtures, and nothing reads them at run time:

sudo rm -rf /etc/skel/.nvm/test

With them gone, adduser --disabled-password exited 0, and the new account joined users as it does on 24.04. The image fix is merged here too (PR #14941), yet the files were still in /etc/skel on build 20261012.163.1. The same files sit in the 24.04 image as well; only the newer adduser on 26.04 refuses them. Keep the line anyway, since rm -rf on a path that no longer exists does nothing.

i386 containers can’t resolve any hostname

32-bit glibc containers lose networking on ubuntu-26.04. The default seccomp profile in the preinstalled Docker blocks socketcall, which i386 glibc uses for its sockets (issue #14790). docker run --rm i386/debian:bookworm getent hosts github.com printed nothing and exited 2, while the same command on 24.04 resolved the name. Turning seccomp off for the container brings name resolution back:

docker run --rm --security-opt seccomp=unconfined i386/debian:bookworm getent hosts github.com

That workaround resolved the name on 26.04, but it disables syscall filtering for the container, so keep it to throwaway CI containers. The issue’s proposed fix is a Docker 29.4.3 or later on the image. Until that lands, keeping i386 jobs on ubuntu-24.04 is the safer choice.

Which toolchain changes break builds?

The second group is version jumps in compilers, runtimes and services. None of them is a bug in the image. They are upstream defaults that 24.04 was too old to have, so the lasting fix belongs in your code, with a flag or a pinned version to buy time.

“Compatibility with CMake < 3.5 has been removed from CMake”

CMake 4 refuses any project whose cmake_minimum_required asks for a version older than 3.5, which catches a lot of vendored dependencies. Our test project used VERSION 3.0. On 24.04 it configured with only a deprecation warning, and on 26.04 it failed outright:

CMake Error at CMakeLists.txt:1 (cmake_minimum_required):
  Compatibility with CMake < 3.5 has been removed from CMake.

  Update the VERSION argument <min> value.  Or, use the <min>...<max> syntax
  to tell CMake that the project requires at least <min> but has been updated
  to work with policies introduced by <max> or earlier.

  Or, add -DCMAKE_POLICY_VERSION_MINIMUM=3.5 to try configuring anyway.


-- Configuring incomplete, errors occurred!

The error names its own escape hatch. Passing the policy minimum on the command line configured and built the project on 26.04:

cmake -S . -B build -DCMAKE_POLICY_VERSION_MINIMUM=3.5

On 24.04 the same flag only adds a “Manually-specified variables were not used” warning, so a matrix can pass it to both images. The lasting fix is to raise the VERSION line in every CMakeLists.txt you own.

“‘bool’ cannot be defined via ‘typedef'”

GCC 15 compiles C as gnu23 (C23 plus GNU extensions) by default, and in C23 bool, true and false are keywords. __STDC_VERSION__ reports 202311L on 26.04 against 201710L on 24.04. Old C code that defines its own bool stops compiling:

oldbool.c:1:13: error: ‘bool’ cannot be defined via ‘typedef’
    1 | typedef int bool;
      |             ^~~~
oldbool.c:1:13: note: ‘bool’ is a keyword with ‘-std=c23’ onwards

Pin the older standard while you clean the code up. With -std=gnu17 the same file compiled on both images. In a Make or Autotools build, the flag goes into CFLAGS:

gcc -std=gnu17 -c oldbool.c -o oldbool.o

Treat the flag as a stopgap. Replacing the home-made typedef with #include <stdbool.h> compiled on both images with the default standard, so that is the change to make in the source.

“ModuleNotFoundError: No module named ‘cgi'”

The system Python on ubuntu-26.04 is 3.14, which no longer has the old standard-library modules that PEP 594 removed in 3.13, including cgi, crypt and telnetlib. On 24.04 the same import cgi, crypt, telnetlib printed three DeprecationWarnings and then worked. The warning you may have scrolled past for two years is now the error.

If you cannot port the code yet, give the job an older Python. actions/setup-python installed 3.10, 3.11 and 3.12 on ubuntu-26.04 in our matrix, and the 3.12 job then reported Python 3.12.15 from the tool cache:

      - uses: actions/setup-python@v7
        with:
          python-version: '3.12'

That only covers code run with the python the action puts first on PATH. Scripts that call /usr/bin/python3 by full path still get 3.14.

Java 25 is the default JDK, and Java 8 is gone

On ubuntu-26.04, java is Temurin 25 and JAVA_HOME points at it. A build that never set a Java version now compiles and tests on 25 instead of 17. The preinstalled JDKs are 11, 17, 21 and 25, so jobs that read JAVA_HOME_8_X64 get an empty variable. JDK 17 is still on the image, and these lines make it the default for the rest of the job:

      - name: Switch with the preinstalled JDK 17
        run: |
          echo "JAVA_HOME=$JAVA_HOME_17_X64" >> "$GITHUB_ENV"
          echo "$JAVA_HOME_17_X64/bin" >> "$GITHUB_PATH"

The next step’s java -version reported 17. For Java 8, actions/setup-java still works on 26.04: this step downloaded Temurin 8 and made it the default, and java -version then printed 1.8.0_504:

      - uses: actions/setup-java@v6
        with:
          distribution: temurin
          java-version: '8'

Prefer the setup action over the env switch on repositories that build on several images, since it gives you the same JDK everywhere instead of whatever each image preinstalls.

“Plugin ‘mysql_native_password’ is not loaded”

The preinstalled MySQL server on 26.04 is 8.4, which doesn’t load the old mysql_native_password plugin by default. Test setups that create users with it fail on the first statement, right after sudo systemctl start mysql:

ERROR 1524 (HY000) at line 1: Plugin 'mysql_native_password' is not loaded

Move your test users to the default caching_sha2_password if your client library supports it. If it does not yet, turn the old plugin back on before you start the service. The loose- prefix keeps the line from stopping a server that does not know the option, and with this file in place the same CREATE USER ... IDENTIFIED WITH mysql_native_password succeeded on both images:

printf '[mysqld]\nloose-mysql_native_password=ON\n' | sudo tee /etc/mysql/mysql.conf.d/native-password.cnf
sudo systemctl start mysql

If you run MySQL outside CI as well, the MySQL install guide for Ubuntu 26.04 walks through the server side of the same release.

“Flag –atomic has been deprecated, use –rollback-on-failure instead”

Helm on 26.04 is Helm 4, which renamed --atomic to --rollback-on-failure, and its help lists --force-replace where Helm 3 had --force. The old --atomic is still accepted, with that deprecation warning. The trap runs the other way: --rollback-on-failure fails on Helm 3 with Error: unknown flag: --rollback-on-failure, so renaming the flag early breaks any job still on 24.04.

Keep --atomic until every job that runs the chart is on Helm 4, then rename it. The Helm 3 to Helm 4 migration guide lists the rest of what changed.

conda, hg and pulumi: command not found

Some tools are simply not on the new image. On ubuntu-26.04 conda, hg (Mercurial) and pulumi all returned command not found, while 24.04 still had all three. Install what you need inside the job, and check the Ubuntu 26.04 image README before you migrate, because GitHub keeps that list of installed software current with each image build.

Are the Rust coreutils on the runner a problem?

Not in our tests. ls, date, sort and dd on ubuntu-26.04 come from uutils, the Rust rewrite, while cp is still GNU through a gnucp link. The GNU-only flags that CI scripts lean on gave identical output on both images:

  • date -d '2026-10-19 +30 days' +%F printed 2026-11-18.
  • sort -h put 9, 10 and 100 in the right order.
  • ls --time-style=+%Y -l printed the year instead of the full date.
  • stat -c '%U %a' /etc/passwd printed root 644.

Error messages are where they differ. The uutils dd reported the quota as “IO error: Disk quota exceeded”, so if a test greps a tool’s error text, check that text on the new image before you trust the assertion. The Rust coreutils guide for Ubuntu 26.04 explains how the switch works on a normal install.

Pin or migrate: what should each repository do?

Most repositories should migrate now and keep a pin only where the fix depends on someone else. Let the error you hit decide:

  • Your job only failed on /tmp, sudo -E, CMake, GCC or Python: apply the fix above and stay on ubuntu-latest, because each of those fixes also works on 24.04.
  • Your job needs Java 17 or 8, native MySQL passwords or Helm 3 flags: migrate, and set the JDK, the MySQL option or the Helm version in the workflow so it stops riding on the image default.
  • Your job runs i386 containers: pin ubuntu-24.04 until the image ships a fixed Docker, rather than turning seccomp off.
  • A vendor script calls sudo -E or adduser: add the sudo switch or the skel cleanup as an early step, and report it to the vendor.

Keep the image guard on every repository you pin, so the day the pin comes off is a decision and not an accident. The GitHub CLI cheat sheet has the gh run view --log commands we used to pull and compare the job logs from both images.

Keep reading

Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) Ubuntu Upgrade Ubuntu 24.04 to Ubuntu 26.04 LTS (Step by Step) UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 Security UFW Firewall Commands with Examples on Ubuntu 26.04 / 24.04 OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) AI OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) Upgrade a Single Package on Ubuntu / Debian with apt Debian Upgrade a Single Package on Ubuntu / Debian with apt APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Cheat Sheets APT Cheat Sheet: apt and apt-get Commands for Ubuntu/Debian Ansible Vault: Encrypting Secrets and Passwords Ansible Ansible Vault: Encrypting Secrets and Passwords

Leave a Comment

Press ESC to close