Your workflow pins actions/checkout@v4. It pins node-version: 22. It pins every dependency in your lockfile.
It does not pin the compiler.
GitHub rebuilds the hosted runner images roughly weekly, and you cannot select an older one. The feature request to pin an image version was closed as rejected — "there's no technical feasibility for implementation yet, and there are no timelines or implementation plans to share at this time." GitHub staff have said the same thing more bluntly in the community forum: "Unfortunately it's impossible to specify older version of runner-images during workflow."
So when Clang, Python or CMake moves underneath you, the first sign is a red build on a commit that didn't touch anything related. You diff your own changes, find nothing, re-run the job, and it fails again.
This is about to get much more common, because ubuntu-22.04 is on the way out: deprecation began 2026-09-17, fully unsupported 2027-04-17, with four brownout windows starting 2027-03-23 where jobs on that label simply fail for ten hours.
The data is already public — it's just not in a shape anyone uses
Two things turn out to be true, and together they're enough.
1. Every image ships a full software manifest. Ubuntu2204-Readme.md in actions/runner-images is not documentation, it's a bill of materials:
# Ubuntu 22.04
- OS Version: 22.04.5 LTS
- Image Version: 20260720.234.2
### Language and Runtime
- Clang: 13.0.1, 14.0.0, 15.0.7
- GNU C++: 10.5.0, 11.4.0, 12.3.0
- Node.js 22.23.1
- Python 3.10.12
2. That file is committed once per image rollout, with the version in the commit message.
$ curl -s "https://api.github.com/repos/actions/runner-images/commits?path=images/ubuntu/Ubuntu2204-Readme.md&per_page=3"
3b7fa9c 2026-07-27 Updating readme file for ubuntu22 version 20260720.234.2
f3d0fbf 2026-07-17 Updating readme file for ubuntu22 version 20260714.228.1
e161e34 2026-07-10 Updating readme file for ubuntu22 version 20260705.219.1
So the commit log for that one path is the image-version history, and the blob at each SHA is the exact manifest that shipped with that image. Every runner also exports ImageVersion and ImageOS as environment variables. Which means: given a version, you can fetch its manifest; given two versions, you can diff them; and given a diff, you can name the commit that caused it.
Nobody does this by hand, so I wrote a tool.
runner-drift
runner-drift is MIT, zero runtime dependencies, and does three things.
Before you migrate: plan
$ npx runner-drift plan --from ubuntu-22.04 --to ubuntu-24.04
ubuntu-22.04 -> ubuntu-24.04 (images 20260720.234.2 -> 20260720.247.2)
ubuntu-22.04 is fully unsupported on 2027-04-17; brownouts begin 2027-03-23 (source: actions/runner-images#14254)
255 days left (230 until the first brownout) — deprecation began 2026-09-17
Clang 13.0.1,14.0.0,15.0.7 -> 16.0.6,17.0.6,18.1.3 REMOVED: 13.0.1, 14.0.0, 15.0.7 / ADDED: 16.0.6, 17.0.6, 18.1.3
Python 3.10.12 -> 3.12.3 MINOR
2 of 3 detected tool(s) change; 1 unchanged (not shown)
The important part is what is not in that output. CMake is 3.31.6 on both images, so it's gone. The 22.04 manifest lists over 200 tools; this repo's workflows invoke three of them, and one of those didn't move. You get two rows.
It works that out by scanning .github/workflows/*.yml for the commands your run: steps actually invoke and mapping them to manifest names (python3 → Python, clang++ → Clang, npx → Node.js, and so on).
For the record, here's the full 22.04 → 24.04 delta across the common toolchain:
| Tool | ubuntu-22.04 | ubuntu-24.04 | |
|---|---|---|---|
| Python | 3.10.12 | 3.12.3 | minor |
| Clang | 13.0.1, 14.0.0, 15.0.7 | 16.0.6, 17.0.6, 18.1.3 | major |
| GNU C++ | 10.5.0, 11.4.0, 12.3.0 | 12.4.0, 13.3.0, 14.2.0 | major |
| Ruby | 3.0.2p107 | 3.2.3 | minor |
| Node.js | 22.23.1 | 22.23.1 | — |
| CMake | 3.31.6 | 3.31.6 | — |
| Git | 2.54.0 | 2.54.0 | — |
| Docker Client | 28.0.4 | 28.0.4 | — |
Clang is the one that bites, because it's a clean generation swap rather than a bump: 22.04 carries nothing ≥ 16, 24.04 carries nothing ≤ 15. Anything naming an explicit clang-14 or g++-11 breaks outright instead of degrading.
After you migrate: guard
- uses: Booyaka101/runner-drift@v1
with:
fail-on: major # omit to report only and never fail the job
First run records a baseline into runner-lock.json and exits 0. A later run, once GitHub has rolled new images, produces this in the job summary:
| Tool | Locked | Now | Change | Shipped by |
|---|---|---|---|---|
Terraform |
1.15.6 | 1.15.8 | 🟡 PATCH | 20260714.228.1 |
Kotlin |
2.4.0-release-281 | 2.4.10-release-377 | 🟡 PATCH | 20260720.234.2 |
Four image versions shipped between the lock and that run. Each tool is attributed to the specific rollout that changed it, not just "the newest image" — it walks the commit window and finds the first manifest carrying the new value. Unchanged tools never appear.
It also probes the machine directly (clang --version, python3 --version, …) rather than trusting the manifest alone, because a manifest says what the image was built with and a probe says what your job will actually execute. Tools with no probe recipe fall back to the manifest for that exact image version, and which source was used is recorded in the lock file so a source change is never mistaken for a version change.
By default it never fails your build. --fail-on major|minor|any is opt-in.
Details that turned out to matter
A few things I only learned by parsing the real files:
-
The Ubuntu manifest lists
CMaketwice. Once as the host tool (- CMake 3.31.6) and once in the Android SDK table as a bundled package (3.18.1,3.22.1,3.31.5). A naive whole-document scan reports the wrong one. First-occurrence-in-document-order fixes it. -
Tool names are not stable across operating systems. macOS spells it
CmakeandPython3; Windows saysNodewhere Ubuntu saysNode.js; and no manifest contains the string "Temurin" at all — JDKs live in a column-less| Version | Environment Variable |table. Every lookup goes through a candidate list. -
The readme sometimes lags the rollout. A runner can report an
ImageVersionthat has no commit yet. In that case it falls back to the nearest earlier commit and labels the row approximate rather than silently guessing.
Scope
No account, no API key, no hosted service. It talks to raw.githubusercontent.com and api.github.com, unauthenticated; GITHUB_TOKEN is used only to raise the rate limit if it happens to be set. Self-hosted runners are a clean skip. Node 22+, ESM, 101 tests running offline against real downloaded manifest snapshots.
It does not auto-fix anything. It tells you what moved and who moved it; the migration is still yours.
- GitHub: https://github.com/Booyaka101/runner-drift
- npm: https://www.npmjs.com/package/runner-drift
- Marketplace: https://github.com/marketplace/actions/runner-drift
If your CI is still on ubuntu-22.04, the useful thing to do today is run plan once and find out whether your migration is a two-line change or a compiler problem. It takes about ten seconds and you don't have to install anything.
Top comments (0)