Skip to content

GitHub Actions ubuntu-latest to 26.04: The Label I Mistook for a Pin

GitHub Actions ubuntu-latest to 26.04: The Label I Mistook for a Pin

Okay, this is going to sound dumb, but for about a year I treated runs-on: ubuntu-latest as if it were a version pin. It is the opposite of a pin. It is a label that GitHub re-points whenever it likes, and on 17 September GitHub announced it is about to re-point it again: the Ubuntu 26.04 runner image is generally available, and ubuntu-latest will move from 24.04 to 26.04 in a rolling window between 19 October and 19 November 2026.

I found out because I went looking for something else in the changelog. Nobody emails you about this. If you have a workflow that has been green for eighteen months and you have not touched it, there is a decent chance it goes red sometime in that window, on a Tuesday, for a reason that has nothing to do with your code.

What GitHub actually said

The announcement is here: Ubuntu 26 generally available and latest migration. Three things in it matter. The 26.04 image is out of preview and supported for production on both x64 and arm64, so runs-on: ubuntu-26.04 and runs-on: ubuntu-26.04-arm work today. The ubuntu-latest label migrates gradually over that October to November window, and during it your workflows move whether you asked or not. And the escape hatch is explicit: pin to ubuntu-24.04 if you are not ready.

The post is also honest that this will break things. It says outright that the new image has updated and in some cases removed tools, and that the migration “may break builds that rely on tools, packages, or versions that changed”. That is GitHub telling you to go test. I would take them up on it.

What changed between the two images

The changelog links to the runner-images repository for the software list, which is the part you actually need. I diffed the 24.04 and 26.04 READMEs on the morning of the announcement (image version 20260907.131.1 for 26.04). Here is what jumped out for the kind of projects I run.

System Python goes from 3.12.3 to 3.14.4. The toolcache still has 3.10 through 3.14, so actions/setup-python is unaffected. But anything that shells out to bare python3 gets 3.14, and I have at least one script that calls asyncio.get_event_loop() with no loop running, which 3.14 turned from a warning into an error.

System Node goes from 22 to 24. Again, setup-node users are fine. Anyone running node directly in a run: step is not.

PHP goes from 8.3.6 to 8.5.4. This one bit me on the last migration. My Laravel test job ran system PHP because it was easier than adding a setup step, and 24.04 quietly moved it from 8.1 to 8.3. I spent an hour reading a deprecation about dynamic properties before I understood that my code had not changed, the runner had.

PostgreSQL goes from 16.15 to 18.6. Two majors. The service is disabled by default on both images, so you still start it with sudo systemctl start postgresql.service, but any test that depends on 16-specific behaviour or an extension version is now running against 18. If your CI uses the system Postgres the way I described in my post on unindexed foreign keys in CI, this is the row to test first.

MySQL goes from 8.0.46 to 8.4.11. This is the one I would put money on breaking Laravel projects. MySQL 8.4 disables the mysql_native_password plugin by default. If your CI job creates a test user with IDENTIFIED WITH mysql_native_password, or your PHP mysqlnd is old enough to only speak that auth method, connections fail with an error that looks like a wrong password. It is not a wrong password.

Docker goes from 28.0.4 to 29.4.2, Compose from 2.38.2 to 5.1.3, Podman from 4.9.3 to 5.7.0. Compose jumping three majors in a CI image is the kind of thing I would want release notes for, and I have not read them all yet. If your workflow runs docker compose with an older version: key in the file, test it.

Compilers move up too: GCC 12 through 14 becomes 13 through 15, Clang 16 through 18 becomes 20 through 22. Ruby 3.2.3 becomes 3.3.8.

What is gone entirely

The list of tools present on 24.04 and absent on 26.04, from the same diff: Fastlane, Haveged, Julia, Lerna, MediaInfo, Mercurial, Miniconda, Newman, Parcel, Pulumi, the Sphinx search server, and Swift.

I don’t use most of these. But Newman is how a lot of teams run Postman collections in CI. Pulumi and Fastlane tend to live in deploy jobs that people are scared to touch. If any of those are in your workflows without an explicit install step, the job will fail with “command not found” and no other clue.

The audit I did in one afternoon

First, find out where you are exposed. From the root of your repos:

grep -rn --include='*.yml' --include='*.yaml' 'ubuntu-latest' .github/

I run this across every client repo I still have access to. The count was higher than I would have guessed, because reusable workflows and composite actions each carry their own runs-on. I wrote about the reusable workflow bug I fixed eleven times and this is the same shape of problem: the thing you fix in one place is copied in twelve.

Second, run the new image alongside the old one before the window opens. This is the before:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5
      - run: sudo systemctl start mysql.service
      - run: php -v && composer install && php artisan test

And this is the version I moved to. It pins the current image, adds 26.04 as a second matrix entry that is allowed to fail, and stops trusting whatever PHP the runner happens to ship:

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-24.04, ubuntu-26.04]
    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.os == 'ubuntu-26.04' }}
    steps:
      - uses: actions/checkout@v5
      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.3'
      - run: sudo systemctl start mysql.service
      - run: php -v && composer install && php artisan test

The 26.04 leg goes red, you read the log, you fix the real issue, and then you flip the pin. The 24.04 leg keeps your main branch green in the meantime. When 26.04 is clean, I delete the matrix and write runs-on: ubuntu-26.04. The label does not go back in, because I have learned my lesson about labels.

Third, for the MySQL case specifically, the fix in my test setup step was one line:

CREATE USER 'ci'@'%' IDENTIFIED WITH caching_sha2_password BY 'ci';

That works on 8.0 and 8.4 both, so you can change it before the migration rather than during.

The arm64 side, and the 22.04 stragglers

Two smaller things from the same batch of announcements that I almost missed.

The arm64 image reached GA at the same time as x64, and the runner-images README now lists PostgreSQL as available on the Ubuntu arm64 images, which it was not before. If you have been running arm builds on a self-hosted Raspberry Pi or a Hetzner CAX box because the hosted arm runner was missing the database you needed, that reason may have expired. I have not moved anything over yet. The arm runners are cheaper per minute, and my Docker images are multi-arch anyway, so the honest answer is that I have been putting it off because the current setup works and I am busy.

The other item is the far end of the pipeline. The 22.04 images started deprecation on 17 September and are unsupported from 17 April. If you still have ubuntu-22.04 pinned somewhere, and I found two in an old client repo last week, you are looking at a two-major jump straight to 24.04 or 26.04, with PHP going 8.1 to 8.3 or 8.5 in one step. That is a bigger change than the one this post is about, and there is no gradual rollout to warn you. The job just stops being scheduled one day with a brownout message. If that describes you, do the 22.04 repos first and the ubuntu-latest ones second.

I also want to flag the thing I don’t know. Self-hosted runners are not affected by any of this, obviously, but a lot of people run hosted for tests and self-hosted for deploys, and the deploy job pulls artifacts built on the hosted image. If the hosted side now builds with GCC 15 or links against a newer glibc, the artifact may not run on the older self-hosted box. I have not hit this myself. I mention it because it is exactly the kind of failure that shows up two weeks after the migration and gets blamed on the wrong change.

Should you pin or float?

I went back and forth on this. Pinning to ubuntu-24.04 is the safe move for the next two months and GitHub says so. But 24.04 is not forever either. The runner-images team has already started deprecating the 22.04 images, with full removal in April, and 24.04 will follow on the same kind of schedule.

My current position: pin to an explicit version, always, and treat the pin as a dependency you bump on purpose. ubuntu-latest is fine for a throwaway repo. For anything a client pays for, I want the build to break because I changed something, not because a label moved on a date I did not choose. That is the whole argument, and I am not fully sure it is right for teams with hundreds of repos who would rather absorb one bad week than manage a hundred pins. For a freelancer with a dozen client repos it is clearly right.

One thing to do this week

Run the grep above on your most important repo. If it finds ubuntu-latest, add the matrix from this post and push it to a branch. You will know by the end of the CI run whether October is going to be a quiet month or not, and you will have found out on a branch, on your own schedule, instead of on main during the rollout window.